#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>
This commit is contained in:
Loïc Wisniewski 2026-08-24 13:25:10 +02:00
commit d455361328
8 changed files with 527 additions and 57 deletions

72
shaders/breach.gdshader Normal file
View file

@ -0,0 +1,72 @@
shader_type canvas_item;
// #125: the dimensional breach, as a screen-space lens.
//
// The prologue's warp used to be a handful of lines turning around a point.
// This bends the ACTUAL SCENE around the breach instead - the ships, the
// rocks and the starfield all get dragged into it - which is most of what
// makes a hole in space read as a hole rather than as a decal drawn on top
// of one.
//
// Two effects, both radial and both fading to nothing at the edge of their
// reach, so the shader is invisible outside its own radius and costs a
// clamped texture fetch everywhere else:
//
// swirl angle offset that grows toward the centre, so the scene winds
// around the hole rather than merely sliding toward it
// pull inward displacement, strongest just outside the horizon, which
// is the lensing itself
//
// Inside the horizon nothing is sampled at all: the core is drawn as empty,
// the same decision BlackHoleVisual makes for the black-hole planet, and
// for the same reason - a "black hole" with something visible in the middle
// stops reading as one.
uniform sampler2D screen_texture : hint_screen_texture, repeat_disable, filter_linear;
// Where the breach is, in SCREEN pixels, and how big it has grown.
uniform vec2 breach_center = vec2(360.0, 640.0);
uniform vec2 viewport_size = vec2(720.0, 1280.0);
// The event horizon: everything inside is void.
uniform float horizon_radius = 0.0;
// How far the lens reaches past the horizon. Beyond this the scene is
// untouched.
uniform float lens_radius = 1.0;
// 0 while the breach is opening, 1 at full strength. Scales both effects
// together so one knob fades the whole thing in.
uniform float strength = 0.0;
uniform float swirl_turns = 2.4;
uniform vec4 rim_color : source_color = vec4(0.72, 0.45, 1.0, 1.0);
void fragment() {
vec2 pixel = UV * viewport_size;
vec2 offset = pixel - breach_center;
float distance_to_centre = length(offset);
if (distance_to_centre >= lens_radius || strength <= 0.001) {
COLOR = texture(screen_texture, SCREEN_UV);
} else if (distance_to_centre <= horizon_radius) {
// The core. Not black-with-alpha: genuinely nothing, so whatever is
// behind the breach is gone rather than dimmed.
COLOR = vec4(0.0, 0.0, 0.0, 1.0);
} else {
// 1 at the horizon, 0 at the edge of the lens. Squared so the bend
// is concentrated near the hole instead of smeared evenly outward.
float falloff = 1.0 - clamp(
(distance_to_centre - horizon_radius) / max(lens_radius - horizon_radius, 1.0),
0.0, 1.0);
float bend = falloff * falloff * strength;
float angle = atan(offset.y, offset.x) + bend * swirl_turns;
float pulled = distance_to_centre - bend * (lens_radius - horizon_radius) * 0.55;
pulled = max(pulled, horizon_radius);
vec2 sampled = breach_center + vec2(cos(angle), sin(angle)) * pulled;
COLOR = texture(screen_texture, sampled / viewport_size);
// A hot rim where the lensing is fiercest, which is what gives the
// horizon an edge rather than a fade.
float rim = smoothstep(0.72, 1.0, falloff) * strength;
COLOR.rgb = mix(COLOR.rgb, rim_color.rgb, rim * 0.65);
}
}

View file

