#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 # 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 # 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 # 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 game_state: Node
var scene: Node var scene: Node
@ -85,6 +90,18 @@ func _process(delta: float) -> bool:
if not director.running: if not director.running:
return _fail("the director did not start on a new game") 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 -------------------------------------- # --- C. the convoy --------------------------------------
convoy_size = fleet.ships.size() convoy_size = fleet.ships.size()
if convoy_size <= GameState.BASE_FLEET_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") return _fail("the convoy has no escorts at all")
print("convoy: %d vessels, %d holds and %d escorts" % [convoy_size, holds, escorts]) 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 ----------------------- # --- B. movement and nothing else -----------------------
for pressable in ["UI/BuildButton", "UI/MapButton", "UI/BuildActionButton", for pressable in ["UI/BuildButton", "UI/MapButton", "UI/BuildActionButton",
"UI/BuildingActionButton", "UI/EnterAtmosphereButton", "UI/BuildingActionButton", "UI/EnterAtmosphereButton",
@ -138,7 +184,10 @@ func _process(delta: float) -> bool:
stage = 2 stage = 2
return false return false
2: 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 return false
# The director advances itself, so the evidence that clearing the # The director advances itself, so the evidence that clearing the
# ambush ended the beat is that it has MOVED ON - asking # 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") return _fail("player steering still moved the fleet during the breach")
print("the breach has the wheel: steering input moves nothing") 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 -------------------------------------- # --- H. the vortex --------------------------------------
director._begin(director.Beat.VORTEX) director._begin(director.Beat.VORTEX)
elapsed = 0.0 elapsed = 0.0
@ -226,6 +297,10 @@ func _process(delta: float) -> bool:
return _fail("the build button never came back") return _fail("the build button never came back")
if narration.is_speaking(): if narration.is_speaking():
return _fail("the guide is still talking after the handover") 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") print("handover: flags clear, 1 interceptor, waves and rocks running, buttons back")
_drop_scene() _drop_scene()
elapsed = 0.0 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 # 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 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 FADE_SECONDS: float = 0.5
const TEXT_COLOR: Color = Color(0.82, 0.92, 1.0) const TEXT_COLOR: Color = Color(0.82, 0.92, 1.0)
const FRAME_COLOR: Color = Color(0.45, 0.68, 0.95) 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 label: Label
var queued: Array = [] var queued: Array = []
@ -97,14 +109,54 @@ func _process(delta: float) -> void:
_advance() _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: func _draw() -> void:
if current == "": if current == "":
return return
# Fades out over its last moments so lines hand over rather than cutting. # 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(time_left / FADE_SECONDS, 0.0, 1.0)
label.modulate.a = alpha label.modulate.a = alpha
draw_rect(Rect2(Vector2.ZERO, size), Color(0.02, 0.05, 0.12, 0.72 * alpha)) var frame: PackedVector2Array = _frame()
NeonDraw.draw_glowing_line(self, Vector2(0.0, size.y), Vector2(size.x, size.y),
Color(FRAME_COLOR, alpha), 0.7, 0.55) draw_colored_polygon(frame, Color(0.02, 0.05, 0.12, 0.78 * alpha))
NeonDraw.draw_glowing_line(self, Vector2(0.0, 0.0), Vector2(size.x, 0.0), NeonDraw.draw_glowing_polygon(self, frame, Color(FRAME_COLOR, alpha), 0.8)
Color(FRAME_COLOR, alpha * 0.6), 0.5, 0.4)
# 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 # 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 # not fleet-eligible at all, so a convoy of them would be silently filtered
# down to its escorts. # down to its escorts.
const CONVOY: PackedStringArray = ["interceptor", "carrier", "carrier", #
"carrier", "interceptor", "carrier", "carrier", "interceptor", # #125: the order here pairs index-for-index with CONVOY_FORMATION below.
"carrier", "interceptor"] # 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_COUNT: int = 3
const AMBUSH_AHEAD: float = 1100.0 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_AHEAD: float = 2200.0
const BELT_ROCKS: int = 90 const BELT_ROCKS: int = 90
const BELT_HALF_WIDTH: float = 2600.0 const BELT_HALF_WIDTH: float = 2600.0
const BELT_DEPTH: float = 1400.0 const BELT_DEPTH: float = 1400.0
const BREACH_PULL: float = 620.0 const BREACH_PULL: float = 620.0
const SHAKE_MAX: float = 34.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 = [ 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(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), 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 shake: float = 0.0
var vortex_time: float = 0.0 var vortex_time: float = 0.0
var survivor: Node2D = null var survivor: Node2D = null
var breach: Node2D = null
var _done: bool = false var _done: bool = false
@onready var overlay: ColorRect = get_node_or_null("../UI/BreachOverlay")
func _ready() -> void: func _ready() -> void:
# A NEW-game thing. A player returning from a planet surface, or # A NEW-game thing. A player returning from a planet surface, or
@ -91,13 +117,30 @@ func _process(delta: float) -> void:
beat_time += delta beat_time += delta
_tick(delta) _tick(delta)
_apply_shake(delta) _apply_shake(delta)
if beat_time >= _deadline() or _beat_complete(): if beat_time < _deadline() and not _ready_to_advance():
return
if beat >= Beat.AFTERMATH: if beat >= Beat.AFTERMATH:
finish() finish()
return return
_begin(beat + 1) _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 # Ends the prologue and hands over. The same call ends it whether it was
# played out or skipped. # played out or skipped.
func finish() -> void: func finish() -> void:
@ -125,7 +168,42 @@ func skip() -> void:
finish() 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: 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: for prop in ambush + rocks:
if is_instance_valid(prop): if is_instance_valid(prop):
prop.queue_free() prop.queue_free()
@ -143,6 +221,11 @@ func _begin(next: int) -> void:
Beat.BRIEF: Beat.BRIEF:
start_at = fleet.get_centroid() start_at = fleet.get_centroid()
GameState.intro_fleet_size = CONVOY.size() 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) 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("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("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: Beat.BREACH:
GameState.intro_locks_movement = true GameState.intro_locks_movement = true
breach_at = fleet.get_centroid() + Vector2(0.0, -900.0) 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 -", 4.0)
Beat.VORTEX: Beat.VORTEX:
vortex_time = 0.0 vortex_time = 0.0
@ -172,17 +256,44 @@ func _begin(next: int) -> void:
func _deadline() -> float: func _deadline() -> float:
match beat: match beat:
Beat.BRIEF: return 10.0 Beat.BRIEF: return 16.0
Beat.RUN: return 45.0 Beat.RUN: return 60.0
Beat.AMBUSH: return 75.0 Beat.AMBUSH: return 90.0
Beat.CLEAR: return 4.0 Beat.CLEAR: return 8.0
Beat.GAUNTLET: return 60.0 Beat.GAUNTLET: return 75.0
Beat.BREACH: return 4.2 Beat.BREACH: return 9.0
Beat.VORTEX: return 3.6 Beat.VORTEX: return 7.0
Beat.AFTERMATH: return 10.5 Beat.AFTERMATH: return 15.0
return 1.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: func _beat_complete() -> bool:
match beat: match beat:
Beat.RUN: Beat.RUN:
@ -199,6 +310,9 @@ func _tick(delta: float) -> void:
match beat: match beat:
Beat.BREACH: Beat.BREACH:
shake = minf(shake + delta * SHAKE_MAX * 0.9, SHAKE_MAX) 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. # Dragged in, not flown in: the player has already lost the wheel.
var to_breach: Vector2 = breach_at - fleet.get_centroid() var to_breach: Vector2 = breach_at - fleet.get_centroid()
if to_breach.length() > 1.0: if to_breach.length() > 1.0:
@ -207,10 +321,15 @@ func _tick(delta: float) -> void:
Beat.VORTEX: Beat.VORTEX:
vortex_time += delta vortex_time += delta
shake = maxf(shake - delta * SHAKE_MAX * 0.5, SHAKE_MAX * 0.35) shake = maxf(shake - delta * SHAKE_MAX * 0.5, SHAKE_MAX * 0.35)
_drive_breach(1.0)
_whirl(delta) _whirl(delta)
queue_redraw() queue_redraw()
Beat.AFTERMATH: Beat.AFTERMATH:
shake = maxf(shake - delta * SHAKE_MAX, 0.0) 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() queue_redraw()
@ -309,42 +428,17 @@ func _living(nodes: Array) -> int:
# --- what the prologue draws ---------------------------------------------- # --- what the prologue draws ----------------------------------------------
func _draw() -> void: 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 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) var pull: float = clampf(vortex_time / 3.0, 0.0, 1.0)
for i in range(VORTEX_COLORS.size() * 2): for i in range(VORTEX_COLORS.size() * 2):
var colour: Color = VORTEX_COLORS[i % VORTEX_COLORS.size()] 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 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 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 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.35 + 0.5 * pull), 1.0, 0.8) NeonDraw.draw_glowing_line(self, from, to, Color(colour, 0.3 + 0.45 * 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

View file

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