Weapons/Shockwave.cs

Static Shockwave ammo mod logic for NZombies. Computes tuned values and upgrade-modified values, triggers a shockwave that knocks back and stuns nearby zombies, optionally applies Seismic Slam damage, fires visual/audio effects, and routes client requests to the host.

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

namespace NZombies;

/// <summary>
/// Shockwave — every zombie around the one you hit is shoved away and stunned, and the ground shakes.
///
/// | | value |
/// |---|---|
/// | proc | **15%** per hit, **5s** cooldown — IV, Quick Quake: **3s** (`AmmoMods.QuickQuakeCooldown`) |
/// | reach | **250u** around the zombie hit — I, Wide Wave: **350u** |
/// | shove | **110u** at the centre, **50u** at the edge, along the floor |
/// | stun | it reels back, is held **0.4s**, then straightens — **0.8s** in all; II, Dazed: **1.2s**, **1.6s** in all |
/// | damage | **none** — III, Seismic Slam: **300%** of your weapon's damage to each zombie it stuns |
/// | around you | **nothing** — V, Epicenter: the same wave goes off around the SHOOTER too, **350u**, with the same II and III |
///
/// ⚠️ THE USER (2026-10-04): *"use the falling we used for one of the banana colada augments, but backwards / So when it
/// triggers there's a quick tremmor on screen and all zombies in that radius just fall backwards and move backwards a bit"*.
/// The fall IS Banana Colada M1's slick-bar pratfall (`ZombieAI.PlayPratfall`), run backwards with a shove
/// (`ZombieAI.Knockback`). The 0.4s on its back is the user's earlier *"should stagger for 0.4 sec"*.
///
/// ⛔ NO FALL SINCE 2026-10-06 (`Knockback`'s `tip: false`): it reels back a little as it slides, is held there unable to move
/// or swing, then straightens. The user: *"shockwave should no longer make them trip, instead just pushes them back and stuns them"*. The timings are the fall's,
/// so every number below kept its meaning: the hold is the stun. II, Hard Fall, became Dazed.
///
/// ⛔ THE HOST SHOVES AND STUNS THEM; THE SHOOTER ONLY SAYS WHERE (and what its gun deals, for Seismic Slam — below). Zombies think
/// on the host — a pratfall on a client's copy would be overwritten by the next transform update — so a client's proc is sent
/// there (`NZNet.ShockwaveAsk`), and the host shows it to everybody: the ring (`ShockRing.FireShared`), the tremor
/// (`NZNet.ShakeAt`, each screen by its distance) and the thump.
///
/// ⚠️ THE ZOMBIE HIT IS SHOVED AWAY FROM THE SHOOTER; THE REST AWAY FROM IT. Each turns to face where the blow came from,
/// so "backwards" is always away from the blast. At V the ones around the shooter go away from the shooter (Epicenter, `Go`).
///
/// ⚠️ NOT EVERY ZOMBIE CAN GO OVER: a boss doesn't, nor one tearing boards, climbing through a window or crossing a link
/// (`ZombieAI.Knockback` says no), nor one already down. They are simply left standing.
///
/// ⚠️ THE UPGRADES (2026-10-05, `AmmoModUpgrades`; the user, 01:13: *"ok i like it"*). The levels are the SHOOTER'S, read on
/// the host where the zombies go over: its own player's, or a client's from `Rpc.CallerId` (`NZNet.ShockwaveAsk`; levels
/// sync, `NZPlayer.AmmoUpgradeNet`). One helper per number (`RadiusFor`, `TotalFor`, `SlamFor`). The ring travels with its
/// reach (`ShockRing.FireShared`) and each watcher holds the pose for the upgraded time (`PlayPratfall`'s relay).
///
/// ⛔ SEISMIC SLAM'S FIGURE IS THE SHOOTER'S, ITS DAMAGE THE HOST'S. "Your weapon's damage" reads only where the gun is
/// (`AmmoMods.WeaponDamage`) and only the host knows who went over, so the figure rides the ask and the host deals 300% of it
/// ONCE to each zombie `Knockback` took. Bosses stay refused at every level, so it reaches only what it knocks down (the doc).
/// A blast, not a bullet (`TechBlast.BlastTag`): no per-hit points, and no bullet's mod on a zombie it kills
/// (`AmmoMods.HitModOf`). ⚠️ A client's slam carries none of their own perks' terms (`Health.AttackerScale` runs on the
/// shooter's machine) and shows them no damage numbers, as Bleeder's host-side ticks don't.
///
/// ⚠️ TIERS IV AND V (2026-10-06, `AMMO_MODS.md` "Tiers IV and V"; the user, 23:41: *"I like your upgrades"*). IV, Quick Quake, is
/// the cooldown, 3s (5s), and lives with the other upgraded rolls (`AmmoMods.BaseCooldown`, so Time Warp and the rest still cut
/// it). V, Epicenter: every wave ALSO goes off around the SHOOTER — every zombie within 350u of where the shot came from
/// (`EpicenterFor`) is shoved away from them and stunned, with the ring, the tremor and the thump there too. It is the same shove
/// and stun, from you (the doc's reading), so the shooter's Dazed and Seismic Slam hold in both waves. The host does it from the
/// position the ask already carries (`NZNet.ShockwaveAsk`'s `shooter`): nothing new on the wire.
/// </summary>
public static class Shockwave
{
	// ══ tuning ═══════════════════════════════════════════════════════════════
	//
	// ⛔ NULLABLE-BACKED GETTERS — INSTRUCTIONS.md §1.

