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.
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 <key> <value>` — 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();
}
}