Effects/ShockRing.cs

A visual effect component that spawns an expanding ring (LineRenderer) on the ground to represent a shockwave. It traces the floor per-segment (optional), eases radius over time, fades/thins the line, and self-destroys when finished. Includes static Fire/FireShared helpers and console commands for diagnostics and tuning.

NetworkingFile Access
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;

namespace NZombies;

/// <summary>
/// An expanding ring of light that runs along the floor — a shockwave, seen from above.
///
/// ⛔ THIS IS THE ONLY LAYER OF THUNDERWALL'S ORIGINAL VISUAL THAT PORTS CHEAPLY, and it is the one
/// that carries the read. `perks_aat_thunderwall.pcf` holds four child systems — wave, energy, warp
/// and dust. The wave is a ring, and a ring is a `LineRenderer` with an animated radius: no
/// textures, no shader, nothing that has to be extracted first.
///
/// ⚠️ AND UPSTREAM CURRENTLY DRAWS NONE OF THEM. Phase 7 replaced Thunderwall's TFA-only cone with a
/// plain sphere and dropped the particle with it, leaving `bo3_aat_thunderwall` precached but never
/// spawned. So this is not catching up to the live GMod version — it is ahead of it.
///
/// ⚠️ A GENERAL PRIMITIVE, NOT A THUNDERWALL DETAIL. PhD Flopper's fall blast and the grenade both
/// draw an explosion with no ground tell at all, and both would read better with one. Written here
/// rather than inside `Thunderwall` so neither has to copy it.
/// </summary>
public sealed class ShockRing : Component
{
	// ══ tuning ═══════════════════════════════════════════════════════════════
	//
	// ⛔ NULLABLE-BACKED GETTERS — a static's VALUE survives a hotload but its initialiser does not
	// re-run. INSTRUCTIONS.md §1.

	static float? _seconds;
	/// <summary>How long the ring takes to reach full radius and fade. 0.85s.</summary>
	public static float Seconds { get => _seconds ?? 0.85f; set => _seconds = value; }

	static int? _segments;
	/// <summary>
	/// How many points the ring is drawn with. 32.
	///
	/// ⛔ THIS IS ALSO THE PER-FRAME TRACE COUNT, WHICH IS THE REAL COST OF THIS CLASS. The ring
	/// changes radius every frame, so unlike `PitVisual`'s static ring it cannot trace once at
	/// spawn — every point has to find the floor again as it moves outward. 32 traces a frame for
	/// under a second, on a mod with a 1-second cooldown, is the deliberate trade; dropping to 16
	/// halves it and is barely visible at 600u.
	/// </summary>
	public static int Segments { get => _segments ?? 32; set => _segments = value; }

	static float? _width;
	/// <summary>
	/// Line thickness at its brightest. 3.
	///
	/// ⚠️ `LineRenderer.Width` IS A `Curve`, AND ITS UNITS ARE NOT WORLD UNITS. `LightningArc`
	/// learned this the expensive way: 1.4 there drew a band about a foot across. Assume this needs
	/// tuning by eye rather than by arithmetic.
	/// </summary>
	public static float Width { get => _width ?? 3f; set => _width = value; }

	static bool? _trace;
	/// <summary>
	/// Follow the floor. On.
	///
	/// ⚠️ THE SWITCH EXISTS SO THE COST CAN BE TURNED OFF, not because a flat ring is correct. With
	/// it off the ring is a flat circle at the origin's height, which is fine on open ground and
	/// wrong on stairs — and turning it off is the first thing to try if a 600u ring ever costs
	/// something measurable.
	/// </summary>
	public static bool TraceFloor { get => _trace ?? true; set => _trace = value; }

	// ══ per-ring state ═══════════════════════════════════════════════════════

	/// <summary>Radius the ring grows to.</summary>
	public float Radius { get; set; } = 600f;

	/// <summary>Ring colour.</summary>
	public Color Colour { get; set; } = new Color( 0.75f, 0.93f, 1f );

	LineRenderer _line;
	Vector3 _at;
	float _born;

	/// <summary>
	/// Fire a ring outward from a point.
	///
	/// ⚠️ ITS OWN GAMEOBJECT, and it destroys itself. Nothing has to remember it, which is what lets
	/// a one-shot effect be fired from a static method with no bookkeeping.
	/// </summary>
	public static ShockRing Fire( Vector3 at, float radius, Color? colour = null )
	{
		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) return null;

		var go = scene.CreateObject();
		go.Name = "nz_shock_ring";

		// ⚠️ THE CENTRE IS SNAPPED TO THE FLOOR, reusing `PitVisual.GroundAt` rather than
		// repeating the trace. That method already handles the two things this gets wrong on its
		// own: tracing from above so it does not start solid, and ignoring bodies so it does not
		// land on the corpse that caused the blast.
		go.WorldPosition = PitVisual.GroundAt( at );

		var r = go.Components.Create<ShockRing>();

		r.Radius = MathF.Max( 8f, radius );
		r.Colour = colour ?? r.Colour;

