spaceship/world/IntroDirector.gd
Loïc Wisniewski 902d6922e7 #127: dialogs wait for the reader, and say who is talking
A line now stays until it is DISMISSED - there is no timer under it at all.
A prologue whose lines expire on their own reads at the game's pace rather
than at the reader's, and that was most of what made the opening feel
rushed even after #125 slowed the beats down.

The beats that are about the guide talking already hold while the banner is
speaking, so dismissing a line is what advances the prologue. A player who
never clicks is still never stuck: each beat's own deadline is the way out,
and the test pins that the deadline sits past the floor.

A dismissed line eats the press, so clicking to continue does not also steer
the fleet on the way through.

The speaker's name sits in a notch cut out of the top border at the left,
the way a label is riveted to a panel - the frame is one polygon, so the
fill and the stroke cannot disagree about where that notch is. Everything up
to the breach is the convoy's controller; 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.

The tick strip along the bottom is gone. It read as an underline attached to
the last line rather than as part of the frame. What replaces it is a
blinking caret at the bottom right - the one thing that says a click is what
moves this on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 20:57:56 +02:00

448 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
# #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"
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.")
narration.say("Hold your heading and keep the holds intact. We will be watching.")
Beat.RUN:
narration.say("World Ship bears ahead. Fly on.")
Beat.AMBUSH:
_spawn_ambush()
narration.say("Contacts ahead - raiders, a light group. Break them and continue.")
Beat.CLEAR:
narration.say("Clean work, Kestrel. Resume your heading.")
Beat.GAUNTLET:
_spawn_belt()
narration.say("Debris belt across your lane. Take it slow - there is no way around.")
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 -", "CONVOY CONTROL")
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.", SHIP_LOG)
narration.say("No communication with the World Ship can be heard.", 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.
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)