@ -21,6 +21,11 @@ extends SceneTree
# I. the handover: the flag clears, the buttons come back, the spawner and
# the asteroid field are running again, and the fleet is a new game's
# J. skipping lands in the same state as playing it through
# K. #125 - no beat ends before its own floor, and a beat whose point is
# the guide talking does not end mid-sentence
# 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
var game_state: Node
var scene: Node
@ -85,6 +90,18 @@ func _process(delta: float) -> bool:
if not director.running:
return _fail("the director did not start on a new game")
# --- 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.
for b in range(director.Beat.AFTERMATH + 1):
if director._floor_time() <= 0.0 and b != director.Beat.RUN:
return _fail("beat %d has no minimum length" % b)
if director.beat != director.Beat.BRIEF:
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")
print("opening beat still running at %.1fs, guide still talking" % director.beat_time)
# --- C. the convoy --------------------------------------
convoy_size = fleet.ships.size()
if convoy_size <= GameState.BASE_FLEET_SIZE:
@ -104,6 +121,35 @@ func _process(delta: float) -> bool:
return _fail("the convoy has no escorts at all")
print("convoy: %d vessels, %d holds and %d escorts" % [convoy_size, holds, escorts])
# --- L. spread out, and the holds are on the inside -------
var spread: float = 0.0
var closest: float = 1.0e9
for a in fleet.ships:
for b in fleet.ships:
if a == b:
continue
spread = maxf(spread, a.position.distance_to(b.position))
closest = minf(closest, a.position.distance_to(b.position))
if spread < 400.0:
return _fail("the whole convoy fits in %.0fpx - that is a wedge, not a convoy" % spread)
if closest < 80.0:
return _fail("two convoy vessels are %.0fpx apart" % closest)
# Every hold must be further in than the furthest-out escort is,
# which is what "surrounded by the default vessels" means.
var escort_reach: float = 0.0
var hold_reach: float = 0.0
for ship in fleet.ships:
var from_centre: float = ship.position.length()
if String(ship.vessel_type) == GameState.VESSEL_INTERCEPTOR:
escort_reach = maxf(escort_reach, from_centre)
else:
hold_reach = maxf(hold_reach, from_centre)
if hold_reach >= escort_reach:
return _fail("a hold sits %.0fpx out, past the escorts at %.0f - they are not screened" \
% [hold_reach, escort_reach])
print("convoy %.0fpx across, holds within %.0f and escorts out at %.0f" \
% [spread, hold_reach, escort_reach])
# --- B. movement and nothing else -----------------------
for pressable in ["UI/BuildButton", "UI/MapButton", "UI/BuildActionButton",
"UI/BuildingActionButton", "UI/EnterAtmosphereButton",
@ -138,7 +184,10 @@ func _process(delta: float) -> bool:
stage = 2
return false
2:
if elapsed < 0.3:
# Past the ambush beat's own floor (#125): clearing it is what
# ENDS it, but no beat may end before its floor, so watching for
# the advance means waiting that long.
if elapsed < director._floor_time() + 0.5:
return false
# The director advances itself, so the evidence that clearing the
# ambush ended the beat is that it has MOVED ON - asking
@ -177,6 +226,28 @@ func _process(delta: float) -> bool:
return _fail("player steering still moved the fleet during the breach")
print("the breach has the wheel: steering input moves nothing")
# --- M. the breach is a black hole ----------------------
var hole: Node2D = director.breach
if hole == null or not is_instance_valid(hole):
return _fail("the breach beat opened no black hole")
if hole.horizon_radius() > 1.0:
return _fail("the hole was already open the moment the beat began")
director._drive_breach(0.5)
var half_open: float = hole.horizon_radius()
director._drive_breach(1.0)
if hole.horizon_radius() <= half_open:
return _fail("the hole does not grow - %.1f at half, %.1f at full" \
% [half_open, hole.horizon_radius()])
if hole.disc_radius() <= hole.horizon_radius():
return _fail("the accretion disc is inside the horizon")
var lens: ColorRect = scene.get_node("UI/BreachOverlay")
if not lens.visible:
return _fail("the screen-space lens never came up")
if lens.material == null or lens.material.shader == null:
return _fail("the lens has no shader on it")
print("breach: horizon %.0f -> %.0f, disc %.0f, lens up" \
% [half_open, hole.horizon_radius(), hole.disc_radius()])
# --- H. the vortex --------------------------------------
director._begin(director.Beat.VORTEX)
elapsed = 0.0
@ -226,6 +297,10 @@ func _process(delta: float) -> bool:
return _fail("the build button never came back")
if narration.is_speaking():
return _fail("the guide is still talking after the handover")
if scene.get_node("UI/BreachOverlay").visible:
return _fail("the breach lens is still over the screen after the handover")
if is_instance_valid(director) and director.breach != null:
return _fail("the black hole outlived the prologue")
print("handover: flags clear, 1 interceptor, waves and rocks running, buttons back")
_drop_scene()
elapsed = 0.0

43
ui/BreachOverlay.gd Normal file
View file

@ -0,0 +1,43 @@
extends ColorRect
# #125: drives breach.gdshader.
#
# Lives in the UI layer because it works in SCREEN space - it bends the
# rendered frame, so it has to be the last thing drawn and it has to cover
# the whole viewport. The world-space half of the breach (the horizon, the
# accretion disc, the infalling matter) is DimensionalBreach, a Node2D out
# in the world; this is only the lens over the top of it.
#
# Inert unless something calls set_breach(): the shader is a no-op at zero
# strength, but a full-screen quad that is never needed is still a full
# screen of fragment work, so it hides itself instead.
const SHADER_PATH: String = "res://shaders/breach.gdshader"
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
visible = false
if material == null:
var shader_material := ShaderMaterial.new()
shader_material.shader = load(SHADER_PATH)
material = shader_material
func clear_breach() -> void:
visible = false
if material != null:
material.set_shader_parameter("strength", 0.0)
# `at` is in SCREEN pixels; the director projects the world point through the
# camera before calling, because only it knows where the camera is.
func set_breach(at: Vector2, horizon: float, lens: float, breach_strength: float) -> void:
if material == null:
return
visible = breach_strength > 0.001
material.set_shader_parameter("breach_center", at)
material.set_shader_parameter("viewport_size", size)
material.set_shader_parameter("horizon_radius", horizon)
material.set_shader_parameter("lens_radius", maxf(lens, horizon + 1.0))
material.set_shader_parameter("strength", clampf(breach_strength, 0.0, 1.0))

View file

@ -10,10 +10,22 @@ extends Control
# Lines queue rather than replacing each other, so a beat that fires while
# the previous line is still up does not silently eat it.
const HOLD_SECONDS: float = 4.5
# #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 = []
@ -97,14 +109,54 @@ func _process(delta: float) -> void:
_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
draw_rect(Rect2(Vector2.ZERO, size), Color(0.02, 0.05, 0.12, 0.72 * alpha))
NeonDraw.draw_glowing_line(self, Vector2(0.0, size.y), Vector2(size.x, size.y),
Color(FRAME_COLOR, alpha), 0.7, 0.55)
NeonDraw.draw_glowing_line(self, Vector2(0.0, 0.0), Vector2(size.x, 0.0),
Color(FRAME_COLOR, alpha * 0.6), 0.5, 0.4)
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)

