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

659 lines
30 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
# 实现 GDD:design/ux/battle.md(战斗屏 D 区 BoardView——格态/悬停预承诺光标/可挖域呼吸微光/
# BFS 路径预览/奖励徽章/结算期去饱和锁输入)。
# Date: 2026-09-12(battle.md 战斗屏改造批;基础 = 2026-09-11 HUD框架-lite 版)
# 棋盘 Node2D 迁移批 Phase A 2026-09-23:渲染本体由「GridContainer + 逐格 Button」平移为
# 「Control 壳 + SubViewportContainer + SubViewport + BoardWorld(Node2D) + CellUnit(Node2D)」
# (ADR-0006 / docs/architecture/board-node2d-migration-plan.md §2)。对外契约逐项保持:
# 类名/extends/三信号/click_cell/_cells 键面(Vector2i→格单元,值 Button→CellUnit)/
# board()/set_desaturated()/set_identity_resolver()/set_variant_mask_resolver()/set_cell_area()/
# rebuild()/refresh()/set_dig_glow_enabled()——探针与组合根零改造。
# 命中 = 壳层 _gui_input + 板级公式单点换算(ADR-0006 Decision 3,_cell_at 为唯一公式函数;
# SubViewportContainer 逻辑分辨率恒等于板像素尺寸(stretch 1:1,无缩放比),换算只需减居中偏移)。
# 程序化占位视觉等价平移(数字三档/旗/徽章/危险红/变异遮蔽 "?"/呼吸微光/悬停/路径预览/R₀ 豁免);
# CellUnit 叠层 texture 槽位留好(本批不接纹理资产)。新增只读属性 cell_size(旧私有名 _cell 契约化出口)。
# 棋盘小人贴图批 2026-09-25:英雄=BoardWorld 独立 Sprite2D(移动实体不进格面语义——计划书 §3.A;
# refresh diff last_hero_pos 驱动位移 Tween+站/走两帧+flip_h 朝向;素材缺位回退格内字形「人」);
# 挖掘=挥铲三帧特效(BoardFxView)+角色弹跳 Tween;格面五 texture 槽零改动。
# 高内聚边界:只读 排雷玩法 查询面(+地图战斗资源 身份解析回调)做渲染与意图识别,动作经信号外发——不调用核心写接口
# (settle_batch 归组合根编排),不持久持有战斗态(minesweeper 引用随 refresh 更新,仅作只读查询)。
# 行为等价:格件常驻、逐格重绘在 refresh 内完成,无布局回流(缺陷①回归约束——CellUnit 定长不增删,
# 重建仅在换板时发生)。
class_name BoardView
extends Control
signal action_requested(cell: Vector2i, kind: String) # kind ∈ {dig, move, flag_place, flag_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 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 # 小人边框——小人蓝 #2E6E8E
const COL_FLAG := UiKit.FLAG_RED # 旗朱 #C63D22
const COL_GOLD := UiKit.GOLD # 奖励金 #F0A828
const COL_PATH := UiKit.HERO_BLUE # 路径预览点迹——小人蓝细线
const COL_DIG_EDGE := UiKit.DIG_EDGE # 可挖域呼吸微光基色(绿相,明度贴格底)
const COL_HL_TARGET := UiKit.GOLD # 悬停目标格边框(金)
const REWARD_BADGE := { # 已开格格面资源身份 icon(cell_type 九型键;极性视觉归批 2)
"attack": "攻", "defense": "守", "heal": "医", "blast": "爆", "weaken": "弱", "pierce": "穿", "magic": "魔", # 穿/魔 = 伤害道具批 2026-09-18
"action": "行", # 行 = 行动点型(八型批 2026-09-20——ap_up/ap_drain 两行;徽章键漏网补齐,ui_click_probe 91 断言修复)
"gp": "金", # 金 = GP 型(GP+商店房实施批 2026-09-21——gp_gain/gp_loss 两行;金色系 ~40° 相位,禁入带紫赤 300–345° 外,取色 UiKit.GOLD 徽标色单点)
}
const BADGE_PLACEHOLDER := "?" # 未知图标键占位纹样(HUD框架:禁静默空图标)
const CURSOR_DIG := Input.CURSOR_CROSS # 挖——镐形自绘纹理载体
const CURSOR_FLAG := Input.CURSOR_POINTING_HAND # 旗——旗形自绘纹理载体
const CURSOR_MOVE_SEM := Input.CURSOR_ARROW # 移动——箭头(命名避开 Control.CURSOR_MOVE 原生成员遮蔽)
const CHORD_WINDOW_MS := 350
const SEP := 2 # 格间距(旧 GridContainer v/h_separation 同值——格尺寸公式与命中换算共用)
# 棋盘立体参数(2026-09-24 用户实机三档对比拍板「中档」定稿——原型 F9/按钮入口已退役;
# §1.4 注记待 art-bible 并行批落籍后补写)
const DEPTH_PX := 8.0 # 悬崖厚度(像素)
const DEPTH_BEVEL := 0.6 # 顶面倒角强度
# 英雄单位演出参数(棋盘小人贴图批 2026-09-25——计划书 §3.B/C 时长档)
const HERO_FRAME_PX := 32.0 # 英雄帧画布边长(素材 32×32 锁定——缩放比分母)
const HERO_SPRITE_SCALE := 1.5 # 1.5× 档(2026-09-26 用户二裁:实机看 1× 偏小放大 1.5 倍——明示
# 裁决压圣经 §8.2「禁非整数缩放」一般规则,§8.2 豁免注记归 art-bible
# 批另落;头可越格上沿不裁剪)
const HERO_GROUND_FRAC := 0.25 # 脚锚纵向落点 = 格底往上 25%(用户口径「脚踩格子 1/4 处·从下往上数」;
# 头越上格沿造立体感。⚠ 参照系纪律:本值一律自底边起算——上一批用
# 「自顶 75%」表述同线异名,措辞口径误导用户被纠,本批统一为自底边)
const HERO_MOVE_S := 0.14 # 位移时长(~140ms)
const HERO_STEP_S := 0.18 # 走路帧周期 = 原地撞墙帧闪时长(~180ms)
const HERO_DIG_S := 0.15 # 挖掘弹跳总时长(~150ms,半程压半程回)
const HERO_BOUNCE := 0.82 # 弹跳压缩比(scale.y 1→0.82→1 脚底收敛)
const HERO_SHADOW_PX := Vector2(32, 12) # 脚下椭圆影屏幕尺寸(Godot 组件合成——径向渐变纹理压扁,
# 贴图零改动,2026-09-26 用户裁决走引擎侧)
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 _diggable := {} # 可挖域(8 向相邻未开格)——呼吸微光作用集
var _breath_t := 0.0
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 格尺寸公式)
# ── 英雄单位精灵状态(棋盘小人贴图批 2026-09-25;素材 PNG 不入 git——load_tex 守卫装载,缺位=字形回退)──
var _hero_tex_idle: Texture2D # 站立帧(null = 素材缺位——零精灵,字形「人」原路径)
var _hero_tex_walk: Texture2D # 跨步帧
var _hero_last := Vector2i(-99, -99) # 上拍英雄格(refresh diff 基准;哨兵 = 未落位/随 rebuild 复位)
var _hero_face_left := true # 朝向(素材只入库朝左帧——右向 = flip_h,计划书 §2 约定)
var _hero_bump_pending := false # 受理零位移移动回执(refresh diff 只认位置变化,原地移动须外部告知)
var _hero_move_tw: Tween
var _hero_bump_tw: Tween
var _hero_dig_tw: Tween
var cell_size: float: # 只读属性(ui_layout_probe 断言面——旧私有名 _cell 的契约化出口)
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 区底色(与旧 Button 网格间隙同观感)
_svp_container.add_child(_viewport)
_world = _BOARD_WORLD.new()
_viewport.add_child(_world)
_desat_mat = ShaderMaterial.new()
_desat_mat.shader = load("res://presentation/views/desaturation.gdshader")
_desat_mat.set_shader_parameter("strength", 0.0)
_hero_tex_idle = UiKit.load_tex(UiKit.TEX_HERO_IDLE)
_hero_tex_walk = UiKit.load_tex(UiKit.TEX_HERO_WALK)
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
# 组合根注入 地图战斗资源 身份解析只读回调(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
# SubViewport 主题/字体显式注入挂点(Phase B 两舞台复用——SubViewport 不自动继承主场景 Control 主题;
# 本项目无自定义主题,缺省 = 引擎回退字体,与旧 Button 渲染同源)
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 格尺寸公式)
# 2026-09-19 修复:格边长公式补算 (N−1)×2px 网格间距 + 垂直预留 24px——旧公式只算 N×cell,
# L2 12×12 板网格实高溢出可用区 ~6px,下缘被 clip_contents 裁掉约 1/6 格高。
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))
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_last.x >= 0:
_place_hero(_hero_last, _hero_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) -> void:
_board = board
_minesweeper = minesweeper
for child in _world.get_children():
child.queue_free()
_world.hero = null # 英雄精灵随清场退役——下拍 refresh 经 ensure_hero 重建(首建即时落位)
_cells.clear()
_hero_last = Vector2i(-99, -99)
_r0.clear()
_hover = Vector2i(-9, -9)
for c in _board.compute_r0():
_r0[c] = true
if _board == null:
return
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——单元随世界材质灰化
u.font = _world.board_font
_world.add_child(u)
_cells[c] = u
set_cell_area(size)
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):
_diggable[n] = true
for y in _board.height:
for x in _board.width:
_paint_cell(Vector2i(x, y), hero)
_refresh_hover_after_paint()
_update_hero(hero) # 英雄精灵 diff 驱动(棋盘小人贴图批 2026-09-25)
# ---- 英雄单位精灵(棋盘小人贴图批 2026-09-25——计划书 §3.A/B;格面五 texture 槽零改动) ----
# refresh diff 驱动:位置变化 = 位移演出(140ms Tween + 走路帧);零位移但有受理移动回执 = 只播帧
# 不位移(撞墙语义——排雷玩法「目标=当前格」幂等受理)。素材缺位 = 零精灵,字形「人」回退路径。
func _update_hero(hero: Vector2i) -> void:
if _hero_tex_idle == null:
return
if _world.hero == null:
var sp: Sprite2D = _world.ensure_hero(_hero_tex_idle, HERO_SHADOW_PX, HERO_SPRITE_SCALE)
sp.scale = Vector2.ONE * HERO_SPRITE_SCALE
sp.position = _hero_feet(hero)
_hero_last = hero # 首建/随 rebuild 重建——即时落位无演出
return
if hero == _hero_last and not _hero_bump_pending:
return
if hero != _hero_last and hero.x != _hero_last.x:
_hero_face_left = hero.x < _hero_last.x
_place_hero(_hero_last, hero, hero == _hero_last)
if hero == _hero_last:
_hero_frame_flash()
_hero_bump_pending = false
_hero_last = hero
# 英雄落位(instant = 即位无演出——首建/格边长变更;否则 140ms 位移 + 走路帧起手、到位回站帧。
# 帧权威单一化:Sprite2D 换纹理路线(计划书 §3.B 二选一之后者)——换纹理+flip_h 同拍写定即生效,
# 无 AnimatedSprite2D 的 play()/advance(0) 错向面)
func _place_hero(from: Vector2i, to: Vector2i, instant: bool) -> void:
var sp: Sprite2D = _world.hero
if sp == null:
return
var s := HERO_SPRITE_SCALE
var feet := _hero_feet(to)
if _hero_move_tw != null and _hero_move_tw.is_valid():
_hero_move_tw.kill()
if instant:
sp.scale = Vector2(s, s)
sp.position = feet
sp.texture = _hero_tex_idle
return
sp.scale = Vector2(s, s) # 位移重置弹跳残形(scale.y 由 play_hero_dig_bounce 独立回收)
sp.texture = _hero_tex_walk
sp.flip_h = not _hero_face_left # 素材基准朝左
_hero_move_tw = create_tween()
_hero_move_tw.tween_property(sp, "position", feet, UiKit.motion(HERO_MOVE_S)).set_trans(Tween.TRANS_SINE)
_hero_move_tw.tween_callback(func() -> void:
if is_instance_valid(sp):
sp.texture = _hero_tex_idle)
# 原地移动帧闪(撞墙语义):跨步帧 ~180ms 后回站帧,零位移
func _hero_frame_flash() -> void:
var sp: Sprite2D = _world.hero
if sp == null:
return
if _hero_bump_tw != null and _hero_bump_tw.is_valid():
_hero_bump_tw.kill()
sp.texture = _hero_tex_walk
sp.flip_h = not _hero_face_left
_hero_bump_tw = create_tween()
_hero_bump_tw.tween_interval(UiKit.motion(HERO_STEP_S))
_hero_bump_tw.tween_callback(func() -> void:
if is_instance_valid(sp):
sp.texture = _hero_tex_idle)
# 受理移动指令回执(组合根 settle 受理后、refresh 前调用)——原地移动经此触发帧闪;
# 目标异格时本旗标被 diff 位移路径自然吞掉(置位即清)
func notify_hero_move() -> void:
_hero_bump_pending = true
# 挖掘角色弹跳(计划书 §3.C:scale.y 1→0.82→1,脚底锚收敛不悬空;与位移/帧闪 tween 分开管理
# 避免属性争抢——旧弹跳先 kill)
func play_hero_dig_bounce() -> void:
var sp: Sprite2D = _world.hero
if sp == null or _hero_tex_idle == null:
return
if _hero_dig_tw != null and _hero_dig_tw.is_valid():
_hero_dig_tw.kill()
var s := HERO_SPRITE_SCALE
sp.scale = Vector2(s, s)
_hero_dig_tw = create_tween()
_hero_dig_tw.tween_property(sp, "scale:y", s * HERO_BOUNCE, UiKit.motion(HERO_DIG_S * 0.5)).set_trans(Tween.TRANS_SINE)
_hero_dig_tw.tween_property(sp, "scale:y", s, UiKit.motion(HERO_DIG_S * 0.5)).set_trans(Tween.TRANS_SINE)
# 格左上角(board_area 全幅坐标系)——fx 同层演出定位出口(本视图与 fx 同为 board_area 全幅子件,
# 局部坐标同系;cell_center_local 同公式族)
func cell_origin(c: Vector2i) -> Vector2:
return _svp_container.position + Vector2(c) * (_cell + float(SEP))
# 英雄脚底锚点——**世界坐标**(精灵在 SubViewport 世界内:格原点 = 格×步距,禁带壳层居中偏移。
# 2026-09-26 修正:上批误用壳层 cell_origin(含 _svp_container.position 居中偏移)定位世界内精灵,
# 实测整体右偏 ~7 格+偏离格心——实机截图用户报缺陷)。
# 纵向 = 格底往上 25% 线(脚踩格子 1/4 处——2026-09-26 用户裁决);横向格中线。身体向上延伸,
# 头越上格沿不裁剪(立体感裁决);锚点与精灵缩放解耦(缩放只改身体向上长多少)。
func _hero_feet(c: Vector2i) -> Vector2:
return Vector2(c) * (_cell + float(SEP)) + Vector2(_cell * 0.5, _cell * (1.0 - HERO_GROUND_FRAC))
# 英雄持握手位(fx 同层坐标系 = 壳层坐标——fx 在 SubViewport 之外,须含 _svp_container.position
# 居中偏移:世界脚底锚 + 壳层偏移即得)。手 = 盒底往上 1/4 盒高、水平居中(2026-09-25 用户裁决;
# 盒高随 HERO_SPRITE_SCALE——手位钉在身体上不随缩放漂移)
func hero_hand_point() -> Vector2:
return _svp_container.position + _hero_feet(_hero_last) \
+ Vector2(0.0, -HERO_FRAME_PX * 0.25 * HERO_SPRITE_SCALE)
# 英雄精灵缩放档(fx 挥铲同档出口——两演出比例恒一致)
func hero_sprite_scale() -> float:
return HERO_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)
# 板级公式单点(ADR-0006 Decision 3):格坐标 = floor(板内局部坐标 / 格步长)
#(格步长 = 格边长 + 间距;板外/负域返回 (-9,-9)。旧逐格 Button 命中被此函数取代;
# 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
# 真实输入路径(ADR-0006 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_flag(c):
action_requested.emit(c, "flag_place")
# 旗 = 一次性承诺(插旗不可逆批 2026-09-19):拔旗退役——旗格右键零动作;
# 左键相邻旗格直接挖(四情况②④兑付改判,规则引擎放行)。
# 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 flag: bool = _minesweeper.has_flag(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 flag:
u.hl_visible = true
u.hl_color = COL_FLAG # 已承诺旗标记(旗朱;无交互可供——拔旗已退役)
_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:
# D3 合法性指示:悬停已开格 → BFS 路径蓝细线点迹(compute_move_path 只读面)
if _minesweeper == null:
return
var path: Array = _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 = _minesweeper.reveal_record(c) # E2 收口:已开记录经查询面(禁直读 排雷玩法 状态字典)
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 九值枚举——GP+商店房实施批 2026-09-21 八→九)
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
# ---- 格态渲染(灰度截图可辨:完整覆尘 / 碎裂裸土 / 已开数字) ----
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 flag: bool = _minesweeper.has_flag(c)
var is_hero: bool = c == hero
var glyph := ""
var bg: Color
var digit_col := COL_DIGIT
if revealed:
var rec: Dictionary = _minesweeper.reveal_record(c) # E2 收口:已开记录经查询面
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)
else:
if flag:
bg = COL_BRICK
glyph = "旗"
digit_col = COL_FLAG
else:
bg = COL_BRICK
var sb := UiKit.panel_flat(bg, bg.darkened(0.15), 3)
if is_hero:
sb.set_border_width_all(3)
sb.border_color = COL_HERO
if glyph == "" and _hero_tex_idle == null:
# 贴图缺位回退字形「人」(项目纹理槽惯例——贴图在位=格内字形退役,单位本体由独立精灵承载)
glyph = "人"
digit_col = COL_HERO
u.bg_sb = sb
u.glyph = glyph
u.glyph_color = digit_col
u.badge_glyph = _update_badge(c)
u.queue_redraw()
# ---- 可挖域 8 向常驻呼吸微光(挖掘期唯一在动元素) ----
func set_dig_glow_enabled(on: bool) -> void:
_dig_glow = on
if not on:
_reset_breath()
func _process(delta: float) -> void:
if not _dig_glow or _diggable.is_empty():
return
_breath_t += delta
if UiKit.motion_scale < 1.0:
return # 减动效:呼吸停格(保留当前微光边框——静态可辨、无持续运动)
var k := 0.5 + 0.5 * sin(_breath_t * 3.0) # 3 rad/s≈0.48Hz——无 >3 次/秒频闪(AC 基线)
var col := COL_DIG_EDGE.lerp(UiKit.DIG_EDGE_DEEP, 0.55 - 0.35 * k)
for c in _diggable:
var u: Node2D = _cells.get(c)
if u == null or u.bg_sb == null:
continue
u.bg_sb.set_border_width_all(2)
u.bg_sb.border_color = col
u.queue_redraw()
func _reset_breath() -> void:
for c in _diggable:
var u: Node2D = _cells.get(c)
if u != null and u.bg_sb != null:
u.bg_sb.set_border_width_all(1)
u.bg_sb.border_color = u.bg_sb.bg_color.darkened(0.15)
u.queue_redraw()
# ---- 自绘光标纹理(镐/旗——引擎内置光标无此语义;程序化占位,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)