spaceship/world/IntroDirector.gd
Loïc Wisniewski d455361328 #125: slow the prologue down, and give the breach a real black hole
Five things, all of them "this happens too fast or looks too thin".

PACING. Every beat had a completion test that could be satisfied while the
guide was still mid-sentence, which is why everything landed seconds after
the thing before it. Beats now have a FLOOR as well as a deadline - a
minimum length - and a beat whose point is the guide talking will not end
while a line is still on screen. One rule in one place rather than tuning
each condition. The run is 3400px instead of 1500, which at cruise was about
four seconds, and a held line is 6.5s instead of 4.5.

THE CONVOY. Escorts at the four corners of a box, holds packed in the middle
of it, roughly three times the spread of the fleet's own wedge - this is
freight under guard, not a fighting formation. CONVOY and CONVOY_FORMATION
pair index-for-index, which is all it takes for spawn_ships to seat the guns
on the outside, and the test pins that every hold sits further in than the
furthest-out escort.

THE DIALOG. Cut corners, brackets holding the short edges open, a tick strip
along the bottom and a caret in the corner - geometry rather than art, so it
scales with the banner and costs nothing.

THE BREACH. It was a few lines turning around a point. It is now two halves
driven by one number: DimensionalBreach, a growing black hole out in the
world built on BlackHoleVisual's idiom - empty core, counter-rotating arc
segments hotter toward the centre - with infalling streamers it does not
have, because a planet's disc is steady and this one has to say "now"; and
breach.gdshader over the top, bending the ACTUAL SCENE around it. Ships,
rocks and the starfield all get dragged in, which is most of what makes a
hole in space read as a hole rather than as a decal drawn on one.

Inside the horizon the shader samples nothing at all - the same decision
BlackHoleVisual makes, for the same reason: a black hole with something
visible in the middle stops being one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 13:25:10 +02:00

444 lines
16 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.
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.
#
# #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
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
# 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
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 breach: Node2D = null
var _done: bool = false
@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
_tick(delta)
_apply_shake(delta)
if beat_time < _deadline() and not _ready_to_advance():
return
if beat >= Beat.AFTERMATH:
finish()
return
_begin(beat + 1)
func _ready_to_advance() -> bool:
if beat_time < _floor_time():
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
_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 _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
match beat:
Beat.BRIEF:
start_at = fleet.get_centroid()
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)
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)
_open_breach()
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 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.
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)
_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()
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:
# #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)