#108: the prologue - the convoy, the ambush, and the breach

Landed as one piece across #122, #123 and #124: the frame is useless without
a script, and the script has nowhere to run without the frame.

The director drives 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 the flag, and what is left running is the ordinary game.

The script is a list of beats, each with a deadline as well as its own
completion test, so a beat whose condition is never met - a player who
refuses to fly at the ambush - runs out rather than wedging the prologue.
skip() ends the whole thing at any point and lands in exactly the state
playing it through does, so the game has one way to start rather than two
that have to be kept in step. The test pins that equivalence.

"No action button available, only movements" is enforced where the buttons
are decided, not by the director reaching in: the ability controllers set
their own visibility every frame and would have put them straight back, and
the map button belongs to the map. The joystick, the pivot and the fader
stay.

The convoy is ten hulls where a new game's fleet is one, so it needs an
explicit fleet-size override - a plain number rather than a fake doctrine,
because nothing about the player's own progression should be bent to fit a
cutscene. It is carriers rather than transporters: transporters belong to
hubs and are not fleet-eligible, so a convoy of them would be silently
filtered down to its escorts.

The ambush is chasers and only chasers. "Easy enough" is the requirement,
and a sniper against a convoy the player cannot yet fight properly is
neither easy nor a good first impression.

The breach takes the wheel through one check in GameController, covering the
stick, the keyboard and the released-again path together - three separate
gates would be three chances for one of them to keep steering. The shake is
written to camera.offset, never to its position, because GameController owns
where the camera is and would smooth a shake away.

The vessel that survives is chosen as the interceptor rather than whichever
happened to be first in the list, and the prologue ends on exactly a new
game's opening position.

Also: PlanetAtmosphereController no longer spawns the opening fleet while
the prologue is arming, and the tests that are about what the GAME starts
from now say so by turning the prologue off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Loïc Wisniewski 2026-08-23 20:59:12 +02:00
commit 209169206b
14 changed files with 885 additions and 9 deletions

View file

@ -33,7 +33,10 @@ func _ready() -> void:
func _process(delta: float) -> void: func _process(delta: float) -> void:
var had_visuals: bool = aiming or _has_effect_visuals() var had_visuals: bool = aiming or _has_effect_visuals()
button.visible = fleet.has_capability(_component_id()) # #122: "there's no action button available, only movements". Checked
# here rather than by the director reaching in, because this line runs
# every frame and would put the button straight back.
button.visible = fleet.has_capability(_component_id()) and not GameState.intro_active
button.enabled = _next_ready_ship() != null and _can_afford_shot() button.enabled = _next_ready_ship() != null and _can_afford_shot()
if not button.visible and aiming: if not button.visible and aiming:
_cancel_aim() _cancel_aim()

View file

@ -397,6 +397,19 @@ func _unhandled_input(event: InputEvent) -> void:
func _process(delta: float) -> void: func _process(delta: float) -> void:
_check_fleet_wipe() _check_fleet_wipe()
# #122: the prologue leaves the player movement and nothing else. Handled
# at the top, before any of the context detection below, so nothing can
# turn a button back on further down.
if GameState.intro_active:
_hide_all_actions()
return
# Build is permanently under the resource counter (see
# _layout_context_buttons) - it is the one action button nothing else
# ever hides, so the prologue taking it away is the only thing that has
# to give it back.
if not build_button.visible:
build_button.visible = true
if build_menu.visible: if build_menu.visible:
# Keep the greyed state live while the menu is open, so flying onto # Keep the greyed state live while the menu is open, so flying onto
# a planet lights the option up without reopening it. # a planet lights the option up without reopening it.
@ -735,6 +748,21 @@ func _refresh_build_option_states() -> void:
entry[1].queue_redraw() entry[1].queue_redraw()
# Everything the player can PRESS, as opposed to steer with. Used by the
# prologue (#122), which takes them all away at once.
func _hide_all_actions() -> void:
current_building = null
pending_building = null
current_extractor_index = -1
pending_extractor_index = -1
current_landing_planet = null
context_actions_expanded = false
for control in [build_button, action_button, building_action_button,
landing_button, transfer_button, hangar_button]:
if control != null:
control.visible = false
func _close_build_menu() -> void: func _close_build_menu() -> void:
# The popup is a child panel of this menu; leaving it "open" while its # The popup is a child panel of this menu; leaving it "open" while its
# parent is hidden is what blocked every button in the game. # parent is hidden is what blocked every button in the game.

