A component that renders an animated jagged lightning arc between two endpoints using a LineRenderer with vector points. It tracks endpoint GameObjects (falling back to saved positions), periodically rebuilds jittered points, and provides console commands to inspect and retune parameters and spawn a test arc.
using Sandbox;
using System;
using System.Collections.Generic;
namespace NZombies;
/// <summary>
/// A jagged, animated bolt of electricity between two things.
///
/// ⛔ IT REPLACED A STRAIGHT TRACER, AND THE TRACER WAS THE WRONG PRIMITIVE. Dead Wire's arc was
/// first drawn with `ColourTracer` — a single particle fired along the segment. That reads as a
/// bullet, not a current: it has one straight path, it travels, and it is gone. A chain wants a
/// STRING that stays up while the chain lives and writhes in place.
///
/// ⚠️ BUILT ON `LineRenderer` WITH `UseVectorPoints`, so the whole shape is driven from code with no
/// child GameObjects per node. The alternative — an object per segment point, which `Points` wants —
/// would mean creating and destroying ten objects per bolt per rebuild, twenty times a second.
///
/// ⚠️ THE ENDPOINTS TRACK GAMEOBJECTS, NOT POSITIONS. Both zombies are alive and walking while the
/// bolt is up; pinned to the positions they occupied when the hop fired, the string would visibly
/// detach from both ends within a stride. Positions are the fallback for the first arc, which comes
/// from the shooter.
/// </summary>
public sealed class LightningArc : Component
{
// ══ tuning ═══════════════════════════════════════════════════════════════
//
// ⛔ NULLABLE-BACKED GETTERS — a static's VALUE survives a hotload but its initialiser does not
// re-run. INSTRUCTIONS.md §1.
static int? _segments;
/// <summary>
/// How many pieces the bolt is broken into. 12.
///
/// ⚠️ THE JAGGEDNESS IS SEGMENT COUNT AND OFFSET TOGETHER. Few segments with a big offset is a
/// zigzag; many with a small one is a fuzzy rope. Twelve over a 200u hop is roughly one kink
/// every 16 units, which reads as electricity at the distance you fight at.
/// </summary>
public static int Segments { get => _segments ?? 12; set => _segments = value; }
static float? _jitter;
/// <summary>Peak sideways offset at the middle of the bolt, units. 10.</summary>
public static float Jitter { get => _jitter ?? 10f; set => _jitter = value; }
static float? _rebuildHz;
/// <summary>
/// How many times a second the bolt re-kinks. 24.
///
/// ⛔ NOT PER FRAME, DELIBERATELY. Re-jittering every frame ties the animation speed to the
/// frame rate — the same bolt crawls at 30fps and strobes at 200. A fixed rate looks identical
/// on any machine, which is the whole reason this is a number and not `OnUpdate` doing it
/// unconditionally.
/// </summary>
public static float RebuildHz { get => _rebuildHz ?? 24f; set => _rebuildHz = value; }
static float? _life;
/// <summary>
/// Seconds a bolt stays up. 0.9.
///
/// ⚠️ LONGER THAN THE 0.2s HOP GAP ON PURPOSE, so several arcs overlap and the chain reads as
/// one connected string rather than a single link hopping along. At 0.9s roughly four or five
/// links of a seven-hop chain are lit at once.
/// </summary>
public static float Life { get => _life ?? 0.9f; set => _life = value; }
static float? _width;
/// <summary>
/// Bolt thickness. 0.22.
///
/// ⛔ THE FIRST VALUE WAS 1.4 AND IT WAS A RIBBON, NOT A BOLT. `Width` is a
/// `Curve`, and a float assigned to it becomes a single-key constant — which works, but
/// the units are nothing like a world distance: 1.4 drew a band about a foot across.
/// Tuned by eye down to 0.22, which is the value in the screenshot that was signed
/// off, nudged up one step.
///
/// ⚠️ CHANGING THIS DEFAULT DOES NOT MOVE A LIVE SESSION. `_width` is already set the
/// moment anyone runs `nz_arc_set width`, and a set backing field survives hotload while an
/// initialiser does not — so the console value keeps winning until it is set again.
/// That is the same §1 trap the nullable pattern exists for, seen from the other side.
/// </summary>
public static float Width { get => _width ?? 0.22f; set => _width = value; }
// ══ per-arc state ════════════════════════════════════════════════════════
/// <summary>What the bolt starts at. Falls back to <see cref="FromPos"/> when null or dead.</summary>
public GameObject From { get; set; }
/// <summary>What the bolt ends at. Falls back to <see cref="ToPos"/> when null or dead.</summary>
public GameObject To { get; set; }
public Vector3 FromPos { get; set; }
public Vector3 ToPos { get; set; }
/// <summary>How high up each endpoint to attach, so the bolt runs chest to chest.</summary>
public float Rise { get; set; } = 44f;
LineRenderer _line;
TimeUntil _dies;
TimeUntil _nextKink;
/// <summary>
/// Hang a bolt between two objects.
///
/// ⚠️ ITS OWN GAMEOBJECT, not a child of either end. Parented to a zombie it would inherit that
/// zombie's transform — and be destroyed with it, which for a bolt whose whole job is to
/// connect the thing that just died is exactly backwards.
/// </summary>
public static LightningArc Hang( Vector3 from, Vector3 to,
GameObject fromObj = null, GameObject toObj = null, Color? colour = null )
{
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) return null;
var go = scene.CreateObject();
go.Name = "nz_lightning_arc";
go.WorldPosition = from;
var arc = go.Components.Create<LightningArc>();
arc.From = fromObj;
arc.To = toObj;
arc.FromPos = from;
arc.ToPos = to;
arc._dies = MathF.Max( 0.05f, Life );
arc._nextKink = 0f;
var line = go.Components.Create<LineRenderer>();
// ⚠️ VECTOR POINTS, NOT GAMEOBJECT POINTS. See the class note — the object-per-point mode
// would churn objects at the rebuild rate.
line.UseVectorPoints = true;
line.VectorPoints = new List<Vector3>();
// ⚠️ ADDITIVE AND UNLIT, which is what makes it read as light rather than as a painted
// cable. Lit, the bolt takes the scene's shading and goes grey in shadow — the one place
// electricity should be brightest.
line.Additive = true;
line.Lighting = false;
line.CastShadows = false;
line.Color = colour ?? new Color( 0.55f, 0.8f, 1f );
line.Width = MathF.Max( 0.05f, Width );
arc._line = line;
arc.Rebuild();
return arc;
}
/// <summary>Where an end currently is — the live object if it still exists, else the snapshot.</summary>
Vector3 EndOf( GameObject obj, Vector3 fallback )
=> obj.IsValid() ? obj.WorldPosition + Vector3.Up * Rise : fallback;
/// <summary>
/// Lay out a fresh jagged path between the two ends.
///
/// ⚠️ THE OFFSET IS ZERO AT BOTH ENDS AND PEAKS IN THE MIDDLE. Uniform jitter would leave the
/// bolt visibly detached from the zombies it is supposed to be attached to, which is the one
/// place the shape has to be exact.
///
/// ⚠️ TWO PERPENDICULARS, NOT ONE, so the kinks leave the plane. Offsetting along a single axis
/// makes a flat zigzag that looks like a paper cutout as soon as you walk around it.
///
/// ⛔ THE PERPENDICULAR BASIS CANNOT BE `dir.Cross( Vector3.Up )` ALONE. When the bolt is
/// vertical — a hop onto something directly above or below — that cross product is zero and
/// every offset collapses, giving a perfectly straight line exactly when the effect is most
/// visible. Picking the reference axis away from `dir` avoids it.
/// </summary>
void Rebuild()
{
if ( !_line.IsValid() ) return;
var a = EndOf( From, FromPos );
var b = EndOf( To, ToPos );
var dir = b - a;
var len = dir.Length;
if ( len < 0.1f )
{
_line.VectorPoints = new List<Vector3> { a, b };
return;
}
dir = dir.Normal;
var reference = MathF.Abs( dir.z ) > 0.9f ? Vector3.Forward : Vector3.Up;
var side = dir.Cross( reference ).Normal;
var up = dir.Cross( side ).Normal;
var n = Math.Max( 2, Segments );
var points = new List<Vector3>( n + 1 );
var amp = MathF.Max( 0f, Jitter );
for ( var i = 0; i <= n; i++ )
{
var t = i / (float)n;
var p = a + (b - a) * t;
// A bell that is 0 at t=0 and t=1 and 1 at the middle.
var falloff = MathF.Sin( t * MathF.PI );
p += side * Game.Random.Float( -amp, amp ) * falloff;
p += up * Game.Random.Float( -amp, amp ) * falloff;
points.Add( p );
}
_line.VectorPoints = points;
}
protected override void OnUpdate()
{
if ( _dies )
{
GameObject.Destroy();
return;
}
if ( !_nextKink ) return;
_nextKink = 1f / MathF.Max( 1f, RebuildHz );
Rebuild();
}
// ══ diagnostics ══════════════════════════════════════════════════════════
/// <summary>`nz_arc` — the resolved shape numbers and how many bolts are up.</summary>
[ConCmd( "nz_arc" )]
public static void Report()
{
var live = Game.ActiveScene?.GetAllComponents<LightningArc>().Count() ?? 0;
Log.Info( $"[nz-fx] LIGHTNING ARC · {live} up · {Segments} segment(s)"
+ $" · {Jitter:0.#}u jitter · {RebuildHz:0.#} rebuild/s"
+ $" · {Life:0.##}s life · {Width:0.##} wide" );
}
/// <summary>`nz_arc_set <key> <value>` — retune the bolt live.</summary>
[ConCmd( "nz_arc_set" )]
public static void SetCmd( string key = "", float value = 0f )
{
switch ( key.ToLowerInvariant() )
{
case "segments": Segments = (int)value; break;
case "jitter": Jitter = value; break;
case "hz": RebuildHz = value; break;
case "life": Life = value; break;
case "width": Width = value; break;
default:
Log.Info( "[nz-fx] nz_arc_set <segments|jitter|hz|life|width> <value>" );
return;
}
Log.Info( $"[nz-fx] arc {key} = {value:0.###}" );
Report();
}
/// <summary>
/// `nz_arc_test` — hang one bolt in front of the player so the shape can be tuned without
/// waiting for a Dead Wire proc.
///
/// ⚠️ IT HANGS BETWEEN TWO FIXED POINTS, so what is being judged is the jitter and the
/// animation rather than how well it tracks a walking zombie.
/// </summary>
[ConCmd( "nz_arc_test" )]
public static void TestCmd( float length = 200f )
{
var p = NZPlayer.Local;
if ( !p.IsValid() ) { Log.Warning( "[nz-fx] no player" ); return; }
var eye = p.WorldPosition + Vector3.Up * 44f;
var fwd = p.EyeAngles.Forward;
var a = eye + fwd * 60f;
var b = a + fwd * MathF.Max( 20f, length );
Hang( a, b );
Log.Info( $"[nz-fx] test arc {length:0}u long, {Life:0.##}s" );
}
}