#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

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