Effects/Vortex.cs

A visual effect component that draws a floor vortex using LineRenderer lines. It samples the floor height on a polar grid at spawn, creates rim, core, spiral arms and collapsing rings, animates rotation, fading and a point light, and can be spawned locally or announced to other clients.

File AccessNetworking
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;

namespace NZombies;

/// <summary>
/// A vortex painted on the floor — spiral arms winding into a hot core, for something that pulls.
///
/// ⛔ IT IS DRAWN AT THE RADIUS IT IS GIVEN AND NOTHING ELSE, which is the whole contract. Oberon's
/// hole drags anything inside `PullRadius`, so the rim ring IS that number: standing outside it has
/// to be safe, and a ring drawn generously is a lie the attack does not tell.
///
/// ⛔ AND IT IS `LineRenderer`s, NOT A PARTICLE SYSTEM OR A SHADER. The same reasoning as
/// `ShockRing`: a ring is a polyline with a radius, needing no texture, no material and nothing
/// extracted from a `.pcf` first. A spiral is the same polyline with the angle advancing as the
/// radius shrinks — so the entire effect is arithmetic this project can already draw.
///
/// ⚠️ FOUR LAYERS, AND EACH ONE IS DOING A JOB:
///
///   • THE RIM says where the reach ends. Static, bright, traced to the floor.
///   • THE ARMS say which way it turns, and are the only reason it reads as a vortex rather than
///     as a circle. They rotate DIFFERENTIALLY — the core end faster than the rim end — so the
///     spiral visibly tightens. That shear is what sells inward flow; arms that merely spin as a
///     rigid shape read as a wheel.
///   • THE COLLAPSING RINGS say it is pulling. They leave the rim and shrink into the core on a
///     stagger, which states the direction outright rather than implying it.
///   • THE CORE is a small bright ring with NOTHING inside it. Additive drawing cannot paint
///     black, so the hole is made of absence: a hot event horizon around an unlit middle.
///
/// ⚠️ THE ARMS FADE FROM DIM AT THE RIM TO HOT AT THE CORE, through a two-stop `Gradient` on each
/// line. That gradient is the cheapest thing in the file and does more for the read than any of
/// the geometry: it pulls the eye inward, which is the direction the attack moves you.
/// </summary>
public sealed class Vortex : Component
{
	// ══ tuning ═══════════════════════════════════════════════════════════════
	//
	// ⛔ NULLABLE-BACKED GETTERS — a static's VALUE survives a hotload but its initialiser does not
	// re-run. INSTRUCTIONS.md §1. The same pattern as `ShockRing` and `PitVisual`.

	static int? _arms;
	/// <summary>How many spiral arms. 5.</summary>
	///
	/// ⚠️ ODD ON PURPOSE. An even count puts an arm directly opposite every other arm, and from
	/// above that reads as a set of straight bars crossing the middle rather than as a swirl.
	public static int Arms { get => _arms ?? 5; set => _arms = value; }

	static float? _turns;
	/// <summary>How far an arm sweeps, in whole turns, from rim to core. 0.85.</summary>
	///
	/// ⚠️ UNDER ONE FULL TURN ON PURPOSE. At 1.3 the outer half of every arm runs almost
	/// parallel to the rim, so five arms read as a set of concentric circles instead of as five
	/// arms. Below a turn they stay diagonal all the way out and the count stays legible.
	public static float Turns { get => _turns ?? 0.85f; set => _turns = value; }

	static float? _tightness;
	/// <summary>
	/// How hard the arms bunch toward the core. 1.35. 1 is a plain Archimedean spiral.
	///
	/// ⚠️ THIS IS WHY THE SEGMENTS GO WHERE THEY ARE NEEDED. Radius falls as `(1-t)^tightness`, so
	/// points crowd at the small end — which is exactly where a spiral turns fastest and where an
	/// evenly-spaced polyline would visibly become a polygon.
	/// </summary>
	///
	/// ⚠️ AND IT TRADES AGAINST `ArmSegments`, WHICH IS NOT OBVIOUS. Points are spaced evenly in
	/// `t`, not in arc length, so crowding them at the core necessarily thins them at the rim —
	/// where the circle is longest and a straight segment shows. At 1.7 with 40 points the first
	/// segment spanned about 180 units of a 900-unit disc and the arms were visibly faceted.
	public static float Tightness { get => _tightness ?? 1.35f; set => _tightness = value; }