View file

@ -250,15 +250,13 @@ func _process(delta: float) -> void:
# anchor in _process, so this only needs to report the direction. The # anchor in _process, so this only needs to report the direction. The
# keyboard takes precedence, and releasing the keys has to stop the fleet # keyboard takes precedence, and releasing the keys has to stop the fleet
# itself - unlike the stick, there is no centred-again signal to hook. # itself - unlike the stick, there is no centred-again signal to hook.
var keyed: Vector2 = keyboard_direction() # #124: the breach drives. One check here covers the stick, the keyboard
if keyed != Vector2.ZERO: # and the released-again path together - three separate gates would be
keyboard_steering = true # three chances for one of them to keep steering.
fleet.move_in_direction(keyed) if GameState.intro_locks_movement:
elif keyboard_steering:
keyboard_steering = false keyboard_steering = false
fleet.stop() else:
elif joystick != null and joystick.direction != Vector2.ZERO: _apply_steering()
fleet.move_in_direction(joystick.direction)
var target: Vector2 = fleet.get_centroid() var target: Vector2 = fleet.get_centroid()
if camera.global_position.distance_to(target) <= camera_snap_distance: if camera.global_position.distance_to(target) <= camera_snap_distance:
@ -272,6 +270,18 @@ func _process(delta: float) -> void:
camera.global_position = camera.global_position.lerp(target, t) camera.global_position = camera.global_position.lerp(target, t)
func _apply_steering() -> void:
var keyed: Vector2 = keyboard_direction()
if keyed != Vector2.ZERO:
keyboard_steering = true
fleet.move_in_direction(keyed)
elif keyboard_steering:
keyboard_steering = false
fleet.stop()
elif joystick != null and joystick.direction != Vector2.ZERO:
fleet.move_in_direction(joystick.direction)
func _unhandled_input(event: InputEvent) -> void: func _unhandled_input(event: InputEvent) -> void:
if event is InputEventScreenTouch: if event is InputEventScreenTouch:
_handle_touch(event) _handle_touch(event)

View file

@ -24,6 +24,24 @@ var surviving_vessel_types: PackedStringArray = PackedStringArray()
# whenever the composition changes. GameState cannot see scene nodes, and the # whenever the composition changes. GameState cannot see scene nodes, and the
# census the Planning Center works from has to include the fleet. # census the Planning Center works from has to include the fleet.
var fleet_vessel_types: PackedStringArray = PackedStringArray() var fleet_vessel_types: PackedStringArray = PackedStringArray()
# #122: the prologue is running. Set by reset_new_game() so it plays once at
# the start of a NEW game and never on a resumed one, and cleared by the
# director when the prologue hands over.
#
# It is a flag rather than a separate scene because the prologue DIRECTS the
# real game: the real fleet, the real enemies, the real asteroid field. That
# is also what makes "then the game starts" cost nothing - the director stops
# steering and there is nothing left to load.
var intro_active: bool = false
# #124: the breach takes the wheel. Movement input is ignored while this is
# set, so the fleet goes where the script sends it rather than fighting the
# player for the last few seconds of the prologue.
var intro_locks_movement: bool = false
# #123: the prologue's convoy is bigger than any fleet the player can field
# yet - it is not their fleet, it is the one they are about to lose. An
# explicit override rather than a fake doctrine, so nothing about the
# player's own progression has to be bent to fit a cutscene.
var intro_fleet_size: int = 0
var return_world_position: Vector2 = Vector2.ZERO var return_world_position: Vector2 = Vector2.ZERO
var return_camera_zoom: float = 1.0 var return_camera_zoom: float = 1.0
@ -566,6 +584,8 @@ func research_for_building(type: String) -> String:
func max_fleet_size() -> int: func max_fleet_size() -> int:
if intro_fleet_size > 0:
return mini(intro_fleet_size, DEFAULT_FORMATION.size())
var n: int = BASE_FLEET_SIZE var n: int = BASE_FLEET_SIZE
for id in unlocked: for id in unlocked:
n += int(skill_node(id).get("fleet_bonus", 0)) n += int(skill_node(id).get("fleet_bonus", 0))
@ -582,6 +602,9 @@ func reset_progression() -> void:
# process would start from wherever the last one left off instead of a # process would start from wherever the last one left off instead of a
# fresh game. # fresh game.
func reset_new_game() -> void: func reset_new_game() -> void:
intro_active = true
intro_locks_movement = false
intro_fleet_size = 0
has_return_position = false has_return_position = false
surviving_ship_count = STARTING_SHIP_COUNT surviving_ship_count = STARTING_SHIP_COUNT
surviving_vessel_types = PackedStringArray() surviving_vessel_types = PackedStringArray()

