#127: dialogs wait for the reader, and say who is talking

A line now stays until it is DISMISSED - there is no timer under it at all.
A prologue whose lines expire on their own reads at the game's pace rather
than at the reader's, and that was most of what made the opening feel
rushed even after #125 slowed the beats down.

The beats that are about the guide talking already hold while the banner is
speaking, so dismissing a line is what advances the prologue. A player who
never clicks is still never stuck: each beat's own deadline is the way out,
and the test pins that the deadline sits past the floor.

A dismissed line eats the press, so clicking to continue does not also steer
the fleet on the way through.

The speaker's name sits in a notch cut out of the top border at the left,
the way a label is riveted to a panel - the frame is one polygon, so the
fill and the stroke cannot disagree about where that notch is. Everything up
to the breach is the convoy's controller; 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.

The tick strip along the bottom is gone. It read as an underline attached to
the last line rather than as part of the frame. What replaces it is a
blinking caret at the bottom right - the one thing that says a click is what
moves this on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Loïc Wisniewski 2026-08-24 20:57:56 +02:00
commit 902d6922e7
3 changed files with 170 additions and 58 deletions

View file

@ -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 --------------------------------------

View file

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

View file

@ -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: