Static utility that implements the Thunderwall ammo mod. It computes damage and radius based on tunables and upgrade levels, finds nearby zombies, applies damage, plays a ground ring visual and sound, and supports a delayed second strike for level III plus console commands to report and set tunables.
using Sandbox;
using System;
using System.Linq;
namespace NZombies;
/// <summary>
/// Thunderwall — a wide shockwave centred on the zombie you hit.
///
/// | | value |
/// |---|---|
/// | proc | **5%** per hit, **1s** cooldown |
/// | radius | **600u** around the zombie that was shot |
/// | damage | **100% of your weapon's per-shot damage** — the whole shot — to every zombie inside |
/// | cap | **20** zombies |
/// | upgrades (2026-10-05) | I cap **30** · II **200%** · III a second blast **0.5s** later, same spot, same numbers |
/// | IV and V (2026-10-06) | IV **400%** · V **1200u** and **no cap**, for both of III's blasts |
///
/// ⚠️ IV AND V (2026-10-06, `AMMO_MODS.md` "Tiers IV and V"): IV Supercell, 400% to each (II's 200%); V Perfect Storm, twice
/// the reach and every zombie inside it. The user: *"for V make it so the radius is incresed to double, and no limit on zombies
/// hit"*.
///
/// ⚠️ THE 1-SECOND COOLDOWN MAKES THIS THE MOST FREQUENT MOD OF THE SIX, which its 5% chance hides.
/// Chance and cooldown only mean anything together: Fire Works at 10%/10s can fire once every ten
/// seconds at best, while this can fire every second. At 600 RPM a 5% chance comes up about twice a
/// second, so in practice the cooldown is the limit and Thunderwall procs roughly once a second of
/// sustained fire.
///
/// ⛔ IT IS NOT A CONE AND IT DOES NOT LAUNCH, WHICH IS THE PHASE 7 SHAPE AND NOT THE ORIGINAL'S.
/// Upstream used to fire a TFA-only cone along `wep:GetAimVector` — which silently did nothing on
/// ArcCW and ARC9, because neither has that method. The rebalance replaced it with a plain sphere
/// around the victim and dropped the knockback entirely. Porting the cone would have meant porting
/// a bug.
///
/// ⚠️ UPSTREAM'S OWN REGISTRY COMMENT IS WRONG ABOUT THIS ONE. `sh_register.lua` says the rebalance
/// made it "deals % max HP, no launch" — the no-launch half is right, but the effect file plainly
/// computes `perhit * 0.20`, a share of the held weapon's PER-SHOT DAMAGE. The code is the source of
/// truth and the comment beside it is not.
///
/// ⚠️ PER-SHOT DAMAGE, NOT DPS — AND NOW THE WHOLE SHOT. Upstream's is 20% of one shot; since
/// 2026-09-24 it is 100%, asked for as *"make thunderwall deal the full weapon damage per trigger"*:
/// every zombie in the blast takes what one trigger pull of your gun deals. Dead Wire moved from DPS
/// to per-shot the same day, so the two scale alike now; `nz_thunderwall_set dps 1` still switches
/// this one to DPS.
/// </summary>
public static class Thunderwall
{
// ══ tuning ═══════════════════════════════════════════════════════════════
//
// ⛔ NULLABLE-BACKED GETTERS — a static's VALUE survives a hotload but its initialiser does not
// re-run. INSTRUCTIONS.md §1.
static float? _radius;
/// <summary>How far the blast reaches. 600u.</summary>
public static float Radius { get => _radius ?? 600f; set => _radius = value; }
static float? _fraction;
/// <summary>Share of the held weapon's per-shot damage each zombie takes. 1.0 — all of it.</summary>
public static float Fraction { get => _fraction ?? 1f; set => _fraction = value; }
static int? _cap;
/// <summary>
/// The most zombies one blast may hit. 20.
///
/// ⚠️ HIGH ENOUGH THAT IT RARELY BINDS, unlike Blast Furnace's three. Upstream's number, and it
/// reads as a runaway guard rather than as part of the design — twenty zombies inside 600u is
/// most of a horde.
/// </summary>
public static int Cap { get => _cap ?? 20; set => _cap = value; }
static bool? _useDps;
/// <summary>
/// Scale off the weapon's DPS instead of its per-shot damage. Off.
///
/// ⚠️ THE SWITCH EXISTS BECAUSE DEAD WIRE WENT THE OTHER WAY. Keeping both readings one command
/// apart is cheaper than arguing about which is right, and makes the difference testable rather
/// than theoretical.
/// </summary>
public static bool UseDps { get => _useDps ?? false; set => _useDps = value; }
static bool? _visual;
/// <summary>
/// Draw the ground wave. On.
///
/// ⚠️ THIS USED TO SAY "show the explosion" AND THERE IS NO LONGER AN EXPLOSION. The
/// `BlastEffect` call was removed by request; this switch now governs the `ShockRing` wave and
/// the sound, which is the whole of the mod's presentation.
/// </summary>
public static bool Visual { get => _visual ?? true; set => _visual = value; }
// ══ upgrades (2026-10-05) ════════════════════════════════════════════════
//
// ⛔ EACH UPGRADED NUMBER IS ITS OWN TUNABLE BESIDE THE BASE ONE, AND ONE HELPER PICKS BETWEEN THEM (§3): every reader
// asks `CapFor` / `FractionFor` / `StrikesTwice` with the level. `AMMO_MODS.md` "Upgrades"; the user: *"Love it, proceed"*.
static int? _biggerStormCap;
/// <summary>
/// I BIGGER STORM — the most zombies one blast may hit from level I. 30 (`Cap` is 20).
///
/// ⚠️ THE CAP, NOT THE 5% CHANCE, because the 1 s cooldown is what limits it on a fast gun: it already fires about once a
/// second of shooting (`AMMO_MODS.md`).
/// </summary>
public static int BiggerStormCap { get => _biggerStormCap ?? 30; set => _biggerStormCap = value; }
static float? _thunderclapFraction;
/// <summary>II THUNDERCLAP — the share of a shot each zombie takes from level II. 2.0 (`Fraction` is 1.0).</summary>
public static float ThunderclapFraction { get => _thunderclapFraction ?? 2f; set => _thunderclapFraction = value; }
static float? _doubleStrikeDelay;
/// <summary>III DOUBLE STRIKE — seconds from the first blast to the second. 0.5.</summary>
public static float DoubleStrikeDelay { get => _doubleStrikeDelay ?? 0.5f; set => _doubleStrikeDelay = value; }
// ── tiers IV and V (2026-10-06, `AMMO_MODS.md` "Tiers IV and V") ──
static float? _supercellFraction;
/// <summary>IV SUPERCELL — the share of a shot each zombie takes from level IV. 4.0 (II's `ThunderclapFraction`, 2.0).</summary>
public static float SupercellFraction { get => _supercellFraction ?? 4f; set => _supercellFraction = value; }
static float? _perfectStormRadius;
/// <summary>
/// V PERFECT STORM — how far the blast reaches at level V. 1200u (`Radius`, 600): *"the radius is incresed to double"*.
///
/// ⚠️ BOTH OF III'S BLASTS TAKE IT (`Fire` resolves the reach once and hands it to both), and the ground ring is drawn at it:
/// `ShockRing.Fire` takes the real reach, so the wave grows with it.
/// </summary>
public static float PerfectStormRadius { get => _perfectStormRadius ?? 1200f; set => _perfectStormRadius = value; }
/// <summary>
/// V PERFECT STORM'S CAP: none — *"no limit on zombies hit"*. The largest count `Take` accepts, so a blast takes every zombie
/// in reach. ⚠️ THE CROWD IS WHAT BOUNDS IT NOW: the concurrent cap, 50 by default (`ZombieStats.MaxAlive`, raised from 35;
/// a map's config and the lobby's Difficulty can move it), so at the default about 20 more than I's 30.
///
/// ⚠️ A `const`, NOT A TUNABLE (§1): it is a rule, not a number to retune, and a const cannot go stale.
/// </summary>
public const int NoCap = int.MaxValue;
/// <summary>The most zombies a blast hits at this level: `Cap`, `BiggerStormCap` from I, and none at V (`NoCap`).</summary>
public static int CapFor( int level ) => level >= 5 ? NoCap : level >= 1 ? BiggerStormCap : Cap;
/// <summary>The share of a shot each zombie takes at this level: `Fraction`, `ThunderclapFraction` from II, `SupercellFraction` from IV.</summary>
public static float FractionFor( int level ) => level >= 4 ? SupercellFraction : level >= 2 ? ThunderclapFraction : Fraction;
/// <summary>How far a blast reaches at this level: `Radius`, or `PerfectStormRadius` at V.</summary>
public static float RadiusFor( int level ) => level >= 5 ? PerfectStormRadius : Radius;
/// <summary>Does a proc at this level blast twice: III.</summary>
public static bool StrikesTwice( int level ) => level >= 3;
/// <summary>A cap as the logs write it: "cap 30", or "no cap" at V.</summary>
static string CapText( int cap ) => cap >= NoCap ? "no cap" : $"cap {cap}";
/// <summary>
/// What one zombie takes.
///
/// ⚠️ READ THROUGH THE CHOKEPOINTS, not off the authored numbers. `DamageFor` and `GetRealRPM`
/// are what every perk, augment and tech node folds into — so the blast scales with
/// Pack-a-Punch, rarity, Double Tap and Vigor without knowing any of them exist.
///
/// ⛔ AND THE DPS PATH GOES THROUGH `FireAugments.WeaponDps` RATHER THAN REPEATING THE SUM.
/// `GetRealRPM` returns an INTERVAL in seconds-per-shot, so DPS is damage divided by it —
/// writing `damage * rpm / 60` against that value is wrong by a factor of RPM²/3600, which is a
/// hundredfold at 600 RPM. One author for that arithmetic (§3).
/// </summary>
/// <param name="level">
/// The player's Thunderwall level (2026-10-05): II's 200% comes in here, for all three readings, and IV's 400% (2026-10-06).
/// </param>
public static float DamageFor( NZPlayer player, int level = 0 )
{
var share = MathF.Max( 0f, FractionFor( level ) );
if ( UseDps )
return MathF.Max( 1f, FireAugments.WeaponDps( player ) * share );
// ⚠️ `AmmoMods.WeaponDamage` — the one reading of "your weapon's damage" every mod shares,
// × Bullets for shotguns as the stats card shows it.
var perShot = AmmoMods.WeaponDamage( player );
// ⚠️ THE SAME NO-WEAPON FALLBACK NAPALM USES, so an empty-handed proc does something
// visible rather than silently nothing — which reads as the mod being broken.
if ( perShot <= 0f )
return MathF.Max( 1f, FireAugments.NoWeaponDps * share );
return MathF.Max( 1f, perShot * share );
}
/// <summary>
/// Blast everything around the zombie that was hit.
///
/// ⚠️ CENTRED ON THE VICTIM, NOT THE PLAYER. Upstream parents its effect to the shot zombie and
/// reads `WorldSpaceCenter` — so a 600u sphere reaches 600u past whatever you shot, which at
/// range is a very different volume from one centred on you.
///
/// ⚠️ INSTANT AND ONE-SHOT. There is no component and nothing to tick: it damages, draws, and
/// is done. Upstream's entity removes itself on the same frame it spawns.
///
/// ⚠️ EXCEPT AT III (2026-10-05), WHICH STRIKES AGAIN HALF A SECOND LATER — still with no component: `StrikeAgain` waits
/// on a task holding only the player, the spot and the numbers.
/// </summary>
public static void Fire( NZPlayer player, GameObject zombie )
{
if ( !player.IsValid() || !zombie.IsValid() ) return;
// ⚠️ THE UPGRADES ARE READ HERE (2026-10-05), ON THE SHOOTER'S MACHINE, FOR THE SHOOTER — where this runs, and where
// `AmmoMods.WeaponDamage` is valid. `AmmoMods.Fire` is the only caller (the proc and `FireExternal`), so nothing else
// inherits them; a system that reuses this blast for an effect of its own must pass a level, not read the player's.
var level = AmmoModUpgrades.Level( player, "thunderwall" );
var at = zombie.WorldPosition + Vector3.Up * 32f;
var damage = DamageFor( player, level );
// ⚠️ V PERFECT STORM (2026-10-06): the reach and the cap by level, resolved ONCE here, so both of III's blasts and the
// ground ring take V's 1200u and no cap. IV's 400% is in `damage` (`FractionFor`).
var reach = MathF.Max( 0f, RadiusFor( level ) );
var cap = CapFor( level );
Strike( player, at, reach, damage, cap, level, first: true );
// ⚠️ III DOUBLE STRIKE: *"it strikes twice, a second blast 0.5 s after the first"* — at the same spot, on whatever
// stands there by then, with the same cap and the damage SNAPSHOTTED ABOVE. `AmmoMods.WeaponDamage` reads the gun in
// hand, so a weapon swap in between must not change the second blast; it is dealt from this machine, as the first.
if ( StrikesTwice( level ) )
StrikeAgain( player, at, reach, damage, cap, level );
}
/// <summary>
/// One blast: the wave and the sound, then the damage to the nearest <paramref name="cap"/> zombies within
/// <paramref name="reach"/> of <paramref name="at"/>. Both of III's blasts are this.
/// </summary>
static void Strike( NZPlayer player, Vector3 at, float reach, float damage, int cap, int level, bool first )
{
// ⚠️ THE VISUAL FIRST, so a damage handler that removes a body cannot cancel the
// wave — the same ordering Dead Wire and Blast Furnace both use.
if ( Visual )
{
// ⛔ NO EXPLOSION. `BlastEffect.Spawn( at, reach )` was called here and is removed by
// request: Thunderwall is a wave, not a detonation, and a grenade-sized fireball read as
// one. The class stays — Grenade, PhD Flopper and Napalm Nectar all still use it.
//
// ⚠️ AND ITS WARNING IS WORTH KEEPING EVEN THOUGH THIS FILE NO LONGER CALLS IT.
// `BlastEffect` is not optional polish for its remaining callers: the only explosion
// prefab in the asset system ships a `RadiusDamage` component with `DamageOnEnabled`
// set, so cloning it directly deals a flat 100 damage in 256u and shoves the player.
// `BlastEffect` clones it disabled, strips that, then enables — which is why nobody
// should reach for the prefab straight.
//
// ⛔ THE GROUND RING IS THE ONLY LAYER OF UPSTREAM'S ORIGINAL VISUAL THAT PORTS
// CHEAPLY, and it is the one that carries the read. `perks_aat_thunderwall.pcf` holds
// four children — wave, energy, warp and dust. This is the wave: a `LineRenderer`
// with an animated radius, needing no texture and no shader.
//
// ⛔ AND IT IS NOW THE WHOLE VISUAL, WHICH PROMOTES IT FROM SUPPORTING TO LOAD-BEARING.
// It is the only thing that makes the 600u radius legible — drawn AT the real radius, so
// what you see is what was hit. Turning `Visual` off now leaves the mod with nothing but
// its sound, where before it still had a blast.
//
// ⚠️ AT V THAT IS 1200u (2026-10-06, Perfect Storm), and the ring grows with it. Same cost — `ShockRing.Segments` floor
// traces a frame, whatever the radius — but each chord is twice as long (about 235u at the default 32 segments), so
// judge its roundness in play.
ShockRing.Fire( at, reach );
// ⚠ THE ONE SOUND UPSTREAM STILL PLAYS. Its effect file emits this and nothing else —
// no particles at all after Phase 7 — so this cue is the whole of what the live GMod
// version presents for Thunderwall.
Sound.Play( NZSound.PopThunderwallShoot, at );
}
var hit = ZombieAI.All
.Where( z => z.IsValid() && z.GameObject.IsValid() )
.Where( z => at.Distance( z.WorldPosition ) <= reach )
.Select( z => new
{
Ai = z,
Hp = z.Components.Get<Health>( FindMode.EverythingInSelfAndAncestors ),
Dist = at.Distance( z.WorldPosition ),
} )
.Where( x => x.Hp.IsValid() && !x.Hp.IsDead )
.OrderBy( x => x.Dist )
.Take( Math.Max( 1, cap ) )
.ToList();
// ⚠️ NEAREST-FIRST BEFORE THE CAP, so when twenty is not enough the twenty that get hit are
// the closest. Upstream takes them in whatever order `FindInSphere` returned, which is
// arbitrary — a zombie on your face surviving while one across the room dies.
//
// ⚠️ NO FALLOFF, DELIBERATELY. Upstream applies the same figure at every distance, and a
// 600u sphere with falloff would be a different effect. The flat number is what makes this
// a wall rather than a grenade.
foreach ( var x in hit )
x.Hp.OnDamage( new DamageInfo
{
Damage = damage,
Attacker = player.GameObject,
Position = x.Ai.WorldPosition + Vector3.Up * 32f,
Tags = new TagSet(),
} );
Log.Info( $"[nz-ammo] THUNDERWALL{(level > 0 ? $" {HudTheme.ToRoman( level )}" : "")}{(first ? "" : ", second strike")}"
+ $" — {damage:0} to {hit.Count} zombie(s)"
+ $" within {reach:0}u ({CapText( cap )})"
+ $" · {(UseDps ? "dps" : "per-shot")} × {FractionFor( level ):0.##}" );
}
/// <summary>
/// III's second blast, <see cref="DoubleStrikeDelay"/> after the first.
///
/// ⚠️ `GameTask.Delay`, THE PROJECT'S DELAY IDIOM, RE-VALIDATED AFTER THE WAIT as `PhdAugments.ChainRest` is: half a second
/// is long enough to leave the game. The zombie that was shot is not needed — it may be dead by now; the spot is.
/// </summary>
static async void StrikeAgain( NZPlayer player, Vector3 at, float reach, float damage, int cap, int level )
{
await GameTask.Delay( (int)(MathF.Max( 0.02f, DoubleStrikeDelay ) * 1000f) );
if ( !player.IsValid() ) return;
// ⛔ THE SECOND BLAST CAN SET A MOD OFF WHERE THE FIRST CANNOT: half a second on, a cut cooldown has run out (Catalyst's
// ×0.5 alone makes it 0.5 s), and thirty hits at 10-20% each would all but surely proc the next Thunderwall, whose own
// second blast would proc the one after — a storm without a shot fired. So its hits roll nothing (`AmmoMods.WithoutProcs`,
// shared since 2026-10-05 with the other upgrades that deal damage late).
AmmoMods.WithoutProcs( player, () => Strike( player, at, reach, damage, cap, level, first: false ) );
}
// ══ diagnostics ══════════════════════════════════════════════════════════
/// <summary>`nz_thunderwall` — the resolved numbers, and what a blast would deal right now.</summary>
[ConCmd( "nz_thunderwall" )]
public static void Report()
{
Log.Info( $"[nz-ammo] THUNDERWALL · 5% per hit, 1s cooldown"
+ $" · {Radius:0}u · cap {Cap}"
+ $" · {Fraction * 100f:0.#}% of {(UseDps ? "weapon DPS" : "per-shot damage")}"
+ $" · wave {(Visual ? "on" : "off")}" );
// ⚠️ THE UPGRADES (2026-10-05), AND IV AND V (2026-10-06).
Log.Info( $"[nz-ammo] upgrades · I cap {BiggerStormCap} · II {ThunderclapFraction * 100f:0.#}%"
+ $" · III a second blast {DoubleStrikeDelay:0.##}s later, same spot, same numbers"
+ $" · IV {SupercellFraction * 100f:0.#}% · V {PerfectStormRadius:0}u, no cap" );
var me = NZPlayer.Local;
if ( !me.IsValid() ) { Log.Warning( "[nz-ammo] no player" ); return; }
// ⚠️ THE WORKED NUMBER, because "a share of per-shot damage" is not something anyone can feel —
// and because the two scaling modes give very different answers on the same gun.
//
// ⚠️ AT YOUR LEVEL (2026-10-05), which is what your own Thunderwall deals.
var level = AmmoModUpgrades.Level( me, "thunderwall" );
var perShot = DamageFor( me, level );
// ⚠️ AT YOUR REACH (2026-10-06): V's 1200u when you own it.
var reach = RadiusFor( level );
var inRange = ZombieAI.All.Count( z => z.IsValid()
&& me.WorldPosition.Distance( z.WorldPosition ) <= reach );
Log.Info( $"[nz-ammo] held weapon → {perShot:0} per zombie"
+ (level > 0 ? $" at {HudTheme.ToRoman( level )} ({CapText( CapFor( level ) )}{(StrikesTwice( level ) ? ", twice" : "")})" : "")
+ $" · {inRange} zombie(s) within {reach:0}u of YOU"
+ $" (a blast centres on the VICTIM, so this is only an estimate)" );
}
/// <summary>`nz_thunderwall_set <key> <value>` — retune one number live.</summary>
[ConCmd( "nz_thunderwall_set" )]
public static void SetCmd( string key = "", float value = 0f )
{
switch ( key.ToLowerInvariant() )
{
case "radius": Radius = value; break;
case "fraction": Fraction = value; break;
case "cap": Cap = (int)value; break;
case "dps": UseDps = value > 0.5f; break;
case "visual": Visual = value > 0.5f; break;
// ⚠️ THE UPGRADES' OWN NUMBERS (2026-10-05): I, II and III.
case "storm": BiggerStormCap = (int)value; break;
case "clap": ThunderclapFraction = value; break;
case "strike": DoubleStrikeDelay = value; break;
// ⚠️ IV'S AND V'S NUMBERS (2026-10-06). V's "no cap" is a rule (`NoCap`), not a key.
case "supercell": SupercellFraction = value; break;
case "perfect": PerfectStormRadius = value; break;
default:
Log.Info( "[nz-ammo] nz_thunderwall_set <radius|fraction|cap|dps|visual|storm|clap|strike|supercell|perfect> <value>" );
return;
}
Log.Info( $"[nz-ammo] thunderwall {key} = {value:0.###}" );
Report();
}
}