# 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.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]) # ---- 判定 ---- 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()])