View file

@ -39,6 +39,12 @@ func _ready() -> void:
camera.position = GameState.return_world_position camera.position = GameState.return_world_position
camera.zoom = Vector2(GameState.return_camera_zoom, GameState.return_camera_zoom) camera.zoom = Vector2(GameState.return_camera_zoom, GameState.return_camera_zoom)
GameState.has_return_position = false GameState.has_return_position = false
elif GameState.intro_active:
# #108: the prologue opens with a convoy, and crews it itself. This
# node still runs first, so spawning the ordinary opening fleet here
# would be immediately thrown away - and worse, would be the fleet
# for the one frame before the director replaced it.
pass
else: else:
# A brand new game. STARTING_SHIP_COUNT rather than a literal: this # A brand new game. STARTING_SHIP_COUNT rather than a literal: this
# said 5, which was the old BASE_FLEET_SIZE written out by hand, and # said 5, which was the old BASE_FLEET_SIZE written out by hand, and

View file

@ -36,6 +36,9 @@ func _process(delta: float) -> bool:
0: 0:
# --- E. a normal game hands over nothing ---------------------- # --- E. a normal game hands over nothing ----------------------
game_state.reset_new_game() game_state.reset_new_game()
# #108: reset_new_game() also arms the prologue. Nothing below is
# about it, and this test never builds a scene for it to run in.
game_state.intro_active = false
if game_state.rocks != 0: if game_state.rocks != 0:
return _fail("a normal game started with %d ore banked" % game_state.rocks) return _fail("a normal game started with %d ore banked" % game_state.rocks)
for resource_type in game_state.resource_types(): for resource_type in game_state.resource_types():

View file

@ -31,6 +31,11 @@ var elapsed: float = 0.0
func _initialize() -> void: func _initialize() -> void:
game_state = root.get_node("GameState") game_state = root.get_node("GameState")
game_state.reset_new_game() game_state.reset_new_game()
# #108: reset_new_game() arms the prologue, and the prologue opens with a
# convoy rather than the game's own starting fleet. This test is about
# what the GAME starts from, which is where the prologue hands over -
# see test_intro_scene.gd for the prologue itself.
game_state.intro_active = false
func _process(delta: float) -> bool: func _process(delta: float) -> bool:
@ -96,6 +101,9 @@ func _process(delta: float) -> bool:
# --- B. what actually flies ---------------------------------- # --- B. what actually flies ----------------------------------
game_state.reset_new_game() game_state.reset_new_game()
# ... and again here: this is the reset the scene below is built
# from, so the prologue has to be off for THIS one too.
game_state.intro_active = false
scene = load("res://world/Main.tscn").instantiate() scene = load("res://world/Main.tscn").instantiate()
root.add_child(scene) root.add_child(scene)
fleet = scene.get_node("Fleet") fleet = scene.get_node("Fleet")

296
tests/test_intro_scene.gd Normal file
View file