		return r;
	}

	/// <summary>
	/// Fire a ring here AND on every other machine. For rings whose cause is host-only.
	/// </summary>
	///
	/// ⛔ `Fire` IS LOCAL AND ALWAYS HAS BEEN. It is `scene.CreateObject()` with no announcement,
	/// so a ring set off by something only the host simulates — a boss, above all — has never
	/// existed on any other screen. ALL VISUAL EFFECTS ARE GLOBAL; a 600-unit shockwave that only
	/// the host can see is the clearest possible breach of that.
	///
	/// ⚠️ THE SAME SHAPE AS `NZSound.PlayShared`, deliberately: draw locally either way, and
	/// announce only when we are the host. The receiving side skips the host so it cannot double.
	///
	/// ⚠️ FOR HOST-ONLY CAUSES ONLY. A ring both machines already draw for themselves — a
	/// player's own Thunderwall — would appear twice if it came through here, which is why
	/// `Thunderwall` and `TeleportPortal` still call `Fire` directly.
	public static ShockRing FireShared( Vector3 at, float radius, Color? colour = null )
	{
		if ( Networking.IsActive && NZGame.IsHost )
		{
			var c = colour ?? new Color( 0.75f, 0.93f, 1f );

			// ⚠️ THREE FLOATS RATHER THAN A `Color`. Nothing in `NZNet` sends one yet, and an RPC
			// argument that fails to serialise fails at RUNTIME with the effect simply missing on
			// the far side — indistinguishable from the bug this method exists to fix.
			NZNet.ShockRingFx( at, radius, c.r, c.g, c.b );
		}

		return Fire( at, radius, colour );
	}

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

		_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 );

		Rebuild( 0f );
	}

	/// <summary>
	/// Lay the ring out at a given fraction of its full radius.
	///
	/// ⚠️ THE POINTS ARE LIFTED 2 UNITS, the same as `PitVisual`'s ring and for the same reason:
	/// a line exactly on a surface z-fights with it and flickers.
	///
	/// ⚠️ AND THE LIST IS CLOSED — the last point repeats the first. A `LineRenderer` draws a
	/// polyline, not a loop, so without that the ring has a visible gap at zero degrees.
	/// </summary>
	void Rebuild( float f )
	{
		if ( !_line.IsValid() ) return;

		var n = Math.Max( 8, Segments );
		var r = Radius * f;
		var pts = new List<Vector3>( n + 1 );

		for ( var i = 0; i <= n; i++ )
		{
			var a = i / (float)n * MathF.PI * 2f;
			var p = _at + new Vector3( MathF.Cos( a ), MathF.Sin( a ), 0f ) * r;

			pts.Add( (TraceFloor ? PitVisual.GroundAt( p ) : p) + Vector3.Up * 2f );
		}

		_line.VectorPoints = pts;
	}

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

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

		var t = age / life;

		// ⚠️ EASED OUT, NOT LINEAR, and this is what makes it read as a shockwave rather than a
		// growing circle. A blast leaves fast and coasts; a cubic ease-out does exactly that, and a
		// linear expansion looks like an animation playing.
		var f = 1f - MathF.Pow( 1f - t, 3f );

		Rebuild( f );

		// ⚠️ THE FADE AND THE THINNING RUN TOGETHER. The line starts thick and bright and ends
		// hairline and gone; holding the width constant makes the tail look like a drawn circle.
		var a = 1f - t;

		_line.Color = Colour.WithAlpha( a );
		_line.Width = MathF.Max( 0.05f, Width * (0.25f + 0.75f * a) );
	}

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

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

		Log.Info( $"[nz-fx] SHOCK RING · {live} live · {Seconds:0.##}s"
			+ $" · {Segments} segment(s) · width {Width:0.##}"
			+ $" · floor trace {(TraceFloor ? "on" : "off")}" );

		Log.Info( $"[nz-fx]   {(TraceFloor ? Segments : 0)} trace(s) per frame while one is running" );
	}

	/// <summary>
	/// `nz_shockring_test [radius]` — fire one at your feet.
	///
	/// ⚠️ IT EXISTS BECAUSE THE REAL TRIGGER IS A 5% ROLL. Waiting for Thunderwall to proc is not a
	/// way to tune a ring's width.
	/// </summary>
	[ConCmd( "nz_shockring_test" )]
	public static void TestCmd( float radius = 600f )
	{
		var p = NZPlayer.Local;
		if ( !p.IsValid() ) { Log.Warning( "[nz-fx] no player" ); return; }

		Fire( p.WorldPosition, radius );
		Log.Info( $"[nz-fx] test ring {radius:0}u over {Seconds:0.##}s" );
	}

	/// <summary>`nz_shockring_set &lt;key&gt; &lt;value&gt;` — retune the ring.</summary>
	[ConCmd( "nz_shockring_set" )]
	public static void SetCmd( string key = "", float value = 0f )
	{
		switch ( key.ToLowerInvariant() )
		{
			case "seconds": Seconds = value; break;
			case "segments": Segments = (int)value; break;
			case "width": Width = value; break;
			case "trace": TraceFloor = value > 0.5f; break;

			default:
				Log.Info( "[nz-fx] nz_shockring_set <seconds|segments|width|trace> <value>" );
				return;
		}

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