#108: the prologue - the convoy, the ambush, and the breach

Landed as one piece across #122, #123 and #124: the frame is useless without
a script, and the script has nowhere to run without the frame.

The director drives 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 the flag, and what is left running is the ordinary game.

The script is a list of beats, each with a deadline as well as its own
completion test, so a beat whose condition is never met - a player who
refuses to fly at the ambush - runs out rather than wedging the prologue.
skip() ends the whole thing at any point and lands in exactly the state
playing it through does, so the game has one way to start rather than two
that have to be kept in step. The test pins that equivalence.

"No action button available, only movements" is enforced where the buttons
are decided, not by the director reaching in: the ability controllers set
their own visibility every frame and would have put them straight back, and
the map button belongs to the map. The joystick, the pivot and the fader
stay.

The convoy is ten hulls where a new game's fleet is one, so it needs an
explicit fleet-size override - a plain number rather than a fake doctrine,
because nothing about the player's own progression should be bent to fit a
cutscene. It is carriers rather than transporters: transporters belong to
hubs and are not fleet-eligible, so a convoy of them would be silently
filtered down to its escorts.

The ambush is chasers and only chasers. "Easy enough" is the requirement,
and a sniper against a convoy the player cannot yet fight properly is
neither easy nor a good first impression.

The breach takes the wheel through one check in GameController, covering the
stick, the keyboard and the released-again path together - three separate
gates would be three chances for one of them to keep steering. The shake is
written to camera.offset, never to its position, because GameController owns
where the camera is and would smooth a shake away.

The vessel that survives is chosen as the interceptor rather than whichever
happened to be first in the list, and the prologue ends on exactly a new
game's opening position.

Also: PlanetAtmosphereController no longer spawns the opening fleet while
the prologue is arming, and the tests that are about what the GAME starts
from now say so by turning the prologue off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Loïc Wisniewski 2026-08-23 20:59:12 +02:00
commit 209169206b
14 changed files with 885 additions and 9 deletions

350
world/IntroDirector.gd Normal file
View file

@ -0,0 +1,350 @@
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.
signal finished
# Beats, in order. The names are the whole outline.
enum Beat { BRIEF, RUN, AMBUSH, CLEAR, GAUNTLET, BREACH, VORTEX, AFTERMATH }
# 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.
const CONVOY: PackedStringArray = ["interceptor", "carrier", "carrier",
"carrier", "interceptor", "carrier", "carrier", "interceptor",
"carrier", "interceptor"]
const AMBUSH_COUNT: int = 3
const AMBUSH_AHEAD: float = 1100.0
const RUN_DISTANCE: float = 1500.0
const BELT_AHEAD: float = 2200.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 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")
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 _done: bool = false
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
_tick(delta)
_apply_shake(delta)
if beat_time >= _deadline() or _beat_complete():
if beat >= Beat.AFTERMATH:
finish()
return
_begin(beat + 1)
# 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
_clear_props()
GameState.intro_active = false
GameState.intro_locks_movement = false
GameState.intro_fleet_size = 0
narration.clear()
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.
if fleet.ships.size() != GameState.STARTING_SHIP_COUNT:
fleet.spawn_ships(GameState.STARTING_SHIP_COUNT, fleet.get_centroid(),
PackedStringArray([GameState.VESSEL_INTERCEPTOR]))
finished.emit()
queue_free()
func skip() -> void:
finish()
func _clear_props() -> void:
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
match beat:
Beat.BRIEF:
start_at = fleet.get_centroid()
GameState.intro_fleet_size = CONVOY.size()
fleet.spawn_ships(CONVOY.size(), start_at, CONVOY)
narration.say("Convoy Kestrel, you are carrying the season's shipment to the World Ship.", 5.0)
narration.say("Hold your heading and keep the holds intact. We will be watching.", 4.5)
Beat.RUN:
narration.say("World Ship bears ahead. Fly on.", 3.5)
Beat.AMBUSH:
_spawn_ambush()
narration.say("Contacts ahead - raiders, a light group. Break them and continue.", 4.5)
Beat.CLEAR:
narration.say("Clean work, Kestrel. Resume your heading.", 3.5)
Beat.GAUNTLET:
_spawn_belt()
narration.say("Debris belt across your lane. Take it slow - there is no way around.", 5.0)
Beat.BREACH:
GameState.intro_locks_movement = true
breach_at = fleet.get_centroid() + Vector2(0.0, -900.0)
narration.say("Kestrel, your heading is - something is opening ahead of you -", 4.0)
Beat.VORTEX:
vortex_time = 0.0
narration.clear()
Beat.AFTERMATH:
GameState.intro_locks_movement = false
shake = 0.0
narration.say("It seems you've lost your way through the space, lost and abandoned.", 5.0)
narration.say("No communication with the World Ship can be heard.", 5.0)
func _deadline() -> float:
match beat:
Beat.BRIEF: return 10.0
Beat.RUN: return 45.0
Beat.AMBUSH: return 75.0
Beat.CLEAR: return 4.0
Beat.GAUNTLET: return 60.0
Beat.BREACH: return 4.2
Beat.VORTEX: return 3.6
Beat.AFTERMATH: return 10.5
return 1.0
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)
# 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)
_whirl(delta)
queue_redraw()
Beat.AFTERMATH:
shake = maxf(shake - delta * SHAKE_MAX, 0.0)
queue_redraw()
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)
var scene: PackedScene = load("res://world/Asteroid.tscn")
for i in range(BELT_ROCKS):
var rock: Node2D = scene.instantiate()
rock.position = belt_at + Vector2(
randf_range(-BELT_HALF_WIDTH, BELT_HALF_WIDTH),
randf_range(-BELT_DEPTH, BELT_DEPTH))
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:
if not running:
return
if beat == Beat.BREACH:
_draw_breach(clampf(beat_time / _deadline(), 0.0, 1.0))
elif beat == Beat.VORTEX:
_draw_breach(1.0)
_draw_vortex()
func _draw_breach(progress: float) -> void:
var radius: float = lerpf(20.0, 340.0, progress)
draw_circle(breach_at, radius, Color(0.06, 0.0, 0.12, 0.75 * progress))
for i in range(VORTEX_COLORS.size()):
var colour: Color = VORTEX_COLORS[i]
var r: float = radius * (0.45 + 0.14 * float(i))
var spin: float = beat_time * (1.4 + 0.5 * float(i))
NeonDraw.draw_glowing_path(self, _spiral(breach_at, r, spin, i),
Color(colour, 0.55 + 0.35 * progress), 0.9, 0.5)
func _draw_vortex() -> void:
# Ribbons dragged inward, brightening as the fleet is pulled apart.
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, 120.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)) * 40.0
NeonDraw.draw_glowing_line(self, from, to, Color(colour, 0.35 + 0.5 * pull), 1.0, 0.8)
func _spiral(centre: Vector2, radius: float, phase: float, arm: int) -> PackedVector2Array:
var points: PackedVector2Array = PackedVector2Array()
for i in range(18):
var t: float = float(i) / 17.0
var angle: float = phase + t * TAU * 0.8 + float(arm)
points.append(centre + Vector2(cos(angle), sin(angle)) * radius * (0.35 + 0.65 * t))
return points