fix: repair parallax collisions and add play guide

This commit is contained in:
wxm
2026-07-06 23:51:59 -07:00
parent 8cca00d0da
commit af7b580691
12 changed files with 921 additions and 618 deletions
@@ -0,0 +1,216 @@
extends SceneTree
const PLAYER_BODY_MIN := Vector2(34, 52)
const MINION_BODY_MIN := Vector2(36, 50)
const BOSS_BODY_MIN := Vector2(52, 64)
const ENEMY_BODY_MASK := 1 << 6
var failures: Array[String] = []
func _init() -> void:
_run.call_deferred()
func _run() -> void:
root.size = Vector2i(1152, 648)
await process_frame
await _check_background_art_layer_is_world_locked()
await _check_body_shapes_match_visual_blocking_contract()
await _check_scene_body_rects_overlap_before_visible_interpenetration()
await _check_dash_through_ghost_query_uses_scene_body_shapes("Minion", "res://scenes/enemies/minion.tscn", 26.0, (PLAYER_BODY_MIN.x + MINION_BODY_MIN.x) * 0.5 + 2.0)
await _check_dash_through_ghost_query_uses_scene_body_shapes("Boss", "res://scenes/enemies/boss.tscn", 40.0, (PLAYER_BODY_MIN.x + BOSS_BODY_MIN.x) * 0.5 + 2.0)
_finish()
func _check_background_art_layer_is_world_locked() -> void:
var container := Node2D.new()
root.add_child(container)
var camera := Camera2D.new()
root.add_child(camera)
camera.global_position = Vector2(1734.5, 324.0)
camera.make_current()
var ground := (load("res://scenes/ground/ground1.tscn") as PackedScene).instantiate() as Node2D
container.add_child(ground)
await process_frame
await process_frame
var art_layer := ground.get_node("ArtLayer") as Node2D
var base_x := art_layer.position.x
camera.global_position.x += 640.0
await process_frame
await process_frame
_expect_float(art_layer.position.x, base_x, "Ground ArtLayer should stay locked to world x while camera moves")
camera.free()
container.free()
func _check_body_shapes_match_visual_blocking_contract() -> void:
var player := _instantiate_body("res://scenes/characters/player.tscn", "Player") as CharacterBody2D
var minion := _instantiate_body("res://scenes/enemies/minion.tscn", "Minion") as CharacterBody2D
var boss := _instantiate_body("res://scenes/enemies/boss.tscn", "Boss") as CharacterBody2D
_expect_vector_min(_body_shape_size(player), PLAYER_BODY_MIN, "Player body shape should cover the visual torso/feet")
_expect_vector_min(_body_shape_size(minion), MINION_BODY_MIN, "Minion body shape should cover the visual torso/feet")
_expect_vector_min(_body_shape_size(boss), BOSS_BODY_MIN, "Boss body shape should cover the visual torso/feet")
player.free()
minion.free()
boss.free()
func _check_scene_body_rects_overlap_before_visible_interpenetration() -> void:
var player := _instantiate_body("res://scenes/characters/player.tscn", "Player") as CharacterBody2D
var minion := _instantiate_body("res://scenes/enemies/minion.tscn", "Minion") as CharacterBody2D
var boss := _instantiate_body("res://scenes/enemies/boss.tscn", "Boss") as CharacterBody2D
_disable_enemy_ai(minion)
_disable_enemy_ai(boss)
player.global_position = Vector2(500.0, 300.0)
minion.global_position = Vector2(526.0, 300.0)
boss.global_position = Vector2(540.0, 300.0)
await physics_frame
_expect(_body_rects_overlap(player, minion), "Player/Minion bodies should still overlap at a 26px center gap")
_expect(_body_rects_overlap(player, boss), "Player/Boss bodies should still overlap at a 40px center gap")
minion.global_position = Vector2(500.0 + (PLAYER_BODY_MIN.x + MINION_BODY_MIN.x) * 0.5 + 2.0, 300.0)
boss.global_position = Vector2(500.0 + (PLAYER_BODY_MIN.x + BOSS_BODY_MIN.x) * 0.5 + 2.0, 300.0)
await physics_frame
_expect(not _body_rects_overlap(player, minion), "Player/Minion bodies should separate beyond the enlarged half-width sum")
_expect(not _body_rects_overlap(player, boss), "Player/Boss bodies should separate beyond the enlarged half-width sum")
player.free()
minion.free()
boss.free()
func _check_dash_through_ghost_query_uses_scene_body_shapes(label: String, scene_path: String, overlap_gap: float, clear_gap: float) -> void:
var player := _instantiate_body("res://scenes/characters/player.tscn", "Player") as CharacterBody2D
var enemy := _instantiate_body(scene_path, label) as CharacterBody2D
_disable_enemy_ai(enemy)
await process_frame
_expect(player.get_node_or_null("MotionExecutor") != null, "Player scene should include MotionExecutor for dash-through")
_expect(player.get_node_or_null("FrameCollisionDriver") != null, "Player scene should include FrameCollisionDriver")
_expect(enemy.get_node_or_null("FrameCollisionDriver") != null, "%s scene should include FrameCollisionDriver" % label)
player.global_position = Vector2(700.0, 300.0)
enemy.global_position = Vector2(700.0 + overlap_gap, 300.0)
await physics_frame
var motion := player.get_node("MotionExecutor")
motion.call("execute", _dash_through_action(), Vector2.RIGHT, 0.5, 220.0)
await physics_frame
_expect((int(player.collision_mask) & ENEMY_BODY_MASK) == 0, "dash_through should ghost enemy bodies before restore")
_expect(bool(motion.call("_overlaps_ghosted_bodies")), "dash_through ghost query should detect overlapped %s body shape" % label)
enemy.global_position = Vector2(700.0 + clear_gap, 300.0)
await physics_frame
_expect(not bool(motion.call("_overlaps_ghosted_bodies")), "dash_through ghost query should clear after %s body separation" % label)
motion.call("cancel")
_expect(not bool(motion.call("is_ghosting")), "dash_through should restore collision after %s separation" % label)
player.free()
enemy.free()
func _dash_through_action() -> Resource:
var script: Script = load("res://resources/action_data.gd")
var action: Resource = script.new()
var action_tags: Array[StringName] = [&"dash_through"]
action.set("id", &"test_scene_dash_through")
action.set("action_tags", action_tags)
action.set("move_mult_x", 1.0)
action.set("startup_beats", 0.25)
action.set("active_beats", 0.25)
action.set("recovery_beats", 0.25)
return action
func _instantiate_body(scene_path: String, label: String) -> CharacterBody2D:
var scene := load(scene_path) as PackedScene
if scene == null:
failures.append("%s scene should load from %s" % [label, scene_path])
return null
var actor := scene.instantiate() as CharacterBody2D
if actor == null:
failures.append("%s scene root should be CharacterBody2D" % label)
return null
root.add_child(actor)
return actor
func _disable_enemy_ai(actor: CharacterBody2D) -> void:
if actor == null:
return
var behavior := actor.get_node_or_null("MinionBehavior")
if behavior != null:
behavior.set("enabled", false)
var boss_behavior := actor.get_node_or_null("BossBehaviorTree")
if boss_behavior != null:
boss_behavior.set("enabled", false)
actor.set_physics_process(false)
func _body_shape_size(actor: CharacterBody2D) -> Vector2:
if actor == null:
return Vector2.ZERO
var shape_node := actor.get_node_or_null("CollisionShape2D") as CollisionShape2D
if shape_node == null or shape_node.shape == null:
failures.append("%s should have a main CollisionShape2D" % actor.name)
return Vector2.ZERO
var rect := shape_node.shape as RectangleShape2D
if rect == null:
failures.append("%s main body shape should be RectangleShape2D" % actor.name)
return Vector2.ZERO
return rect.size
func _body_rects_overlap(left_actor: CharacterBody2D, right_actor: CharacterBody2D) -> bool:
var left_rect := _body_world_rect(left_actor)
var right_rect := _body_world_rect(right_actor)
return left_rect.intersects(right_rect, true)
func _body_world_rect(actor: CharacterBody2D) -> Rect2:
if actor == null:
return Rect2()
var shape_node := actor.get_node_or_null("CollisionShape2D") as CollisionShape2D
if shape_node == null or shape_node.shape == null:
failures.append("%s should have a main CollisionShape2D" % actor.name)
return Rect2()
var rect_shape := shape_node.shape as RectangleShape2D
if rect_shape == null:
failures.append("%s main body shape should be RectangleShape2D" % actor.name)
return Rect2()
var center := shape_node.global_position
return Rect2(center - rect_shape.size * 0.5, rect_shape.size)
func _expect(condition: bool, label: String) -> void:
if not condition:
failures.append(label)
func _expect_float(actual: float, expected: float, label: String) -> void:
if absf(actual - expected) > 0.5:
failures.append("%s: expected %.2f, got %.2f" % [label, expected, actual])
func _expect_vector_min(actual: Vector2, expected_min: Vector2, label: String) -> void:
if actual.x + 0.5 < expected_min.x or actual.y + 0.5 < expected_min.y:
failures.append("%s: expected at least %s, got %s" % [label, expected_min, actual])
func _finish() -> void:
if failures.is_empty():
print("PASS parallax and body collision contract")
quit(0)
else:
for failure: String in failures:
push_error(failure)
quit(1)
@@ -0,0 +1 @@
uid://bdpqavi1hbe8t
+13 -9
View File
@@ -7,9 +7,9 @@ extends SceneTree
## 2. 趔趄门禁:击退滑行期间 Boss/小怪不得起手新动作(EnemyActionDriver
## 钩子),否则动作突进立刻接管 velocity 冲回玩家,把击退整个吃掉
## (真实 stage 实测净位移为负);Boss 受击链强制撤退豁免(清残速标志)。
## 3. 关卡背景单层视差:背景水平速度为相机 0.8 倍(0.92 肉眼难辨已加强),
## 锚点 2047,无相机保持原位;脚下地砖轻微滑动是单张烙死原画的已知
## 取舍,无伤视差待美术拆层
## 3. 关卡背景分层契约:当前整图里的地面/步道先锁在 1:1 世界层,避免脚下
## 地砖相对角色滑动;美术拆出的天空/夕阳放入 SkyParallaxLayer 后继续按
## 0.2 倍世界滚动(相机 0.8 跟随)
## (Boss 击退 ×9 的加码钉在 test_round3_fixes。)
var failures: Array[String] = []
@@ -147,24 +147,28 @@ func _check_background_parallax() -> void:
await process_frame
await process_frame
var art_layer := ground.get_node("ArtLayer") as Node2D
var sky_layer := ground.get_node("SkyParallaxLayer") as Node2D
# 无相机:背景保持原位(headless/菜单兜底)。
_expect_float(art_layer.position.x, 896.5, "无相机时背景保持基准位置")
# 无相机:世界层与可选远景层都保持原位(headless/菜单兜底)。
_expect_float(art_layer.position.x, 896.5, "无相机时地面世界层保持基准位置")
_expect_float(sky_layer.position.x, 896.5, "无相机时远景视差层保持基准位置")
# 有相机:偏移 = (相机中心 - 1734.5) × 0.8——"远景天幕"语义,背景 80%
# 跟随相机,仅 0.2 倍速随世界滚动,保证远景夕阳时刻在画面内(策划定案)
# 有相机:地面/步道保持 1:1 世界坐标;远景容器按
# (相机中心 - 1734.5) × 0.8 视差移动
var camera := Camera2D.new()
root.add_child(camera)
camera.make_current()
camera.global_position = Vector2(2547.0, 395.0)
await process_frame
await process_frame
_expect_float(art_layer.position.x, 896.5 + (2547.0 - 1734.5) * 0.8, "背景 80% 跟随相机(天幕视差")
_expect_float(art_layer.position.x, 896.5, "相机右移时地面世界层不做视差")
_expect_float(sky_layer.position.x, 896.5 + (2547.0 - 1734.5) * 0.8, "相机右移时远景层 80% 跟随")
camera.global_position = Vector2(1547.0, 395.0)
await process_frame
await process_frame
_expect_float(art_layer.position.x, 896.5 + (1547.0 - 1734.5) * 0.8, "相机左移时天幕对称跟随")
_expect_float(art_layer.position.x, 896.5, "相机左移时地面世界层仍不做视差")
_expect_float(sky_layer.position.x, 896.5 + (1547.0 - 1734.5) * 0.8, "相机左移时远景层对称跟随")
camera.free()
ground.free()