	static float? _spin;
	/// <summary>Turns per second at the RIM. 0.3.</summary>
	public static float Spin { get => _spin ?? 0.3f; set => _spin = value; }

	static float? _shear;
	/// <summary>
	/// How much faster the core end turns than the rim end. 0.45.
	///
	/// ⛔ THE SINGLE MOST IMPORTANT NUMBER HERE. At 0 the arms are a rigid shape being rotated,
	/// which the eye reads as a spinning wheel — the shape never changes, so nothing is flowing.
	/// Any shear above zero makes the spiral wind tighter every frame, and a curve that is
	/// continuously tightening is the whole visual language of "being sucked in".
	/// </summary>
	/// ⛔ AND IT IS BUDGETED AGAINST THE LIFETIME, WHICH IS THE WHOLE OF THE TUNING. The core
	/// gains `Seconds × Spin × Shear` extra turns before the vortex closes — here 6 × 0.3 × 0.45,
	/// or about **0.8 of a turn**. That is enough to watch it tighten and not enough to wind the
	/// arms past each other. At 0.9 it was 1.9 turns and the last three seconds were a scribble:
	/// the arms had wrapped so far they merged into a dense set of rings with no direction left in
	/// them. Raise `Shear` for a longer attack only by lowering it for a shorter one.
	public static float Shear { get => _shear ?? 0.45f; set => _shear = value; }

	static int? _armSegments;
	/// <summary>Points per arm. 56. See `Tightness` for why this is not lower.</summary>
	public static int ArmSegments { get => _armSegments ?? 56; set => _armSegments = value; }

	static int? _rimSegments;
	/// <summary>Points in the rim and collapsing rings. 64.</summary>
	///
	/// ⚠️ HIGHER THAN `ShockRing`'S 32 BECAUSE IT COSTS NOTHING HERE. There it is 32 TRACES a
	/// frame; here the floor is already measured and a point is arithmetic.
	public static int RimSegments { get => _rimSegments ?? 64; set => _rimSegments = value; }

	static int? _collapse;
	/// <summary>How many rings are falling inward at once. 3.</summary>
	public static int Collapse { get => _collapse ?? 3; set => _collapse = value; }

	static float? _collapseSeconds;
	/// <summary>How long one ring takes to fall from the rim to the core. 1.6s.</summary>
	public static float CollapseSeconds
	{
		get => _collapseSeconds ?? 1.6f;
		set => _collapseSeconds = value;
	}

	static float? _core;
	/// <summary>Where the arms stop, as a fraction of the radius. 0.09.</summary>
	///
	/// ⚠️ NOT ZERO, AND NOT ONLY FOR LOOKS. Every arm converging on one point is a bright knot of
	/// overlapping additive lines. Stopping them on a small circle leaves the middle unlit, which
	/// is what makes it read as a hole rather than as a star.
	public static float CoreFraction { get => _core ?? 0.09f; set => _core = value; }

	static float? _width;
	/// <summary>
	/// Line thickness. 2.4.
	///
	/// ⚠️ `LineRenderer.Width` IS A `Curve` AND ITS UNITS ARE NOT WORLD UNITS — `LightningArc`
	/// learned that at 1.4 drawing a ribbon a foot across. Tuned by eye, like every other caller.
	/// </summary>
	public static float Width { get => _width ?? 2.4f; set => _width = value; }

	static int? _heightRings;
	/// <summary>Radial samples in the floor-height field. 7.</summary>
	public static int HeightRings { get => _heightRings ?? 7; set => _heightRings = value; }

	static int? _heightSpokes;
	/// <summary>Angular samples in the floor-height field. 24.</summary>
	public static int HeightSpokes { get => _heightSpokes ?? 24; set => _heightSpokes = value; }

	static float? _maxStep;
	/// <summary>
	/// A sample this far from the centre's height is a wall, not a floor. 48.
	///
	/// ⚠️ `PitVisual.MaxStep`'S REASONING, APPLIED TO A GRID. Without it the arms climb the side of
	/// any crate they cross; flattening the sample to the centre's height puts that point inside
	/// the geometry, where the wall occludes the line and nothing is drawn — which is correct.
	/// </summary>
	public static float MaxStep { get => _maxStep ?? 48f; set => _maxStep = value; }

