#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() extractors.clear()
transporters.clear() transporters.clear()
planet_capture.clear() planet_capture.clear()
reset_waves()
reset_progression() reset_progression()
reset_formation() reset_formation()
@ -613,6 +614,9 @@ var vessel_deliveries: Array = []
const VESSEL_DELIVERY_SPEED: float = 180.0 const VESSEL_DELIVERY_SPEED: float = 180.0
signal order_completed(building_id: int, type: String) 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: func add_building(type: String, at_position: Vector2) -> int:
@ -621,6 +625,7 @@ func add_building(type: String, at_position: Vector2) -> int:
buildings.append({ buildings.append({
"id": id, "type": type, "position": at_position, "id": id, "type": type, "position": at_position,
"inventory": INVENTORY.new(), "orders": [], "inventory": INVENTORY.new(), "orders": [],
"hp": building_max_hp(type),
}) })
var building: Dictionary = buildings[-1] var building: Dictionary = buildings[-1]
if building_has_capability(building, "transport_hub"): if building_has_capability(building, "transport_hub"):
@ -635,6 +640,94 @@ func find_building(id: int) -> Dictionary:
return {} 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: func building_has_capability(building: Dictionary, capability: String) -> bool:
if building.is_empty(): if building.is_empty():
return false return false
@ -1520,9 +1613,132 @@ func total_capture_points(planet_key: String) -> int:
return capture_state(planet_key).get("captured", []).size() 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: func _process(delta: float) -> void:
# Runs in the autoload, so mining and transport continue while the player # Runs in the autoload, so mining and transport continue while the player
# is on a planet's surface or nowhere near either. # is on a planet's surface or nowhere near either.
advance_waves(delta)
_advance_production(delta) _advance_production(delta)
_advance_lab_research(delta) _advance_lab_research(delta)
_advance_vessel_deliveries(delta) _advance_vessel_deliveries(delta)

View file

@ -1,51 +1,62 @@
extends Node2D extends Node2D
@export var min_spawn_interval: float = 3.0 # The wave director (#100 / #97). Keeps its old node name on purpose: it is
@export var max_spawn_interval: float = 6.0 # 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 @export var spawn_margin: float = 150.0
# #96: this spawner is the only place the player meets an enemy off an # Waves arrive as a ring around the fleet rather than from one bearing: a
# authored map, so a new open-space enemy has to take a share of it or it # wave that all came from the left would be dodged by flying right.
# only ever exists in the setup editor. Kept a minority of the wave: the @export var wave_spread: float = 0.55
# lancer is punctuation against a background of chasers, and a screen of
# them would be all telegraph and no fight.
@export var lancer_chance: float = 0.25
@onready var fleet: Node2D = get_node("../Fleet") @onready var fleet: Node2D = get_node("../Fleet")
var enemy_scene: PackedScene = preload("res://enemies/Enemy.tscn") var enemy_scene: PackedScene = preload("res://enemies/Enemy.tscn")
var timer: float = 0.0 # The enemies this director launched, so "the wave is dead" is a question
var next_spawn_time: float = 0.0 # 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 _ready() -> void: func _process(_delta: float) -> void:
randomize() wave_enemies = wave_enemies.filter(func(e): return is_instance_valid(e))
_reset_timer() if GameState.wave_pending:
_launch_wave()
func _process(delta: float) -> void:
timer += delta
if timer >= next_spawn_time:
timer = 0.0
_reset_timer()
_spawn_enemy()
func _reset_timer() -> void:
next_spawn_time = randf_range(min_spawn_interval, max_spawn_interval)
func _spawn_enemy() -> void:
if fleet.ships.is_empty():
return 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 visible_rect: Rect2 = ViewUtils.get_visible_world_rect(self)
var radius: float = visible_rect.size.length() / 2.0 + spawn_margin var radius: float = visible_rect.size.length() / 2.0 + spawn_margin
var angle: float = randf() * TAU var centre: Vector2 = fleet.get_centroid()
var spawn_pos: Vector2 = fleet.get_centroid() + Vector2(cos(angle), sin(angle)) * radius 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() var enemy: Node2D = enemy_scene.instantiate()
# Set before add_child: Enemy._ready() is what resolves enemy_id into a # Before add_child: Enemy._ready() resolves enemy_id into a definition.
# definition, and it runs the moment the node enters the tree. enemy.enemy_id = composition[i]
enemy.enemy_id = "lancer" if randf() < lancer_chance else "chaser" enemy.position = centre + Vector2(cos(angle), sin(angle)) * radius
enemy.position = spawn_pos
get_parent().add_child(enemy) get_parent().add_child(enemy)
wave_enemies.append(enemy)

View file

