#100 (#97b): waves replace the roaming enemy spawner
The old spawner put one enemy on the map every 3-6 seconds for ever. There
was no rhythm to the danger and nothing to prepare for. Enemies now arrive
in waves: a long quiet, a warning, an attack, and quiet again once the wave
is dead.
The clock lives in GameState, because Main.tscn is destroyed on every trip
to a planet surface and a wave must not be cancelled, nor restarted from
zero, by a landing. The spawning does not: GameState cannot see the world,
so EnemySpawner reads the phase and puts the enemies in it. That also
settles what happens when a wave turns while the player is on a surface -
the wave is waiting in space when they come back.
EnemySpawner keeps its name deliberately. It is still the thing that puts
enemies in the world, and 88 test files already say
`get_node("EnemySpawner").set_process(false)` to ask for a quiet map.
Strength is a function of the FLEET, per the ticket, with the wave number as
a slower second term so a player who never grows still meets escalation. The
budget is spent most-expensive-first, so a large wave is heavier rather than
merely longer, and min_strength keeps the first waves to chasers whatever
the arithmetic says.
"No timer should be visible" is enforced rather than merely observed:
GameState offers wave_warning_pulse(), a 0..1 throb, and no
remaining-seconds accessor at all, so the alert cannot drift into being a
countdown. The test pins both - that no such accessor exists, and that the
alert's own text contains no digits.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
c221584848
commit
26f3728546
6 changed files with 593 additions and 62 deletions
|
|
@ -24,8 +24,8 @@ extends SceneTree
|
|||
# ship that moves after the lock is missed, which is what makes the
|
||||
# telegraph worth reading
|
||||
# F. it dies on contact, taking the vessel with it
|
||||
# G. the roaming spawner can actually produce one, and still produces
|
||||
# chasers - a new enemy nobody meets is not a new enemy
|
||||
# G. a wave can actually field one, and still fields chasers - a new
|
||||
# enemy nobody meets is not a new enemy
|
||||
|
||||
var game_state: Node
|
||||
var main: Node
|
||||
|
|
@ -241,29 +241,25 @@ func _process(delta: float) -> bool:
|
|||
return _fail("the rammed vessel survived, so the lancer cost nothing to stop")
|
||||
print("lancer and vessel destroyed each other")
|
||||
|
||||
# --- G. the roaming spawner puts it in the game --------------
|
||||
var spawner: Node2D = main.get_node("EnemySpawner")
|
||||
if spawner.lancer_chance <= 0.0 or spawner.lancer_chance >= 1.0:
|
||||
return _fail("the spawner's lancer_chance is %.2f - it makes one enemy or the other, never a mix" \
|
||||
% spawner.lancer_chance)
|
||||
# The spawner refuses to spawn against an empty fleet, and the
|
||||
# ram just emptied it - the wipe safety net normally refills it,
|
||||
# but this must not depend on that having happened yet.
|
||||
if fleet.ships.is_empty():
|
||||
fleet.spawn_ships(1, Vector2(8000.0, 0.0))
|
||||
var made: Dictionary = {}
|
||||
for i in range(40):
|
||||
spawner.lancer_chance = 1.0 if i % 2 == 0 else 0.0
|
||||
spawner._spawn_enemy()
|
||||
for enemy in get_nodes_in_group("enemies"):
|
||||
if is_instance_valid(enemy):
|
||||
made[enemy.enemy_id] = true
|
||||
enemy.queue_free()
|
||||
if not made.has("lancer"):
|
||||
return _fail("40 spawns at lancer_chance 1.0 produced no lancer")
|
||||
if not made.has("chaser"):
|
||||
return _fail("40 spawns at lancer_chance 0.0 produced no chaser")
|
||||
print("the roaming spawner makes both %s" % str(made.keys()))
|
||||
# --- G. waves can field one ---------------------------------
|
||||
# This originally checked the roaming spawner's share of lancers.
|
||||
# #100 removed that spawner entirely: the way a lancer reaches
|
||||
# the player now is a wave, so that is what has to be able to
|
||||
# produce one - and it must not have displaced the chaser while
|
||||
# doing it.
|
||||
var kinds: Dictionary = {}
|
||||
var lancer_at: int = 0
|
||||
for size in range(1, 12):
|
||||
for id in game_state.wave_composition(size):
|
||||
kinds[id] = true
|
||||
if id == "lancer" and lancer_at == 0:
|
||||
lancer_at = size
|
||||
if lancer_at == 0:
|
||||
return _fail("no wave against a fleet of 1..11 contains a lancer")
|
||||
if not kinds.has("chaser"):
|
||||
return _fail("lancers displaced the chaser out of every wave")
|
||||
print("waves field lancers from a fleet of %d up, alongside %s" \
|
||||
% [lancer_at, str(kinds.keys())])
|
||||
|
||||
print("TEST_LANCER_ENEMY: PASS")
|
||||
quit(0)
|
||||
|
|
|
|||
228
tests/test_wave_system.gd
Normal file
228
tests/test_wave_system.gd
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless test: run with
|
||||
# godot --headless -s res://tests/test_wave_system.gd
|
||||
# Forgejo #100 (#97b): waves replace the roaming enemy spawner.
|
||||
#
|
||||
# The old EnemySpawner put one enemy on the map every 3-6 seconds for ever.
|
||||
# There was no rhythm to it and nothing to prepare for. Enemies now arrive
|
||||
# in waves: a long quiet, a warning, an attack, and quiet again once the
|
||||
# wave is dead.
|
||||
#
|
||||
# The clock lives in GameState because Main.tscn is destroyed on every trip
|
||||
# to a planet surface - a wave must not be cancelled, nor restarted from
|
||||
# zero, by a landing.
|
||||
#
|
||||
# Covers:
|
||||
# A. the phase machine runs peace -> warning -> active, and a wave that is
|
||||
# killed puts it back to peace
|
||||
# B. no timer is exposed and none is drawn. #97 asks for an alert with no
|
||||
# visible countdown, so the alert's own text must carry no digits and
|
||||
# GameState must not offer a remaining-seconds accessor at all
|
||||
# C. waves get stronger with the FLEET, and with the wave number
|
||||
# D. the trickle is gone: a live director with no wave running leaves the
|
||||
# map empty, where the old spawner would have filled it
|
||||
# E. a launched wave really arrives, around the fleet, and clearing it
|
||||
# returns the world to peace
|
||||
# F. the alert is up during the warning and at no other time
|
||||
|
||||
var game_state: Node
|
||||
var scene: Node
|
||||
var spawner: Node2D
|
||||
var fleet: Node2D
|
||||
var alert: Control
|
||||
var stage: int = 0
|
||||
var elapsed: float = 0.0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
# GameState is an autoload: a bare GameState.<instance member> does not
|
||||
# compile in a SceneTree script, so the node is fetched by name instead.
|
||||
game_state = root.get_node("GameState")
|
||||
game_state.buildings.clear()
|
||||
game_state.extractors.clear()
|
||||
game_state.transporters.clear()
|
||||
game_state.reset_progression()
|
||||
game_state.reset_waves()
|
||||
|
||||
|
||||
func _fail(message: String) -> bool:
|
||||
print("TEST_WAVE_SYSTEM: FAIL (%s)" % message)
|
||||
quit(1)
|
||||
return true
|
||||
|
||||
|
||||
func _state_cases() -> bool:
|
||||
# --- A. the phase machine --------------------------------------------
|
||||
if game_state.wave_phase != GameState.WAVE_PHASE_PEACE:
|
||||
return _fail("a reset game does not open in peace")
|
||||
game_state.advance_waves(GameState.WAVE_PEACE_SECONDS - 0.1)
|
||||
if game_state.wave_phase != GameState.WAVE_PHASE_PEACE:
|
||||
return _fail("the peace ended early")
|
||||
game_state.advance_waves(0.2)
|
||||
if not game_state.wave_is_incoming():
|
||||
return _fail("the peace ran out without a warning")
|
||||
if game_state.wave_number != 0:
|
||||
return _fail("the wave was counted while it was still only a warning")
|
||||
game_state.advance_waves(GameState.WAVE_WARNING_SECONDS - 0.1)
|
||||
if not game_state.wave_is_incoming():
|
||||
return _fail("the warning ended early")
|
||||
game_state.advance_waves(0.2)
|
||||
if game_state.wave_phase != GameState.WAVE_PHASE_ACTIVE:
|
||||
return _fail("the warning did not turn into an attack")
|
||||
if game_state.wave_number != 1 or not game_state.wave_pending:
|
||||
return _fail("wave %d turned active without being owed to anyone" % game_state.wave_number)
|
||||
game_state.wave_cleared()
|
||||
if game_state.wave_phase != GameState.WAVE_PHASE_PEACE or game_state.wave_pending:
|
||||
return _fail("clearing the wave did not return the world to peace")
|
||||
print("peace -> warning -> wave 1 -> peace")
|
||||
|
||||
# --- B. nothing that could be read as a countdown --------------------
|
||||
for forbidden in ["wave_seconds_left", "wave_time_left", "wave_countdown",
|
||||
"seconds_until_wave", "wave_remaining"]:
|
||||
if game_state.has_method(forbidden):
|
||||
return _fail("GameState.%s() exists - the alert could become a timer" % forbidden)
|
||||
game_state.advance_waves(GameState.WAVE_PEACE_SECONDS + 0.1)
|
||||
if not game_state.wave_is_incoming():
|
||||
return _fail("could not get back into a warning for the pulse check")
|
||||
var pulse: float = game_state.wave_warning_pulse()
|
||||
if pulse < 0.0 or pulse > 1.0:
|
||||
return _fail("the warning pulse is %f, outside 0..1" % pulse)
|
||||
print("no remaining-seconds accessor exists; urgency is a %.2f pulse" % pulse)
|
||||
|
||||
# --- C. strength follows the fleet -----------------------------------
|
||||
var small: PackedStringArray = game_state.wave_composition(1)
|
||||
var large: PackedStringArray = game_state.wave_composition(9)
|
||||
if small.is_empty():
|
||||
return _fail("a wave against a single vessel is empty")
|
||||
if large.size() <= small.size():
|
||||
return _fail("a fleet of 9 draws %d enemies, no more than the %d a single vessel draws" \
|
||||
% [large.size(), small.size()])
|
||||
if game_state.wave_strength(9) <= game_state.wave_strength(1):
|
||||
return _fail("wave strength does not rise with fleet size")
|
||||
var before_number: int = game_state.wave_number
|
||||
game_state.wave_number = before_number + 20
|
||||
var later: PackedStringArray = game_state.wave_composition(1)
|
||||
game_state.wave_number = before_number
|
||||
if later.size() <= small.size():
|
||||
return _fail("wave 21 against one vessel is no bigger than wave 1 - there is no escalation")
|
||||
# A big wave must not be the same swarm at greater length.
|
||||
var heavy_kinds: Dictionary = {}
|
||||
for id in large:
|
||||
heavy_kinds[id] = true
|
||||
if heavy_kinds.size() < 2:
|
||||
return _fail("a large wave is %d copies of one enemy" % large.size())
|
||||
if large.size() > GameState.WAVE_MAX_ENEMIES:
|
||||
return _fail("a wave of %d exceeds the %d cap" % [large.size(), GameState.WAVE_MAX_ENEMIES])
|
||||
print("fleet 1 -> %d enemies, fleet 9 -> %d enemies of %d kinds" \
|
||||
% [small.size(), large.size(), heavy_kinds.size()])
|
||||
game_state.reset_waves()
|
||||
return false
|
||||
|
||||
|
||||
func _enemy_count() -> int:
|
||||
var n: int = 0
|
||||
for enemy in get_nodes_in_group("enemies"):
|
||||
if is_instance_valid(enemy) and not enemy.is_queued_for_deletion():
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
func _process(delta: float) -> bool:
|
||||
elapsed += delta
|
||||
match stage:
|
||||
0:
|
||||
if _state_cases():
|
||||
return true
|
||||
scene = load("res://world/Main.tscn").instantiate()
|
||||
root.add_child(scene)
|
||||
spawner = scene.get_node("EnemySpawner")
|
||||
fleet = scene.get_node("Fleet")
|
||||
alert = scene.get_node("UI/WaveAlert")
|
||||
elapsed = 0.0
|
||||
stage = 1
|
||||
return false
|
||||
1:
|
||||
if elapsed < 0.3:
|
||||
return false
|
||||
scene.get_node("AsteroidField").set_process(false)
|
||||
for hazard in get_nodes_in_group("asteroids") + get_nodes_in_group("enemies"):
|
||||
if is_instance_valid(hazard):
|
||||
hazard.queue_free()
|
||||
# The director stays ENABLED for this test - it is the thing
|
||||
# under test.
|
||||
game_state.reset_waves()
|
||||
elapsed = 0.0
|
||||
stage = 2
|
||||
return false
|
||||
2:
|
||||
# --- D. no trickle ------------------------------------------
|
||||
# Comfortably longer than the old 3-6s spawn interval, so the
|
||||
# removed behaviour would have shown up several times over.
|
||||
if elapsed < 8.0:
|
||||
if _enemy_count() > 0:
|
||||
return _fail("an enemy appeared with no wave running - the trickle is still there")
|
||||
return false
|
||||
if game_state.wave_phase != GameState.WAVE_PHASE_PEACE:
|
||||
return _fail("the peace should still be running after %.0fs" % elapsed)
|
||||
print("%.0fs of peace and not one enemy - the old trickle is gone" % elapsed)
|
||||
|
||||
# --- F. the alert is up only during the warning --------------
|
||||
if alert.visible:
|
||||
return _fail("the wave alert is showing during peacetime")
|
||||
game_state.wave_phase = GameState.WAVE_PHASE_WARNING
|
||||
game_state.wave_timer = 0.0
|
||||
alert._process(0.0)
|
||||
if not alert.visible:
|
||||
return _fail("the wave alert did not appear for an incoming wave")
|
||||
var text: String = alert.label.text
|
||||
for c in text:
|
||||
if c >= "0" and c <= "9":
|
||||
return _fail("the alert reads \"%s\" - that is a timer" % text)
|
||||
print("the alert reads \"%s\" during the warning, with no digits in it" % text)
|
||||
elapsed = 0.0
|
||||
stage = 3
|
||||
return false
|
||||
3:
|
||||
# --- E. the wave really arrives -----------------------------
|
||||
game_state.wave_phase = GameState.WAVE_PHASE_ACTIVE
|
||||
game_state.wave_number += 1
|
||||
game_state.wave_pending = true
|
||||
spawner._process(0.0)
|
||||
var arrived: int = _enemy_count()
|
||||
if arrived <= 0:
|
||||
return _fail("the wave turned active and nothing arrived")
|
||||
if game_state.wave_pending:
|
||||
return _fail("the wave was launched but is still owed")
|
||||
if alert.visible:
|
||||
alert._process(0.0)
|
||||
if alert.visible:
|
||||
return _fail("the alert is still up after the wave arrived")
|
||||
var centre: Vector2 = fleet.get_centroid()
|
||||
var nearest: float = INF
|
||||
for enemy in get_nodes_in_group("enemies"):
|
||||
if is_instance_valid(enemy):
|
||||
nearest = minf(nearest, centre.distance_to(enemy.global_position))
|
||||
if nearest < 200.0:
|
||||
return _fail("a wave enemy arrived %.0fpx from the fleet - that is on top of them" % nearest)
|
||||
print("wave %d arrived: %d enemies, nearest %.0fpx away" \
|
||||
% [game_state.wave_number, arrived, nearest])
|
||||
|
||||
for enemy in get_nodes_in_group("enemies"):
|
||||
if is_instance_valid(enemy):
|
||||
enemy.queue_free()
|
||||
elapsed = 0.0
|
||||
stage = 4
|
||||
return false
|
||||
4:
|
||||
if elapsed < 0.2:
|
||||
return false
|
||||
spawner._process(0.0)
|
||||
if game_state.wave_phase != GameState.WAVE_PHASE_PEACE:
|
||||
return _fail("killing the whole wave did not bring the quiet back")
|
||||
print("the wave died and the quiet came back")
|
||||
|
||||
print("TEST_WAVE_SYSTEM: PASS")
|
||||
quit(0)
|
||||
return true
|
||||
return false
|
||||
Loading…
Reference in a new issue