A game component that implements a moving upright ring-shaped sonic wave projectile fired by a Shrieker enemy. It spawns locally on every machine, moves along a direction, checks swept collisions against players and world geometry, applies a daze effect to hit players, renders as a LineRenderer ring, and provides console commands to test and tune parameters.
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
namespace NZombies;
/// <summary>
/// THE SHRIEKER'S SCREAM, AS A THING THAT TRAVELS.
///
/// ⛔ A PROJECTILE, NOT AN AREA EFFECT, AND THE DIFFERENCE IS THE WHOLE FIGHT. An instant sphere
/// centred on the zombie cannot be avoided once it starts — the only input it accepts is where you
/// already were. A wave that leaves the Shrieker and crosses the room in about a third of a second
/// can be stepped out of, put a pillar in front of, or eaten on purpose to keep moving toward it.
/// That turns the scream from a tax into a thing you play against.
///
/// ⛔ AND IT IS STOPPED BY GEOMETRY. A wave that passed through walls would make cover meaningless
/// against the one enemy whose entire threat is at range, which is exactly backwards.
///
/// ⚠️ NOT `ShockRing`, AND THE REASON IS ITS FIRST LINE: *"an expanding ring of light that runs
/// along the floor — a shockwave, seen from above."* It snaps every point to the ground and grows
/// in place. This ring stands upright, faces where it is going, and MOVES. The shared part is
/// "build a ring out of a LineRenderer", which is a dozen lines; bolting a second orientation and a
/// travel mode onto a primitive whose identity is "seen from above" would cost more than it saves.
///
/// ⚠️ BROADCAST, AND EVERY MACHINE RUNS ITS OWN. The Shrieker's AI is host-only, so the host is the
/// only one that can decide a scream happened — but the wave itself is deterministic (a straight
/// line, a fixed speed), so once told, each machine reaches the same verdict about its own local
/// player. That is better than host authority here: the thing the hit applies is a MOVEMENT
/// modifier, and movement belongs to the client doing the moving.
/// </summary>
public sealed class SonicWave : Component
{
// ══ tuning ═══════════════════════════════════════════════════════════════
//
// ⛔ NULLABLE-BACKED GETTERS — a static's VALUE survives a hotload but its initialiser does not
// re-run, so a plain `= 850f` can never be corrected in a live session. INSTRUCTIONS.md §1.
static float? _speed;
/// <summary>
/// How fast the wave crosses the room, u/s.
///
/// ⚠️ 850 IS DODGEABLE AND ONLY JUST. Across the Shrieker's 325-unit reach that is under four
/// tenths of a second — enough to react to the wind-up you already heard, not enough to react
/// to the wave itself. The window the player is really being given is the charge cue, and this
/// number is what stops the wave from being a second, easier window.
/// </summary>
public static float Speed { get => _speed ?? 850f; set => _speed = value; }
static float? _hit;
/// <summary>How close the wave's centre must pass to a player to catch them.</summary>
public static float HitRadius { get => _hit ?? 52f; set => _hit = value; }
static float? _range;
/// <summary>How far it travels before giving up.</summary>
public static float Range { get => _range ?? 1200f; set => _range = value; }
static float? _ring;
/// <summary>Radius the ring grows to by the end of its flight.</summary>
public static float RingRadius { get => _ring ?? 46f; set => _ring = value; }
static int? _segments;
public static int Segments { get => _segments ?? 28; set => _segments = value; }
static float? _width;
public static float Width { get => _width ?? 2.5f; set => _width = value; }
static Color? _colour;
/// <summary>Cold blue-white — the same family as the daze overlay, and away from the napalm.</summary>
public static Color Colour { get => _colour ?? new Color( 0.62f, 0.82f, 1f ); set => _colour = value; }
static float? _seconds;
/// <summary>How long a dazed player stays slowed and blurred.</summary>
public static float DazeSeconds { get => _seconds ?? 4f; set => _seconds = value; }
static float? _slow;
/// <summary>What a dazed player's speed is multiplied by.</summary>
public static float DazeSlow { get => _slow ?? 0.35f; set => _slow = value; }
Vector3 _dir;
Vector3 _from;
float _travelled;
LineRenderer _line;
/// <summary>
/// Fire a wave from one point toward another, on every machine.
///
/// ⚠️ THE HOST BROADCASTS AND THEN FALLS THROUGH TO SPAWN ITS OWN — the RPC does not come back
/// to the sender, so a `return` after it would leave the host the only machine without the
/// wave that it fired.
/// </summary>
public static void Fire( Vector3 from, Vector3 to )
{
if ( Networking.IsActive && NZGame.IsHost )
NZNet.SonicWave( from, to );
Spawn( from, to );
}
/// <summary>Build one locally. Called directly by the RPC on every other machine.</summary>
public static SonicWave Spawn( Vector3 from, Vector3 to )
{
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) return null;
var dir = (to - from);
if ( dir.Length < 1f ) return null;
var go = scene.CreateObject();
go.Name = "nz_sonic_wave";
go.WorldPosition = from;
go.Flags |= GameObjectFlags.NotSaved;
// ⚠️ LOCAL TO EACH MACHINE, because every machine was told to make one. A networked object
// here would mean the host's copy replicating on top of the copies the clients just built.
go.NetworkMode = NetworkMode.Never;
var w = go.Components.Create<SonicWave>();
w._from = from;
w._dir = dir.Normal;
return w;
}
protected override void OnStart()
{
_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();
}
/// <summary>
/// Lay the ring out standing upright, facing the way it is going.
///
/// ⚠️ 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. Same trap `ShockRing` documents.
/// </summary>
void Rebuild()
{
if ( !_line.IsValid() ) return;
// ⚠️ A BASIS BUILT FROM THE TRAVEL DIRECTION, not from world axes. A ring drawn in the XY
// plane would face the sky the moment the Shrieker screams up or down a staircase.
var right = Vector3.Cross( _dir, Vector3.Up );
if ( right.Length < 0.01f ) right = Vector3.Cross( _dir, Vector3.Forward );
right = right.Normal;
var up = Vector3.Cross( right, _dir ).Normal;
var t = ( _travelled / MathF.Max( 1f, Range ) ).Clamp( 0f, 1f );
var r = MathF.Max( 4f, RingRadius * ( 0.35f + 0.65f * t ) );
var n = Math.Max( 8, Segments );
var pts = new List<Vector3>( n + 1 );
var at = WorldPosition;
for ( var i = 0; i <= n; i++ )
{
var a = i / (float)n * MathF.PI * 2f;
pts.Add( at + ( right * MathF.Cos( a ) + up * MathF.Sin( a ) ) * r );
}
_line.VectorPoints = pts;
}
protected override void OnUpdate()
{
var step = MathF.Max( 1f, Speed ) * Time.Delta;
var prev = WorldPosition;
var next = prev + _dir * step;
// ⛔ SWEPT, NOT SAMPLED. At 850 u/s a frame is ~14 units and a player capsule is ~32 across,
// so a point test would usually work and occasionally tunnel straight through somebody — the
// worst kind of bug, because it looks like the wave "missed" and nobody can reproduce it.
foreach ( var p in Scene.GetAllComponents<NZPlayer>().ToList() )
{
if ( !p.IsValid() ) continue;
var body = p.WorldPosition + Vector3.Up * 36f;
if ( DistanceToSegment( body, prev, next ) > HitRadius ) continue;
SonicDaze.Apply( p, DazeSeconds, DazeSlow );
if ( p == NZPlayer.Local )
Log.Info( $"[nz-sonic] wave caught you — {DazeSeconds:0.#}s at x{DazeSlow:0.##}"
+ " speed, vision blurred" );
Burst();
return;
}
// ⛔ STOPPED BY THE WORLD. `WithoutTags( "player", "zombie" )` so it is not blocked by the
// thing it was aimed at or by the crowd it is flying through — only by geometry, which is
// the one thing that should be able to stop it.
var tr = Scene.Trace.Ray( prev, next )
.WithoutTags( "player", "zombie", "trigger" )
.Radius( 4f )
.Run();
if ( tr.Hit )
{
WorldPosition = tr.HitPosition;
Burst();
return;
}
WorldPosition = next;
_travelled += step;
if ( _travelled >= Range )
{
Burst();
return;
}
Rebuild();
// Fades as it goes, so a wave that reaches nobody dies visually rather than blinking out.
var a = 1f - ( _travelled / MathF.Max( 1f, Range ) ).Clamp( 0f, 1f );
_line.Color = Colour.WithAlpha( 0.35f + 0.65f * a );
}
/// <summary>End it. Kept as one call so every exit looks the same.</summary>
void Burst() => GameObject.Destroy();
/// <summary>
/// Shortest distance from a point to the segment the wave crossed this frame.
///
/// ⚠️ TO THE SEGMENT, NOT TO EITHER END. Measuring to the end points is the same bug as a point
/// test: a player standing exactly halfway along a frame's step is closest to neither.
/// </summary>
static float DistanceToSegment( Vector3 p, Vector3 a, Vector3 b )
{
var ab = b - a;
var len2 = ab.LengthSquared;
if ( len2 < 0.0001f ) return p.Distance( a );
var t = Vector3.Dot( p - a, ab ) / len2;
return p.Distance( a + ab * t.Clamp( 0f, 1f ) );
}
// ── commands ─────────────────────────────────────────────────────────────
/// <summary>`nz_sonic_wave` — fire one at yourself from 400 units away.</summary>
[ConCmd( "nz_sonic_wave" )]
public static void TestCmd( float from = 400f )
{
var p = NZPlayer.Local;
if ( !p.IsValid() ) { Log.Warning( "[nz-sonic] no local player" ); return; }
var eye = p.WorldPosition + Vector3.Up * 36f;
// ⚠️ FIRED FROM IN FRONT OF YOU so it arrives where you are looking. Behind would be a more
// honest test of the hit detection and a useless one for judging how it looks.
var start = eye + p.WorldRotation.Forward * from;
Fire( start, eye );
Log.Info( $"[nz-sonic] wave fired from {from:0}u — {Speed:0} u/s, {HitRadius:0}u hit radius" );
}
/// <summary>`nz_sonic_tune [speed] [hit] [daze] [slowto]` — 0 leaves a field alone.</summary>
[ConCmd( "nz_sonic_tune" )]
public static void Tune( float speed = 0f, float hit = 0f, float daze = 0f, float slowTo = 0f )
{
if ( speed > 0f ) Speed = speed;
if ( hit > 0f ) HitRadius = hit;
if ( daze > 0f ) DazeSeconds = daze;
if ( slowTo > 0f ) DazeSlow = slowTo;
Log.Info( $"[nz-sonic] {Speed:0} u/s · {HitRadius:0}u hit · {Range:0}u range"
+ $" · daze {DazeSeconds:0.#}s at x{DazeSlow:0.##}" );
}
}