Files
gplan-2/game/presentation/views/board_view.gd
T
DKChan fcd34b52bc draw calls 优化批:瓦片态非英雄格隐形 StyleBoxFlat 置 null——满负荷帧 504→196 DC
定位(临时 A/B 探针逐档实测,窗口化满负荷帧,探针已删方法存档于本信息):
- 基线 census:100 格已开 92/glyph 23/叠层约 20/tex 变体 12
- 统一全格 tex_base 为单纹理:504→504(零增益——纹理切换非批断裂主因)
- 去 glyph/badge:-23(每字格 1 DC)
- 去矢量叠层(outline/facade/proj/eflag/contact):-23
- 去隐形 bg_sb:-402(100 格×~4 个不可合批 StyleBoxFlat polygon 命令,逐格打断棋盘批)
- 隐整个 board_zone 参照系:37 DC(HUD 外围)
- sb 清除后 100 格 tex quad 合回 ~1 批(隐格仅省 1 DC)——批可跨 item 合并,唯一杀手=sb 多边形

改动(game/presentation/views/board_view.gd _paint_cell 单点):
- 瓦片态非英雄格不再挂透明载体 sb(透明底+零描边=视觉贡献零),u.bg_sb=null
- 英雄格保留蓝框载体 sb(新透明 sb+3px 边框,绘制序不变);平色回退路径零改动
- 核心层/data/smoke 策略参数零触碰

读数与闸门:
- battle_draw_calls 504→196(目标 ≤200 达标;三跑 196/196/196 稳定)
- 视觉零变化:三关棋盘 PNG(board_shot_probe)+四屏截图(shot_probe)改前后 7/7
  逐像素零差异(PIL diff bbox=None,容差 0)
- 冒烟 596/0;四探针 ui_input/ui_probe/ui_click/ui_layout 退出码全 0
- 附:补交 shot_probe.gd.uid(Godot 4.7 uid 元数据,库内 .uid 全跟踪惯例)
2026-10-01 07:09:10 +08:00

