390 lines
18 KiB
GDScript
390 lines
18 KiB
GDScript
# 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)
|