Effects/SonicWave.cs

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.

NetworkingFile Access
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.##}" );
	}
}