1113 lines
55 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.SEAL_BLACK # 封印黑 #000000(旗朱→封印紫→封印黑,2026-09-28 用户拍板)
const COL_GOLD := UiKit.GOLD # 奖励金 #F0A828
const COL_PATH := UiKit.HERO_BLUE # 路径预览点迹——小人蓝细线
const COL_CONTACT := UiKit.CONTACT_GLOW # 接触带微光基色(蓝族浅一档·近白蓝——§3.2 v2.8;旧绿相退役 2026-09-26)
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 # 顶面倒角强度
# 高墙感 D 投影档(2026-09-28 挑档落码):地面投影带宽 = 格边比(研究件 64px 拼版实测 14/64≈0.22;
# 立面/投影色参数在 CellUnit 常量——绘制侧单点)
const WALL_PROJECTION_FRAC := 0.22
# 英雄单位演出参数(棋盘小人贴图批 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 用户裁决走引擎侧)
# ── 高墙/地面瓦片纹理路径单点(棋盘高墙瓦片接线批 2026-09-26)──
# 素材 PNG 不入 git(用户裁定)——一律 UiKit.load_tex(exists 守卫)装载,禁 preload;
# 缺位返回 null,整批任一缺位 = 弃用瓦片走程序化平色回退(BRICK/FLOOR,fresh checkout 冒烟不炸)。
# 路径常量落本文件单点(ui_kit.gd 由并行批占用,任务书 §2.5 禁入)。词表 =
# design/research/board-tile-vocab-plan-a-2026-09-26.md(16 唯一墙瓦片 + 5 地面变体;
# 东/东北/东南/end_e/in_ne/in_se 六镜像件运行时 flip_x 合成,不出文件——词表 §二)。
# 换色批(T6 2026-09-28):路径按 board_type 后缀参数化——terrain_<件名>_<board_type>.png;
# 词表 §一 16 唯一墙瓦片名单单点在本常量数组。underdark/deep_underdark 两套 =
# tools/recolor_terrain_tiles.py 程序换色产(台账 164 换色遗留清偿);任一套缺位 → 整批回落
# dungeon 套(禁同板混渲染);dungeon 套亦缺位 → 程序化平色回退(fresh checkout 冒烟不炸)。
const TILE_WALL_NAMES := ["wall_center", "wall_center_crack", "wall_center_speckle", "wall_n", "wall_s", "wall_w", "wall_nw", "wall_sw", "wall_hrun", "wall_vrun", "wall_end_n", "wall_end_s", "wall_end_w", "wall_lone", "wall_in_nw", "wall_in_sw"]
const TILE_WALL_FMT := "res://assets/art/board/terrain_%s_%s.png" # %s=件名 %s=board_type
const TILE_FLAG_SEAL := "res://assets/art/board/flag_seal.png" # 封印旗纹理(迭代 9 卡⑬——独立可选件)
const TILE_FLOOR_FMT := "res://assets/art/board/terrain_floor_%s_%d.png" # %s=board_type 1..%d 变体
const TILE_FLOOR_VARIANTS := 5 # 地面瓦片变体数(词表 §一 floor_1..5)
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() # 地形变异 角标回调(迭代 6 T6 只读面)——Vector2i → String("flat_damage"=伤害型变异未开格;""=无)
var _aux_reveal := {} # 魔法道具 真视集(迭代 7 T4——Vector2i → true;组合根 set_aux_overlays 注入投影)
var _aux_preview := {} # 魔法道具 预读集(同上——未开格徽章呈现挂点)
var _diggable := {} # 可挖域(8 向相邻未开格)——呼吸微光作用集
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 格尺寸公式)
# ── 英雄单位精灵状态(棋盘小人贴图批 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
# ── 瓦片状态(高墙瓦片接线批 2026-09-26;素材缺位 = 全 false/空——程序化平色回退)──
var _tiles := {} # 词表键 → Texture2D(镜像件已预翻转;装载见 _load_board_tiles)
var _floor_tiles: Array[Texture2D] = [] # 地面瓦片五变体(下标 0..4 = floor_1..5)
var _tiles_ok := false # false = 素材缺位/未装载——bg_sb 程序化平色原路径
var _flag_tex: Texture2D = null # 封印旗纹理(缺盘=null=字形「旗」回退——独立可选件不连瓦片整批)
var _tiles_board_type := "dungeon" # 当前装载瓦片套的 board_type(rebuild 按板切换——T6 换色批)
var _digit_dark_ink := true # 数字墨族按板自适应(true=深墨三档)。缺盘平色回退态 #B39A78 裸土
# 为中明度底——深墨族 CR 4.96 达标、亮墨族仅 2.2,故缺盘默认深墨;瓦片装载后按实测分位数覆写
var _digit_plate := false # 数字色牌垫底模式(双峰纹理板——直画两族皆不过门槛时落牌,
# 素牌+深墨 12:1 纹理免疫;2026-10-01 渲染帧取证 underdark 系深墨直画隐形后启用)
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)
_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
# 组合根注入 地图战斗资源 身份解析只读回调(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
# 组合根注入 地形变异 角标只读回调(迭代 6 T6——flat_damage 未开格视觉标记;变异是策略纹理非数值墙:
# 玩家可据此绕行/承担的决策输入。空 Callable = 全板无标记(缺位回退态)
func set_variant_mark_resolver(cb: Callable) -> void:
_vmark_cb = cb
# 魔法道具叠加层投影注入(迭代 7 T4——组合根在 use 成功/三清后调;refresh 消费)
func set_aux_overlays(reveal_cells: Array, preview_cells: Array) -> void:
_aux_reveal.clear()
_aux_preview.clear()
for c in reveal_cells:
_aux_reveal[Vector2i(c)] = true
for c in preview_cells:
_aux_preview[Vector2i(c)] = true
refresh()
# 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()
_contact_unit = null
_hero_last = Vector2i(-99, -99)
_r0.clear()
_hover = Vector2i(-9, -9)
for c in _board.compute_r0():
_r0[c] = true
if _board == null:
return
# 瓦片套随板图型切换(T6 换色批——board_gen 词表 v1.1 dungeon/underdark/deep_underdark;
# 同图型重复 rebuild 零重载,_tiles_board_type 守卫)
var bt: String = _board.board_type if _board.board_type != "" else "dungeon"
if bt != _tiles_board_type:
_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 _tiles_ok:
# NEAREST 采样(2026-09-26 用户拍板:32×32 源 × 45/36px 格边非整数缩放接受档,
# 与英雄小人 1.5× 豁免同级——圣经 §8.2 豁免注记归 art-bible 批补写);只在瓦片
# 模式启用——程序化回退路径渲染零扰动(含格面文字采样不回退不变化)
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):
_diggable[n] = true
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 驱动(棋盘小人贴图批 2026-09-25)
# ---- 入场演出(battle.md Transitions「install 淡入 200ms + 初始 R0 展开涟漪」——2026-09-26 补实装)----
# 全板 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)
# ---- 英雄单位精灵(棋盘小人贴图批 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
# ---- 格态渲染(灰度截图可辨:完整覆尘 / 碎裂裸土 / 已开数字) ----
# 数字三档色温(按板自适应双族——v3.5 冻结④ 修订 2026-10-01):亮墨族 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;选族判据见 _digit_ink_mode)
func _digit_ink(d: int) -> Color:
if _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 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)
digit_col = _digit_ink(d) # 数字墨三档色温(样张 v2 冻结批 T4——§4.1)
else:
if flag:
bg = COL_BRICK
# 封印旗纹理落位(迭代 9 卡⑬——tex_flag 槽接线,迭代 8 卡⑤降级债务清偿):
# 纹理在位=字形「旗」退役(项目纹理槽惯例);缺盘=字形回退原语义(load_tex exists 守卫)
if _flag_tex != null:
u.tex_flag = _flag_tex
else:
glyph = "旗"
digit_col = COL_FLAG
else:
bg = COL_BRICK
# 瓦片模式(高墙接线批 2026-09-26):tex_base = 查表瓦片(未开=墙瓦片/已开=地面瓦片),
# bg_sb 降级为透明描边载体——只承载呼吸微光/主角蓝框(CellUnit 瓦片态画在瓦片之上),
# 程序化平色退役;瓦片分配=格坐标哈希,初始化落定后逐拍稳定(禁逐轮重掷——圣经原则三)
# draw calls 批优化(2026-10-01):透明载体 sb 视觉贡献=零(透明底+零描边——不可见),但
# StyleBoxFlat 多边形命令不可合批,每格打断棋盘批——实测 100 格满负荷帧 ~402 DC(A/B 探针
# 阶梯:纹理统一 0 增益/去 glyph −23/去叠层 −23/去隐形 sb −402)。非英雄格 sb 直接置 null
#(绘制路径零像素变化),仅英雄格保留蓝框载体。
var sb: StyleBoxFlat = null
var tile: Texture2D = null
if _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), 3)
if is_hero:
if sb == null:
sb = UiKit.panel_flat(Color.TRANSPARENT, Color.TRANSPARENT, 3, 0)
sb.set_border_width_all(3)
sb.border_color = COL_HERO
if glyph == "" and _hero_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.5 批 B2——数字直画退牌;雷/「?」维持牌载保读性)+ 2026-10-01 v3.4:双峰纹理板
#(_digit_plate——underdark 系亮白筋+近黑斑并存的直画不可救纹理)数字落牌,牌上深墨 12:1
var plate_digit := _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](代码审查批 3 类型收紧——assign 保持引用语义)
u.facade_south = false
u.proj_band = 0.0
u.tex_facade = null
u.facade_strip = false
u.tex_flag = null
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 投影档(2026-09-28 挑档落码):南邻地面格 → 墙格南缘立「口沿高光+双段暗带」立面
#(邻地面才画——墙-墙内界保墙群内聚,轮廓加强批同口径);板外=墙不画
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 _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 _tiles_ok:
u.facade_strip = true
u.tex_facade = _tiles.get("wall_center")
# ②( 魔法道具叠加层(迭代 7 T4):真视格=红内缩框;预读格=奖励身份徽章(identity_resolver 既有
# 只读面取字形——零数据复制,badge 位复用);已开格标记自然退位(refresh 逐拍重算)
if not revealed:
u.aux_mark = "mine_reveal" if _aux_reveal.has(c) else ""
if _aux_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
# ②' 变异角标(迭代 6 T6):未开格带伤害型变异 → 危险红角标(已开=威胁已兑现标记退位);
# 回调缺位 = 无标记(程序化角标,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.queue_redraw()
# ---- 可挖域接触带呼吸微光(挖掘期唯一在动元素——§3.2 v2.8 画法:光画英雄格周界空间侧,
# 正交邻墙=整条边、斜向=共享角点+两侧各半条边;旧"墙格整框绿相"退役 2026-09-26 批) ----
func set_dig_glow_enabled(on: bool) -> void:
_dig_glow = on
if on:
_apply_contact_bands() # 相位重入(结算→挖掘):refresh 未跑也重挂带几何
else:
_reset_breath()
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
var k := 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()
# ---- 高墙/地面瓦片接线(2026-09-26 批;词表 = design/research/board-tile-vocab-plan-a-2026-09-26.md) ----
# 瓦片整批守卫装载(_init 单次调用):21 枚全成才启用——任一缺位按整批缺位处理(禁同板混渲染;
# fresh checkout 无素材目录 → 静默全回退)。缺位不 push_error——可选素材缺位是预期路径非故障
# (英雄批 load_tex 纪律同款,防冒烟刷屏)。镜像件运行时 flip_x 合成,不出文件。
func _load_board_tiles(board_type: String = "dungeon") -> void:
if board_type == "":
board_type = "dungeon"
var t := {
"wall_center": UiKit.load_tex(TILE_WALL_FMT % ["wall_center", board_type]),
"wall_center_crack": UiKit.load_tex(TILE_WALL_FMT % ["wall_center_crack", board_type]),
"wall_center_speckle": UiKit.load_tex(TILE_WALL_FMT % ["wall_center_speckle", board_type]),
"wall_n": UiKit.load_tex(TILE_WALL_FMT % ["wall_n", board_type]),
"wall_s": UiKit.load_tex(TILE_WALL_FMT % ["wall_s", board_type]),
"wall_w": UiKit.load_tex(TILE_WALL_FMT % ["wall_w", board_type]),
"wall_nw": UiKit.load_tex(TILE_WALL_FMT % ["wall_nw", board_type]),
"wall_sw": UiKit.load_tex(TILE_WALL_FMT % ["wall_sw", board_type]),
"wall_hrun": UiKit.load_tex(TILE_WALL_FMT % ["wall_hrun", board_type]),
"wall_vrun": UiKit.load_tex(TILE_WALL_FMT % ["wall_vrun", board_type]),
"wall_end_n": UiKit.load_tex(TILE_WALL_FMT % ["wall_end_n", board_type]),
"wall_end_s": UiKit.load_tex(TILE_WALL_FMT % ["wall_end_s", board_type]),
"wall_end_w": UiKit.load_tex(TILE_WALL_FMT % ["wall_end_w", board_type]),
"wall_lone": UiKit.load_tex(TILE_WALL_FMT % ["wall_lone", board_type]),
"wall_in_nw": UiKit.load_tex(TILE_WALL_FMT % ["wall_in_nw", board_type]),
"wall_in_sw": UiKit.load_tex(TILE_WALL_FMT % ["wall_in_sw", board_type]),
}
var floors: Array[Texture2D] = []
for i in range(1, TILE_FLOOR_VARIANTS + 1):
floors.append(UiKit.load_tex(TILE_FLOOR_FMT % [board_type, i]))
for k in t:
if t[k] == null:
_request_dungeon_fallback(board_type)
return
for tex in floors:
if tex == null:
_request_dungeon_fallback(board_type)
return
# 地板归一(圣经 v3.5 冻结①/§1.4 样张 v3 记录——已开格底归一世界底层档 L≈0.10,带 0.08–0.14):
# 方向稿 _v3_darken_floors 同式移植;现行瓦片 L≈0.036 低于带下沿故实为提亮;素材重产归资产批,
# 本归一为程序代役(逐批装载时对 freshly-loaded 原图测光,幂等)。归一后逐像素明度顺路采集,
# 驱动数字墨族按板自适应(_digit_ink_mode——均值分位同源同像素,禁二次解码)
var lumas: Array = []
for i in range(floors.size()):
floors[i] = _normalize_floor_luma(floors[i], 0.10, lumas)
var ink_mode := _digit_ink_mode(lumas)
_digit_dark_ink = bool(ink_mode.dark)
_digit_plate = bool(ink_mode.plate)
# 六镜像件(词表 §二:wall_e←wall_w / wall_ne←wall_nw / wall_se←wall_sw /
# wall_end_e←wall_end_w / wall_in_ne←wall_in_nw / wall_in_se←wall_in_sw;只水平翻转禁旋转
# ——北受光不受损)
t["wall_e"] = _flip_tex(t["wall_w"] as Texture2D)
t["wall_ne"] = _flip_tex(t["wall_nw"] as Texture2D)
t["wall_se"] = _flip_tex(t["wall_sw"] as Texture2D)
t["wall_end_e"] = _flip_tex(t["wall_end_w"] as Texture2D)
t["wall_in_ne"] = _flip_tex(t["wall_in_nw"] as Texture2D)
t["wall_in_se"] = _flip_tex(t["wall_in_sw"] as Texture2D)
_tiles = t
_floor_tiles = floors
_tiles_board_type = board_type
_tiles_ok = true
# 封印旗纹理(迭代 9 卡⑬——独立可选件:缺盘=字形回退,不触发瓦片整批回落语义)
_flag_tex = UiKit.load_tex(TILE_FLAG_SEAL)
# 整批回落(T6 换色批):请求套缺位 → 回落 dungeon 套(禁同板混渲染);dungeon 本身缺位 =
# 维持程序化平色回退原语义(_tiles_ok 恒 false,缺位不刷屏纪律不变)
func _request_dungeon_fallback(requested: String) -> void:
if requested != "dungeon":
_load_board_tiles("dungeon")
# 水平镜像贴图运行时合成(flip_h 语义):Image 级逐位翻转后重建 ImageTexture——CellUnit 零改动。
# VRAM 压缩源先解压(flip_x 不支持压缩格式);32×32 原尺寸逐位翻转,无 ±1px 取整问题
static func _flip_tex(tex: Texture2D) -> Texture2D:
var img := tex.get_image()
if img.is_compressed():
img.decompress()
img.flip_x()
return ImageTexture.create_from_image(img)
# 地板瓦片明度归一(v3.5 批 B1——方向稿 _v3_darken_floors 同式移植):8×8 双线性重采样均值测光
# (sRGB→线性域,Rec.709 系数),增益 k=(目标/实测)^(1/2.2) 钳 [0.6,1.8],逐通道 sRGB 域线性拉伸。
# 输入恒为 freshly-loaded 原图(load_tex 每次盘读)——幂等可重入;32×32 五变体开销可忽略
static func _normalize_floor_luma(tex: Texture2D, target_l: float = 0.10, lumas_out = null) -> Texture2D:
var img: Image = tex.get_image()
if img.is_compressed():
img.decompress()
img.convert(Image.FORMAT_RGBA8)
var small: Image = img.duplicate()
small.resize(8, 8, Image.INTERPOLATE_BILINEAR)
var sum := 0.0
for y in range(8):
for x in range(8):
var p := small.get_pixel(x, y)
sum += 0.2126 * _srgb_lin(p.r) + 0.7152 * _srgb_lin(p.g) + 0.0722 * _srgb_lin(p.b)
var k := pow(target_l / maxf(sum / 64.0, 0.001), 1.0 / 2.2)
k = clampf(k, 0.6, 1.8)
for y in range(img.get_height()):
for x in range(img.get_width()):
var c := img.get_pixel(x, y)
var nr := minf(c.r * k, 1.0)
var ng := minf(c.g * k, 1.0)
var nb := minf(c.b * k, 1.0)
img.set_pixel(x, y, Color(nr, ng, nb, c.a))
if lumas_out != null:
lumas_out.append(0.2126 * _srgb_lin(nr) + 0.7152 * _srgb_lin(ng) + 0.0722 * _srgb_lin(nb))
return ImageTexture.create_from_image(img)
static func _srgb_lin(c: float) -> float:
return c / 12.92 if c <= 0.04045 else pow((c + 0.055) / 1.055, 2.4)
# ---- 数字墨族按板自适应(L2/L3 亮瓦融字缺陷修复 2026-10-01——甲案:墨色按关自适应+三关对比断言)----
const DIGIT_INK_CR_P90 := 4.5 # 墨族对地板 p90 亮斑对比度门槛(失效模式=亮白筋纹冲字,WCAG 数字体同源)
const DIGIT_INK_CR_MEAN_BRIGHT := 2.5 # 亮族对地板均值门槛(中调防吃字)
const DIGIT_INK_CR_MEAN_DARK := 3.0 # 深族对地板均值门槛(大字 AA 级——2.4 的实测观感=隐形,v3.4 收紧)
# 归一后地板逐像素明度 → 选数字呈现模式。三态:亮族直画(暗纹理板)/深族直画(中暗板)/
# 色牌垫底(双峰纹理板——近黑斑与近白筋并存时任何墨族直画必融一峰,2026-10-01 渲染帧取证
# 实证 underdark 系深墨隐形,牌上深墨 12:1 纹理免疫)。家族最弱成员(亮族=金亮/深族=锈墨)
# 对 p90 与均值双门槛;亮族过选亮族,否则深族(均值门 3.0),两族皆不过落牌。
# 实测(2026-10-01):dungeon p90=0.121 亮族 4.79/5.80 ✓ 直画;underdark p90=0.406 亮族 ✗、
# 深族 5.34/**2.41**(均值 <3.0 ✗)→ 落牌;deep_underdark 同→落牌
static func _digit_ink_mode(lumas: Array) -> Dictionary:
var sorted := lumas.duplicate()
sorted.sort()
var n := sorted.size()
if n == 0:
return {"dark": false, "plate": false, "cr_p90_bright": 0.0, "cr_mean_bright": 0.0, "cr_p90_dark": 0.0, "cr_mean_dark": 0.0}
var p90: float = sorted[int(n * 0.9)]
var mean := 0.0
for v in sorted:
mean += v
mean /= n
var cr_p90_b := _contrast_y(UiKit.INK_L_GOLD, p90)
var cr_mean_b := _contrast_y(UiKit.INK_L_GOLD, mean)
var cr_p90_d := _contrast_y(UiKit.INK_RUST, p90)
var cr_mean_d := _contrast_y(UiKit.INK_RUST, mean)
var bright_ok := cr_p90_b >= DIGIT_INK_CR_P90 and cr_mean_b >= DIGIT_INK_CR_MEAN_BRIGHT
var dark_ok := cr_p90_d >= DIGIT_INK_CR_P90 and cr_mean_d >= DIGIT_INK_CR_MEAN_DARK
var plate := false
var dark := false
if bright_ok:
dark = false
elif dark_ok:
dark = true
else:
plate = true # 双峰纹理:直画不可救,色牌兜底(素牌+深墨 12:1)
return {"dark": dark, "plate": plate, "cr_p90_bright": cr_p90_b, "cr_mean_bright": cr_mean_b,
"cr_p90_dark": cr_p90_d, "cr_mean_dark": cr_mean_d}
static func _contrast_y(ink: Color, bg: float) -> float:
var y := _ink_y(ink)
return (maxf(y, bg) + 0.05) / (minf(y, bg) + 0.05)
static func _ink_y(c: Color) -> float:
return 0.2126 * _srgb_lin(c.r) + 0.7152 * _srgb_lin(c.g) + 0.0722 * _srgb_lin(c.b)
# 格坐标确定性散列(禁 RNG/禁 randomize 纪律——「初始化随机落定禁逐轮重掷」圣经原则三落点:
# 同格坐标恒同值,refresh/重绘/逐轮不变)。乘加混合 + 移位雪崩;64 位整型乘法回绕即哈希语义
#(GDScript int 溢出静默回绕),板域 10×10/12×12 内 mod 10/mod 5 分布均匀
static func _cell_hash(c: Vector2i) -> int:
var h := c.x * 374761393 + c.y * 668265263
h = (h ^ (h >> 13)) * 1274126177
return h ^ (h >> 16)
# 四正交邻墙判定(词表 §三.1:板外视为墙;未开格=墙、已开格=地板——用户世界观口径「未开=墙、
# 已开=挖出的空间」)。唯一调用面 _paint_cell(refresh 对 _minesweeper 非空守卫在先)
func _is_wall(c: Vector2i) -> bool:
if c.x < 0 or c.y < 0 or c.x >= _board.width or c.y >= _board.height:
return true
return not _minesweeper.is_revealed(c)
# 墙瓦片查表(词表 §一 16 正交情形映射 + §三.2 凹角回落):nf/ef/sf/wf = 北/东/南/西邻是地板。
# 16 情形 = 0 地板(center 族)+ 3 地板×4(端头)+ 2 地板×6(条带/凸角)+ 1 地板×4(单缘)
# + 4 地板(lone),逐分支穷尽
func _wall_tile_for(c: Vector2i) -> Texture2D:
var nf := not _is_wall(c + Vector2i(0, -1))
var ef := not _is_wall(c + Vector2i(1, 0))
var sf := not _is_wall(c + Vector2i(0, 1))
var wf := not _is_wall(c + Vector2i(-1, 0))
if nf and ef and sf and wf:
return _wall_center_tile(c) # 四邻皆墙 → 对角凹角/中心三变体(下位函数)
if nf and ef and sf:
return _tiles["wall_end_w"] # 仅西墙(端头·东连墙)
if ef and sf and wf:
return _tiles["wall_end_s"] # 仅北墙(端头·南连地板、北连墙)
if nf and sf and wf:
return _tiles["wall_end_e"] # 仅东墙(= end_w 镜像)
if nf and ef and wf:
return _tiles["wall_end_n"] # 仅南墙(端头·北向死头、南连墙)
if nf and sf:
return _tiles["wall_hrun"] # 上下地板 = 横条带中段
if ef and wf:
return _tiles["wall_vrun"] # 左右地板 = 竖条带中段
if nf and wf:
return _tiles["wall_nw"] # 凸角(上+左地板)
if nf and ef:
return _tiles["wall_ne"] # 凸角镜像
if sf and wf:
return _tiles["wall_sw"] # 凸角(下+左地板)
if sf and ef:
return _tiles["wall_se"] # 凸角镜像
if nf:
return _tiles["wall_n"] # 北缘(上方地板)
if ef:
return _tiles["wall_e"] # 东缘(镜像件)
if sf:
return _tiles["wall_s"] # 南缘(下方地板)
return _tiles["wall_w"] # 西缘(仅西地板——16 情形穷尽收尾)
# 四邻皆墙格:对角凹角优先 nw > ne > sw > se(词表 §三.2——双凹角不做专件,多对角地板回落
# 首中单凹角件,残留 1px 缝为已裁决可接受限);无对角地板 → 中心三变体按格坐标哈希 60/20/20
func _wall_center_tile(c: Vector2i) -> Texture2D:
if not _is_wall(c + Vector2i(-1, -1)):
return _tiles["wall_in_nw"] # 西北对角是地板
if not _is_wall(c + Vector2i(1, -1)):
return _tiles["wall_in_ne"] # 东北对角是地板(镜像件)
if not _is_wall(c + Vector2i(-1, 1)):
return _tiles["wall_in_sw"] # 西南对角是地板
if not _is_wall(c + Vector2i(1, 1)):
return _tiles["wall_in_se"] # 东南对角是地板(镜像件)
var m := posmod(_cell_hash(c), 10)
if m < 6:
return _tiles["wall_center"] # 6/10 = 60%
if m < 8:
return _tiles["wall_center_crack"] # 2/10 = 20%
return _tiles["wall_center_speckle"] # 2/10 = 20%
# 地面瓦片五变体按格坐标哈希均匀分配(词表 §一 floor_1..5 / §三.3「同法哈希分配」;
# posmod 保非负下标)
func _floor_tile_for(c: Vector2i) -> Texture2D:
return _floor_tiles[posmod(_cell_hash(c), _floor_tiles.size())]
# ---- 自绘光标纹理(镐/旗——引擎内置光标无此语义;程序化占位,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)