@ -0,0 +1,296 @@
extends SceneTree
# Headless test: run with
# godot --headless -s res://tests/test_intro_scene.gd
# Forgejo #108 (#122, #123, #124): the prologue.
#
# The director drives the REAL scene rather than being a scene of its own, so
# everything below is checked against the real fleet, the real enemies and
# the real asteroid field.
#
# Covers:
# A. it runs on a NEW game and not on a resumed one
# B. "no action button available, only movements" - every pressable
# affordance is gone, and the joystick is not
# C. the convoy: bigger than the player's own ceiling, and mostly holds
# D. the guide speaks, and the banner never shows a line nobody set
# E. the ambush is easy: chasers only, and clearing them advances the beat
# F. the belt is dense, and it is across the lane rather than beside it
# G. the breach takes the wheel: player input stops moving the fleet
# H. the vortex leaves exactly one vessel, and it is the default interceptor
# I. the handover: the flag clears, the buttons come back, the spawner and
# the asteroid field are running again, and the fleet is a new game's
# J. skipping lands in the same state as playing it through
var game_state: Node
var scene: Node
var director: Node
var fleet: Node
var narration: Control
var stage: int = 0
var elapsed: float = 0.0
var convoy_size: int = 0
var skipped_props: Array = []
func _initialize() -> void:
# GameState is an autoload: a bare GameState.<instance member> does not
# compile in a SceneTree script, so the node is fetched by name instead.
game_state = root.get_node("GameState")
game_state.reset_new_game()
game_state.rocks = 0
func _fail(message: String) -> bool:
print("TEST_INTRO_SCENE: FAIL (%s)" % message)
quit(1)
return true
func _load_scene() -> void:
scene = load("res://world/Main.tscn").instantiate()
root.add_child(scene)
current_scene = scene
fleet = scene.get_node("Fleet")
director = scene.get_node_or_null("IntroDirector")
narration = scene.get_node("UI/NarrationBanner")
func _drop_scene() -> void:
scene.queue_free()
scene = null
func _hidden(path: String) -> bool:
var node: Control = scene.get_node_or_null(path)
return node == null or not node.visible
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
# --- A. a new game gets one ------------------------------
if not game_state.intro_active:
return _fail("reset_new_game() did not arm the prologue")
_load_scene()
elapsed = 0.0
stage = 1
return false
1:
if elapsed < 0.4:
return false
if director == null:
return _fail("no IntroDirector in the scene")
if not director.running:
return _fail("the director did not start on a new game")
# --- C. the convoy --------------------------------------
convoy_size = fleet.ships.size()
if convoy_size <= GameState.BASE_FLEET_SIZE:
return _fail("the convoy is %d vessels, no more than a new game's %d slots" \
% [convoy_size, GameState.BASE_FLEET_SIZE])
var holds: int = 0
var escorts: int = 0
for ship in fleet.ships:
if String(ship.vessel_type) == GameState.VESSEL_INTERCEPTOR:
escorts += 1
else:
holds += 1
if holds <= escorts:
return _fail("the convoy is %d holds to %d escorts - it should be mostly cargo" \
% [holds, escorts])
if escorts <= 0:
return _fail("the convoy has no escorts at all")
print("convoy: %d vessels, %d holds and %d escorts" % [convoy_size, holds, escorts])
# --- B. movement and nothing else -----------------------
for pressable in ["UI/BuildButton", "UI/MapButton", "UI/BuildActionButton",
"UI/BuildingActionButton", "UI/EnterAtmosphereButton",
"UI/TransferButton", "UI/HangarButton", "UI/LaserButton",
"UI/GasBombButton"]:
if not _hidden(pressable):
return _fail("%s is offered during the prologue" % pressable)
var joystick: Control = scene.get_node("UI/Joystick")
if not joystick.visible:
return _fail("the joystick is hidden - the player cannot even fly")
print("every action button is away; the joystick is not")
# --- D. the guide speaks --------------------------------
if not narration.is_speaking():
return _fail("the prologue opened without a word from the guide")
if narration.label.text.strip_edges() == "":
return _fail("the banner is up with nothing on it")
print("guide: \"%s\"" % narration.label.text)
# --- E. the ambush --------------------------------------
director._begin(director.Beat.AMBUSH)
if director.ambush.is_empty():
return _fail("the ambush beat spawned nothing")
for enemy in director.ambush:
if String(enemy.enemy_id) != "chaser":
return _fail("the ambush includes a %s - it is meant to be easy" % enemy.enemy_id)
if director._beat_complete():
return _fail("the ambush counts as cleared while its raiders are alive")
for enemy in director.ambush:
enemy.queue_free()
elapsed = 0.0
stage = 2
return false
2:
if elapsed < 0.3:
return false
# The director advances itself, so the evidence that clearing the
# ambush ended the beat is that it has MOVED ON - asking
# _beat_complete() again would be asking about a beat it left.
if director.beat <= director.Beat.AMBUSH:
return _fail("killing every raider did not end the ambush beat")
print("%d raiders, all chasers, and clearing them ends the beat" % director.AMBUSH_COUNT)
# --- F. the belt ----------------------------------------
director._begin(director.Beat.GAUNTLET)
# Only the rocks still standing: the convoy flies into them, and
# a rock it has already broken is not evidence about the belt.
var standing: Array = []
for rock in director.rocks:
if is_instance_valid(rock) and not rock.is_queued_for_deletion():
standing.append(rock)
if standing.size() < 40:
return _fail("the \"pretty dense\" belt is %d rocks" % standing.size())
var ahead: int = 0
for rock in standing:
if rock.global_position.y < fleet.get_centroid().y:
ahead += 1
if ahead < standing.size() / 2:
return _fail("only %d of %d rocks are on the lane ahead" \
% [ahead, standing.size()])
print("belt: %d rocks, %d of them across the lane ahead" % [standing.size(), ahead])
# --- G. the breach takes the wheel ----------------------
director._begin(director.Beat.BREACH)
if not game_state.intro_locks_movement:
return _fail("the breach beat left the player in control")
# GameController.gd is the script on Main itself, not a child.
var before: Vector2 = fleet.fleet_target
scene._process(0.1)
if fleet.fleet_target != before:
return _fail("player steering still moved the fleet during the breach")
print("the breach has the wheel: steering input moves nothing")
# --- H. the vortex --------------------------------------
director._begin(director.Beat.VORTEX)
elapsed = 0.0
stage = 3
return false
3:
# Left to the director's own clock rather than ticked by hand:
# the vortex is a timed animation and driving it twice would not
# be the thing that ships.
if elapsed < 4.2:
return false
var alive: int = 0
for ship in fleet.ships:
if is_instance_valid(ship) and not ship.is_queued_for_deletion():
alive += 1
if alive != 1:
return _fail("the vortex left %d vessels, expected 1" % alive)
if String(fleet.ships[0].vessel_type) != GameState.VESSEL_INTERCEPTOR:
return _fail("the survivor is a %s, not the default interceptor" \
% fleet.ships[0].vessel_type)
print("the vortex took %d vessels and left one interceptor" % (convoy_size - 1))
# --- I. the handover ------------------------------------
director.finish()
elapsed = 0.0
stage = 4
return false
4:
if elapsed < 0.3:
return false
if game_state.intro_active or game_state.intro_locks_movement:
return _fail("the prologue finished without clearing its own flags")
if game_state.intro_fleet_size != 0:
return _fail("the convoy's fleet-size override outlived the prologue")
if game_state.max_fleet_size() != GameState.BASE_FLEET_SIZE:
return _fail("the fleet still holds %d slots after the prologue" \
% game_state.max_fleet_size())
if fleet.ships.size() != GameState.STARTING_SHIP_COUNT:
return _fail("the game starts with %d vessels, expected %d" \
% [fleet.ships.size(), GameState.STARTING_SHIP_COUNT])
if not scene.get_node("EnemySpawner").is_processing():
return _fail("waves are still switched off after the prologue")
if not scene.get_node("AsteroidField").is_processing():
return _fail("the asteroid field is still switched off after the prologue")
scene.get_node("BuildController")._process(0.1)
if _hidden("UI/BuildButton"):
return _fail("the build button never came back")
if narration.is_speaking():
return _fail("the guide is still talking after the handover")
print("handover: flags clear, 1 interceptor, waves and rocks running, buttons back")
_drop_scene()
elapsed = 0.0
stage = 5
return false
5:
if elapsed < 0.3:
return false
# --- A (second half). a resumed game gets no prologue ----
game_state.intro_active = false
_load_scene()
elapsed = 0.0
stage = 6
return false
6:
if elapsed < 0.4:
return false
if director != null and is_instance_valid(director) and director.running:
return _fail("the prologue ran again on a game that is past it")
if fleet.ships.size() > GameState.BASE_FLEET_SIZE:
return _fail("a resumed game opened with a convoy")
print("a game already past the prologue does not replay it")
_drop_scene()
elapsed = 0.0
stage = 7
return false
7:
if elapsed < 0.3:
return false
# --- J. skipping lands where playing it through does -----
game_state.reset_new_game()
_load_scene()
elapsed = 0.0
stage = 8
return false
8:
if elapsed < 0.4:
return false
# Skipped mid-prologue, with props on the board, so the check
# below is about clearing them rather than about an empty stage.
director._begin(director.Beat.AMBUSH)
director._begin(director.Beat.GAUNTLET)
skipped_props = director.ambush + director.rocks
if skipped_props.size() < 40:
return _fail("nothing was on the board to skip past")
director.skip()
elapsed = 0.0
stage = 9
return false
9:
if elapsed < 0.3:
return false
if game_state.intro_active or game_state.intro_locks_movement:
return _fail("skipping left the prologue's flags set")
if game_state.max_fleet_size() != GameState.BASE_FLEET_SIZE:
return _fail("skipping left the convoy's fleet size behind")
if fleet.ships.size() != GameState.STARTING_SHIP_COUNT:
return _fail("skipping left %d vessels flying" % fleet.ships.size())
for prop in skipped_props:
if is_instance_valid(prop) and not prop.is_queued_for_deletion():
return _fail("skipping left the prologue's own props on the board")
print("skipping clears %d props and lands where playing it through does" \
% skipped_props.size())
print("TEST_INTRO_SCENE: PASS")
quit(0)
return true
return false

