Static utility that implements 'explosive rounds' and other small blasts for weapons. It rate-limits per weapon class, computes blast damage from a weapon's DamageFor and a tunable share, finds zombies via ZombieAI.All with LOS checks, applies flat damage via Health.OnDamage with attacker/weapon set, and spawns a visual/sound effect.
using Sandbox;
using System.Collections.Generic;
namespace NZombies;
/// <summary>
/// EXPLOSIVE ROUNDS (`t5_explosive`) — the small blast the bullet paths trigger.
///
/// ⛔ NOT `Grenade.Detonate`, AND THE VETO IS WRITTEN DOWN BECAUSE IT LOOKS LIKE FREE
/// REUSE. Docs/TIER5_WIRING_PLAN.md §6.1 surveyed that method and rejected it on six
/// counts, every one of which this file exists to avoid:
///
/// • Every magnitude is an instance `[Property]` — `Damage = 500`, `Radius = 220`,
/// `SelfDamage = 0.25`. There is no way to ask it for a small blast.
/// • It damages the SHOOTER. Any `Health` carrying an `NZPlayer` takes a scaled
/// share, so a round fired at a zombie in melee range can kill the player.
/// • Its `DamageInfo` carries no `Attacker` and no `Weapon`, so every blast
/// overwrites `Health.LastAttacker` to NULL — which `ZombieAI._lastAttacker`
/// latches from — breaking Bounty, `PickupDrops.RollOnDeath` and kill points on
/// every AoE kill.
/// • Per call it runs `scene.GetAllComponents<Health>().ToList()`: a scene-wide
/// component scan plus a List allocation, 35 live zombies plus lingering corpses
/// plus the player.
/// • Per call it also clones a prefab, creates a GameObject, creates a PointLight
/// and plays a sound — 35 times a second on a G11.
/// • `Weapon.Shoot` calls `BulletType.Shoot` once per PELLET, so a 16-pellet KS23
/// would fire SIXTEEN of those per trigger pull.
///
/// ⚠️ SO THE SHAPE OF THIS FILE IS THE LIST OF THINGS IT DOES NOT DO. No prefab clone,
/// no light, no sound, no `Log.Info`, no scene scan, no allocation on the hot path, and
/// a hard rate limit that collapses a shotgun's pellets into one blast and caps a
/// 2100 RPM weapon at <see cref="MinInterval"/>.
///
/// ✅ §6.1's "no points awarded from blast hits" IS NOW DONE, elsewhere. It was unreachable from
/// this file — every damage path crosses `Health.Apply` → `OnDamaged` → `ZombieAI`, which paid
/// unconditionally — so <see cref="BlastTag"/> was stamped here and left waiting for a reader.
/// `Health.Pays` is that reader, built for `ShotPoints` and picking this up in the same
/// expression: `ZombieAI.OnHurt` skips the drip for either refusal.
/// </summary>
public static class TechBlast
{
/// <summary>
/// Stamped on every blast hit so the points path can recognise one.
///
/// ✅ AND IT IS READ NOW. It was written as a hand-off for §6.1 and spent its life unread,
/// describing the fix it wanted: *"latch it in `Health.OnDamage` beside the melee latch, and
/// skip the `AwardPoints` call in `ZombieAI.OnHurt`."* That is exactly what
/// `Health.LastHitPays` does — built for `ShotPoints`' penetration and pellet caps, which is
/// the same requirement arriving from the other side.
///
/// ⚠️ DELIBERATELY NOT `TagsHelper.Bullet`. That tag is stamped in exactly one
/// place — `DamageInfo.FromBullet` — and `Health.OnDamage` reads it to apply Vigor
/// Rush's double bullet damage. A blast is not a bullet, and borrowing the tag
/// would quietly hand the perk a second multiply on the same trigger pull.
/// </summary>
public const string BlastTag = "blast";
/// <summary>
/// How far the blast reaches, in units.
///
/// ⚠️ A NAMED CONSTANT AND NOT IN THE CATALOGUE, WHICH MEANS `nz_tech` CANNOT
/// PRINT IT. `WeaponTech.Node` has two numeric slots; `t5_explosive` spends
/// `Factor` on the -40% direct damage and `Bound` on
/// <see cref="DamageShareFallback"/>, so the third and fourth numbers this node
/// needs have no catalogue home. This is the tier-4 precedent (the `ScatterRpm` /
/// `DrumWalk` block in NZPlayer.cs), followed knowingly — see
/// Docs/TIER5_WIRING_PLAN.md §4.0.
///
/// ⚠️ 70 units is ~1.8 m, against the grenade's 220. "A small AoE blast" is the
/// catalogue's own wording, and a bullet blast that reached a grenade's radius
/// would clear a room from a doorway.
/// </summary>
// ⛔ THE CATALOGUE HOLDS IT NOW (2026-10-04): the shotguns' Explosive Rounds declares `radius` 100 (the user: *"increase
// the units to 100"*), so `nz_tech` prints it at last. 70, the old constant, is only the fallback.
static float Radius => WeaponTech.MagOf( "t5_explosive", "radius", 70f );
/// <summary>
/// The blast's share of the round that fired it.
///
/// ⛔ A FRACTION OF THE ROUND, NOT A FLAT NUMBER, AND THAT IS THE WHOLE REASON
/// THIS NODE IS TUNEABLE AT ALL. Authored damage across the roster runs 25 (MAC11)
/// to 1465 (AWM) — a 59x spread — while fire rate runs the other way, 55 to 2100
/// RPM. Any flat blast figure is negligible on one end and absurd on the other. A
/// share of the round makes the blast's DPS proportional to the weapon's own, which
/// is the only normalisation the roster supports, and it composes for free with
/// Pack-a-Punch, rarity and every tier-1..4 damage node because it reads
/// `DamageFor`.
///
/// ⚠️ IT IS A SHARE OF THE **ALREADY REDUCED** ROUND. The node's `Factor` of 0.6
/// is written into `si.Damage` at spawn time, so by the time a bullet reads
/// `DamageFor` the -40% has already landed. Single-target arithmetic therefore
/// comes out at 0.6 + 0.6 x 0.5 = **0.9x stock**, and every additional zombie
/// inside the radius adds another 0.3x. That is the trade, stated in numbers.
///
/// ⚠️ A FALLBACK, NOT THE MAGNITUDE — the `WeaponTech.BoundOf` precedent
/// `t5_deadeye` set. Declaring `Bound = 0.5f` on `t5_explosive` makes this literal
/// unreachable and puts the number in the table `nz_tech` prints.
/// </summary>
const float DamageShareFallback = 0.5f;
/// <summary>
/// The hard rate limit: no weapon may produce two blasts closer together than this.
///
/// ⛔ THIS SINGLE GATE IS BOTH HALVES OF §6.1'S "ONE PER TRIGGER PULL, NOT PER
/// PELLET" AND ITS "OR A HARD RATE LIMIT", and that is why it is one number and not
/// two mechanisms. A shotgun's pellets all resolve inside one frame, so any
/// positive interval collapses a KS23's sixteen into one. A G11 at 2100 RPM shoots
/// every 0.029 s, so 0.15 s caps it at 6.7 blasts a second instead of 35.
///
/// ⚠️ 0.15 IS BELOW EVERY WEAPON'S SHOT INTERVAL EXCEPT THE FAST AUTOS, which is
/// the intent: the slower half of the roster gets a blast on literally every shot
/// and only the weapons that would have made this node a performance problem are
/// throttled. Enumerated from the RPM census in Docs/TIER5_WIRING_PLAN.md §5b: at
/// 0.15 s the throttle binds above 400 RPM.
///
/// ⚠️ Also not in the catalogue — see <see cref="Radius"/>.
/// </summary>
const float MinInterval = 0.15f;
/// <summary>
/// When each weapon last produced a blast, by `ClassName`.
///
/// ⚠️ KEYED BY CLASS, NOT BY INSTANCE, FOR TWO REASONS. Pack-a-Punch destroys the
/// weapon clone and respawns it, so an instance key would hand a freshly upgraded
/// gun a free blast on its first shot and leave a dead entry behind — and the
/// roster is 31 weapons, so a class key is bounded for the whole session where an
/// instance key grows with every upgrade and every re-equip.
///
/// ⚠️ `Time.Now` IS SCENE TIME AND RESTARTS AT ZERO WITH THE SCENE, the same trap
/// `NZPlayer._fabDue` records. A stored stamp in the future therefore means the
/// clock was rewound, not that a blast is pending, so it is treated as ready.
/// </summary>
static readonly Dictionary<string, float> _lastBlastAt = new();
/// <summary>
/// Fire the blast at <paramref name="pos"/>, if this weapon owns the node and its
/// rate limit allows one.
///
/// ⚠️ CALLED FROM THE BULLET PATHS' IMPACT SITES, ONCE PER PELLET AT MOST — the
/// rate limit is what turns that into once per trigger pull. Calling it once per
/// PENETRATION iteration instead would put sixteen pellets x ten bodies through
/// this method on a KS23, which the gate would absorb but which would still cost
/// the lookup 160 times.
/// </summary>
public static void TryBlast( SWB.Base.Weapon weapon, SWB.Base.ShootInfo shootInfo, Vector3 pos )
{
if ( !weapon.IsValid() || shootInfo is null ) return;
if ( !TechEffects.Has( weapon, "t5_explosive" ) ) return;
var key = weapon.ClassName ?? string.Empty;
if ( _lastBlastAt.TryGetValue( key, out var last )
&& last <= Time.Now
&& Time.Now - last < MinInterval ) return;
_lastBlastAt[key] = Time.Now;
// ⚠️ Distance 0 and no hit tags: the blast is not a headshot and carries no
// falloff of its own. `DamageFor` is the one choke point both bullet paths
// read, so this composes with Pack-a-Punch, rarity and every damage node
// rather than duplicating their arithmetic.
var damage = shootInfo.DamageFor( 0f, null )
* WeaponTech.BoundOf( "t5_explosive", DamageShareFallback );
if ( damage <= 0f ) return;
var attacker = weapon.Owner.IsValid() ? weapon.Owner.GameObject : null;
Detonate( pos, damage, attacker, weapon.GameObject );
// ⛔ AND NOW YOU CAN SEE IT (2026-10-04, the user: *"needs to have an actual explosion effect"*). The "no prefab, no
// light, no sound" rule above was for per-PELLET blasts; the rate limit has already made this one per trigger pull.
Effect( pos, Radius );
}
/// <summary>
/// The visible half: the grenade's fireball and flash, sized to the blast, on every machine (`BlastEffect.Spawn`
/// announces it), and the grenade's bang, quieter — Explosive Rounds goes off on every shot.
/// </summary>
static void Effect( Vector3 pos, float radius )
{
BlastEffect.Spawn( pos, radius );
var snd = NZSound.Play( NZSound.GrenadeExplode, pos );
if ( snd.IsValid() ) snd.Volume *= 0.6f;
}
/// <summary>
/// THE UNDERBARREL LAUNCHER'S GRENADE (`t5_ar_underbarrel`, 2026-10-04): this file's zombies-only, line-of-sight blast at
/// a damage and radius of its own, with the explosion. No rate limit — it is charged by kills.
/// </summary>
public static void Launch( SWB.Base.Weapon weapon, Vector3 pos, float damage, float radius )
{
if ( !weapon.IsValid() || damage <= 0f || radius <= 0f ) return;
var attacker = weapon.Owner.IsValid() ? weapon.Owner.GameObject : null;
Detonate( pos, damage, attacker, weapon.GameObject, radius );
Effect( pos, radius );
}
/// <summary>
/// THE AMMO MODS' BLASTS (2026-10-04, `KillMods`): Shatter Blast's explosion and Headhunter's carried shot — this file's
/// zombies-only, line-of-sight blast at a damage and reach of their own. <paramref name="hitAt"/> collects where each zombie
/// it reached stood; <paramref name="except"/> is the corpse it came from; <paramref name="effect"/> false leaves out the
/// fireball and the bang.
/// </summary>
public static void ModBlast( Vector3 pos, float damage, float radius, GameObject attacker, GameObject weapon,
List<Vector3> hitAt = null, GameObject except = null, bool effect = true )
{
if ( damage <= 0f || radius <= 0f ) return;
Detonate( pos, damage, attacker, weapon, radius, hitAt, except );
if ( effect ) Effect( pos, radius );
}
/// <summary>
/// The blast itself.
///
/// ⛔ THE TARGET QUERY IS `ZombieAI.All`, NOT `GetAllComponents<Health>()`.
/// That static list is maintained by `OnEnabled`/`OnDisabled` and is capped by
/// `MaxAlive = 50`, so this is a bounded walk over a list that already exists — no
/// scene scan, no LINQ, no allocation. It also excludes the PLAYER structurally
/// rather than by a test: the player carries a `Health` but never a `ZombieAI`, so
/// there is no path by which this method can hurt whoever fired the shot. That is
/// §6.1's "players excluded" requirement satisfied by the choice of collection.
///
/// ⚠️ ITERATED DOWNWARD, WITH A BOUNDS CHECK. `Health.Apply` can kill a zombie
/// inside this loop, and `Die` -> `StopBeingSolid` can end with the component
/// disabled, which removes it from `All` mid-iteration. Walking down means a
/// removal only shifts entries we have already passed.
/// </summary>
static void Detonate( Vector3 pos, float damage, GameObject attacker, GameObject weapon, float radius = -1f,
List<Vector3> hitAt = null, GameObject except = null )
{
var scene = Game.ActiveScene;
if ( scene is null ) return;
// ⚠️ THE CALLER'S RADIUS, OR EXPLOSIVE ROUNDS' — resolved once, not per zombie.
var reach = radius > 0f ? radius : Radius;
for ( int i = ZombieAI.All.Count - 1; i >= 0; i-- )
{
if ( i >= ZombieAI.All.Count ) continue;
var z = ZombieAI.All[i];
if ( !z.IsValid() || z.State == ZombieState.Dead ) continue;
if ( except.IsValid() && z.GameObject == except ) continue;
// ⚠️ Aimed at the middle of the body using the zombie's OWN authored
// height rather than a literal — a variant that authors a taller or
// shorter body stays correctly centred, and the grenade's hardcoded
// `Up * 32` does not have to be repeated here.
var target = z.WorldPosition + Vector3.Up * (z.BodyHeight * 0.5f);
var dist = target.Distance( pos );
if ( dist > reach ) continue;
// ⛔ LINE OF SIGHT, FOR THE REASON `Grenade.Detonate` RECORDS: without it
// the blast kills through walls, floors and closed doors, and on a map
// built of small rooms that is most of its kills.
//
// ⛔ AND THE TARGET'S OWN BODY IS IGNORED, which is the other lesson from
// that method. Without it a zombie BLOCKS ITS OWN LINE OF SIGHT: the ray
// strikes its front surface a few units short, the occlusion test sees a
// hit closer than the target, and the zombie is skipped as though it were
// behind a wall.
//
// ⚠️ The slack is the zombie's own `HitRadius`, not a typed-in tolerance:
// anything that stops within one body radius of the target has reached it.
var trace = scene.Trace.Ray( pos, target )
.WithoutTags( "player", "trigger" )
.IgnoreGameObjectHierarchy( z.GameObject );
// ⛔ AND THE CORPSE A KILL MOD'S BLAST COMES FROM (2026-10-04, the review). On the host its capsule is still
// there in the frame it dies — `Destroy` is deferred — and the blast starts inside it, so every ray read as
// blocked a few units out and the blast hit nothing.
if ( except.IsValid() ) trace = trace.IgnoreGameObjectHierarchy( except );
var tr = trace.Run();
if ( tr.Hit && tr.Distance < dist - z.HitRadius ) continue;
// ⚠️ `EverythingInSelf` — trap 2 in INSTRUCTIONS.md. A component on a
// disabled object is invisible to a plain `Get`, and this list can hold a
// zombie mid-transition.
var hp = z.Components.Get<Health>( FindMode.EverythingInSelf );
if ( !hp.IsValid() ) continue;
// ⛔ FLAT DAMAGE, NOT DISTANCE-SCALED, following the grenade's own
// reasoning verbatim: a blast either clears a group or it does not, and
// making a horde's survivors depend on where each one stood turns a shot
// into a lottery the player cannot read.
//
// ⛔ `Attacker` AND `Weapon` ARE BOTH CARRIED. `Health.OnDamage` latches
// them into `LastAttacker`/`LastWeapon`, which `ZombieAI._lastAttacker`
// and `AwardPoints` read for Bounty, kill points and `PickupDrops`. The
// grenade sets neither, which is why every one of its blasts wipes the
// attribution the killing bullet just set.
//
// ⚠️ ROUTED THROUGH `OnDamage` RATHER THAN `Apply`, deliberately: `Apply`
// takes only an attacker and would leave `LastWeapon` null, so a blast
// kill would read the wrong weapon's tech tree under Mule Kick.
hp.OnDamage( new SWB.Shared.DamageInfo
{
Attacker = attacker,
Weapon = weapon,
Damage = damage,
Position = target,
Origin = pos,
Tags = [BlastTag],
} );
// ⚠️ WHERE IT STOOD, for a caller that draws to each one (Headhunter's lines).
hitAt?.Add( target );
}
}
}