@ -24,8 +24,8 @@ extends SceneTree
# ship that moves after the lock is missed, which is what makes the # ship that moves after the lock is missed, which is what makes the
# telegraph worth reading # telegraph worth reading
# F. it dies on contact, taking the vessel with it # F. it dies on contact, taking the vessel with it
# G. the roaming spawner can actually produce one, and still produces # G. a wave can actually field one, and still fields chasers - a new
# chasers - a new enemy nobody meets is not a new enemy # enemy nobody meets is not a new enemy
var game_state: Node var game_state: Node
var main: 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") return _fail("the rammed vessel survived, so the lancer cost nothing to stop")
print("lancer and vessel destroyed each other") print("lancer and vessel destroyed each other")
# --- G. the roaming spawner puts it in the game -------------- # --- G. waves can field one ---------------------------------
var spawner: Node2D = main.get_node("EnemySpawner") # This originally checked the roaming spawner's share of lancers.
if spawner.lancer_chance <= 0.0 or spawner.lancer_chance >= 1.0: # #100 removed that spawner entirely: the way a lancer reaches
return _fail("the spawner's lancer_chance is %.2f - it makes one enemy or the other, never a mix" \ # the player now is a wave, so that is what has to be able to
% spawner.lancer_chance) # produce one - and it must not have displaced the chaser while
# The spawner refuses to spawn against an empty fleet, and the # doing it.
# ram just emptied it - the wipe safety net normally refills it, var kinds: Dictionary = {}
# but this must not depend on that having happened yet. var lancer_at: int = 0
if fleet.ships.is_empty(): for size in range(1, 12):
fleet.spawn_ships(1, Vector2(8000.0, 0.0)) for id in game_state.wave_composition(size):
var made: Dictionary = {} kinds[id] = true
for i in range(40): if id == "lancer" and lancer_at == 0:
spawner.lancer_chance = 1.0 if i % 2 == 0 else 0.0 lancer_at = size
spawner._spawn_enemy() if lancer_at == 0:
for enemy in get_nodes_in_group("enemies"): return _fail("no wave against a fleet of 1..11 contains a lancer")
if is_instance_valid(enemy): if not kinds.has("chaser"):
made[enemy.enemy_id] = true return _fail("lancers displaced the chaser out of every wave")
enemy.queue_free() print("waves field lancers from a fleet of %d up, alongside %s" \
if not made.has("lancer"): % [lancer_at, str(kinds.keys())])
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()))
print("TEST_LANCER_ENEMY: PASS") print("TEST_LANCER_ENEMY: PASS")
quit(0) quit(0)

228
tests/test_wave_system.gd Normal file
View 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

68
ui/WaveAlert.gd Normal file
View file

@ -0,0 +1,68 @@
extends Control
# #100: "an small alert on top right appear when a new wave is coming. No
# timer should be visible."
#
# The second half of that is a constraint on this file, not just a note.
# GameState deliberately offers no remaining-seconds accessor - only
# wave_warning_pulse(), which is a 0..1 throb - so there is no number here
# that could be rendered as a countdown even by accident. The urgency is
# carried by the pulse rate, which says "soon" without saying "in 4".
const GLOW: Color = Color(1.0, 0.45, 0.2)
const TEXT: String = "WAVE INCOMING"
var label: Label
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
visible = false
label = Label.new()
label.text = TEXT
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
label.add_theme_font_size_override("font_size", 22)
label.add_theme_color_override("font_color", GLOW)
label.add_theme_color_override("font_outline_color", Color(0.05, 0.01, 0.0, 0.95))
label.add_theme_constant_override("outline_size", 3)
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
add_child(label)
resized.connect(_layout)
_layout()
func _layout() -> void:
if label == null:
return
# Room on the left for the chevron _draw() puts there.
label.position = Vector2(size.y, 0.0)
label.size = Vector2(maxf(size.x - size.y, 0.0), size.y)
func _process(_delta: float) -> void:
var incoming: bool = GameState.wave_is_incoming()
if incoming != visible:
visible = incoming
if incoming:
queue_redraw()
func _draw() -> void:
if not visible:
return
var pulse: float = GameState.wave_warning_pulse()
var alpha: float = 0.35 + 0.65 * pulse
draw_rect(Rect2(Vector2.ZERO, size), Color(0.16, 0.04, 0.01, 0.62))
NeonDraw.draw_glowing_path(self, PackedVector2Array([
Vector2(0.0, 0.0), Vector2(size.x, 0.0), Vector2(size.x, size.y),
Vector2(0.0, size.y), Vector2(0.0, 0.0),
]), Color(GLOW, alpha), 0.9, 0.7)
# A warning triangle, not a clock face.
var centre: Vector2 = Vector2(size.y * 0.5, size.y * 0.5)
var radius: float = size.y * 0.28
NeonDraw.draw_glowing_polygon(self, PackedVector2Array([
centre + Vector2(0.0, -radius),
centre + Vector2(radius * 0.92, radius * 0.72),
centre + Vector2(-radius * 0.92, radius * 0.72),
]), Color(GLOW, alpha), 1.0)

View file

@ -20,6 +20,7 @@
[ext_resource type="Script" path="res://ui/NeonNumber.gd" id="19"] [ext_resource type="Script" path="res://ui/NeonNumber.gd" id="19"]
[ext_resource type="Script" path="res://ui/NeonPanel.gd" id="20"] [ext_resource type="Script" path="res://ui/NeonPanel.gd" id="20"]
[ext_resource type="Script" path="res://ui/SkillTreeView.gd" id="21"] [ext_resource type="Script" path="res://ui/SkillTreeView.gd" id="21"]
[ext_resource type="Script" path="res://ui/WaveAlert.gd" id="32"]
[ext_resource type="Script" path="res://logistics/TransporterField.gd" id="23"] [ext_resource type="Script" path="res://logistics/TransporterField.gd" id="23"]
[ext_resource type="Script" path="res://ui/ResourceBar.gd" id="24"] [ext_resource type="Script" path="res://ui/ResourceBar.gd" id="24"]
[ext_resource type="Script" path="res://logistics/VesselDeliveryField.gd" id="26"] [ext_resource type="Script" path="res://logistics/VesselDeliveryField.gd" id="26"]
@ -207,6 +208,17 @@ offset_bottom = 172
script = ExtResource("10") script = ExtResource("10")
icon = 0 icon = 0
[node name="WaveAlert" type="Control" parent="UI"]
visible = false
mouse_filter = 2
anchor_left = 1.0
anchor_right = 1.0
offset_left = -300.0
offset_right = -24.0
offset_top = 188.0
offset_bottom = 252.0
script = ExtResource("32")
[node name="Joystick" type="Control" parent="UI"] [node name="Joystick" type="Control" parent="UI"]
mouse_filter = 2 mouse_filter = 2
script = ExtResource("13") script = ExtResource("13")