View file

@ -24,6 +24,11 @@ var elapsed: float = 0.0
func _initialize() -> void: func _initialize() -> void:
game_state = root.get_node("GameState") game_state = root.get_node("GameState")
game_state.reset_new_game() game_state.reset_new_game()
# #108: reset_new_game() arms the prologue, and the prologue opens with a
# convoy rather than the game's own starting fleet. This test is about
# what the GAME starts from, which is where the prologue hands over -
# see test_intro_scene.gd for the prologue itself.
game_state.intro_active = false
func _process(delta: float) -> bool: func _process(delta: float) -> bool:

View file

@ -65,6 +65,11 @@ func _initialize() -> void:
# --- F. a fresh game still reaches a crafter and an extractor ------------- # --- F. a fresh game still reaches a crafter and an extractor -------------
game_state.reset_new_game() game_state.reset_new_game()
# #108: reset_new_game() arms the prologue, and the prologue opens with a
# convoy rather than the game's own starting fleet. This test is about
# what the GAME starts from, which is where the prologue hands over -
# see test_intro_scene.gd for the prologue itself.
game_state.intro_active = false
# can_unlock() also checks the ore price, and a new game now banks # can_unlock() also checks the ore price, and a new game now banks
# nothing - the opening mining run is the point. Stock the ore so this # nothing - the opening mining run is the point. Stock the ore so this
# checks what it says it checks: reachability at ZERO RESEARCH POINTS. # checks what it says it checks: reachability at ZERO RESEARCH POINTS.

