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>
110 lines
3.1 KiB
GDScript
110 lines
3.1 KiB
GDScript
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)
|