Files
gplan-2/game/smoke/ui_struct_probe.gd
T
DKChan d44fdd54c5 暂停层组B:两屏入口角标+has_open守卫+四探针断言+冒烟数据一致性(迭代17卡③T2)
- modal_stack.gd 新增 has_open_excluding(skip):排除式开态查询(地图屏系登记册模态成员=常态
  屏态,其上角标必须可点;只读查询零副作用,登记序/开合记账语义零改动)
- board_zone_view.gd 战斗屏 HUD 右下角+level_map_screen.gd 地图屏顶栏 GP 徽右——各一枚「暂停」
  角标(debug 角标同形制:UiKit.button 紧凑豁免宽度基准+FS_HINT 字号降档;可见性/置灰归组合根,
  信号连接归组合根 ADR-0001;非对局期显式不可见防菜单误现——P1 丁)
- main.gd 接线:两角标 pressed→_on_pause_badge_pressed(守卫兜底处理器:他层开着即拒开——
  常态防线=置灰,探针 emit 绕过 disabled 时由此兜住;地图屏开态经 has_open_excluding 不计自身)
  +_sync_pause_entry 单一同步点(可见性=_level_active∧屏态;置灰=has_open)挂模态开合单口与
  屏级互斥口/进局/离局/续玩五路径
- ui_struct_probe:pause 结构断言+只读性契约(游戏语义信号清单巡检——pressed/toggled/text/
  value/item/gui_input/focus/closed 十二名,白名单外零连接,合法连接恰 2=关闭钮+closed 外发)
  +零动效契约(子树零 AnimationPlayer+源码零 create_tween——R5/P4 甲)【首跑 L1 修正:全集
  巡检把 Godot 容器布局生命线连接(child_order_changed/item_rect_changed 等引擎自动脏标记)
  误判非法——收敛为信号名清单,任务书 v1.1「巡检信号名清单落码列全」本意】
- ui_input_probe case⑯:暂停层输入锁语义(角标真点击 press+release 开层→锁期挖掘零穿透→
  「继续」关层→同格挖掘重新到达;十七断言 PASS)【首跑 L1 修正:域恰 2 枚时 flag_t 带旗+dig_t
  揭开后零无旗格可取——case 挪全 case 之尾+前沿移位扩大可挖域兜底(板面扰动零波及)】
- ui_click_probe:战斗屏角标开合/守卫断言(图例开着=置灰+emit 兜底拒开)+地图屏角标一次性
  case(选路前开/关,地图屏选路态零扰动);ui_contrast_probe 增钥石徽深墨/烛光黄对(12 对全绿)
- smoke_all._pause_overlay_projection:暂停层投影==MetaProgression 访问器逐项(余额徽/锻造三行/
  工坊六行解锁态/天赋树概览——隔离档 smoke_meta 复用,+5 check)
- 任务书回填 T0 基线注记(冒烟 783/0·run_all 17 套·交集核对零命中,HEAD=44328b1 时点)
- T2 闸读数:冒烟 788/0(基线 783/0,+5 本批)· ui_input 十七断言 PASS · ui_struct 全绿 ·
  ui_contrast 12 对 PASS · ui_click 问题=0 · ui_layout_probe 退出码 0;纯呈现零数值=零 MC
2026-10-06 22:38:36 +08:00

419 lines
17 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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()])