A static utility implementing the Bouncy Rounds weapon effect. When a zombie dies from a qualifying hit, Chain finds nearby living zombies up to the weapon penetration cap and applies progressively amplified damage to them until a link survives or the cap is reached.
using Sandbox;
using System;
using System.Collections.Generic;
namespace NZombies;
/// <summary>
/// BOUNCY ROUNDS (`t5_bouncy`) — a round that kills carries on to the next zombie, twice as hard.
///
/// ⛔ IT CHAINS ON A KILL, NOT ON A HIT, AND THAT IS WHAT KEEPS IT FROM BEING A CROWD CLEARER.
/// A shot that leaves a zombie standing does nothing at all, so the node pays for OVERKILL — the
/// surplus a round had left over — rather than for firing into a pack. Most shots in a real round
/// do not kill, so most shots never bounce.
///
/// ⚠️ ONCE IT STARTS THOUGH, IT TENDS TO FINISH. Damage doubles each link while zombie health
/// stays fixed, so a link that killed almost guarantees the next one will. The cap is what bounds
/// it, and the cap is the weapon's own PENETRATION — thematic (the bullet has that much left in
/// it), already tuned per class, and it makes tier 3's Overpenetrator a real synergy:
///
/// shotgun 2 links (final x4) assault rifle 5 (x32) sniper 10 (x1024)
///
/// So a sniper that one-shots clears eleven zombies with one round and a shotgun clears three.
///
/// ⛔ IT IS TRIGGERED FROM `Health.OnDamage`, NOT FROM `HitScan`, AND THE FIRST VERSION HAD THAT
/// WRONG. The trigger is a KILL, and on a CLIENT the local copy of a zombie never takes the damage
/// at all — `OnDamage` relays to the host and returns before `Apply` ever subtracts — so `IsDead`
/// read from the shooter's machine was permanently false and the node did nothing for anybody but
/// the host. The kill is knowable on exactly one machine, which is the machine that owns the
/// zombie, so that is where this runs.
///
/// ⚠️ AND THE SAME MOVE FIXED THE DAMAGE FIGURE. `HitScan` could only offer the bullet's damage
/// BEFORE `AttackerScale`, the hit group and the ammo mods were applied to it; `OnDamage` knows
/// what the zombie actually lost. The doubling now doubles the real number.
///
/// ⚠️ IT STILL DOES NOTHING FOR A CLIENT'S OWN SHOTS, and that is a known, shared gap rather than
/// this node's FORMER bug, fixed 2026-09-15: `NZNet.HurtRemote` carried a damage figure and no
/// weapon, so the host could not
/// see which tech the shooter's gun had. Adrenaline Rounds — which sits on the very next lines of
/// `OnDamage` — has the identical limitation for the identical reason. It closes when weapon tech
/// replicates, which is one coherent job, not eleven per-node hacks.
///
/// ⚠️ A BOUNCE CANNOT START ANOTHER BOUNCE, because the link damage built below carries no
/// `ShotId` and the trigger only fires on damage that has one. That test costs nothing, needs no
/// depth counter, and excludes blasts and Flechette shards by the same stroke.
/// </summary>
public static class BouncyRounds
{
/// <summary>How much harder each link hits than the one before.</summary>
public static float Step { get => _step ?? 2f; set => _step = value; }
static float? _step;
/// <summary>
/// How far a bounce will look for its next target, in units. 400.
///
/// ⛔ A RANGE LIMIT AS WELL AS A COUNT, BECAUSE "THE NEAREST ZOMBIE" HAS NO NATURAL END. Without
/// it a chain would cross the map to find its next link, and a round fired into one corner would
/// kill something the player cannot see in another. The bullet should run out of pack, not out
/// of patience.
/// </summary>
public static float Reach { get => _reach ?? 400f; set => _reach = value; }
static float? _reach;
/// <summary>The tag every link carries, so the damage can be told apart downstream.</summary>
public const string Tag = "bounce";
// ⚠️ REUSED ACROSS CALLS RATHER THAN ALLOCATED PER KILL. A chain runs inside one bullet's
// resolution, never concurrently with another, so one list is enough — and this is on the
// damage path, where INSTRUCTIONS records an array-per-hit already costing frames.
static readonly List<GameObject> _chained = new();
/// <summary>
/// A zombie just died to this weapon. Carry the round on while it keeps killing.
/// </summary>
/// <param name="killed">The body that died — the first link, already paid for.</param>
/// <param name="damage">What the killing hit dealt, before doubling.</param>
/// <param name="cap">Links allowed: the weapon's penetration depth.</param>
public static void Chain( in TechEffects.TechRef tech, GameObject killed, float damage, float cap )
{
if ( !tech.Valid || !killed.IsValid() || damage <= 0f ) return;
var links = (int)cap;
if ( links < 1 ) return;
// ⛔ THE SHOOTER COMES FROM THE `TechRef`, NOT FROM `weapon.Owner`, AND THAT IS THE
// WHOLE FIX FOR THIS NODE ON A CLIENT. There is no weapon object on the host for a hit a
// client relayed, so `weapon.Owner` was a null dereference waiting to happen and the
// guard above simply returned instead — the node did nothing at all for a client. The
// player replicates; the weapon does not.
var attacker = tech.AttackerGo;
// ⚠️ NULL ON A RELAYED HIT, WHICH IS WHY THE STAMP GOES ON BESIDE IT. `LastWeapon`
// wants the object when there is one; `Extra` carries the prefab when there is not, and
// between them every link arrives at `OnDamage` knowing which tree fired it.
var weaponGo = tech.WeaponGo;
var stamp = tech.Stamp();
_chained.Clear();
_chained.Add( killed );
var from = killed;
var hit = damage;
for ( var i = 0; i < links; i++ )
{
hit *= Step;
var next = Nearest( from );
if ( !next.IsValid() ) return;
var hp = next.Components.Get<Health>( FindMode.EverythingInSelf );
if ( hp is null ) return;
_chained.Add( next );
// ⚠️ THROUGH `OnDamage`, NOT `Apply`, AND BOTH HALVES OF THAT MATTER. `Apply` takes
// no weapon, so `LastWeapon` would be null and a bounce kill would read the wrong
// gun's tech tree under Mule Kick — `TechBlast.Detonate` records the same finding.
// It is also local-only, which is half of what was wrong with the first version.
//
// ⚠️ NO `head` TAG, EVER. A bounce is not aimed, so it earns neither the head damage
// product nor the 100-point award; the doubling is the reward. Paying head money for
// a shot nobody placed would make the node a points engine as well as a damage one.
hp.OnDamage( new SWB.Shared.DamageInfo
{
Attacker = attacker,
Weapon = weaponGo,
Damage = hit,
Position = next.WorldPosition,
Origin = from.WorldPosition,
Tags = [Tag],
Extra = stamp,
} );
// ⛔ THE CHAIN ENDS THE MOMENT A LINK SURVIVES, which is the node's whole contract.
// Checked AFTER the damage, so the zombie that absorbed the round still took it.
if ( !hp.IsDead ) return;
from = next;
}
}
/// <summary>
/// The closest living zombie to a point that this chain has not already used.
///
/// ⚠️ THE EXCLUSION LIST IS WHAT STOPS IT BOUNCING BETWEEN TWO CORPSES. A dead zombie is not
/// removed from `ZombieAI.All` in the same frame it dies, so "nearest living" alone would keep
/// finding the body it just killed and spend the whole chain on it.
///
/// ⛔ ITERATED DOWNWARD WITH A BOUNDS CHECK, NOT WITH `foreach`, FOR THE REASON
/// `TechBlast.Detonate` RECORDS: the previous link's damage has already killed a zombie inside
/// this same call stack, and `Die` → `StopBeingSolid` can end with the component disabled,
/// which removes it from `All`. A `foreach` over a list something else is shortening is an
/// exception waiting for a busy round; walking down means a removal only shifts entries
/// already passed.
/// </summary>
static GameObject Nearest( GameObject from )
{
if ( !from.IsValid() ) return null;
var origin = from.WorldPosition;
var best = (GameObject)null;
var bestDist = Reach * Reach;
for ( int i = ZombieAI.All.Count - 1; i >= 0; i-- )
{
if ( i >= ZombieAI.All.Count ) continue;
var z = ZombieAI.All[i];
if ( !z.IsValid() ) continue;
var go = z.GameObject;
if ( !go.IsValid() || _chained.Contains( go ) ) 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 = go.Components.Get<Health>( FindMode.EverythingInSelf );
if ( hp is null || hp.IsDead ) continue;
var d = go.WorldPosition.DistanceSquared( origin );
if ( d >= bestDist ) continue;
bestDist = d;
best = go;
}
return best;
}
/// <summary>`nz_bouncy [step] [reach]` — how hard each link hits, and how far it will look.</summary>
[ConCmd( "nz_bouncy" )]
public static void Cmd( float step = -1f, float reach = -1f )
{
if ( step > 0f ) Step = step;
if ( reach > 0f ) Reach = reach;
Log.Info( $"[nz-bouncy] x{Step:0.##} per link, looks {Reach:0} units for the next" );
Log.Info( "[nz-bouncy] links are capped by the weapon's PENETRATION — shotgun 2, rifle 5,"
+ " sniper 10" );
Log.Info( $"[nz-bouncy] so a sniper's last link hits x{MathF.Pow( Step, 10 ):0} and clears"
+ " eleven zombies with one round" );
Log.Info( "[nz-bouncy] host-only for now: a client's relayed hit carries no weapon, so"
+ " the host cannot see the tech (same gap as Adrenaline Rounds)" );
}
}