	static float? _radius;
	/// <summary>How far from the zombie hit the blast reaches. 250u.</summary>
	public static float Radius { get => _radius ?? 250f; set => _radius = value; }

	static float? _pushNear;
	/// <summary>How far a zombie at the centre is shoved. 110u.</summary>
	public static float PushNear { get => _pushNear ?? 110f; set => _pushNear = value; }

	static float? _pushFar;
	/// <summary>How far a zombie at the edge is shoved. 50u.</summary>
	public static float PushFar { get => _pushFar ?? 50f; set => _pushFar = value; }

	static float? _down;
	/// <summary>How long it is held, stunned, after the shove. 0.4s — the user's stagger.</summary>
	public static float Down { get => _down ?? 0.4f; set => _down = value; }

	static float? _rise;
	/// <summary>How long it takes to straighten up. 0.25s.</summary>
	public static float Rise { get => _rise ?? 0.25f; set => _rise = value; }

	static float? _shake;
	/// <summary>The tremor at the blast, before distance takes it down. 0.4 trauma.</summary>
	public static float Shake { get => _shake ?? 0.4f; set => _shake = value; }

	static float? _shakeRange;
	/// <summary>How far away the tremor is still felt. 1500u.</summary>
	public static float ShakeRange { get => _shakeRange ?? 1500f; set => _shakeRange = value; }

	// ══ the upgrades (2026-10-05) ════════════════════════════════════════════
	//
	// ⛔ EACH UPGRADED NUMBER IS ITS OWN TUNABLE BESIDE THE BASE ONE, AND ONE HELPER PICKS BETWEEN THEM (§3). Level 0 reads
	// exactly the numbers above.

	static float? _wideRadius;
	/// <summary>I, WIDE WAVE: how far the blast reaches. 350u (250).</summary>
	public static float WideRadius { get => _wideRadius ?? 350f; set => _wideRadius = value; }

	static float? _hardDown;
	/// <summary>II, DAZED (Hard Fall until 2026-10-06): how long it is held, stunned. 1.2s (0.4s).</summary>
	public static float HardDown { get => _hardDown ?? 1.2f; set => _hardDown = value; }

	static float? _slamShare;
	/// <summary>III, SEISMIC SLAM: what each zombie it stuns takes, as a share of your weapon's damage. 3 — 300%.</summary>
	public static float SlamShare { get => _slamShare ?? 3f; set => _slamShare = value; }

	// ── tiers IV and V (2026-10-06, `AMMO_MODS.md` "Tiers IV and V") ──
	//
	// ⚠️ IV, QUICK QUAKE, IS NOT HERE: a cooldown is a roll, and the rolls' upgrades all live in `AmmoMods.BaseCooldown`.

	static float? _epicenterRadius;
	/// <summary>
	/// V, EPICENTER: how far around the SHOOTER its second wave reaches. 350u — Wide Wave's figure today, but its own number (§3),
	/// so the wave around you can be retuned without the one around the zombie hit.
	/// </summary>
	public static float EpicenterRadius { get => _epicenterRadius ?? 350f; set => _epicenterRadius = value; }

