Weapons/PrismaFx.cs

Visual effect code for the Prisma weapon. Defines tuning parameters and draws a travelling pulse (hitscan visual) and a muzzle discharge composed of rings, filaments and a core using LineRenderer and PointLight components; includes console commands to report and tweak parameters and to test the effect.

Native Interop
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;

namespace NZombies;

/// <summary>
/// The Prisma's look when it fires: a blue discharge at the muzzle and a pulse that travels.
///
/// ⛔ A TRAVELLING HEAD, NOT A STREAK, AND THAT IS THE WHOLE DIFFERENCE. `FastTracer` draws the
/// whole muzzle-to-impact line at once and fades it — correct for a bullet, which is already
/// there by the time you see it. An energy weapon reads as energy because something CROSSES the
/// gap: a bright head with a short tail behind it, arriving a moment after the shot.
///
/// ⚠️ THE SHOT IS STILL HITSCAN. Damage, penetration and the fuse all resolve on the frame you
/// fire, exactly as before; this is a drawing of a decision already made. A pulse that took
/// 40ms to arrive and THEN dealt damage would be a different weapon and a networking problem.
///
/// ⚠️ AND IT IS LINES, LIKE EVERYTHING ELSE HERE. `ShockRing`, `Vortex` and the bomb shells are
/// all `LineRenderer`s for the same reason: no texture to extract, no `.pcf` to port, and the
/// colour is a number this project can measure off the weapon rather than guess.
/// </summary>
public static class PrismaFx
{
	// ══ tuning ═══════════════════════════════════════════════════════════════
	//
	// ⛔ NULLABLE-BACKED GETTERS — INSTRUCTIONS.md §1.

	static float? _speed;
	/// <summary>How fast the pulse crosses the gap, units/sec. 11000.</summary>
	///
	/// ⚠️ FAST ENOUGH TO READ AS ENERGY, SLOW ENOUGH TO SEE. At 11,000 a shot across a big room
	/// takes about 90ms — two or three frames of travel, which is the difference between "a bolt
	/// went out" and "a line appeared".
	public static float Speed { get => _speed ?? 11000f; set => _speed = value; }

	static float? _tail;
	/// <summary>How long the bright tail behind the head is, in units. 120.</summary>
	public static float Tail { get => _tail ?? 120f; set => _tail = value; }

	static float? _width;
	/// <summary>Thickness of the pulse, in world units. 2.6.</summary>
	///
	/// ⛔ THE PULSE ONLY. The discharge used to derive all three of its stroke widths from this
	/// and came out as a solid blob — see `RingWidth`. A bolt crossing a room and a ring 3 units
	/// across held 20 units from the eye have nothing to say to each other about thickness.
	public static float Width { get => _width ?? 2.6f; set => _width = value; }

	static bool? _light;
	/// <summary>A light at the muzzle and on the pulse head. On.</summary>
	public static bool Light { get => _light ?? true; set => _light = value; }

	// ── the discharge ────────────────────────────────────────────────────
	//
	// ⛔ THREE LAYERS, AND EACH ONE IS DOING A DIFFERENT JOB. A muzzle effect fails by being one
	// bright thing that appears and vanishes — the eye reads that as a lamp blinking, which is
	// exactly what the six-spike star this replaces looked like.
	//
	//   THE RINGS are the weapon's signature: a tight HEXAGON at the muzzle, stacked five deep so
	//     it burns rather than glows. Six segments, on a gun called the Prisma.
	//   THE FILAMENTS say the energy is unstable. Straight lines read as a lens flare; jagged
	//     ones that re-wobble every frame read as something arcing.
	//   THE CORE says it was violent. One frame of near-white, gone before the rest of it.
	//
	// ⛔ AND THE WHOLE THING COOLS, from a hot blue-white to a saturated azure. That is what
	// separates a discharge from a coloured light: anything hot enough to matter is near-white at
	// the instant it happens and takes on its colour as it dies. An effect that is one flat colour
	// from start to finish always reads as a decal being shown and hidden.

	static float? _flashSeconds;
	/// <summary>How long the whole discharge lasts. 0.14s.</summary>
	///
	/// ⛔ IT IS A HARD CUT, NOT A FADE, AND EVERY LAYER IS SUBJECT TO IT. The component destroys
	/// itself at this age no matter what the layers are doing — which is load-bearing here,
	/// because `RingLife` is 0.205 and the ring is meant to be killed at 32% alpha rather than
	/// allowed to fade out. See `RingLife`.
	///
	/// ⚠️ SO CHANGING THIS RETIMES THE RING AS WELL AS ENDING IT. Raising it lets the ring fade
	/// further before it stops; lowering it cuts the ring off brighter and harder. The core and
	/// the filaments are long finished by 0.14 either way.
	public static float FlashSeconds { get => _flashSeconds ?? 0.14f; set => _flashSeconds = value; }

