diff --git a/design/gdd/systems/表现与支撑/HUD框架.md b/design/gdd/systems/表现与支撑/HUD框架.md index 396a0d18..184a6830 100644 --- a/design/gdd/systems/表现与支撑/HUD框架.md +++ b/design/gdd/systems/表现与支撑/HUD框架.md @@ -251,3 +251,4 @@ HUD框架站在玩家与全部玩法系统之间:挖掘期它是输入的翻 | v0.6 | 2026-09-20 | **敌人卡形态裁决收口批(与 battle.md / 战斗表现 / 06 清单同批,台账 99)**:全站桩形态拍板——B 区主角与敌人均为无框立绘+底座、状态小图标挂血条正下方,英雄卡/敌人卡退役;B 区行、敌人呈现义务行(载体由卡改立绘,意图占位/共享层数各立绘同值/死亡灰化退场等义务面平移)、下游战斗表现交付行、就地更新示例措辞四处同步;攻击值等规划数据仍归 C 区数据框(C 区不动)。呈现快照数据面零改动(消费字段不变,只换呈现载体) | | v0.7 | 2026-09-20 | **表现层三批落地 + 义务清单补行(代码同日三批:样式收敛 / 站桩改造 / 结算演出链)**:呈现义务来源清单补「关卡节点地图」行(节点链进度常显 + 选路覆盖层只读呈现——此前漏登记);新增结算演出覆盖层(跨区弹道/光带载体,兑现演出因果可视);减动效承诺落地(UiKit.motion 单点包装全部呈现层 tween 时长,开启时长减半、可挖域呼吸停格;切片期入口 = TunePanel 开关,正式设置层归设置批)。代码侧站桩载体(StandeeView)与卡片退役随 v0.6 裁决执行 | | v0.8 | 2026-09-23 | **停点前移批(冒险入口流程倒置修复,用户 2026-09-23 拍板;上位裁决 `docs/architecture/adr-0006-board-node2d-rendering.md` 与 `docs/architecture/board-node2d-migration-plan.md` §1,代码实装另批并行)**:① 战斗屏布局四区 → 三区——删 A 区关卡进度条行(LevelStripView 退役删除,节点链进度呈现移出战斗屏、只留关卡画面);C/D 区纵向 520 → 548;装板校验变量与实算同步(棋盘区高 504、可用高度 488);② 呈现义务来源清单「关卡节点地图」行改承载口径——节点链进度由关卡画面选路覆盖层节点态承载(暗金已过 / 金当前 / 候选下一跳通道亮金 / Boss 旗朱描边),不另设进度条组件;③ 「四区」措辞全线改「三区」(负责表、节题、结构冻结、验收、下游战斗表现交付行),双舞台常驻语义不动。**批内代拍板三项待追认**:① B/C/D 字母保持不重编(字母是跨文档/代码注释的承载标识);② A 区 28px 纵向空间划归 C/D 底行(520 → 548;1920 档换算仍归表现层升级批);③ 关卡画面进度呈现由选路覆盖层节点态直接承载、不另设进度条组件 | +| v0.9 | 2026-09-23 | 渲染技术注记(棋盘 Node2D 迁移批 Phase A,代码实装同批;上位裁决 ADR-0006):棋盘本体 Node2D 化——BoardView 内部由 GridContainer+逐格控件改为 Control 壳 + SubViewport + Node2D 世界,类名/信号/公共方法契约不变、探针零改造;三区布局与性能预算(同屏 draw calls < 200)不变;程序化占位视觉等价平移,素材终稿按 art-bible §8.2 格面 32×32 暂定档槽位 1:1 换入 | diff --git a/design/ux/battle.md b/design/ux/battle.md index 142e1f27..057474ad 100644 --- a/design/ux/battle.md +++ b/design/ux/battle.md @@ -404,3 +404,4 @@ | 2026-09-16 | 格面资源批 UI 欠账收口(随 2026-09-15 可选类道具删除的代码收口同步文档):① 布局四区化——删 E 区道具面板行、布局决策 2(道具窗口形态=右栏原位激活)整段删除(编号留空防交叉引用失锚)、ASCII 线框图重绘为 A/B/C/D 四区(D 区横向加宽 ~1036px)、A 区节点链示例改文字签槽实际渲染(战/赏/疗/王);② States 删道具窗口态与零库存空态(「道包空空」);③ 结算口径五段化(授予→增益/减益→战斗→衰减→判定)与明细行 10 类封闭协议(删 15 类行、四类行旧口径;「奖励未生效」行保留);④ 交互/动效/事件表删道具窗口行、跳过按钮、`ux_window_choice`、`submit_window_selection`;⑤ 数据源登记删 `query_inventory` 与窗口载荷,奖励身份按现行 `query_identity` 只读回调口径校正;⑥ 验收清单删窗口断言、五区改四区、未生效类行改现行两型(溢出未生效/奖励未生效);⑦ 关卡末清库反馈义务改载魔法道具背包(随魔法道具 P0 落地,本批不设计具体呈现);⑧ OQ7(道具窗口原位激活)删除、OQ 重编号,「囤刀待机」等旧可选类措辞清理。本 spec 不装载被删除机制的呈现义务 | | 2026-09-20 | **敌人卡形态裁决收口(2026-09-20 用户看对照样张拍板,06 待办「敌人卡形态」解决移除——台账 99)**:全站桩形态定稿——① B 区主角与敌人均为无框立绘+底座(站桩式),**英雄卡/敌人卡组件整体退役,卡片式不再作为候选**;主角血条悬浮头顶、状态效果小图标一排挂血条正下方,敌人血条+顺位徽标悬浮头顶、状态小图标同款挂血条下;攻击值等规划数据仍归 C 区数据框(C 区不动);② 布局表 B 区行、Component Inventory 英雄卡/敌人卡两行随裁决改写(敌方 side 池共享层数各立绘同值、意图动画占位等既有语义平移到站桩载体,不随形态改变);③ ASCII 线框图 B 区重绘(旧"意图:爪击/环点"示意早已随行为环退役,本次一并清掉);④ OQ4 改写为裁决已收口注记,对照样张存档 `production/mockups/`;呈现义务细节重述(受击/退场演出挂点等)随表现层升级批执行 | | 2026-09-23 | **停点前移批(冒险入口流程倒置修复,用户 2026-09-23 拍板;上位裁决 `docs/architecture/adr-0006-board-node2d-rendering.md` 与 `docs/architecture/board-node2d-migration-plan.md` §1,代码实装另批并行)**:① 布局四区 → 三区——删布局表 A 区关卡进度条行与 Component Inventory LevelStripView 行(LevelStripView 退役删除,节点链进度只留关卡画面,战斗屏零进度承载);C/D 底行 520 → 548;ASCII 线框图重绘为 B/C/D 三区;② 新流程口径落文——关卡画面(选路覆盖层常驻形态)为关卡内常驻枢纽:Navigation Position 图、到达语境、Entry 表、信息层级第 5 层、Data Requirements 节点图行改「关卡开始即出图选路、战后返回关卡画面再选路」;③ AC 四区措辞改三区。**批内代拍板三项待追认**:① B/C/D 字母保持不重编;② 28px 纵向空间划归 C/D 底行(520 → 548);③ 关卡画面进度呈现由选路覆盖层节点态承载(暗金已过 / 金当前 / 亮金候选通道 / 旗朱 Boss)、不另设进度条组件 | +| 2026-09-23 | 实现注记(棋盘 Node2D 迁移批 Phase A,代码实装同批;上位裁决 ADR-0006):棋盘渲染 Node2D 化(BoardView = Control 壳 + SubViewportContainer + SubViewport + Node2D 世界,信号/click_cell/_cells 契约保持,新增 push_input 端到端输入探针);格尺寸公式锚点不变(set_cell_area = floor((min(宽,高) − 预留 − (N−1)×格间距) / N) 单点可调),布局与交互语义零改动 | diff --git a/docs/architecture/architecture.md b/docs/architecture/architecture.md index cad5defc..22b45698 100644 --- a/docs/architecture/architecture.md +++ b/docs/architecture/architecture.md @@ -2,8 +2,8 @@ ## Document Status -- Version: 1.2 -- Last Updated: 2026-09-16(格面资源批 UI 欠账收口:分层图与层表中 B4 旧体系变体名改现行规范名"地图战斗资源"——代号前缀按图内旧代号风格保留) +- Version: 1.3 +- Last Updated: 2026-09-23(棋盘 Node2D 渲染迁移 Phase A:D1 层表 BoardView 形态注记——引 ADR-0006) - Engine: Godot 4.7.2-stable(GDScript 单语言;2026-09-07 锁定) - Review Mode: solo(`production/review-mode.txt`,2026-09-12 用户拍板——TD/LP 门禁按 solo 跳过,用户授权架构产出自主推进、迭代期回调) - GDDs Covered: game-concept · game-pillars · systems-index · d2-data-config · a3-action-economy · a4-terrain-variation · b3-status-effect · a1-board-generation · a2-minesweeper-rules · b2-enemy-system · b4-reward-item-economy · b1-settlement(12 份;A5/C1–C4/B5/D3–D6 未设计,按 systems-index 依赖分层预留) @@ -120,7 +120,7 @@ | 模块 | Owns | Exposes | Consumes | 引擎 API | |---|---|---|---|---| -| **D1 HUD**(切片 lite 已拆 9 单元:BoardView / HUDView / HudPresenter / BattleLogView / ItemWindowOverlay / NodeOverlay / LevelStripView / TunePanel / UiKit + 组合根 main.gd) | 全部 Control/CanvasLayer、输入→意图翻译(**唯一提交路径 = settle_batch,不直调 A3**)、呈现义务台账(A2×6 / B2×4 / B3×4 / B4×5 / B1×3) | 视图单元接口(信号外发、setter 只读注入) | A2 compute_move_path / ActionOutcome;A3 ap/rap;B1 步骤流行协议(数据源);ConfigDB display_name | **MEDIUM**:Control / CanvasLayer / draw_* / InputEvent / RichTextLabel——对照 breaking-changes.md 4.5–4.7 条目(CanvasItem 线条无抗锯齿羽化、设备 ID 常量、RichTextLabel 图片参数两轮变化) | +| **D1 HUD**(切片 lite 已拆 9 单元:BoardView / HUDView / HudPresenter / BattleLogView / ItemWindowOverlay / NodeOverlay / LevelStripView / TunePanel / UiKit + 组合根 main.gd) | 全部 Control/CanvasLayer(例外:BoardView 棋盘本体 = Control 壳 + SubViewportContainer + SubViewport + Node2D 世界〔BoardWorld/CellUnit〕,探针契约面不变——ADR-0006 棋盘 Node2D 迁移批 Phase A 2026-09-23)、输入→意图翻译(**唯一提交路径 = settle_batch,不直调 A3**)、呈现义务台账(A2×6 / B2×4 / B3×4 / B4×5 / B1×3) | 视图单元接口(信号外发、setter 只读注入) | A2 compute_move_path / ActionOutcome;A3 ap/rap;B1 步骤流行协议(数据源);ConfigDB display_name | **MEDIUM**:Control / CanvasLayer / draw_* / InputEvent / RichTextLabel——对照 breaking-changes.md 4.5–4.7 条目(CanvasItem 线条无抗锯齿羽化、设备 ID 常量、RichTextLabel 图片参数两轮变化) | | **battle_presentation**(B5,垂直切片后) | 结算动画 / 伤害数字 / 战斗舞台 | (未设计——前置"战斗表现美术测试"锁双舞台陷阱) | B1 步骤流(回放脚本源)、D1 | **MEDIUM**:AnimationPlayer(String→StringName 变更)/ Tween | | **audio**(D4,Alpha) | 音频挂点 | (挂点 = B1 明细行类型流;负反馈聚合——单结算至多一次) | B1 流水 | AudioStreamPlayer(4.7 area_mask 默认 1→0 注意) | | **tutorial**(D5,Alpha) | 教学条目 | (口径来源:A2/A3/B3/B1 各教学条目) | 各上游 | 无 | diff --git a/prototypes/dungeon-sweeper-vertical-slice/presentation/views/board_cell_unit.gd b/prototypes/dungeon-sweeper-vertical-slice/presentation/views/board_cell_unit.gd new file mode 100644 index 00000000..cf71c47d --- /dev/null +++ b/prototypes/dungeon-sweeper-vertical-slice/presentation/views/board_cell_unit.gd @@ -0,0 +1,102 @@ +# VERTICAL SLICE - NOT FOR PRODUCTION +# 棋盘 Node2D 迁移批 Phase A 2026-09-23:CellUnit——BoardView SubViewport 世界内逐格渲染件(Node2D)。 +# 固定叠层序(ADR-0006 Decision 4):砖面底 < 变异纹带 < 数字/徽章 < 旗 < 焦点高亮——本批全部程序化 +# _draw 等价平移现行占位视觉;每层留 texture 槽位(art-bible §8.2 格面 32×32 暂定档——终稿 1:1 换入, +# 本批不实现纹理加载)。无 class_name(类名缓存面纪律——BoardView 经 preload 使用)。 +# 字体:font 槽位缺省 = ThemeDB.fallback_font(与旧 Button 缺省主题字体同源);注入挂点归 +# BoardView.set_world_font(SubViewport 内主题不自动继承主场景,Phase B 复用面)。 +# 行为等价(缺陷①回归约束):格件常驻定长、逐格重绘由 BoardView 驱动,本类零游戏状态——渲染数据 +# 全部由 BoardView 每次 paint 写入(bg_sb/glyph/badge),呼吸微光由 BoardView 就地改 bg_sb 边框。 +extends Node2D + +var cell := Vector2i.ZERO # 板坐标(诊断观测面) +var edge := 46.0: # 格边长(BoardView.set_cell_area 单点驱动) + set(v): + edge = v + queue_redraw() +var bg_sb: StyleBoxFlat = null # 格底 StyleBox(BoardView 每次 paint 重建;呼吸微光就地改边框) +var glyph := "": # 文字位——数字/雷/旗/人/?(现行视觉旗与数字互斥,同位承载) + set(v): + glyph = v + queue_redraw() +var glyph_color := UiKit.INK: # 文字色(墨族/雷朱/旗朱/小人蓝) + set(v): + glyph_color = v + queue_redraw() +var badge_glyph := "": # 奖励徽章文字(右下角金——旗之下、变异纹之上、不进数字区) + set(v): + badge_glyph = v + queue_redraw() +var dot_visible := false: # BFS 路径预览点迹(中心 6×6 小人蓝——旧 ColorRect 等价) + set(v): + dot_visible = v + queue_redraw() +var hl_visible := false: # 焦点高亮(悬停态边框覆盖层) + set(v): + hl_visible = v + queue_redraw() +var hl_color := UiKit.GOLD: # 高亮色(金目标/旗朱/禁入灰禁红) + set(v): + hl_color = v + queue_redraw() +var font: Font = null # 字体槽位(null = ThemeDB.fallback_font——BoardView.set_world_font 注入) +# ── 素材槽位(本批全 null = 程序化占位服役;art-bible §8.2 格面 32×32 暂定档)── +var tex_base: Texture2D = null # ① 砖面底 +var tex_mutation: Texture2D = null # ② 变异纹带 +var tex_glyph: Texture2D = null # ③ 数字 +var tex_badge: Texture2D = null # ③' 徽章 +var tex_flag: Texture2D = null # ④ 旗 + + +func _draw() -> void: + var rect := Rect2(Vector2.ZERO, Vector2(edge, edge)) + # ① 砖面底(占位 = StyleBoxFlat:格底色 + 暗化描边 + 3px 圆角——旧 Button normal 样式等价; + # 呼吸微光/主角蓝框由 BoardView 改 bg_sb 后触发重绘) + if tex_base != null: + draw_texture_rect(tex_base, rect, false) + elif bg_sb != null: + draw_style_box(bg_sb, rect) + # ② 变异纹带(占位批次无程序化纹带——槽位先行,终稿换入即用) + if tex_mutation != null: + draw_texture_rect(tex_mutation, rect, false) + # ③ 数字/徽章(数字 ≥ 格高 50% 墨族对格底 ≥4.5:1;徽章右下角金,遮蔽态 = "?") + if glyph != "": + if tex_glyph != null: + draw_texture_rect(tex_glyph, rect, false) + else: + _draw_glyph(glyph, glyph_color, maxi(14, int(edge * 0.5))) + if badge_glyph != "": + if tex_badge != null: + draw_texture_rect(tex_badge, rect, false) + else: + _draw_badge(badge_glyph) + # ④ 旗(现行视觉旗文字在 ③ 文字位承载——独立槽位留给终稿旗图) + if tex_flag != null: + draw_texture_rect(tex_flag, rect, false) + # ⑤ 路径点迹 + 焦点高亮(悬停态覆盖层,不参与命中) + if dot_visible: + var ds := 6.0 + draw_rect(Rect2(Vector2((edge - ds) * 0.5, (edge - ds) * 0.5), Vector2(ds, ds)), UiKit.HERO_BLUE, true) + if hl_visible: + draw_rect(Rect2(1, 1, edge - 2.0, edge - 2.0), hl_color, false, 2.0) # 2px 内描边(旧 hl Panel 等价) + + +# 文字位绘制:水平居中 + 垂直居中(基线 = (格高 − 字高)/2 + 上伸部——旧 Button 居中对齐等价) +func _draw_glyph(g: String, col: Color, fs: int) -> void: + var f := font if font != null else ThemeDB.fallback_font + if f == null: + return + var asc := f.get_ascent(fs) + var desc := f.get_descent(fs) + draw_string(f, Vector2(0.0, (edge - (asc + desc)) * 0.5 + asc), g, + HORIZONTAL_ALIGNMENT_CENTER, edge, fs, col) + + +# 徽章绘制:右下角金(旧 Label BOTTOM_RIGHT 锚点等价——文字贴角内缩 2–3px) +func _draw_badge(g: String) -> void: + var f := font if font != null else ThemeDB.fallback_font + if f == null: + return + var fs := maxi(10, int(edge * 0.28)) + var sz := f.get_string_size(g, HORIZONTAL_ALIGNMENT_LEFT, -1, fs) + draw_string(f, Vector2(edge - sz.x - 2.0, edge - 3.0), g, HORIZONTAL_ALIGNMENT_LEFT, -1, fs, UiKit.GOLD) diff --git a/prototypes/dungeon-sweeper-vertical-slice/presentation/views/board_cell_unit.gd.uid b/prototypes/dungeon-sweeper-vertical-slice/presentation/views/board_cell_unit.gd.uid new file mode 100644 index 00000000..a390ada9 --- /dev/null +++ b/prototypes/dungeon-sweeper-vertical-slice/presentation/views/board_cell_unit.gd.uid @@ -0,0 +1 @@ +uid://c46ettm7ap332 diff --git a/prototypes/dungeon-sweeper-vertical-slice/presentation/views/board_view.gd b/prototypes/dungeon-sweeper-vertical-slice/presentation/views/board_view.gd index 7f4715c2..e9bd731c 100644 --- a/prototypes/dungeon-sweeper-vertical-slice/presentation/views/board_view.gd +++ b/prototypes/dungeon-sweeper-vertical-slice/presentation/views/board_view.gd @@ -2,12 +2,20 @@ # 实现 GDD:design/ux/battle.md(战斗屏 D 区 BoardView——格态/悬停预承诺光标/可挖域呼吸微光/ # BFS 路径预览/奖励徽章/结算期去饱和锁输入)。 # Date: 2026-09-12(battle.md 战斗屏改造批;基础 = 2026-09-11 HUD框架-lite 版) -# 棋盘视图:格子渲染(art-bible 明亮风、数字高对比、 -# 绘制优先级 数字>小人>旗>奖励徽章>底色)+ 玩家输入意图翻译 + 悬停预承诺三态。 +# 棋盘 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 内完成,无布局回流(格控件/子标记定长,不增删)。 -# ADR-0003 形态约束:GridContainer + 逐格常驻控件(探针耦合 _cells/click_cell——禁盲目迁移 _draw)。 +# 行为等价:格件常驻、逐格重绘在 refresh 内完成,无布局回流(缺陷①回归约束——CellUnit 定长不增删, +# 重建仅在换板时发生)。 class_name BoardView extends Control @@ -15,6 +23,9 @@ signal action_requested(cell: Vector2i, kind: String) # kind ∈ {dig, move, f 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 硬指标) @@ -34,11 +45,13 @@ 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 同值——格尺寸公式与命中换算共用) -var _grid: GridContainer -var _cells := {} # Vector2i → Button -var _cell_sb := {} # Vector2i → StyleBoxFlat(呼吸微光就地改边框色) -var _marker := {} # Vector2i → {"dot": ColorRect, "badge": Label, "hl": Panel} +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 @@ -53,18 +66,27 @@ 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 - var wrap := CenterContainer.new() - wrap.set_anchors_preset(Control.PRESET_FULL_RECT) - wrap.mouse_filter = Control.MOUSE_FILTER_IGNORE - add_child(wrap) - _grid = GridContainer.new() - _grid.add_theme_constant_override("h_separation", 2) - _grid.add_theme_constant_override("v_separation", 2) - wrap.add_child(_grid) + _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) @@ -76,9 +98,11 @@ func board() -> BoardData: func _ready() -> void: _apply_custom_cursors() + mouse_exited.connect(_on_shell_mouse_exited) -# 结算期/道具窗口态:D 区去饱和 + 金橙偏移锁输入(明度不变——art-bible §2;占位级逐格材质) +# 结算期/道具窗口态: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() @@ -86,8 +110,7 @@ func set_desaturated(on: bool) -> void: 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)) - for c in _cells: - (_cells[c] as Button).material = _desat_mat if on else null + _world.material = _desat_mat if on else null # 组合根注入 地图战斗资源 身份解析只读回调(query_identity)——奖励徽章数据源 @@ -101,6 +124,15 @@ 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 格高。 @@ -108,42 +140,43 @@ 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 := 2 # GridContainer v/h_separation(_init 同值) + var sep := SEP # 格间距(_init 同值) var reserve := 24.0 # 垂直总预留(上 12 + 下 12:棋盘底部不贴 D 区边框) var cell := int(floor((minf(avail.x - 12.0, avail.y - reserve) - float((n - 1) * sep)) / float(n))) _cell = float(clampi(cell, 26, 46)) + var stride := _cell + float(SEP) for c in _cells: - (_cells[c] as Button).custom_minimum_size = Vector2(_cell, _cell) + var u: Node2D = _cells[c] + u.edge = _cell + u.position = Vector2(c) * stride + _update_world_size() -# 战斗启动/换板:按板尺寸重建格子控件(minesweeper 引用随之更新) +# 战斗启动/换板:按板尺寸重建格单元(minesweeper 引用随之更新) func rebuild(board: BoardData, minesweeper) -> void: _board = board _minesweeper = minesweeper - for child in _grid.get_children(): + for child in _world.get_children(): child.queue_free() _cells.clear() - _cell_sb.clear() - _marker.clear() _r0.clear() _hover = Vector2i(-9, -9) for c in _board.compute_r0(): _r0[c] = true if _board == null: return - _grid.columns = _board.width + var stride := _cell + float(SEP) for y in _board.height: for x in _board.width: var c := Vector2i(x, y) - var btn := Button.new() - btn.custom_minimum_size = Vector2(_cell, _cell) - btn.focus_mode = Control.FOCUS_NONE - btn.gui_input.connect(func(ev: InputEvent): _on_cell_input(c, ev)) - btn.mouse_entered.connect(func(): _on_cell_hover(c)) - btn.mouse_exited.connect(func(): _on_cell_unhover(c)) - _grid.add_child(btn) - _cells[c] = btn - _marker[c] = _build_markers(btn) + var u: Node2D = _CELL_UNIT.new() + u.cell = c + u.edge = _cell + u.position = Vector2(c) * stride + u.use_parent_material = true # 去饱和材质挂 BoardWorld——单元随世界材质灰化 + u.font = _world.board_font + _world.add_child(u) + _cells[c] = u set_cell_area(size) refresh() @@ -171,6 +204,64 @@ func click_cell(cell: Vector2i, button: int) -> void: _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 px := Vector2(_board.width, _board.height) * (_cell + float(SEP)) - Vector2(SEP, SEP) + _svp_container.custom_minimum_size = px + _viewport.size = px # 树内 stretch=true 由容器回写同值;树外(冒烟直建)此处保 1:1 + + +# 真实输入路径(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 @@ -220,40 +311,39 @@ 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) - var hl: Panel = _marker[c]["hl"] if revealed: - hl.visible = true - _set_hl_color(hl, COL_HL_TARGET) # 移动目标格边框(金) + u.hl_visible = true + u.hl_color = COL_HL_TARGET # 移动目标格边框(金) _set_cursor_shape(c, CURSOR_MOVE_SEM) _show_path_preview(c) elif adjacent: - hl.visible = true - _set_hl_color(hl, COL_HL_TARGET) # 挖掘目标格边框(相邻旗格同此——左键直接挖,插旗不可逆批) + u.hl_visible = true + u.hl_color = COL_HL_TARGET # 挖掘目标格边框(相邻旗格同此——左键直接挖,插旗不可逆批) _set_cursor_shape(c, CURSOR_DIG) elif flag: - hl.visible = true - _set_hl_color(hl, COL_FLAG) # 已承诺旗标记(旗朱;无交互可供——拔旗已退役) + u.hl_visible = true + u.hl_color = COL_FLAG # 已承诺旗标记(旗朱;无交互可供——拔旗已退役) _set_cursor_shape(c, Input.CURSOR_FORBIDDEN) else: - hl.visible = true - _set_hl_color(hl, UiKit.RED_FORBID) # 不可及域灰禁框(禁止光标+首遇 toast 双通道) + 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 _marker.has(c): + if not _cells.has(c): return - _marker[c]["hl"].visible = false + _cells[c].hl_visible = false _hide_path_preview() func _set_cursor_shape(c: Vector2i, shape: int) -> void: - if _cells.has(c): - (_cells[c] as Button).mouse_default_cursor_shape = shape + mouse_default_cursor_shape = shape # 壳级光标(悬停格语义决定——单格悬停恒一,等价旧逐格光标) func _show_path_preview(c: Vector2i) -> void: @@ -262,33 +352,29 @@ func _show_path_preview(c: Vector2i) -> void: return var path: Array = _minesweeper.compute_move_path(c) for pc in path: - if _marker.has(pc): - _marker[pc]["dot"].visible = true + if _cells.has(pc): + _cells[pc].dot_visible = true func _hide_path_preview() -> void: - for c in _marker: - _marker[c]["dot"].visible = false + for c in _cells: + _cells[c].dot_visible = false # ---- 奖励徽章(已开格身份 icon——旗之下、纹带之上、不进数字区) ---- -func _update_badge(c: Vector2i, btn: Button) -> void: - var badge: Label = _marker[c]["badge"] - badge.visible = false +func _update_badge(c: Vector2i) -> String: if _minesweeper == null or not _minesweeper.is_revealed(c) or not _identity_cb.is_valid(): - return + return "" if _r0.has(c): - return # R₀ 初始展开格无奖励身份(地图战斗资源 资格域 = S∖R₀) + return "" # R₀ 初始展开格无奖励身份(地图战斗资源 资格域 = S∖R₀) var rec: Dictionary = _minesweeper.reveal_record(c) # E2 收口:已开记录经查询面(禁直读 排雷玩法 状态字典) if rec.get("kind", "") != "safe": - return + return "" var ident = _identity_cb.call(c) if not (ident is Dictionary): - return - badge.text = _badge_glyph(String(ident.get("cell_type", ""))) - badge.add_theme_font_size_override("font_size", maxi(10, int(_cell * 0.28))) - badge.visible = true + return "" + return _badge_glyph(String(ident.get("cell_type", ""))) # 奖励图标键解析(HUD框架:查不到的键立即断言 + ? 占位纹样 + 诊断;键集 = 格面资源 cell_type 九值枚举——GP+商店房实施批 2026-09-21 八→九) @@ -303,33 +389,33 @@ func _badge_glyph(category: String) -> String: # ---- 格态渲染(灰度截图可辨:完整覆尘 / 碎裂裸土 / 已开数字) ---- func _paint_cell(c: Vector2i, hero: Vector2i) -> void: - var btn: Button = _cells.get(c) - if btn == null: + 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 text := "" + 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 - text = "雷" + 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)): - text = "?" # 数字遮蔽(地形变异 hide_number——信息型:该格真实数字不显示) + glyph = "?" # 数字遮蔽(地形变异 hide_number——信息型:该格真实数字不显示) else: - text = str(d) + glyph = str(d) else: if flag: bg = COL_BRICK - text = "旗" + glyph = "旗" digit_col = COL_FLAG else: bg = COL_BRICK @@ -337,28 +423,14 @@ func _paint_cell(c: Vector2i, hero: Vector2i) -> void: if is_hero: sb.set_border_width_all(3) sb.border_color = COL_HERO - if text == "": - text = "人" + if glyph == "": + glyph = "人" digit_col = COL_HERO - _cell_sb[c] = sb - btn.add_theme_stylebox_override("normal", sb) - btn.add_theme_stylebox_override("hover", sb) - btn.add_theme_stylebox_override("pressed", sb) - btn.text = text - btn.add_theme_color_override("font_color", digit_col) - btn.add_theme_font_size_override("font_size", maxi(14, int(_cell * 0.5))) # 数字 ≥ 格高 50% - _update_badge(c, btn) - # 悬停光标三态(静态语义按四格态预置——hover 期再精调) - if is_hero: - _set_cursor_shape(c, Input.CURSOR_ARROW) - elif revealed: - _set_cursor_shape(c, CURSOR_MOVE_SEM) - elif flag: - _set_cursor_shape(c, CURSOR_FLAG) - elif _diggable.has(c): - _set_cursor_shape(c, CURSOR_DIG) - else: - _set_cursor_shape(c, Input.CURSOR_FORBIDDEN) + u.bg_sb = sb + u.glyph = glyph + u.glyph_color = digit_col + u.badge_glyph = _update_badge(c) + u.queue_redraw() # ---- 可挖域 8 向常驻呼吸微光(挖掘期唯一在动元素) ---- @@ -378,53 +450,21 @@ func _process(delta: float) -> void: 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: - if not _cell_sb.has(c): + var u: Node2D = _cells.get(c) + if u == null or u.bg_sb == null: continue - var sb: StyleBoxFlat = _cell_sb[c] - sb.set_border_width_all(2) - sb.border_color = col + 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: - if _cell_sb.has(c): - var sb: StyleBoxFlat = _cell_sb[c] - sb.set_border_width_all(1) - sb.border_color = sb.bg_color.darkened(0.15) - - -# ---- 子标记层(不参与命中测试) ---- - -func _build_markers(btn: Button) -> Dictionary: - var dot := ColorRect.new() - dot.color = COL_PATH - dot.custom_minimum_size = Vector2(6, 6) - dot.set_anchors_preset(Control.PRESET_CENTER) - dot.mouse_filter = Control.MOUSE_FILTER_IGNORE - dot.visible = false - btn.add_child(dot) - var badge := Label.new() - badge.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT) - badge.grow_horizontal = Control.GROW_DIRECTION_BEGIN - badge.grow_vertical = Control.GROW_DIRECTION_BEGIN - badge.add_theme_color_override("font_color", COL_GOLD) - badge.mouse_filter = Control.MOUSE_FILTER_IGNORE - badge.visible = false - btn.add_child(badge) - var hl := Panel.new() - hl.set_anchors_preset(Control.PRESET_FULL_RECT) - hl.mouse_filter = Control.MOUSE_FILTER_IGNORE - var hl_sb := UiKit.panel_flat(Color.TRANSPARENT, COL_HL_TARGET, 0, 2) - hl_sb.draw_center = false - hl.add_theme_stylebox_override("panel", hl_sb) - hl.visible = false - btn.add_child(hl) - return {"dot": dot, "badge": badge, "hl": hl, "hl_sb": hl_sb} - - -func _set_hl_color(hl: Panel, col: Color) -> void: - var sb: StyleBoxFlat = hl.get_theme_stylebox("panel") - sb.border_color = col + 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 跳过) ---- diff --git a/prototypes/dungeon-sweeper-vertical-slice/presentation/views/board_world.gd b/prototypes/dungeon-sweeper-vertical-slice/presentation/views/board_world.gd new file mode 100644 index 00000000..ad12ed2c --- /dev/null +++ b/prototypes/dungeon-sweeper-vertical-slice/presentation/views/board_world.gd @@ -0,0 +1,9 @@ +# VERTICAL SLICE - NOT FOR PRODUCTION +# 棋盘 Node2D 迁移批 Phase A 2026-09-23:BoardWorld——棋盘 Node2D 世界根(SubViewport 内 +# CellUnit 容器)。职责单点:世界级材质挂点(set_desaturated 去饱和挂此处,CellUnit +# use_parent_material 链下随世界灰化)+ 主题/字体显式注入槽位(SubViewport 不自动继承主场景 +# Control 主题——ADR-0006 Negative 项;Phase B 两舞台共用骨架,ADR-0006 Decision 5)。 +# 无 class_name(类名缓存面纪律——BoardView 经 preload 使用)。 +extends Node2D + +var board_font: Font = null # 字体注入槽位(null = ThemeDB.fallback_font——与旧 Button 缺省主题字体同源) diff --git a/prototypes/dungeon-sweeper-vertical-slice/presentation/views/board_world.gd.uid b/prototypes/dungeon-sweeper-vertical-slice/presentation/views/board_world.gd.uid new file mode 100644 index 00000000..e88c2fae --- /dev/null +++ b/prototypes/dungeon-sweeper-vertical-slice/presentation/views/board_world.gd.uid @@ -0,0 +1 @@ +uid://ckpx51y78qqq1 diff --git a/prototypes/dungeon-sweeper-vertical-slice/smoke/ui_input_probe.gd b/prototypes/dungeon-sweeper-vertical-slice/smoke/ui_input_probe.gd new file mode 100644 index 00000000..7f4817a3 --- /dev/null +++ b/prototypes/dungeon-sweeper-vertical-slice/smoke/ui_input_probe.gd @@ -0,0 +1,203 @@ +# VERTICAL SLICE - NOT FOR PRODUCTION +# 棋盘 Node2D 迁移批 Phase A 2026-09-23:push_input 端到端输入探针(ADR-0003 登记项转义务、 +# ADR-0006 Risks 缓解面)。现役探针全走 BoardView.click_cell 直调(合成鼠标事件零命中已实证)—— +# 本探针是真实输入路径的唯一自动化覆盖:鼠标事件 → 壳层 _gui_input → 板级公式换算(_cell_at)→ +# 悬停/点选/chord 既有出口 → 组合根信号。四 case:①悬停可挖域(金框+十字光标)②悬停禁入 +# (hover_forbidden 信号+灰禁红框)③悬停已开格(路径预览点迹+移动光标)④右键插旗(flag_place +# 到达组合根+核心旗态)⑤双击 chord(禁入格双左击,核心拒挖零副作用)⑥左键挖(dig 到达组合根+核心揭格)。 +# 事件路径:Viewport.push_input(ev, in_local_coords=true)——落点用 BoardView.cell_center_local +# (与 _cell_at 同一公式族)换算画布坐标,SubViewportContainer mouse_filter=IGNORE 不拦截。 +# 运行:Godot_console.exe --headless --path . res://smoke/ui_input_probe.tscn +extends Node + +var main +var _problems: Array = [] +var _actions: Array = [] # action_requested 收集([cell, kind]) +var _chords: Array = [] # chord_attempted 收集 +var _forbids: Array = [] # hover_forbidden 收集 + + +func _ready() -> void: + await get_tree().process_frame # autoload 就绪 + main = load("res://presentation/main.tscn").instantiate() + add_child(main) + await get_tree().process_frame + main._start_adventure_from_menu() + await get_tree().process_frame + if main.route_overlay.visible: + main.route_overlay.press_candidate(0) # 入口选路停点 → 首战 + for i in range(3): + await get_tree().process_frame + var bv = main.board_view + var ms = main.settlement.minesweeper if main.settlement != null else null + if ms == null or bv._cells.is_empty(): + _pre_fail("战斗未就绪(cells=%d settlement=%s)" % [bv._cells.size(), str(main.settlement)]) + return + bv.action_requested.connect(func(c: Vector2i, kind: String): _actions.append([c, kind])) + bv.chord_attempted.connect(func(c: Vector2i): _chords.append(c)) + bv.hover_forbidden.connect(func(c: Vector2i): _forbids.append(c)) + # ---- 先行移动(真实点击路径):R₀ 展开后主角邻域全已开(_diggable 空)——点击前沿已开格 + # 迫近未开边界(与 ui_click_probe 行为同构:先 move 后 dig)---- + var hero0: Vector2i = ms.hero_pos() + var mv := Vector2i(-9, -9) + for c in bv._board.neighbors8(hero0): + if ms.is_revealed(c) and _has_hidden_neighbor(bv, ms, c): + mv = c + break + if mv.x == -9: + mv = _pick_frontier(bv, ms) + if mv.x == -9: + _pre_fail("前沿已开格选取失败(板面无可迫近边界)") + return + _push_click(_pos(bv, mv), MOUSE_BUTTON_LEFT) + await get_tree().process_frame + await get_tree().process_frame + if not _has_action(mv, "move") or ms.hero_pos() == hero0: + _pre_fail("先导移动未到达(actions=%s hero=%s→%s)" % [str(_actions), hero0, ms.hero_pos()]) + return + # ---- 目标格实证选取(不写死坐标;两枚相邻可挖格 + 禁入格 + 已开格)---- + var flag_t := Vector2i(-9, -9) + var dig_t := Vector2i(-9, -9) + for c in bv._diggable: + if not ms.has_flag(c): + if flag_t.x == -9: + flag_t = c + elif dig_t.x == -9: + dig_t = c + break + var forbid_t := _pick_forbidden(bv, ms) + var reveal_t := _pick_revealed(ms) + if flag_t.x == -9 or dig_t.x == -9 or forbid_t.x == -9 or reveal_t.x == -9: + _pre_fail("目标格选取失败 flag=%s dig=%s forbid=%s reveal=%s" % [flag_t, dig_t, forbid_t, reveal_t]) + return + # ---- case① 悬停可挖域:motion → 悬停态到达(金框 + 十字光标)---- + _push_motion(_pos(bv, flag_t)) + await get_tree().process_frame + if bv._hover != flag_t: + _problems.append("case① 悬停未到达(_hover=%s 应=%s)" % [bv._hover, flag_t]) + var u1 = bv._cells[flag_t] + if not u1.hl_visible or u1.hl_color != UiKit.GOLD: + _problems.append("case① 悬停金框缺失(visible=%s color=%s)" % [str(u1.hl_visible), str(u1.hl_color)]) + if bv.mouse_default_cursor_shape != Input.CURSOR_CROSS: + _problems.append("case① 挖掘光标未切十字(%d)" % bv.mouse_default_cursor_shape) + # ---- case② 悬停禁入:hover_forbidden 信号 + 灰禁红框 ---- + _push_motion(_pos(bv, forbid_t)) + await get_tree().process_frame + if _forbids.is_empty() or _forbids[_forbids.size() - 1] != forbid_t: + _problems.append("case② hover_forbidden 未达(%s)" % [str(_forbids)]) + if bv._cells[forbid_t].hl_color != UiKit.RED_FORBID: + _problems.append("case② 禁入红框缺失(color=%s)" % str(bv._cells[forbid_t].hl_color)) + # ---- case③ 悬停已开格:路径预览点迹 + 移动光标 ---- + _push_motion(_pos(bv, reveal_t)) + await get_tree().process_frame + var dots := 0 + for c in bv._cells: + if bv._cells[c].dot_visible: + dots += 1 + if dots < 1: + _problems.append("case③ 路径预览点迹未亮(dots=0)") + if bv.mouse_default_cursor_shape != Input.CURSOR_ARROW: + _problems.append("case③ 移动光标缺失(%d)" % bv.mouse_default_cursor_shape) + # ---- case④ 右键插旗:flag_place 到达组合根 + 核心旗态落位 ---- + _push_click(_pos(bv, flag_t), MOUSE_BUTTON_RIGHT) + await get_tree().process_frame + if not _has_action(flag_t, "flag_place"): + _problems.append("case④ flag_place 未达组合根(actions=%s)" % [str(_actions)]) + await get_tree().process_frame + if not ms.has_flag(flag_t): + _problems.append("case④ 核心旗态未落位") + # ---- case⑤ 双击 chord:禁入格双左击(核心拒挖零副作用)→ chord_attempted ---- + _push_click(_pos(bv, forbid_t), MOUSE_BUTTON_LEFT) + _push_click(_pos(bv, forbid_t), MOUSE_BUTTON_LEFT) + await get_tree().process_frame + if _chords.is_empty() or _chords[_chords.size() - 1] != forbid_t: + _problems.append("case⑤ chord_attempted 未达(chords=%s)" % [str(_chords)]) + # ---- case⑥ 左键挖:dig 到达组合根 + 核心揭格落位 ---- + _push_click(_pos(bv, dig_t), MOUSE_BUTTON_LEFT) + await get_tree().process_frame + if not _has_action(dig_t, "dig"): + _problems.append("case⑥ dig 未达组合根(actions=%s)" % [str(_actions)]) + await get_tree().process_frame + if not ms.is_revealed(dig_t): + _problems.append("case⑥ 核心揭格未落位(目标=%s)" % dig_t) + if _problems.is_empty(): + print("[ui_input_probe] PASS — 悬停金框/禁入信号/路径预览/flag_place/chord/dig 六断言全绿(真实 push_input 路径)") + get_tree().quit(0) + else: + for p in _problems: + push_error("[ui_input_probe] FAIL — " + p) + print("[ui_input_probe] FAIL — " + "; ".join(_problems)) + get_tree().quit(1) + + +func _has_action(cell: Vector2i, kind: String) -> bool: + for a in _actions: + if a[0] == cell and a[1] == kind: + return true + return false + + +func _pick_forbidden(bv, ms) -> Vector2i: + var b = ms.current_board() + for y in b.height: + for x in b.width: + var c := Vector2i(x, y) + if not ms.is_revealed(c) and not bv._diggable.has(c): + return c + return Vector2i(-9, -9) + + +func _pick_revealed(ms) -> Vector2i: + var b = ms.current_board() + var hero: Vector2i = ms.hero_pos() + for y in b.height: + for x in b.width: + var c := Vector2i(x, y) + if ms.is_revealed(c) and c != hero: + return c + return Vector2i(-9, -9) + + +# 前沿已开格兜底选取(主角邻域无前沿时全板扫描) +func _pick_frontier(bv, ms) -> Vector2i: + var b = ms.current_board() + for y in b.height: + for x in b.width: + var c := Vector2i(x, y) + if ms.is_revealed(c) and _has_hidden_neighbor(bv, ms, c): + return c + return Vector2i(-9, -9) + + +func _has_hidden_neighbor(bv, ms, c: Vector2i) -> bool: + for n in bv._board.neighbors8(c): + if not ms.is_revealed(n): + return true + return false + + +# 合成事件落点 = BoardView 画布原点 + 格中心(cell_center_local 与 _cell_at 同一公式族) +func _pos(bv, c: Vector2i) -> Vector2: + return bv.global_position + bv.cell_center_local(c) + + +func _push_motion(pos: Vector2) -> void: + var ev := InputEventMouseMotion.new() + ev.position = pos + ev.global_position = pos + main.get_viewport().push_input(ev, true) + + +func _push_click(pos: Vector2, btn: int) -> void: + var ev := InputEventMouseButton.new() + ev.position = pos + ev.global_position = pos + ev.button_index = btn + ev.pressed = true + main.get_viewport().push_input(ev, true) + + +func _pre_fail(why: String) -> void: + push_error("[ui_input_probe] FAIL — " + why) + print("[ui_input_probe] FAIL — " + why) + get_tree().quit(1) diff --git a/prototypes/dungeon-sweeper-vertical-slice/smoke/ui_input_probe.gd.uid b/prototypes/dungeon-sweeper-vertical-slice/smoke/ui_input_probe.gd.uid new file mode 100644 index 00000000..38a1b334 --- /dev/null +++ b/prototypes/dungeon-sweeper-vertical-slice/smoke/ui_input_probe.gd.uid @@ -0,0 +1 @@ +uid://6qb420weehdu diff --git a/prototypes/dungeon-sweeper-vertical-slice/smoke/ui_input_probe.tscn b/prototypes/dungeon-sweeper-vertical-slice/smoke/ui_input_probe.tscn new file mode 100644 index 00000000..686310e0 --- /dev/null +++ b/prototypes/dungeon-sweeper-vertical-slice/smoke/ui_input_probe.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://smoke/ui_input_probe.gd" id="1"] + +[node name="UIInputProbe" type="Node"] +script = ExtResource("1")