391 lines
15 KiB
GDScript
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)
|