	static int? _rings;
	/// <summary>How many rings are drawn. 5.</summary>
	///
	/// ⛔ AT THE AUTHORED TUNING THESE ARE FIVE COPIES OF ONE RING, AND THAT IS THE POINT. With
	/// `RingStagger` and `RingTravel` both at 0 every ring shares a birth, a centre and a radius
	/// curve, so they draw exactly on top of each other. The only thing separating them is the
	/// `1 - i*0.22` brightness step, which makes the set a BRIGHTNESS control: alphas of
	/// 1.00 + 0.78 + 0.56 + 0.34 + 0.12 = **2.80**.
	///
	/// ⚠️ AND STACKING IS THE ONLY WAY TO GET THERE. `Ramp` clamps alpha at 1, so a single
	/// stroke cannot be brighter than full — five additive strokes can. Turning this down does not
	/// remove rings you can see, it dims the one you can.
	///
	/// ⚠️ THE TRAIN MACHINERY IS STILL LIVE, just switched off. Give `RingStagger` or
	/// `RingTravel` a value and these five stop being copies and become a train of pulses leaving
	/// the barrel — which will look like a completely different weapon, so change one at a time.
	public static int Rings { get => _rings ?? 5; set => _rings = value; }

	static float? _ringStagger;
	/// <summary>
	/// Seconds between one ring leaving and the next. 0 — they all leave together.
	/// </summary>
	///
	/// ⚠️ ZERO IS WHAT MAKES THE FIVE RINGS ONE RING. Non-zero turns them into a train, spread
	/// over `(Rings - 1) × this + RingLife` seconds in total — watch `FlashSeconds` if you raise
	/// it, because that total is what gets cut.
	public static float RingStagger { get => _ringStagger ?? 0f; set => _ringStagger = value; }

	static float? _ringLife;
	/// <summary>How long a ring takes to expand and fade. 0.205s.</summary>
	///
	/// ⛔ DELIBERATELY LONGER THAN `FlashSeconds`, WHICH IS NOT THE MISTAKE IT LOOKS LIKE. The
	/// discharge is destroyed at 0.14s, so the ring is killed at p = 0.68 — already at 98% of its
	/// final radius but still at **32% alpha**. It ends while still burning instead of fading to
	/// nothing.
	///
	/// ⚠️ SO THIS IS AN EXPANSION-RATE KNOB HERE, NOT A DURATION. Raising it slows the ring's
	/// growth and leaves it brighter at the cut; lowering it speeds the growth and lets it fade
	/// further before the end. The preview's HUD flags the overrun in orange, which is how the
	/// value was chosen.
	public static float RingLife { get => _ringLife ?? 0.205f; set => _ringLife = value; }

	static float? _ringEnd;
	/// <summary>How wide a ring gets, as a radius. 3.5 units.</summary>
	///
	/// ⛔ SMALL, AND ON PURPOSE. The muzzle sits about twenty units from the camera in first
	/// person, so world-space radii here subtend far more than their size suggests — the first
	/// attempt at 14 ran off both edges of the screen in the preview. At 3.5 the ring is a tight
	/// collar on the barrel rather than a halo over the view, which is what lets it be this
	/// bright without swallowing the crosshair.
	public static float RingEnd { get => _ringEnd ?? 3.5f; set => _ringEnd = value; }

	static float? _ringTravel;
	/// <summary>How far a ring drifts down the barrel as it expands. 0 — it stays put.</summary>
	///
	/// ⚠️ THIS WAS ARGUED FOR AT 7 AND AUTHORED AT 0, AND THE ARGUMENT WAS FOR A DIFFERENT
	/// EFFECT. Drift is what stops a TRAIN reading as a ripple on a pond: several rings pinned at
	/// one point are a flat pattern, several drifting forward are a cone leaving the weapon. With
	/// one ring there is no train to spread, and a single ring that slides down the barrel just
	/// detaches from the gun.
	///
	/// ⚠️ IT ONLY EARNS ITS KEEP ONCE `RingStagger` IS NON-ZERO. Raise the two together or
	/// neither.
	public static float RingTravel { get => _ringTravel ?? 0f; set => _ringTravel = value; }