110
ui/NarrationBanner.gd Normal file
View file

@ -0,0 +1,110 @@
extends Control
# #122: what the guide says during the prologue.
#
# A banner rather than a dialogue box: the player is flying while they read
# it, so it sits across the top out of the way of the fleet and never takes
# a press to dismiss. The SCRIPT decides when a line is done, not the player
# - a prologue that waits for a tap is a prologue that can be sat in.
#
# Lines queue rather than replacing each other, so a beat that fires while
# the previous line is still up does not silently eat it.
const HOLD_SECONDS: float = 4.5
const FADE_SECONDS: float = 0.5
const TEXT_COLOR: Color = Color(0.82, 0.92, 1.0)
const FRAME_COLOR: Color = Color(0.45, 0.68, 0.95)
var label: Label
var queued: Array = []
var current: String = ""
var time_left: float = 0.0
func _ready() -> void:
_ensure_label()
# The director is a world node and this banner lives under the UI layer,
# which readies afterwards - so the first line can arrive before _ready().
# Building on demand is simpler than making the director wait a frame, and
# it cannot be got wrong twice.
func _ensure_label() -> void:
if label != null:
return
mouse_filter = Control.MOUSE_FILTER_IGNORE
visible = false
label = Label.new()
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
label.add_theme_font_size_override("font_size", 30)
label.add_theme_color_override("font_color", TEXT_COLOR)
label.add_theme_color_override("font_outline_color", Color(0.01, 0.02, 0.06, 0.95))
label.add_theme_constant_override("outline_size", 4)
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
add_child(label)
resized.connect(_layout)
_layout()
func _layout() -> void:
if label == null:
return
label.position = Vector2(24.0, 0.0)
label.size = Vector2(maxf(size.x - 48.0, 0.0), size.y)
# Says one line. Held for `hold` seconds, then the next in the queue.
func say(line: String, hold: float = HOLD_SECONDS) -> void:
_ensure_label()
queued.append({"line": line, "hold": maxf(hold, 0.1)})
if current == "":
_advance()
func clear() -> void:
queued.clear()
current = ""
time_left = 0.0
visible = false
func is_speaking() -> bool:
return current != "" or not queued.is_empty()
func _advance() -> void:
if queued.is_empty():
current = ""
time_left = 0.0
visible = false
return
var next: Dictionary = queued.pop_front()
current = String(next.line)
time_left = float(next.hold)
label.text = current
visible = true
queue_redraw()
func _process(delta: float) -> void:
if current == "":
return
time_left -= delta
queue_redraw()
if time_left <= 0.0:
_advance()
func _draw() -> void:
if current == "":
return
# Fades out over its last moments so lines hand over rather than cutting.
var alpha: float = clampf(time_left / FADE_SECONDS, 0.0, 1.0)
label.modulate.a = alpha
draw_rect(Rect2(Vector2.ZERO, size), Color(0.02, 0.05, 0.12, 0.72 * alpha))
NeonDraw.draw_glowing_line(self, Vector2(0.0, size.y), Vector2(size.x, size.y),
Color(FRAME_COLOR, alpha), 0.7, 0.55)
NeonDraw.draw_glowing_line(self, Vector2(0.0, 0.0), Vector2(size.x, 0.0),
Color(FRAME_COLOR, alpha * 0.6), 0.5, 0.4)

