Files
gplan-2/game/presentation/views/board_view.gd
T

893 lines
41 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## VERTICAL SLICE - NOT FOR PRODUCTION
## 战斗屏 D 区棋盘视图:格态渲染、悬停预承诺光标与路径预览、奖励徽章与玩家意图识别(意图经信号外发)。
## 素材禁 preload——一律 UiKit.load_tex 装载(素材不入 git),任一缺位静默回退程序化平色/字形,不刷错误。
class_name BoardView
extends Control
signal action_requested(cell: Vector2i, kind: String) ## kind ∈ {dig, move, seal_place, seal_pull}
signal hover_forbidden(cell: Vector2i) ## 非相邻未开格悬停(首遇 toast 归组合根去重)
signal chord_attempted(cell: Vector2i) ## 双击快开尝试(chord 肌肉记忆提示锚)
const _CELL_UNIT := preload("res://presentation/views/board_cell_unit.gd")
const _BOARD_WORLD := preload("res://presentation/views/board_world.gd")
const _TILE_PALETTE := preload("res://presentation/views/tile_palette.gd")
const _HERO_SPRITE := preload("res://presentation/views/hero_sprite.gd")
const COL_BRICK := UiKit.BRICK # 未开——掩尘完整地砖(色板原语 #C9AF83)
const COL_FLOOR := UiKit.FLOOR # 已开——裸土 #B39A78(碎裂裸露地面)
const COL_DIGIT := UiKit.INK # 数字——墨族 #33261B(对格底 ≥4.5:1 硬指标)
const COL_HERO := UiKit.HERO_BLUE # 小人边框——小人蓝
const COL_SEAL := UiKit.SEAL_BLACK # 封印黑 #000000
const COL_GOLD := UiKit.GOLD # 奖励金 #F0A828
const COL_CONTACT := UiKit.CONTACT_GLOW # 接触带微光基色(蓝族浅一档·近白蓝——§3.2 v2.8)
const COL_HL_TARGET := UiKit.GOLD # 悬停目标格边框(金)
const REWARD_BADGE := { # 已开格格面资源身份 icon(cell_type 九型键)
"attack": "攻", "defense": "守", "heal": "医", "blast": "爆", "weaken": "弱", "pierce": "穿", "magic": "魔",
"action": "行", # 行 = 行动点型(ap_up/ap_drain 两行)
"gp": "金", # 金 = GP 型(gp_gain/gp_loss 两行;金色系 ~40° 相位,禁入带紫赤 300–345° 外,取色 UiKit.GOLD 徽标色单点)
}
const BADGE_PLACEHOLDER := "?" # 未知图标键占位纹样(HUD框架:禁静默空图标)
const CURSOR_DIG := Input.CURSOR_CROSS # 挖——镐形自绘纹理载体
const CURSOR_SEAL := Input.CURSOR_POINTING_HAND # 封印——封印形自绘纹理载体
const CURSOR_MOVE_SEM := Input.CURSOR_ARROW # 移动——箭头(命名避开 Control.CURSOR_MOVE 原生成员遮蔽)
const CHORD_WINDOW_MS := 350
const SEP := 2 # 格间距(格尺寸公式与命中换算共用)
# 棋盘立体参数
const DEPTH_PX := 8.0 # 悬崖厚度(像素)
const DEPTH_BEVEL := 0.6 # 顶面倒角强度
# 高墙感 D 投影档:地面投影带宽 = 格边比(研究件 64px 拼版实测 14/64≈0.22;
# 立面/投影色参数在 CellUnit 常量——绘制侧单点)
const WALL_PROJECTION_FRAC := 0.22
# 英雄单位演出参数单点 = HeroSprite 常量族(本处只保留探针直读面别名,数值禁二写)
const HERO_SPRITE_SCALE := _HERO_SPRITE.SPRITE_SCALE # 1.5× 档(探针 case⑨/⑩ 直读面)
const HERO_GROUND_FRAC := _HERO_SPRITE.GROUND_FRAC # 脚锚纵向落点 25%(探针 case⑦/⑩ 直读面)
# 瓦片纹理路径与 16 墙瓦片词表单点 = TilePalette 常量族(装载/查表同件;词表注释见该件)
var _wrap: CenterContainer
var _svp_container: SubViewportContainer
var _viewport: SubViewport
var _world: Node2D # BoardWorld(preload 脚本实例——Node2D 世界根/材质挂点)
var _cells := {} # Vector2i → CellUnit(Node2D)——键面同构(探针断言面)
var _board: BoardData = null
var _minesweeper = null # 排雷玩法 只读查询引用(refresh 更新;null = 无战斗)
var _identity_cb := Callable() # 地图战斗资源 身份解析回调(组合根注入只读面)——Vector2i → Dictionary/null
var _mask_cb := Callable() # 地形变异 数字遮蔽回调(组合根注入只读面)——Vector2i → bool(true = 该格数字不显示)
var _vmark_cb := Callable() # 地形变异 角标回调(只读面)——Vector2i → String("flat_damage"=伤害型变异未开格;""=无)
var _treasure_reveal := {} # 秘宝 真视集(Vector2i → true;组合根 set_treasure_overlays 注入投影)
var _treasure_preview := {} # 秘宝 预读集(同上——未开格徽章呈现挂点)
var _diggable := {} # 可挖域(8 向相邻未开格)——呼吸微光作用集
var _glow: Dictionary[Vector2i, int] = {} # 未开格类型微光作用集(Vector2i → 组号;=可挖域∩身份可查)
var _type_glow := true # 呈现态布尔:生产默认开;探针/夹具/调试浮窗可显式 off
var _glow_errors: Array[String] = [] # 清单外 cell_type 登记(禁静默空光)
var _breath_t := 0.0
var _contact_unit: Node2D = null # 接触带承载单元(=英雄格 CellUnit;rebuild 随清场复位)
var _install_tw: Tween # 入场淡入 tween(rebuild 重入先杀)
var _ripple_tws: Array[Tween] = [] # R0 涟漪逐格 tween(rebuild 重入全杀)
var _dig_glow := false # 挖掘期才允许唯一在动元素(art-bible §2.1)
var _hover := Vector2i(-9, -9)
var _last_click := {"cell": Vector2i(-9, -9), "ms": -100000}
var _r0 := {} # 出生展开区 R₀(地图战斗资源 mapping 资格域 = S∖R₀——徽章查询跳过,防 E19 诊断噪音)
var _desat_mat: ShaderMaterial
var _desat_tween: Tween
var _cell := 46.0 # 当前格边长(set_cell_area 按容器尺寸重算——battle.md 格尺寸公式)
# ── 拆出件(状态单点在件内)──
var _palette = _TILE_PALETTE.new() # 瓦片件:装载缓存+查表+明度/墨族静态算法
var _hero_sprite = _HERO_SPRITE.new() # 英雄精灵件:_init 挂为本件子级(tween 需在树上)
# ── 件成员同名转发(探针直读面零改动:读 _palette.seal_tex、读写 _hero_sprite.tex_idle;其余为随迁状态单点再导出)──
# ---- 视野迷雾(纯呈现层;现行事实源=HUD框架「视野迷雾」节 v0.13,渲染规格=art-bible §6.5 v3.5)----
const FOG_RADIUS := 8 # 菱形灯光区基准半径(曼哈顿距离 ≤R——与微光环「周围 8 格」同圈起步;半径基准值改动=非白名单须用户)
var _fog_enabled := true # 迷雾开关(呈现态布尔——真实游戏恒 on;探针/夹具可 off=无迷雾基线,两态结算逐位一致断言面)
var _fog_lantern := 0 # 视野加成现值(缺省 0=无加成基线零扰动;生产写入面=组合根每战装配链显式重算——main._sync_treasure_glow)
# 视野加成接线:light_radius_bonus 装配参数(magnitude 4→半径 8+4=12 格 provisional);类型微光
# 不随视野扩〔微光锚=可挖域贴身 8 邻零改动〕。组合根 main._sync_treasure_glow 每战首轮装配链显式重算。
func set_fog_glow_bonus(bonus: int) -> void:
_fog_lantern = maxi(0, bonus)
refresh() # 灯光区半径变了——全板重算雾态(refresh/notify_hero_move 既有重绘驱动复用)
var cell_size: float: # 只读属性(棋盘格边长对外契约出口)
get:
return _cell
func _init() -> void:
size_flags_horizontal = Control.SIZE_EXPAND_FILL
size_flags_vertical = Control.SIZE_EXPAND_FILL
_wrap = CenterContainer.new()
_wrap.set_anchors_preset(Control.PRESET_FULL_RECT)
_wrap.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_wrap)
_svp_container = SubViewportContainer.new()
_svp_container.stretch = true # 容器尺寸 = 视口逻辑分辨率(_update_world_size 同步)——恒 1:1 无缩放
_svp_container.mouse_filter = Control.MOUSE_FILTER_IGNORE # 输入归壳层 _gui_input(整图公式换算),不进 SubViewport
_wrap.add_child(_svp_container)
_viewport = SubViewport.new()
_viewport.transparent_bg = true # 格间距露出 D 区底色
_svp_container.add_child(_viewport)
_world = _BOARD_WORLD.new()
_viewport.add_child(_world)
_hero_sprite.bind(_world, _svp_container)
_hero_sprite.set_cell_size(_cell, float(SEP))
add_child(_hero_sprite)
_desat_mat = ShaderMaterial.new()
_desat_mat.shader = load("res://presentation/views/desaturation.gdshader")
_desat_mat.set_shader_parameter("strength", 0.0)
_hero_sprite.tex_idle = UiKit.load_tex(UiKit.TEX_HERO_IDLE)
_hero_sprite.tex_walk = UiKit.load_tex(UiKit.TEX_HERO_WALK)
_palette.load_board_tiles() # 高墙/地面瓦片整批守卫装载(缺位静默回退——英雄同款纪律,禁刷屏)
func board() -> BoardData:
return _board
func _ready() -> void:
_apply_custom_cursors()
mouse_exited.connect(_on_shell_mouse_exited)
# 结算期/道具窗口态:D 区去饱和 + 金橙偏移锁输入(明度不变——art-bible §2);
# 材质挂 BoardWorld 世界级单点(CellUnit use_parent_material 链下随世界灰化)
func set_desaturated(on: bool) -> void:
if _desat_tween != null and _desat_tween.is_valid():
_desat_tween.kill()
_desat_tween = create_tween()
var target := 1.0 if on else 0.0
_desat_tween.tween_method(func(v: float): _desat_mat.set_shader_parameter("strength", v),
float(_desat_mat.get_shader_parameter("strength")), target, UiKit.motion(0.15))
_world.material = _desat_mat if on else null
# 视野迷雾开关(呈现态布尔:真实游戏恒 on;探针/夹具 off=无迷雾基线,
# 迷雾开/关两态结算逐位一致断言面;切换即全板重算 fog_state)
func set_fog_enabled(on: bool) -> void:
_fog_enabled = on
refresh()
# 组合根注入 地图战斗资源 身份解析只读回调(query_identity)——奖励徽章数据源
func set_identity_resolver(cb: Callable) -> void:
_identity_cb = cb
# 组合根注入 地形变异 数字遮蔽只读回调(信息型 hide_number 触发面——触发枚举 on_number_shown:
# 已开且显示数字时起作用;读取方按格子变异数据行动,规则本体零特判)
func set_variant_mask_resolver(cb: Callable) -> void:
_mask_cb = cb
# 组合根注入 地形变异 角标只读回调(flat_damage 未开格视觉标记;变异是策略纹理非数值墙:
# 玩家可据此绕行/承担的决策输入。空 Callable = 全板无标记(缺位回退态)
func set_variant_mark_resolver(cb: Callable) -> void:
_vmark_cb = cb
# 秘宝叠加层投影注入(组合根在 use 成功/三清后调;refresh 消费)
func set_treasure_overlays(reveal_cells: Array, preview_cells: Array) -> void:
_treasure_reveal.clear()
_treasure_preview.clear()
for c in reveal_cells:
_treasure_reveal[Vector2i(c)] = true
for c in preview_cells:
_treasure_preview[Vector2i(c)] = true
refresh()
# SubViewport 主题/字体显式注入挂点(SubViewport 不自动继承主场景 Control 主题;
# 本项目无自定义主题,缺省 = 引擎回退字体)
func set_world_font(f: Font) -> void:
_world.board_font = f
for c in _cells:
_cells[c].font = f
_cells[c].queue_redraw()
# D 区可用面积(AP 条之外)→ 格边长 = floor((min(宽, 高) − 预留 − (N−1)×格间距) / N)(battle.md 格尺寸公式)
# 公式须补算 (N−1)×2px 网格间距与垂直预留 24px,否则 12×12 板实高溢出可用区 ~6px,下缘被 clip_contents 裁掉。
func set_cell_area(avail: Vector2) -> void:
if _board == null or _board.width == 0:
return
var n: int = maxi(_board.width, _board.height)
var sep := SEP # 格间距(_init 同值)
var d := DEPTH_PX
var reserve := 24.0 + d # 垂直总预留(上 12 + 下 12 + 悬崖外延——悬崖占预算防溢出)
var cell := int(floor((minf(avail.x - 12.0 - d, avail.y - reserve) - float((n - 1) * sep)) / float(n)))
_cell = float(clampi(cell, 26, 46))
_hero_sprite.set_cell_size(_cell, float(SEP))
var stride := _cell + float(SEP)
for c in _cells:
var u: Node2D = _cells[c]
u.edge = _cell
u.position = Vector2(c) * stride
if _world.hero != null and _hero_sprite.last.x >= 0:
_place_hero(_hero_sprite.last, _hero_sprite.last, true) # 格边长变更(窗口缩放)——英雄即时随格重置(杀位移 tween 防飞陈旧落点)
_refresh_slab()
_update_world_size()
# 悬崖参数下发(网格像素尺寸/步距/列数/材质锚色——BoardWorld._draw 消费)
func _refresh_slab() -> void:
if _board == null or _board.width == 0:
return
_world.depth_px = DEPTH_PX
_world.slab = Vector2(_board.width, _board.height) * (_cell + float(SEP)) - Vector2(SEP, SEP)
_world.stride_px = _cell + float(SEP)
_world.cols = _board.width
_world.mat = COL_BRICK
# 战斗启动/换板:按板尺寸重建格单元(minesweeper 引用随之更新)
func rebuild(board: BoardData, minesweeper: MinesweeperRules) -> void:
_board = board
_minesweeper = minesweeper
_palette.bind_board(_board, _minesweeper) # 查表族棋盘态随板同步(换板后首拍 refresh 前置)
for child in _world.get_children():
child.queue_free()
_world.hero = null # 英雄精灵随清场复位——下拍 refresh 经 ensure_hero 重建(首建即时落位)
_cells.clear()
_glow.clear()
_glow_errors.clear()
_contact_unit = null
_hero_sprite.last = Vector2i(-99, -99)
_r0.clear()
_hover = Vector2i(-9, -9)
for c in _board.compute_r0():
_r0[c] = true
# 瓦片套随板图型切换(board_gen 词表 v1.1 dungeon/underdark/deep_underdark;
# 同图型重复 rebuild 零重载,_palette.tiles_board_type 守卫)
var bt: String = _board.board_type if _board.board_type != "" else "dungeon"
if bt != _palette.tiles_board_type:
_palette.load_board_tiles(bt)
var stride := _cell + float(SEP)
for y in _board.height:
for x in _board.width:
var c := Vector2i(x, y)
var u: Node2D = _CELL_UNIT.new()
u.cell = c
u.edge = _cell
u.position = Vector2(c) * stride
u.bevel = DEPTH_BEVEL # 新建格单元继承
u.use_parent_material = true # 去饱和材质挂 BoardWorld——单元随世界材质灰化
if _palette.tiles_ok:
# NEAREST 采样(32×32 源 × 45/36px 格边非整数缩放接受档,
# 与英雄小人 1.5× 豁免同级(圣经 §8.2);只在瓦片
# 模式启用——程序化回退路径渲染零扰动(含格面文字采样不回退不变化)
u.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
u.font = _world.board_font
_world.add_child(u)
_cells[c] = u
set_cell_area(size)
_install_entrance_fx()
refresh()
# 逐格重绘(挖掘/结算后调用)
func refresh() -> void:
if _board == null or _minesweeper == null:
return
var hero: Vector2i = _minesweeper.hero_pos()
_diggable.clear()
for n in _board.neighbors8(hero):
if not _minesweeper.is_revealed(n) and not _minesweeper.is_removed(n):
_diggable[n] = true
_recompute_glow(hero)
for y in _board.height:
for x in _board.width:
_paint_cell(Vector2i(x, y), hero)
_refresh_hover_after_paint()
_apply_contact_bands()
_update_hero(hero) # 英雄精灵 diff 驱动
# 未开格类型微光作用集:= 可挖域 ∩ 身份可查格 ∩ 灯光区内。
# 组号 = cell_type 经 UiKit.glow_group_for_cell_type 九值封闭判定得出;清单外值 = 数据错,
# 登记进 _glow_errors 由断言面显式暴露,禁静默空光(图标键错误处理同款纪律)。
# 纯派生:只读 query_identity 回调,零玩法数据写入(🔴 契约 1)。
# 相位门控与接触带同源(_dig_glow):结算/道具窗口态不显示——art-bible §2.1 挖掘期纪律面。
func _recompute_glow(hero: Vector2i) -> void:
_glow.clear()
if not _type_glow or not _dig_glow:
return
for n in _diggable:
if _r0.has(n):
continue # 出生展开区不入摇号清单 ⇒ query 恒 null,显式跳过(省回调、零诊断噪音)
var fdist: int = absi(n.x - hero.x) + absi(n.y - hero.y)
if fdist > FOG_RADIUS + _fog_lantern:
continue # 灯光区外未开格全黑不渲染(§6.5)——微光亦不画
var info: Variant = _identity_cb.call(n) if _identity_cb.is_valid() else null
if info == null:
continue
var ctype := String((info as Dictionary).get("cell_type", ""))
var g: int = UiKit.glow_group_for_cell_type(ctype)
if g == UiKit.GLOW_NONE:
# 登记去重:cell_type 随板摇定不可变,同一数据错在相位切换多次重算中只记一次
# (否则 set_dig_glow_enabled 每切一次 _sync_glow_to_units 就追加一条,数组无限增长)
if not _glow_errors.has(ctype):
_glow_errors.append(ctype)
continue
_glow[n] = g
# 未开格类型微光开关:呈现态布尔,生产写入面 = 组合根单点。
# 不新增设置项——工程有闸、界面无感;off 态为基线保护面(结算逐位一致断言承接)。
func set_type_glow_enabled(on: bool) -> void:
if _type_glow == on:
return
_type_glow = on
if _board != null and _minesweeper != null:
refresh()
# ---- 入场演出(battle.md Transitions「install 淡入 200ms + 初始 R0 展开涟漪」)----
# 全板 modulate 淡入 200ms;R0 格按离板心距离逐格延迟显形(涟漪);纯视觉 alpha,零输入影响;
# 减动效时长减半(motion);重入(弃图换图 rebuild)先杀旧 tween 防叠加
func _install_entrance_fx() -> void:
if _install_tw != null and _install_tw.is_valid():
_install_tw.kill()
for tw in _ripple_tws:
if tw != null and tw.is_valid():
tw.kill()
_ripple_tws.clear()
modulate.a = 0.0
_install_tw = create_tween()
_install_tw.tween_property(self, "modulate:a", 1.0, UiKit.motion(0.2))
var center := Vector2(_board.width, _board.height) * 0.5
for c in _r0:
var u: Node2D = _cells.get(c)
if u == null:
continue
u.modulate.a = 0.0
var tw := create_tween()
tw.tween_interval(UiKit.motion(0.04 * (Vector2(c) + Vector2(0.5, 0.5) - center).length()))
tw.tween_property(u, "modulate:a", 1.0, UiKit.motion(0.12))
_ripple_tws.append(tw)
# ---- 英雄单位精灵(件 = hero_sprite.gd;本视图保留调用面与探针直读转发) ----
# refresh diff 驱动(件内实现:位置变化 = 位移演出;零位移但受理移动回执 = 只播帧闪)
func _update_hero(hero: Vector2i) -> void:
_hero_sprite.update_hero(hero)
# 英雄落位(instant = 即位无演出——首建/格边长变更;否则 140ms 位移 + 走路帧起手、到位回站帧)
func _place_hero(from: Vector2i, to: Vector2i, instant: bool) -> void:
_hero_sprite.place_hero(from, to, instant)
# 受理移动指令回执(组合根 settle 受理后、refresh 前调用)——原地移动经此触发帧闪;
# 目标异格时本封印标被 diff 位移路径自然吞掉(置位即清)
func notify_hero_move() -> void:
_hero_sprite.notify_move()
# 挖掘角色弹跳(计划书 §3.C:scale.y 1→0.82→1,脚底锚收敛不悬空;旧弹跳先 kill)
func play_hero_dig_bounce() -> void:
_hero_sprite.play_dig_bounce()
# 格左上角(board_area 全幅坐标系)——fx 同层演出定位出口(本视图与 fx 同为 board_area 全幅子件,
# 局部坐标同系;cell_center_local 同公式族)
func cell_origin(c: Vector2i) -> Vector2:
return _svp_container.position + Vector2(c) * (_cell + float(SEP))
# 英雄持握手位(fx 同层坐标系 = 壳层坐标——件内换算含 _svp_container.position 居中偏移:
# 世界脚底锚 + 壳层偏移即得)
func hero_hand_point() -> Vector2:
return _hero_sprite.hand_point()
# 英雄精灵缩放档(fx 挥铲同档出口——两演出比例恒一致)
func hero_sprite_scale() -> float:
return _hero_sprite.sprite_scale()
# 真实输入路径(行为探针复用):合成点击 → 与真实 gui_input 同一翻译出口
func click_cell(cell: Vector2i, button: int) -> void:
var ev := InputEventMouseButton.new()
ev.button_index = button
ev.pressed = true
_on_cell_input(cell, ev)
# 整图公式单点:格坐标 = floor(板内局部坐标 / 格步长)
#(格步长 = 格边长 + 间距;板外/负域返回 (-9,-9);2px 格间隙落入左/上邻格)
func _cell_at(shell_pos: Vector2) -> Vector2i:
if _board == null or _board.width == 0:
return Vector2i(-9, -9)
var p := shell_pos - _svp_container.position # CenterContainer 居中偏移(wrap 恒在壳 (0,0)——容器位置即棋盘原点)
if p.x < 0.0 or p.y < 0.0:
return Vector2i(-9, -9)
var stride := _cell + float(SEP)
var c := Vector2i(int(floor(p.x / stride)), int(floor(p.y / stride)))
if c.x >= _board.width or c.y >= _board.height:
return Vector2i(-9, -9)
return c
# 格中心(壳内局部坐标)——ui_input_probe 合成事件落点出口(与 _cell_at/cell_origin 同一公式族)
func cell_center_local(c: Vector2i) -> Vector2:
return cell_origin(c) + Vector2.ONE * (_cell * 0.5)
# 视口逻辑分辨率 = 板像素尺寸 + 悬崖外延(stretch=true 且容器 min 尺寸同步——缩放比恒 1:1)
func _update_world_size() -> void:
if _board == null or _board.width == 0:
return
var d := DEPTH_PX
var px := Vector2(_board.width, _board.height) * (_cell + float(SEP)) - Vector2(SEP, SEP) + Vector2(d, d)
_svp_container.custom_minimum_size = px
# 树内 stretch=true 时 SubViewport 尺寸由容器接管(手设被引擎拒绝并刷警告);树外(冒烟直建)才需手设保 1:1
if not _svp_container.is_inside_tree():
_viewport.size = px
# 真实输入路径(Node2D 迁移批 Decision 3):壳层 _gui_input → 整图公式换算 → 复用既有 hover/点选/chord 出口
func _gui_input(ev: InputEvent) -> void:
if _minesweeper == null:
return
if ev is InputEventMouseMotion:
_hover_at(ev.position)
elif ev is InputEventMouseButton and ev.pressed:
var c := _cell_at(ev.position)
if c.x >= 0:
_on_cell_input(c, ev)
func _hover_at(pos: Vector2) -> void:
var c := _cell_at(pos)
if c.x < 0:
if _hover.x >= 0:
_on_cell_unhover(_hover)
elif c != _hover:
if _hover.x >= 0:
_on_cell_unhover(_hover)
_on_cell_hover(c)
func _on_shell_mouse_exited() -> void:
if _hover.x >= 0:
_on_cell_unhover(_hover)
func _on_cell_input(c: Vector2i, ev: InputEvent) -> void:
if _minesweeper == null or not (ev is InputEventMouseButton and ev.pressed):
return
if ev.button_index == MOUSE_BUTTON_LEFT:
if _minesweeper.is_revealed(c):
action_requested.emit(c, "move")
else:
action_requested.emit(c, "dig")
_detect_chord(c)
elif ev.button_index == MOUSE_BUTTON_RIGHT:
if not _minesweeper.has_seal(c):
action_requested.emit(c, "seal_place")
# 封印 = 一次性承诺:右键对封印格零动作(无撤封印);
# 左键相邻封印格直接挖(规则引擎放行)。
# chord 肌肉记忆首遇锚:同格双左击快开尝试(350ms 窗)——每 run 一次提示由组合根去重
func _detect_chord(c: Vector2i) -> void:
var now := Time.get_ticks_msec()
if _last_click.cell == c and now - int(_last_click.ms) <= CHORD_WINDOW_MS:
chord_attempted.emit(c)
_last_click.ms = -100000
return
_last_click = {"cell": c, "ms": now}
# ---- 悬停预承诺(pattern 4):三语义光标 + 目标格边框 + BFS 路径预览 ----
func _on_cell_hover(c: Vector2i) -> void:
_hover = c
_apply_hover_visuals(c)
func _on_cell_unhover(c: Vector2i) -> void:
if _hover == c:
_hover = Vector2i(-9, -9)
_clear_hover_visuals(c)
func _refresh_hover_after_paint() -> void:
# 状态随 refresh 变化后光标/边框语义需重算(悬停格四格态可能已变)
if _hover.x >= 0 and _cells.has(_hover):
_apply_hover_visuals(_hover)
func _apply_hover_visuals(c: Vector2i) -> void:
if _minesweeper == null or not _cells.has(c):
return
_clear_hover_visuals(c)
var u: Node2D = _cells[c]
var revealed: bool = _minesweeper.is_revealed(c)
var seal: bool = _minesweeper.has_seal(c)
var adjacent: bool = _diggable.has(c)
if revealed:
u.hl_visible = true
u.hl_color = COL_HL_TARGET # 移动目标格边框(金)
_set_cursor_shape(c, CURSOR_MOVE_SEM)
_show_path_preview(c)
elif adjacent:
u.hl_visible = true
u.hl_color = COL_HL_TARGET # 挖掘目标格边框(相邻封印格同此——左键直接挖)
_set_cursor_shape(c, CURSOR_DIG)
elif seal:
u.hl_visible = true
u.hl_color = COL_SEAL # 已承诺封印标记(封印黑;无交互可供)
_set_cursor_shape(c, Input.CURSOR_FORBIDDEN)
else:
u.hl_visible = true
u.hl_color = UiKit.RED_FORBID # 不可及域灰禁框(禁止光标+首遇 toast 双通道)
_set_cursor_shape(c, Input.CURSOR_FORBIDDEN)
hover_forbidden.emit(c)
func _clear_hover_visuals(c: Vector2i) -> void:
if not _cells.has(c):
return
_cells[c].hl_visible = false
_hide_path_preview()
func _set_cursor_shape(c: Vector2i, shape: int) -> void:
mouse_default_cursor_shape = shape # 壳级光标(悬停格语义决定——单格悬停恒一)
func _show_path_preview(c: Vector2i) -> void:
# 合法性指示:悬停已开格 → BFS 路径蓝细线点迹(compute_move_path 只读面)
if _minesweeper == null:
return
var path: Array[Variant] = _minesweeper.compute_move_path(c)
for pc in path:
if _cells.has(pc):
_cells[pc].dot_visible = true
func _hide_path_preview() -> void:
for c in _cells:
_cells[c].dot_visible = false
# ---- 奖励徽章(已开格身份 icon——封印之下、纹带之上、不进数字区) ----
func _update_badge(c: Vector2i) -> String:
if _minesweeper == null or not _minesweeper.is_revealed(c) or not _identity_cb.is_valid():
return ""
if _r0.has(c):
return "" # R₀ 初始展开格无奖励身份(地图战斗资源 资格域 = S∖R₀)
var rec: Dictionary[Variant, Variant] = _minesweeper.reveal_record(c) # 已开记录经查询面(禁直读 排雷玩法 状态字典)
if rec.get("kind", "") != "safe":
return ""
var ident = _identity_cb.call(c)
if not (ident is Dictionary):
return ""
return _badge_glyph(String(ident.get("cell_type", "")))
# 奖励图标键解析(HUD框架:查不到的键立即断言 + ? 占位纹样 + 诊断;键集 = 格面资源 cell_type 九值枚举)
func _badge_glyph(category: String) -> String:
if REWARD_BADGE.has(category):
return REWARD_BADGE[category]
push_error("[棋盘] 未知奖励图标键(映射表外 category——立即断言): %s" % category)
assert(false, "未知奖励图标键: " + category)
return BADGE_PLACEHOLDER
# ---- 格态渲染(灰度截图可辨:完整覆尘 / 碎裂裸土 / 已开数字) ----
# 数字三档色温(按板自适应双族——v3.5 冻结④):亮墨族 1-3 冷亮/4-5 暖白/6-8 金亮
#(dungeon 瓦片实测达标维持);深墨族 1-3 青墨/4-5 赭墨/6-8 锈墨(色温语义同构——underdark/
# deep_underdark 亮白筋纹瓦片上亮墨对亮斑 CR 1.0–1.9 融字,深墨族 5.7–11.1;选族判据见 TilePalette.digit_ink_mode)
func _digit_ink(d: int) -> Color:
if _palette.digit_dark_ink:
if d <= 3:
return UiKit.INK_COLD
if d <= 5:
return UiKit.INK_OCHRE
return UiKit.INK_RUST
if d <= 3:
return UiKit.INK_L_COLD
if d <= 5:
return UiKit.INK_L_WARM
return UiKit.INK_L_GOLD
func _paint_cell(c: Vector2i, hero: Vector2i) -> void:
var u: Node2D = _cells.get(c)
if u == null:
return
var revealed: bool = _minesweeper.is_revealed(c)
var seal: bool = _minesweeper.has_seal(c)
var removed: bool = _minesweeper.is_removed(c) # 中立移除格(退色降档)
var is_hero: bool = c == hero
var glyph := ""
var bg: Color
var digit_col := COL_DIGIT
# 封印槽统一维护:封印格=封印印记纹理;其余格清 null(封印格挖开后残留在 tex_seal
# 会让 _draw 无条件把封印印画在已开格上——tex_seal 绘制无 revealed 条件)。缺盘走字形回退。
u.tex_seal = _palette.seal_tex if (seal and not revealed and _palette.seal_tex != null) else null
if removed:
revealed = false # 移除格固定未开呈现基座(退色暗幕层承载;危险格本无数字,周边数字冻结)
seal = false
u.tex_seal = null
if revealed:
var rec: Dictionary[Variant, Variant] = _minesweeper.reveal_record(c) # 已开记录经查询面
if rec.get("kind", "") == "mine":
bg = COL_FLOOR
glyph = "危"
digit_col = UiKit.RED_MINE
else:
bg = COL_FLOOR
var d: int = rec.get("digit", 0)
if d > 0:
if _mask_cb.is_valid() and bool(_mask_cb.call(c)):
glyph = "?" # 数字遮蔽(地形变异 hide_number——信息型:该格真实数字不显示)
else:
glyph = str(d)
digit_col = _digit_ink(d) # 数字墨三档色温(§4.1)
else:
if seal:
bg = COL_BRICK
if _palette.seal_tex == null:
glyph = "封"
digit_col = COL_SEAL
else:
bg = COL_BRICK
# 瓦片模式:tex_base = 查表瓦片(未开=墙瓦片/已开=地面瓦片),
# bg_sb 降级为透明描边载体——只承载呼吸微光/主角蓝框(CellUnit 瓦片态画在瓦片之上),
# 瓦片分配=格坐标哈希,初始化落定后逐拍稳定(禁逐轮重掷——圣经原则三)
# draw calls 批优化:透明载体 sb 视觉贡献=零(透明底+零描边——不可见),但
# StyleBoxFlat 多边形命令不可合批,每格打断棋盘批——实测 100 格满负荷帧 ~402 DC(A/B 探针
# 阶梯:纹理统一 0 增益/去 glyph −23/去叠层 −23/去隐形 sb −402)。非英雄格 sb 直接置 null
#(绘制路径零像素变化),仅英雄格保留蓝框载体。
var sb: StyleBoxFlat = null
var tile: Texture2D = null
if _palette.tiles_ok:
tile = _floor_tile_for(c) if revealed else _wall_tile_for(c)
else:
# 素材整批缺位回退 = 现行程序化平色路径(fresh checkout 冒烟不炸——需求卡片 §2.2)
sb = UiKit.panel_flat(bg, bg.darkened(0.15),UiKit.RADIUS_OBJECT)
if is_hero:
if sb == null:
sb = UiKit.panel_flat(Color.TRANSPARENT, Color.TRANSPARENT,UiKit.RADIUS_OBJECT, 0)
sb.set_border_width_all(3)
sb.border_color = COL_HERO
if glyph == "" and _hero_sprite.tex_idle == null:
# 贴图缺位回退字形「人」(项目纹理槽惯例——贴图在位=格内字形退役,单位本体由独立精灵承载)
glyph = "人"
digit_col = COL_HERO
u.tex_base = tile
u.bg_sb = sb
u.glyph = glyph
u.glyph_color = digit_col
# 色牌后备(数字直画退牌;「危」/「?」维持牌载保读性)+ v3.4:双峰纹理板
#(_palette.digit_plate——underdark 系亮白筋+近黑斑并存的直画不可救纹理)数字落牌,牌上深墨 12:1
var plate_digit: bool = _palette.digit_plate and glyph != "" and glyph != "危" and glyph != "?" and glyph != "封" and glyph != "人"
if plate_digit:
digit_col = COL_DIGIT # 牌上深墨族(素牌 12:1——双峰纹理免疫)
u.glyph_color = digit_col
u.plate = revealed and (glyph == "危" or glyph == "?" or plate_digit)
u.outline_sides.assign([]) # typed Array[int](assign 保持引用语义)
u.facade_south = false
u.proj_band = 0.0
u.tex_facade = null
u.facade_strip = false
# 封印槽不在此重置:尾部重置会与此上方条件赋值构成双写踩踏(把封印纹理恒清 null=素材在位时封印不可见);
# 上方 else 分支已是全路径清残留,尾部重置系冗余且有害
var south_n: Vector2i = c + Vector2i(0, 1)
var north_n: Vector2i = c + Vector2i(0, -1)
var south_in: bool = south_n.x >= 0 and south_n.y >= 0 and south_n.x < _board.width and south_n.y < _board.height
var north_in: bool = north_n.x >= 0 and north_n.y >= 0 and north_n.x < _board.width and north_n.y < _board.height
if not revealed:
# 高墙感 D 投影档:南邻地面格 → 墙格南缘立「口沿高光+双段暗带」立面
#(邻地面才画——墙-墙内界保墙群内聚);板外=墙不画
u.facade_south = south_in and _minesweeper.is_revealed(south_n)
# 轮廓加强:墙格四邻中已开(地面)格朝向记描边码(板外=墙不描——保墙群外轮廓只在墙/地交界)
for d in [Vector2i(0, -1), Vector2i(0, 1), Vector2i(-1, 0), Vector2i(1, 0)]:
var n: Vector2i = c + d
if n.x >= 0 and n.y >= 0 and n.x < _board.width and n.y < _board.height and _minesweeper.is_revealed(n):
var code := 0 if d.y < 0 else (1 if d.y > 0 else (2 if d.x < 0 else 3))
if code == 1 and u.facade_south:
continue # 南缘由立面件承载(高光+双段暗带)——2px 描边让位防三重叠黑
if code == 0 and _palette.tiles_ok:
continue # v3.5 冻结① (b)-(c) 交界无边框:墙面直贴接墙顶(E 格墙面件在邻格承载)——北向描边不画(平色回退态保留描边)
u.outline_sides.append(code)
else:
# 地面投影:北邻墙格在本已开格北缘投渐隐暗带(ITB 同款高度线索——带宽按格边比例常量)
if north_in and not _minesweeper.is_revealed(north_n):
u.proj_band = u.edge * WALL_PROJECTION_FRAC
# E 格高墙面(圣经 v3.5 冻结①——方向稿 is_facade_cell 口径):已开格南邻未开墙 → 南部 30%
# 承接墙面(材质=同板墙顶 center 瓦片,原亮度直贴);仅瓦片态启用——平色回退保持原语义
if south_in and not _minesweeper.is_revealed(south_n) and _palette.tiles_ok:
u.facade_strip = true
u.tex_facade = _palette.tiles.get("wall_center")
# ②( 秘宝叠加层:真视格=红内缩框;预读格=奖励身份徽章(identity_resolver 既有
# 只读面取字形——零数据复制,badge 位复用);已开格标记自然退位(refresh 逐拍重算)
if not revealed:
u.treasure_mark = "mine_reveal" if _treasure_reveal.has(c) else ""
if _treasure_preview.has(c) and glyph == "":
var pid = _identity_cb.call(c) if _identity_cb.is_valid() else null
if pid != null and REWARD_BADGE.has(String(pid.get("cell_type", ""))):
glyph = String(REWARD_BADGE[String(pid.get("cell_type", ""))])
digit_col = COL_GOLD
u.badge_glyph = glyph
# ②' 变异角标:未开格带伤害型变异 → 危险红角标(已开=威胁已兑现标记退位);
# 回调缺位 = 无标记(程序化角标,tex_mutation 纹带槽暂留)
if not revealed and _vmark_cb.is_valid():
u.mutation_mark = String(_vmark_cb.call(c))
else:
u.mutation_mark = ""
u.badge_glyph = _update_badge(c)
u.glow_group = int(_glow.get(c, UiKit.GLOW_NONE))
# 视野迷雾呈态:曼哈顿距离 > 半径 → 区外——未开格全黑(现行事实源照抄)/
# 已开格记忆态(灰显可读);以英雄格为心逐格重算,refresh()/notify_hero_move() 既有重绘驱动=移动实时跟随。
# 纯呈现零逻辑——格态/数字真值/清图判定零触碰(🔴 契约 1);结算期去饱和叠加互斥由 CellUnit 记忆态终态档承载。
if _fog_enabled:
var fdist := absi(c.x - hero.x) + absi(c.y - hero.y)
var fogged := fdist > FOG_RADIUS + _fog_lantern
u.fog_state = (2 if revealed else 1) if fogged else 0
else:
u.fog_state = 0
u.removed_dim = removed
u.queue_redraw()
# ---- 可挖域接触带呼吸微光(挖掘期唯一在动元素——§3.2 v2.8 画法:光画英雄格周界空间侧,
# 正交邻墙=整条边、斜向=共享角点+两侧各半条边) ----
func set_dig_glow_enabled(on: bool) -> void:
_dig_glow = on
if on:
_apply_contact_bands() # 相位重入(结算→挖掘):refresh 未跑也重挂带几何
else:
_reset_breath()
_sync_glow_to_units() # 相位切换 → 类型微光作用集随之增减(只重绘变动格)
# 类型微光作用集与格件同步:只重绘「上一拍有组/这一拍有组」的并集格,
# 其余格零触碰;与 refresh() 全板重绘互为补充(refresh 走全板,本函数走增量)。
func _sync_glow_to_units() -> void:
if _board == null or _minesweeper == null:
return
var prev: Dictionary[Vector2i, int] = _glow.duplicate()
_recompute_glow(_minesweeper.hero_pos())
var touched := {}
for c in prev:
touched[c] = true
for c in _glow:
touched[c] = true
for c in touched:
var u: Node2D = _cells.get(c)
if u != null:
u.glow_group = int(_glow.get(c, UiKit.GLOW_NONE))
func _process(delta: float) -> void:
if not _dig_glow or _diggable.is_empty():
return
if _contact_unit == null or not is_instance_valid(_contact_unit):
return
_breath_t += delta
if UiKit.motion_scale < 1.0:
return # 减动效:呼吸停格(refresh/enable 时已施静态色——静态可辨、无持续运动)
var k := 0.5 + 0.5 * sin(_breath_t * 3.0) # 3 rad/s≈0.48Hz——无 >3 次/秒频闪(AC 基线)
_contact_unit.contact_col = COL_CONTACT.lerp(UiKit.CONTACT_GLOW_DEEP, 0.55 - 0.35 * k)
_contact_unit.queue_redraw()
# 带几何写入(refresh / set_dig_glow_enabled(true) 挂点):清旧承载件→按英雄格可挖域写边/角码→施色
func _apply_contact_bands() -> void:
if _contact_unit != null and is_instance_valid(_contact_unit) and (_cells.get(_minesweeper.hero_pos()) != _contact_unit if _minesweeper != null else true):
_contact_unit.contact_col = Color(0, 0, 0, 0)
_contact_unit.queue_redraw()
_contact_unit = null
if _board == null or _minesweeper == null or not _dig_glow:
return
var hu: Node2D = _cells.get(_minesweeper.hero_pos())
if hu == null:
return
_contact_unit = hu
var bands: Array[int] = []
var corners: Array[int] = []
for d in [Vector2i(0, -1), Vector2i(0, 1), Vector2i(-1, 0), Vector2i(1, 0)]:
if _diggable.has(_minesweeper.hero_pos() + d):
bands.append(0 if d.y < 0 else (1 if d.y > 0 else (2 if d.x < 0 else 3)))
for d in [Vector2i(-1, -1), Vector2i(1, -1), Vector2i(-1, 1), Vector2i(1, 1)]:
if _diggable.has(_minesweeper.hero_pos() + d):
corners.append(0 if (d.x < 0 and d.y < 0) else (1 if d.x > 0 and d.y < 0 else (2 if d.x < 0 else 3)))
hu.contact_bands = bands
hu.contact_corners = corners
# 减动效:呼吸停格——refresh/enable 重放时施**静态**色(本件头注「已施静态色」的落実);
# 0.5 = 呼吸周期中值(正常态 k 的均值)。否则减动效下每次 refresh 都会按当前相位重施色,
# 带色在 refresh 间跳变=持续运动假象,且令 on/off 双帧取证无法隔离单一变量。
var k := 0.5 if UiKit.motion_scale < 1.0 else 0.5 + 0.5 * sin(_breath_t * 3.0)
hu.contact_col = COL_CONTACT.lerp(UiKit.CONTACT_GLOW_DEEP, 0.55 - 0.35 * k)
hu.queue_redraw()
func _reset_breath() -> void:
if _contact_unit != null and is_instance_valid(_contact_unit):
_contact_unit.contact_col = Color(0, 0, 0, 0)
_contact_unit.queue_redraw()
# ---- 瓦片装载与查表(件 = tile_palette.gd;本视图仅保留格面渲染消费面) ----
# 墙瓦片查表(词表 §一 16 正交情形映射 + §三.2 凹角回落;查表用棋盘态由件持 bind_board 同步)
func _wall_tile_for(c: Vector2i) -> Texture2D:
return _palette.wall_tile_for(c)
# 地面瓦片五变体按格坐标哈希分配(词表 §三.3「同法哈希分配」)
func _floor_tile_for(c: Vector2i) -> Texture2D:
return _palette.floor_tile_for(c)
# ---- 自绘光标纹理(镐/封印——引擎内置光标无此语义;程序化占位,headless 跳过) ----
func _apply_custom_cursors() -> void:
if DisplayServer.get_name() == "headless":
return
Input.set_custom_mouse_cursor(_make_cursor_texture("⛏"), Input.CURSOR_CROSS, Vector2(4, 4))
Input.set_custom_mouse_cursor(_make_cursor_texture("⚑"), Input.CURSOR_POINTING_HAND, Vector2(2, 2))
func _make_cursor_texture(glyph: String) -> ImageTexture:
var img := Image.create(24, 24, false, Image.FORMAT_RGBA8)
img.fill(Color(0, 0, 0, 0))
var f := ThemeDB.fallback_font
if f != null:
f.draw_string(img, Vector2(2, 18), glyph, HORIZONTAL_ALIGNMENT_LEFT, 20, 18, COL_DIGIT)
return ImageTexture.create_from_image(img)
# ---- 探针只读出口(形态仿 fx_probe 先例:只读快照) ----
# 格件映射只读快照(Vector2i → CellUnit;键面同构——断言语义零变化;破私有面的只是容器字典,
# 格件字段本为公有呈现面,快照值即原格件引用)
func cells_probe() -> Dictionary:
return _cells.duplicate()
# 可挖域只读快照(Vector2i → true;8 向相邻未开格=呼吸微光作用集)
func diggable_probe() -> Dictionary:
return _diggable.duplicate()
# 悬停格只读出口(case① 悬停到达断言消费面)
func hover_probe() -> Vector2i:
return _hover
# 迷雾参数只读快照(case⑮ 参数漂移断言消费面)
func fog_params_probe() -> Dictionary:
return {"lantern": _fog_lantern, "enabled": _fog_enabled}
# 类型微光只读快照(断言消费面——开关态/作用集与组号/清单外 cell_type 登记)
func type_glow_probe() -> Dictionary:
return {"enabled": _type_glow, "cells": _glow.duplicate(), "errors": _glow_errors.duplicate()}
# 格件全局矩形只读出口(像素侧取样坐标源——禁测试侧硬编码像素)
# 坐标系:格件居于 SubViewport 内(_world 于原点),global_position 是**子视口画布**坐标;
# 取证帧截的是窗口画面 ⇒ 必须经 SubViewportContainer 的全局矩形换算回窗口坐标
# (stretch 恒 1:1,仍按 host/vp 比例换算保稳健——容器中心化由 CenterContainer 驱动)。
func cell_rect_probe(c: Vector2i) -> Rect2:
var u: Node2D = _cells.get(c)
if u == null or not is_instance_valid(u):
return Rect2()
var host: Rect2 = _svp_container.get_global_rect()
var vp_size: Vector2 = _viewport.size
if host.size.x <= 0.0 or host.size.y <= 0.0 or vp_size.x <= 0.0 or vp_size.y <= 0.0:
return Rect2()
var k: Vector2 = host.size / vp_size
var local := Rect2(u.global_position, Vector2(u.edge, u.edge))
return Rect2(host.position + local.position * k, local.size * k)