	static bool? _light;
	/// <summary>A light in the middle. On.</summary>
	public static bool Light { get => _light ?? true; set => _light = value; }

	static float? _brightness;
	/// <summary>How hard that light burns. 6.</summary>
	public static float Brightness { get => _brightness ?? 6f; set => _brightness = value; }

	/// <summary>The authored rim and core colours — a deep violet winding into near-white.</summary>
	///
	/// ⚠️ EXPRESSIONS, NOT `static readonly` FIELDS, so a hotload cannot freeze them at whatever
	/// they held when the session started. INSTRUCTIONS.md §1 again.
	public static Color DefaultRim => new Color( 0.42f, 0.16f, 0.88f );
	public static Color DefaultCore => new Color( 0.93f, 0.78f, 1f );

	static Color? _rimColour, _coreColour;
	public static Color RimColour { get => _rimColour ?? DefaultRim; set => _rimColour = value; }
	public static Color CoreColour { get => _coreColour ?? DefaultCore; set => _coreColour = value; }

	/// <summary>Lift off the floor, so the lines do not z-fight with it.</summary>
	const float Lift = 2f;

	// ══ per-vortex state ═════════════════════════════════════════════════════

	/// <summary>The reach this is drawing. Set by <see cref="Spawn"/>.</summary>
	public float Radius { get; set; } = 900f;

	/// <summary>How long it stays open.</summary>
	public float Seconds { get; set; } = 6f;

	/// <summary>Colour at the rim, and at the core.</summary>
	public Color Rim { get; set; } = DefaultRim;
	public Color Core { get; set; } = DefaultCore;

	/// <summary>
	/// Floor height sampled on a polar grid, ONCE, at spawn. `[ring, spoke]`.
	/// </summary>
	///
	/// ⛔ THIS IS THE ONE PERFORMANCE DECISION IN THE FILE, AND IT IS FREE BECAUSE THE VORTEX DOES
	/// NOT MOVE. `ShockRing` has to re-trace every point every frame — its ring changes radius, so
	/// last frame's floor is not this frame's. `PitVisual`'s drag rings have the same problem and
	/// pay for it by re-tracing at 20 Hz instead of 60. Nothing here changes shape in a way that
	/// moves it over new ground: the disc is fixed and only the drawing inside it turns. So the
	/// floor is measured once — 7 × 24 = 168 traces at spawn — and every point for the rest of the
	/// effect is a bilinear lookup. **Zero traces per frame**, which is why this can afford five
	/// arms at 40 points and rebuild all of them at frame rate rather than on a timer.
	float[,] _height;

	Vector3 _at;
	float _born;

	LineRenderer _rimLine;
	LineRenderer _coreLine;
	readonly List<LineRenderer> _armLines = new();
	readonly List<LineRenderer> _ringLines = new();

	/// <summary>
	/// Scratch point buffers, one per line, refilled in place every frame.
	/// </summary>
	///
	/// ⚠️ SO THE REBUILD ALLOCATES NOTHING. Ten lines rebuilt at 60 Hz for six seconds is 3,600
	/// lists a second if each frame news its own, and a one-shot effect has no business handing
	/// the collector that.
	readonly Dictionary<LineRenderer, List<Vector3>> _buffers = new();

	PointLight _pointLight;

	// ══ spawning ═════════════════════════════════════════════════════════════

	/// <summary>
	/// Open a vortex on the floor at <paramref name="at"/>.
	/// </summary>
	///
	/// ⚠️ ITS OWN GAMEOBJECT, AND IT DESTROYS ITSELF, exactly like `ShockRing`. Nothing has to
	/// remember it, which is what lets a one-shot be fired from a static with no bookkeeping.
	///
	/// ⚠️ THE CENTRE IS SNAPPED TO THE FLOOR through `PitVisual.GroundAt`, which already traces
	/// from above and ignores bodies — so a caster standing on his own origin does not put the
	/// centre of the disc on his own head.
	public static Vortex Spawn( Vector3 at, float radius, float seconds,
		Color? rim = null, Color? core = null )
	{
		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) return null;

