spaceship/planets/BlackHoleVisual.gd
Claude aaf8b400ef Build the accretion disc once per spawn, not twice
Planet.gd calls visual.configure() BEFORE add_child(), and the SceneTree
defers _ready() to the first frame - so configure() built the five rings,
and _ready() then threw them away and built five more. Eighty antialiased
polylines tessellated per black hole instead of forty, plus five nodes
queued for free.

Confirmed by instrumenting _build():

  _build() called, existing children=0    <- configure()
  _build() called, existing children=5    <- _ready()

_ready() now uses the same is-it-built guard configure() already had.

Note for anyone chasing the same thing: this is NOT the disc's steady-state
cost. Measured, the geometry rasterises zero times over sixty frames (the
NeonPiece refactor works), and rendering the disc costs 0.1 ms against a
133 ms softpipe floor. The disc is cheap once it exists.

172/172.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD
2026-09-08 22:35:41 +02:00

79 lines
3 KiB
GDScript

extends Node2D
# Same informal interface as GasGiantVisual/RockyPlanetVisual - configure(),
# extractor_anchor(), animates via its own _process(). No solid body: the
# core stays genuinely empty (nothing drawn inside CORE_RADIUS), and an
# accretion disc of rotating arc segments - not closed rings - stands in
# for a surface, brighter and thicker toward the centre.
#
# Each ring is a NeonPiece that is SPUN, not redrawn. Issues #6 and #10 made
# that the rule for the buildings and it applies here more than anywhere: the
# disc was ten glowing paths of twenty-five points re-tessellated every frame
# - forty antialiased polylines - to turn five rings at five different rates.
# Rotation is a node property, so now nothing is rasterised after the first
# frame and the disc turns for free.
var disc_phase: float = 0.0
const CORE_RADIUS: float = 46.0
const RING_COUNT: int = 5
const RING_STEP: float = 16.0
const DISC_COLOR: Color = Color(0.55, 0.15, 0.85)
const DISC_HOT_COLOR: Color = Color(0.9, 0.6, 1.0)
# One per ring, with the rate and direction it turns at.
var _rings: Array = []
func _ready() -> void:
# Planet.gd calls configure() BEFORE add_child(), so the disc is usually
# already built by the time _ready fires (which the SceneTree defers to
# the first frame). Without this guard the ring set was thrown away and
# re-tessellated - eighty antialiased polylines per spawn instead of forty.
if _rings.is_empty():
_build()
func configure(_shape_seed: int) -> void:
if _rings.is_empty():
_build()
func extractor_anchor() -> Vector2:
return Vector2(0.0, -(CORE_RADIUS + float(RING_COUNT - 1) * RING_STEP + 12.0))
func _build() -> void:
for child in get_children():
child.queue_free()
_rings.clear()
for i in range(RING_COUNT):
var radius: float = CORE_RADIUS + float(i) * RING_STEP
# 1.0 at the innermost ring, fading to 0.0 at the outermost - drives
# both colour (hotter inward) and stroke weight/brightness.
var t: float = 1.0 - float(i) / float(maxi(RING_COUNT - 1, 1))
var piece: NeonPiece = NeonPiece.create(self, Vector2.ZERO,
DISC_COLOR.lerp(DISC_HOT_COLOR, t * 0.65))
# The two arcs are built at their resting angles; the ring node turns
# them. Alternating direction per ring and a speed tied to t reads as
# genuine turbulence rather than one disc rotating as a rigid whole.
for arc_i in range(2):
var start: float = float(arc_i) * PI + 0.15
var end: float = start + PI - 0.3
var points := PackedVector2Array()
var steps: int = 24
for j in range(steps + 1):
var angle: float = lerpf(start, end, float(j) / float(steps))
points.append(Vector2(cos(angle), sin(angle)) * radius)
piece.add_path(points, 0.5 + t * 0.5, 0.45 + t * 0.35)
_rings.append({
"piece": piece,
"rate": (0.12 + t * 0.22) * (1.0 if i % 2 == 0 else -1.0),
})
func _process(delta: float) -> void:
disc_phase += delta
# One property per ring. Nothing is rasterised again.
for ring in _rings:
(ring.piece as NeonPiece).rotation = disc_phase * float(ring.rate)