iter53-卡1:卷轴视口批: 关卡画面视口化(level-map.md 布局决策5落地)——视口承载件/锚点跟随/平滑滚动减动效/拖拽滚轮平移与clamp/指引件/探针26断言/golden两Shot冻结
This commit is contained in:
1 parent
fd9c8ad90e
commit
55f4de3ca4
22 files changed
+983
-152
No files matched your search
@@ -1,6 +1,7 @@
|
||||
## 关卡节点图参数档 schema(节点图生成器核心批 卡① T1):五旋钮+战斗保底+岔口去路数的行式数据表。
|
||||
## 键 = level_index(关卡行集单点),消费面经 ConfigDB.run.level_gen_params.row_by_level_index() 读取;生成器只吃参数档、不认关卡号(契约 6)。
|
||||
## 数值全 provisional(定值批落终值);域校验沿关卡节点图生成.md v1.1 调参表已锁条款——层深 L1≥6 递增增幅变大、
|
||||
## 数值全 provisional(定值批落终值);取值域与字段契约单点=本文件域校验(概念与设计意图指针=
|
||||
## 关卡节点图生成.md v1.5 参数契约节)——层深 L1≥6 递增增幅变大、
|
||||
## 配比四类合计 1、出口 L1 恒 1/L2-L3 1-3、岔口去路数恒 2/3、战斗保底≥0。
|
||||
class_name LevelGenParams
|
||||
extends Resource
|
||||
@@ -66,7 +67,7 @@ func _validate_row_shape(row: Dictionary, L: int, key: String, layers_by_level:
|
||||
var bd: float = float(row.get("branch_density", -1.0))
|
||||
if bd < 0.0 or bd > 1.0:
|
||||
errs.append({"category": "domain", "message": "%s 分支密度 %f 越域 [0,1]" % [key, bd]})
|
||||
# 域并单点(关卡节点图生成.md v1.4 调参表——生成器侧域校验退役,唯此一处)
|
||||
# 取值域单点=本文件域校验(概念与设计意图指针=关卡节点图生成.md v1.5 参数契约节)
|
||||
var ftw: float = float(row.get("fork_third_way_pct", -1.0))
|
||||
if ftw < 0.0 or ftw > 100.0:
|
||||
errs.append({"category": "domain", "message": "%s fork_third_way_pct %f 越域 [0,100]" % [key, ftw]})
|
||||
|
||||
@@ -31,6 +31,8 @@ const SLOT_STEP_Y := 58 # 同层槽位纵向步距
|
||||
const HIT_PAD := 6.0 # 图上命中外扩(指宽容差)
|
||||
const CONFIRM_WINDOW_S := 3.0 # 放弃确认态窗口(秒——超时回落)
|
||||
const COL_HP_BAR_BACK := UiKit.HP_BAR_BACK # 迷你血条深底(对暗面板可辨;值单点收 UiKit)
|
||||
const DRAG_THRESHOLD := 8.0 # 拖动距离阈值(布局决策 5 provisional——超阈值进拖拽态,拖拽不触发选路)
|
||||
const WHEEL_STEP := 60.0 # 滚轮横滚单档步进(provisional 实现期定值;方向映射=代拍板:下/右=向右前进向)
|
||||
|
||||
## 内部状态件(u2 子域拆件——契约 9 职责轴):_MapState=选路屏运行态与图绘制态(单一变化原因=
|
||||
## 选路呈现)。layer_decor 域字典承载换层装饰三件(图标贴图/石纹面/tint 层号——装饰域归并形态)。
|
||||
@@ -39,6 +41,7 @@ class _MapState extends RefCounted:
|
||||
var runner = null
|
||||
var title: Label
|
||||
var canvas: Control
|
||||
var viewport: LevelMapViewport # 卷轴视口承载件(布局决策 5——画布宿主/裁切/clamp/跟随/指引)
|
||||
var level_label: Label
|
||||
var gp_value: Label
|
||||
var hp_value: Label
|
||||
@@ -54,6 +57,8 @@ class _MapState extends RefCounted:
|
||||
var _cand_ids: Array[int] = [] # 本停点候选 id 集(图上命中合法集;探针直读冻结——住宿主)
|
||||
var _content_size := Vector2(620, 240) # 图内容包络(open_choices 按图拟合——画布 min 尺寸随它;探针直读冻结)
|
||||
var _hover_id := -1 # 悬停预承诺候选(-1 = 无;探针直读冻结)
|
||||
var _drag_active := false # 拖拽态(超阈值至抬杆——拖拽不触发选路,布局决策 5)
|
||||
var _drag_press_pos := Vector2(-1, -1) # 拖距判定基准(按下点画布局部坐标;-1 = 无按下跟踪)
|
||||
var abandon_btn: Button # 放弃入口(可见性归组合根 set_abandon_available——只在冒险模式开;探针直读冻结)
|
||||
var status_badge: Button # 状态面板入口角标(顶栏 GP 徽右侧;连接归组合根;探针直读冻结)
|
||||
var _layout_cache := {"nodes": [], "canvas_size": Vector2.ZERO, "pos": {}} # 布局缓存(_layout 纯函数于 (nodes, canvas)——键=nodes 内容等价+画布尺寸,图态换代或画布尺寸变即自然失效;探针直读冻结)
|
||||
@@ -74,15 +79,15 @@ func _init() -> void:
|
||||
base.add_child(tex)
|
||||
_map.layer_decor.noise_rect = tex # 世界侧 tint 承载面(换层色板渐变挂点——§2.7 切片档)
|
||||
base.add_child(UiKit.vignette_rect())
|
||||
var center := CenterContainer.new()
|
||||
center.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
base.add_child(center)
|
||||
var box := VBoxContainer.new()
|
||||
box.add_theme_constant_override("separation", 10)
|
||||
center.add_child(box)
|
||||
_build_top_bar(box)
|
||||
_build_canvas(box)
|
||||
_build_status_bar(box)
|
||||
# 三段纵排页(布局决策 5 卷轴视口:顶栏/标题在上、图区视口扩张充盈、底栏在下——
|
||||
# 视口为窗口屏为框,画布随图生长可超屏)
|
||||
var page := VBoxContainer.new()
|
||||
page.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
page.add_theme_constant_override("separation", 10)
|
||||
base.add_child(page)
|
||||
_build_top_bar(page)
|
||||
_build_canvas(page)
|
||||
_build_status_bar(page)
|
||||
visible = false
|
||||
|
||||
|
||||
@@ -122,14 +127,21 @@ func _build_top_bar(box: VBoxContainer) -> void:
|
||||
box.add_child(_map.title)
|
||||
|
||||
|
||||
## 节点图画布域(图上点击 = 唯一提交通道——布局决策 2;mouse_filter STOP 接悬停/点击)
|
||||
## 节点图区(卷轴视口承载——布局决策 5):视口件为窗口(裁切/offset/clamp/跟随/指引),
|
||||
## 画布为其子件;图上点击 = 唯一提交通道(布局决策 2),拖拽/滚轮判定留在画布 _gui_input
|
||||
## (事件流同源——悬停/选路同源,视口件不做父子事件路由)
|
||||
func _build_canvas(box: VBoxContainer) -> void:
|
||||
_map.canvas = Control.new()
|
||||
_map.canvas.custom_minimum_size = Vector2(620, 240)
|
||||
_map.canvas.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
_map.canvas.draw.connect(_draw_graph.bind(_map.canvas))
|
||||
_map.canvas.gui_input.connect(_on_canvas_input)
|
||||
box.add_child(_map.canvas)
|
||||
_map.canvas.mouse_exited.connect(_on_canvas_mouse_exited)
|
||||
_map.viewport = LevelMapViewport.new()
|
||||
_map.viewport.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_map.viewport.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
box.add_child(_map.viewport)
|
||||
_map.viewport.setup(_map.canvas, _viewport_anchor, Vector2(1280, 800)) # 兜底尺寸=设计基座(实况窗口以布局为准)
|
||||
|
||||
|
||||
## 底部状态栏域(合并单条:亮米面板一站读齐=头像+生命+属性+本关加成/流派件——demo ④;
|
||||
@@ -297,6 +309,9 @@ func open_choices(cands: Array, runner: LevelRunner) -> void:
|
||||
_fit_canvas(_map.runner.nodes())
|
||||
_apply_level_tint(int(_map.runner.level_row.get("level_index", 0)))
|
||||
visible = true
|
||||
# 视口锚定(布局决策 5):入口停点(尚无当前节点)=首锚即达;其余停点/兑付回图=平滑跟随
|
||||
# (停点切换与兑付确认回图——减动效开由视口件跳终态)
|
||||
_map.viewport.reanchor(_map.runner.current_node_id() >= 0)
|
||||
if not was_visible:
|
||||
# 独立屏淡入(spec Transitions:约 200ms;alpha 淡入非明度变化——art-bible §2 明度恒定)
|
||||
modulate.a = 0.0
|
||||
@@ -323,6 +338,7 @@ func _fit_canvas(nodes: Array) -> void:
|
||||
SLOT_BASE_Y + maxslot * SLOT_STEP_Y + NODE_HALF.y + 24.0) # +24 = 类型文字标签行
|
||||
_content_size = _content_size.max(Vector2(620, 256)) # 下限(抬高容纳标签行)
|
||||
_map.canvas.custom_minimum_size = _content_size
|
||||
_map.canvas.size = _content_size # 同帧收敛(视口锚点/包络数学与 _layout 同源读画布尺寸——容器延迟收敛不等锚点)
|
||||
|
||||
|
||||
# 换层色板渐变挂点(art-bible §2.7 下探过渡切片档——关卡结算确认→下一关→独立屏淡入链路上的
|
||||
@@ -356,20 +372,70 @@ func apply_status(payload: Dictionary) -> void:
|
||||
|
||||
|
||||
# ---- 图上命中(布局决策 2——唯一提交通道;探针 press_candidate 同路径) ----
|
||||
# 拖拽与点击以拖动距离阈值区分(布局决策 5):按下起跟踪拖距,超阈值进拖拽态逐帧平移视口,
|
||||
# 抬杆不提交;未超阈值的完整点击(按下→抬杆)=提交沿——命中候选即提交,语义零改动。
|
||||
|
||||
func _on_canvas_input(ev: InputEvent) -> void:
|
||||
if _map.runner == null or not visible:
|
||||
return
|
||||
if ev is InputEventMouseMotion:
|
||||
var nid := _candidate_at(ev.position) # gui_input 事件坐标 = 接收控件局部坐标(push_input 同成立)
|
||||
if nid != _hover_id:
|
||||
_hover_id = nid
|
||||
_map.canvas.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND if nid >= 0 else Control.CURSOR_ARROW
|
||||
if ev is InputEventMouseButton and ev.pressed and _wheel_pan_step(ev.button_index) != 0.0:
|
||||
_map.viewport.pan_by(Vector2(_wheel_pan_step(ev.button_index), 0))
|
||||
elif ev is InputEventMouseMotion:
|
||||
_on_canvas_motion(ev)
|
||||
elif ev is InputEventMouseButton and ev.button_index == MOUSE_BUTTON_LEFT:
|
||||
_on_canvas_click(ev)
|
||||
|
||||
|
||||
## 悬停预承诺与拖拽态推进沿(画布局部坐标——gui_input 事件流同源)
|
||||
func _on_canvas_motion(ev: InputEventMouseMotion) -> void:
|
||||
if _drag_active:
|
||||
_map.viewport.pan_by(ev.relative) # 拖拽态逐帧平移(视口件执行 clamp——本屏只判定)
|
||||
return
|
||||
if _drag_press_pos.x >= 0.0 and ev.position.distance_to(_drag_press_pos) >= DRAG_THRESHOLD:
|
||||
_drag_active = true
|
||||
_drag_press_pos = Vector2(-1, -1)
|
||||
if _hover_id != -1: # 进拖拽态清悬停预承诺(拖拽非选路意图)
|
||||
_hover_id = -1
|
||||
_map.canvas.mouse_default_cursor_shape = Control.CURSOR_ARROW
|
||||
_map.canvas.queue_redraw()
|
||||
elif ev is InputEventMouseButton and ev.button_index == MOUSE_BUTTON_LEFT and ev.pressed:
|
||||
var nid2 := _candidate_at(ev.position)
|
||||
if nid2 >= 0:
|
||||
_submit_choice(nid2)
|
||||
_map.viewport.pan_by(ev.relative) # 跨阈值帧位移计入平移(拖距全长=平移全长)
|
||||
return
|
||||
var nid := _candidate_at(ev.position) # gui_input 事件坐标 = 接收控件局部坐标(push_input 同成立)
|
||||
if nid != _hover_id:
|
||||
_hover_id = nid
|
||||
_map.canvas.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND if nid >= 0 else Control.CURSOR_ARROW
|
||||
_map.canvas.queue_redraw()
|
||||
|
||||
|
||||
## 点击沿与拖拽抬杆(未超阈值的完整点击=提交沿——布局决策 2 语义零改动)
|
||||
func _on_canvas_click(ev: InputEventMouseButton) -> void:
|
||||
if ev.pressed:
|
||||
_drag_press_pos = ev.position
|
||||
_drag_active = false
|
||||
return
|
||||
_drag_press_pos = Vector2(-1, -1)
|
||||
if _drag_active:
|
||||
_drag_active = false # 拖拽抬杆=平移结束,不触发选路提交(布局决策 5)
|
||||
return
|
||||
var nid2 := _candidate_at(ev.position)
|
||||
if nid2 >= 0:
|
||||
_submit_choice(nid2)
|
||||
|
||||
|
||||
## 滚轮横滚步进(代拍板:下/右=向右前进向——视线移向深层,画布左移;上/左反向;非滚轮=0)
|
||||
func _wheel_pan_step(button_index: int) -> float:
|
||||
if button_index == MOUSE_BUTTON_WHEEL_DOWN or button_index == MOUSE_BUTTON_WHEEL_RIGHT:
|
||||
return -WHEEL_STEP
|
||||
if button_index == MOUSE_BUTTON_WHEEL_UP or button_index == MOUSE_BUTTON_WHEEL_LEFT:
|
||||
return WHEEL_STEP
|
||||
return 0.0
|
||||
|
||||
|
||||
## 拖距跟踪器生命周期清理(按下即武装——鼠标离画布且未入拖拽态时解除,
|
||||
## 防丢失 release 沿后的残留 armed 态把后续 motion 误判成拖拽激活)
|
||||
func _on_canvas_mouse_exited() -> void:
|
||||
if not _drag_active:
|
||||
_drag_press_pos = Vector2(-1, -1)
|
||||
|
||||
|
||||
# 候选命中:位置 = 布局公式(与 _draw_graph 同源),矩形 = 节点方块 + 外扩容差;非候选格不响应
|
||||
@@ -413,6 +479,42 @@ func press_candidate(index: int) -> void:
|
||||
_submit_choice(nid)
|
||||
|
||||
|
||||
# ---- 视口锚点(布局决策 5:当前节点居中;入口停点尚无当前节点=锚入口组包围盒居中) ----
|
||||
|
||||
## 锚点画布坐标提供方(构造注入视口件——本屏供数,视口件只消费)
|
||||
func _viewport_anchor() -> Vector2:
|
||||
if _map.runner == null:
|
||||
return _content_size / 2.0
|
||||
var nodes: Array[Variant] = _map.runner.nodes()
|
||||
var pos := _layout(nodes, _map.canvas)
|
||||
var cur_id: int = _map.runner.current_node_id()
|
||||
if cur_id < 0:
|
||||
return _entry_group_center(nodes, pos) # 入口停点(尚未走出第一步)
|
||||
var cur := _node_by_id(cur_id, nodes)
|
||||
if cur.is_empty():
|
||||
return _content_size / 2.0
|
||||
return pos[cur]
|
||||
|
||||
|
||||
func _entry_group_center(nodes: Array, pos: Dictionary) -> Vector2:
|
||||
# 入口组 = 全部无入边节点(与 _assign_layers 入口判据同源);多入口取包围盒居中
|
||||
var inbound := {}
|
||||
for node in nodes:
|
||||
for tid in _out_ids(node, nodes):
|
||||
inbound[tid] = true
|
||||
var lo := Vector2(INF, INF)
|
||||
var hi := Vector2(-INF, -INF)
|
||||
for node in nodes:
|
||||
if inbound.has(_id_of(node)):
|
||||
continue
|
||||
var p: Vector2 = pos[node]
|
||||
lo = lo.min(p)
|
||||
hi = hi.max(p)
|
||||
if lo.x > hi.x:
|
||||
return _content_size / 2.0 # 空图兜底(无入口——不达路径,防御位)
|
||||
return (lo + hi) / 2.0
|
||||
|
||||
|
||||
func _id_of(node: Dictionary) -> int:
|
||||
# 编号显示经行进器读面(编号空间解析单一出口——单链 = 下标 / 图 = node_depth)
|
||||
return _map.runner.node_id_of(node)
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
## 卷轴视口承载件(level-map.md 布局决策 5——关卡画面选路屏):画布宿主与裁切、平移状态
|
||||
## 与 clamp、锚点跟随与平滑滚动、指引件(屏缘渐隐带+平移提示 chip)。
|
||||
## 纯呈现态:平移位置零核心写、不进存档(重进本屏按锚点规则重新定位);跟随只随停点切换/
|
||||
## 兑付回图发生(零自动推进)。拖拽/滚轮判定留画布 _gui_input(事件流同源——悬停/选路同源),
|
||||
## 本件只管裁切/offset/clamp/跟随/指引;画布包络=画布实际尺寸(open_choices 拟合后同帧收敛)。
|
||||
class_name LevelMapViewport
|
||||
extends Control
|
||||
|
||||
const SCROLL_DUR := 0.3 # 平滑滚动时长(布局决策 5 provisional 0.3s 单段缓动)
|
||||
const FADE_BAND_W := 70.0 # 屏缘渐隐带宽(布局决策 5/像素级判据②)
|
||||
const CHIP_SIZE := Vector2(240, 30) # 平移提示 chip(像素级判据③——图区底部居中)
|
||||
const CHIP_CORNER := 6 # chip 圆角(布局决策 5)
|
||||
const CHIP_BOTTOM_GAP := 24.0 # chip 距视口底缘(效果图档同族取值)
|
||||
const CHIP_TEXT := "◄ 拖拽画布 / 滚轮平移 ►" # 14px(FS_HINT 档——布局决策 5)
|
||||
const EDGE_EPS := 0.5 # 包络边界判定容差(浮点平移累积)
|
||||
|
||||
var _canvas: Control
|
||||
var _anchor_provider: Callable # () -> Vector2 锚点画布坐标(screen 提供——当前节点/入口组)
|
||||
var _viewport_size := Vector2.ZERO # 窗口尺寸兜底(setup 注入设计基座——实况窗口以控件 size 为准,布局晚于构造)
|
||||
var _offset := Vector2.ZERO: # 画布左上角在视口件坐标系的位置(纯呈现态——直驱画布 position)
|
||||
set(value):
|
||||
_offset = value
|
||||
if _canvas != null:
|
||||
_canvas.position = _offset
|
||||
var _view_tween: Tween
|
||||
var _guided_visible := false # 指引件可见态(画布超窗才暗示——浅图无平移余量即隐藏)
|
||||
|
||||
|
||||
## 一次交齐注入(code-conventions §1.6 拆分交接:画布宿主、锚点来源、窗口兜底尺寸)
|
||||
func setup(canvas: Control, anchor_provider: Callable, viewport_size: Vector2) -> void:
|
||||
_canvas = canvas
|
||||
_anchor_provider = anchor_provider
|
||||
_viewport_size = viewport_size
|
||||
clip_contents = true
|
||||
add_child(canvas)
|
||||
_build_guidance()
|
||||
|
||||
|
||||
func _notification(what: int) -> void:
|
||||
# 窗口实况尺寸落定/变化即重锚(首开布局晚于 open_choices 的锚定——隐藏子树容器排序
|
||||
# 首个可见帧才传播,4.7 行为;实况窗口以本件 size 为准,落定即收敛锚点)
|
||||
if what == NOTIFICATION_RESIZED and _canvas != null and _anchor_provider.is_valid():
|
||||
reanchor(false)
|
||||
|
||||
|
||||
## 锚点跟随(布局决策 5:锚点居中;smooth=停点切换/兑付回图平滑滚动,减动效开=跳终态;
|
||||
## 重入先杀在飞 tween 再建新——快速连续停点切换防叠加)
|
||||
func reanchor(smooth: bool) -> void:
|
||||
if _canvas == null or not _anchor_provider.is_valid():
|
||||
return
|
||||
var target := _clamped_center_offset(_anchor_provider.call())
|
||||
_kill_scroll_tween()
|
||||
if not smooth or ArmedTimer.motion_still():
|
||||
_offset = target
|
||||
_sync_guidance()
|
||||
return
|
||||
_view_tween = create_tween()
|
||||
_view_tween.tween_property(self, "_offset", target, UiKit.motion(SCROLL_DUR)) \
|
||||
.set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT)
|
||||
_sync_guidance()
|
||||
|
||||
|
||||
## 手动平移(画布 _gui_input 判定后调用——拖拽沿逐帧/滚轮步进;纯呈现零核心写)
|
||||
func pan_by(delta: Vector2) -> void:
|
||||
if _canvas == null:
|
||||
return
|
||||
_kill_scroll_tween()
|
||||
var win := _window_size()
|
||||
_offset = Vector2(
|
||||
_axis_pan_offset(_offset.x, delta.x, _canvas.size.x, win.x),
|
||||
_axis_pan_offset(_offset.y, delta.y, _canvas.size.y, win.y))
|
||||
_sync_guidance()
|
||||
|
||||
|
||||
## 视口中线的画布坐标(锚点居中判据投影面——浅图=画布中心,深图=夹紧域内当前注视点)
|
||||
func viewport_center_canvas_pos() -> Vector2:
|
||||
return _window_size() / 2.0 - _offset
|
||||
|
||||
|
||||
## 视口是否钉在画布包络边界(任一超窗轴 offset 顶边——clamp 判据面)
|
||||
func is_clamped_at_edge() -> bool:
|
||||
var win := _window_size()
|
||||
if _canvas == null:
|
||||
return false
|
||||
return _axis_at_edge(_offset.x, _canvas.size.x, win.x) \
|
||||
or _axis_at_edge(_offset.y, _canvas.size.y, win.y)
|
||||
|
||||
|
||||
func _kill_scroll_tween() -> void:
|
||||
if _view_tween != null and _view_tween.is_valid():
|
||||
_view_tween.kill()
|
||||
_view_tween = null
|
||||
|
||||
|
||||
## 单轴锚定 offset:画布不超窗=居中分支(夹紧域退化——布局决策 5);
|
||||
## 超窗=锚点夹紧在画布包络内,锚点落视口中线(offset = 半窗 − 锚点)
|
||||
func _axis_center_offset(anchor: float, canvas_len: float, half_win: float) -> float:
|
||||
if canvas_len <= half_win * 2.0:
|
||||
return half_win - canvas_len / 2.0
|
||||
return half_win - clampf(anchor, half_win, canvas_len - half_win)
|
||||
|
||||
|
||||
func _clamped_center_offset(anchor: Vector2) -> Vector2:
|
||||
var half := _window_size() / 2.0
|
||||
return Vector2(
|
||||
_axis_center_offset(anchor.x, _canvas.size.x, half.x),
|
||||
_axis_center_offset(anchor.y, _canvas.size.y, half.y))
|
||||
|
||||
|
||||
## 单轴平移收敛:居中轴零平移余量不动;超窗轴 offset 收敛在 [半窗−画布, 0](不出露屏底空档)
|
||||
func _axis_pan_offset(cur: float, delta: float, canvas_len: float, win_len: float) -> float:
|
||||
if canvas_len <= win_len:
|
||||
return cur
|
||||
return clampf(cur + delta, win_len - canvas_len, 0.0)
|
||||
|
||||
|
||||
func _axis_at_edge(offset: float, canvas_len: float, win_len: float) -> bool:
|
||||
if canvas_len <= win_len:
|
||||
return false
|
||||
return offset >= -EDGE_EPS or offset <= win_len - canvas_len + EDGE_EPS
|
||||
|
||||
|
||||
## 实况窗口优先(容器布局晚于构造——注入值只兜构造帧数学)
|
||||
func _window_size() -> Vector2:
|
||||
if size.x > 0.0 and size.y > 0.0:
|
||||
return size
|
||||
return _viewport_size
|
||||
|
||||
|
||||
## 指引件(布局决策 5/像素级判据②③):屏缘渐隐带盖在画布之上+图区底部居中提示 chip;
|
||||
## 可见性=画布超窗(_sync_guidance 随锚定/平移刷新)
|
||||
func _build_guidance() -> void:
|
||||
var band_l := TextureRect.new()
|
||||
band_l.name = "FadeL"
|
||||
band_l.texture = _fade_gradient(false)
|
||||
band_l.set_anchors_preset(Control.PRESET_LEFT_WIDE)
|
||||
band_l.offset_right = FADE_BAND_W
|
||||
band_l.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
band_l.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(band_l)
|
||||
var band_r := TextureRect.new()
|
||||
band_r.name = "FadeR"
|
||||
band_r.texture = _fade_gradient(true)
|
||||
band_r.set_anchors_preset(Control.PRESET_RIGHT_WIDE)
|
||||
band_r.offset_left = -FADE_BAND_W
|
||||
band_r.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
band_r.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(band_r)
|
||||
var chip := PanelContainer.new()
|
||||
chip.name = "PanHint"
|
||||
chip.add_theme_stylebox_override("panel",
|
||||
UiKit.panel_flat(UiKit.HP_BAR_BACK, UiKit.STONE, CHIP_CORNER, 1, Vector4(12, 5, 12, 5)))
|
||||
chip.set_anchors_preset(Control.PRESET_CENTER_BOTTOM)
|
||||
chip.offset_left = -CHIP_SIZE.x / 2.0
|
||||
chip.offset_right = CHIP_SIZE.x / 2.0
|
||||
chip.offset_top = -CHIP_SIZE.y - CHIP_BOTTOM_GAP
|
||||
chip.offset_bottom = -CHIP_BOTTOM_GAP
|
||||
chip.custom_minimum_size = CHIP_SIZE
|
||||
chip.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
var chip_label := UiKit.label(CHIP_TEXT, UiKit.FS_HINT, false, UiKit.WARM)
|
||||
chip_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
chip_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
chip.add_child(chip_label)
|
||||
add_child(chip)
|
||||
_sync_guidance()
|
||||
|
||||
|
||||
## 屏缘渐隐渐变(色取屏底同族 BG_ROOT——代拍板:不透明端 0.9 单段衰减)
|
||||
func _fade_gradient(transparent_at_left: bool) -> GradientTexture2D:
|
||||
var grad := Gradient.new()
|
||||
grad.offsets = PackedFloat32Array([0.0, 1.0])
|
||||
grad.colors = PackedColorArray([Color(UiKit.BG_ROOT, 0.9), Color(UiKit.BG_ROOT, 0.0)])
|
||||
var tex := GradientTexture2D.new()
|
||||
tex.gradient = grad
|
||||
tex.fill_from = Vector2(1, 0.5) if transparent_at_left else Vector2(0, 0.5)
|
||||
tex.fill_to = Vector2(0, 0.5) if transparent_at_left else Vector2(1, 0.5)
|
||||
tex.width = int(FADE_BAND_W)
|
||||
return tex
|
||||
|
||||
|
||||
func _sync_guidance() -> void:
|
||||
var win := _window_size()
|
||||
var over := _canvas != null and (_canvas.size.x > win.x or _canvas.size.y > win.y)
|
||||
if over == _guided_visible:
|
||||
return
|
||||
_guided_visible = over
|
||||
for nm in ["FadeL", "FadeR", "PanHint"]:
|
||||
var node := get_node_or_null(NodePath(nm)) as Control
|
||||
if node != null:
|
||||
node.visible = over
|
||||
@@ -0,0 +1 @@
|
||||
uid://dgin6f6214cbg
|
||||
@@ -0,0 +1,91 @@
|
||||
# level_map_shot_probe.gd — 关卡画面视口 golden 采集探针(卷轴视口批 golden 扩项)
|
||||
# 两 Shot 面(2026-11-05 用户裁:只对拍关卡最开始与关卡 Boss 节点,中间过程不对拍):
|
||||
# lmap_entry.png = 入口停点(关卡最开始——锚点=入口组包围盒中心夹紧)
|
||||
# lmap_boss.png = 中段停点锚定末列(Boss 紫赤描边列入屏——viewport_center 夹紧右缘)
|
||||
# 采集确定性 = 固定种子单关直构(LevelRunner.start_level_external defer_entry / resume_state,
|
||||
# 零 RNG 消耗路径)+ 探针隔离档(无断点、出厂设置 1280×800);运行=窗口化(--headless 禁——
|
||||
# 渲染闸机器面吃真实渲染帧)。截图经 user args 目录落盘,基线换代走 golden_baseline_refresh.sh --lmap。
|
||||
# 运行:Godot_console.exe --path game res://probe/level_map_shot_probe.tscn -- <shots_dir>
|
||||
extends Node
|
||||
|
||||
const SEED_L3 := 910212 # 深图固定种子(与 level_map_viewport_probe 同源——图确定性承载)
|
||||
|
||||
func _ready() -> void:
|
||||
var args := OS.get_cmdline_user_args()
|
||||
var out_dir := args[0] if args.size() > 0 else "user://"
|
||||
await get_tree().process_frame
|
||||
var host := Control.new()
|
||||
add_child(host)
|
||||
host.size = get_viewport().get_visible_rect().size
|
||||
var screen = load("res://presentation/views/level_map_screen.gd").new()
|
||||
screen.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
host.add_child(screen)
|
||||
for i in range(6):
|
||||
await get_tree().process_frame # 布局收敛(视口实况尺寸落定→resize 重锚)
|
||||
# Shot①:入口停点(关卡最开始)
|
||||
var runner = _make_runner(true, -1)
|
||||
screen.open_choices(_cand_nodes(runner), runner)
|
||||
await _settle()
|
||||
_shot(out_dir, "lmap_entry.png")
|
||||
# Shot②:中段停点锚定末列(布局 x 最深的有出边节点——id 不按深度排,锚定后 Boss 列入屏)
|
||||
var boss_stop := _deepest_with_outs(screen, runner)
|
||||
runner = _make_runner(false, boss_stop)
|
||||
screen.open_choices(_cand_nodes(runner), runner)
|
||||
await _settle()
|
||||
_shot(out_dir, "lmap_boss.png")
|
||||
print("[level_map_shot_probe] PASS — 两 Shot 落盘(entry+boss → %s)" % out_dir)
|
||||
get_tree().quit(0)
|
||||
|
||||
|
||||
func _settle() -> void:
|
||||
for i in range(8):
|
||||
await get_tree().process_frame
|
||||
await get_tree().create_timer(0.5).timeout # 平滑滚动终态余量(reanchor tween 0.3s)
|
||||
|
||||
|
||||
func _make_runner(entry: bool, node_id: int):
|
||||
var rng = load("res://core/rng_service.gd").new()
|
||||
rng.new_adventure(SEED_L3)
|
||||
var runner = load("res://core/level_runner.gd").new(get_node("/root/ConfigDB"), rng)
|
||||
var ok: bool
|
||||
if entry:
|
||||
ok = runner.start_level_external(3, Callable(runner, "rest_heal_amount"), true)
|
||||
else:
|
||||
ok = runner.start_level_external(3, Callable(runner, "rest_heal_amount"),
|
||||
false, {"node_id": node_id, "visited_ids": [node_id]})
|
||||
if not ok:
|
||||
push_error("[level_map_shot_probe] 状态注入失败(entry=%s node=%d)" % [str(entry), node_id])
|
||||
get_tree().quit(1)
|
||||
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
|
||||
|
||||
|
||||
func _deepest_with_outs(screen, runner) -> int:
|
||||
var pos: Dictionary = screen.layout_probe(runner.nodes())
|
||||
var best := -1
|
||||
var best_x := -INF
|
||||
for node in runner.nodes():
|
||||
if runner.node_out_ids(node).is_empty():
|
||||
continue
|
||||
var x: float = pos[node].x
|
||||
if x > best_x:
|
||||
best_x = x
|
||||
best = runner.node_id_of(node)
|
||||
return best
|
||||
|
||||
|
||||
func _shot(out_dir: String, fname: String) -> void:
|
||||
DirAccess.make_dir_recursive_absolute(out_dir) # 落盘目录自建(save_png 对缺目录静默失败——刷新链不预建)
|
||||
var img := get_viewport().get_texture().get_image()
|
||||
var err := img.save_png(out_dir.path_join(fname))
|
||||
if err != OK:
|
||||
push_error("[level_map_shot_probe] 截图落盘失败 %s(err=%d)" % [fname, err])
|
||||
get_tree().quit(1)
|
||||
@@ -0,0 +1,6 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://probe/level_map_shot_probe.gd" id="1"]
|
||||
|
||||
[node name="LevelMapShotProbe" type="Node"]
|
||||
script = ExtResource("1")
|
||||
@@ -0,0 +1,389 @@
|
||||
# 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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cprbakccv06re
|
||||
@@ -0,0 +1,6 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://probe/level_map_viewport_probe.gd" id="1"]
|
||||
|
||||
[node name="LevelMapViewportProbe" type="Node"]
|
||||
script = ExtResource("1")
|
||||
@@ -141,6 +141,42 @@ static func push_click_at(main, pos: Vector2) -> void:
|
||||
main.get_viewport().push_input(up, true)
|
||||
|
||||
|
||||
# 真输入拖拽沿(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 驱动沿;方向映射在被测面,
|
||||
# 本工具只发原始滚轮档)
|
||||
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)
|
||||
|
||||
|
||||
# 营地经进档开层(卡② mn9 新路径——campbtn 已撤;槽模板注入隔离命名空间,防碰真实槽
|
||||
# user://save_slot_N.json——真实档红线)。创建=激活=暂行开营地(卡③ Q3 乙落点);
|
||||
# 驱动后写盘目标=隔离槽文件(持久化断言读 user://<tag>_slot_1.json)。返回 false=新档钮缺失(驱动中断)。
|
||||
|
||||
@@ -87,6 +87,14 @@ func _ready() -> void:
|
||||
evf.position = fog_pos
|
||||
evf.global_position = fog_pos
|
||||
main.get_viewport().push_input(evf, true)
|
||||
# 完整点击=press+release 成对(卷轴视口批阈值语义:拖距跟踪器由按下起武装,
|
||||
# 单 press 沿会留 armed 态污染后续 motion 判定——判据语义不变:雾下点击零提交)
|
||||
var evf2 := InputEventMouseButton.new()
|
||||
evf2.button_index = MOUSE_BUTTON_LEFT
|
||||
evf2.pressed = false
|
||||
evf2.position = fog_pos
|
||||
evf2.global_position = fog_pos
|
||||
main.get_viewport().push_input(evf2, true)
|
||||
await get_tree().process_frame
|
||||
await get_tree().process_frame
|
||||
ro.route_chosen.disconnect(on_route)
|
||||
@@ -114,6 +122,14 @@ func _ready() -> void:
|
||||
evc.position = gpos
|
||||
evc.global_position = gpos
|
||||
main.get_viewport().push_input(evc, true)
|
||||
# 完整点击=press+release 成对(卷轴视口批阈值语义:提交沿=未超阈值的抬杆——
|
||||
# 判据语义不变:图上真实点击提交选路;单 press 沿不再是提交沿)
|
||||
var evr := InputEventMouseButton.new()
|
||||
evr.button_index = MOUSE_BUTTON_LEFT
|
||||
evr.pressed = false
|
||||
evr.position = gpos
|
||||
evr.global_position = gpos
|
||||
main.get_viewport().push_input(evr, true)
|
||||
await get_tree().process_frame
|
||||
await get_tree().process_frame
|
||||
if main.ui.modals.run.level_screen.visible or main.run_scope.runner.awaiting_choice():
|
||||
|
||||
@@ -4,11 +4,15 @@
|
||||
`windows/` 与 `mac/` 子目录各持一套,平台内部照常严格比对,跨平台不互比;
|
||||
平台选择实现单点 = `tools/gate_platform.sh` 的 `gate_platform_id`,比较器 `tools/golden_compare.py`
|
||||
自动拼平台子目录)。本目录现持:`windows/`(棋盘三图 `board_L1_dungeon` / `board_L2_underdark` /
|
||||
`board_L3_deep_underdark`,裁剪区 900×760 + 启动态菜单 `menu_boot`,全屏)、
|
||||
`level_map`(关卡画面全屏,历史基线留根——现行闸件未对拍它)、
|
||||
`board_L3_deep_underdark`,裁剪区 900×760 + 启动态菜单 `menu_boot`,全屏 + 关卡视口两 Shot
|
||||
`lmap_entry` / `lmap_boss`——2026-11-05 用户裁两 Shot 面:只对拍关卡最开始(入口停点)与
|
||||
关卡 Boss 节点(末列锚定),中间过程不对拍)、
|
||||
`level_map`(关卡画面全屏,历史基线留根——现行闸件未对拍它,由两 Shot 面接替)、
|
||||
`palette_baseline/`(色板基线,纯文件内容比对与平台无关)。
|
||||
比对判据与工具见 `tools/golden_compare.py`
|
||||
(逐像素灰度差 >16 的像素占比 ≤2%)。
|
||||
(逐像素灰度差 >16 的像素占比 ≤2%)。两 Shot 基线采集=`res://probe/level_map_shot_probe.tscn`
|
||||
(固定种子单关直构——确定性采集),换代入口=`tools/golden_baseline_refresh.sh --lmap`;
|
||||
他平台(mac)的关卡视口基线尚未冻结——渲染闸对本平台缺基线记 SKIP 不判红,冻结义务归批末呈报。
|
||||
|
||||
**未建锚面备案**:营地/设置/战斗三屏与战末结算/三选一/图例/调试浮窗四浮层无改前 golden 锚;这些呈现面的等价证据承载=现役探针/单元测试断言面+实机目检(体验验证清单 ㊵ 节)。
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 210 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 207 KiB |
Reference in new issue
Block a user