Effects/LightningArc.cs

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.

File Access
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 &lt;key&gt; &lt;value&gt;` — 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" );
	}
}