# 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=红(容器纪律:组内等宽;钮高族 # equal_height 2026-10-06 增——同容器高度差 >1px=红,纪律=ui_kit BUTTON_MIN_H) # ③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]) _standeefit_contract(main) # ---- 暂停层(迭代 17 卡③):组合根真实路径开层→结构断言→关闭。附只读性契约 #(R4——子树信号连接白名单巡检)与零动效契约(R5/P4 甲——零 AnimationPlayer/零 tween)。 # 巡检信号名清单=子树全控件的 get_signal_list() 全集(不放行任何未列名信号——新连接 # 一律红,须先入册审查);白名单={视图自身 _emit_closed, 组合根 _on_pause_closed}。 main._open_pause_layer() _settle() await _audit("pause", [main.pause_overlay]) _pause_readonly_contract(main) _pause_zero_motion_contract(main) main._on_pause_closed() _settle() if main.pause_overlay.visible: problems.append("[pause] 关闭后层未隐藏(模态栈单口失守)") 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.curio_overlay.open_pick("结构探针·三选一", [ {"id": "probe_heal", "text": "回血\n恢复 3 点"}, {"id": "probe_magic", "text": "爆裂轰击\n下一击 +5"}, {"id": "probe_pierce", "text": "穿甲\n无视护甲"}, ]) _settle() await _audit("curio_pick", [main.curio_overlay]) main.curio_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]) # ---- ⑦resume 专项:续玩停点恢复(2026-10-06 用户实机 main.gd:717 报错回归闸)---- # 三选一停点续玩分支在 level_started 之前同步 curio_choice_presented.emit(adventure_runner # resume 提前 return)——GP 总结 lambda 求值时 runner 尚未赋值,修复前 717 炸 null。 ProbeUiUtil.isolate_save(main, "smoke_ui_struct_probe_resume", true) main.save_manager.set_breakpoint(_resume_bp_fixture()) main._refresh_resume_button() main.runner = null # 全新启动等价态(真机=菜单直进续玩,无前置冒险——runner 从未赋值;探针此前推流会残留旧 runner) main._resume_from_menu() _settle(8) if not main._end_overlay.visible: problems.append("[resume] 续玩恢复后胜利总结面未开(三选一停点分支中断)") else: var found := {"ok": false} _find_label_text(main._end_overlay, "合计 24", found) if not found.ok: problems.append("[resume] 胜利总结面 GP 行未见快照合计「合计 24」(runner 缺位守卫缺位——717 回归)") var s: String = main._gp_summary_text({"gp_dropped": 0}) if not s.contains("合计 24"): problems.append("[resume] _gp_summary_text runner=null 守卫未取快照 gp(%s)" % s) # 恢复现场(resume 改写了 main 的 adventure/save 态——后续无屏依赖,仅为整洁) main._level_active = false # ---- 判定 ---- 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) # ⑦resume 夹具:最小合法断点快照——bonus_pending 非空=三选一停点分支(合计 gp=24 供守卫兜底断言) func _resume_bp_fixture() -> Dictionary: return { "master_seed": 42, "rng_counter": 0, "hero_hp": 20, "gp": 24, "keystone": 0, "level_index": 1, "bonus": {}, "shop_purchased": {}, "relics": [], "bonus_aux_carry": [], "keystone_drops": [], "bonus_taken": {}, "bonus_pending": {"candidates": [{"kind": "heal", "tier": "common"}], "payload": {"gp_dropped": 0}}, } # 递归找含目标子串的 Label(⑦resume 断言读数面) func _find_label_text(node: Node, needle: String, found: Dictionary) -> void: for ch in node.get_children(): if ch is Label and String(ch.text).contains(needle): found.ok = true return _find_label_text(ch, needle, found) 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 # ⑧pause 只读性契约(迭代 17 卡③ R4):暂停层子树全控件 × 游戏语义信号连接巡检——清单外零连接。 # 巡检信号名清单(任务书 v1.1「巡检信号名清单落码时列全进探针」)= 玩家交互可携带写意图的通道 # 全集(按钮按下/拨杆/文本/滑值/选项/键盘焦点)+ 本层外发信号 closed;引擎内部布局生命线信号 # 不计(child_order_changed/item_rect_changed/visibility_changed/size_flags_changed/ # minimum/maximum_size_changed 等系 Godot 容器系统对每控件自动建立的脏标记连接,与写通路无关 #——首跑实测踩中:全集巡检把布局生命线误判为非法连接)。清单外任何连接(尤其指向 # 购买/解锁/写入路径的)一律红,须先入册审查再改清单。 const _PAUSE_READONLY_SIGNALS: Array[String] = [ "pressed", "button_down", "button_up", "toggled", "text_changed", "text_submitted", "value_changed", "item_selected", "gui_input", "focus_entered", "focus_exited", "closed", ] func _pause_readonly_contract(main: Node) -> void: var overlay: Control = main.pause_overlay var stack: Array = [overlay] var conn_count := 0 while not stack.is_empty(): var n: Node = stack.pop_back() for sig_name in _PAUSE_READONLY_SIGNALS: for conn in n.get_signal_connection_list(sig_name): conn_count += 1 var cb: Callable = conn["callable"] var host: Object = cb.get_object() var m := String(cb.get_method()) if host == overlay and m == "_emit_closed": continue if host == main and m == "_on_pause_closed": continue problems.append("[pause][readonly] 非法信号连接 %s.%s → %s.%s(零写通路巡检——白名单=视图 _emit_closed/组合根 _on_pause_closed)" % [n.name, sig_name, host, m]) for ch in n.get_children(): stack.append(ch) if conn_count != 2: problems.append("[pause][readonly] 信号连接数=%d 应=2(关闭钮 pressed+closed 外发——多连接=写通路嫌疑)" % conn_count) # ⑨pause 零动效契约(迭代 17 卡③ R5/P4 甲——零动效直开直关路径存在断言): # 子树零 AnimationPlayer + 视图源码零 tween/动效包装调用(源码 token 扫描=ui_contrast 静态段同手法) func _pause_zero_motion_contract(main: Node) -> void: var overlay: Control = main.pause_overlay var stack: Array = [overlay] while not stack.is_empty(): var n: Node = stack.pop_back() if n is AnimationPlayer: problems.append("[pause][motion] 子树含 AnimationPlayer(R5 零动效破)") for ch in n.get_children(): stack.append(ch) var src := FileAccess.get_file_as_string("res://presentation/views/pause_overlay.gd") for token in ["create_tween", "UiKit.motion"]: if src.contains(token): problems.append("[pause][motion] 视图源码含「%s」(R5 零动效直开直关破——P4 甲)" % token) # ⑥standee 专项:B 区站桩内容矩形 ⊆ 252px 舞台矩形(内缩 8px)且不被裁切 # (UI 设计对齐批 N2 2026-10-06——名行基线离盒底 1px 同型高危写法返修回归闸; # 内容矩形=StandeeBody.content_rect(),最低绘制元素=底座下缘) func _standeefit_contract(main: Node) -> void: var st: Node = main.stage if st == null or st.hero_standee == null: problems.append("[standee] 舞台/主角站桩缺失(断言中断)") return var stage_gr: Rect2 = st.get_global_rect() var bodies: Array = [st.hero_standee] for c in st._cards: bodies.append(c) for body in bodies: if body == null or not is_instance_valid(body): continue var content := Rect2(st.world_to_shell(body.position), body.content_rect().size) if not stage_gr.encloses(content): problems.append("[standee] %s 内容矩形 %s 越出舞台矩形 %s(N2 站桩出框回归)" % [body.name if body.name != "" else "standee", content, stage_gr]) elif content.end.y > stage_gr.end.y - 8.0: problems.append("[standee] %s 内容底缘 %s 距舞台底 <8px(N2 纵向余量回归,舞台底=%s)" % [body.name if body.name != "" else "standee", content.end.y, stage_gr.end.y]) 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 + equal_height:同一直接父容器内 ≥2 可见按钮宽度/高度差 >1px #(钮高族 2026-10-06 用户令「对齐好、不要细」——主钮 26px 对常规 38px 不齐回归闸;纪律=ui_kit BUTTON_MIN_H) 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 h0: float = (g[0] as Control).size.y var diff := false var diff_h := false for b in g: if _wl(screen, "equal_width", b): diff = false diff_h = false break if absf((b as Control).size.x - w0) > 1.0: diff = true if absf((b as Control).size.y - h0) > 1.0: diff_h = 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)]) if diff_h: var names_h: Array = [] var heights: Array = [] for x in g: names_h.append(String((x as Control).name)) heights.append((x as Control).size.y) problems.append("[%s][equal_height] 同容器按钮高度不一(%s——%s)" % [screen, str(names_h), str(heights)]) # ③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()])