spaceship/ui/NarrationBanner.gd
Loïc Wisniewski d455361328 #125: slow the prologue down, and give the breach a real black hole
Five things, all of them "this happens too fast or looks too thin".

PACING. Every beat had a completion test that could be satisfied while the
guide was still mid-sentence, which is why everything landed seconds after
the thing before it. Beats now have a FLOOR as well as a deadline - a
minimum length - and a beat whose point is the guide talking will not end
while a line is still on screen. One rule in one place rather than tuning
each condition. The run is 3400px instead of 1500, which at cruise was about
four seconds, and a held line is 6.5s instead of 4.5.

THE CONVOY. Escorts at the four corners of a box, holds packed in the middle
of it, roughly three times the spread of the fleet's own wedge - this is
freight under guard, not a fighting formation. CONVOY and CONVOY_FORMATION
pair index-for-index, which is all it takes for spawn_ships to seat the guns
on the outside, and the test pins that every hold sits further in than the
furthest-out escort.

THE DIALOG. Cut corners, brackets holding the short edges open, a tick strip
along the bottom and a caret in the corner - geometry rather than art, so it
scales with the banner and costs nothing.

THE BREACH. It was a few lines turning around a point. It is now two halves
driven by one number: DimensionalBreach, a growing black hole out in the
world built on BlackHoleVisual's idiom - empty core, counter-rotating arc
segments hotter toward the centre - with infalling streamers it does not
have, because a planet's disc is steady and this one has to say "now"; and
breach.gdshader over the top, bending the ACTUAL SCENE around it. Ships,
rocks and the starfield all get dragged in, which is most of what makes a
hole in space read as a hole rather than as a decal drawn on one.

Inside the horizon the shader samples nothing at all - the same decision
BlackHoleVisual makes, for the same reason: a black hole with something
visible in the middle stops being one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 13:25:10 +02:00

162 lines
5.4 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 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.
#
# Lines queue rather than replacing each other, so a beat that fires while
# the previous line is still up does not silently eat it.
# #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
const TEXT_COLOR: Color = Color(0.82, 0.92, 1.0)
const FRAME_COLOR: Color = Color(0.45, 0.68, 0.95)
# #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.
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
var label: Label
var queued: Array = []
var current: String = ""
var time_left: float = 0.0
func _ready() -> void:
_ensure_label()
# 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
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)
resized.connect(_layout)
_layout()
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)
# Says one line. Held for `hold` seconds, then the next in the queue.
func say(line: String, hold: float = HOLD_SECONDS) -> void:
_ensure_label()
queued.append({"line": line, "hold": maxf(hold, 0.1)})
if current == "":
_advance()
func clear() -> void:
queued.clear()
current = ""
time_left = 0.0
visible = false
func is_speaking() -> bool:
return current != "" or not queued.is_empty()
func _advance() -> void:
if queued.is_empty():
current = ""
time_left = 0.0
visible = false
return
var next: Dictionary = queued.pop_front()
current = String(next.line)
time_left = float(next.hold)
label.text = current
visible = true
queue_redraw()
func _process(delta: float) -> void:
if current == "":
return
time_left -= 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 _frame() -> PackedVector2Array:
var w: float = size.x
var h: float = size.y
return PackedVector2Array([
Vector2(CUT, 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
# 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)
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)
# 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)
# And a caret in the top-left corner, so a line reads as being SAID by
# someone rather than printed on the glass.
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)