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