		var go = scene.CreateObject();
		go.Name = "nz_vortex";
		go.Flags |= GameObjectFlags.NotSaved;
		go.NetworkMode = NetworkMode.Never;   // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)
		go.WorldPosition = PitVisual.GroundAt( at );

		var v = go.Components.Create<Vortex>();

		v.Radius = MathF.Max( 32f, radius );
		v.Seconds = MathF.Max( 0.2f, seconds );
		v.Rim = rim ?? RimColour;
		v.Core = core ?? CoreColour;

		return v;
	}

	/// <summary>
	/// Open one here AND on every other machine. For a vortex whose cause is host-only.
	/// </summary>
	///
	/// ⛔ A BOSS IS SIMULATED ON THE HOST ALONE, so `Spawn` on its own puts a 900-unit attack
	/// telegraph on exactly one screen. ALL VISUAL EFFECTS ARE GLOBAL. The same shape as
	/// `ShockRing.FireShared` and `NZSound.PlayShared`: draw locally either way, announce only as
	/// host, and the receiving side skips the host so it cannot double.
	public static Vortex SpawnShared( Vector3 at, float radius, float seconds,
		Color? rim = null, Color? core = null )
	{
		if ( Networking.IsActive && NZGame.IsHost )
		{
			var r = rim ?? RimColour;
			var c = core ?? CoreColour;

			// ⚠️ SIX FLOATS RATHER THAN TWO `Color`s, for the reason given on `NZNet.ShockRingFx`:
			// an RPC argument that fails to serialise fails at RUNTIME, with the effect simply
			// absent on the far side — indistinguishable from the bug this exists to fix.
			NZNet.VortexFx( at, radius, seconds, r.r, r.g, r.b, c.r, c.g, c.b );
		}

		return Spawn( at, radius, seconds, rim, core );
	}

	// ══ building ═════════════════════════════════════════════════════════════

	protected override void OnStart()
	{
		_born = Time.Now;
		_at = WorldPosition;

		BuildHeightField();

		// ⚠️ THE RIM IS THE ONE LINE DRAWN IN THE RIM COLOUR FLAT. Everything else gradients toward
		// the core; the boundary should read as one even edge, because its job is to be a boundary.
		// ⚠️ THE RIM IS DRAWN THICKER THAN THE ARMS. It is the only line carrying information —
		// where the pull stops — and at equal width the arms' outer ends run close enough to it
		// that the boundary stops being obvious, which for a telegraph is the one failure that
		// matters.
		_rimLine = MakeLine( Flat( Rim ), Width * 1.3f );
		_coreLine = MakeLine( Flat( Core ), Width * 1.35f );

		for ( var i = 0; i < Math.Max( 1, Arms ); i++ )
			_armLines.Add( MakeLine( RimToCore(), Width ) );

		for ( var i = 0; i < Math.Max( 0, Collapse ); i++ )
			_ringLines.Add( MakeLine( Flat( Rim ), Width * 0.8f ) );

		if ( Light )
		{
			var lightGo = Scene.CreateObject();
			lightGo.Name = "nz_vortex_light";
			lightGo.Flags |= GameObjectFlags.NotSaved;
			lightGo.NetworkMode = NetworkMode.Never;   // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)
			lightGo.SetParent( GameObject );
			lightGo.LocalPosition = Vector3.Up * 48f;

			_pointLight = lightGo.Components.Create<PointLight>();
			_pointLight.LightColor = Core * MathF.Max( 0f, Brightness );
			_pointLight.Radius = Radius * 0.6f;
			_pointLight.Shadows = false;
		}

		Draw( 0f );
	}

	static Gradient Flat( Color c ) => new Gradient( new Gradient.ColorFrame( 0f, c ) );

	/// <summary>Dim at the start of the line, hot at the end — and the arms run rim → core.</summary>
	Gradient RimToCore() => new Gradient(
		new Gradient.ColorFrame( 0f, Rim ),
		new Gradient.ColorFrame( 1f, Core ) );

	LineRenderer MakeLine( Gradient colour, float width )
	{
		var line = Components.Create<LineRenderer>();

		line.UseVectorPoints = true;
		line.Additive = true;
		line.Lighting = false;
		line.CastShadows = false;
		line.Color = colour;
		line.Width = MathF.Max( 0.05f, width );

		_buffers[line] = new List<Vector3>( Math.Max( ArmSegments, RimSegments ) + 2 );

		return line;
	}

	/// <summary>
	/// Measure the floor across the whole disc, once.
	/// </summary>
	///
	/// ⚠️ THE OUTER SAMPLES SIT EXACTLY ON THE RADIUS, because `i / (rings-1)` reaches 1 — so the
	/// rim ring is interpolating between real measurements rather than extrapolating past the last
	/// one, which would leave it hovering wherever the ground fell away at the edge.
	void BuildHeightField()
	{
		var rings = Math.Max( 2, HeightRings );
		var spokes = Math.Max( 6, HeightSpokes );
		var step = MathF.Max( 0f, MaxStep );

		_height = new float[rings, spokes];

		for ( var i = 0; i < rings; i++ )
		{
			var r = Radius * (i / (float)(rings - 1));

			for ( var j = 0; j < spokes; j++ )
			{
				var a = j / (float)spokes * MathF.PI * 2f;
				var p = _at + new Vector3( MathF.Cos( a ), MathF.Sin( a ), 0f ) * r;
				var g = PitVisual.GroundAt( p );

				_height[i, j] = MathF.Abs( g.z - _at.z ) > step ? _at.z : g.z;
			}
		}
	}

	/// <summary>Floor height anywhere on the disc, bilinear between the samples.</summary>
	///
	/// ⚠️ THE ANGULAR AXIS WRAPS. Spoke `n-1` interpolates back to spoke 0, not off the end of the
	/// array — without that the disc has a seam at zero degrees where every line snaps to a
	/// different height.
	float GroundZ( float r, float ang )
	{
		if ( _height is null ) return _at.z;

		var rings = _height.GetLength( 0 );
		var spokes = _height.GetLength( 1 );

		var fr = MathX.Clamp( r / MathF.Max( 1f, Radius ), 0f, 1f ) * (rings - 1);
		var i0 = Math.Clamp( (int)MathF.Floor( fr ), 0, rings - 1 );
		var i1 = Math.Min( i0 + 1, rings - 1 );
		var ti = fr - i0;

		var turn = ang / (MathF.PI * 2f);
		turn -= MathF.Floor( turn );

		var fa = turn * spokes;
		var j0 = Math.Clamp( (int)MathF.Floor( fa ), 0, spokes - 1 ) % spokes;
		var j1 = (j0 + 1) % spokes;
		var tj = fa - MathF.Floor( fa );

		var lo = MathX.Lerp( _height[i0, j0], _height[i0, j1], tj );
		var hi = MathX.Lerp( _height[i1, j0], _height[i1, j1], tj );

		return MathX.Lerp( lo, hi, ti );
	}

	Vector3 On( float r, float ang ) => new Vector3(
		_at.x + MathF.Cos( ang ) * r,
		_at.y + MathF.Sin( ang ) * r,
		GroundZ( r, ang ) + Lift );

	/// <summary>
	/// Lay a closed circle into a line's buffer.
	/// </summary>
	///
	/// ⚠️ CLOSED — the last point repeats the first — because `LineRenderer` draws a polyline and
	/// not a loop. `ShockRing` and `PitVisual` both have the same note; without it every circle
	/// here has a bite out of it at zero degrees.
	void Circle( LineRenderer line, float r )
	{
		if ( !line.IsValid() || !_buffers.TryGetValue( line, out var buf ) ) return;

		var n = Math.Max( 8, RimSegments );
		buf.Clear();

		for ( var i = 0; i <= n; i++ )
			buf.Add( On( r, i / (float)n * MathF.PI * 2f ) );

		line.VectorPoints = buf;
	}

	/// <summary>One arm, from the rim inward, at the given moment.</summary>
	void Arm( LineRenderer line, int index, float age )
	{
		if ( !line.IsValid() || !_buffers.TryGetValue( line, out var buf ) ) return;

		var n = Math.Max( 4, ArmSegments );
		var inner = Radius * MathX.Clamp( CoreFraction, 0.02f, 0.9f );
		var sweep = Turns * MathF.PI * 2f;
		var tight = MathF.Max( 0.2f, Tightness );

		var spun = age * Spin * MathF.PI * 2f;
		var offset = index / (float)Math.Max( 1, Arms ) * MathF.PI * 2f;

		buf.Clear();

		for ( var i = 0; i <= n; i++ )
		{
			var t = i / (float)n;

			// ⚠️ RADIUS FALLS AS A POWER OF (1-t), SO POINTS CROWD AT THE CORE. See `Tightness`.
			var r = inner + (Radius - inner) * MathF.Pow( 1f - t, tight );

			// ⛔ THE SHEAR TERM IS WHAT MAKES IT A VORTEX. `t * Shear` means the core end has turned
			// further than the rim end by now, and the gap grows every frame — so the spiral winds
			// tighter as you watch instead of holding one shape and rotating.
			var ang = offset + t * sweep + spun * (1f + Shear * t);

			buf.Add( On( r, ang ) );
		}

		line.VectorPoints = buf;
	}

	// ══ running ══════════════════════════════════════════════════════════════

	protected override void OnUpdate()
	{
		var age = Time.Now - _born;

		if ( age >= Seconds )
		{
			GameObject.Destroy();
			return;
		}

		Draw( age );
	}

	void Draw( float age )
	{
		// ⚠️ IT FADES IN AS WELL AS OUT. A 900-unit disc appearing at full brightness on one frame
		// reads as a bug; a third of a second of rise reads as something opening.
		var fade = MathX.Clamp( age / 0.35f, 0f, 1f )
			* MathX.Clamp( (Seconds - age) / 0.6f, 0f, 1f );

		var inner = Radius * MathX.Clamp( CoreFraction, 0.02f, 0.9f );

		Circle( _rimLine, Radius );
		if ( _rimLine.IsValid() ) _rimLine.Color = Flat( Rim.WithAlpha( fade ) );

		// ⚠️ THE CORE BREATHES, at a rate unrelated to the spin, so the middle never looks like a
		// still image while the arms move around it.
		var pulse = 0.85f + 0.15f * MathF.Sin( age * 9f );

		Circle( _coreLine, inner * pulse );
		if ( _coreLine.IsValid() ) _coreLine.Color = Flat( Core.WithAlpha( fade ) );

		for ( var i = 0; i < _armLines.Count; i++ )
		{
			Arm( _armLines[i], i, age );

			if ( _armLines[i].IsValid() )
				_armLines[i].Color = new Gradient(
					new Gradient.ColorFrame( 0f, Rim.WithAlpha( fade * 0.55f ) ),
					new Gradient.ColorFrame( 1f, Core.WithAlpha( fade ) ) );
		}

		// ⚠️ STAGGERED BY INDEX so they are evenly spaced down the fall rather than leaving the rim
		// together — three rings arriving as one is one ring that happens to be brighter.
		var period = MathF.Max( 0.2f, CollapseSeconds );

		for ( var i = 0; i < _ringLines.Count; i++ )
		{
			var p = (age / period + i / (float)_ringLines.Count) % 1f;
			var r = MathX.Lerp( Radius, inner, p );

			Circle( _ringLines[i], r );

			// ⚠️ IN QUICKLY, OUT ALL THE WAY TO ZERO. The phase wraps at 1, so a ring still visible
			// there would pop back to the rim in one frame.
			var a = MathF.Min( p / 0.15f, 1f ) * (1f - p);

			if ( _ringLines[i].IsValid() )
				_ringLines[i].Color = Flat( Color.Lerp( Rim, Core, p ).WithAlpha( fade * a ) );
		}

		if ( _pointLight.IsValid() )
			_pointLight.LightColor = Core * (MathF.Max( 0f, Brightness ) * fade * pulse);
	}

	protected override void OnDestroy()
	{
		// ⚠️ THE LIGHT IS A CHILD OBJECT, not a component on this one, so destroying this component
		// does not take it with it.
		if ( _pointLight.IsValid() && _pointLight.GameObject.IsValid() )
			_pointLight.GameObject.Destroy();
	}

	// ══ diagnostics ══════════════════════════════════════════════════════════

	/// <summary>`nz_vortex` — the resolved look and how many are open.</summary>
	[ConCmd( "nz_vortex" )]
	public static void Report()
	{
		var live = Game.ActiveScene?.GetAllComponents<Vortex>().Count() ?? 0;

		Log.Info( $"[nz-fx] VORTEX · {live} open · {Arms} arm(s) × {ArmSegments}pt"
			+ $" · {Turns:0.##} turn(s) · tightness {Tightness:0.##}" );

		Log.Info( $"[nz-fx]   spin {Spin:0.##}/s · shear {Shear:0.##}"
			+ $" · {Collapse} collapsing ring(s) over {CollapseSeconds:0.##}s"
			+ $" · core {CoreFraction:0.###}" );

		Log.Info( $"[nz-fx]   rim {RimColour.r:0.##},{RimColour.g:0.##},{RimColour.b:0.##}"
			+ $" → core {CoreColour.r:0.##},{CoreColour.g:0.##},{CoreColour.b:0.##}"
			+ $" · light {(Light ? $"on ×{Brightness:0.#}" : "off")}" );

		Log.Info( $"[nz-fx]   floor field {HeightRings}×{HeightSpokes}"
			+ $" = {Math.Max( 2, HeightRings ) * Math.Max( 6, HeightSpokes )} trace(s) at spawn,"
			+ " 0 per frame" );
	}

	/// <summary>
	/// `nz_vortex_test [radius] [seconds]` — open one at your feet.
	/// </summary>
	///
	/// ⚠️ IT EXISTS BECAUSE THE REAL TRIGGER IS A BOSS ROLLING ONE ATTACK IN THREE. Waiting out
	/// `nz_oberon_move hole` and its wind-up is not a way to tune a spiral.
	[ConCmd( "nz_vortex_test" )]
	public static void TestCmd( float radius = 900f, float seconds = 6f )
	{
		var p = NZPlayer.Local;
		if ( !p.IsValid() ) { Log.Warning( "[nz-fx] no player" ); return; }

		// ⚠️ `Spawn`, NOT `SpawnShared`. A preview is for whoever typed the command.
		Spawn( p.WorldPosition, radius, seconds );
		Log.Info( $"[nz-fx] test vortex {radius:0}u for {seconds:0.##}s" );
	}

	/// <summary>`nz_vortex_set &lt;key&gt; &lt;value&gt;` — retune it, then preview.</summary>
	[ConCmd( "nz_vortex_set" )]
	public static void SetCmd( string key = "", float value = 0f )
	{
		switch ( key.ToLowerInvariant() )
		{
			case "arms": Arms = (int)value; break;
			case "turns": Turns = value; break;
			case "tightness": Tightness = value; break;
			case "spin": Spin = value; break;
			case "shear": Shear = value; break;
			case "armsegments": ArmSegments = (int)value; break;
			case "rimsegments": RimSegments = (int)value; break;
			case "collapse": Collapse = (int)value; break;
			case "collapseseconds": CollapseSeconds = value; break;
			case "core": CoreFraction = value; break;
			case "width": Width = value; break;
			case "heightrings": HeightRings = (int)value; break;
			case "heightspokes": HeightSpokes = (int)value; break;
			case "maxstep": MaxStep = value; break;
			case "light": Light = value > 0.5f; break;
			case "brightness": Brightness = value; break;

			default:
				Log.Info( "[nz-fx] nz_vortex_set <arms|turns|tightness|spin|shear|armsegments"
					+ "|rimsegments|collapse|collapseseconds|core|width|heightrings|heightspokes"
					+ "|maxstep|light|brightness> <value>" );
				return;
		}

		Log.Info( $"[nz-fx] vortex {key} = {value:0.###}" );
		Report();
	}

	/// <summary>
	/// `nz_vortex_colour &lt;r&gt; &lt;g&gt; &lt;b&gt; [cr] [cg] [cb]` — rim colour, then core.
	/// </summary>
	///
	/// ⚠️ WITH THREE ARGUMENTS IT SETS THE RIM AND LEAVES THE CORE, because the rim is the one
	/// people mean by "what colour is it". Six sets both. Values are 0–1.
	[ConCmd( "nz_vortex_colour" )]
	public static void ColourCmd( float r = -1f, float g = 0f, float b = 0f,
		float cr = -1f, float cg = 0f, float cb = 0f )
	{
		if ( r >= 0f ) RimColour = new Color( r, g, b );
		if ( cr >= 0f ) CoreColour = new Color( cr, cg, cb );

		Report();

		var p = NZPlayer.Local;
		if ( p.IsValid() ) Spawn( p.WorldPosition, 900f, 4f );
	}
}