Eight tickets. The last one is the interesting one. #166 - PLAINER DIALOG. Every spoken line rewritten: the nine prologue lines and all twenty quest brief/done pairs. Mostly splitting em-dash subclauses into sentences and dropping the literary register ("hulls" -> "ships", "That is our hands back" -> "That frees us for other work"). Same meaning, same voice. #167 - NOTHING ANNOUNCED TWICE. Quests carry an `important` flag; seven of the twenty have it, and they are the search for the World Ship. A quest handed out with its goal ALREADY met is now credited in silence unless it is one of those - no brief, no answer, both signals still fired and the index still moved, so it appears ticked in the objectives panel. The chain used to stop the game to set a task the player had already done, then stop it again to congratulate them on it. #168 - THE KEYBOARD CAN ANSWER A DIALOG. The rule the ticket asks for (still arriving, show it all; already whole, move on) was already the rule for a click since #149, but a key press reached the banner and did nothing - and this game is played with WASD and a mouse. Touch, click and key now go through one door. Modifiers alone and auto-repeat echoes are not presses. #169 - A LOADING SCREEN. A press raises a cover, and the load happens after it has been on screen for a frame, so the wait is something the player is watching. Deliberately still a blocking load: load_threaded_request() comes back FAILED for these scenes, reporting text-parse errors that load() does not produce, because the global class names and preloads they reach each other through are not resolvable off the loading thread. A progress bar is not worth a menu that sometimes refuses to start the game, and the web build ships with thread support off anyway. Further presses while loading are ignored - on the web a button fires on PRESS, so an impatient double tap costs nothing to make and used to build the world twice. #170 - THE PICTURE BEFORE THE CAPTION. The prologue's first line was up before the scene had finished arriving, so the game opened STOPPED (#128) over a convoy nobody had seen yet. It flies for three seconds first. #171 - THE CONTROLS FOLLOW WHAT IS BEING HELD. is_touchscreen_available() asks whether a touch COULD arrive, and in a browser that is yes on any laptop whose panel reports touch support - so the web build put thumb sticks over a screen being played with a mouse, and switched mouse aiming off on the machine it exists for. The reading is now the last input actually used, it lives in GameState so the sticks and the guns cannot disagree, and it is two-way: nothing can strand a tablet, because the gesture that needs the controls is the gesture that brings them back. A touch suppresses mouse evidence for a second, so the click Godot manufactures out of a tap is not mistaken for a mouse. #172 - AIM AT A PLACE. Every gun was handed the same direction, so a fleet firing at something threw a sheet of parallel bolts past either side of it and only the middle hull could hit - worse the wider the formation. Guns now take a POINT and each hull works out its own heading to it, so the fleet's fire converges. The mouse already had a place and was flattening it into a bearing; a pushed stick names the point at the guns' reach along it, so touch loses nothing. The assist (#89) still bends a shot onto an enemy, now per hull and around that hull's own heading. The aim guide stops at the point and marks it. #173 - AND THE ONE THAT EXPLAINS ITSELF. "In the web export version the fleet can not store any resources" is not a web bug. It is the third time the Godot editor's .tres writer has quietly dropped a property it thought was default-valued: InterceptorVesselDefinition losing `capabilities` leaves cargo_capacity at 20 and takes away the "cargo" claim that fleet_cargo_capacity() actually gates on, so the hold is zero and nothing can be mined or carried. That is the symptom, word for word. test_definitions_intact caught it in the suite. What it could not do is catch it in a BUILD - an export is taken from the working tree at whatever moment the editor last touched it, which is exactly when the damage is done and exactly where nobody is running tests. The checks move to core/CatalogCheck.gd and now run at startup in every build as well as in the test, from the same function. On the web push_error lands in the on-page log web_shell.html mirrors, which is the only error a phone can read. The tree is intact today, so a fresh export should restore the hold. The point of the change is that the next time it is not, the build says so. Full suite green at 154/154. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0139MmxRAJSUe1j77kftAWv3
566 lines
22 KiB
GDScript
566 lines
22 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
|
|
# 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")
|
|
|
|
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
|
|
|
|
@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()
|
|
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()
|
|
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()
|
|
narration.say("Kestrel, your course is - something is opening ahead of you -", "CONVOY CONTROL")
|
|
Beat.VORTEX:
|
|
vortex_time = 0.0
|
|
_ensure_survivor_hull()
|
|
narration.clear()
|
|
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)
|
|
_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)
|
|
# #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)
|