# 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: # 判据出处: design/ux/level-map.md:3 (判定上报通道——验证函数组) _checks += 1 if not ok: _problems.append(desc) func _ready() -> void: # 判据出处: design/ux/level-map.md:3 (关卡画面视口跟随——卷轴视口组) 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: # 判据出处: design/ux/level-map.md:3 (浅层不裁切组) _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: # 判据出处: design/ux/level-map.md:3 (深层入口钳制组) _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: # 判据出处: design/ux/level-map.md:3 (判定上报通道——验证函数组) # 重建到中段可居中停点(候选=停点出边——候选节点居屏中可拖可达) 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: # 判据出处: design/ux/level-map.md:3 (判定上报通道——验证函数组) 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: # 判据出处: design/ux/level-map.md:3 (判定上报通道——验证函数组) 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: # 判据出处: design/ux/level-map.md:3 (判定上报通道——验证函数组) 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): # 判据出处: design/ux/level-map.md:3 (判定上报通道——验证函数组) 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): # 判据出处: design/ux/level-map.md:3 (判定上报通道——验证函数组) 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: # 判据出处: design/ux/level-map.md:3 (判定上报通道——验证函数组) 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)