119
vfx/DimensionalBreach.gd Normal file
View file

@ -0,0 +1,119 @@
class_name DimensionalBreach
extends Node2D
# #125: the hole itself, in world space.
#
# The prologue's warp was a few lines turning around a point. This is built
# on the same idiom as BlackHoleVisual - an empty core with an accretion
# disc of counter-rotating ARC SEGMENTS around it, hotter and thicker toward
# the centre - because that is the part of the black-hole planet that reads
# well, and a breach and a black hole should look like the same kind of
# thing.
#
# What it adds over that planet is growth and infall. A planet's disc is
# steady; this one opens from nothing, drags streamers in from well outside
# its own disc, and keeps accelerating, so the picture says "this is
# happening now" rather than "this has always been here".
#
# grow() drives everything from one 0..1 number, so the director never has
# to know about rings or streamers - it says how far along the breach is and
# this decides what that looks like.
const CORE_RADIUS: float = 54.0
const RING_COUNT: int = 6
const RING_STEP: float = 19.0
const DISC_COLOR: Color = Color(0.55, 0.15, 0.85)
const DISC_HOT_COLOR: Color = Color(0.92, 0.62, 1.0)
const RIM_COLOR: Color = Color(0.98, 0.88, 1.0)
# Streamers of matter falling in from outside the disc. They are what makes
# the hole look like it is PULLING rather than merely spinning.
const STREAMER_COUNT: int = 14
const STREAMER_REACH: float = 5.2
const STREAMER_STEPS: int = 14
# 0 = closed, 1 = fully open. Everything below is scaled from it.
var progress: float = 0.0
var disc_phase: float = 0.0
func grow(value: float) -> void:
progress = clampf(value, 0.0, 1.0)
queue_redraw()
func horizon_radius() -> float:
return CORE_RADIUS * progress
func disc_radius() -> float:
return (CORE_RADIUS + float(RING_COUNT - 1) * RING_STEP) * progress
func _process(delta: float) -> void:
# The spin accelerates as it opens - a hole that turned at a constant
# rate while growing read as a picture being scaled up.
disc_phase += delta * (0.6 + progress * 2.4)
queue_redraw()
func _draw() -> void:
if progress <= 0.001:
return
var horizon: float = horizon_radius()
# The core is drawn as genuinely empty - an opaque disc of nothing, not a
# dark tint. Same decision as BlackHoleVisual, and for the same reason: a
# black hole with something visible in the middle stops reading as one.
draw_circle(Vector2.ZERO, horizon, Color(0.0, 0.0, 0.0, 1.0))
_draw_streamers(horizon)
for i in range(RING_COUNT):
var radius: float = (CORE_RADIUS + float(i) * RING_STEP) * progress
# 1.0 at the innermost ring, fading outward - drives colour, weight
# and spin, exactly as the planet's disc does.
var t: float = 1.0 - float(i) / float(maxi(RING_COUNT - 1, 1))
var ring_color: Color = DISC_COLOR.lerp(DISC_HOT_COLOR, t * 0.7)
# Alternating direction per ring reads as turbulence rather than as
# one disc rotating rigidly.
var spin: float = disc_phase * (0.3 + t * 0.9) * (1.0 if i % 2 == 0 else -1.0)
for arc_i in range(2):
var start: float = spin + float(arc_i) * PI + 0.15
var end: float = start + PI - 0.3
var points := PackedVector2Array()
for j in range(25):
var angle: float = lerpf(start, end, float(j) / 24.0)
points.append(Vector2(cos(angle), sin(angle)) * radius)
NeonDraw.draw_glowing_path(self, points, ring_color,
(0.5 + t * 0.5) * progress, 0.45 + t * 0.4)
# The rim: a bright, thin circle right on the horizon. It is what gives
# the void an edge instead of a fade, and it is drawn last so nothing
# else washes over it.
if horizon > 2.0:
draw_arc(Vector2.ZERO, horizon, 0.0, TAU, 48,
Color(RIM_COLOR, 0.85 * progress), 3.0, true)
draw_arc(Vector2.ZERO, horizon * 1.06, 0.0, TAU, 48,
Color(DISC_HOT_COLOR, 0.4 * progress), 7.0, true)
func _draw_streamers(horizon: float) -> void:
# Each streamer is a spiral falling from outside the disc down onto the
# horizon. They wind tighter as the breach opens, so the pull visibly
# strengthens rather than the picture merely getting bigger.
var outer: float = disc_radius() * STREAMER_REACH
if outer <= horizon + 1.0:
return
for i in range(STREAMER_COUNT):
var lead: float = disc_phase * 1.6 + TAU * float(i) / float(STREAMER_COUNT)
var points := PackedVector2Array()
for j in range(STREAMER_STEPS + 1):
var t: float = float(j) / float(STREAMER_STEPS)
# Cubic, so a streamer spends most of its length far out and
# whips through the last stretch - which is what infall does.
var radius: float = lerpf(outer, horizon, t * t * t)
var angle: float = lead + t * TAU * (0.55 + progress * 0.85)
points.append(Vector2(cos(angle), sin(angle)) * radius)
var fade: float = (0.16 + 0.5 * progress) * (0.5 + 0.5 * sin(lead * 3.0))
NeonDraw.draw_glowing_path(self, points,
DISC_COLOR.lerp(DISC_HOT_COLOR, 0.35), maxf(fade, 0.08), 0.3)

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://vfx/DimensionalBreach.gd" id="1"]
[node name="DimensionalBreach" type="Node2D"]
script = ExtResource("1")

