Measured before changing anything. The ticket's two halves needed opposite
fixes, and the first one made the second worse.
TOO COMPLEX. Variety was gated on fleet size alone, so a player who had
grown early met two kinds of trouble in the first wave they ever saw - a
fleet of four opened on two lancers and three chasers. Each roster entry now
carries a min_wave as well, so the openings teach one enemy at a time
whatever the fleet already looks like.
That alone pushed wave 1 against a fleet of six from a mixed six to SEVEN
chasers: budget that used to buy a lancer now bought two more chasers. So an
early wave also has a size ceiling, 2 + wave_number * 2. Wave 1 is four
chasers at any fleet size; the cap stops binding by wave 6 and nothing about
later balance moves.
before wave 1 fleet 4 -> 2 lancers, 3 chasers
wave 1 fleet 6 -> 1 sniper, 2 lancers, 3 chasers
after wave 1 fleet 4 -> 4 chasers
wave 1 fleet 6 -> 4 chasers
ARRIVE AT THE SAME TIME was literal: the whole wave spawned inside one frame
as a closed ring around the fleet, which reads as a wall rather than as an
attack. The first hull still lands immediately so a wave is never felt as
late, and the rest follow every 0.45s. Same enemies, same bearing, same
total - they simply do not all exist at once.
Four existing cases objected, and each was right to: they encode intents
that still hold LATER - a large wave must be varied, fleet growth must open
more kinds, a wave can field a lancer at all, and killing a wave must bring
the quiet back. The first three now ask at a wave number where variety is
permitted rather than being weakened. The fourth runs the arrivals out
before killing anything, and gained an assertion that doing so ADDS enemies
- which fails if the staggering ever collapses back into one frame.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD
112 lines
4.7 KiB
GDScript
112 lines
4.7 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 = []
|
|
|
|
|
|
# Seconds between one hull of a wave landing and the next.
|
|
const ARRIVAL_INTERVAL: float = 0.45
|
|
|
|
# Hulls of the current wave that have not landed yet, with the position each
|
|
# was assigned when the wave was aimed.
|
|
var arriving: Array = []
|
|
var arrival_timer: float = 0.0
|
|
|
|
|
|
func _process(delta: float) -> void:
|
|
wave_enemies = wave_enemies.filter(func(e): return is_instance_valid(e))
|
|
if GameState.wave_pending:
|
|
_launch_wave()
|
|
return
|
|
_land_arrivals(delta)
|
|
# `arriving` as well as `wave_enemies`: between the first hull landing and
|
|
# the last, the field can legitimately be empty of live enemies, and
|
|
# clearing the wave there would end it before it had finished arriving.
|
|
if GameState.wave_phase == GameState.WAVE_PHASE_ACTIVE \
|
|
and wave_enemies.is_empty() and arriving.is_empty():
|
|
GameState.wave_cleared()
|
|
|
|
|
|
# Forgejo #233: a wave used to appear entirely within one frame, as a closed
|
|
# ring around the fleet. That reads as a wall rather than as an attack, and
|
|
# it was the "arrive at the same time" half of the complaint - the other half
|
|
# being how many there were, which wave_composition now limits.
|
|
#
|
|
# The first hull still lands immediately, so a wave never feels delayed; the
|
|
# rest follow at ARRIVAL_INTERVAL. Same enemies, same bearing, same total -
|
|
# they just do not all exist at once.
|
|
func _land_arrivals(delta: float) -> void:
|
|
if arriving.is_empty():
|
|
return
|
|
arrival_timer -= delta
|
|
while arrival_timer <= 0.0 and not arriving.is_empty():
|
|
_spawn_arrival(arriving.pop_front())
|
|
arrival_timer += ARRIVAL_INTERVAL
|
|
|
|
|
|
func _spawn_arrival(entry: Dictionary) -> void:
|
|
var enemy: Node2D = enemy_scene.instantiate()
|
|
# Before add_child: Enemy._ready() resolves enemy_id into a definition.
|
|
enemy.enemy_id = String(entry.id)
|
|
# #113: a wave is an attack on what the player has built. It converges
|
|
# on the nearest structure from wherever it spawned rather than
|
|
# waiting to be flown near.
|
|
enemy.relentless = true
|
|
enemy.position = entry.at
|
|
get_parent().add_child(enemy)
|
|
wave_enemies.append(enemy)
|
|
|
|
|
|
# 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
|
|
# Positions are settled now, while the fleet is where the wave was aimed
|
|
# at - not as each hull lands, or a moving fleet would drag the later
|
|
# arrivals around behind it and the ring would smear.
|
|
arriving.clear()
|
|
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
|
|
arriving.append({
|
|
"id": composition[i],
|
|
"at": centre + Vector2(cos(angle), sin(angle)) * radius,
|
|
})
|
|
# The first lands at once, so a wave is never felt as late.
|
|
if not arriving.is_empty():
|
|
_spawn_arrival(arriving.pop_front())
|
|
arrival_timer = ARRIVAL_INTERVAL
|