diff --git a/tests/test_intro_scene.gd b/tests/test_intro_scene.gd index ac8bfe8..48d46b8 100644 --- a/tests/test_intro_scene.gd +++ b/tests/test_intro_scene.gd @@ -26,6 +26,9 @@ extends SceneTree # L. #125 - the convoy is spread out, with its holds surrounded by escorts # M. #125 - the breach is a growing black hole with a lens over it, not a # few lines turning around a point +# N. #127 - a dialog stays up until it is dismissed: no timer closes it, +# a click does, and the click is eaten rather than also steering +# O. #127 - every dialog names its speaker var game_state: Node var scene: Node @@ -90,6 +93,39 @@ func _process(delta: float) -> bool: if not director.running: return _fail("the director did not start on a new game") + # --- N. a dialog waits for the reader -------------------- + var first_line: String = narration.label.text + if first_line.strip_edges() == "": + return _fail("the prologue opened with an empty banner") + # Ten seconds of clock, with nothing pressed. A timer-driven + # banner would have moved on several times over. + for i in range(600): + narration._process(1.0 / 60.0) + if narration.label.text != first_line: + return _fail("the line changed on its own after 10s - #127 asks for a click") + if not narration.visible: + return _fail("the banner closed itself") + var click: InputEventMouseButton = InputEventMouseButton.new() + click.button_index = MOUSE_BUTTON_LEFT + click.pressed = true + narration._unhandled_input(click) + if narration.label.text == first_line and narration.is_speaking(): + return _fail("a click did not move the dialog on") + print("line held for 10s untouched, then a click moved it on") + + # --- O. and it says who is talking ----------------------- + if narration.speaker.strip_edges() == "": + return _fail("the dialog names no speaker") + if narration.speaker_label.text != narration.speaker: + return _fail("the speaker label does not show the speaker") + # It sits on the top border, at the left - above the text, and + # left of centre. + if narration.speaker_label.position.y >= narration.label.position.y: + return _fail("the speaker sits below the text, not on the top border") + if narration.speaker_label.position.x > narration.size.x * 0.5: + return _fail("the speaker is not at the left of the border") + print("speaker \"%s\" on the top-left border" % narration.speaker) + # --- K. nothing ends before its floor -------------------- # The floors are what #125 bought: every beat had a condition # that could be satisfied while the guide was still talking. @@ -100,6 +136,10 @@ func _process(delta: float) -> bool: return _fail("the opening beat was over before the first line finished") if not narration.is_speaking(): return _fail("the opening beat is silent, so waiting for it proves nothing") + # A player who never clicks must not be stuck: the beat's own + # deadline is the way out. + if director._deadline() <= director._floor_time(): + return _fail("the opening beat has no deadline past its floor - a silent player would stall") print("opening beat still running at %.1fs, guide still talking" % director.beat_time) # --- C. the convoy -------------------------------------- diff --git a/ui/NarrationBanner.gd b/ui/NarrationBanner.gd index 2c17bc4..6dab8a7 100644 --- a/ui/NarrationBanner.gd +++ b/ui/NarrationBanner.gd @@ -3,34 +3,45 @@ 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. +# it, so it sits across the top out of the way of the fleet. # -# Lines queue rather than replacing each other, so a beat that fires while -# the previous line is still up does not silently eat it. +# #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 - bounded by each beat's own deadline, so a player +# who never clicks is never stuck. -# #125: lines were gone before they had been read. A held line is the -# cheapest possible pacing control and the prologue leans on it heavily. -const HOLD_SECONDS: float = 6.5 -const FADE_SECONDS: float = 0.5 +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, the short edges are brackets rather than sides, and there is a -# tick strip along the bottom. All of it is geometry rather than art, so it -# scales with the banner and costs nothing. +# 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 -const TICK_COUNT: int = 24 -const TICK_HEIGHT: float = 5.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 time_left: float = 0.0 +var speaker: String = "" +var shown_for: float = 0.0 func _ready() -> void: @@ -56,6 +67,16 @@ func _ensure_label() -> void: 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() @@ -63,14 +84,30 @@ func _ensure_label() -> void: 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) + # 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) -# Says one line. Held for `hold` seconds, then the next in the queue. -func say(line: String, hold: float = HOLD_SECONDS) -> void: +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, "hold": maxf(hold, 0.1)}) + queued.append({"line": line, "speaker": who}) if current == "": _advance() @@ -78,7 +115,8 @@ func say(line: String, hold: float = HOLD_SECONDS) -> void: func clear() -> void: queued.clear() current = "" - time_left = 0.0 + speaker = "" + shown_for = 0.0 visible = false @@ -86,36 +124,72 @@ 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 = "" - time_left = 0.0 + speaker = "" visible = false return var next: Dictionary = queued.pop_front() current = String(next.line) - time_left = float(next.hold) + speaker = String(next.speaker) label.text = current + speaker_label.text = speaker visible = true + _layout() queue_redraw() func _process(delta: float) -> void: if current == "": return - time_left -= delta + # 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() - if time_left <= 0.0: - _advance() -# The panel's outline: a rectangle with its corners cut off. Returned rather -# than drawn so the fill and the stroke are guaranteed to be the same shape. +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), Vector2(w - CUT, 0.0), Vector2(w, CUT), + 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), ]) @@ -124,9 +198,9 @@ func _frame() -> PackedVector2Array: 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) + 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)) @@ -143,20 +217,14 @@ func _draw() -> void: NeonDraw.draw_glowing_line(self, Vector2(x, y), Vector2(x + arm, y), Color(FRAME_COLOR, alpha * 0.75), 0.6, 0.3) - # A tick strip along the bottom, dimming toward the ends. It is the one - # piece that is pure texture, and it is what stops the lower edge from - # reading as a plain underline. - var span: float = size.x - CUT * 2.0 - for i in range(TICK_COUNT): - var t: float = float(i) / float(TICK_COUNT - 1) - var tx: float = CUT + span * t - var lit: float = 0.25 + 0.55 * sin(t * PI) - draw_line(Vector2(tx, size.y - INSET), Vector2(tx, size.y - INSET - TICK_HEIGHT), - Color(FRAME_COLOR, alpha * lit), 2.0, true) + # #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. - # And a caret in the top-left corner, so a line reads as being SAID by - # someone rather than printed on the glass. + # 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([ - Vector2(CUT + 4.0, INSET + 8.0), Vector2(CUT + 16.0, INSET + 15.0), - Vector2(CUT + 4.0, INSET + 22.0), - ]), Color(0.72, 0.92, 1.0, alpha * 0.9), 0.9, 0.4) + 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) diff --git a/world/IntroDirector.gd b/world/IntroDirector.gd index 12d931d..1155994 100644 --- a/world/IntroDirector.gd +++ b/world/IntroDirector.gd @@ -63,6 +63,10 @@ const SHAKE_MAX: float = 34.0 # How far past the horizon the lens reaches. Generous: the bend is what # sells the hole, and it falls off to nothing on its own well before this. const BREACH_LENS_SCALE: float = 7.0 +# #127: who is talking. The guide is the convoy's controller for everything +# up to the breach; after it there is nobody left to talk to, so the closing +# lines are the ship's own log - which is the point being made. +const SHIP_LOG: String = "KESTREL - LOG" const VORTEX_COLORS: Array = [ Color(0.55, 0.35, 1.0), Color(0.25, 0.9, 1.0), Color(1.0, 0.35, 0.8), Color(1.0, 0.8, 0.3), Color(0.4, 1.0, 0.6), @@ -227,31 +231,31 @@ func _begin(next: int) -> void: # takes for CONVOY and CONVOY_FORMATION to line up. _apply_convoy_formation() fleet.spawn_ships(CONVOY.size(), start_at, CONVOY) - narration.say("Convoy Kestrel, you are carrying the season's shipment to the World Ship.", 5.0) - narration.say("Hold your heading and keep the holds intact. We will be watching.", 4.5) + narration.say("Convoy Kestrel, you are carrying the season's shipment to the World Ship.") + narration.say("Hold your heading and keep the holds intact. We will be watching.") Beat.RUN: - narration.say("World Ship bears ahead. Fly on.", 3.5) + narration.say("World Ship bears ahead. Fly on.") Beat.AMBUSH: _spawn_ambush() - narration.say("Contacts ahead - raiders, a light group. Break them and continue.", 4.5) + narration.say("Contacts ahead - raiders, a light group. Break them and continue.") Beat.CLEAR: - narration.say("Clean work, Kestrel. Resume your heading.", 3.5) + narration.say("Clean work, Kestrel. Resume your heading.") Beat.GAUNTLET: _spawn_belt() - narration.say("Debris belt across your lane. Take it slow - there is no way around.", 5.0) + narration.say("Debris belt across your lane. Take it slow - there is no way around.") Beat.BREACH: GameState.intro_locks_movement = true breach_at = fleet.get_centroid() + Vector2(0.0, -900.0) _open_breach() - narration.say("Kestrel, your heading is - something is opening ahead of you -", 4.0) + narration.say("Kestrel, your heading is - something is opening ahead of you -", "CONVOY CONTROL") Beat.VORTEX: vortex_time = 0.0 narration.clear() Beat.AFTERMATH: GameState.intro_locks_movement = false shake = 0.0 - narration.say("It seems you've lost your way through the space, lost and abandoned.", 5.0) - narration.say("No communication with the World Ship can be heard.", 5.0) + narration.say("It seems you've lost your way through the space, lost and abandoned.", SHIP_LOG) + narration.say("No communication with the World Ship can be heard.", SHIP_LOG) func _deadline() -> float: