Files
gplan-2/game/smoke/ui_struct_probe.gd
T

515 lines
22 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] 营地入口按钮缺失(驱动中断)")
# ---- 生涯页(迭代 18 卡④):入口点击链(合成点击真路径=pressed.emit→组合根 _open_career)→
# 结构断言族→只读巡检(契约 3)+零动效契约+登记序/树序契约(P3 甲营地同档)+零跳转契约
#(提纲 P4 甲)→组合根真实路径关闭。----
var careerbtn: Button = ProbeUiUtil.find_button(main._start_overlay, "生涯", "prefix")
if careerbtn != null:
careerbtn.pressed.emit()
_settle()
if not main.career_overlay.visible or main._start_overlay.visible:
problems.append("[career] 生涯页未打开或开始层未隐藏(入口点击链断裂)")
await _audit("career", [main.career_overlay])
_career_readonly_contract(main)
_career_zero_motion_contract(main)
_career_registry_contract(main)
_career_nojump_contract(main)
main._on_career_closed()
_settle()
if main.career_overlay.visible or not main._start_overlay.visible:
problems.append("[career] 关闭后页未隐藏或开始层未恢复(模态栈单口失守)")
else:
problems.append("[career] 生涯入口按钮缺失(开始层 careerbtn)")
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.debug_overlay.seed_edit.text = "42" # 种子钉死(迭代 18 卡⑦——种子框默认改空后空框=非首局随机,结构采样确定性禁依赖默认分支)
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)
# ---- 生涯页契约族(迭代 18 卡④——任务书契约 3/4+提纲 P3/P4 甲)----
# ⑩career 只读性契约(契约 3——暂停层 ⑧ 同形制):生涯页子树全控件 × 游戏语义信号连接巡检——
# 清单外零连接。白名单={视图自身 _emit_closed〔返回钮〕, 组合根 _on_career_closed};页内任何
# 购买/导航/写入方向的新连接一律红,须先入册审查。
func _career_readonly_contract(main: Node) -> void:
var overlay: Control = main.career_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_career_closed":
continue
problems.append("[career][readonly] 非法信号连接 %s.%s → %s.%s(零写通路巡检——白名单=视图 _emit_closed/组合根 _on_career_closed)"
% [n.name, sig_name, host, m])
for ch in n.get_children():
stack.append(ch)
if conn_count != 2:
problems.append("[career][readonly] 信号连接数=%d 应=2(返回钮 pressed+closed 外发——多连接=写通路嫌疑)" % conn_count)
# ⑪career 零动效契约(暂停层 ⑨ 同形制——P4 甲直开直关)
func _career_zero_motion_contract(main: Node) -> void:
var overlay: Control = main.career_overlay
var stack: Array = [overlay]
while not stack.is_empty():
var n: Node = stack.pop_back()
if n is AnimationPlayer:
problems.append("[career][motion] 子树含 AnimationPlayer(零动效破)")
for ch in n.get_children():
stack.append(ch)
var src := FileAccess.get_file_as_string("res://presentation/views/career_overlay.gd")
for token in ["create_tween", "UiKit.motion"]:
if src.contains(token):
problems.append("[career][motion] 视图源码含「%s」(零动效直开直关破)" % token)
# ⑫career 登记/树序契约(P3 甲——登记序=营地同档:暂停层之后、确认弹窗之前;树序同步=addChild
# 同位。登记序=树序=z 序纪律〔缺陷⑤〕——两序任一失守即红)
func _career_registry_contract(main: Node) -> void:
var list: Array = main._modals._list
var ci: int = list.find(main.career_overlay)
var pi: int = list.find(main.pause_overlay)
var fi: int = list.find(main.confirm_dialog)
if ci < 0 or pi < 0 or fi < 0:
problems.append("[career][registry] 生涯页未入登记册(或暂停层/确认弹窗缺登记)")
return
if ci != pi + 1 or ci > fi:
problems.append("[career][registry] 登记序违约(应=暂停层后、确认弹窗前——P3 甲营地同档;pause=%d career=%d confirm=%d)" % [pi, ci, fi])
var ti: int = main.career_overlay.get_index()
if ti <= main.pause_overlay.get_index() or ti > main.confirm_dialog.get_index():
problems.append("[career][registry] 树序与登记序不同步(addChild 位应=暂停层后、确认弹窗前;tree_index=%d)" % ti)
# ⑬career 零跳转契约(提纲 P4 甲——页内零「前往他处」入口:除「返回」外任何导航钮=红)
func _career_nojump_contract(main: Node) -> void:
for nav in ["营地", "商店", "设置", "冒险", "继续上局", "退出", "前往"]:
if ProbeUiUtil.find_button(main.career_overlay, nav, "contains") != null:
problems.append("[career][nojump] 页内出现导航入口「%s」(零跳转违约——生涯纯只读)" % nav)
# ⑥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
# 滚动区跨界豁免(迭代 18 卡⑦——当局数据区块加高列表后实证:恰好一枚在 ScrollContainer
# 内时,其未剪裁内容矩形与滚区外控件的全局矩形交叠是滚动态样貌非布局缺陷;
# 同滚区两钮仍查——同一容器 stacking 真重叠照红)
if _in_scroll(a) != _in_scroll(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()])