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. 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" var label: Label var speaker_label: Label var queued: Array = [] var current: String = "" var speaker: String = "" var shown_for: 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 dismissed.emit() _advance() return true func _advance() -> void: shown_for = 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) label.text = current speaker_label.text = speaker visible = true _layout() queue_redraw() _sync_pause() func _process(delta: float) -> void: if current == "": return # Only for the fade-in and the prompt's blink. Nothing here closes the # line - that is the player's job (#127). shown_for += delta queue_redraw() func _unhandled_input(event: InputEvent) -> void: if current == "": return var pressed: bool = (event is InputEventScreenTouch and event.pressed) \ or (event is InputEventMouseButton and event.pressed \ and event.button_index == MOUSE_BUTTON_LEFT) if not pressed: 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. # A blinking caret at the bottom right - the one thing that says a click # is what moves this on. var blink: float = 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)