	/// <summary>The mod's id, for its upgrade levels.</summary>
	const string ModId = "shockwave";

	/// <summary>How far this player's blast reaches: Wide Wave's reach at level I.</summary>
	public static float RadiusFor( NZPlayer player )
		=> MathF.Max( 1f, AmmoModUpgrades.Has( player, ModId, 1 ) ? WideRadius : Radius );

	/// <summary>
	/// The whole time this player's blast holds a zombie: the reel, the stun (Dazed's at level II), and the straightening.
	/// </summary>
	public static float TotalFor( NZPlayer player )
		=> ZombieAI.PratfallSeconds + MathF.Max( 0f, AmmoModUpgrades.Has( player, ModId, 2 ) ? HardDown : Down )
			+ MathF.Max( 0f, Rise );

	/// <summary>What Seismic Slam deals each zombie knocked down, from the shooter's weapon damage. 0 below level III.</summary>
	public static float SlamFor( NZPlayer player, float weaponDamage )
		=> AmmoModUpgrades.Has( player, ModId, 3 ) ? MathF.Max( 0f, SlamShare ) * MathF.Max( 0f, weaponDamage ) : 0f;

	/// <summary>How far around this player their Epicenter wave reaches. 0 — no second wave — below level V.</summary>
	public static float EpicenterFor( NZPlayer player )
		=> AmmoModUpgrades.Has( player, ModId, 5 ) ? MathF.Max( 1f, EpicenterRadius ) : 0f;

	/// <summary>The thump: basalt's tile slam.</summary>
	const string Thump = "nz.hex.smash";

	/// <summary>The ring along the floor: dust, not Thunderwall's blue.</summary>
	static Color RingColour => new( 0.86f, 0.78f, 0.62f );

	/// <summary>The whole time a zombie is down at level 0: the fall, `Down`, and the get-up (`TotalFor`).</summary>
	public static float Total => TotalFor( null );

	// ══ the effect ═══════════════════════════════════════════════════════════

	/// <summary>The mod went off on <paramref name="zombie"/>. THE SHOOTER'S MACHINE: done here on the host, asked of it otherwise.</summary>
	public static void Fire( NZPlayer player, GameObject zombie )
	{
		if ( !player.IsValid() || !zombie.IsValid() ) return;

		var at = zombie.WorldPosition;
		var shooter = player.WorldPosition;

		// ⚠️ THE SHOOTER'S WEAPON DAMAGE GOES WITH IT (2026-10-05, Seismic Slam): this is the one machine that can read it.
		// Whether it is used is the host's call, by the level.
		var damage = AmmoMods.WeaponDamage( player );

		if ( Networking.IsActive && !NZGame.IsHost )
		{
			NZNet.ShockwaveAsk( at, shooter, zombie.Id, damage );
			return;
		}

		Go( at, shooter, zombie, player, damage );
	}

