spaceship/world/IntroDirector.gd
Loïc Wisniewski 78836882af Sharpen upgrades, mark the convoy's destination, and give the world sound
#204 the Beam Collimator widens the beam instead of adding range to one that
already crosses the screen; beam width becomes a per-shot vessel stat.
#205 the Energy Propulser gains three upgrades - dash recharge, dash reach,
and boosted-cruise drain - which turns three constants into vessel stats.
#206 three further doctrines carry the fleet from ten to twenty.
#207 the prologue's World Ship gets a marker, through the scanner's own
guidance arrow rather than a second one.
#208 #210 #213 #214 four new synthesised sounds: the breach opening, cargo
reaching the hold, a rock breaking, and a gas bomb blooming.
#215 building health bars are larger, plated and framed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD
2026-09-05 12:16:17 +02:00

639 lines
24 KiB
GDScript

extends Node2D
# #108: the prologue - the convoy, the ambush, the asteroid belt, and the
# breach that leaves the player with one ship and no way home.
#
# It directs the REAL scene rather than being a scene of its own. It wants
# the real fleet, the real enemies and the real asteroid field, and it makes
# "then the game starts" cost nothing: the director stops steering, clears
# GameState.intro_active, and what is left running is the ordinary game with
# exactly the opening position a new game already has.
#
# The script is a list of BEATS. Each has a deadline as well as its own "am
# I done" test, so a beat whose condition is never met - the player refusing
# to fly at the ambush, say - cannot wedge the prologue; it simply runs out
# and the next one starts. skip() ends the whole thing at any point and
# lands in the same state as playing it through, so there is one way for the
# game to start rather than two that have to be kept in step.
#
# #128: those deadlines are wall clock only while the game is RUNNING. A
# dialog stops the tree, which stops this node too, so a beat's clock never
# ticks past an unread line - the deadline is the way out of a beat the
# player will not finish, not a way past a line they have not acknowledged.
signal finished
# Beats, in order. The names are the whole outline.
enum Beat { BRIEF, RUN, AMBUSH, CLEAR, GAUNTLET, BREACH, VORTEX, AFTERMATH }
# #170: how long the prologue runs before anybody says anything.
#
# The first line used to be up before the scene had finished arriving: the
# game opened on a dialog, which since #128 means it opened STOPPED, over a
# convoy the player had not yet seen and a starfield still being drawn. The
# opening beat of a story should be the picture, not the caption. So the
# convoy flies for a moment first, and the guide calls in over a scene that is
# already moving.
const OPENING_SILENCE: float = 3.0
# Where the shipment was going, and never arrives.
const WORLD_SHIP_AT: Vector2 = Vector2(0.0, -16000.0)
# The convoy: mostly holds, a few escorts. Larger than any fleet the player
# can crew yet, which is the point - this is not their fleet.
# Carriers are the game's cargo hull; transporters belong to hubs and are
# not fleet-eligible at all, so a convoy of them would be silently filtered
# down to its escorts.
#
# #125: the order here pairs index-for-index with CONVOY_FORMATION below.
# The holds are the middle six entries and the escorts are the four outer
# ones, so the layout below can put the guns on the outside without either
# array having to know which slot is which - they simply agree.
const CONVOY: PackedStringArray = ["interceptor", "interceptor",
"interceptor", "interceptor",
"carrier", "carrier", "carrier", "carrier", "carrier", "carrier"]
# #125: "space them a bit more and surround transporters by the default
# vessels". Escorts first, at the four corners of the box; then the holds
# packed in the middle of it. Roughly three times the spread of the fleet's
# own wedge - this is a freight convoy under guard, not a fighting
# formation, and it should not look like one.
const CONVOY_FORMATION: Array[Vector2] = [
Vector2(-300.0, -260.0), Vector2(300.0, -260.0),
Vector2(-300.0, 260.0), Vector2(300.0, 260.0),
Vector2(-105.0, -120.0), Vector2(105.0, -120.0),
Vector2(-105.0, 0.0), Vector2(105.0, 0.0),
Vector2(-105.0, 120.0), Vector2(105.0, 120.0),
]
const AMBUSH_COUNT: int = 3
const AMBUSH_AHEAD: float = 1100.0
# #125: the run was over in about four seconds at cruise. Far enough now
# that the convoy is genuinely under way before anything interrupts it.
const RUN_DISTANCE: float = 3400.0
# #139: far enough that the near edge of the belt is well past the screen
# even before the keep-out below trims it. The two together, rather than
# either alone: the distance keeps the belt looking laid out ahead rather
# than pinned to the screen edge, and the keep-out is what actually
# guarantees nothing appears in view at any aspect ratio or zoom.
const BELT_AHEAD: float = 2900.0
const BELT_ROCKS: int = 90
const BELT_HALF_WIDTH: float = 2600.0
const BELT_DEPTH: float = 1400.0
const BREACH_PULL: float = 620.0
const SHAKE_MAX: float = 34.0
const DEFAULT_SPAWN: Vector2 = Vector2.ZERO
# The cover reaches white after the convoy has visibly collapsed to its
# survivor, then reveals the ordinary game location with time to breathe.
const WHITEOUT_IN_SECONDS: float = 3.2
const WHITEOUT_OUT_SECONDS: float = 2.0
# How far past the horizon the lens reaches. Generous: the bend is what
# sells the hole, and it falls off to nothing on its own well before this.
const BREACH_LENS_SCALE: float = 7.0
# #127: who is talking. The guide is the convoy's controller for everything
# up to the breach; after it there is nobody left to talk to, so the closing
# lines are the ship's own log - which is the point being made.
const SHIP_LOG: String = "KESTREL - LOG"
# By path rather than by class name, for the load-order reason documented in
# PlanetSetups and elsewhere. Only for DEFAULT_SPEAKER, so a queued line names
# the same speaker an immediate one would.
const NARRATION = preload("res://ui/NarrationBanner.gd")
const VORTEX_COLORS: Array = [
Color(0.55, 0.35, 1.0), Color(0.25, 0.9, 1.0), Color(1.0, 0.35, 0.8),
Color(1.0, 0.8, 0.3), Color(0.4, 1.0, 0.6),
]
@onready var fleet: Node2D = get_node("../Fleet")
@onready var camera: Camera2D = get_node("../Camera2D")
@onready var narration: Control = get_node("../UI/NarrationBanner")
@onready var asteroid_field: Node2D = get_node("../AsteroidField")
@onready var enemy_spawner: Node2D = get_node("../EnemySpawner")
@onready var fade_rect: ColorRect = get_node_or_null("../FadeOverlay/FadeRect")
var running: bool = false
var beat: int = Beat.BRIEF
var beat_time: float = 0.0
var start_at: Vector2 = Vector2.ZERO
var belt_at: Vector2 = Vector2.ZERO
var breach_at: Vector2 = Vector2.ZERO
var ambush: Array = []
var rocks: Array = []
var shake: float = 0.0
var vortex_time: float = 0.0
var survivor: Node2D = null
var breach: Node2D = null
var _done: bool = false
# #170: lines waiting on the clock, and the beat time they are waiting for.
# Cleared whenever the beat changes, so a line a beat did not get round to
# saying cannot surface in the middle of the next one.
var _pending_lines: Array = []
var _pending_at: float = 0.0
var whiteout_teleported: bool = false
var _whiteout_tween: Tween = null
@onready var overlay: ColorRect = get_node_or_null("../UI/BreachOverlay")
func _ready() -> void:
# A NEW-game thing. A player returning from a planet surface, or
# resuming, is already past it.
running = GameState.intro_active
if not running:
queue_free()
return
# Nothing arrives on its own while the prologue runs: every enemy and
# every rock in it is placed by this script.
enemy_spawner.set_process(false)
asteroid_field.set_process(false)
for hazard in get_tree().get_nodes_in_group("asteroids") \
+ get_tree().get_nodes_in_group("enemies"):
if is_instance_valid(hazard):
hazard.queue_free()
_begin(Beat.BRIEF)
# --- the frame ------------------------------------------------------------
func _process(delta: float) -> void:
if not running:
return
beat_time += delta
_drain_pending()
_tick(delta)
_apply_shake(delta)
if beat_time < _deadline() and not _ready_to_advance():
return
if beat >= Beat.AFTERMATH:
finish()
return
_begin(beat + 1)
# #170: says a beat's lines once its clock reaches `at`. Deliberately measured
# in BEAT time rather than wall clock, like everything else here: a dialog
# stops the tree, so a line still waiting behind an unread one cannot have its
# turn come round while nobody is looking.
func _say_later(at: float, lines: Array) -> void:
_pending_at = at
_pending_lines = lines
func _drain_pending() -> void:
if _pending_lines.is_empty() or beat_time < _pending_at:
return
var lines: Array = _pending_lines
_pending_lines = []
for line in lines:
narration.say(String(line[0]), String(line[1]))
func _ready_to_advance() -> bool:
if beat_time < _floor_time():
return false
# A beat with a line still to come has not finished talking, whatever the
# banner currently says (#170).
if _waits_for_narration() and not _pending_lines.is_empty():
return false
if _waits_for_narration() and narration.is_speaking():
return false
return _beat_complete() or beat_time >= _floor_time() and _floor_only()
# Beats with no condition of their own end when their floor is up. Kept
# explicit so a beat that DOES have a condition cannot be advanced early by
# the floor alone.
func _floor_only() -> bool:
return beat == Beat.BRIEF or beat == Beat.CLEAR or beat == Beat.BREACH \
or beat == Beat.VORTEX or beat == Beat.AFTERMATH
# Ends the prologue and hands over. The same call ends it whether it was
# played out or skipped.
func finish() -> void:
if _done:
return
_done = true
running = false
_pending_lines = []
_clear_props()
GameState.intro_active = false
GameState.intro_locks_movement = false
GameState.intro_fleet_size = 0
narration.clear()
_reset_whiteout()
camera.offset = Vector2.ZERO
enemy_spawner.set_process(true)
asteroid_field.set_process(true)
# Exactly a new game's opening position: one interceptor, nothing else.
#
# #135: the COUNT is not the whole of that. One surviving carrier is also
# a fleet of one, and it is not the fleet a new game starts with - so the
# types are checked too, and anything else is replaced.
if not _is_starting_fleet():
fleet.spawn_ships(GameState.STARTING_SHIP_COUNT, fleet.get_centroid(),
PackedStringArray([GameState.VESSEL_INTERCEPTOR]))
# #161: and the SHAPE goes back too. The prologue widens the formation
# into a freight convoy - a 600-unit box, roughly three times the fleet's
# own wedge - by writing over GameState.formation, and nothing put it
# back. The player was left flying the convoy's spread for the rest of the
# game, which is not a formation anyone chose and reads as the fleet
# having drifted apart. apply_formation() re-seats the ships that already
# exist, not only the ones built later.
GameState.reset_formation()
fleet.apply_formation()
finished.emit()
queue_free()
func skip() -> void:
finish()
func _is_starting_fleet() -> bool:
if fleet.ships.size() != GameState.STARTING_SHIP_COUNT:
return false
for ship in fleet.ships:
if not is_instance_valid(ship) \
or String(ship.vessel_type) != GameState.VESSEL_INTERCEPTOR:
return false
return true
# #135: the game starts from an interceptor, so an interceptor is what has to
# come out of the vortex.
#
# The convoy's four escorts fly the corners of the box, which is the most
# exposed part of it, and the debris belt can take all four on the way
# through. With none left, the only hulls the whirl could spare were holds -
# and the player flew out of the prologue in a carrier, which is the report.
#
# One is put back into the convoy here, at the top of the vortex, where a
# vessel appearing among ten being thrown around a black hole is not
# something anyone is going to catch. Doing it here rather than only in
# finish() means the ship the player watches survive is the ship they then
# fly, instead of one being swapped for the other at the end.
func _ensure_survivor_hull() -> void:
for ship in fleet.ships:
if is_instance_valid(ship) \
and String(ship.vessel_type) == GameState.VESSEL_INTERCEPTOR:
return
fleet.add_ship(GameState.VESSEL_INTERCEPTOR)
func _apply_convoy_formation() -> void:
for i in range(mini(CONVOY_FORMATION.size(), GameState.formation.size())):
GameState.formation[i] = CONVOY_FORMATION[i]
# The world-space half of the breach, plus the screen-space lens over it.
func _open_breach() -> void:
if is_instance_valid(breach):
return
breach = load("res://vfx/DimensionalBreach.tscn").instantiate()
breach.position = breach_at
get_parent().add_child(breach)
# Drives both halves from one number. The overlay works in SCREEN pixels, so
# the world point is projected through the canvas transform here - only the
# director knows where the camera is.
func _drive_breach(open_amount: float) -> void:
if is_instance_valid(breach):
breach.grow(open_amount)
if overlay == null:
return
var canvas: Transform2D = get_viewport().get_canvas_transform()
var on_screen: Vector2 = canvas * breach_at
var horizon: float = (breach.horizon_radius() if is_instance_valid(breach) else 0.0) \
* canvas.get_scale().x
overlay.set_breach(on_screen, horizon,
maxf(horizon * BREACH_LENS_SCALE, 1.0), open_amount)
func _clear_props() -> void:
if is_instance_valid(breach):
breach.queue_free()
breach = null
if overlay != null:
overlay.clear_breach()
for prop in ambush + rocks:
if is_instance_valid(prop):
prop.queue_free()
ambush.clear()
rocks.clear()
queue_redraw()
# --- the script -----------------------------------------------------------
func _begin(next: int) -> void:
beat = next
beat_time = 0.0
_pending_lines = []
match beat:
Beat.BRIEF:
start_at = fleet.get_centroid()
# #207: the World Ship is 16000 units north and never on screen,
# so "straight ahead" was a line of dialogue with nothing behind
# it. The flight marker gives it a place.
GameState.intro_destination = {
"name": "World Ship",
"world_position": WORLD_SHIP_AT,
}
GameState.intro_fleet_size = CONVOY.size()
# #125: the convoy flies in its own spread-out box, escorts on
# the corners. spawn_ships seats vessel i in formation slot i,
# so writing the layout here - before the spawn - is all it
# takes for CONVOY and CONVOY_FORMATION to line up.
_apply_convoy_formation()
fleet.spawn_ships(CONVOY.size(), start_at, CONVOY)
# #170: not yet. The convoy is under way before the guide calls.
_say_later(OPENING_SILENCE, [
["Convoy Kestrel. You are carrying this season's cargo to the World Ship.",
NARRATION.DEFAULT_SPEAKER],
["Stay on course and keep the holds safe. We are watching.",
NARRATION.DEFAULT_SPEAKER],
])
Beat.RUN:
narration.say("The World Ship is straight ahead. Keep flying.")
Beat.AMBUSH:
_spawn_ambush()
narration.say("Raiders ahead, and not many of them. Break them, then carry on.")
Beat.CLEAR:
narration.say("Good work, Kestrel. Back on course.")
Beat.GAUNTLET:
_spawn_belt()
narration.say("A rock belt lies across your lane. Go slowly. There is no way around it.")
Beat.BREACH:
GameState.intro_locks_movement = true
breach_at = fleet.get_centroid() + Vector2(0.0, -900.0)
_open_breach()
# #208: on the beat that OPENS it, not the one that falls through
# it - the sound is what tells the player something has happened
# while the guide is still saying it has not.
Sfx.play(get_tree(), Sfx.BLACK_HOLE)
narration.say("Kestrel, your course is - something is opening ahead of you -", "CONVOY CONTROL")
Beat.VORTEX:
# Nothing to steer toward any more: the breach has the convoy, and
# what is on the other side is not the World Ship.
GameState.intro_destination = {}
vortex_time = 0.0
_ensure_survivor_hull()
narration.clear()
_start_whiteout()
Beat.AFTERMATH:
GameState.intro_locks_movement = false
shake = 0.0
narration.say("The hole threw us somewhere else. We are lost, and we are alone.", SHIP_LOG)
narration.say("Nothing answers. The World Ship cannot hear us out here.", SHIP_LOG)
func _deadline() -> float:
match beat:
Beat.BRIEF: return 16.0
Beat.RUN: return 60.0
Beat.AMBUSH: return 90.0
Beat.CLEAR: return 8.0
Beat.GAUNTLET: return 75.0
Beat.BREACH: return 9.0
Beat.VORTEX: return 7.0
Beat.AFTERMATH: return 15.0
return 1.0
# #125: "everything happens seconds after the previous thing started."
#
# The deadline above is how long a beat may take; this is how long it must
# take. Two beats ran short for the same reason - their completion test was
# satisfied while the guide was still mid-sentence - so no beat may end
# before its own floor, and a beat that is still TALKING holds regardless.
# One rule, applied in one place, rather than tuning each condition.
func _floor_time() -> float:
match beat:
Beat.BRIEF: return 11.0
Beat.RUN: return 6.0
Beat.AMBUSH: return 3.0
Beat.CLEAR: return 5.0
Beat.GAUNTLET: return 6.0
Beat.BREACH: return 8.0
Beat.VORTEX: return 6.5
Beat.AFTERMATH: return 12.0
return 0.0
# Beats where the guide is the point, and cutting the line off would be
# cutting the beat off. The action beats are deliberately not here: a wave
# arriving must not wait for a sentence to finish.
#
# Since #128 stops the whole tree while a line is up, this node is not
# running to ask the question in the first place - so this now says the same
# thing twice. Kept because it is the DIRECTOR's own reason for waiting, and
# it should not quietly depend on how dialogs happen to be implemented.
func _waits_for_narration() -> bool:
return beat == Beat.BRIEF or beat == Beat.CLEAR or beat == Beat.AFTERMATH
func _beat_complete() -> bool:
match beat:
Beat.RUN:
return start_at.distance_to(fleet.get_centroid()) >= RUN_DISTANCE
Beat.AMBUSH:
return _living(ambush) == 0
Beat.GAUNTLET:
# Past the belt, rather than merely inside it.
return fleet.get_centroid().y <= belt_at.y - BELT_DEPTH * 0.4
return false
func _tick(delta: float) -> void:
match beat:
Beat.BREACH:
shake = minf(shake + delta * SHAKE_MAX * 0.9, SHAKE_MAX)
# Opens over the whole beat rather than snapping to full size,
# which is the "growing" the ticket asks for.
_drive_breach(clampf(beat_time / maxf(_deadline(), 0.001), 0.0, 1.0))
# Dragged in, not flown in: the player has already lost the wheel.
var to_breach: Vector2 = breach_at - fleet.get_centroid()
if to_breach.length() > 1.0:
fleet.move_in_direction(to_breach.normalized())
queue_redraw()
Beat.VORTEX:
vortex_time += delta
shake = maxf(shake - delta * SHAKE_MAX * 0.5, SHAKE_MAX * 0.35)
_drive_breach(1.0)
# Once the opaque frame has moved the survivor to the real spawn,
# nothing from the old side may pull it back into the breach while the
# white cover clears.
if not whiteout_teleported:
_whirl(delta)
queue_redraw()
Beat.AFTERMATH:
shake = maxf(shake - delta * SHAKE_MAX, 0.0)
# It closes behind them over the first stretch of the aftermath,
# so the last thing the player sees is the hole sealing rather
# than a cut.
_drive_breach(clampf(1.0 - beat_time / 3.0, 0.0, 1.0))
queue_redraw()
# The existing transition ColorRect already owns the topmost full-screen
# layer. Recolour it for this one transition, and sequence the teleport as a
# callback between fade-in and fade-out so no world-space jump can be seen.
func _start_whiteout() -> void:
whiteout_teleported = false
if fade_rect == null:
# Headless/minimal scenes may omit presentation, but the story still has
# to land at the same gameplay state.
_complete_whiteout()
return
if _whiteout_tween != null and _whiteout_tween.is_valid():
_whiteout_tween.kill()
fade_rect.color = Color(1.0, 1.0, 1.0, 0.0)
_whiteout_tween = create_tween()
_whiteout_tween.tween_property(fade_rect, "color:a", 1.0, WHITEOUT_IN_SECONDS)
_whiteout_tween.tween_callback(_complete_whiteout)
_whiteout_tween.tween_property(fade_rect, "color:a", 0.0, WHITEOUT_OUT_SECONDS)
_whiteout_tween.tween_callback(_finish_whiteout)
func _complete_whiteout() -> void:
if whiteout_teleported or not running:
return
whiteout_teleported = true
fleet.teleport_to(DEFAULT_SPAWN)
camera.global_position = fleet.get_centroid()
camera.reset_physics_interpolation()
camera.offset = Vector2.ZERO
func _finish_whiteout() -> void:
_whiteout_tween = null
if fade_rect != null:
# Restore the shared rect's normal transparent-black resting state so a
# later planet transition remains black rather than reusing intro white.
fade_rect.color = Color(0.0, 0.0, 0.0, 0.0)
func _reset_whiteout() -> void:
if _whiteout_tween != null and _whiteout_tween.is_valid():
_whiteout_tween.kill()
_whiteout_tween = null
_finish_whiteout()
func _apply_shake(delta: float) -> void:
if shake <= 0.01:
if camera.offset != Vector2.ZERO:
camera.offset = Vector2.ZERO
return
# Offset, never position: GameController owns where the camera IS, and a
# shake written into that would be smoothed away by its own follow.
camera.offset = Vector2(randf_range(-shake, shake), randf_range(-shake, shake))
if beat != Beat.BREACH and beat != Beat.VORTEX:
shake = maxf(shake - delta * SHAKE_MAX, 0.0)
# --- the pieces -----------------------------------------------------------
func _spawn_ambush() -> void:
for enemy in ambush:
if is_instance_valid(enemy):
enemy.queue_free()
ambush.clear()
var ahead: Vector2 = fleet.get_centroid() + Vector2(0.0, -AMBUSH_AHEAD)
for i in range(AMBUSH_COUNT):
var enemy: Node2D = load("res://enemies/Enemy.tscn").instantiate()
# Chasers, and only chasers. "easy enough" is the requirement, and a
# sniper or an artillery piece against a convoy the player cannot
# yet fight properly is neither easy nor a good first impression.
enemy.enemy_id = "chaser"
enemy.position = ahead + Vector2((float(i) - 1.0) * 260.0, randf_range(-120.0, 120.0))
get_parent().add_child(enemy)
ambush.append(enemy)
func _spawn_belt() -> void:
# Idempotent: replaying the beat replaces the belt rather than laying a
# second one on top of it.
for rock in rocks:
if is_instance_valid(rock):
rock.queue_free()
rocks.clear()
belt_at = fleet.get_centroid() + Vector2(0.0, -BELT_AHEAD)
# #139: rocks were popping into existence on screen. The belt is spread
# BELT_DEPTH either side of its centre, so its near edge reached back to
# within a screen of the fleet - and at a wider aspect than the one it
# was eyeballed on, into the view outright.
#
# The rule is the asteroid field's own (#126), borrowed rather than
# restated here: anything that lands in view is pushed out through the
# nearest edge, so the belt keeps its density instead of thinning where
# the player is looking.
var keep_out: Rect2 = asteroid_field.keep_out_rect(fleet.get_centroid())
var scene: PackedScene = load("res://world/Asteroid.tscn")
for i in range(BELT_ROCKS):
var rock: Node2D = scene.instantiate()
rock.position = asteroid_field.safe_position(belt_at + Vector2(
randf_range(-BELT_HALF_WIDTH, BELT_HALF_WIDTH),
randf_range(-BELT_DEPTH, BELT_DEPTH)), keep_out)
get_parent().add_child(rock)
rocks.append(rock)
# The colourful part: the fleet is thrown around the breach and comes apart,
# one vessel at a time, until a single interceptor is left.
func _whirl(delta: float) -> void:
var keep: Node2D = _survivor()
var spin: float = vortex_time * 5.0
for i in range(fleet.ships.size() - 1, -1, -1):
var ship: Node2D = fleet.ships[i]
if not is_instance_valid(ship):
continue
var angle: float = spin + TAU * float(i) / maxf(float(fleet.ships.size()), 1.0)
var radius: float = lerpf(520.0, 60.0, clampf(vortex_time / 3.0, 0.0, 1.0))
var want: Vector2 = breach_at + Vector2(cos(angle), sin(angle)) * radius
ship.position = ship.position.lerp(want, clampf(delta * 4.0, 0.0, 1.0))
ship.move_target = ship.position
if ship == keep:
continue
# Staggered rather than all at once, so the fleet visibly comes apart
# instead of blinking out.
if vortex_time >= 0.6 + 0.22 * float(i):
ship.take_damage(1)
func _survivor() -> Node2D:
if is_instance_valid(survivor):
return survivor
# The one that lives is the ordinary interceptor - the ship the game
# starts from - not whichever happened to be first in the list.
for ship in fleet.ships:
if is_instance_valid(ship) and String(ship.vessel_type) == GameState.VESSEL_INTERCEPTOR:
survivor = ship
return survivor
survivor = fleet.ships[0] if not fleet.ships.is_empty() else null
return survivor
func _living(nodes: Array) -> int:
var n: int = 0
for node in nodes:
if is_instance_valid(node) and not node.is_queued_for_deletion():
n += 1
return n
# --- what the prologue draws ----------------------------------------------
func _draw() -> void:
# #125: the breach draws itself now - a growing black hole out in the
# world (DimensionalBreach) with a screen-space lens over it
# (BreachOverlay). What is left here is the ribbon of matter being torn
# off the convoy, which belongs to the fleet rather than to the hole.
if not running or beat != Beat.VORTEX:
return
var pull: float = clampf(vortex_time / 3.0, 0.0, 1.0)
for i in range(VORTEX_COLORS.size() * 2):
var colour: Color = VORTEX_COLORS[i % VORTEX_COLORS.size()]
var angle: float = vortex_time * 3.2 + TAU * float(i) / float(VORTEX_COLORS.size() * 2)
var outer: float = lerpf(900.0, 150.0, pull)
var from: Vector2 = breach_at + Vector2(cos(angle), sin(angle)) * outer
var to: Vector2 = breach_at + Vector2(cos(angle + 1.1), sin(angle + 1.1)) * 60.0
NeonDraw.draw_glowing_line(self, from, to, Color(colour, 0.3 + 0.45 * pull), 1.0, 0.8)