View file

@ -28,18 +28,41 @@ const WORLD_SHIP_AT: Vector2 = Vector2(0.0, -16000.0)
# Carriers are the game's cargo hull; transporters belong to hubs and are
# not fleet-eligible at all, so a convoy of them would be silently filtered
# down to its escorts.
const CONVOY: PackedStringArray = ["interceptor", "carrier", "carrier",
"carrier", "interceptor", "carrier", "carrier", "interceptor",
"carrier", "interceptor"]
#
# #125: the order here pairs index-for-index with CONVOY_FORMATION below.
# The holds are the middle six entries and the escorts are the four outer
# ones, so the layout below can put the guns on the outside without either
# array having to know which slot is which - they simply agree.
const CONVOY: PackedStringArray = ["interceptor", "interceptor",
"interceptor", "interceptor",
"carrier", "carrier", "carrier", "carrier", "carrier", "carrier"]
# #125: "space them a bit more and surround transporters by the default
# vessels". Escorts first, at the four corners of the box; then the holds
# packed in the middle of it. Roughly three times the spread of the fleet's
# own wedge - this is a freight convoy under guard, not a fighting
# formation, and it should not look like one.
const CONVOY_FORMATION: Array[Vector2] = [
Vector2(-300.0, -260.0), Vector2(300.0, -260.0),
Vector2(-300.0, 260.0), Vector2(300.0, 260.0),
Vector2(-105.0, -120.0), Vector2(105.0, -120.0),
Vector2(-105.0, 0.0), Vector2(105.0, 0.0),
Vector2(-105.0, 120.0), Vector2(105.0, 120.0),
]
const AMBUSH_COUNT: int = 3
const AMBUSH_AHEAD: float = 1100.0
const RUN_DISTANCE: float = 1500.0
# #125: the run was over in about four seconds at cruise. Far enough now
# that the convoy is genuinely under way before anything interrupts it.
const RUN_DISTANCE: float = 3400.0
const BELT_AHEAD: float = 2200.0
const BELT_ROCKS: int = 90
const BELT_HALF_WIDTH: float = 2600.0
const BELT_DEPTH: float = 1400.0
const BREACH_PULL: float = 620.0
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
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),
@ -62,8 +85,11 @@ var rocks: Array = []
var shake: float = 0.0
var vortex_time: float = 0.0
var survivor: Node2D = null
var breach: Node2D = null
var _done: bool = false
@onready var overlay: ColorRect = get_node_or_null("../UI/BreachOverlay")
func _ready() -> void:
# A NEW-game thing. A player returning from a planet surface, or
@ -91,11 +117,28 @@ func _process(delta: float) -> void:
beat_time += delta
_tick(delta)
_apply_shake(delta)
if beat_time >= _deadline() or _beat_complete():
if beat >= Beat.AFTERMATH:
finish()
return
_begin(beat + 1)
if beat_time < _deadline() and not _ready_to_advance():
return
if beat >= Beat.AFTERMATH:
finish()
return
_begin(beat + 1)
func _ready_to_advance() -> bool:
if beat_time < _floor_time():
return false
if _waits_for_narration() and narration.is_speaking():
return false
return _beat_complete() or beat_time >= _floor_time() and _floor_only()
# Beats with no condition of their own end when their floor is up. Kept
# explicit so a beat that DOES have a condition cannot be advanced early by
# the floor alone.
func _floor_only() -> bool:
return beat == Beat.BRIEF or beat == Beat.CLEAR or beat == Beat.BREACH \
or beat == Beat.VORTEX or beat == Beat.AFTERMATH
# Ends the prologue and hands over. The same call ends it whether it was
@ -125,7 +168,42 @@ func skip() -> void:
finish()
func _apply_convoy_formation() -> void:
for i in range(mini(CONVOY_FORMATION.size(), GameState.formation.size())):
GameState.formation[i] = CONVOY_FORMATION[i]
# The world-space half of the breach, plus the screen-space lens over it.
func _open_breach() -> void:
if is_instance_valid(breach):
return
breach = load("res://vfx/DimensionalBreach.tscn").instantiate()
breach.position = breach_at
get_parent().add_child(breach)
# Drives both halves from one number. The overlay works in SCREEN pixels, so
# the world point is projected through the canvas transform here - only the
# director knows where the camera is.
func _drive_breach(open_amount: float) -> void:
if is_instance_valid(breach):
breach.grow(open_amount)
if overlay == null:
return
var canvas: Transform2D = get_viewport().get_canvas_transform()
var on_screen: Vector2 = canvas * breach_at
var horizon: float = (breach.horizon_radius() if is_instance_valid(breach) else 0.0) \
* canvas.get_scale().x
overlay.set_breach(on_screen, horizon,
maxf(horizon * BREACH_LENS_SCALE, 1.0), open_amount)
func _clear_props() -> void:
if is_instance_valid(breach):
breach.queue_free()
breach = null
if overlay != null:
overlay.clear_breach()
for prop in ambush + rocks:
if is_instance_valid(prop):
prop.queue_free()
@ -143,6 +221,11 @@ func _begin(next: int) -> void:
Beat.BRIEF:
start_at = fleet.get_centroid()
GameState.intro_fleet_size = CONVOY.size()
# #125: the convoy flies in its own spread-out box, escorts on
# the corners. spawn_ships seats vessel i in formation slot i,
# so writing the layout here - before the spawn - is all it
# 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)
@ -159,6 +242,7 @@ func _begin(next: int) -> void:
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)
Beat.VORTEX:
vortex_time = 0.0
@ -172,17 +256,44 @@ func _begin(next: int) -> void:
func _deadline() -> float:
match beat:
Beat.BRIEF: return 10.0
Beat.RUN: return 45.0
Beat.AMBUSH: return 75.0
Beat.CLEAR: return 4.0
Beat.GAUNTLET: return 60.0
Beat.BREACH: return 4.2
Beat.VORTEX: return 3.6
Beat.AFTERMATH: return 10.5
Beat.BRIEF: return 16.0
Beat.RUN: return 60.0
Beat.AMBUSH: return 90.0
Beat.CLEAR: return 8.0
Beat.GAUNTLET: return 75.0
Beat.BREACH: return 9.0
Beat.VORTEX: return 7.0
Beat.AFTERMATH: return 15.0
return 1.0
# #125: "everything happens seconds after the previous thing started."
#
# The deadline above is how long a beat may take; this is how long it must
# take. Two beats ran short for the same reason - their completion test was
# satisfied while the guide was still mid-sentence - so no beat may end
# before its own floor, and a beat that is still TALKING holds regardless.
# One rule, applied in one place, rather than tuning each condition.
func _floor_time() -> float:
match beat:
Beat.BRIEF: return 11.0
Beat.RUN: return 6.0
Beat.AMBUSH: return 3.0
Beat.CLEAR: return 5.0
Beat.GAUNTLET: return 6.0
Beat.BREACH: return 8.0
Beat.VORTEX: return 6.5
Beat.AFTERMATH: return 12.0
return 0.0
# Beats where the guide is the point, and cutting the line off would be
# cutting the beat off. The action beats are deliberately not here: a wave
# arriving must not wait for a sentence to finish.
func _waits_for_narration() -> bool:
return beat == Beat.BRIEF or beat == Beat.CLEAR or beat == Beat.AFTERMATH
func _beat_complete() -> bool:
match beat:
Beat.RUN:
@ -199,6 +310,9 @@ func _tick(delta: float) -> void:
match beat:
Beat.BREACH:
shake = minf(shake + delta * SHAKE_MAX * 0.9, SHAKE_MAX)
# Opens over the whole beat rather than snapping to full size,
# which is the "growing" the ticket asks for.
_drive_breach(clampf(beat_time / maxf(_deadline(), 0.001), 0.0, 1.0))
# Dragged in, not flown in: the player has already lost the wheel.
var to_breach: Vector2 = breach_at - fleet.get_centroid()
if to_breach.length() > 1.0:
@ -207,10 +321,15 @@ func _tick(delta: float) -> void:
Beat.VORTEX:
vortex_time += delta
shake = maxf(shake - delta * SHAKE_MAX * 0.5, SHAKE_MAX * 0.35)
_drive_breach(1.0)
_whirl(delta)
queue_redraw()
Beat.AFTERMATH:
shake = maxf(shake - delta * SHAKE_MAX, 0.0)
# It closes behind them over the first stretch of the aftermath,
# so the last thing the player sees is the hole sealing rather
# than a cut.
_drive_breach(clampf(1.0 - beat_time / 3.0, 0.0, 1.0))
queue_redraw()
@ -309,42 +428,17 @@ func _living(nodes: Array) -> int:
# --- what the prologue draws ----------------------------------------------
func _draw() -> void:
if not running:
# #125: the breach draws itself now - a growing black hole out in the
# world (DimensionalBreach) with a screen-space lens over it
# (BreachOverlay). What is left here is the ribbon of matter being torn
# off the convoy, which belongs to the fleet rather than to the hole.
if not running or beat != Beat.VORTEX:
return
if beat == Beat.BREACH:
_draw_breach(clampf(beat_time / _deadline(), 0.0, 1.0))
elif beat == Beat.VORTEX:
_draw_breach(1.0)
_draw_vortex()
func _draw_breach(progress: float) -> void:
var radius: float = lerpf(20.0, 340.0, progress)
draw_circle(breach_at, radius, Color(0.06, 0.0, 0.12, 0.75 * progress))
for i in range(VORTEX_COLORS.size()):
var colour: Color = VORTEX_COLORS[i]
var r: float = radius * (0.45 + 0.14 * float(i))
var spin: float = beat_time * (1.4 + 0.5 * float(i))
NeonDraw.draw_glowing_path(self, _spiral(breach_at, r, spin, i),
Color(colour, 0.55 + 0.35 * progress), 0.9, 0.5)
func _draw_vortex() -> void:
# Ribbons dragged inward, brightening as the fleet is pulled apart.
var pull: float = clampf(vortex_time / 3.0, 0.0, 1.0)
for i in range(VORTEX_COLORS.size() * 2):
var colour: Color = VORTEX_COLORS[i % VORTEX_COLORS.size()]
var angle: float = vortex_time * 3.2 + TAU * float(i) / float(VORTEX_COLORS.size() * 2)
var outer: float = lerpf(900.0, 120.0, pull)
var outer: float = lerpf(900.0, 150.0, pull)
var from: Vector2 = breach_at + Vector2(cos(angle), sin(angle)) * outer
var to: Vector2 = breach_at + Vector2(cos(angle + 1.1), sin(angle + 1.1)) * 40.0
NeonDraw.draw_glowing_line(self, from, to, Color(colour, 0.35 + 0.5 * pull), 1.0, 0.8)
func _spiral(centre: Vector2, radius: float, phase: float, arm: int) -> PackedVector2Array:
var points: PackedVector2Array = PackedVector2Array()
for i in range(18):
var t: float = float(i) / 17.0
var angle: float = phase + t * TAU * 0.8 + float(arm)
points.append(centre + Vector2(cos(angle), sin(angle)) * radius * (0.35 + 0.65 * t))
return points
var to: Vector2 = breach_at + Vector2(cos(angle + 1.1), sin(angle + 1.1)) * 60.0
NeonDraw.draw_glowing_line(self, from, to, Color(colour, 0.3 + 0.45 * pull), 1.0, 0.8)

View file

@ -29,6 +29,7 @@
[ext_resource type="Script" path="res://ui/DevWaveButton.gd" id="38"]
[ext_resource type="Script" path="res://ui/HangarMenuPanel.gd" id="39"]
[ext_resource type="Script" path="res://logistics/RepairDroneField.gd" id="40"]
[ext_resource type="Script" path="res://ui/BreachOverlay.gd" id="41"]
[ext_resource type="Script" path="res://logistics/TransporterField.gd" id="23"]
[ext_resource type="Script" path="res://ui/ResourceBar.gd" id="24"]
[ext_resource type="Script" path="res://logistics/VesselDeliveryField.gd" id="26"]
@ -243,6 +244,14 @@ script = ExtResource("10")
icon = 5
glow_color = Color(1, 0.45, 0.2, 1)
[node name="BreachOverlay" type="ColorRect" parent="UI"]
visible = false
mouse_filter = 2
anchor_right = 1.0
anchor_bottom = 1.0
color = Color(1, 1, 1, 1)
script = ExtResource("41")
[node name="NarrationBanner" type="Control" parent="UI"]
visible = false
mouse_filter = 2