	/// <summary>
	/// Knock down everything in reach of <paramref name="at"/> — and at V everything within Epicenter's reach of
	/// <paramref name="shooter"/> too — and show it everywhere. THE HOST, or solo.
	/// </summary>
	/// <param name="shooter">Where the shot came from: the zombie hit is shoved away from it, and at V it is Epicenter's centre.</param>
	/// <param name="hit">The zombie hit, if this machine still has it.</param>
	/// <param name="player">Whose Shockwave: the host's own player, or the client who asked (`NZNet.ShockwaveAsk`). Its levels.</param>
	/// <param name="damage">The shooter's weapon damage, read on its machine (`AmmoMods.WeaponDamage`): Seismic Slam's base.</param>
	public static void Go( Vector3 at, Vector3 shooter, GameObject hit, NZPlayer player = null, float damage = 0f )
	{
		var reach = RadiusFor( player );
		var total = TotalFor( player );
		var slam = SlamFor( player, damage );
		var level = AmmoModUpgrades.Level( player, ModId );
		var around = EpicenterFor( player );

		// ⚠️ ONLY WHAT `Knockback` TOOK, for Seismic Slam: a boss, or one at a window, stays standing and unhurt. One list a wave
		// (2026-10-06, Epicenter), so each zombie is slammed from the centre that threw it.
		var floored = slam > 0f ? new List<ZombieAI>() : null;
		var flooredAround = slam > 0f && around > 0f ? new List<ZombieAI>() : null;

		// ⛔ EPICENTER'S WAVE (V) GOES FIRST, so a zombie in both is shoved away from YOU. `Knockback` takes a zombie once — one
		// already held is refused — so the first wave to reach it decides its way; from the zombie hit, one standing between you
		// and it would be thrown at you. The zombie hit stays the main wave's: away from you either way, with the centre's full shove.
		var downAround = around > 0f ? Wave( shooter, around, total, shooter, hit, false, flooredAround ) : 0;
		var down = Wave( at, reach, total, shooter, hit, true, floored );

		ShockRing.FireShared( at, reach, RingColour );
		NZNet.ShakeAt( at, MathF.Max( 0f, Shake ), MathF.Max( 0f, ShakeRange ) );
		NZSound.PlayShared( Thump, at );

		// ⚠️ AND THE SAME LOOK AROUND YOU (V): the ring, the tremor and the thump, there — shown to everybody from the host, as the
		// first. Two tremors add up on the shooter's screen (`CameraShake.Add`, capped at 1); `nz_shockwave_set shake` eases both.
		if ( around > 0f )
		{
			ShockRing.FireShared( shooter, around, RingColour );
			NZNet.ShakeAt( shooter, MathF.Max( 0f, Shake ), MathF.Max( 0f, ShakeRange ) );
			NZSound.PlayShared( Thump, shooter );
		}

		// ⚠️ AFTER THE RINGS AND THE THUMPS, the order Thunderwall keeps: a death the slam causes cannot cancel the look.
		var slammed = floored is null ? 0 : Slam( player, at, floored, slam );
		var slammedAround = flooredAround is null ? 0 : Slam( player, shooter, flooredAround, slam );

		Log.Info( $"[nz-ammo] SHOCKWAVE — {down} zombie(s) shoved and stunned within {reach:0}u for {total:0.##}s"
			+ (around > 0f ? $" · Epicenter {downAround} more within {around:0}u of the shooter" : "")
			+ (level > 0 ? $" · level {HudTheme.ToRoman( level )}" : "")
			+ (floored is null ? "" : $" · Seismic Slam {slam:0} to {slammed + slammedAround}") );
	}

	/// <summary>
	/// ONE WAVE (2026-10-06, out of `Go` for Epicenter's second): every zombie within <paramref name="reach"/> of
	/// <paramref name="centre"/> that `Knockback` takes is shoved away from the centre and stunned for <paramref name="total"/> —
	/// the zombie hit away from <paramref name="shooter"/>, or left to the other wave when <paramref name="takesHit"/> is false.
	/// THE HOST, from `Go`. How many it took; each also goes into <paramref name="floored"/>, when there is one.
	/// </summary>
	static int Wave( Vector3 centre, float reach, float total, Vector3 shooter, GameObject hit, bool takesHit,
		List<ZombieAI> floored )
	{
		var down = 0;

		foreach ( var z in ZombieAI.All.ToList() )
		{
			if ( !z.IsValid() || z.State == ZombieState.Dead ) continue;

			var isHit = hit.IsValid() && z.GameObject == hit;
			if ( isHit && !takesHit ) continue;

			var d = centre.Distance( z.WorldPosition );
			if ( d > reach ) continue;

			var from = isHit ? shooter : centre;
			var push = MathX.Lerp( PushNear, PushFar, d / reach );

			// ⛔ `tip: false` — A SHOVE AND A STUN, NO FALL (2026-10-06, the user's)
			if ( !z.Knockback( from, push, total, MathF.Max( 0f, Rise ), tip: false ) ) continue;

			down++;
			floored?.Add( z );
		}

		return down;
	}

