spaceship/ui/NarrationBanner.gd
2026-08-31 18:15:37 +02:00

391 lines
15 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.
#
# #127 makes it wait for the player. A line stays until it is DISMISSED -
# there is no timer under it - because a prologue whose lines expire on
# their own is a prologue that reads at the game's pace rather than at the
# reader's. Lines queue rather than replacing each other, so a beat that
# fires while the previous line is still up does not silently eat it.
#
# The beats that are about the guide talking hold while this is speaking
# (see IntroDirector._waits_for_narration), so dismissing a line is what
# advances the prologue.
#
# #128 makes that unconditional: while a line is up the WHOLE GAME is
# stopped - get_tree().paused, which freezes the fleet, the enemies, the
# production in GameState, and the director's own beat clock, and which
# stops input reaching any node that is not this one. So there is nothing
# to miss while reading and nothing to fumble a command into, and a beat's
# deadline can no longer run out from under an unacknowledged line: the
# player's click is the only thing that starts the world again.
#
# The pause is owned HERE rather than by each caller, because the thing that
# must not get out of step is "a line is showing" and "the game is stopped",
# and the only code that knows the first is this file.
#
# #149: a line ARRIVES rather than appearing. It is typed out at a readable
# rate, with a scan sweep running down the panel while it does - a
# transmission being decoded rather than a caption being switched on.
#
# A press during the typing FINISHES it instead of dismissing the line. That
# is not a flourish: without it, a player who clicks quickly through two
# lines never sees the second one at all, and the whole point of #127 was
# that lines wait for the reader.
#
# #168 says the same rule out loud - still arriving, show it all; already
# whole, move on - and extends it to the KEYBOARD, which had no way in at
# all. See _is_advance_press.
signal dismissed
const FADE_IN_SECONDS: float = 0.35
const TEXT_COLOR: Color = Color(0.82, 0.92, 1.0)
const FRAME_COLOR: Color = Color(0.45, 0.68, 0.95)
const SPEAKER_COLOR: Color = Color(0.62, 0.86, 1.0)
# #125: the panel was a plain rectangle with a line under it. These are the
# pieces that make it read as a transmission window instead - the corners
# are cut and the short edges are brackets rather than sides. All geometry
# rather than art, so it scales with the banner and costs nothing.
const CUT: float = 26.0
const BRACKET: float = 34.0
const INSET: float = 7.0
# #127: the speaker's name sits ON the top edge at the left, in a notch cut
# out of the border, the way a label is riveted to a panel.
const SPEAKER_FONT_SIZE: int = 20
const SPEAKER_PAD: float = 12.0
const SPEAKER_INSET: float = 46.0
const DEFAULT_SPEAKER: String = "CONVOY CONTROL"
# #149: how fast a line decodes. Fast enough not to be a wait, slow enough
# to read as arriving - about three and a half words a second.
const CHARS_PER_SECOND: float = 42.0
# The scan sweep's travel time, top to bottom of the panel.
const SWEEP_SECONDS: float = 0.9
const SWEEP_COLOR: Color = Color(0.55, 0.95, 1.0)
# Faint horizontal rules over the whole panel, the way a screen has them.
const SCANLINE_GAP: float = 7.0
var label: Label
var speaker_label: Label
var queued: Array = []
var current: String = ""
var speaker: String = ""
var shown_for: float = 0.0
# How much of the current line has been decoded, in characters.
var revealed: float = 0.0
# Whether the pause currently in force is OURS. Checked before lifting it, so
# a pause somebody else put on (a menu, a future one) is never lifted by a
# dialog closing.
var _holds_pause: bool = false
func _ready() -> void:
# #128: the one node that keeps running while the game is stopped. Set
# here as well as in _ensure_label() because a banner that pauses the
# tree and then cannot process its own dismissal would wedge the game.
process_mode = Node.PROCESS_MODE_ALWAYS
_ensure_label()
# The pause goes on when a line goes up and comes off when the last one is
# gone. Both are idempotent, so it does not matter how many times the state
# is re-derived - only that it is derived from one place (_sync_pause).
func _sync_pause() -> void:
var wanted: bool = current != ""
if wanted == _holds_pause:
return
var tree: SceneTree = get_tree()
if tree == null:
# Not in the tree: there is nothing to pause, and nothing we could
# have paused earlier is still ours to lift.
_holds_pause = false
return
if wanted:
tree.paused = true
_holds_pause = true
return
tree.paused = false
_holds_pause = false
# Leaving the tree with a line up - the scene swapping to a planet surface,
# a test dropping the scene - must not leave the game stopped with nothing
# left that could start it again.
func _exit_tree() -> void:
if not _holds_pause:
return
var tree: SceneTree = get_tree()
if tree != null:
tree.paused = false
_holds_pause = false
# 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
process_mode = Node.PROCESS_MODE_ALWAYS
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)
speaker_label = Label.new()
speaker_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
speaker_label.add_theme_font_size_override("font_size", SPEAKER_FONT_SIZE)
speaker_label.add_theme_color_override("font_color", SPEAKER_COLOR)
speaker_label.add_theme_color_override("font_outline_color", Color(0.01, 0.02, 0.06, 0.95))
speaker_label.add_theme_constant_override("outline_size", 3)
speaker_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
add_child(speaker_label)
resized.connect(_layout)
_layout()
func _layout() -> void:
if label == null:
return
# The text clears the speaker tab at the top and the brackets at the
# sides, so a long line never runs underneath either.
label.position = Vector2(BRACKET, SPEAKER_FONT_SIZE * 0.9)
label.size = Vector2(maxf(size.x - BRACKET * 2.0, 0.0),
maxf(size.y - SPEAKER_FONT_SIZE * 0.9, 0.0))
speaker_label.position = Vector2(SPEAKER_INSET + SPEAKER_PAD,
-SPEAKER_FONT_SIZE * 0.5)
speaker_label.size = Vector2(_speaker_width(), SPEAKER_FONT_SIZE)
func _speaker_width() -> float:
if speaker_label == null or speaker_label.text == "":
return 0.0
var font: Font = speaker_label.get_theme_font("font")
if font == null:
return float(speaker_label.text.length()) * SPEAKER_FONT_SIZE * 0.6
return font.get_string_size(speaker_label.text, HORIZONTAL_ALIGNMENT_LEFT, -1.0,
SPEAKER_FONT_SIZE).x
# Says one line, from someone. It stays up until dismissed.
func say(line: String, who: String = DEFAULT_SPEAKER) -> void:
_ensure_label()
queued.append({"line": line, "speaker": who})
if current == "":
_advance()
func clear() -> void:
queued.clear()
current = ""
speaker = ""
shown_for = 0.0
visible = false
_sync_pause()
func is_speaking() -> bool:
return current != "" or not queued.is_empty()
# Closes the current line and shows whatever is behind it. Public so a test
# - or any other way of dismissing a dialog that gets added later - has the
# same door the click uses.
func dismiss() -> bool:
if current == "":
return false
# #149: still arriving, so this press is "show me the rest" rather than
# "I have read it". It still counts as handled - the press must not fall
# through and steer the fleet either way.
if is_typing():
finish_typing()
return true
dismissed.emit()
_advance()
return true
func _advance() -> void:
shown_for = 0.0
revealed = 0.0
if queued.is_empty():
current = ""
speaker = ""
visible = false
_sync_pause()
return
var next: Dictionary = queued.pop_front()
current = String(next.line)
speaker = String(next.speaker)
revealed = 0.0
label.text = current
# The full text is set and then hidden a character at a time, rather than
# the text growing. Growing it would re-wrap the line on nearly every
# frame, so words would jump between rows as they were typed.
label.visible_characters = 0
speaker_label.text = speaker
visible = true
Sfx.play(get_tree(), Sfx.DIALOG)
_layout()
queue_redraw()
_sync_pause()
func is_typing() -> bool:
return current != "" and revealed < float(current.length())
# Shows the whole line at once. What a press does while it is still arriving.
func finish_typing() -> void:
revealed = float(current.length())
if label != null:
label.visible_characters = -1
queue_redraw()
func _process(delta: float) -> void:
if current == "":
return
# The fade-in, the prompt's blink, and the decode. Nothing here closes
# the line - that is the player's job (#127).
shown_for += delta
if is_typing():
revealed = minf(revealed + delta * CHARS_PER_SECOND, float(current.length()))
label.visible_characters = -1 if not is_typing() else int(revealed)
queue_redraw()
# #168: what counts as "go on". A tap, a left click, or a key.
#
# The rule the ticket asks for was already the rule for a click (#149) - a
# press finishes a line still arriving, a press on a finished line moves to
# the next - but the game is played on a desktop with WASD and a mouse, and
# a keyboard press reached nothing at all. Sitting behind a dialog pressing
# space is the most obvious thing a player can do, so the keyboard now goes
# through the same one door every other press does (dismiss()).
#
# Modifiers alone are not a press: holding shift before typing must not eat
# a line. Neither is an auto-repeat echo - a held key would otherwise run
# the whole queue out in a second.
func _is_advance_press(event: InputEvent) -> bool:
if event is InputEventScreenTouch:
return event.pressed
if event is InputEventMouseButton:
return event.pressed and event.button_index == MOUSE_BUTTON_LEFT
if event is InputEventKey:
if not event.pressed or event.echo:
return false
return not (event.keycode in [KEY_SHIFT, KEY_CTRL, KEY_ALT, KEY_META,
KEY_CAPSLOCK, KEY_NUMLOCK, KEY_SCROLLLOCK])
return false
func _unhandled_input(event: InputEvent) -> void:
if current == "":
return
if not _is_advance_press(event):
return
# A dismissed line eats the press: it must not also be steering the
# fleet or firing something on the way through.
if dismiss():
get_viewport().set_input_as_handled()
# The panel's outline: a rectangle with its corners cut off, and a notch in
# the top edge where the speaker's name sits. Returned rather than drawn so
# the fill and the stroke are guaranteed to be the same shape.
func _frame() -> PackedVector2Array:
var w: float = size.x
var h: float = size.y
var tab_start: float = SPEAKER_INSET
var tab_end: float = SPEAKER_INSET + _speaker_width() + SPEAKER_PAD * 2.0
var lift: float = SPEAKER_FONT_SIZE * 0.9
return PackedVector2Array([
Vector2(CUT, 0.0),
# The notch: up over the speaker's name and back down.
Vector2(tab_start, 0.0), Vector2(tab_start + 6.0, -lift),
Vector2(tab_end - 6.0, -lift), Vector2(tab_end, 0.0),
Vector2(w - CUT, 0.0), Vector2(w, CUT),
Vector2(w, h - CUT), Vector2(w - CUT, h), Vector2(CUT, h),
Vector2(0.0, h - CUT), Vector2(0.0, CUT),
])
func _draw() -> void:
if current == "":
return
var alpha: float = clampf(shown_for / FADE_IN_SECONDS, 0.0, 1.0)
label.modulate.a = alpha
speaker_label.modulate.a = alpha
var frame: PackedVector2Array = _frame()
draw_colored_polygon(frame, Color(0.02, 0.05, 0.12, 0.78 * alpha))
NeonDraw.draw_glowing_polygon(self, frame, Color(FRAME_COLOR, alpha), 0.8)
# Brackets at the short edges: a doubled inner line that stops well short
# of the corners, so the panel reads as held open rather than closed.
for side in [0.0, 1.0]:
var x: float = lerpf(INSET, size.x - INSET, side)
NeonDraw.draw_glowing_line(self, Vector2(x, CUT + INSET),
Vector2(x, size.y - CUT - INSET), Color(FRAME_COLOR, alpha * 0.75), 0.6, 0.3)
var arm: float = BRACKET * (1.0 if side < 0.5 else -1.0)
for y in [CUT + INSET, size.y - CUT - INSET]:
NeonDraw.draw_glowing_line(self, Vector2(x, y), Vector2(x + arm, y),
Color(FRAME_COLOR, alpha * 0.75), 0.6, 0.3)
# #127: nothing under the text. The tick strip that used to run along the
# bottom edge read as an underline attached to the last line rather than
# as part of the frame.
# #149: the panel reads as a screen decoding something.
#
# Faint rules across the whole of it, always - a surface with a raster
# rather than a sheet of glass - and a bright sweep running down it while
# the line is still arriving, which stops when the decode does. The
# sweep is the thing that says "this is happening now"; without it a
# half-typed line just looks like a short line.
var inner: Rect2 = Rect2(Vector2(INSET, CUT * 0.5),
Vector2(maxf(size.x - INSET * 2.0, 0.0), maxf(size.y - CUT, 0.0)))
var rule: float = inner.position.y
while rule < inner.end.y:
draw_line(Vector2(inner.position.x, rule), Vector2(inner.end.x, rule),
Color(SWEEP_COLOR, 0.05 * alpha), 1.0)
rule += SCANLINE_GAP
if is_typing():
var sweep: float = fposmod(shown_for, SWEEP_SECONDS) / SWEEP_SECONDS
var y: float = lerpf(inner.position.y, inner.end.y, sweep)
# Brightest at the head with a short trail above it, so it reads as
# travelling rather than as a line that moved.
for step in range(6):
var trail: float = y - float(step) * 3.0
if trail < inner.position.y:
break
draw_line(Vector2(inner.position.x, trail), Vector2(inner.end.x, trail),
Color(SWEEP_COLOR, alpha * 0.30 * (1.0 - float(step) / 6.0)), 1.0)
# And the frame carries the same charge while it is working.
NeonDraw.draw_glowing_line(self, Vector2(INSET, y), Vector2(size.x - INSET, y),
Color(SWEEP_COLOR, alpha * 0.5), 0.7, 0.25)
# A blinking caret at the bottom right - the one thing that says a click
# is what moves this on.
# Solid while decoding - there is nothing to prompt for yet - and
# blinking once the line is complete and waiting on the reader.
var blink: float = 1.0 if is_typing() else 0.45 + 0.55 * absf(sin(shown_for * 3.0))
var tip: Vector2 = Vector2(size.x - CUT - 6.0, size.y - INSET - 12.0)
NeonDraw.draw_glowing_path(self, PackedVector2Array([
tip + Vector2(-11.0, -7.0), tip, tip + Vector2(-11.0, 7.0),
]), Color(0.78, 0.94, 1.0, alpha * blink), 0.9, 0.4)