	static int? _ringSegments;
	/// <summary>Points per ring. 6 — a hexagon, not a circle.</summary>
	///
	/// ⚠️ THE FACETS ARE THE POINT, ON A GUN CALLED THE PRISMA. Twenty segments give a circle,
	/// and a circle at the muzzle is a smoke ring; six give a hard-edged hexagon that reads as
	/// something crystalline the weapon is firing through. It is also the one place the weapon's
	/// name shows up in its effects.
	///
	/// ⚠️ AND IT IS ONLY LEGIBLE BECAUSE THE RING IS SMALL. At the 14u radius this started at
	/// six segments looked like a rendering fault; at 3.5u it reads as a shape.
	public static int RingSegments { get => _ringSegments ?? 6; set => _ringSegments = value; }

	static int? _filaments;
	/// <summary>How many arcing tendrils. 5.</summary>
	public static int Filaments { get => _filaments ?? 5; set => _filaments = value; }

	static float? _filamentLife;
	/// <summary>How long they crackle. 0.06s.</summary>
	public static float FilamentLife { get => _filamentLife ?? 0.06f; set => _filamentLife = value; }

	static float? _filamentLength;
	/// <summary>How far they reach. 9 units.</summary>
	public static float FilamentLength
	{
		get => _filamentLength ?? 9f;
		set => _filamentLength = value;
	}

	static float? _filamentJitter;
	/// <summary>How far each joint wanders off the straight line. 2.2 units.</summary>
	public static float FilamentJitter
	{
		get => _filamentJitter ?? 2.2f;
		set => _filamentJitter = value;
	}

	static float? _ringWidth;
	/// <summary>
	/// How thick a ring's stroke is, in world units. 0.35.
	/// </summary>
	///
	/// ⛔ WORLD UNITS, AND THAT IS WHAT WENT WRONG. `LineRenderer.Width` is a world measurement;
	/// the preview draws its strokes in PIXELS. So the ring was authored against a thin outline on
	/// the page and shipped as a stroke **3.9 units wide on a ring of radius 3.5** — wider than
	/// the ring itself, which fills the disc solid. Reported from the game as "a filled heptagon".
	///
	/// ⚠️ SO KEEP IT ROUGHLY A TENTH OF `RingEnd`. That ratio is what reads as a ring rather
	/// than as a coin; the preview now converts world widths to pixels through the same
	/// projection it uses for positions, so the two finally agree.
	public static float RingWidth { get => _ringWidth ?? 0.35f; set => _ringWidth = value; }

	static float? _coreWidth;
	/// <summary>How thick the core cross is, in world units. 0.5.</summary>
	public static float CoreWidth { get => _coreWidth ?? 0.5f; set => _coreWidth = value; }

	static float? _filamentWidth;
	/// <summary>How thick a filament is, in world units. 0.25.</summary>
	public static float FilamentWidth
	{
		get => _filamentWidth ?? 0.25f;
		set => _filamentWidth = value;
	}

	static float? _coreLife;
	/// <summary>The near-white flare at the centre. 0.035s.</summary>
	public static float CoreLife { get => _coreLife ?? 0.035f; set => _coreLife = value; }

	static float? _coreSize;
	/// <summary>How far the core's arms reach. 5 units.</summary>
	public static float CoreSize { get => _coreSize ?? 5f; set => _coreSize = value; }

	static Color? _tint;
	/// <summary>
	/// The blue everything here is drawn in. An electric azure, not the weapon's paint.
	/// </summary>
	///
	/// ⛔ THIS DELIBERATELY IS NOT THE MEASURED ACCENT, AND THE REASON IS THE BLEND MODE. The
	/// figure sampled off `spectra_bone_baset.png` is (0.690, 0.763, 0.883) — in bytes that is
	/// (176, 195, 225), a pale blue-GREY. Correct for a painted surface lit by the room, and
	/// completely wrong drawn ADDITIVELY on a dark screen, where a colour whose three channels are
	/// that close together simply reads as white. The whole discharge came out grey.
	///
	/// ⚠️ SO THE HUE HAS TO BE CARRIED BY THE GAP BETWEEN CHANNELS, not by the name of the
	/// colour. At (0.16, 0.48, 1.00) blue saturates after one layer of overlap and red needs six,
	/// so the centre of the flash blows out to white on its own where the layers pile up, and
	/// every edge and every fading frame stays unmistakably blue. That is the additive blend doing
	/// the hot-centre-cool-edge work for free instead of being fought.
	///
	/// ⚠️ THE MEASURED ACCENT STILL OWNS THE SIGHT MATERIALS. `prisma_sight.vmat` and
	/// `prisma_sight_glow.vmat` are lit surfaces and keep the sampled figure — they are paint, and
	/// paint should match the gun. This is light, and light should read as light.
	public static Color Tint
	{
		get => _tint ?? new Color( 0.16f, 0.48f, 1.00f );
		set => _tint = value;
	}

