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 _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)