192 lines
7.4 KiB
GDScript3
192 lines
7.4 KiB
GDScript3
|
|
extends SceneTree
|
||
|
|
|
||
|
|
# Headless gameplay test: run with
|
||
|
|
# godot --headless -s res://tests/test_turret_spawner_enemy.gd
|
||
|
|
# Forgejo #52: a stationary, high-HP enemy that continuously spawns chasers
|
||
|
|
# beside itself. Verifies:
|
||
|
|
# 1. registered in EnemyDefinitions - the map editor palette (#48) is
|
||
|
|
# generated from EnemyDefinitions.all(), so this alone is what makes
|
||
|
|
# it placeable, with no editor code change
|
||
|
|
# 2. spawns nothing while there is no fleet (or an empty one)
|
||
|
|
# 3. once a fleet exists, spawns one enemy per spawn_interval, stopping
|
||
|
|
# at max_alive
|
||
|
|
# 4. a spawn dying frees its slot, and the next interval fills it again
|
||
|
|
# 5. the turret itself never moves
|
||
|
|
# 6. survives 19 damage, dies on the 20th
|
||
|
|
# 7. its already-spawned children survive its death - they are siblings
|
||
|
|
# under its own parent, not descendants, so killing it can't free them
|
||
|
|
#
|
||
|
|
# spawn_interval/max_alive are overridden to small test-friendly values
|
||
|
|
# directly on the instantiated component, the same way tests/
|
||
|
|
# _artillery_telegraph_rocky_preview.gd pins ArtilleryShell's telegraph_time -
|
||
|
|
# the definition's real defaults (4.0s / 6 alive) are only asserted once,
|
||
|
|
# structurally, before the override.
|
||
|
|
|
||
|
|
var game_state: Node
|
||
|
|
var main: Node
|
||
|
|
var fleet: Node
|
||
|
|
var turret: Node
|
||
|
|
var spawner: Node
|
||
|
|
var stage: int = 0
|
||
|
|
var elapsed: float = 0.0
|
||
|
|
var turret_start_position: Vector2
|
||
|
|
var one_spawn_ref: Node
|
||
|
|
|
||
|
|
|
||
|
|
func _initialize() -> void:
|
||
|
|
game_state = root.get_node("GameState")
|
||
|
|
|
||
|
|
# --- 1. registered, with the definition's real defaults intact ---
|
||
|
|
if not EnemyDefinitions.ORDER.has("turret_spawner"):
|
||
|
|
_fail("turret_spawner is not in EnemyDefinitions.ORDER - the map editor palette would never show it")
|
||
|
|
return
|
||
|
|
var definition: Resource = EnemyDefinitions.get_definition("turret_spawner")
|
||
|
|
if definition.max_hp != 20:
|
||
|
|
_fail("turret_spawner max_hp is %d, expected 20 (a decent amount of life)" % definition.max_hp)
|
||
|
|
return
|
||
|
|
if definition.speed != 0.0:
|
||
|
|
_fail("turret_spawner speed is %s, expected 0.0 (stationary)" % definition.speed)
|
||
|
|
return
|
||
|
|
|
||
|
|
main = load("res://Main.tscn").instantiate()
|
||
|
|
root.add_child(main)
|
||
|
|
fleet = main.get_node("Fleet")
|
||
|
|
|
||
|
|
|
||
|
|
func _process(delta: float) -> bool:
|
||
|
|
elapsed += delta
|
||
|
|
match stage:
|
||
|
|
0:
|
||
|
|
if elapsed < 0.1:
|
||
|
|
return false
|
||
|
|
# This has to happen here, not in _initialize() - a node's
|
||
|
|
# set_process(false) call made before it has fully finished
|
||
|
|
# entering the tree doesn't reliably stick, since the engine's
|
||
|
|
# own deferred tree-entry finalization can reset it back to
|
||
|
|
# enabled. By now _ready() cascades are confirmed complete.
|
||
|
|
main.get_node("EnemySpawner").set_process(false)
|
||
|
|
main.get_node("AsteroidField").set_process(false)
|
||
|
|
# BuildController._check_fleet_wipe() runs every _process() tick
|
||
|
|
# and rebuilds a single ship the moment fleet.ships is empty -
|
||
|
|
# the exact "empty fleet" state stage 2 needs to hold for a while.
|
||
|
|
main.get_node("BuildController").set_process(false)
|
||
|
|
|
||
|
|
# Empty the fleet (gotcha: spawn_ships(0,...) still leaves one
|
||
|
|
# default interceptor - clear explicitly).
|
||
|
|
for ship in fleet.ships:
|
||
|
|
if is_instance_valid(ship):
|
||
|
|
ship.queue_free()
|
||
|
|
fleet.ships.clear()
|
||
|
|
|
||
|
|
turret = load("res://Enemy.tscn").instantiate()
|
||
|
|
turret.enemy_id = "turret_spawner"
|
||
|
|
turret.position = Vector2(500, 500)
|
||
|
|
turret_start_position = turret.position
|
||
|
|
main.add_child(turret)
|
||
|
|
elapsed = 0.0
|
||
|
|
stage = 1
|
||
|
|
return false
|
||
|
|
1:
|
||
|
|
if elapsed < 0.15:
|
||
|
|
return false
|
||
|
|
spawner = turret.get_component("spawner")
|
||
|
|
if spawner == null:
|
||
|
|
return _fail("turret_spawner has no 'spawner' capability component")
|
||
|
|
# --- 2. no fleet -> no spawns, even past several intervals ---
|
||
|
|
spawner.spawn_interval = 0.1
|
||
|
|
spawner.max_alive = 3
|
||
|
|
spawner.spawn_radius = 50.0
|
||
|
|
# spawn_timer already started counting down from the definition's
|
||
|
|
# real default (4.0s) on the component's very first tick, before
|
||
|
|
# this override could reach it - changing spawn_interval alone
|
||
|
|
# only affects the NEXT reset, not the countdown already running.
|
||
|
|
# Reset it explicitly so the override takes effect immediately.
|
||
|
|
spawner.spawn_timer = 0.0
|
||
|
|
elapsed = 0.0
|
||
|
|
stage = 2
|
||
|
|
return false
|
||
|
|
2:
|
||
|
|
if elapsed < 0.6:
|
||
|
|
return false
|
||
|
|
if spawner.spawned.size() > 0:
|
||
|
|
return _fail("spawner spawned %d enemies with no fleet present" % spawner.spawned.size())
|
||
|
|
|
||
|
|
# --- give it a fleet to spawn against ---
|
||
|
|
fleet.spawn_ships(1, Vector2(-2000, -2000))
|
||
|
|
elapsed = 0.0
|
||
|
|
stage = 3
|
||
|
|
return false
|
||
|
|
3:
|
||
|
|
# --- 3. spawns, capped at max_alive ---
|
||
|
|
if elapsed < spawner.spawn_interval * (spawner.max_alive + 2) + 0.3:
|
||
|
|
return false
|
||
|
|
if spawner.spawned.size() != spawner.max_alive:
|
||
|
|
return _fail("spawner has %d alive after plenty of time, expected max_alive=%d" % [
|
||
|
|
spawner.spawned.size(), spawner.max_alive
|
||
|
|
])
|
||
|
|
for spawn in spawner.spawned:
|
||
|
|
if spawn.enemy_id != "chaser":
|
||
|
|
return _fail("a spawn has enemy_id '%s', expected 'chaser'" % spawn.enemy_id)
|
||
|
|
if spawn.get_parent() != turret.get_parent():
|
||
|
|
return _fail("a spawn is not parented under the turret's own parent - it would not survive the turret's death correctly")
|
||
|
|
|
||
|
|
# --- 5. never moves ---
|
||
|
|
if turret.position != turret_start_position:
|
||
|
|
return _fail("turret_spawner moved from %s to %s" % [turret_start_position, turret.position])
|
||
|
|
|
||
|
|
# --- 4. a dead spawn frees its slot and gets refilled ---
|
||
|
|
one_spawn_ref = spawner.spawned[0]
|
||
|
|
one_spawn_ref.take_damage(999)
|
||
|
|
elapsed = 0.0
|
||
|
|
stage = 4
|
||
|
|
return false
|
||
|
|
4:
|
||
|
|
# --- 4. a dead spawn frees its slot, and the interval refills it ---
|
||
|
|
# spawn_interval is short enough (0.1s, overridden above) that the
|
||
|
|
# refill can land within a couple of frames of the kill - there is
|
||
|
|
# no reliably observable moment where the count sits at max_alive-1
|
||
|
|
# and stays there, so this checks the net effect once things have
|
||
|
|
# settled: the killed spawn specifically is gone, and a fresh one
|
||
|
|
# took its slot back up to max_alive, rather than checking for an
|
||
|
|
# intermediate dip that a fast interval can blow straight through.
|
||
|
|
if elapsed < spawner.spawn_interval + 0.3:
|
||
|
|
return false
|
||
|
|
if is_instance_valid(one_spawn_ref):
|
||
|
|
return _fail("killed spawn is still valid after take_damage(999)")
|
||
|
|
if spawner.spawned.has(one_spawn_ref):
|
||
|
|
return _fail("the killed spawn is still tracked in spawner.spawned")
|
||
|
|
if spawner.spawned.size() != spawner.max_alive:
|
||
|
|
return _fail("spawner did not refill the freed slot (has %d, expected %d)" % [
|
||
|
|
spawner.spawned.size(), spawner.max_alive
|
||
|
|
])
|
||
|
|
|
||
|
|
# --- 6 + 7. damage, death, and spawns surviving the death ---
|
||
|
|
var surviving_spawns: Array = spawner.spawned.duplicate()
|
||
|
|
turret.take_damage(19)
|
||
|
|
if not is_instance_valid(turret):
|
||
|
|
return _fail("turret_spawner died from 19 damage - should take 20 to kill")
|
||
|
|
turret.take_damage(1)
|
||
|
|
elapsed = 0.0
|
||
|
|
stage = 6
|
||
|
|
one_spawn_ref = surviving_spawns[0]
|
||
|
|
return false
|
||
|
|
6:
|
||
|
|
if elapsed < 0.15:
|
||
|
|
return false
|
||
|
|
if is_instance_valid(turret):
|
||
|
|
return _fail("turret_spawner survived 20 total damage")
|
||
|
|
if not is_instance_valid(one_spawn_ref):
|
||
|
|
return _fail("a spawn was freed when its spawner died - they are siblings, not descendants")
|
||
|
|
if not one_spawn_ref.has_capability("pursuit"):
|
||
|
|
return _fail("a surviving spawn lost its pursuit component - it should still chase the fleet")
|
||
|
|
|
||
|
|
print("TEST_TURRET_SPAWNER_ENEMY: PASS")
|
||
|
|
quit(0)
|
||
|
|
return true
|
||
|
|
return false
|
||
|
|
|
||
|
|
|
||
|
|
func _fail(message: String) -> bool:
|
||
|
|
print("TEST_TURRET_SPAWNER_ENEMY: FAIL (%s)" % message)
|
||
|
|
quit(1)
|
||
|
|
return true
|