	static Color? _core;
	/// <summary>
	/// The hot centre — the head of the pulse and the middle of the discharge.
	/// </summary>
	///
	/// ⚠️ BLUE-WHITE, NOT WHITE. Pure white here put a grey dot at the centre of everything,
	/// because the hottest part of the effect is also the part most on screen. Keeping red low
	/// even at the core means the flash reads blue from its first frame rather than starting
	/// white and becoming blue once it is already dim.
	public static Color Core
	{
		get => _core ?? new Color( 0.62f, 0.86f, 1.00f );
		set => _core = value;
	}

	/// <summary>Is this the weapon the effects belong to.</summary>
	public static bool IsFor( SWB.Base.Weapon w )
		=> w.IsValid()
			&& string.Equals( w.ClassName, PrismaChain.Weapon, StringComparison.OrdinalIgnoreCase );

	// ══ the pulse ════════════════════════════════════════════════════════════

	/// <summary>Send a pulse from the muzzle to where the shot landed.</summary>
	public static void Pulse( Vector3 from, Vector3 to )
	{
		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) return;

		var go = scene.CreateObject();
		go.Name = "nz_prisma_pulse";
		go.Flags |= GameObjectFlags.NotSaved;
		go.WorldPosition = from;

		var p = go.Components.Create<PulseBolt>();
		p.From = from;
		p.To = to;
	}

	/// <summary>One bolt in flight.</summary>
	public sealed class PulseBolt : Component
	{
		public Vector3 From, To;

		float _born;
		float _life;
		LineRenderer _line;
		PointLight _light;
		readonly List<Vector3> _buf = new( 2 );

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

			// ⚠️ THE LIFE IS THE TRAVEL TIME, FLOORED. A point-blank shot would otherwise last
			// zero seconds and never be drawn at all — which is the shot you are most likely to
			// be looking at.
			_life = MathF.Max( 0.035f, From.Distance( To ) / MathF.Max( 1f, Speed ) );

			_line = Components.Create<LineRenderer>();
			_line.UseVectorPoints = true;
			_line.Additive = true;
			_line.Lighting = false;
			_line.CastShadows = false;
			_line.Width = MathF.Max( 0.05f, Width );

			if ( Light )
			{
				_light = Components.Create<PointLight>();
				_light.LightColor = Tint * 3f;
				_light.Radius = 150f;
				_light.Shadows = false;
			}
		}

		protected override void OnUpdate()
		{
			var t = MathX.Clamp( (Time.Now - _born) / _life, 0f, 1f );

			var head = Vector3.Lerp( From, To, t );
			var dir = (To - From).Normal;

			// ⚠️ THE TAIL IS CLIPPED AT THE MUZZLE, so a pulse fired at a wall a foot away does not
			// draw a hundred units of trail out of the back of the gun.
			var back = head - dir * MathF.Min( Tail, From.Distance( head ) );

			_buf.Clear();
			_buf.Add( back );
			_buf.Add( head );

			_line.VectorPoints = _buf;

			// ⚠️ TRANSPARENT AT THE TAIL, HOT AT THE HEAD. Frame 0 is the first point in the list.
			_line.Color = new Gradient(
				new Gradient.ColorFrame( 0f, Tint.WithAlpha( 0f ) ),
				new Gradient.ColorFrame( 1f, Core.WithAlpha( 1f ) ) );

			if ( _light.IsValid() ) WorldPosition = head;

			if ( t >= 1f ) GameObject.Destroy();
		}
	}

	// ══ the muzzle discharge ═════════════════════════════════════════════════

	/// <summary>
	/// A blue discharge at the muzzle, pointing down the barrel, riding the gun while it lasts.
	/// </summary>
	///
	/// ⛔ `follow` IS WHAT STOPS IT HANGING IN THE AIR. This used to spawn a loose world object
	/// and leave it there, so for the 140ms it lives the discharge stayed where the barrel WAS —
	/// turn or strafe while firing and it visibly detaches and drifts behind the gun. The
	/// weapon's particle flash never had the problem because `CreateParticle` parents it to the
	/// muzzle; this simply never did the same.
	///
	/// ⚠️ PARENTED KEEPING WORLD POSITION, so the local transform comes out as whatever the
	/// offset already put it at. Passing `false` would snap the discharge onto the muzzle bone and
	/// throw `nz_muzzle_offset` away at the moment of firing.
	///
	/// ⚠️ IT DOES NOT CHANGE WHICH CAMERA DRAWS IT. Rendering here goes by TAG, not by
	/// hierarchy, and the object stays untagged — so this is purely a change of what it is glued
	/// to. Tagging it into the viewmodel pass would also take its light out of the room, which is
	/// the trap `PapMuzzleFlash.Spawn` documents at length.
	public static void Flash( Vector3 at, Rotation rot, GameObject follow = null )
	{
		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) return;

		var go = scene.CreateObject();
		go.Name = "nz_prisma_flash";
		go.Flags |= GameObjectFlags.NotSaved;
		go.WorldPosition = at;
		go.WorldRotation = rot;

		if ( follow.IsValid() ) go.SetParent( follow, true );

		go.Components.Create<MuzzleBloom>();
	}

	/// <summary>
	/// One line and the buffer that feeds it.
	/// </summary>
	///
	/// ⛔ EVERY RENDERER OWNS ITS OWN LIST. The old star shared a single `_buf` across all six
	/// spikes, which only worked because `VectorPoints` happens to copy — if it ever held the
	/// reference instead, all six would have drawn the last spike written and the bug would have
	/// looked like "the flash is one line". Not worth depending on either way for the cost of a
	/// list per strand, allocated once at spawn.
	sealed class Strand
	{
		public LineRenderer Line;
		public readonly List<Vector3> Pts = new( 24 );

		public void Push( Color c )
		{
			if ( !Line.IsValid() ) return;
			Line.VectorPoints = Pts;
			Line.Color = new Gradient( new Gradient.ColorFrame( 0f, c ) );
		}
	}

	/// <summary>
	/// The discharge: a near-white core, a train of rings leaving the barrel, and arcing filaments.
	/// </summary>
	///
	/// ⛔ IT REPLACES THE WEAPON'S PARTICLE FLASH RATHER THAN SITTING ON TOP OF IT. An orange
	/// powder flash behind a blue discharge reads as two guns firing at once, which is worse than
	/// either alone — see the suppression in `Weapon.Shoot.cs`.
	///
	/// ⚠️ ONE COMPONENT DRAWS ALL THREE LAYERS, because they share a clock and a colour ramp.
	/// Three components would be three lifetimes to keep in step and three places for the cooling
	/// to drift apart.
	public sealed class MuzzleBloom : Component
	{
		float _born;
		PointLight _light;

		Strand _coreA, _coreB;
		readonly List<Strand> _rings = new();
		readonly List<Strand> _fils = new();

		/// <summary>
		/// Per-filament directions and phases, fixed at spawn.
		/// </summary>
		///
		/// ⛔ FIXED, NOT RE-ROLLED PER FRAME. A tendril whose ROOT direction changes every frame is
		/// not arcing, it is strobing — it reads as noise rather than as one filament moving. Only
		/// the joints wobble; the direction it set off in is its identity.
		readonly List<Vector3> _dir = new();
		readonly List<float> _phase = new();

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

			_coreA = Make( CoreWidth );
			_coreB = Make( CoreWidth );

			for ( var i = 0; i < Math.Max( 0, Rings ); i++ )
				_rings.Add( Make( RingWidth ) );

			var up = WorldRotation.Up;
			var right = WorldRotation.Right;
			var fwd = WorldRotation.Forward;
			var count = Math.Max( 0, Filaments );

			for ( var i = 0; i < count; i++ )
			{
				_fils.Add( Make( FilamentWidth ) );

				// ⚠️ SPREAD ROUND THE BARREL WITH A JITTER ON TOP, rather than fully random.
				// Pure randomness clumps: five tendrils come out looking like two and a gap.
				var a = (i + Game.Random.Float( -0.28f, 0.28f )) / count * MathF.Tau;

				_dir.Add( (up * MathF.Cos( a ) + right * MathF.Sin( a )
					+ fwd * Game.Random.Float( 0.35f, 1.1f )).Normal );

				_phase.Add( Game.Random.Float( 0f, 100f ) );
			}

			if ( Light )
			{
				_light = Components.Create<PointLight>();
				_light.Radius = 320f;
				_light.Shadows = false;
			}
		}

		Strand Make( float w )
		{
			var line = Components.Create<LineRenderer>();
			line.UseVectorPoints = true;
			line.Additive = true;
			line.Lighting = false;
			line.CastShadows = false;
			line.Width = MathF.Max( 0.05f, w );

			return new Strand { Line = line };
		}

		/// <summary>
		/// The colour at a point in a layer's own life: hot blue-white, cooling to the full azure.
		/// </summary>
		///
		/// ⚠️ ONE RAMP SHARED BY EVERY LAYER, so the whole discharge cools together. A core that
		/// whitened while the rings blued would read as two effects overlapping rather than one
		/// thing happening.
		///
		/// ⚠️ THE ×1.8 GETS IT TO FULL BLUE BY BARELY HALFWAY, which is the change that made the
		/// effect read as blue at all. At ×1.25 it was still interpolating when it faded out, so
		/// most of every layer's visible life was spent in the pale middle of the ramp and the
		/// saturated end was only ever reached by frames too dim to see.
		static Color Ramp( float p, float alpha )
			=> Color.Lerp( Core, Tint, MathX.Clamp( p * 1.8f, 0f, 1f ) )
				.WithAlpha( MathX.Clamp( alpha, 0f, 1f ) );

		protected override void OnUpdate()
		{
			var life = MathF.Max( 0.02f, FlashSeconds );
			var age = Time.Now - _born;

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

			var pos = WorldPosition;
			var fwd = WorldRotation.Forward;
			var up = WorldRotation.Up;
			var right = WorldRotation.Right;

			DrawCore( age, pos, fwd, up, right );
			DrawRings( age, pos, fwd, up, right );
			DrawFilaments( age, pos, fwd, up, right );

			// ── the light: bright, and over before the rings are ──────────
			//
			// ⚠️ SQUARED FALLOFF RATHER THAN LINEAR. A muzzle flash lighting the room is a spike,
			// not a fade; a linear ramp at this length reads as a torch being waved.
			if ( _light.IsValid() )
			{
				var lp = MathX.Clamp( age / MathF.Max( 0.01f, CoreLife * 2f ), 0f, 1f );
				_light.LightColor = Ramp( lp, 1f ) * (14f * (1f - lp) * (1f - lp));
			}
		}

		// ── the core: two crossed arms, near-white, gone first ────────────
		//
		// ⚠️ A CROSS, NOT A STAR. Two arms read as a flare; six read as a cartoon sparkle, which
		// is what this looked like before. The arms are different lengths so it is a flare rather
		// than a plus sign.
		void DrawCore( float age, Vector3 pos, Vector3 fwd, Vector3 up, Vector3 right )
		{
			var p = MathX.Clamp( age / MathF.Max( 0.005f, CoreLife ), 0f, 1f );
			var a = 1f - p;
			var reach = CoreSize * (0.5f + 0.5f * p);

			Arm( _coreA, pos, fwd, up, reach, Ramp( p * 0.5f, a ) );
			Arm( _coreB, pos, fwd, right, reach * 0.62f, Ramp( p * 0.5f, a * 0.8f ) );
		}

		/// <summary>One arm of the core cross — out both ways, swept slightly forward.</summary>
		static void Arm( Strand s, Vector3 pos, Vector3 fwd, Vector3 axis, float reach, Color c )
		{
			s.Pts.Clear();
			s.Pts.Add( pos - axis * reach + fwd * (reach * 0.25f) );
			s.Pts.Add( pos + fwd * 1.5f );
			s.Pts.Add( pos + axis * reach + fwd * (reach * 0.25f) );
			s.Push( c );
		}

		// ── the rings: a train, expanding and drifting forward ────────────
		void DrawRings( float age, Vector3 pos, Vector3 fwd, Vector3 up, Vector3 right )
		{
			var n = Math.Max( 6, RingSegments );

			for ( var i = 0; i < _rings.Count; i++ )
			{
				var s = _rings[i];
				var p = (age - i * MathF.Max( 0f, RingStagger )) / MathF.Max( 0.01f, RingLife );

				// ⛔ NOT YET BORN IS NOT THE SAME AS FINISHED, and both have to draw nothing. A
				// ring that renders its first frame before its turn makes the stagger invisible,
				// which is the one thing holding the train together.
				if ( p < 0f || p > 1f ) { s.Line.Enabled = false; continue; }

				s.Line.Enabled = true;

				// eased out — a pulse leaves fast and coasts, the curve `ShockRing` already uses
				var e = 1f - MathF.Pow( 1f - p, 3f );

				var radius = MathX.Lerp( 1.5f, MathF.Max( 2f, RingEnd ), e );
				var centre = pos + fwd * (RingTravel * e);

				// ⚠️ LATER RINGS ARE DIMMER, so the train has a direction in brightness as well as
				// in space. Without it the third ring is as loud as the first and the set reads as
				// a pattern rather than as something dying away.
				var fade = (1f - p) * (1f - i * 0.22f);

				s.Pts.Clear();

				for ( var k = 0; k <= n; k++ )
				{
					var ang = k / (float)n * MathF.Tau;
					s.Pts.Add( centre + (up * MathF.Cos( ang ) + right * MathF.Sin( ang )) * radius );
				}

				s.Push( Ramp( p, fade ) );
				s.Line.Width = MathF.Max( 0.01f, RingWidth * (0.35f + 0.65f * (1f - p)) );
			}
		}

		// ── the filaments: short, jagged, flickering ──────────────────────
		void DrawFilaments( float age, Vector3 pos, Vector3 fwd, Vector3 up, Vector3 right )
		{
			var p = MathX.Clamp( age / MathF.Max( 0.01f, FilamentLife ), 0f, 1f );

			for ( var i = 0; i < _fils.Count; i++ )
			{
				var s = _fils[i];

				if ( p >= 1f ) { s.Line.Enabled = false; continue; }

				s.Line.Enabled = true;

				var reach = FilamentLength * (0.55f + 0.45f * p);
				var d = _dir[i];

				// ⚠️ A DETERMINISTIC WOBBLE RATHER THAN `Game.Random`. The joints have to move
				// every frame to crackle, and that is three RNG draws per filament per frame for
				// a sixtieth of a second — sines of a per-filament phase and the clock give the
				// same look for arithmetic, and cost nothing when five become twenty.
				var t = Time.Now * 46f + _phase[i];

				s.Pts.Clear();
				s.Pts.Add( pos + fwd * 1.5f );

				for ( var k = 1; k <= 3; k++ )
				{
					var f = k / 3f;

					// ⚠️ THE WOBBLE SHRINKS TOWARD THE TIP AND OVER TIME. A filament anchored at
					// the muzzle and loose at the end would flail; tapering the other way keeps
					// the root crackling and the tip pointing where it set off.
					var wob = FilamentJitter * (1f - f) * (1f - p);

					s.Pts.Add( pos + d * (reach * f)
						+ up * (MathF.Sin( t + k * 2.1f ) * wob)
						+ right * (MathF.Cos( t * 1.31f + k * 3.7f ) * wob) );
				}

				// ⚠️ THIS ONE GETS A REAL GRADIENT rather than `Push`'s flat colour — hot at the
				// root where it leaves the barrel, almost gone at the tip, which is what sells it
				// as arcing off the muzzle rather than as a drawn line that happens to be bent.
				s.Line.VectorPoints = s.Pts;
				s.Line.Color = new Gradient(
					new Gradient.ColorFrame( 0f, Ramp( p * 0.4f, 1f - p ) ),
					new Gradient.ColorFrame( 1f, Tint.WithAlpha( (1f - p) * 0.15f ) ) );
			}
		}
	}

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

	/// <summary>`nz_prisma_fx` — the resolved look.</summary>
	[ConCmd( "nz_prisma_fx" )]
	public static void Report()
	{
		Log.Info( $"[nz-prisma] pulse {Speed:0}u/s · tail {Tail:0}u · width {Width:0.##}" );

		Log.Info( $"[nz-prisma]   blue ({Tint.r:0.00}, {Tint.g:0.00}, {Tint.b:0.00})"
			+ $" · core ({Core.r:0.00}, {Core.g:0.00}, {Core.b:0.00})" );

		Log.Info( $"[nz-prisma]   discharge {FlashSeconds:0.###}s · core {CoreLife:0.###}s"
			+ $" ×{CoreSize:0.#}u · light {(Light ? "on" : "off")}" );

		Log.Info( $"[nz-prisma]   {Rings} ring(s) every {RingStagger:0.###}s"
			+ $" · {RingLife:0.###}s out to {RingEnd:0.#}u, drifting {RingTravel:0.#}u"
			+ $" · {RingSegments} seg" );

		Log.Info( $"[nz-prisma]   {Filaments} filament(s) {FilamentLife:0.###}s"
			+ $" · {FilamentLength:0.#}u, wobble {FilamentJitter:0.##}u" );

		Log.Info( $"[nz-prisma]   stroke widths (world u) — ring {RingWidth:0.###}"
			+ $" · core {CoreWidth:0.###} · filament {FilamentWidth:0.###}" );
	}

	/// <summary>`nz_prisma_fx_set &lt;key&gt; &lt;value&gt;`.</summary>
	[ConCmd( "nz_prisma_fx_set" )]
	public static void SetCmd( string key = "", float value = 0f )
	{
		switch ( key.ToLowerInvariant() )
		{
			case "speed": Speed = value; break;
			case "tail": Tail = value; break;
			case "width": Width = value; break;
			case "light": Light = value > 0.5f; break;

			case "flashseconds": FlashSeconds = value; break;
			case "rings": Rings = (int)value; break;
			case "ringstagger": RingStagger = value; break;
			case "ringlife": RingLife = value; break;
			case "ringend": RingEnd = value; break;
			case "ringtravel": RingTravel = value; break;
			case "ringsegments": RingSegments = (int)value; break;
			case "ringwidth": RingWidth = value; break;
			case "corewidth": CoreWidth = value; break;
			case "filamentwidth": FilamentWidth = value; break;

			case "filaments": Filaments = (int)value; break;
			case "filamentlife": FilamentLife = value; break;
			case "filamentlength": FilamentLength = value; break;
			case "filamentjitter": FilamentJitter = value; break;

			case "corelife": CoreLife = value; break;
			case "coresize": CoreSize = value; break;

			default:
				Log.Info( "[nz-prisma] nz_prisma_fx_set <speed|tail|width|light"
					+ "|flashseconds|rings|ringstagger|ringlife|ringend|ringtravel|ringsegments"
					+ "|ringwidth|corewidth|filamentwidth"
					+ "|filaments|filamentlife|filamentlength|filamentjitter"
					+ "|corelife|coresize> <value>" );
				return;
		}

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

	/// <summary>
	/// `nz_prisma_fx_blue &lt;r&gt; &lt;g&gt; &lt;b&gt;` — retune the blue without a recompile.
	/// </summary>
	///
	/// ⚠️ IT TAKES 0–1 OR 0–255 AND WORKS OUT WHICH. Everything in this file is authored in
	/// 0–1 floats, but a colour picked out of an image editor comes in bytes, and mistyping the
	/// scale silently gives you white (all three clamped to 1) which looks like the command did
	/// nothing rather than like it took the wrong units.
	///
	/// ⚠️ `core` AS THE FOURTH ARGUMENT SETS THE HOT CENTRE INSTEAD. Setting the blue alone and
	/// leaving a near-white core is how the effect went grey in the first place.
	[ConCmd( "nz_prisma_fx_blue" )]
	public static void BlueCmd( float r = -1f, float g = -1f, float b = -1f, string which = "" )
	{
		if ( r < 0f || g < 0f || b < 0f )
		{
			Log.Info( "[nz-prisma] nz_prisma_fx_blue <r> <g> <b> [core]  — 0-1 or 0-255" );
			Report();
			return;
		}

		if ( r > 1f || g > 1f || b > 1f ) { r /= 255f; g /= 255f; b /= 255f; }

		var c = new Color( MathX.Clamp( r, 0f, 1f ),
			MathX.Clamp( g, 0f, 1f ), MathX.Clamp( b, 0f, 1f ) );

		if ( which.Equals( "core", StringComparison.OrdinalIgnoreCase ) ) Core = c;
		else Tint = c;

		Report();
	}

	/// <summary>
	/// `nz_prisma_fx_test` — one pulse straight ahead, and a discharge where it leaves.
	/// </summary>
	[ConCmd( "nz_prisma_fx_test" )]
	public static void TestCmd( float distance = 900f )
	{
		var p = NZPlayer.Local;
		if ( !p.IsValid() ) { Log.Warning( "[nz-prisma] no player" ); return; }

		var eye = p.WorldPosition + Vector3.Up * 60f;
		var rot = Game.ActiveScene.Camera.IsValid()
			? Game.ActiveScene.Camera.WorldRotation
			: p.WorldRotation;

		Flash( eye + rot.Forward * 20f, rot );
		Pulse( eye + rot.Forward * 20f, eye + rot.Forward * distance );

		Log.Info( $"[nz-prisma] test pulse {distance:0}u" );
	}
}