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>
444 lines
16 KiB
GDScript
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)
|