Files
rthythm_archor_game/tests/test_level1_spawn_flow.gd
T
2026-07-06 22:59:47 -07:00

183 lines
7.4 KiB
GDScript

extends SceneTree
## 第一关出怪流程(关卡设计指南 §7/§8):
## 阶段一预置近战怪 → 击杀后阶段二在玩家附近刷远程怪 → 再击杀后阶段三
## 左右交替增援 2近战+2远程 → 全灭 6 只解封 Boss 房门。
var failures: Array[String] = []
var cleared_signals := 0
const MINION_GATE_CLEARANCE := 220.0
func _init() -> void:
_run.call_deferred()
func _run() -> void:
await process_frame
var rhythm := root.get_node_or_null("RhythmManager")
if rhythm != null and rhythm.has_method("stop_manager"):
rhythm.call("stop_manager")
var bus := _ensure_event_bus()
if bus.has_signal("front_area_cleared"):
bus.connect("front_area_cleared", func() -> void: cleared_signals += 1)
var stage_scene: PackedScene = load("res://scenes/stage/stage.tscn")
_expect(stage_scene != null, "stage.tscn should load")
if stage_scene == null:
_finish()
return
var stage: Node = stage_scene.instantiate()
root.add_child(stage)
await process_frame
await process_frame
var director: Node = stage.get_node_or_null("LevelDirector")
var gate: Node = stage.get_node_or_null("BossRoomGate")
var container: Node = stage.get_node("ActorsContainer")
var player: Node2D = container.get_node("Player")
_expect(director != null, "Stage should carry a LevelDirector")
_expect(gate != null, "Stage should carry a BossRoomGate")
if director == null or gate == null:
stage.free()
_finish()
return
director.set("reinforcement_time_scale", 0.01)
# ---- 阶段一:只有 1 只预置近战怪,Boss 房门封锁 ----
var initial := _alive_minions(container)
_expect_int(initial.size(), 1, "Phase 1 should start with exactly one pre-placed minion")
if initial.size() == 1:
_expect_equal(initial[0].get("strength_profile"), &"past_strong", "The pre-placed minion should be the melee (past-strong) one")
_expect_minions_left_of_gate(initial, gate, "Initial pre-placed minion")
var initial_behavior := initial[0].get_node_or_null("MinionBehavior")
if initial_behavior != null:
_expect_float(float(initial_behavior.get("arena_max_x")), float(gate.get("gate_x")) - MINION_GATE_CLEARANCE, "Initial pre-placed minion should use the front-area safe patrol max")
_expect(bool(gate.get("sealed")), "Gate should start sealed")
var wall_shape := gate.get_node("GateWall").get_child(0) as CollisionShape2D
_expect(not wall_shape.disabled, "Sealed gate should enable its blocking wall")
_expect_int(int(director.get("phase")), 1, "Director should start in phase 1")
# ---- 击杀近战怪 → 阶段二远程怪在玩家附近刷新(可感知、不贴脸)----
_kill(initial[0])
await process_frame
_expect_int(int(director.get("phase")), 2, "First kill should advance to phase 2")
var phase2 := _alive_minions(container)
_expect_int(phase2.size(), 1, "Phase 2 should spawn exactly one new minion")
if phase2.size() == 1:
var ranged: Node2D = phase2[0]
_expect_equal(ranged.get("strength_profile"), &"future_strong", "Phase 2 minion should be the ranged (future-strong) one")
_expect_minions_left_of_gate(phase2, gate, "Phase 2 minion")
var distance := absf(ranged.global_position.x - player.global_position.x)
_expect(distance >= 200.0 and distance <= 480.0, "Ranged minion should spawn near but not on the player (got %.0f px)" % distance)
var behavior := ranged.get_node_or_null("MinionBehavior")
_expect(behavior != null and behavior.get("role") == &"ranged", "Phase 2 minion behavior should use the ranged role")
# ---- 击杀远程怪 → 阶段三增援:近战L → 远程R → 近战R → 远程L ----
_kill(ranged)
await process_frame
_expect_int(int(director.get("phase")), 3, "Second kill should advance to phase 3")
# 缩放后的增援间隔与 headless 帧长同数量级:只断言 0 秒那只已经
# 立即出现,后续数量交给下面“全部四只”断言覆盖。
var first_wave := _alive_minions(container)
_expect(first_wave.size() >= 1, "First reinforcement should spawn immediately (got %d)" % first_wave.size())
if first_wave.size() >= 1:
_expect_equal(first_wave[0].get("strength_profile"), &"past_strong", "First reinforcement should be melee")
_expect(first_wave[0].global_position.x < 1500.0, "First reinforcement should enter from the LEFT edge")
# 其余三只按 0.01 缩放的间隔陆续出现。
await create_timer(0.5).timeout
await process_frame
var reinforcements := _alive_minions(container)
_expect_int(reinforcements.size(), 4, "All four reinforcements should have spawned")
_expect_minions_left_of_gate(reinforcements, gate, "Phase 3 reinforcement")
_expect_int(int(director.get("total_spawned")), 6, "Level should field six minions in total")
var melee_count := 0
var ranged_count := 0
for minion: Node2D in reinforcements:
if minion.get("strength_profile") == &"past_strong":
melee_count += 1
else:
ranged_count += 1
_expect_int(melee_count, 2, "Reinforcements should include two melee minions")
_expect_int(ranged_count, 2, "Reinforcements should include two ranged minions")
# ---- 全灭 → 解封 Boss 房门 ----
_expect(bool(gate.get("sealed")), "Gate must stay sealed while reinforcements live")
for minion: Node2D in reinforcements:
_kill(minion)
await process_frame
await process_frame
_expect(bool(director.get("cleared")), "Director should report the front area cleared")
_expect(not bool(gate.get("sealed")), "Clearing all six minions should unseal the gate")
_expect(wall_shape.disabled, "Unsealed gate should disable its blocking wall")
_expect_int(cleared_signals, 1, "front_area_cleared should broadcast exactly once")
stage.free()
_finish()
func _alive_minions(container: Node) -> Array[Node2D]:
var result: Array[Node2D] = []
for child: Node in container.get_children():
if not child.is_in_group("enemies"):
continue
var health := child.get_node_or_null("HealthComponent")
if health != null and int(health.get("current")) > 0:
result.append(child as Node2D)
return result
func _kill(minion: Node) -> void:
var health := minion.get_node_or_null("HealthComponent")
if health != null:
health.call("apply_damage", 999999)
func _expect_minions_left_of_gate(minions: Array[Node2D], gate: Node, label: String) -> void:
var limit := float(gate.get("gate_x")) - MINION_GATE_CLEARANCE
for minion: Node2D in minions:
_expect(
minion.global_position.x <= limit,
"%s should stay left of the boss-room air wall safety line %.0f, got %.0f" % [label, limit, minion.global_position.x]
)
func _ensure_event_bus() -> Node:
var bus := root.get_node_or_null("EventBus")
if bus == null:
bus = load("res://autoload/event_bus.gd").new()
bus.name = "EventBus"
root.add_child(bus)
return bus
func _expect(condition: bool, label: String) -> void:
if not condition:
failures.append(label)
func _expect_int(actual: int, expected: int, label: String) -> void:
if actual != expected:
failures.append("%s: expected %d, got %d" % [label, expected, actual])
func _expect_equal(actual: Variant, expected: Variant, label: String) -> void:
if actual != expected:
failures.append("%s: expected %s, got %s" % [label, expected, actual])
func _expect_float(actual: float, expected: float, label: String, tolerance := 0.01) -> void:
if absf(actual - expected) > tolerance:
failures.append("%s: expected %.2f, got %.2f" % [label, expected, actual])
func _finish() -> void:
if failures.is_empty():
print("PASS level1 spawn flow")
quit(0)
else:
for failure: String in failures:
push_error(failure)
quit(1)