#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

@ -556,6 +556,7 @@ func reset_new_game() -> void:
extractors.clear()
transporters.clear()
planet_capture.clear()
reset_waves()
reset_progression()
reset_formation()
@ -613,6 +614,9 @@ var vessel_deliveries: Array = []
const VESSEL_DELIVERY_SPEED: float = 180.0
signal order_completed(building_id: int, type: String)
# #99: fired after the record is already gone, so a listener that reacts by
# asking GameState about the world sees the world without it.
signal building_destroyed(building_id: int, type: String)
func add_building(type: String, at_position: Vector2) -> int:
@ -621,6 +625,7 @@ func add_building(type: String, at_position: Vector2) -> int:
buildings.append({
"id": id, "type": type, "position": at_position,
"inventory": INVENTORY.new(), "orders": [],
"hp": building_max_hp(type),
})
var building: Dictionary = buildings[-1]
if building_has_capability(building, "transport_hub"):
@ -635,6 +640,94 @@ func find_building(id: int) -> Dictionary:
return {}
# --- what a structure can take before it comes down (#99) ------------------
#
# Health lives on the RECORD, not on the Building node. Main.tscn is torn
# down and rebuilt on every trip to a planet surface, so damage kept on a
# node would heal itself the moment the player landed - the same reason the
# buildings themselves live here.
func building_definition(type: String) -> Resource:
return BUILDING_DEFINITIONS.get_definition(type)
func building_max_hp(type: String) -> int:
var definition: Resource = building_definition(type)
return 1 if definition == null else maxi(int(definition.max_hp), 1)
func building_hp(id: int) -> int:
var b: Dictionary = find_building(id)
if b.is_empty():
return 0
# Records written before #99 carry no "hp" at all. Reading that as zero
# would present every structure in an existing save as rubble, so a
# missing field means undamaged.
return int(b.get("hp", building_max_hp(String(b.type))))
func building_health_fraction(id: int) -> float:
var b: Dictionary = find_building(id)
if b.is_empty():
return 0.0
return clampf(float(building_hp(id)) / float(building_max_hp(String(b.type))), 0.0, 1.0)
# The mothership is where the bank, the hangar and research all live, and it
# is where a wiped-out fleet is rebuilt. Losing it would delete the player's
# savings and their stored vessels with no route back, so it bottoms out at
# one hit point instead of dying. Keyed on the capability rather than on the
# type: whatever building holds the bank is the one that cannot be lost.
func building_is_indestructible(building: Dictionary) -> bool:
return building_has_capability(building, "bank")
# Returns true when this killed the building.
func damage_building(id: int, amount: int) -> bool:
if amount <= 0:
return false
var b: Dictionary = find_building(id)
if b.is_empty():
return false
var floor_hp: int = 1 if building_is_indestructible(b) else 0
b.hp = maxi(building_hp(id) - amount, floor_hp)
if int(b.hp) > 0:
return false
return destroy_building(id)
func repair_building(id: int, amount: int) -> int:
if amount <= 0:
return 0
var b: Dictionary = find_building(id)
if b.is_empty():
return 0
var before: int = building_hp(id)
b.hp = mini(before + amount, building_max_hp(String(b.type)))
return int(b.hp) - before
func destroy_building(id: int) -> bool:
var b: Dictionary = find_building(id)
if b.is_empty() or building_is_indestructible(b):
return false
var type: String = String(b.type)
var was_hub: bool = building_has_capability(b, "transport_hub")
buildings.erase(b)
if was_hub:
# Its transporters belonged to it and have nowhere to go home to.
var kept: Array = []
for transporter in transporters:
if int(transporter.get("hub_id", 0)) != id:
kept.append(transporter)
transporters = kept
# Everything else keyed on a building id already copes with the building
# being gone: a transporter mid-delivery puts its cargo back rather than
# destroying it (see _arrive), and find_building simply returns empty.
building_destroyed.emit(id, type)
return true
func building_has_capability(building: Dictionary, capability: String) -> bool:
if building.is_empty():
return false
@ -1520,9 +1613,132 @@ func total_capture_points(planet_key: String) -> int:
return capture_state(planet_key).get("captured", []).size()
# --- waves (#100) ----------------------------------------------------------
#
# #97 replaces the roaming spawner - one enemy every few seconds, forever -
# with waves: a long quiet, a warning, then an attack. The CLOCK lives here
# rather than in the space scene for the usual reason, that Main.tscn is
# destroyed on every landing; a wave must not be cancelled, nor restarted
# from zero, by a trip to a planet.
#
# What is deliberately NOT here is the spawning. GameState cannot see the
# world, so EnemySpawner reads the phase and puts the enemies in it. That
# also settles what happens if the wave turns while the player is on a
# surface: the phase goes to ACTIVE with nothing to spawn it, and the wave
# is waiting in space when they come back.
const WAVE_PHASE_PEACE: String = "peace"
const WAVE_PHASE_WARNING: String = "warning"
const WAVE_PHASE_ACTIVE: String = "active"
const WAVE_PEACE_SECONDS: float = 50.0
# How long the alert is up before anything arrives. Long enough to fly home
# and take a position, short enough that it is a warning and not a chore.
const WAVE_WARNING_SECONDS: float = 9.0
# A wave that wandered off and never died must not stall the next one for
# ever. The director normally ends the phase by clearing the wave.
const WAVE_MAX_SECONDS: float = 240.0
var wave_number: int = 0
var wave_phase: String = WAVE_PHASE_PEACE
var wave_timer: float = 0.0
# Set when a wave turns ACTIVE, cleared by whoever puts it in the world.
# Without it a director arriving mid-wave (the player just came back from a
# planet) could not tell "this wave still owes you enemies" from "this wave
# is already out there and half dead".
var wave_pending: bool = false
func reset_waves() -> void:
wave_number = 0
wave_phase = WAVE_PHASE_PEACE
wave_timer = 0.0
wave_pending = false
func wave_is_incoming() -> bool:
return wave_phase == WAVE_PHASE_WARNING
# Deliberately NOT a remaining-seconds accessor. #97: "No timer should be
# visible" - so the one number that could be rendered as a countdown is not
# offered to the UI at all, and the alert cannot drift into being a clock.
func wave_warning_pulse() -> float:
if wave_phase != WAVE_PHASE_WARNING:
return 0.0
return 0.5 + 0.5 * sin(wave_timer * 7.0)
# Strength is a function of the FLEET, per #97 - a bigger fleet is a bigger
# target, so growing it raises the stakes rather than trivialising them.
# The wave number is a slower second term, so a player who never grows still
# meets escalation rather than the same two chasers all game.
func wave_strength(fleet_size: int) -> int:
# The flat term matters: without it the opening wave against a one-vessel
# fleet is a single chaser, which reads as the system not working rather
# than as an easy first wave.
return 3 + maxi(fleet_size, 1) * 3 + wave_number * 2
# Spent most-expensive-first, so a large budget buys heavier enemies rather
# than an ever-larger swarm of chasers. min_strength keeps the openings
# honest: the first waves are chasers whatever the arithmetic says.
const WAVE_ROSTER: Array = [
{"id": "artillery", "cost": 9, "min_strength": 26},
{"id": "sniper", "cost": 6, "min_strength": 16},
{"id": "lancer", "cost": 4, "min_strength": 12},
{"id": "chaser", "cost": 3, "min_strength": 0},
]
const WAVE_MAX_ENEMIES: int = 18
func wave_composition(fleet_size: int) -> PackedStringArray:
var budget: int = wave_strength(fleet_size)
var strength: int = budget
var out: PackedStringArray = PackedStringArray()
for entry in WAVE_ROSTER:
if strength < int(entry.min_strength):
continue
var cost: int = int(entry.cost)
# The cheapest tier is last and takes whatever is left, so the budget
# is always spent rather than rounded away.
var share: int = budget if String(entry.id) == "chaser" else budget / 2
while share >= cost and budget >= cost and out.size() < WAVE_MAX_ENEMIES:
out.append(String(entry.id))
budget -= cost
share -= cost
if out.is_empty():
out.append("chaser")
return out
func advance_waves(delta: float) -> void:
wave_timer += delta
match wave_phase:
WAVE_PHASE_PEACE:
if wave_timer >= WAVE_PEACE_SECONDS:
wave_phase = WAVE_PHASE_WARNING
wave_timer = 0.0
WAVE_PHASE_WARNING:
if wave_timer >= WAVE_WARNING_SECONDS:
wave_phase = WAVE_PHASE_ACTIVE
wave_timer = 0.0
wave_number += 1
wave_pending = true
WAVE_PHASE_ACTIVE:
if wave_timer >= WAVE_MAX_SECONDS:
wave_cleared()
func wave_cleared() -> void:
wave_phase = WAVE_PHASE_PEACE
wave_timer = 0.0
wave_pending = false
func _process(delta: float) -> void:
# Runs in the autoload, so mining and transport continue while the player
# is on a planet's surface or nowhere near either.
advance_waves(delta)
_advance_production(delta)
_advance_lab_research(delta)
_advance_vessel_deliveries(delta)