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>
119 lines
4.7 KiB
GDScript
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)
|