#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
639 lines
24 KiB
GDScript
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)
|