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. const CONVOY: PackedStringArray = ["interceptor", "carrier", "carrier", "carrier", "interceptor", "carrier", "carrier", "interceptor", "carrier", "interceptor"] const AMBUSH_COUNT: int = 3 const AMBUSH_AHEAD: float = 1100.0 const RUN_DISTANCE: float = 1500.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 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 _done: bool = false 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() or _beat_complete(): if beat >= Beat.AFTERMATH: finish() return _begin(beat + 1) # 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 _clear_props() -> void: 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() 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) 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 10.0 Beat.RUN: return 45.0 Beat.AMBUSH: return 75.0 Beat.CLEAR: return 4.0 Beat.GAUNTLET: return 60.0 Beat.BREACH: return 4.2 Beat.VORTEX: return 3.6 Beat.AFTERMATH: return 10.5 return 1.0 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) # 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) _whirl(delta) queue_redraw() Beat.AFTERMATH: shake = maxf(shake - delta * SHAKE_MAX, 0.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: if not running: return if beat == Beat.BREACH: _draw_breach(clampf(beat_time / _deadline(), 0.0, 1.0)) elif beat == Beat.VORTEX: _draw_breach(1.0) _draw_vortex() func _draw_breach(progress: float) -> void: var radius: float = lerpf(20.0, 340.0, progress) draw_circle(breach_at, radius, Color(0.06, 0.0, 0.12, 0.75 * progress)) for i in range(VORTEX_COLORS.size()): var colour: Color = VORTEX_COLORS[i] var r: float = radius * (0.45 + 0.14 * float(i)) var spin: float = beat_time * (1.4 + 0.5 * float(i)) NeonDraw.draw_glowing_path(self, _spiral(breach_at, r, spin, i), Color(colour, 0.55 + 0.35 * progress), 0.9, 0.5) func _draw_vortex() -> void: # Ribbons dragged inward, brightening as the fleet is pulled apart. 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, 120.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)) * 40.0 NeonDraw.draw_glowing_line(self, from, to, Color(colour, 0.35 + 0.5 * pull), 1.0, 0.8) func _spiral(centre: Vector2, radius: float, phase: float, arm: int) -> PackedVector2Array: var points: PackedVector2Array = PackedVector2Array() for i in range(18): var t: float = float(i) / 17.0 var angle: float = phase + t * TAU * 0.8 + float(arm) points.append(centre + Vector2(cos(angle), sin(angle)) * radius * (0.35 + 0.65 * t)) return points