# 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 契约化出口)。 # 高内聚边界:只读 排雷玩法 查询面(+地图战斗资源 身份解析回调)做渲染与意图识别,动作经信号外发——不调用核心写接口 # (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 冻结) const DEPTH_NAMES := ["平面(现行)", "薄(缩小帽档)", "中", "厚(纸雕织梦岛档)"] const DEPTH_PX := [0.0, 4.0, 8.0, 14.0] # 悬崖厚度(像素) const DEPTH_BEVEL := [0.0, 0.35, 0.6, 1.0] # 顶面倒角强度 var depth_mode := 0 # 当前立体档(0..3——平面/薄/中/厚) 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 格尺寸公式) 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) 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 := float(DEPTH_PX[depth_mode]) 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 _refresh_slab() _update_world_size() # 立体档切换(F9 组合根入口;返回档名供 toast) func set_depth_mode(m: int) -> String: depth_mode = clampi(m, 0, DEPTH_NAMES.size() - 1) for c in _cells: _cells[c].bevel = float(DEPTH_BEVEL[depth_mode]) _refresh_slab() set_cell_area(size) return DEPTH_NAMES[depth_mode] # 悬崖参数下发(网格像素尺寸/步距/列数/材质锚色——BoardWorld._draw 消费) func _refresh_slab() -> void: if _board == null or _board.width == 0: return _world.depth_px = float(DEPTH_PX[depth_mode]) _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() _cells.clear() _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 = float(DEPTH_BEVEL[depth_mode]) # 立体原型批——新建格单元继承当前档倒角 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() # 真实输入路径(行为探针复用):合成点击 → 与真实 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 同一公式族) func cell_center_local(c: Vector2i) -> Vector2: return _svp_container.position + Vector2(c) * (_cell + float(SEP)) + 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 := float(DEPTH_PX[depth_mode]) 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 == "": 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)