Files
gplan-2/game/probe/level_map_viewport_probe.gd
T

390 lines
18 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.
# level_map_viewport_probe.gd — 卷轴视口行为探针(卷轴视口批 R1–R8 验收载体)
# 判的是 level-map.md 布局决策 5 的视口行为四条:浅图整幅居中无裁切(R1)、停点切换/兑付回图
# 当前节点居中锚定——平滑滚动与减动效瞬切(R2/R7)、拖拽/滚轮手动平移与 clamp 包络——
# 首末列可达(R3/R4/R5)、拖拽不触发选路提交(R6)、指引件可见性与几何(R8,像素级判据②③)。
# 真实入口 = 合成拖拽沿/滚轮沿/点击沿经 push_input 进引擎输入管线打在真实画布 _gui_input 上
# (probe_ui_util.push_drag_at/push_wheel_at/push_click_at);状态注入 = 直调核心层写口构造
# 单关停点(LevelRunner.start_level_external——defer_entry=入口停点 / resume_state=断点续玩
# 重建到任意停点,零 RNG 消耗;test-conventions §5.4 自动化测试直构口径),零 main 冒险流程
# 依赖:真实屏 = LevelMapScreen 实例 + 真实单关 runner。
# 运行:Godot_console.exe --headless --path . res://probe/level_map_viewport_probe.tscn
extends Node
const ProbeUiUtil := preload("res://probe/probe_ui_util.gd")
const ViewportScript := preload("res://presentation/views/level_map_viewport.gd")
const SEED_L1 := 910211 # 浅图停点种子(L1 单链——画布 846.5×256 全幅入窗)
const SEED_L3 := 910212 # 深图停点种子(L3 图——画布超 1280 窗宽,滚动视口生效面)
var _problems: Array[String] = []
var _checks := 0
var _runner # 当前停点的单关 runner(open_choices 驱动随记——查证面共用)
func _check(desc: String, ok: bool) -> void:
_checks += 1
if not ok:
_problems.append(desc)
func _ready() -> void:
await get_tree().process_frame
await get_tree().process_frame
var host := Control.new()
add_child(host)
host.size = get_viewport().get_visible_rect().size # 显式置底座(父非 Control 锚点不解析)
var screen = load("res://presentation/views/level_map_screen.gd").new()
screen.set_anchors_preset(Control.PRESET_FULL_RECT)
host.add_child(screen)
await get_tree().process_frame
await get_tree().process_frame # 布局收敛先行(视口实况窗口尺寸就位后再开停点——锚点数学以实况窗口为准)
var vp = _find_viewport(screen)
if vp == null:
_problems.append("卷轴视口承载件未挂入关卡屏(LevelMapViewport 实例缺位)")
_finish()
return
await _probe_shallow_no_crop(screen, vp)
await _probe_deep_entry_clamp(screen, vp)
await _probe_wheel_pan(screen, vp)
await _probe_drag_pan(screen, vp)
await _probe_clamp_bounds_and_columns(screen, vp)
await _probe_drag_no_submit(screen, vp)
await _probe_follow_anchor(screen, vp)
await _probe_smooth_scroll_reduced_motion(screen, vp)
await _probe_guidance_chrome(screen, vp)
await _probe_shallow_guidance_hidden(screen, vp)
_finish()
## R1 浅图整幅居中无裁切+画布入视口件承载(画布=视口首子件——setup 注入序)
func _probe_shallow_no_crop(screen, vp) -> void:
_runner = _make_runner_entry(1, SEED_L1)
screen.open_choices(_cand_nodes(_runner), _runner)
await get_tree().process_frame
await get_tree().process_frame
var canvas: Control = vp.get_child(0) as Control
if canvas == null:
_problems.append("R1 画布实例缺位(视口首子件非 Control)")
return
_check("R1 浅图画布全幅入窗(canvas=%s window=%s)〔卷轴批 V1〕" % [canvas.size, vp.size],
canvas.size.x <= vp.size.x + 0.5 and canvas.size.y <= vp.size.y + 0.5)
var want: Vector2 = (vp.size - canvas.size) / 2.0
_check("R1 居中分支 offset 定位(want=%s got=%s)〔卷轴批 V1〕" % [want, canvas.position],
canvas.position.distance_to(want) <= 0.5)
_check("R1 画布矩形在窗内无裁切〔卷轴批 V1〕", vp.get_global_rect().encloses(canvas.get_global_rect()))
## R5 前置+深图入口停点:锚点=入口组包围盒中心夹紧(spec :101——多入口并列取包围盒居中)
func _probe_deep_entry_clamp(screen, vp) -> void:
_runner = _make_runner_entry(3, SEED_L3)
screen.open_choices(_cand_nodes(_runner), _runner)
await get_tree().process_frame
await get_tree().process_frame
var center: Vector2 = vp.viewport_center_canvas_pos()
var half: float = vp.size.x / 2.0
var canvas_len: float = _canvas_len(screen)
_check("R5 入口锚定后视口中线在夹紧域内(center.x=%.1f ∈ [%.1f, %.1f])〔卷轴批 V5〕" %
[center.x, half, canvas_len - half],
center.x >= half - 0.5 and center.x <= canvas_len - half + 0.5)
# 入口停点候选 = 入口节点——包围盒中心夹紧即锚点(本档图入口分布跨图,中心落夹紧域)
var nodes: Array = _runner.nodes()
var pos: Dictionary = screen.layout_probe(nodes)
var ids: Array = _runner.next_candidate_ids()
var lo := INF
var hi := -INF
for node in nodes:
if ids.has(_runner.node_id_of(node)):
lo = minf(lo, pos[node].x)
hi = maxf(hi, pos[node].x)
var want: float = clampf((lo + hi) / 2.0, half, canvas_len - half)
_check("R5 入口组包围盒中心=锚点(bbox=%.1f center=%.1f want=%.1f)〔卷轴批 V5〕" %
[(lo + hi) / 2.0, center.x, want], absf(center.x - want) <= 0.5)
## R4 滚轮横滚移动视口(下=向右前进向——代拍板映射;窗内平移不钉边界,与 R5 两极 true 构成翻转)
func _probe_wheel_pan(screen, vp) -> void:
var before: float = vp.viewport_center_canvas_pos().x
var pos: Vector2 = vp.get_global_rect().get_center()
ProbeUiUtil.push_wheel_at(self, pos, true)
await get_tree().process_frame
await get_tree().process_frame
var after: float = vp.viewport_center_canvas_pos().x
_check("R4 滚轮下单档平移 +60(before=%.1f after=%.1f)〔卷轴批 V4〕" % [before, after],
absf(after - before - 60.0) <= 0.5)
_check("R4 窗内平移不钉边界(is_clamped_at_edge=false)〔卷轴批 V4〕", not vp.is_clamped_at_edge())
ProbeUiUtil.push_wheel_at(self, pos, false)
await get_tree().process_frame
await get_tree().process_frame
_check("R4 滚轮上回移 −60〔卷轴批 V4〕",
absf(vp.viewport_center_canvas_pos().x - before) <= 0.5)
## R3 拖拽平移移动视口(窗内位移量=拖拽位移,无 clamp 干扰)
func _probe_drag_pan(screen, vp) -> void:
# 先滚入窗内区(两档 +120——避开左包络边界的 clamp 吸收),再拖拽核位移
var pos: Vector2 = vp.get_global_rect().get_center()
ProbeUiUtil.push_wheel_at(self, pos, true)
ProbeUiUtil.push_wheel_at(self, pos, true)
await get_tree().process_frame
await get_tree().process_frame
var before: float = vp.viewport_center_canvas_pos().x
ProbeUiUtil.push_drag_at(self, pos, Vector2(-40, 0))
await get_tree().process_frame
await get_tree().process_frame
var after: float = vp.viewport_center_canvas_pos().x
_check("R3 拖拽左移 −40 平移量逐位一致(画布随手——视线 +40,before=%.1f after=%.1f)〔卷轴批 V3〕" % [before, after],
absf(after - before - 40.0) <= 0.5)
# 复位到左包络边界,给后续探针稳定起点
ProbeUiUtil.push_drag_at(self, pos, Vector2(2000, 0))
await get_tree().process_frame
await get_tree().process_frame
## R5 clamp 边界与首末列可达(拖拽两极)
func _probe_clamp_bounds_and_columns(screen, vp) -> void:
var nodes: Array = _runner.nodes()
var pos: Dictionary = screen.layout_probe(nodes)
var first_node := _extreme_node(pos, nodes, true)
var last_node := _extreme_node(pos, nodes, false)
var vp_rect: Rect2 = vp.get_global_rect()
# 右极:拖拽超包络 → 视口钉左缘,首列(深度 0)入屏
ProbeUiUtil.push_drag_at(self, vp_rect.get_center(), Vector2(2000, 0))
await get_tree().process_frame
await get_tree().process_frame
var center: float = vp.viewport_center_canvas_pos().x
var half: float = vp.size.x / 2.0
_check("R5 拖拽超包络收敛夹紧域(center.x=%.1f)〔卷轴批 V5〕" % center,
center >= half - 0.5 and center <= _canvas_len(screen) - half + 0.5)
_check("R5 右极视口钉边界(is_clamped_at_edge=true)〔卷轴批 V5〕", vp.is_clamped_at_edge())
_check("R5 首列(深度 0)中心入屏〔卷轴批 V5〕",
vp_rect.has_point(_node_screen_pos(screen, pos, first_node)))
# 左极:反向超包络 → 视口钉右缘,末列(含 Boss 紫赤描边列)入屏
ProbeUiUtil.push_drag_at(self, vp_rect.get_center(), Vector2(-2000, 0))
await get_tree().process_frame
await get_tree().process_frame
_check("R5 左极视口钉边界(is_clamped_at_edge=true)〔卷轴批 V5〕", vp.is_clamped_at_edge())
_check("R5 末列中心入屏(拖拽两极可达——布局决策 5)〔卷轴批 V5〕",
vp_rect.has_point(_node_screen_pos(screen, pos, last_node)))
## R6 拖拽不触发选路提交;阈值另一侧完整点击仍提交(提交语义零改动)
func _probe_drag_no_submit(screen, vp) -> void:
# 重建到中段可居中停点(候选=停点出边——候选节点居屏中可拖可达)
var stops := _centerable_stops(screen, vp)
if stops.size() < 3:
_problems.append("R6 可居中停点不足(=%d)" % stops.size())
return
_runner = _make_runner_at_stop(3, SEED_L3, _runner.node_id_of(stops[0]))
screen.open_choices(_cand_nodes(_runner), _runner)
await get_tree().process_frame
await get_tree().process_frame
var nodes: Array = _runner.nodes()
var cands: Array = _runner.next_candidate_ids()
var target := {}
for node in nodes:
if cands.has(_runner.node_id_of(node)):
target = node
break
if target.is_empty():
_problems.append("R6 候选节点缺位(候选=%s)" % str(cands))
return
var gpos: Vector2 = _node_screen_pos(screen, screen.layout_probe(nodes), target)
var submitted: Array = []
var on_route := func(nid: int) -> void: submitted.append(nid)
screen.route_chosen.connect(on_route)
ProbeUiUtil.push_drag_at(self, gpos, Vector2(120, 24))
await get_tree().process_frame
await get_tree().process_frame
_check("R6 拖拽沿起止候选上方零提交(submitted=%s)〔卷轴批 V6〕" % str(submitted), submitted.is_empty())
_check("R6 拖拽后本屏未关〔卷轴批 V6〕", screen.visible)
var gpos2: Vector2 = _node_screen_pos(screen, screen.layout_probe(nodes), target) # 拖拽平移后重算投影(画布随视口移动)
ProbeUiUtil.push_click_at(self, gpos2)
await get_tree().process_frame
await get_tree().process_frame
_check("R6 完整点击(未超阈值)仍提交选路——阈值区分另一侧〔卷轴批 V6〕", submitted.size() == 1)
screen.route_chosen.disconnect(on_route)
## R2 停点切换后当前节点居中(视口中线投影 x=640±2——视口满屏宽即屏中线)
func _probe_follow_anchor(screen, vp) -> void:
var stops := _centerable_stops(screen, vp)
if stops.size() < 3:
_problems.append("R2 可居中停点不足(=%d)" % stops.size())
return
_runner = _make_runner_at_stop(3, SEED_L3, _runner.node_id_of(stops[1]))
screen.open_choices(_cand_nodes(_runner), _runner)
await get_tree().create_timer(0.6).timeout # 平滑滚动终态(0.3s 单段缓动+余量)
var sx: float = _node_screen_pos(screen, screen.layout_probe(_runner.nodes()), stops[1]).x
var want: float = vp.get_global_rect().get_center().x
_check("R2 停点切换后当前节点投影=视口中线(got=%.1f want=%.1f±2)〔卷轴批 V2〕" % [sx, want],
absf(sx - want) <= 2.0)
## R7 平滑滚动有 tween 中间帧;减动效开=位置即达(画布坐标域判定)
func _probe_smooth_scroll_reduced_motion(screen, vp) -> void:
var saved_scale: float = UiKit.motion_scale
UiKit.motion_scale = 1.0
var stops := _centerable_stops(screen, vp)
if stops.size() < 3:
_problems.append("R7 可居中停点不足(=%d)" % stops.size())
return
# 起点:锚在停点 A(画面已停稳)
_runner = _make_runner_at_stop(3, SEED_L3, _runner.node_id_of(stops[0]))
screen.open_choices(_cand_nodes(_runner), _runner)
await get_tree().create_timer(0.6).timeout
var start: float = vp.viewport_center_canvas_pos().x
# 停点切换 A→B(屏幕保持可见=兑付回图同路径)——平滑滚动
var target_b: float = screen.layout_probe(_runner.nodes())[stops[1]].x
_runner = _make_runner_at_stop(3, SEED_L3, _runner.node_id_of(stops[1]))
screen.open_choices(_cand_nodes(_runner), _runner)
await get_tree().create_timer(0.05).timeout # 中间帧采样(300ms 单段缓动早段)
var mid: float = vp.viewport_center_canvas_pos().x
var total := absf(target_b - start)
_check("R7 平滑滚动中间帧在途中(start=%.1f mid=%.1f target=%.1f)〔卷轴批 V7〕" % [start, mid, target_b],
total > 4.0 and absf(mid - start) > 0.5 and absf(mid - target_b) > 0.5
and (mid - start) * (target_b - start) > 0.0) # 同向在途
await get_tree().create_timer(0.5).timeout
_check("R7 平滑滚动终态到位〔卷轴批 V7〕",
absf(vp.viewport_center_canvas_pos().x - target_b) <= 2.0)
# 减动效置位(UiKit.motion 通道单点——设置面开关写同一值):停点切换 B→C 位置即达
UiKit.motion_scale = 0.5
var target_c: float = screen.layout_probe(_runner.nodes())[stops[2]].x
_runner = _make_runner_at_stop(3, SEED_L3, _runner.node_id_of(stops[2]))
screen.open_choices(_cand_nodes(_runner), _runner)
await get_tree().process_frame
await get_tree().process_frame
_check("R7 减动效开=位置即达(got=%.1f target=%.1f)〔卷轴批 V7〕" %
[vp.viewport_center_canvas_pos().x, target_c],
absf(vp.viewport_center_canvas_pos().x - target_c) <= 2.0)
UiKit.motion_scale = saved_scale
## R8 指引件可见性与几何(深图超窗——像素级判据②③)
func _probe_guidance_chrome(screen, vp) -> void:
var band_l: Control = vp.get_node_or_null(NodePath("FadeL"))
var band_r: Control = vp.get_node_or_null(NodePath("FadeR"))
var chip: Control = vp.get_node_or_null(NodePath("PanHint"))
if band_l == null or band_r == null or chip == null:
_problems.append("R8 指引件缺位(FadeL/FadeR/PanHint)〔卷轴批 V8〕")
return
_check("R8 深图渐隐带可见〔卷轴批 V8〕", band_l.visible and band_r.visible)
_check("R8 渐隐带宽 70px(L=%.1f R=%.1f)〔卷轴批 V8〕" % [band_l.size.x, band_r.size.x],
absf(band_l.size.x - 70.0) <= 0.5 and absf(band_r.size.x - 70.0) <= 0.5)
_check("R8 平移提示 chip 可见且 240×30(size=%s)〔卷轴批 V8〕" % chip.size,
chip.visible and absf(chip.size.x - 240.0) <= 0.5 and absf(chip.size.y - 30.0) <= 0.5)
_check("R8 chip 图区底部居中(水平中线对窗中线±2)〔卷轴批 V8〕",
absf(chip.get_global_rect().get_center().x - vp.get_global_rect().get_center().x) <= 2.0)
## R8 浅图指引件隐藏(无暗示必要)
func _probe_shallow_guidance_hidden(screen, vp) -> void:
_runner = _make_runner_entry(1, SEED_L1)
screen.open_choices(_cand_nodes(_runner), _runner)
await get_tree().process_frame
await get_tree().process_frame
var band_l: Control = vp.get_node_or_null(NodePath("FadeL"))
var chip: Control = vp.get_node_or_null(NodePath("PanHint"))
_check("R8 浅图画布不超窗指引件隐藏〔卷轴批 V8〕",
band_l != null and not band_l.visible and chip != null and not chip.visible)
# ---- 驱动与查证工具(状态注入面——直调核心层写口) ----
## 入口停点 runner(defer_entry——选路态挂起、候选=入口节点)
func _make_runner_entry(level_index: int, seed_val: int):
var rng = load("res://core/rng_service.gd").new()
rng.new_adventure(seed_val)
var runner = load("res://core/level_runner.gd").new(get_node("/root/ConfigDB"), rng)
if not runner.start_level_external(level_index, Callable(runner, "rest_heal_amount"), true):
_problems.append("状态注入失败:start_level_external(%d, %d) 未受理" % [level_index, seed_val])
return runner
## 中段停点 runner(断点续玩重建——图由种子+参数档重生成同图,零 RNG 消耗)
func _make_runner_at_stop(level_index: int, seed_val: int, node_id: int):
var rng = load("res://core/rng_service.gd").new()
rng.new_adventure(seed_val)
var runner = load("res://core/level_runner.gd").new(get_node("/root/ConfigDB"), rng)
if not runner.start_level_external(level_index, Callable(runner, "rest_heal_amount"),
false, {"node_id": node_id, "visited_ids": [node_id]}):
_problems.append("状态注入失败:断点停点 %d 未受理" % node_id)
return runner
func _cand_nodes(runner) -> Array:
var ids: Array = runner.next_candidate_ids()
var out: Array = []
for node in runner.nodes():
if ids.has(runner.node_id_of(node)):
out.append(node)
return out
## 深图可居中停点选取(有出边且画布 x 落在夹紧域内的节点——锚定不贴边、当前节点投影可居中;
## 按 x 降序去重取第 idx 个——同层同 x 只计一个,层距 122 保证停点间滚动位移充分)
func _centerable_stops(screen, vp) -> Array:
var nodes: Array = _runner.nodes()
var pos: Dictionary = screen.layout_probe(nodes)
var half: float = vp.size.x / 2.0
var canvas_len: float = _canvas_len(screen)
var by_x_desc: Array = nodes.duplicate()
by_x_desc.sort_custom(func(a, b) -> bool: return pos[a].x > pos[b].x)
var picked: Array = []
var seen_x := {}
for node in by_x_desc:
if _runner.node_out_ids(node).is_empty():
continue
var x: float = pos[node].x
if x < half + 20.0 or x > canvas_len - half - 20.0 or seen_x.has(x):
continue
seen_x[x] = true
picked.append(node)
return picked
func _find_viewport(screen) -> Control:
return _find_by_script(screen, ViewportScript)
func _find_by_script(root: Node, script: Script) -> Control:
if root.get_script() == script:
return root as Control
for child in root.get_children():
var found := _find_by_script(child, script)
if found != null:
return found
return null
func _canvas_len(screen) -> float:
return screen.canvas_probe()["min_size"].x # 画布包络宽(open_choices 拟合值——画布尺寸即它)
func _node_screen_pos(screen, pos: Dictionary, node: Dictionary) -> Vector2:
return screen.canvas_probe()["origin"] + pos[node]
func _extreme_node(pos: Dictionary, nodes: Array, first: bool) -> Dictionary:
var best := {}
var best_x := INF if first else -INF
for node in nodes:
var x: float = pos[node].x
if (first and x < best_x) or (not first and x > best_x):
best_x = x
best = node
return best
func _finish() -> void:
if _checks == 0:
_problems.append("零断言执行(断言数下限闸——三硬闸③)")
if _problems.is_empty():
print("[level_map_viewport_probe] PASS — 卷轴视口行为断言全绿(%d 项)" % _checks)
get_tree().quit(0)
else:
for p in _problems:
printerr("[level_map_viewport_probe] FAIL — " + p)
get_tree().quit(1)