fix: repair parallax collisions and add play guide
This commit is contained in:
@@ -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
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user