spaceship/vfx/DimensionalBreach.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

119 lines
4.7 KiB
GDScript

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)