	/// <summary>
	/// SEISMIC SLAM (III): <paramref name="slam"/> to each zombie the blast stunned, credited to <paramref name="player"/>.
	/// THE HOST, from `Go`: once a wave, each wave's from its own centre (<paramref name="at"/>), and a zombie is in one list only
	/// (`Knockback` takes it once). How many it hit.
	/// </summary>
	static int Slam( NZPlayer player, Vector3 at, List<ZombieAI> floored, float slam )
	{
		var hit = 0;

		// ⚠️ ITS HITS ROLL NO MOD (2026-10-05). From the proc they could not anyway — its cooldown was stamped this frame — but
		// Elemental Pop's surge stamps nothing (`AmmoMods.FireExternal`), and a slam off one would roll the host's held gun on
		// every zombie it floored. A client's slam has no gun on the host to roll (`AmmoMods.WithoutProcs`).
		AmmoMods.WithoutProcs( player, () =>
		{
			foreach ( var z in floored )
			{
				if ( !z.IsValid() || z.State == ZombieState.Dead ) continue;

				var hp = z.Components.Get<Health>( FindMode.EverythingInSelf );
				if ( !hp.IsValid() || hp.IsDead ) continue;

				// ⚠️ NO WEAPON, as Thunderwall's blast has none: a mod's splash must not pick up the gun's tech tree
				// (`TechEffects.Of`), and on the host a client's gun does not exist anyway.
				hp.OnDamage( new SWB.Shared.DamageInfo
				{
					Attacker = player.IsValid() ? player.GameObject : null,
					Damage = slam,
					Position = z.WorldPosition + Vector3.Up * (z.BodyHeight * 0.5f),
					Origin = at,
					Tags = [TechBlast.BlastTag],
				} );

				hit++;
			}
		} );

		return hit;
	}

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

	/// <summary>`nz_shockwave` — the resolved numbers, the upgrades', and what your level makes of them.</summary>
	[ConCmd( "nz_shockwave" )]
	public static void Report()
	{
		var mod = AmmoMods.Find( ModId );

		Log.Info( $"[nz-ammo] SHOCKWAVE · {(mod?.Chance ?? 0f) * 100f:0.#}% per hit, {mod?.Cooldown ?? 0f:0.#}s cooldown"
			+ $" · {Radius:0}u · shove {PushNear:0}→{PushFar:0}u · stun {ZombieAI.PratfallSeconds:0.##}+{Down:0.##}+{Rise:0.##}s"
			+ $" ({Total:0.##}s) · tremor {Shake:0.##} within {ShakeRange:0}u · deals NO damage below III" );

		var me = NZPlayer.Local;
		var level = AmmoModUpgrades.Level( me, ModId );

		// ⚠️ IV AND V (2026-10-06). Quick Quake's cooldown is read from `AmmoMods`, where the rolls' upgrades live.
		Log.Info( $"[nz-ammo]   upgrades · I {WideRadius:0}u · II {HardDown:0.##}s stunned · III {SlamShare * 100f:0}% of"
			+ $" your damage to each one stunned · IV {AmmoMods.QuickQuakeCooldown:0.#}s cooldown"
			+ $" · V the wave goes off around you too, {EpicenterRadius:0}u" );

		var around = EpicenterFor( me );

		Log.Info( $"[nz-ammo]   you: {(level == 0 ? "0" : HudTheme.ToRoman( level ))} → {RadiusFor( me ):0}u,"
			+ $" stun {TotalFor( me ):0.##}s, slam {SlamFor( me, AmmoMods.WeaponDamage( me ) ):0},"
			+ $" {AmmoMods.BaseCooldown( me, mod ):0.#}s cooldown before its scales,"
			+ (around > 0f ? $" Epicenter {around:0}u" : " no Epicenter") );
	}

	/// <summary>
	/// `nz_shockwave_set &lt;key&gt; &lt;value&gt;` — retune one number live. `epicenter` is V's reach around the shooter (2026-10-06);
	/// IV's cooldown is `AmmoMods.QuickQuakeCooldown`, with the other rolls.
	/// </summary>
	[ConCmd( "nz_shockwave_set" )]
	public static void SetCmd( string key = "", float value = 0f )
	{
		switch ( key.ToLowerInvariant() )
		{
			case "radius": Radius = value; break;
			case "near": PushNear = value; break;
			case "far": PushFar = value; break;
			case "down": Down = value; break;
			case "rise": Rise = value; break;
			case "shake": Shake = value; break;
			case "shakerange": ShakeRange = value; break;
			case "wide": WideRadius = value; break;
			case "harddown": HardDown = value; break;
			case "slam": SlamShare = value; break;
			case "epicenter": EpicenterRadius = value; break;

			default:
				Log.Info( "[nz-ammo] nz_shockwave_set <radius|near|far|down|rise|shake|shakerange|wide|harddown|slam|epicenter>"
					+ " <value>" );
				return;
		}

		Report();
	}
}