350
world/IntroDirector.gd Normal file
View file

@ -0,0 +1,350 @@
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

View file

@ -24,6 +24,8 @@
[ext_resource type="Script" path="res://ui/GarrisonPanel.gd" id="33"] [ext_resource type="Script" path="res://ui/GarrisonPanel.gd" id="33"]
[ext_resource type="Script" path="res://vessels/GarrisonField.gd" id="34"] [ext_resource type="Script" path="res://vessels/GarrisonField.gd" id="34"]
[ext_resource type="Script" path="res://ui/PlanningPanel.gd" id="35"] [ext_resource type="Script" path="res://ui/PlanningPanel.gd" id="35"]
[ext_resource type="Script" path="res://ui/NarrationBanner.gd" id="36"]
[ext_resource type="Script" path="res://world/IntroDirector.gd" id="37"]
[ext_resource type="Script" path="res://logistics/TransporterField.gd" id="23"] [ext_resource type="Script" path="res://logistics/TransporterField.gd" id="23"]
[ext_resource type="Script" path="res://ui/ResourceBar.gd" id="24"] [ext_resource type="Script" path="res://ui/ResourceBar.gd" id="24"]
[ext_resource type="Script" path="res://logistics/VesselDeliveryField.gd" id="26"] [ext_resource type="Script" path="res://logistics/VesselDeliveryField.gd" id="26"]
@ -92,6 +94,9 @@ script = ExtResource("11")
[node name="BuildingField" type="Node2D" parent="."] [node name="BuildingField" type="Node2D" parent="."]
script = ExtResource("14") script = ExtResource("14")
[node name="IntroDirector" type="Node2D" parent="."]
script = ExtResource("37")
[node name="GarrisonField" type="Node2D" parent="."] [node name="GarrisonField" type="Node2D" parent="."]
script = ExtResource("34") script = ExtResource("34")
@ -214,6 +219,16 @@ offset_bottom = 172
script = ExtResource("10") script = ExtResource("10")
icon = 0 icon = 0
[node name="NarrationBanner" type="Control" parent="UI"]
visible = false
mouse_filter = 2
anchor_right = 1.0
offset_left = 60.0
offset_right = -60.0
offset_top = 300.0
offset_bottom = 428.0
script = ExtResource("36")
[node name="WaveAlert" type="Control" parent="UI"] [node name="WaveAlert" type="Control" parent="UI"]
visible = false visible = false
mouse_filter = 2 mouse_filter = 2

View file

@ -21,6 +21,20 @@ func _ready() -> void:
close_button.pressed.connect(close_map) close_button.pressed.connect(close_map)
func _process(_delta: float) -> void:
# #122: the prologue leaves movement and nothing else, and the map is a
# button like any other. Owned here rather than by BuildController, which
# hides the rest - this button belongs to the map.
if GameState.intro_active:
if open_button.visible:
open_button.visible = false
if visible:
close_map()
open_button.visible = false
elif not open_button.visible and not visible:
open_button.visible = true
func open_map() -> void: func open_map() -> void:
visible = true visible = true
open_button.visible = false open_button.visible = false