spaceship/enemies/EnemySpawner.gd
Loïc Wisniewski 26f3728546 #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>
2026-08-23 13:37:42 +02:00

62 lines
2.8 KiB
GDScript

extends Node2D
# The wave director (#100 / #97). Keeps its old node name on purpose: it is
# still "the thing that puts enemies in the world", and every test that
# wanted a quiet world already says
# `main.get_node("EnemySpawner").set_process(false)`. Renaming it would have
# changed 88 test files to say the same thing differently.
#
# What it no longer does is trickle. It used to spawn one enemy every 3-6
# seconds for ever, so there was no rhythm to the danger and nothing to
# prepare for. Now the CLOCK lives in GameState (which survives the trip to
# a planet surface) and this node only reacts to it: when a wave turns
# active, it puts the wave in the world; when the wave is dead, it says so
# and the quiet starts again.
@export var spawn_margin: float = 150.0
# Waves arrive as a ring around the fleet rather than from one bearing: a
# wave that all came from the left would be dodged by flying right.
@export var wave_spread: float = 0.55
@onready var fleet: Node2D = get_node("../Fleet")
var enemy_scene: PackedScene = preload("res://enemies/Enemy.tscn")
# The enemies this director launched, so "the wave is dead" is a question
# about THIS wave and not about every enemy on the map - a planet garrison
# or an enemy mothership's own spawns must not hold the next wave back.
var wave_enemies: Array = []
func _process(_delta: float) -> void:
wave_enemies = wave_enemies.filter(func(e): return is_instance_valid(e))
if GameState.wave_pending:
_launch_wave()
return
if GameState.wave_phase == GameState.WAVE_PHASE_ACTIVE and wave_enemies.is_empty():
GameState.wave_cleared()
# Public so a test can put a wave in the world without waiting out the peace.
func _launch_wave() -> void:
if fleet == null or fleet.ships.is_empty():
# Nothing to attack. Leave it pending rather than spending the wave
# on an empty map - the fleet-wipe safety net rebuilds within a frame
# and the wave arrives then.
return
GameState.wave_pending = false
var composition: PackedStringArray = GameState.wave_composition(fleet.ships.size())
var visible_rect: Rect2 = ViewUtils.get_visible_world_rect(self)
var radius: float = visible_rect.size.length() / 2.0 + spawn_margin
var centre: Vector2 = fleet.get_centroid()
var bearing: float = randf() * TAU
for i in range(composition.size()):
# Spread around one bearing rather than scattered at random: a wave
# should read as an attack arriving, not as the map filling up.
var angle: float = bearing + randf_range(-wave_spread, wave_spread) \
+ TAU * float(i) / float(maxi(composition.size(), 1)) * wave_spread
var enemy: Node2D = enemy_scene.instantiate()
# Before add_child: Enemy._ready() resolves enemy_id into a definition.
enemy.enemy_id = composition[i]
enemy.position = centre + Vector2(cos(angle), sin(angle)) * radius
get_parent().add_child(enemy)
wave_enemies.append(enemy)