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() 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() _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.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)