#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:
Loïc Wisniewski 2026-08-23 13:37:42 +02:00
commit 26f3728546
6 changed files with 593 additions and 62 deletions

View file

@ -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)