From d77a245bcced5794496a3839d090e8a052aa056a Mon Sep 17 00:00:00 2001 From: DKChan <524912269@qq.com> Date: Mon, 5 Oct 2026 18:00:32 +0800 Subject: [PATCH] =?UTF-8?q?ui-struct-probe=20mini-loop=20T0-T6=20=E5=85=A8?= =?UTF-8?q?=E8=90=BD=EF=BC=88=E6=8E=A2=E9=92=88=E5=85=A8=E5=B1=8F=E9=9B=B6?= =?UTF-8?q?=E7=BA=A2+=E8=B4=9F=E5=90=91=E9=AA=8C=E8=AF=81=E7=BA=A2+probe?= =?UTF-8?q?=5Fgate=20=E6=B3=A8=E5=86=8C=E6=AE=B5=E5=85=A8=E7=BB=BF+?= =?UTF-8?q?=E5=86=92=E7=83=9F772/0+run=5Fall=2014/15=E5=9F=BA=E7=BA=BF?= =?UTF-8?q?=E7=BA=A2+doc=E4=B8=89=E9=97=B8=E9=9B=B6=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=EF=BC=89=E2=80=94=E2=80=94=E2=91=A0M2=E5=8F=8C=E5=88=97?= =?UTF-8?q?=E5=8F=A0=E6=94=BE=E4=BF=AE=E5=A4=8D=EF=BC=9Aui=5Fassembler.gd?= =?UTF-8?q?=20=E5=8F=B3=E5=88=97=E6=8C=82=E5=9B=9E=20HBox=20=E7=AC=AC?= =?UTF-8?q?=E4=BA=8C=E5=88=97=EF=BC=88=E5=8E=9F=20pad.add=5Fchild(right)?= =?UTF-8?q?=20=E8=AF=AF=E6=8C=82=20MarginContainer=20=E4=B8=8E=E5=B7=A6?= =?UTF-8?q?=E5=88=97=E5=90=8C=E7=9F=A9=E5=BD=A2=E5=8F=A0=E6=94=BE=3D?= =?UTF-8?q?=E5=AE=9E=E6=9C=BA=E7=9B=AE=E6=A3=80=E6=8A=93=E5=88=B0=E7=9A=84?= =?UTF-8?q?=E6=A0=B9=E5=9B=A0=EF=BC=9Bstart=5Fbox/start=5Fright=20?= =?UTF-8?q?=E5=91=BD=E5=90=8D=E9=94=9A=EF=BC=89=E2=91=A1=E6=8C=89=E9=92=AE?= =?UTF-8?q?=E5=AE=BD=E5=BA=A6=E7=BA=AA=E5=BE=8B=EF=BC=9Aui=5Fkit.button(te?= =?UTF-8?q?xt,min=5Fw=3D216)=20=E5=85=A8=E7=AB=99=E6=9C=80=E5=B0=8F?= =?UTF-8?q?=E5=AE=BD=E5=BA=A6=E5=9F=BA=E5=87=86+SIZE=5FFILL=EF=BC=88VBox?= =?UTF-8?q?=20=E5=88=97=E5=86=85=E7=AD=89=E5=AE=BD=E6=8B=89=E6=BB=A1/HBox?= =?UTF-8?q?=20=E8=A1=8C=E5=86=85=E4=B8=8D=E6=8A=A2=E4=BD=99=E5=AE=BD?= =?UTF-8?q?=E2=80=94=E2=80=94EXPAND=5FFILL=20=E6=92=91=E7=88=86=E8=90=A5?= =?UTF-8?q?=E5=9C=B0=E5=BA=95=E6=9D=A1=201616px=20=E5=AE=9E=E8=AF=81?= =?UTF-8?q?=E6=95=99=E8=AE=AD=E6=B3=A8=E9=87=8A=E5=9C=A8=E6=A1=88=EF=BC=89?= =?UTF-8?q?=EF=BC=9B=E8=A1=8C=E6=9D=BF/=E8=A7=92=E6=A0=87/=E5=9B=BE?= =?UTF-8?q?=E6=A0=87=E9=92=AE=E8=B1=81=E5=85=8D9=E5=A4=84=EF=BC=88camp=205?= =?UTF-8?q?+shop=202+ap=5Fbar=202+=E8=B0=83=E8=AF=95=E8=A7=92=E6=A0=87/?= =?UTF-8?q?=E8=AF=8A=E6=96=AD=E8=BD=AC=E5=82=A8=202=EF=BC=89=E2=91=A2?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=20ui=5Fstruct=5Fprobe=EF=BC=88=E5=85=A8=20UI?= =?UTF-8?q?=20=E5=B1=8F=E7=BB=93=E6=9E=84=E6=96=AD=E8=A8=80=EF=BC=9A?= =?UTF-8?q?=E6=8C=89=E9=92=AE=E4=BA=92=E5=8E=8B/=E5=90=8C=E5=AE=B9?= =?UTF-8?q?=E5=99=A8=E7=AD=89=E5=AE=BD/=E5=87=BA=E5=B1=8F/=E9=9B=B6?= =?UTF-8?q?=E5=B0=BA=E5=AF=B8/=E6=A0=87=E9=A2=98=E5=B1=8F=E5=8F=8C?= =?UTF-8?q?=E5=88=97=E4=BA=94=E6=97=8F=EF=BC=9B=E8=8F=9C=E5=8D=95=E7=B0=87?= =?UTF-8?q?=E7=9C=9F=E9=A9=B1=E5=8A=A8+=E5=86=92=E9=99=A9=E7=B0=87?= =?UTF-8?q?=E7=9C=9F=E6=B5=81+=E5=85=91=E4=BB=98=E5=B1=8F=20open=20?= =?UTF-8?q?=E9=9D=A2=E5=90=88=E6=88=90=E5=96=82=E5=85=A5=EF=BC=9BScrollCon?= =?UTF-8?q?tainer=20=E5=86=85=E5=AE=B9=E8=B1=81=E5=85=8D=EF=BC=9Bmodal=20?= =?UTF-8?q?=E5=B1=8F=20queue=5Fsort=20=E6=89=8B=E6=B3=95=E5=9D=91=E5=9C=A8?= =?UTF-8?q?=E5=86=8C=EF=BC=89=E2=91=A3probe=5Fgate.sh=20=E6=B3=A8=E5=86=8C?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E5=BF=85=E8=B7=91=E6=AE=B5=E2=91=A407=20?= =?UTF-8?q?=E6=94=B9=E6=8E=92=EF=BC=9A=C2=A7=E2=91=AA=E6=94=B9=E6=8E=92?= =?UTF-8?q?=E6=B3=A8=E8=AE=B0+=C2=A7=E2=91=AE=E5=88=86=E5=B7=A5=E6=B3=A8?= =?UTF-8?q?=E8=AE=B0+=E6=96=B0=E5=A2=9E=C2=A7=E2=91=AF=E5=A4=8D=E9=AA=8C?= =?UTF-8?q?=E9=A1=B9=E2=91=A5=E5=87=BA=E5=8F=A3=E6=8A=A5=E5=91=8A=EF=BC=88?= =?UTF-8?q?=E4=BB=A3=E8=A3=81=E8=BF=BD=E8=AE=A4=E5=8D=95=20P1-P4+=E5=85=AD?= =?UTF-8?q?=E8=BD=AE=20red-green=20=E5=AE=9E=E5=BD=95=EF=BC=89=3Ddesign/qa?= =?UTF-8?q?/ui-struct-probe-exit-report-2026-10-05.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- design/gdd/07-体验验证清单.md | 10 +- .../ui-struct-probe-exit-report-2026-10-05.md | 61 ++++ game/presentation/ui_assembler.gd | 8 +- game/presentation/views/ap_bar_view.gd | 4 +- game/presentation/views/bonus_pick_overlay.gd | 2 +- game/presentation/views/camp_overlay_view.gd | 10 +- game/presentation/views/shop_overlay_view.gd | 4 +- game/presentation/views/ui_kit.gd | 7 +- game/smoke/ui_struct_probe.gd | 270 ++++++++++++++++++ game/smoke/ui_struct_probe.gd.uid | 1 + game/smoke/ui_struct_probe.tscn | 6 + tools/probe_gate.sh | 17 +- 12 files changed, 382 insertions(+), 18 deletions(-) create mode 100644 design/qa/ui-struct-probe-exit-report-2026-10-05.md create mode 100644 game/smoke/ui_struct_probe.gd create mode 100644 game/smoke/ui_struct_probe.gd.uid create mode 100644 game/smoke/ui_struct_probe.tscn diff --git a/design/gdd/07-体验验证清单.md b/design/gdd/07-体验验证清单.md index 5aa7513e..6a20766e 100644 --- a/design/gdd/07-体验验证清单.md +++ b/design/gdd/07-体验验证清单.md @@ -105,6 +105,8 @@ ### ⑪ Boss 图整图变异(贫矿脉)目检项(2026-10-04 全局作用域变异首发批随批登记——**目检改排:随 UI 优化批之后统一验证**,用户 2026-10-04 裁「先做完 UI 优化再一起验证」) +> **改排状态注记(2026-10-05 ui-struct-probe 批)**:UI 优化批(UI 落码 11 卡+SR-1)已全落籍;期间实机目检抓到标题屏双列叠放缺陷,已随 ui-struct-probe 批修复,并新增全 UI 屏结构探针入轻闸(按钮互压/等宽/出屏/零尺寸——机械可判项自此探针代管,见 §⑯)。本条统一验证窗口仍开,等用户实机目检时点;下列六项均为体感/呈现面,保留人闸。 + **怎么玩**:打一局正常打到第 2 关的 Boss 图(想快可以在开始界面开调试入口直接进第 2 关);顺手的话再看一眼第 1 关 Boss 和任意普通战斗图,前后共约十分钟。本条目在 UI 优化批落地后执行——届时以改完后的最终界面为准,下述文案若随 UI 批调整以当批登记为准。 **看什么**(六项,逐项是/否即可,感受写一句话): @@ -147,7 +149,13 @@ ### ⑮ 秘宝词族换词全 UI 面目检 + 渲染像素闸补跑(2026-10-05 秘宝代码词族对齐批随批登记——迭代15③号卡) - **实机目检(「秘宝」字样全 UI 面,约 3 分钟)**:①C 区槽位标题应显示「秘宝」(原「魔法道具」,hero_info_panel_view 槽位组);②空槽悬停提示应显示「秘宝槽(空)」;③战斗总结三选一叙述行应显示「秘宝 / …」或「秘宝 / 随机获得一件」(原「魔法道具」开头);④换关 toast 应显示「上关剩余 N 件秘宝已随关卡结束清空」。**红灯信号**:任何界面残留「魔法道具」字样。 -- **渲染像素类闸补跑挂账**:本批屏蔽五件(golden_compare / shot_probe / fx_probe / ui_layout_probe / board_shot——组 A 文案变更使像素断言合法失效,按 goal 令弃断);**补跑时点=迭代 14 全落籍后的表现批周期闸一次性补跑**,与本批无独立补跑义务;届时若 golden 基线因文案换代失效,按换代流程重锚而非当缺陷修。 +- **渲染像素类闸补跑挂账**:本批屏蔽五件(golden_compare / shot_probe / fx_probe / ui_layout_probe / board_shot——组 A 文案变更使像素断言合法失效,按 goal 令弃断);**补跑时点=迭代 14 全落籍后的表现批周期闸一次性补跑**,与本批无独立补跑义务;届时若 golden 基线因文案换代失效,按换代流程重锚而非当缺陷修。**分工注记(2026-10-05 ui-struct-probe 批)**:结构类断言(互压/等宽/出屏)已由 `ui_struct_probe` 入轻闸默认必跑段(tools/probe_gate.sh),与像素五闸互补不替代——五闸补跑义务不变。 + +### ⑯ UI 结构批复验(2026-10-05 ui-struct-probe mini-loop 随批登记——标题屏双列修复+按钮宽度纪律,约 2 分钟) + +- **背景**:实机目检抓到标题屏立绘/帮助牌叠压操作行牌(M2 双列接线缺陷,ui_assembler.gd 右列误挂 MarginContainer)+全站按钮宽度无纪律(UiKit 工厂无宽度约束)。已修复:右列挂回 HBox 第二列;UiKit.button 全站最小宽度基准 216+SIZE_FILL(行板/角标/图标钮 9 处豁免);全 UI 屏结构探针入轻闸。 +- **看什么(实机目检约 2 分钟)**:①标题屏应为左右双列——左操作列(冒险/营地与系统两组)与右立绘看台+玩法速览牌并排,互不遮挡,「退出游戏」按钮完整可见;②同组按钮等宽(菜单列/确认弹窗),按钮不再宽窄参差;③营地屏三柜台+底栏不出屏、不重叠。 +- **红灯信号**:任何屏出现控件叠压/按钮宽窄不一回潮/内容超出屏幕——结构探针已拦机检面,红灯=探针盲区,报批补断言。 ## 验收读数重测义务(履行留痕 + 现行义务) diff --git a/design/qa/ui-struct-probe-exit-report-2026-10-05.md b/design/qa/ui-struct-probe-exit-report-2026-10-05.md new file mode 100644 index 00000000..406f5884 --- /dev/null +++ b/design/qa/ui-struct-probe-exit-report-2026-10-05.md @@ -0,0 +1,61 @@ +# ui-struct-probe mini-loop · 出口报告(2026-10-05) + +任务书:`.zcode/plans/ui-struct-probe-miniloop-plan-2026-10-05.md`(goal 开工令=/goal 完成 plan 全部工作——按 iter13 先例「本 prompt 即批准」,**四拍板点 P1-P4 全取推荐案**,代裁追认单见 §三)。 + +## 一、交付对账(计划书 T0-T6) + +| 步骤 | 交付 | 状态 | +|---|---|---| +| T0 探针骨架 | `game/smoke/ui_struct_probe.gd/.tscn`——逐屏实例化 10 屏(start/settings/camp/confirm/debug/level_map/battle_hud/battle_end/bonus_pick/shop):菜单簇真驱动(组合根真实路径开关)+冒险簇真流推进(开冒险→地图选路态→选战斗节点→战斗 HUD)+兑付屏真实 open 面合成最小数据 | ✅ | +| T1 断言族 | ①overlap 同屏操作钮两两不相交 ②equal_width 同父容器按钮等宽(差>1px 红)③offscreen 完全出视口红 ④zero_size 红 ⑤start 专项双列不相交(start_box/start_right 命名锚);ScrollContainer 内容系可滚动设计内豁免;WL 豁免表机制(现零豁免) | ✅ | +| T2 叠放修复 | `ui_assembler.gd` 右列挂回 HBox 第二列(原 `pad.add_child(right)` 误挂 MarginContainer 与左列同矩形叠放——M2 双列失效根因);装配件命名 start_box/start_right 为探针锚 | ✅ | +| T3 按钮宽度纪律 | `ui_kit.button(text, min_w=216)`——最小宽度基准+SIZE_FILL(VBox 列内拉满列宽等宽;HBox 行内不抢余宽);**行板/角标/图标钮豁免 9 处**(camp 5+shop 2+ap_bar 2+调试角标/诊断转储 2) | ✅ | +| T4 red-green 循环 | 六轮收敛(实录 §二),终局 **PASS 全屏结构断言零红**;负向验证=临时还原叠放 bug 探针必红(§四) | ✅ | +| T5 闸收口 | probe_gate 全绿(含新段)+冒烟 772/0+run_all 14/15(唯一红=基线在册)+doc 三闸零新增(§五) | ✅ | +| T6 报告与改排 | 本报告+07 §⑪改排注记/§⑮分工注记/新增 §⑯复验项+台账 360 | ✅ | + +## 二、red-green 循环实录(六轮) + +1. **R1 解析错**:GDScript Variant 元素类型推断两处(`var k := b.get_parent()`/`var gr := c.get_global_rect()`)——显式类型标注修复。 +2. **R2 全屏 (0,0) 假红**:modal 屏(settings/camp/confirm/debug)首次 visible 后容器排序不自动传播——size 已算而 position 停 0(4.7 行为,加帧无效实证)。修=采样前对屏根子树全部 Container 强制 `queue_sort()`。 +3. **R3 camp offscreen 群**:ScrollContainer 内容坐标系超出=可滚动设计内——offscreen/zero_size 断言豁免 scroll 子树(`_in_scroll`)。 +4. **R4 debug 出屏两件**:考证=`_sim_btn`/分隔条在浮窗 ScrollContainer 内容区(滚动超出合法),浮窗固定骨架(关闭钮 y=690)不出屏——随 R3 豁免消灯,非缺陷。 +5. **R5 camp 底条爆宽 1616px(真缺陷)**:T3 首版 EXPAND_FILL 把营地行板钮拉到 435-622 宽→行板 524→横向禁滚 ScrollContainer 传播内容 min→营地面板撑到 1632px 全线出屏。修=工厂改 SIZE_FILL(EXPAND_FILL 会把行撑爆——注释在案)+**行板紧凑钮 9 处豁免 min 0**。 +6. **R6 全绿**:PASS 全屏结构断言零红;battle_hud 驱动修复(入口/兑付停点走 `level_screen.open_choices` 直传不写 `_pending_candidates`——候选改从 `_cand_ids × runner.node_id_of(nodes())` 取)。 + +**抓到的真缺陷两件**:M2 双列叠放(用户实机目检发现+本批修复+探针回归闸);营地屏 1632px 爆宽(探针首次发现,T3 连带破坏,随豁免消解)。**机检盲区结论**:原 `ui_layout_probe` 为 53 行纯打印 dump 零断言,历次「layout 全绿」仅表示未崩——结构面此前无任何机检覆盖。 + +## 三、代裁追认单(goal 开工令按推荐案执行,4 项) + +| # | 拍板点 | 执行形态 | +|---|---|---| +| P1 | 按钮宽度形态 | 推荐案落地=同容器等宽+min 基准 216+SIZE_FILL;行板/角标/图标钮豁免(计划书 T3 豁免清单条款) | +| P2 | 探针载体 | 新建 `ui_struct_probe`;旧 `ui_layout_probe` 保留诊断打印用途不改名不退役 | +| P3 | 编号归属 | 独立 mini-loop(bug=mini-loop 惯例),roadmap 不占迭代卡;台账号 360 | +| P4 | 与渲染像素五闸关系 | 本批零触碰像素闸;五闸补跑仍挂 07⑮ 表现批周期闸;07⑮ 补分工注记(结构探针互补不替代) | + +## 四、断言有效性负向验证 + +临时还原 T2(右列改回误挂 pad)复跑探针——**立刻红**:`[start][columns] 左列 (56,56)+(937,688) 与右列同矩形相交(双列叠放回归——M2 缺陷闸)`,随即还原复绿。探针非恒真,缺陷闸成立。 + +## 五、闸读数(worktree ui-struct-probe-20261005,HEAD 8944dca 基线) + +| 闸 | 读数 | 判读 | +|---|---|---| +| ui_struct_probe | PASS 全屏结构断言零红 | 本批新增,已注册 probe_gate 默认必跑段 | +| probe_gate --click | 全绿(ui_input 十六断言+ui_struct+click 问题=0+残留 0) | ✅ | +| 冒烟 | **772 项 0 失败**(与基线 772 同——本批零冒烟断言增量) | ✅ | +| run_all | 14/15 绿;唯一 FAIL=`reward_overlay_test`(master 既有在册红——秘宝批/技能池批台账同款,非本批新增) | ✅ | +| doc 死链 | 4=基线 4(06×2+奇物.md×2——本批未触碰文件)零新增 | ✅ | +| doc 术语 | 5=基线 5(主区对照)零新增 | ✅ | +| gdd_lint | 53 vs 基线 50:+3 全为本批 07 登记注记(与历史批 §⑫-⑮ 登记注记同款形态);07「表格占比 8%」FAIL=基线既有(主区对照同败) | ✅ | +| 负向验证 | 叠放还原→探针红→还原→绿 | ✅ | + +## 六、遗留与移交 + +1. **主区合并后复跑**:merge 后主区冒烟+probe_gate 复跑(合并惯例)。 +2. **实机复验(07⑯)**:标题屏双列+按钮等宽+营地不出屏,约 2 分钟,归用户。 +3. **07⑪ 统一验证窗口仍开**:贫矿脉六项体感目检待用户时点。 +4. **渲染像素五闸**(golden_compare/shot_probe/fx_probe/ui_layout_probe/board_shot)补跑义务不变,挂 07⑮ 表现批周期闸。 +5. **宽度基准 216 为 provisional**:菜单/弹窗/结算操作钮现值;若其他屏出现紧凑域挤压,按「豁免传 0」扩展,禁回调全局基准。 +6. 探针采样机制注记:modal 屏首次 visible 后需强制 queue_sort(Godot 4.7 容器排序不自动传播)——后继新探针如采 modal 屏矩形须复用 `_force_layout` 手法(坑在册)。 diff --git a/game/presentation/ui_assembler.gd b/game/presentation/ui_assembler.gd index b6a2cb04..1fe1f26d 100644 --- a/game/presentation/ui_assembler.gd +++ b/game/presentation/ui_assembler.gd @@ -122,6 +122,7 @@ static func build_start_overlay(host: Control, db = null) -> Dictionary: cols.add_child(pad) overlay.add_child(cols) var box := VBoxContainer.new() + box.name = "start_box" box.size_flags_horizontal = Control.SIZE_EXPAND_FILL box.size_flags_stretch_ratio = 1.4 box.add_theme_constant_override("separation", 10) @@ -156,17 +157,18 @@ static func build_start_overlay(host: Control, db = null) -> Dictionary: # 迭代 14.5 调试整合批——调试入口收右下角标(与主按钮组非平级;DS_DEBUG=1 点亮,可见性归组合根)。 # margin 16px 角距;字号降档与主按钮拉开层级 - var dbgbtn := UiKit.button("调试") + var dbgbtn := UiKit.button("调试", 0.0) # 角标豁免宽度基准(0=不设 min/flags——角落小件) dbgbtn.add_theme_font_size_override("font_size", 13) dbgbtn.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_RIGHT, Control.PRESET_MODE_MINSIZE, 16) overlay.add_child(dbgbtn) # 右列:立绘看台 + 玩法速览牌(M2——帮助全文保留表值驱动,仅容器化;内容含地形变异段不删) var right := VBoxContainer.new() + right.name = "start_right" right.size_flags_horizontal = Control.SIZE_EXPAND_FILL right.add_theme_constant_override("separation", 18) right.alignment = BoxContainer.ALIGNMENT_CENTER - pad.add_child(right) + cols.add_child(right) # ui-struct-probe 批 T2:右列挂 HBox 第二列(原误挂 pad 与左列同矩形叠放——M2 双列失效根因) var stage := VBoxContainer.new() stage.alignment = BoxContainer.ALIGNMENT_CENTER var foe_row := HBoxContainer.new() @@ -222,7 +224,7 @@ static func build_inter_overlay(host: Control) -> Dictionary: # Playtest 诊断按钮(调试变体——battle.md §States 允许;卡死转储) static func build_diag_button(host: Control) -> Button: - var diag_btn := UiKit.button("诊断转储") + var diag_btn := UiKit.button("诊断转储", 0.0) # 角落小件豁免宽度基准(固定 offsets 112px 宽) diag_btn.set_anchors_preset(Control.PRESET_BOTTOM_LEFT) diag_btn.offset_left = 6.0 diag_btn.offset_top = -30.0 diff --git a/game/presentation/views/ap_bar_view.gd b/game/presentation/views/ap_bar_view.gd index 24ce62f8..f115b025 100644 --- a/game/presentation/views/ap_bar_view.gd +++ b/game/presentation/views/ap_bar_view.gd @@ -85,7 +85,7 @@ func _init() -> void: row.add_child(_confirm_row) _confirm_lab = UiKit.label("RAP−%d · 旧板旗作废" % refresh_cost, 14, true, COL_INK) _confirm_row.add_child(_confirm_lab) - var confirm_btn := UiKit.button("确认弃图") + var confirm_btn := UiKit.button("确认弃图", 0.0) # HUD 顶条紧凑钮豁免宽度基准 confirm_btn.pressed.connect(_disarm_and_fire) _confirm_row.add_child(confirm_btn) @@ -99,7 +99,7 @@ func _init() -> void: refresh_box.add_theme_constant_override("separation", 0) refresh_box.size_flags_vertical = Control.SIZE_SHRINK_CENTER row.add_child(refresh_box) - _btn_refresh = UiKit.button("弃图刷新") + _btn_refresh = UiKit.button("弃图刷新", 0.0) # HUD 顶条紧凑钮豁免宽度基准 _btn_refresh.pressed.connect(_on_refresh_pressed) refresh_box.add_child(_btn_refresh) _refresh_sub = UiKit.label("RAP−%d · 剩 0 次" % refresh_cost, 14, false, UiKit.INK_LABEL) diff --git a/game/presentation/views/bonus_pick_overlay.gd b/game/presentation/views/bonus_pick_overlay.gd index 1056142c..567ef910 100644 --- a/game/presentation/views/bonus_pick_overlay.gd +++ b/game/presentation/views/bonus_pick_overlay.gd @@ -38,7 +38,7 @@ func _init() -> void: var top_sp := Control.new() top_sp.size_flags_horizontal = Control.SIZE_EXPAND_FILL top.add_child(top_sp) - _refresh_btn = UiKit.button("") + _refresh_btn = UiKit.button("", 0.0) # 图标钮自设 44×44——豁免宽度基准 _refresh_btn.icon = UiKit.load_tex(UiKit.TEX_ICON_REFRESH) _refresh_btn.custom_minimum_size = Vector2(44, 44) _refresh_btn.tooltip_text = "刷新候选——装配刷新件 X 优先,Y 为冒险池;不消耗 AP" diff --git a/game/presentation/views/camp_overlay_view.gd b/game/presentation/views/camp_overlay_view.gd index 4636f5fe..b5fd68da 100644 --- a/game/presentation/views/camp_overlay_view.gd +++ b/game/presentation/views/camp_overlay_view.gd @@ -199,7 +199,7 @@ func _rebuild_tree(data: Dictionary) -> void: hb.add_child(name_l) if String(row.get("kind", "")) == "fork": continue # Fork 行去 UI 化——由 fork_groups 段承载切换钮 - var btn := UiKit.button("购买 %d" % int(row.get("price", 0))) + var btn := UiKit.button("购买 %d" % int(row.get("price", 0)), 0.0) # 柜台行板紧凑钮豁免宽度基准 btn.pressed.connect(func(): tree_buy_requested.emit(String(row.get("node_id", "")))) btn.disabled = not bool(row.get("can_buy", false)) hb.add_child(btn) @@ -213,7 +213,7 @@ func _rebuild_tree(data: Dictionary) -> void: fb.add_child(gl) for side_row in (fg.get("sides", []) as Array): var sd := String(side_row.get("side", "")) - var sb := UiKit.button(String(side_row.get("display_name", sd))) + var sb := UiKit.button(String(side_row.get("display_name", sd)), 0.0) # 行板紧凑钮豁免 sb.pressed.connect(func(): tree_fork_toggled.emit(group, sd)) sb.disabled = not bool(side_row.get("owned", false)) fb.add_child(sb) @@ -338,7 +338,7 @@ func _build_forge_row(holder: VBoxContainer, row: Dictionary) -> Dictionary: var price_l := UiKit.label("", 14, false, UiKit.STONE) price_l.custom_minimum_size = Vector2(64, 0) line.add_child(price_l) - var btn := UiKit.button("锻造 +1 级") + var btn := UiKit.button("锻造 +1 级", 0.0) # 柜台行板紧凑钮豁免宽度基准 btn.pressed.connect(_emit_buy_requested.bind(attr)) line.add_child(btn) holder.add_child(card) @@ -388,7 +388,7 @@ func _rebuild_attune(data: Dictionary) -> void: var form_l := UiKit.label("被动" if String(row.get("form", "")) == "passive" else "主动·充能", 12, false, UiKit.STONE) form_l.custom_minimum_size = Vector2(58, 0) line.add_child(form_l) - var btn := UiKit.button("装配") + var btn := UiKit.button("装配", 0.0) # 行板紧凑钮豁免 btn.pressed.connect(attune_toggled.emit.bind(iid)) line.add_child(btn) _workshop_holder.add_child(line) @@ -398,7 +398,7 @@ func _rebuild_attune(data: Dictionary) -> void: refresh_line.add_theme_constant_override("separation", 6) _refresh_l = UiKit.label("", 12, false, UiKit.STONE) refresh_line.add_child(_refresh_l) - _refresh_btn = UiKit.button("升级") + _refresh_btn = UiKit.button("升级", 0.0) # 行板紧凑钮豁免 _refresh_btn.pressed.connect(_emit_buy_requested.bind("attune_refresh")) refresh_line.add_child(_refresh_btn) _workshop_holder.add_child(refresh_line) diff --git a/game/presentation/views/shop_overlay_view.gd b/game/presentation/views/shop_overlay_view.gd index 69be6d6f..7d01754d 100644 --- a/game/presentation/views/shop_overlay_view.gd +++ b/game/presentation/views/shop_overlay_view.gd @@ -38,7 +38,7 @@ func _init() -> void: var top_sp := Control.new() top_sp.size_flags_horizontal = Control.SIZE_EXPAND_FILL top.add_child(top_sp) - _refresh_btn = UiKit.button("") + _refresh_btn = UiKit.button("", 0.0) # 图标钮自设 44×44——豁免宽度基准 _refresh_btn.icon = UiKit.load_tex(UiKit.TEX_ICON_REFRESH) _refresh_btn.custom_minimum_size = Vector2(44, 44) _refresh_btn.disabled = true # MC-1 投喂后启用(set_refresh_display) @@ -106,7 +106,7 @@ func _build_card(p: Dictionary) -> Dictionary: row.add_child(price_l) var bought_l := UiKit.label("", 14, false, UiKit.WARM_FAINT) row.add_child(bought_l) - var btn := UiKit.button("购买") + var btn := UiKit.button("购买", 0.0) # 行卡紧凑钮豁免宽度基准 btn.pressed.connect(_emit_buy_requested.bind(p)) row.add_child(btn) _cards_box.add_child(card) diff --git a/game/presentation/views/ui_kit.gd b/game/presentation/views/ui_kit.gd index 3dff1792..09b71ff3 100644 --- a/game/presentation/views/ui_kit.gd +++ b/game/presentation/views/ui_kit.gd @@ -253,9 +253,14 @@ static func scaled(base: int, pct: int) -> int: return maxi(1, int(round(float(base) * (100.0 + float(pct)) / 100.0))) -static func button(text: String) -> Button: +# 宽度纪律(ui-struct-probe 批 T3——全站按钮最小宽度基准 216+SIZE_FILL:VBox 列内拉满列宽等宽, +# HBox 行内不抢余宽(EXPAND_FILL 会把行撑爆——营地底条 1616px 实证);角落小件/紧凑位传 0 豁免) +static func button(text: String, min_w := 216.0) -> Button: var b := Button.new() b.text = text + if min_w > 0.0: + b.custom_minimum_size = Vector2(min_w, 0) + b.size_flags_horizontal = Control.SIZE_FILL _apply_button_skin(b) return b diff --git a/game/smoke/ui_struct_probe.gd b/game/smoke/ui_struct_probe.gd new file mode 100644 index 00000000..6c489519 --- /dev/null +++ b/game/smoke/ui_struct_probe.gd @@ -0,0 +1,270 @@ +# VERTICAL SLICE - NOT FOR PRODUCTION +# UI 结构探针(ui-struct-probe mini-loop 2026-10-05——计划书 .zcode/plans/ui-struct-probe-miniloop-plan-2026-10-05.md T0/T1) +# 全 UI 屏结构断言一次跑完:逐屏实例化(菜单簇真驱动 + 冒险簇真流推进 + 兑付屏真实 open 面合成喂入), +# 断言族: +# ①overlap 同屏内可见未禁用按钮两两 global_rect 不相交(互压=红;层间模态盖底层系设计内——各屏采样以屏根为界天然排除) +# ②equal_width 同一直接父容器内 ≥2 可见按钮宽度差 >1px=红(容器纪律:组内等宽) +# ③offscreen 可见件 global_rect 完全落在视口外=红 +# ④zero_size 可见件宽或高 ≤0=红 +# ⑤start 专项 标题屏左列(start_box)与右列(start_right)矩形不相交(M2 双列契约——叠放缺陷回归闸) +# 豁免:WL 常量表按「屏名/规则名/件名」登记合法例外(登记须注理由于行尾注释)。 +# 运行:Godot_console.exe --headless --path . res://smoke/ui_struct_probe.tscn +extends Node + +const ProbeUiUtil := preload("res://smoke/probe_ui_util.gd") + +# 豁免清单(空 = 零豁免起步;红灯逐条核对后合法者入表并注理由) +const WL := {} + +var problems: Array = [] + + +func _ready() -> void: + await get_tree().process_frame # autoload 就绪 + var main: Node = load("res://presentation/main.tscn").instantiate() + add_child(main) + await get_tree().process_frame + + # ---- 菜单簇(开始层可见态;逐屏开→采样→关,模态恢复走组合根真实路径)---- + ProbeUiUtil.isolate_save(main, "smoke_ui_struct_probe_menu", true, false, true) + _settle() + await _audit("start", [main._start_overlay]) + _start_columns_contract(main) + + main._open_settings() + _settle() + await _audit("settings", [main.settings_overlay]) + main._on_settings_closed() + _settle() + + var campbtn: Button = ProbeUiUtil.find_button(main._start_overlay, "营地", "prefix") + if campbtn != null: + campbtn.pressed.emit() + _settle() + await _audit("camp", [main.camp_overlay]) + main._on_camp_closed() + _settle() + else: + problems.append("[camp] 营地入口按钮缺失(驱动中断)") + + main.confirm_dialog.open("结构探针采样", "确认") + _settle() + await _audit("confirm", [main.confirm_dialog]) + main.confirm_dialog.close() + _settle() + + main._show_debug_overlay() + _settle() + await _audit("debug", [main.debug_overlay]) + main._close_debug_overlay() + _settle() + + # ---- 冒险簇(真流推进:开冒险→地图选路态→选战斗节点→战斗 HUD)---- + ProbeUiUtil.isolate_save(main, "smoke_ui_struct_probe_adv", true, false, true) + main._start_adventure_from_menu() + _settle(4) + await _audit("level_map", [main.level_screen]) + + var battle_id := _pick_battle_node(main) + if battle_id >= 0: + main.level_screen.route_chosen.emit(battle_id) + _settle(6) + _audit("battle_hud", [main.board_zone, main.info_panel, main.stage]) + else: + problems.append("[battle_hud] 候选中无战斗节点(驱动中断——候选=%s)" % str(main.level_screen._cand_ids)) + + # ---- 兑付屏(真实 open 面 + 合成最小数据——结构断言只看矩形/宽度/可见性)---- + main._end_overlay.open_victory("结构探针·胜利演算", "返回", Rect2(200, 200, 400, 300), "GP 0") + _settle() + await _audit("battle_end", [main._end_overlay]) + main._end_overlay.close() + _settle() + + main.bonus_overlay.open_pick("结构探针·三选一", [ + {"id": "probe_heal", "text": "回血\n恢复 3 点"}, + {"id": "probe_magic", "text": "爆裂轰击\n下一击 +5"}, + {"id": "probe_pierce", "text": "穿甲\n无视护甲"}, + ]) + _settle() + await _audit("bonus_pick", [main.bonus_overlay]) + main.bonus_overlay.visible = false + _settle() + + main.shop_overlay.open_shop([ + {"product_id": "probe_p1", "display_name": "探针商品甲", "attr": "atk", "flat_value": 1, "price": 10, "purchased": 0}, + {"product_id": "probe_p2", "display_name": "探针商品乙", "attr": "hp", "flat_value": 2, "price": 20, "purchased": 0}, + ], 100) + _settle() + await _audit("shop", [main.shop_overlay]) + + # ---- 判定 ---- + if problems.is_empty(): + print("[ui_struct_probe] PASS — 全屏结构断言零红") + get_tree().quit(0) + else: + for p in problems: + print("[ui_struct_probe] FAIL — %s" % p) + get_tree().quit(1) + + +func _pick_battle_node(main: Node) -> int: + # 入口/兑付停点走 level_screen.open_choices 直传(不写 _pending_candidates)—— + # 候选从视图态取:_cand_ids × runner.nodes() 的 type(编号解析=node_id_of 单一出口) + var ls: Node = main.level_screen + if ls._runner == null: + return -1 + for nd in ls._runner.nodes(): + if String(nd.get("type", "")) in ["battle", "boss"]: + var nid: int = ls._runner.node_id_of(nd) + if nid in ls._cand_ids: + return nid + return -1 + + +func _settle(frames := 4) -> void: + for i in range(frames): + await get_tree().process_frame + await get_tree().process_frame # 布局排序系帧尾 call_deferred——末帧后再保一帧 + + +# ---- 采样与断言 ---- + +func _visible_children(root: Control) -> Array: + var out: Array = [] + var stack: Array = [root] + while not stack.is_empty(): + var c: Control = stack.pop_back() + if c == null: + continue + if c.is_visible_in_tree(): + out.append(c) + for ch in c.get_children(): + if ch is Control: + stack.append(ch) + return out + + +func _buttons(root: Control) -> Array: + var out: Array = [] + for c in _visible_children(root): + if c is Button and not c.disabled: + out.append(c) + return out + + +func _wl(screen: String, rule: String, ctl: Control) -> bool: + var s: Dictionary = WL.get(screen, {}) + if s.is_empty(): + return false + var names: Array = s.get(rule, []) + if names.is_empty(): + return false + return String(ctl.name) in names + + +func _force_layout(root: Control) -> void: + # modal 屏首次 visible 后容器排序不自动传播(size 已算而 position 停 0——4.7 行为)—— + # 采样前对子树全部 Container 强制 queue_sort + var stack: Array = [root] + while not stack.is_empty(): + var c: Control = stack.pop_back() + if c is Container: + c.queue_sort() + for ch in c.get_children(): + if ch is Control: + stack.append(ch) + + +func _in_scroll(c: Control) -> bool: + var p := c.get_parent() + while p is Control: + if p is ScrollContainer: + return true + p = p.get_parent() + return false + + +func _audit(screen: String, roots: Array) -> void: + for r in roots: + if r != null and is_instance_valid(r): + _force_layout(r) + await _settle(1) + if OS.get_environment("STRUCT_PROBE_DEBUG") == "1": + for r in roots: + if r != null and is_instance_valid(r): + _dump(r, 0) + var all_ctls: Array = [] + var all_buttons: Array = [] + for r in roots: + if r == null or not is_instance_valid(r): + problems.append("[%s] 采样根缺失" % screen) + return + all_ctls += _visible_children(r) + all_buttons += _buttons(r) + var vp := get_viewport().get_visible_rect() + # ①overlap:操作钮两两不相交 + for i in range(all_buttons.size()): + for j in range(i + 1, all_buttons.size()): + var a: Control = all_buttons[i] + var b: Control = all_buttons[j] + if _wl(screen, "overlap", a) or _wl(screen, "overlap", b): + continue + if a.get_global_rect().intersects(b.get_global_rect()): + problems.append("[%s][overlap] %s 与 %s 矩形相交(%s ∩ %s)" % [ + screen, a.name, b.name, a.get_global_rect(), b.get_global_rect()]) + # ②equal_width:同一直接父容器内 ≥2 可见按钮宽度差 >1px + var groups := {} + for b in all_buttons: + var k: Node = b.get_parent() + if not groups.has(k): + groups[k] = [] + groups[k].append(b) + for k in groups: + var g: Array = groups[k] + if g.size() < 2: + continue + var w0: float = (g[0] as Control).size.x + var diff := false + for b in g: + if _wl(screen, "equal_width", b): + diff = false + break + if absf((b as Control).size.x - w0) > 1.0: + diff = true + if diff: + var names: Array = [] + var widths: Array = [] + for x in g: + names.append(String((x as Control).name)) + widths.append((x as Control).size.x) + problems.append("[%s][equal_width] 同容器按钮宽度不一(%s——%s)" % [screen, str(names), str(widths)]) + # ③offscreen + ④zero_size(ScrollContainer 内容坐标系超出=可滚动设计内——豁免) + for c in all_ctls: + if _in_scroll(c): + continue + var gr: Rect2 = c.get_global_rect() + if not _wl(screen, "offscreen", c) and not gr.intersects(vp): + problems.append("[%s][offscreen] %s 完全出视口(%s)" % [screen, c.name, gr]) + if not _wl(screen, "zero_size", c) and (c.size.x <= 0.0 or c.size.y <= 0.0): + problems.append("[%s][zero_size] %s 零尺寸(%s)" % [screen, c.name, c.size]) + + +func _dump(c: Control, depth: int) -> void: + var ind := " ".repeat(depth) + print("[dump] %s%s(%s) pos=%s gpos=%s size=%s vis=%s" % [ind, c.name, c.get_class(), + c.position, c.get_global_position(), c.size, c.is_visible_in_tree()]) + for ch in c.get_children(): + if ch is Control: + _dump(ch, depth + 1) + + +# ⑤start 专项:左列/右列矩形不相交(M2 双列契约——装配件命名 start_box/start_right 为探针锚点) +func _start_columns_contract(main: Node) -> void: + var box: Control = main._start_overlay.find_child("start_box", true, false) + var right: Control = main._start_overlay.find_child("start_right", true, false) + if box == null or right == null: + problems.append("[start][columns] 左列/右列节点缺失(start_box/start_right——装配命名失守)") + return + if box.get_global_rect().intersects(right.get_global_rect()): + problems.append("[start][columns] 左列 %s 与右列 %s 相交(双列叠放回归——M2 缺陷闸)" % [ + box.get_global_rect(), right.get_global_rect()]) diff --git a/game/smoke/ui_struct_probe.gd.uid b/game/smoke/ui_struct_probe.gd.uid new file mode 100644 index 00000000..67fda882 --- /dev/null +++ b/game/smoke/ui_struct_probe.gd.uid @@ -0,0 +1 @@ +uid://dqaphrds5ncet diff --git a/game/smoke/ui_struct_probe.tscn b/game/smoke/ui_struct_probe.tscn new file mode 100644 index 00000000..3467060e --- /dev/null +++ b/game/smoke/ui_struct_probe.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://smoke/ui_struct_probe.gd" id="1"] + +[node name="UIStructProbe" type="Node"] +script = ExtResource("1") diff --git a/tools/probe_gate.sh b/tools/probe_gate.sh index d6233932..dad8e1a4 100644 --- a/tools/probe_gate.sh +++ b/tools/probe_gate.sh @@ -2,9 +2,10 @@ # 轻闸探针正主(2026-10-02 挂死灾害批落籍)——headless 安全段固化,禁再手拼同款 Godot 命令。 # 三闸:①每段 timeout 守卫(挂死=FAIL 不静默)②段前预清+段后清本 game 目录孤儿 Godot # (按命令行精确匹配本 game 路径,不误杀其他 worktree 会话的合法进程)③末尾残留清点=批末闸(必须 0)。 -# 段:ui_input_probe=默认必跑;--click=加跑 ui_click_probe 行为走查(自退出+退出码闸;超时多半= -# 探针未适配新停点层,归 probe适配批);shot_probe 禁入本闸——必须窗口化运行(headless 无渲染面, -# 探针内已有 fail-fast 护栏),归真人目检。 +# 段:ui_input_probe=默认必跑;ui_struct_probe=默认必跑(ui-struct-probe mini-loop 2026-10-05 注册—— +# 全 UI 屏结构断言:按钮互压/等宽/出屏/零尺寸/标题屏双列);--click=加跑 ui_click_probe 行为走查 +# (自退出+退出码闸;超时多半=探针未适配新停点层,归 probe适配批);shot_probe 禁入本闸—— +# 必须窗口化运行(headless 无渲染面,探针内已有 fail-fast 护栏),归真人目检。 # 用法: tools/probe_gate.sh [game目录] [--click] 缺省 game 目录=脚本所在仓库的 game/(worktree 内自动指向该 worktree) # 依赖: Git Bash(coreutils timeout/taskkill) + PowerShell;Godot 路径可用环境变量 GODOT_BIN 覆盖。 set -u @@ -51,6 +52,16 @@ elif ! grep -q "PASS" "$TMP/uip.log"; then echo "[probe_gate] FAIL — ui_input_probe 未见 PASS(exit=$st)"; _rc=1 fi +echo "======== ui_struct_probe ========" +timeout 120 "$G" --headless --path "$GAME_WIN" res://smoke/ui_struct_probe.tscn >"$TMP/stp.log" 2>&1 +st=$?; kill_orphans +grep -E "PASS|FAIL" "$TMP/stp.log" | head -12 +if [ "$st" -eq 124 ] || [ "$st" -eq 137 ]; then + echo "[probe_gate] FAIL — ui_struct_probe 超时挂死(exit=$st),孤儿已清"; _rc=1 +elif ! grep -q "PASS" "$TMP/stp.log"; then + echo "[probe_gate] FAIL — ui_struct_probe 未见 PASS(exit=$st)"; _rc=1 +fi + if [ "$WITH_CLICK" -eq 1 ]; then echo "======== ui_click_probe 行为走查 ========" timeout 240 "$G" --headless --path "$GAME_WIN" res://smoke/ui_click_probe.tscn >"$TMP/clk.log" 2>&1