250 lines
9.6 KiB
GDScript3
250 lines
9.6 KiB
GDScript3
|
|
extends SceneTree
|
||
|
|
|
||
|
|
# Headless gameplay test: run with
|
||
|
|
# godot --headless -s res://tests/test_enemy_mothership.gd
|
||
|
|
# Forgejo #53: a big, slow capital enemy that spawns chasers/artillery on
|
||
|
|
# their own intervals and fires a heavily telegraphed line beam.
|
||
|
|
#
|
||
|
|
# Split into two independent parts, each with its own fleet setup:
|
||
|
|
# A. EnemyBigLaserBeam mechanics, tested directly (not through the
|
||
|
|
# component's own tick/cooldown timing, which would make the precise
|
||
|
|
# geometry assertions below timing-fragile):
|
||
|
|
# 1. the telegraph holds for the charge - no damage, phase stays
|
||
|
|
# CHARGING, for most of charge_time
|
||
|
|
# 2. the locked aim direction never changes even if the fleet moves
|
||
|
|
# away mid-charge
|
||
|
|
# 3. three ships in a line all die from one shot; a ship that steps
|
||
|
|
# outside beam_half_width before the shot lands survives
|
||
|
|
# 4. a shielded ship in the beam survives, losing exactly one charge
|
||
|
|
# B. The composed enemy_mothership definition, tested through a real
|
||
|
|
# Enemy.tscn instance:
|
||
|
|
# 5. structural composition - pursuit, two DISTINCT spawner presets
|
||
|
|
# (chaser/artillery), big_laser, no type switch anywhere
|
||
|
|
# 6. it launches both chaser and artillery spawns on their own
|
||
|
|
# intervals
|
||
|
|
# 7. it closes on the fleet without ever exceeding its own defined
|
||
|
|
# speed - "never reaches ramming speed"
|
||
|
|
#
|
||
|
|
# Repo-wide lessons from #52, both apply again here:
|
||
|
|
# - main.get_node(...).set_process(false) has to happen in _process()'s
|
||
|
|
# first real tick, not _initialize() - a disable made before the node
|
||
|
|
# has finished entering the tree doesn't reliably stick.
|
||
|
|
# - overriding an @export interval on an already-ticking component does
|
||
|
|
# NOT reset its in-progress countdown - reset the timer variable too.
|
||
|
|
|
||
|
|
var game_state: Node
|
||
|
|
var main: Node
|
||
|
|
var fleet: Node
|
||
|
|
var beam_script: Script
|
||
|
|
var beam: Node2D
|
||
|
|
var line_a: Node
|
||
|
|
var line_b: Node
|
||
|
|
var line_c: Node
|
||
|
|
var shield_ship: Node
|
||
|
|
var initial_aim: Vector2
|
||
|
|
var mothership: Node
|
||
|
|
var chaser_spawner: Node
|
||
|
|
var artillery_spawner: Node
|
||
|
|
var mothership_start_position: Vector2
|
||
|
|
var closing_elapsed: float = 0.0
|
||
|
|
var stage: int = 0
|
||
|
|
var elapsed: float = 0.0
|
||
|
|
|
||
|
|
|
||
|
|
func _initialize() -> void:
|
||
|
|
game_state = root.get_node("GameState")
|
||
|
|
beam_script = load("res://EnemyBigLaserBeam.gd")
|
||
|
|
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
|
||
|
|
main.get_node("EnemySpawner").set_process(false)
|
||
|
|
main.get_node("AsteroidField").set_process(false)
|
||
|
|
main.get_node("BuildController").set_process(false)
|
||
|
|
for ship in fleet.ships:
|
||
|
|
if is_instance_valid(ship):
|
||
|
|
ship.queue_free()
|
||
|
|
fleet.ships.clear()
|
||
|
|
|
||
|
|
# --- A: line-up three ships along +X, one shielded further out ---
|
||
|
|
fleet.add_ship(game_state.VESSEL_INTERCEPTOR)
|
||
|
|
line_a = fleet.ships[fleet.ships.size() - 1]
|
||
|
|
_pin(line_a, Vector2(300, 0))
|
||
|
|
fleet.add_ship(game_state.VESSEL_INTERCEPTOR)
|
||
|
|
line_b = fleet.ships[fleet.ships.size() - 1]
|
||
|
|
_pin(line_b, Vector2(400, 0))
|
||
|
|
fleet.add_ship(game_state.VESSEL_INTERCEPTOR)
|
||
|
|
line_c = fleet.ships[fleet.ships.size() - 1]
|
||
|
|
_pin(line_c, Vector2(500, 0))
|
||
|
|
fleet.add_ship(game_state.VESSEL_SHIELD)
|
||
|
|
shield_ship = fleet.ships[fleet.ships.size() - 1]
|
||
|
|
_pin(shield_ship, Vector2(600, 0))
|
||
|
|
if not shield_ship.has_shield():
|
||
|
|
return _fail("shield ship has no shield right after being added")
|
||
|
|
|
||
|
|
var beam_scene: PackedScene = load("res://EnemyBigLaserBeam.tscn")
|
||
|
|
beam = beam_scene.instantiate()
|
||
|
|
beam.charge_time = 0.5
|
||
|
|
beam.beam_range = 1000.0
|
||
|
|
beam.beam_half_width = 35.0
|
||
|
|
beam.damage = 3
|
||
|
|
root.add_child(beam)
|
||
|
|
beam.launch(Vector2.ZERO, Vector2(1, 0))
|
||
|
|
initial_aim = beam.aim_direction
|
||
|
|
elapsed = 0.0
|
||
|
|
stage = 1
|
||
|
|
return false
|
||
|
|
1:
|
||
|
|
# --- 1. telegraph holds, no damage, for most of the charge ---
|
||
|
|
if elapsed < beam.charge_time * 0.6:
|
||
|
|
return false
|
||
|
|
if beam.phase != beam_script.Phase.CHARGING:
|
||
|
|
return _fail("beam left CHARGING before charge_time elapsed")
|
||
|
|
for ship in [line_a, line_b, line_c, shield_ship]:
|
||
|
|
if not is_instance_valid(ship):
|
||
|
|
return _fail("a ship in the beam's path died before the charge finished")
|
||
|
|
|
||
|
|
# Move line_b out of the beam's path - this alone also shifts
|
||
|
|
# fleet.get_centroid() (line_b is part of the same fleet), which
|
||
|
|
# is exactly the "the fleet moved mid-charge" event stage 2
|
||
|
|
# needs to prove the beam does NOT re-track. line_a/line_c/
|
||
|
|
# shield_ship stay exactly where they were.
|
||
|
|
_pin(line_b, Vector2(400, 400))
|
||
|
|
elapsed = 0.0
|
||
|
|
stage = 2
|
||
|
|
return false
|
||
|
|
2:
|
||
|
|
if elapsed < beam.charge_time * 0.6:
|
||
|
|
return false
|
||
|
|
if beam.aim_direction != initial_aim:
|
||
|
|
return _fail("beam re-tracked the fleet mid-charge - aim_direction changed from %s to %s" % [
|
||
|
|
initial_aim, beam.aim_direction
|
||
|
|
])
|
||
|
|
elapsed = 0.0
|
||
|
|
stage = 3
|
||
|
|
return false
|
||
|
|
3:
|
||
|
|
# --- 3 + 4. the shot has landed by now ---
|
||
|
|
if is_instance_valid(line_a):
|
||
|
|
return _fail("line_a (still in the beam) survived the shot")
|
||
|
|
if is_instance_valid(line_c):
|
||
|
|
return _fail("line_c (still in the beam) survived the shot")
|
||
|
|
if not is_instance_valid(line_b):
|
||
|
|
return _fail("line_b (moved out of the beam before it fired) was killed anyway")
|
||
|
|
if not is_instance_valid(shield_ship):
|
||
|
|
return _fail("the shielded ship died despite having a shield")
|
||
|
|
if shield_ship.shield_charges != 0:
|
||
|
|
return _fail("shield charges did not drop to exactly 1 lost (charges=%d)" % shield_ship.shield_charges)
|
||
|
|
|
||
|
|
# --- B: reset to a clean single-ship fleet for the mothership ---
|
||
|
|
for ship in fleet.ships:
|
||
|
|
if is_instance_valid(ship):
|
||
|
|
ship.queue_free()
|
||
|
|
fleet.ships.clear()
|
||
|
|
fleet.spawn_ships(1, Vector2(3000, 0))
|
||
|
|
|
||
|
|
mothership = load("res://Enemy.tscn").instantiate()
|
||
|
|
mothership.enemy_id = "enemy_mothership"
|
||
|
|
mothership.position = Vector2.ZERO
|
||
|
|
main.add_child(mothership)
|
||
|
|
elapsed = 0.0
|
||
|
|
stage = 4
|
||
|
|
return false
|
||
|
|
4:
|
||
|
|
if elapsed < 0.15:
|
||
|
|
return false
|
||
|
|
# --- 5. structural composition, no type switch ---
|
||
|
|
if mothership.max_hp != 60:
|
||
|
|
return _fail("enemy_mothership max_hp is %d, expected 60" % mothership.max_hp)
|
||
|
|
if mothership.speed != 35.0:
|
||
|
|
return _fail("enemy_mothership speed is %s, expected 35.0" % mothership.speed)
|
||
|
|
if not mothership.has_capability("pursuit"):
|
||
|
|
return _fail("enemy_mothership has no pursuit component - it should close on the fleet")
|
||
|
|
if not mothership.has_capability("big_laser"):
|
||
|
|
return _fail("enemy_mothership has no big_laser component")
|
||
|
|
|
||
|
|
var spawners: Array = []
|
||
|
|
for child in mothership.get_children():
|
||
|
|
if child.get("capability_id") == "spawner":
|
||
|
|
spawners.append(child)
|
||
|
|
if spawners.size() != 2:
|
||
|
|
return _fail("enemy_mothership has %d spawner components, expected 2 (chaser + artillery)" % spawners.size())
|
||
|
|
for spawner in spawners:
|
||
|
|
if spawner.spawned_enemy_id == "chaser":
|
||
|
|
chaser_spawner = spawner
|
||
|
|
elif spawner.spawned_enemy_id == "artillery":
|
||
|
|
artillery_spawner = spawner
|
||
|
|
if chaser_spawner == null or artillery_spawner == null:
|
||
|
|
return _fail("the two spawner components are not one chaser + one artillery preset")
|
||
|
|
|
||
|
|
# Speed up both intervals for the test, resetting the in-progress
|
||
|
|
# timer too (#52's lesson: changing the interval alone does not
|
||
|
|
# touch a countdown already running from the definition default).
|
||
|
|
# Staggered slightly rather than both at 0.0: two sibling
|
||
|
|
# spawners both instantiating Enemy.tscn on the exact same
|
||
|
|
# physics frame hit a transient PackedScene.instantiate()
|
||
|
|
# failure ("node count is 0") on this engine build - spreading
|
||
|
|
# their first fire across different frames avoids it, and is
|
||
|
|
# also what naturally happens in real play anyway.
|
||
|
|
chaser_spawner.spawn_interval = 0.2
|
||
|
|
chaser_spawner.spawn_timer = 0.0
|
||
|
|
artillery_spawner.spawn_interval = 0.3
|
||
|
|
artillery_spawner.spawn_timer = 0.05
|
||
|
|
|
||
|
|
mothership_start_position = mothership.position
|
||
|
|
elapsed = 0.0
|
||
|
|
closing_elapsed = 0.0
|
||
|
|
stage = 5
|
||
|
|
return false
|
||
|
|
5:
|
||
|
|
# --- 6. launches both chasers and artillery on their own intervals ---
|
||
|
|
closing_elapsed += delta
|
||
|
|
if elapsed < 1.2:
|
||
|
|
return false
|
||
|
|
if chaser_spawner.spawned.is_empty():
|
||
|
|
return _fail("mothership never spawned a chaser")
|
||
|
|
if artillery_spawner.spawned.is_empty():
|
||
|
|
return _fail("mothership never spawned an artillery")
|
||
|
|
for spawn in chaser_spawner.spawned:
|
||
|
|
if spawn.enemy_id != "chaser":
|
||
|
|
return _fail("a chaser-spawner spawn has enemy_id '%s'" % spawn.enemy_id)
|
||
|
|
for spawn in artillery_spawner.spawned:
|
||
|
|
if spawn.enemy_id != "artillery":
|
||
|
|
return _fail("an artillery-spawner spawn has enemy_id '%s'" % spawn.enemy_id)
|
||
|
|
|
||
|
|
# --- 7. closes on the fleet, never exceeding its own speed ---
|
||
|
|
var traveled: float = mothership.position.distance_to(mothership_start_position)
|
||
|
|
var max_possible: float = mothership.speed * closing_elapsed + 5.0 # small integration slack
|
||
|
|
if traveled > max_possible:
|
||
|
|
return _fail("mothership traveled %.1f in %.2fs, more than speed(%.1f) allows (%.1f)" % [
|
||
|
|
traveled, closing_elapsed, mothership.speed, max_possible
|
||
|
|
])
|
||
|
|
var distance_to_fleet: float = mothership.position.distance_to(fleet.get_centroid())
|
||
|
|
var starting_distance: float = mothership_start_position.distance_to(Vector2(3000, 0))
|
||
|
|
if distance_to_fleet >= starting_distance:
|
||
|
|
return _fail("mothership did not close on the fleet at all (distance %.1f >= starting %.1f)" % [
|
||
|
|
distance_to_fleet, starting_distance
|
||
|
|
])
|
||
|
|
|
||
|
|
print("TEST_ENEMY_MOTHERSHIP: PASS")
|
||
|
|
quit(0)
|
||
|
|
return true
|
||
|
|
return false
|
||
|
|
|
||
|
|
|
||
|
|
func _pin(ship: Node, at: Vector2) -> void:
|
||
|
|
ship.position = at
|
||
|
|
ship.move_target = at
|
||
|
|
|
||
|
|
|
||
|
|
func _fail(message: String) -> bool:
|
||
|
|
print("TEST_ENEMY_MOTHERSHIP: FAIL (%s)" % message)
|
||
|
|
quit(1)
|
||
|
|
return true
|