350 lines
12 KiB
GDScript3
350 lines
12 KiB
GDScript3
|
|
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
|