Files
gplan-2/game/probe/probe_ui_util.gd
T
DKChan 886368684c Merge branch 'master' into qa-harness
# Conflicts:
#	game/probe/probe_ui_util.gd
2026-11-06 01:52:17 +08:00

276 lines
12 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_probe / ui_click_probe / shot_probe / fx_shot_probe 四探针的树寻址样板
# 与隔离样板(各探针隔离文件名后缀 tag 保持不变)。
# 用法:探针侧 `const ProbeUiUtil := preload("res://probe/probe_ui_util.gd")` 后静态调用
#(探针载体含 --script 场景两种,均无全局标识符——禁 class_name 裸引用)。
extends RefCounted
const SaveManagerScript := preload("res://core/save_manager.gd")
const MetaProgressionScript := preload("res://core/meta_progression.gd")
const SettingsStoreScript := preload("res://presentation/settings_store.gd")
# ── 树寻址(递归只读——按钮/标签句柄未存组合根成员的探针专用面) ──────────────
# 匹配模式参数化(历史两种语义,禁混用禁静默改变):
# mode="prefix" = text.begins_with(needle)(ui_probe / ui_click_probe 历史语义)
# mode="contains" = text.contains(needle)(shot_probe / fx_shot_probe 历史语义)
# 调用点替换时逐一核对原语义后选参。
static func find_button(root: Node, needle: String, mode: String = "prefix") -> Button:
if root == null:
return null
if root is Button and _text_match(String(root.text), needle, mode):
return root
for child in root.get_children():
var found := find_button(child, needle, mode)
if found != null:
return found
return null
# CheckButton 同款寻址(ui_click_probe 语义 = contains——断言设置面旧入口不在)
static func find_check_button(root: Node, needle: String) -> CheckButton:
if root == null:
return null
if root is CheckButton and String(root.text).contains(needle):
return root
for child in root.get_children():
var found := find_check_button(child, needle)
if found != null:
return found
return null
# Label 按文案包含寻址(shot_probe GP 徽章读数锚)
static func find_label(root: Node, text_fragment: String) -> Label:
if root == null:
return null
if root is Label and String(root.text).contains(text_fragment):
return root
for child in root.get_children():
var found := find_label(child, text_fragment)
if found != null:
return found
return null
# 控件实测几何(shot_probe checker 文字框取样坐标源——禁硬编码像素,分辨率档位自适应)
static func label_rect(root: Node, text_fragment: String) -> Dictionary:
if root == null:
return {}
if root is Label and String(root.text).contains(text_fragment):
return {"x": root.global_position.x, "y": root.global_position.y,
"w": root.size.x, "h": root.size.y}
for child in root.get_children():
var r := label_rect(child, text_fragment)
if not r.is_empty():
return r
return {}
# 按脚本名寻址(resource_path contains——shot_probe hero 面板锚)
static func find_by_script(root: Node, script_name: String) -> Node:
if root == null:
return null
var s: Script = root.get_script()
if s != null and String(s.resource_path).contains(script_name):
return root
for child in root.get_children():
var r := find_by_script(child, script_name)
if r != null:
return r
return null
static func _text_match(text: String, needle: String, mode: String) -> bool:
return text.begins_with(needle) if mode == "prefix" else text.contains(needle)
# ── 存档/设置/元进度三面隔离(探针禁碰真实档 user://save.json——SaveManager 路径注入纪律) ──
# tag = 隔离档名(不带扩展名,如 "smoke_input_probe_save"——各探针后缀保持不变);先删后建防陈旧档泄入。
# mark_first_done = 对齐真实档游玩史口径(模板抽签路径不变);with_settings = 连设置面换注
#(隔离 cfg 名 = <tag>_settings.cfg,与被收口探针原文件名逐一同名);
# 「继续上局」按钮重刷;swap_save = 是否换注全局 SaveManager(营地先行段只换 meta 侧不换 SaveManager)。
# 返回隔离 SaveManager 实例(调用方常需后续直读余额/重读档)。
# SaveManager/MetaProgression 是 autoload 单例——隔离面走单例原地重置
#(attach_path 沿 RngService.set_master_seed 先例;MetaProgression 持 save 引用透传,
# swap_save=false 分支换 meta 侧引用=独立 iso 实例,只换 meta 不换 save_manager)。
static func isolate_save(main, tag: String, mark_first_done: bool = false, with_settings: bool = false,
swap_save: bool = true): # refresh_resume 参数已随卡② _refresh_resume_button 删除退役(mn7)
var save_path := "user://%s.json" % tag
for p in [save_path, save_path + ".tmp", "user://%s_settings.cfg" % tag]:
if FileAccess.file_exists(p):
DirAccess.remove_absolute(p)
var iso = SaveManagerScript.new(save_path)
iso.load_from_disk()
if mark_first_done:
iso.meta_state.mark_first_adventure_done()
# --script 模式无裸 autoload 标识符(run_all.gd 头注)——经 main 节点路径解析
var sm = main.get_node("/root/SaveManager")
if swap_save:
sm.slots.attach_path(save_path)
if mark_first_done:
sm.meta_state.mark_first_adventure_done()
iso = sm
else:
main.get_node("/root/MetaProgression").save = iso # 只换 meta 侧(营地段语义)——SaveManager 全局不动
if with_settings:
main.settings_store = SettingsStoreScript.new("user://%s_settings.cfg" % tag)
return iso
# ---- 真输入点击承载(test-conventions §8.3「真实入口」判定口径)----
# 合成鼠标事件经 Viewport.push_input 进引擎输入管线(hitbox/命中测试参与),press+release 成对
#(BaseButton pressed 信号走 release 沿——ui_input_probe :717-733 先例式)。
# 信号直发(pressed.emit())只许标「接线验证」,不得充当真实入口断言的驱动。
# coverage_hook = 真输入覆盖登记件(probe_coverage_recorder.gd)挂点:本件三个 push 口是探针
# 真输入的单点通道,push 前通知登记件置旗标——命中记账只认旗标在位期间(信号直发不入账)。
static var coverage_hook: Object = null
static func push_click(main, ctrl: Control) -> void:
push_click_at(main, ctrl.get_global_rect().get_center())
static func push_click_at(main, pos: Vector2) -> void:
if coverage_hook != null:
coverage_hook.mark_real_input()
var down := InputEventMouseButton.new()
down.position = pos
down.global_position = pos
down.button_index = MOUSE_BUTTON_LEFT
down.pressed = true
main.get_viewport().push_input(down, true)
var up := InputEventMouseButton.new()
up.position = pos
up.global_position = pos
up.button_index = MOUSE_BUTTON_LEFT
up.pressed = false
main.get_viewport().push_input(up, true)
# 悬停真输入(tooltip/悬停态断言的承载——motion 进管线触发 mouse_entered/exited 沿)
static func push_motion(main, pos: Vector2) -> void:
if coverage_hook != null:
coverage_hook.mark_real_input()
var mv := InputEventMouseMotion.new()
mv.position = pos
mv.global_position = pos
main.get_viewport().push_input(mv, true)
# 滚轮真输入(dir=+1 向下翻 / -1 向上翻——折叠区行滚入视野的唯一真输入通道)。
# wheel 合成必须 press+release 成对:硬件滚轮虽无 release 沿,但引擎 GUI 会把按键事件的
# press 记为鼠标焦点且等 release 才释放——只发 press 会把焦点钉死在按下点控件上,
# 后续左键点击全被路由给焦点控件(实证:滚轮后 fork/返回营地两钮点击全部失效)。
static func push_wheel(main, pos: Vector2, dir: int) -> void:
if coverage_hook != null:
coverage_hook.mark_real_input()
var idx := MOUSE_BUTTON_WHEEL_DOWN if dir > 0 else MOUSE_BUTTON_WHEEL_UP
var w := InputEventMouseButton.new()
w.position = pos
w.global_position = pos
w.button_index = idx
w.pressed = true
main.get_viewport().push_input(w, true)
var w2 := InputEventMouseButton.new()
w2.position = pos
w2.global_position = pos
w2.button_index = idx
w2.pressed = false
main.get_viewport().push_input(w2, true)
# 窗板定位(窗壳视图子树固定链:遮罩 ColorRect → CenterContainer → 窗板 PanelContainer——
# screen_flow_probe._window_panel 同式,探针族共用单点)
static func window_panel(view: Control) -> PanelContainer:
if view == null or view.get_child_count() == 0:
return null
var dim := view.get_child(0)
if dim.get_child_count() == 0:
return null
var center := dim.get_child(0)
if center.get_child_count() == 0 or not (center.get_child(0) is PanelContainer):
return null
return center.get_child(0) as PanelContainer
# 折叠区行滚入窗板可视区(真输入滚轮驱动;滚动不重建行,rect 随滚更新)。
# 判据:钮 rect 整体落在窗板内(上缘留 8px、下缘让开底栏 64px——返回营地钮带高约 56px)。
# 保险丝 60 次滚叶防异常内容死循环;窗板缺失或钮句柄失效即返回(调用方前置断言兜底)。
static func scroll_button_into_view(main, view: Control, btn: Control) -> void:
var panel := window_panel(view)
if panel == null or btn == null:
return
var guard := 60
while guard > 0:
var r := btn.get_global_rect()
var pr := panel.get_global_rect()
if r.position.y >= pr.position.y + 8.0 and r.end.y <= pr.end.y - 64.0:
return
var down := r.end.y > pr.end.y - 64.0
push_wheel(main, pr.get_center(), 1 if down else -1)
guard -= 1
await main.get_tree().process_frame
# 真输入拖拽沿(press→motion×steps→release 成对经 push_input 进引擎输入管线——卷轴视口批
# R3/R5/R6 驱动沿;沿 push_click_at 同式。relative 按步显式置值——合成事件的位移量由事件
# 自身承载,被测面按 relative 逐帧平移)
static func push_drag_at(main, pos: Vector2, delta: Vector2, steps: int = 4) -> void:
var down := InputEventMouseButton.new()
down.position = pos
down.global_position = pos
down.button_index = MOUSE_BUTTON_LEFT
down.pressed = true
main.get_viewport().push_input(down, true)
for i in range(1, steps + 1):
var cur := pos + delta * float(i) / float(steps)
var mv := InputEventMouseMotion.new()
mv.position = cur
mv.global_position = cur
mv.relative = delta / float(steps)
main.get_viewport().push_input(mv, true)
var up := InputEventMouseButton.new()
up.position = pos + delta
up.global_position = pos + delta
up.button_index = MOUSE_BUTTON_LEFT
up.pressed = false
main.get_viewport().push_input(up, true)
# 真输入滚轮沿(单档滚轮事件经 push_input——卷轴视口批 R4 驱动沿;方向映射在被测面,
# 本工具只发原始滚轮档)。press+release 成对(同 push_wheel 注——只发 press 钉死鼠标焦点)。
static func push_wheel_at(main, pos: Vector2, wheel_down: bool = true) -> void:
var ev := InputEventMouseButton.new()
ev.position = pos
ev.global_position = pos
ev.button_index = MOUSE_BUTTON_WHEEL_DOWN if wheel_down else MOUSE_BUTTON_WHEEL_UP
ev.pressed = true
main.get_viewport().push_input(ev, true)
var ev2 := InputEventMouseButton.new()
ev2.position = pos
ev2.global_position = pos
ev2.button_index = MOUSE_BUTTON_WHEEL_DOWN if wheel_down else MOUSE_BUTTON_WHEEL_UP
ev2.pressed = false
main.get_viewport().push_input(ev2, true)
# 营地经进档开层(卡② mn9 新路径——campbtn 已撤;槽模板注入隔离命名空间,防碰真实槽
# user://save_slot_N.json——真实档红线)。创建=激活=暂行开营地(卡③ Q3 乙落点);
# 驱动后写盘目标=隔离槽文件(持久化断言读 user://<tag>_slot_1.json)。返回 false=新档钮缺失(驱动中断)。
static func open_camp_via_savesel(main, tag: String) -> bool:
var sm = main.get_node("/root/SaveManager")
sm.slots.attach_slot_template("user://%s_slot_%%d.json" % tag)
for i in range(1, 4): # 夹具清场——空槽起(陈旧槽文件会翻有档态、新档钮缺席)
for suf in ["", ".tmp", ".corrupt.bak"]:
var sp: String = sm.slots.slot_path(i) + suf
if FileAccess.file_exists(sp):
DirAccess.remove_absolute(sp)
main.open_save_select()
await main.get_tree().process_frame # 排版帧——真点击命中的是控件 rect,开层同帧容器未排(emit 无此依赖)
var new_btn: Button = main.ui.modals.menu.save_select_overlay._new_btns.get(1)
if new_btn == null:
return false
push_click(main, new_btn) # 真输入 → _on_save_select_new → create_slot+open_camp
return true