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