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.
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 <key> <value>`.</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 <r> <g> <b>` — 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" );
}
}