722 lines
35 KiB
GDScript
722 lines
35 KiB
GDScript
# VERTICAL SLICE - NOT FOR PRODUCTION
|
||
# UI 结构探针:全 UI 屏结构断言一次跑完:逐屏实例化(菜单簇真驱动 + 冒险簇真流推进 + 兑付屏真实 open 面合成喂入),
|
||
# 断言族:
|
||
# ①overlap 同屏内可见未禁用按钮两两 global_rect 不相交(互压=红;层间模态盖底层系设计内——各屏采样以屏根为界天然排除)
|
||
# ②equal_width 同一直接父容器内 ≥2 可见按钮宽度差 >1px=红(容器纪律:组内等宽;钮高族
|
||
# equal_height——同容器高度差 >1px=红,纪律=ui_kit BUTTON_MIN_H)
|
||
# ③offscreen 可见件 global_rect 完全落在视口外=红
|
||
# ④zero_size 可见件宽或高 ≤0=红
|
||
# ⑤start 专项 标题屏左列(start_box)与右列(start_right)矩形不相交(双列契约——叠放缺陷回归闸)
|
||
# 豁免:WL 常量表按「屏名/规则名/件名」登记合法例外(登记须注理由于行尾注释)。
|
||
# 运行:Godot_console.exe --headless --path . res://probe/ui_struct_probe.tscn
|
||
extends Node
|
||
|
||
const ProbeUiUtil := preload("res://probe/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.ui.screens.start_overlay])
|
||
_start_columns_contract(main)
|
||
_modal_registry_contract(main)
|
||
|
||
main._open_settings()
|
||
_settle()
|
||
await _audit("settings", [main.ui.modals.settings_overlay])
|
||
main._on_settings_closed()
|
||
_settle()
|
||
|
||
if not ProbeUiUtil.open_camp_via_savesel(main, "smoke_ui_struct_probe_menu"):
|
||
problems.append("[camp] 营地入口缺失(选档层新档钮——mn9 新路径驱动中断)")
|
||
else:
|
||
_settle()
|
||
await _audit("camp", [main.ui.modals.camp_overlay])
|
||
# 营地灰字源 token 扫描(迭代36 wc4 静态半——源内零裸 UiKit.STONE;判别式=
|
||
# STONE 出现次数 == STONE_DEEP 出现次数,即每处 STONE 都带 _DEEP 后缀)
|
||
var camp_src := FileAccess.get_file_as_string("res://presentation/views/camp_overlay_view.gd")
|
||
if camp_src.count("UiKit.STONE") != camp_src.count("UiKit.STONE_DEEP"):
|
||
problems.append("[camp] camp_overlay_view.gd 存在裸 UiKit.STONE(非 _DEEP 后缀——wc4 源扫描)")
|
||
main._on_camp_closed()
|
||
_settle()
|
||
main._on_save_select_closed() # 关闭被栈还原的选档层(动线序中间态——卡④ 换代点)
|
||
_settle()
|
||
|
||
# ---- 生涯页:入口点击链(合成点击真路径=pressed.emit→组合根 _open_career)→
|
||
# 结构断言族→只读巡检(契约 3)+零动效契约+零跳转契约(提纲 P4 甲)→组合根真实路径关闭。
|
||
#(登记序/树序契约统一在全模态 _modal_registry_contract——boot 段跑)----
|
||
# 生涯页驱动=函数直调中间态(卡② mn9/mn10——careerbtn 已撤,卡④ 营地笔记本改挂前无 UI 路径)
|
||
main._open_career()
|
||
_settle()
|
||
if not main.ui.modals.career_overlay.visible or main.ui.screens.start_overlay.visible:
|
||
problems.append("[career] 生涯页未打开或开始层未隐藏(函数直调中间态断裂)")
|
||
await _audit("career", [main.ui.modals.career_overlay])
|
||
_career_readonly_contract(main)
|
||
_career_zero_motion_contract(main)
|
||
_career_nojump_contract(main)
|
||
main._on_career_closed()
|
||
_settle()
|
||
if main.ui.modals.career_overlay.visible or not main.ui.screens.start_overlay.visible:
|
||
problems.append("[career] 关闭后页未隐藏或开始层未恢复(模态栈单口失守)")
|
||
|
||
# ---- 选档界面(迭代 33 卡③):函数直调开层(「进入存档」钮归卡② mn3——点击链在卡② 承接),
|
||
# sl1 三槽呈现/sl2 摘要三项逐槽读数/sl3 摘要禁项/sl4 操作集二分/sl9 重启链不预选。
|
||
# 隔离:attach_slot_template 注入命名空间(卡① 注入口);夹具按槽直写——槽1 有档异值、
|
||
# 槽2 空槽、槽3 坏档(Q2 甲=按空槽呈现→操作集与空槽同形)。----
|
||
var ssel_tpl := "user://smoke_ui_struct_probe_savesel_slot_%d.json"
|
||
for i in range(1, 4):
|
||
for suf in ["", ".tmp", ".corrupt.bak"]:
|
||
var spp: String = ssel_tpl % i + suf
|
||
if FileAccess.file_exists(spp):
|
||
DirAccess.remove_absolute(spp)
|
||
ProbeUiUtil.isolate_save(main, "smoke_ui_struct_probe_savesel", true, false, true)
|
||
main.save_manager.slots.attach_slot_template(ssel_tpl) # 注入口(激活态回零——sl9 重启链同构态)
|
||
var f_s1 := FileAccess.open(ssel_tpl % 1, FileAccess.WRITE)
|
||
f_s1.store_string(JSON.stringify({"version": 7, "meta": {"keystone_balance": 555,
|
||
"adventure_total": 7, "adventure_cleared": 2, "tree_nodes": {}, "fork_sides": {}, "tree_struct": 2}}))
|
||
f_s1.close() # 槽1 有档异值
|
||
var f_s3 := FileAccess.open(ssel_tpl % 3, FileAccess.WRITE)
|
||
f_s3.store_string("broken json not a save{{{") # 槽3 坏档(窥视 readable=false→按空呈现)
|
||
f_s3.close()
|
||
if main.save_manager.slots.active_slot() != 0:
|
||
problems.append("[savesel] 开层前激活态非零(sl9 重启链——boot/注入面后应无激活)")
|
||
main._open_save_select()
|
||
_settle()
|
||
if not main.ui.modals.save_select_overlay.visible or main.ui.screens.start_overlay.visible:
|
||
problems.append("[savesel] 选档层未打开或开始层未隐藏(开层函数断裂)")
|
||
await _audit("savesel", [main.ui.modals.save_select_overlay])
|
||
_save_select_contract(main, ssel_tpl)
|
||
main._on_save_select_closed()
|
||
_settle()
|
||
if main.ui.modals.save_select_overlay.visible or not main.ui.screens.start_overlay.visible:
|
||
problems.append("[savesel] 关闭后层未隐藏或开始层未恢复(模态栈单口失守)")
|
||
|
||
main.ui.modals.confirm_dialog.open("结构探针采样", "确认")
|
||
_settle()
|
||
await _audit("confirm", [main.ui.modals.confirm_dialog])
|
||
main.ui.modals.confirm_dialog.close()
|
||
_settle()
|
||
|
||
main._show_debug_overlay()
|
||
_settle()
|
||
await _audit("debug", [main.ui.modals.debug_overlay])
|
||
main._close_debug_overlay()
|
||
_settle()
|
||
|
||
# ---- 冒险簇(真流推进:开冒险→地图选路态→选战斗节点→战斗 HUD)----
|
||
ProbeUiUtil.isolate_save(main, "smoke_ui_struct_probe_adv", true, false, true)
|
||
main.ui.modals.debug_overlay.seed_edit.text = "42" # 种子钉死(种子框默认为空=非首局随机,结构采样确定性禁依赖默认分支)
|
||
main._start_adventure_from_menu()
|
||
_settle(4)
|
||
await _audit("level_map", [main.ui.modals.level_screen])
|
||
|
||
var battle_id := _pick_battle_node(main)
|
||
if battle_id >= 0:
|
||
main.ui.modals.level_screen.route_chosen.emit(battle_id)
|
||
_settle(6)
|
||
_audit("battle_hud", [main.ui.battle.board_zone, main.ui.battle.info_panel, main.ui.battle.stage])
|
||
_standeefit_contract(main)
|
||
# ---- 状态面板:组合根真实路径开层→结构断言→关闭。附只读性契约
|
||
#(R4——子树信号连接白名单巡检)与零动效契约(R5/P4 甲——零 AnimationPlayer/零 tween)。
|
||
# 巡检信号名清单=子树全控件的 get_signal_list() 全集(不放行任何未列名信号——新连接
|
||
# 一律红,须先入册审查);白名单={视图自身 _emit_closed, 组合根 _on_status_closed}。
|
||
main._open_status_panel()
|
||
_settle()
|
||
await _audit("status", [main.ui.modals.status_panel])
|
||
_status_readonly_contract(main)
|
||
_status_zero_motion_contract(main)
|
||
main._on_status_closed()
|
||
_settle()
|
||
if main.ui.modals.status_panel.visible:
|
||
problems.append("[status] 关闭后层未隐藏(模态栈单口失守)")
|
||
|
||
# ---- 图例层(旧模态、首过结构族):ap_bar legend_toggled
|
||
# 信号真路径开(board_zone 信号→组合根 _on_legend_toggled→_open_modal)→结构断言族→
|
||
# closed 信号真路径关(close()→组合根 _on_modal_closed)。
|
||
main.ui.battle.board_zone.legend_toggled.emit()
|
||
_settle()
|
||
if not main.ui.modals.legend_overlay.visible:
|
||
problems.append("[legend] 图例层未打开(legend_toggled 路径中断)")
|
||
await _audit("legend", [main.ui.modals.legend_overlay])
|
||
main.ui.modals.legend_overlay.close()
|
||
_settle()
|
||
if main.ui.modals.legend_overlay.visible:
|
||
problems.append("[legend] 关闭后层未隐藏(模态栈单口失守)")
|
||
else:
|
||
problems.append("[battle_hud] 候选中无战斗节点(驱动中断——候选=%s)" % str(main.ui.modals.level_screen._cand_ids))
|
||
|
||
# ---- 兑付屏(真实 open 面 + 合成最小数据——结构断言只看矩形/宽度/可见性)----
|
||
main.ui.modals.end_overlay.open_victory("结构探针·胜利演算", "返回", Rect2(200, 200, 400, 300), {"gp_summary": "GP 0"})
|
||
_settle()
|
||
await _audit("battle_end", [main.ui.modals.end_overlay])
|
||
main.ui.modals.end_overlay.close()
|
||
_settle()
|
||
|
||
# ---- 节点结算层(旧模态、首过结构族):rest 兑付真路径开
|
||
#(组合根 _open_modal 登记册+show_rest 合成喂入)→结构断言族→continue_pressed 信号真路径关
|
||
#(press_continue→组合根 _on_node_continue→_close_modal)。
|
||
main.ui.modals.flow.open(main.ui.modals.node_overlay)
|
||
main.ui.modals.node_overlay.show_rest(3, 15, 20)
|
||
_settle()
|
||
if not main.ui.modals.node_overlay.visible:
|
||
problems.append("[node] 节点结算层未打开(rest 兑付路径中断)")
|
||
await _audit("node_overlay", [main.ui.modals.node_overlay])
|
||
main.ui.modals.node_overlay.press_continue()
|
||
_settle()
|
||
if main.ui.modals.node_overlay.visible:
|
||
problems.append("[node] 关闭后层未隐藏(continue_pressed 路径中断)")
|
||
|
||
main.ui.modals.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.ui.modals.curio_overlay])
|
||
main.ui.modals.curio_overlay.visible = false
|
||
_settle()
|
||
|
||
main.ui.modals.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.ui.modals.shop_overlay])
|
||
|
||
# ---- 术语传导:货架卡与三选一卡片的玩家面文案(物品名=专长获取、分类=专长凭证)
|
||
main.ui.modals.shop_overlay.open_shelf([
|
||
{"kind_id": "feat_ticket", "display_name": "专长获取", "value": 0, "price": 5},
|
||
{"kind_id": "heal", "display_name": "应急回复", "value": 6, "price": 5},
|
||
], 20)
|
||
_settle()
|
||
var ticket_row: Dictionary = main.ui.modals.shop_overlay._shelf_rows[0]
|
||
if String(main.db.run.shop.pool_row("feat_ticket").get("display_name", "")) != "专长获取":
|
||
problems.append("[shop_term] shop.tres 专长凭证件名未换代(现=%s)" % String(main.db.run.shop.pool_row("feat_ticket").get("display_name", "")))
|
||
if ticket_row.card.name_l.text != "专长获取":
|
||
problems.append("[shop_term] 专长凭证件名未用物品名「专长获取」(现=%s)" % ticket_row.card.name_l.text)
|
||
if ticket_row.btn.get_parent() != ticket_row.card.body or ticket_row.btn.get_index() >= ticket_row.card.foot_l.get_index():
|
||
problems.append("[shop_term] 购买钮未落在底注之前(add_footer 落位失败)")
|
||
if ticket_row.card.desc_l.text != "开一次专长三选一":
|
||
problems.append("[shop_term] 专长凭证件描述未用规范流程句(现=%s)" % ticket_row.card.desc_l.text)
|
||
if ticket_row.card.foot_l.text != "专长凭证":
|
||
problems.append("[shop_term] 专长凭证件分类标注未用「专长凭证」(现=%s)" % ticket_row.card.foot_l.text)
|
||
var heal_row: Dictionary = main.ui.modals.shop_overlay._shelf_rows[1]
|
||
if heal_row.card.desc_l.text != "回复 +6(本冒险)":
|
||
problems.append("[shop_term] 回复件描述漏了 heal 词表(现=%s)" % heal_row.card.desc_l.text)
|
||
if heal_row.card.foot_l.text != "回复":
|
||
problems.append("[shop_term] 回复件分类标注落到英文 kind(现=%s)" % heal_row.card.foot_l.text)
|
||
var ticket_texts: Array = main._bonus._curio_card_texts([{"kind": "feat_ticket", "tier": "excellent"}])
|
||
var ticket_text := String((ticket_texts[0] as Dictionary).get("text", ""))
|
||
var ticket_lines := ticket_text.replace("\r\n", "\n").split("\n")
|
||
if ticket_lines.size() < 2 or ticket_lines[0] != "专长获取" or not ticket_lines[1].contains("专长三选一"):
|
||
problems.append("[curio_term] 专长凭证三选一卡未用物品名/规范流程句(现=%s)" % ticket_text)
|
||
if ticket_text.contains("门票") or ticket_text.contains("T5") or ticket_text.contains("持有集") or ticket_text.contains("奇物三选一"):
|
||
problems.append("[curio_term] 专长凭证三选一卡残留退役词/内部任务号(现=%s)" % ticket_text)
|
||
main.ui.modals.shop_overlay.visible = false
|
||
_settle()
|
||
|
||
# ---- ⑦resume 专项:续玩停点恢复(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.run_scope.runner = null # 全新启动等价态(真机=菜单直进续玩,无前置冒险——runner 从未赋值;探针此前推流会残留旧 runner)
|
||
main._resume_from_menu()
|
||
_settle(8)
|
||
if not main.ui.modals.end_overlay.visible:
|
||
problems.append("[resume] 续玩恢复后胜利总结面未开(三选一停点分支中断)")
|
||
else:
|
||
var found := {"ok": false}
|
||
_find_label_text(main.ui.modals.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.run_scope.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": [],
|
||
"graph_gen_v2": 1, # 生成图换代代差标记键(缺键=换代前旧断点,resume_adventure 显式拒绝)
|
||
"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.ui.modals.level_screen
|
||
if ls._map._runner == null:
|
||
return -1
|
||
for nd in ls._map._runner.nodes():
|
||
if String(nd.get("type", "")) in ["battle", "boss"]:
|
||
var nid: int = ls._map._runner.node_id_of(nd)
|
||
if nid in ls._cand_ids:
|
||
return nid
|
||
return -1
|
||
|
||
|
||
# ⑧status 只读性契约(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 _status_readonly_contract(main: Node) -> void:
|
||
var overlay: Control = main.ui.modals.status_panel
|
||
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_status_closed":
|
||
continue
|
||
problems.append("[status][readonly] 非法信号连接 %s.%s → %s.%s(零写通路巡检——白名单=视图 _emit_closed/组合根 _on_status_closed)"
|
||
% [n.name, sig_name, host, m])
|
||
for ch in n.get_children():
|
||
stack.append(ch)
|
||
if conn_count != 2:
|
||
problems.append("[status][readonly] 信号连接数=%d 应=2(关闭钮 pressed+closed 外发——多连接=写通路嫌疑)" % conn_count)
|
||
|
||
|
||
# ⑨status 零动效契约(R5/P4 甲——零动效直开直关路径存在断言):
|
||
# 子树零 AnimationPlayer + 视图源码零 tween/动效包装调用(源码 token 扫描=ui_contrast 静态段同手法)
|
||
func _status_zero_motion_contract(main: Node) -> void:
|
||
var overlay: Control = main.ui.modals.status_panel
|
||
var stack: Array = [overlay]
|
||
while not stack.is_empty():
|
||
var n: Node = stack.pop_back()
|
||
if n is AnimationPlayer:
|
||
problems.append("[status][motion] 子树含 AnimationPlayer(R5 零动效破)")
|
||
for ch in n.get_children():
|
||
stack.append(ch)
|
||
var src := FileAccess.get_file_as_string("res://presentation/views/status_panel.gd")
|
||
for token in ["create_tween", "UiKit.motion"]:
|
||
if src.contains(token):
|
||
problems.append("[status][motion] 视图源码含「%s」(R5 零动效直开直关破——P4 甲)" % token)
|
||
|
||
|
||
# ---- 生涯页契约族(需求卡片契约 3/4+提纲 P3/P4 甲)----
|
||
|
||
# ⑩career 只读性契约(契约 3——状态面板 ⑧ 同形制):生涯页子树全控件 × 游戏语义信号连接巡检——
|
||
# 清单外零连接。白名单={视图自身 _emit_closed〔返回钮〕, 组合根 _on_career_closed};页内任何
|
||
# 购买/导航/写入方向的新连接一律红,须先入册审查。
|
||
func _career_readonly_contract(main: Node) -> void:
|
||
var overlay: Control = main.ui.modals.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.ui.modals.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)
|
||
|
||
|
||
# ⑫模态登记/树序全量合同(V1——career 专属登记序契约推广到全部模态+屏壳;
|
||
# 判据=main.ui.LAYER_CONTRACT 单一事实源:登记册序==合同模态序、直接子层树序==合同序逐对相等。
|
||
# 两特例按合同数据表达:level_screen 宿住冒险屏壳(树序随宿主=全模态之下,遮挡语义归模态栈
|
||
# screen_state 登记)、camp_overlay 成员与 camp_screen 屏壳同槽(屏壳树序代言成员登记位)。
|
||
# _career_registry_contract 的断言(career=状态面板后/确认弹窗前、树序同步)由本全量合同覆盖)
|
||
# ⑭选档界面契约(卡③ sl1/sl2/sl3/sl4/sl9——呈现面全部来自喂入数据,视图零自判)
|
||
func _save_select_contract(main: Node, tpl: String) -> void:
|
||
var sso: Node = main.ui.modals.save_select_overlay
|
||
# sl1 三槽呈现:槽卡计数==3+零滚动容器(禁滚动长列表形态——契约 1)
|
||
var rows: VBoxContainer = sso._rows_box
|
||
var live_rows := 0
|
||
for c in rows.get_children():
|
||
if c is Control and not c.is_queued_for_deletion():
|
||
live_rows += 1
|
||
if live_rows != 3:
|
||
problems.append("[savesel] 槽卡行数=%d 应=3(sl1 三槽呈现)" % live_rows)
|
||
var scroll_count := _count_scroll(sso)
|
||
if scroll_count > 0:
|
||
problems.append("[savesel] 子树出现滚动容器 ×%d(sl1 禁滚动长列表)" % scroll_count)
|
||
# sl4 操作集二分(渲染面实测——扫真实按钮文本,不走簿记缝):槽1 有档=进档+删档零新档;
|
||
# 槽2 空=新档零进档删档;槽3 坏档=按空槽呈现(Q2 甲)
|
||
var occ: Dictionary = sso._occupied_flags
|
||
if not (bool(occ.get(1, false)) and not bool(occ.get(2, false)) and not bool(occ.get(3, false))):
|
||
problems.append("[savesel] 槽态判定失守(occ=%s 应=1有/2空/3坏按空)〔sl4〕" % str(occ))
|
||
var row_idx := 0
|
||
for row_c in rows.get_children():
|
||
if not (row_c is Control) or row_c.is_queued_for_deletion():
|
||
continue
|
||
row_idx += 1
|
||
var btn_texts: Array[String] = []
|
||
_collect_button_texts(row_c, btn_texts)
|
||
var has_load := btn_texts.has("进档")
|
||
var has_del := btn_texts.has("删档")
|
||
var has_new := btn_texts.has("新档")
|
||
if row_idx == 1:
|
||
if not (has_load and has_del and not has_new):
|
||
problems.append("[savesel] 行1 操作集失守(有档槽应=进档+删档、零新档——渲染面实测)〔sl4〕")
|
||
else:
|
||
if not (has_new and not has_load and not has_del):
|
||
problems.append("[savesel] 行%d 操作集失守(空/坏槽应=仅新档、零进档删档含置灰形态)〔sl4〕" % row_idx)
|
||
# sl2 摘要三项逐槽读数:槽1 喂入值逐字(钥石 555/冒险成功 2 局/冒险 7 局)
|
||
var sum1: Label = sso._summary_labels.get(1)
|
||
if sum1 == null or sum1.text != "冒险成功 2 局 · 冒险 7 局":
|
||
problems.append("[savesel] 槽1 摘要读数失守(got=%s 应=冒险成功 2 局 · 冒险 7 局)〔sl2〕" % (sum1.text if sum1 else "null"))
|
||
var all_texts: Array[String] = []
|
||
_collect_labels(sso, all_texts)
|
||
var has_keystone := false
|
||
for t in all_texts:
|
||
if t == "钥石 555":
|
||
has_keystone = true
|
||
if not has_keystone:
|
||
problems.append("[savesel] 槽1 钥石徽读数缺失(应=钥石 555)〔sl2〕")
|
||
# sl3 摘要禁项:断点/种子/「继续」/停点/「 关」(关数形——摘要「冒险成功 X 局 · 冒险 Y 局」不含「关」字)零命中
|
||
#(裁决 4——零未完冒险读数;词形覆盖=对照表 sl3 缺陷形态「断点关数拼进槽卡」的拦截面)
|
||
for needle in ["断点", "种子", "继续", "停点", " 关"]:
|
||
for t in all_texts:
|
||
if t.contains(needle):
|
||
problems.append("[savesel] 摘要禁项命中「%s」(label=「%s」)〔sl3〕" % [needle, t])
|
||
# sl9 重启链不预选:视图零选中态字段+喂入数据重建(无「已选」标记文本)
|
||
for t in all_texts:
|
||
if t.contains("已选"):
|
||
problems.append("[savesel] 出现预选标记文本「%s」(sl9 Q1 甲不预选)" % t)
|
||
|
||
|
||
func _count_scroll(node: Node) -> int:
|
||
var count := 1 if node is ScrollContainer else 0
|
||
for c in node.get_children():
|
||
count += _count_scroll(c)
|
||
return count
|
||
|
||
|
||
func _collect_button_texts(node: Node, out: Array[String]) -> void:
|
||
if node is Button and node.visible:
|
||
out.append(String(node.text))
|
||
for c in node.get_children():
|
||
_collect_button_texts(c, out)
|
||
|
||
|
||
func _collect_labels(node: Node, out: Array[String]) -> void:
|
||
if node is Label:
|
||
out.append(String(node.text))
|
||
for c in node.get_children():
|
||
_collect_labels(c, out)
|
||
|
||
|
||
func _modal_registry_contract(main: Node) -> void:
|
||
var list: Array = main.ui.modals.flow.stack._list
|
||
# ① 登记册序==合同模态序(逐位点名——合同是登记序唯一事实源)
|
||
var contract_modals: Array = []
|
||
for entry: Dictionary in main.ui.LAYER_CONTRACT:
|
||
if String(entry["kind"]) != "modal":
|
||
continue
|
||
var n: Node = main.ui.node(String(entry["key"]))
|
||
if n == null:
|
||
problems.append("[registry] 合同层缺失: %s〔装配生命批 V1〕" % String(entry["key"]))
|
||
continue
|
||
contract_modals.append(n)
|
||
if list.size() != contract_modals.size():
|
||
problems.append("[registry] 登记册层数=%d 应=%d(合同模态数)〔装配生命批 V1〕" % [list.size(), contract_modals.size()])
|
||
for i in contract_modals.size():
|
||
if i >= list.size():
|
||
break
|
||
if list[i] != contract_modals[i]:
|
||
problems.append("[registry] 登记序第 %d 位与合同不一致(登记=%s 合同=%s)〔装配生命批 V1〕" % [
|
||
i, list[i].name, contract_modals[i].name])
|
||
# ② 直接子层树序==合同序逐对相等(屏壳/模态/全屏态/非视觉节点全链;宿住层随宿主不参与)
|
||
var prev: Node = null
|
||
for entry: Dictionary in main.ui.LAYER_CONTRACT:
|
||
if entry.has("host"):
|
||
continue
|
||
var node: Node = main.ui.node(String(entry["shell"]) if entry.has("shell") else String(entry["key"]))
|
||
if node == null:
|
||
problems.append("[registry] 合同层缺失: %s〔装配生命批 V1〕" % String(entry["key"]))
|
||
continue
|
||
if prev != null and node.get_index() <= prev.get_index():
|
||
problems.append("[registry] 树序违约(合同邻接对 %s→%s:%d≤%d)〔装配生命批 V1〕" % [
|
||
prev.name, node.name, node.get_index(), prev.get_index()])
|
||
prev = node
|
||
# ③ 宿住层特例:level_screen 宿主=冒险屏壳且在全直接子模态之下(遮挡语义归模态栈 screen_state)
|
||
var lv: Node = main.ui.modals.level_screen
|
||
if lv.get_parent() != main.ui.screens.adventure_screen:
|
||
problems.append("[registry] level_screen 宿主应=冒险屏壳(实测 %s)〔装配生命批 V1〕" % lv.get_parent().name)
|
||
elif lv.get_parent().get_index() >= main.ui.modals.legend_overlay.get_index():
|
||
problems.append("[registry] 冒险屏壳应在全模态之下(宿主 idx=%d legend idx=%d)〔装配生命批 V1〕" % [
|
||
lv.get_parent().get_index(), main.ui.modals.legend_overlay.get_index()])
|
||
# ④ 营地同槽特例:camp_screen 屏壳树序夹商店层与设置层之间(成员 camp_overlay 登记位代言)
|
||
var cs: Node = main.ui.screens.camp_screen
|
||
if cs.get_index() <= main.ui.modals.shop_overlay.get_index() or cs.get_index() >= main.ui.modals.settings_overlay.get_index():
|
||
problems.append("[registry] camp_screen 屏壳树序应夹商店与设置层之间(idx=%d shop=%d settings=%d)〔装配生命批 V1〕" % [
|
||
cs.get_index(), main.ui.modals.shop_overlay.get_index(), main.ui.modals.settings_overlay.get_index()])
|
||
|
||
|
||
# ⑬career 零跳转契约(提纲 P4 甲——页内零「前往他处」入口:除「返回」外任何导航钮=红)
|
||
func _career_nojump_contract(main: Node) -> void:
|
||
for nav in ["营地", "商店", "设置", "冒险", "继续上局", "退出", "前往"]:
|
||
if ProbeUiUtil.find_button(main.ui.modals.career_overlay, nav, "contains") != null:
|
||
problems.append("[career][nojump] 页内出现导航入口「%s」(零跳转违约——生涯纯只读)" % nav)
|
||
|
||
|
||
# ⑥standee 专项:B 区站桩内容矩形 ⊆ 252px 舞台矩形(内缩 8px)且不被裁切
|
||
# (名行基线离盒底 1px 同型高危写法返修回归闸;
|
||
# 内容矩形=StandeeBody.content_rect(),最低绘制元素=底座下缘)
|
||
func _standeefit_contract(main: Node) -> void:
|
||
var st: Node = main.ui.battle.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
|
||
# 滚动区跨界豁免(实证:当局数据区块加高列表后,恰好一枚在 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
|
||
#(钮高族判据「对齐好、不要细」——主钮 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.ui.screens.start_overlay.find_child("start_box", true, false)
|
||
var right: Control = main.ui.screens.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()])
|