Reanimator and ReanimatorHuman implement the "Re-Animator" ammo mod: when a zombie is killed it can spawn a temporary human that screams, runs toward a chosen nav point (or to downed players at upgrade V), lures nearby zombies, may deal no damage or burst/damage zombies depending on upgrades, and can revive players (host-side). It handles tuning values, upgrade interactions, network asks/broadcasts, pathfinding, timing, AI lure lists, and diagnostics console commands.
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
namespace NZombies;
/// <summary>
/// Re-Animator — a zombie you kill may get up as a screaming human and run off, and the zombies around go after it instead
/// of you.
///
/// | | value |
/// |---|---|
/// | proc | **8%** a kill, **30s** cooldown |
/// | human | the VR-11's CIA agent (`models/nz/vr11_human`), where the zombie died |
/// | run | a beat of `react`, then `run_b` at **200 u/s** toward a random spot **~900u** off |
/// | lure | every zombie within **1200u** of it chases it instead of the players |
/// | life | **6s**, then it bursts; **10s** at I Long Lure |
/// | damage | **none**; at III Last Stand the burst deals **1000%** of the killing gun's damage within **250u** |
/// | IV aura | at IV Live Bait, while it lives, every zombie within **200u** of it takes **100%** of the killing gun's damage **each second** |
/// | V rescue | at V Good Samaritan it runs to the nearest **downed** player instead, picks them up within **60u** over the revive's **3s** (its clock holds meanwhile), then the next, or off |
///
/// ⚠️ THE USER (2026-10-04): *"the killed zombie has a chance to turn into this human and it runs away in a random
/// direction, the zombies follow it, it lasts 6 seconds and the cooldown is 30 seconds"*. GMod's own is the TFA VR-11's
/// `nextbot_bo3_vrill_human` (workshop 2898919146): `react`, then `ACT_RUN` (`run_b`) at 200 toward a random nav spot, a
/// scream every 2-4 s, and a burst at the end — whose 160u kill this port leaves out (not asked for; see the doc).
///
/// ⛔ EVERY MACHINE RUNS ITS OWN HUMAN ALONG THE SAME PATH; ONLY THE HOST'S IS CHASED. The host picks the spot and tells
/// everyone where it starts and where it runs to (`NZNet.ReanimatorFx`); each machine builds its own and walks it along its
/// own navmesh path between the two — the same mesh, the same two points, the same route. Nothing is network-spawned and
/// nothing streams a position: the human moves as smoothly on a client as on the host. The host's copy is the lure the AI
/// chases (`ZombieAI.GetTargetables`).
///
/// ⚠️ THE KILLER'S MACHINE ROLLS — the chance and the cooldown are its own (`KillMods`) — and asks the host for the rest
/// (`NZNet.ReanimatorAsk`), which also takes the corpse away: the zombie BECAME the human.
///
/// ⚠️ THE UPGRADES (2026-10-05, `Sbox nzombies/Docs/AMMO_MODS.md` "Upgrades"; II's cooldown is the catalogue's, not here):
/// - I is the HOST'S to read, for the killer it is handed (`Start`), because the host picks the life it broadcasts.
/// - III is the KILLER'S MACHINE'S. The user: *"For III make it so the decoy explodes at the end, dealing 1000% of the
/// weapon's damage"*. The gun can only be read there, so that machine snapshots it at the kill (`ArmLastStand`), and its
/// own copy of the human takes it up when it rises: the one copy whose burst hurts. Below III every burst stays harmless
/// (the user's 2026-10-04 18:35 decision).
///
/// ⚠️ TIERS IV AND V (2026-10-06, `AMMO_MODS.md` "Tiers IV and V"). The user: *"IV: the human damages zombies nearby every second
/// for 100% weapon damage in a 200u radius / V: the human runs towards downed players, and revives them by being near them"*.
/// - IV is the KILLER'S MACHINE'S, as III is: the gun is snapshotted at the kill beside III's burst (`ArmLastStand`), and only that
/// machine's copy of the human hurts (`ReanimatorHuman.TickBait`). Every machine runs a human; the damage is dealt once.
/// - V is the HOST'S to decide, at the rise, and ⛔ IT SAYS SO: `NZNet.ReanimatorFx`'s `samaritan` (2026-10-07, the review). The
/// first build read V off the spot sent, so `PickFlee` had to keep EVERY plain human clear of the downed — a change at levels
/// 0-IV, and a human with nowhere clear to run stood still. The spot the host sends is the downed player's own (`Start`), where
/// each machine finds that first patient (`ReanimatorHuman.Spawn`). From there each copy keeps the same rules on the same synced
/// facts — who is down, and where — and the host's copy does the one thing that counts: the revive, through the game's own
/// (`NZPlayer.Revive`).
/// - ⚠️ SO V IS FOR WHOEVER IS DOWN WHEN IT RISES. One risen with nobody down runs off as before and stays a plain human for its
/// whole life (the host sent it as one). One that rose for somebody keeps at it — the next downed player, even one who fell
/// meanwhile — until its time is up.
/// - ⚠️ CO-OP, MOSTLY: a solo player goes down only with a self-revive coming (`NZPlayer.CanBeRevived`, unchanged — the user hasn't
/// decided whether a human should count as a reviver), and then the human may pick them up before Quick Revive does.
/// </summary>
public static class Reanimator
{
// ══ tuning ═══════════════════════════════════════════════════════════════
//
// ⛔ NULLABLE-BACKED GETTERS — INSTRUCTIONS.md §1.
static float? _life;
/// <summary>How long the human lasts. 6s — the user's.</summary>
public static float Life { get => _life ?? 6f; set => _life = value; }
static float? _speed;
/// <summary>How fast it runs. 200 u/s — GMod's `SetDesiredSpeed( 200 )`.</summary>
public static float Speed { get => _speed ?? 200f; set => _speed = value; }
static float? _flee;
/// <summary>
/// How far off the spot it runs for is. 900u — a little more than six seconds can cover, so it never arrives early.
/// ⚠️ AT I LONG LURE'S 10s IT ARRIVES WITH ABOUT 4s LEFT (2026-10-05) and stands in `react`, still the lure (`LongLure`).
/// </summary>
public static float Flee { get => _flee ?? 900f; set => _flee = value; }
static float? _lure;
/// <summary>How far away a zombie may be and still be drawn to it. 1200u.</summary>
public static float LureRadius { get => _lure ?? 1200f; set => _lure = value; }
static float? _react;
/// <summary>How long it stands in `react` before it runs. 0.8s.</summary>
public static float ReactSeconds { get => _react ?? 0.8f; set => _react = value; }
static float? _runRate;
/// <summary>The run clip's playback rate. 1 — the clip's own speed.</summary>
public static float RunRate { get => _runRate ?? 1f; set => _runRate = value; }
// ══ upgrades (2026-10-05) ════════════════════════════════════════════════
//
// ⚠️ EACH UPGRADED NUMBER IS ITS OWN TUNABLE BESIDE THE BASE: I through `LifeFor`, III through `ArmLastStand` (§3).
static float? _longLure;
/// <summary>
/// I LONG LURE: how long the human lasts. 10s (`Life`, 6s).
///
/// ⚠️ THE RUN IS NOT LENGTHENED: its spot is still `Flee` off, so it gets there with seconds to spare and stands in `react`
/// for the rest (`Step`), still the lure.
/// </summary>
public static float LongLure { get => _longLure ?? 10f; set => _longLure = value; }
static float? _lastStandScale;
/// <summary>III LAST STAND: the burst, as a multiple of the killing gun's damage (`AmmoMods.WeaponDamage`). 10 — 1000%.</summary>
public static float LastStandScale { get => _lastStandScale ?? 10f; set => _lastStandScale = value; }
static float? _lastStandRadius;
/// <summary>III LAST STAND: the burst's reach. 250u, the pack chasing it (GMod's own burst reached 160u).</summary>
public static float LastStandRadius { get => _lastStandRadius ?? 250f; set => _lastStandRadius = value; }
// ── tiers IV and V (2026-10-06, `AMMO_MODS.md` "Tiers IV and V") ──
//
// ⚠️ EACH ITS OWN TUNABLE (§3), NULLABLE-BACKED (§1): IV through `LiveBaitFor`. V's revive time is the game's own
// (`ReviveAugments.ReviveSeconds`, 3s, with no Quick Revive speed-up), not a number of this file's.
static float? _liveBaitScale;
/// <summary>IV LIVE BAIT: what each zombie near the human takes a tick, as a multiple of the killing gun's damage. 1 — 100%.</summary>
public static float LiveBaitScale { get => _liveBaitScale ?? 1f; set => _liveBaitScale = value; }
static float? _liveBaitRadius;
/// <summary>IV LIVE BAIT: how near the human a zombie must be to take it. 200u — the user's.</summary>
public static float LiveBaitRadius { get => _liveBaitRadius ?? 200f; set => _liveBaitRadius = value; }
static float? _liveBaitEvery;
/// <summary>IV LIVE BAIT: how often it hurts. 1s — the user's "every second": ten ticks in I's 10s.</summary>
public static float LiveBaitEvery { get => _liveBaitEvery ?? 1f; set => _liveBaitEvery = value; }
static float? _samaritanReach;
/// <summary>V GOOD SAMARITAN: how near a downed player the human must be to pick them up. 60u (a player's is 90u, `ReviveAugments.ReviveReach`).</summary>
public static float SamaritanReach { get => _samaritanReach ?? 60f; set => _samaritanReach = value; }
/// <summary>
/// V: how near the spot the host sent a downed player must lie for a Good Samaritan's human to take them as its first patient
/// (`ReanimatorHuman.Spawn`). 96u: the host sends the patient's own navmesh point, and only one within half of this (`Start`),
/// so a crawl still in flight to another machine cannot carry them out of it. Half of it is also how far off the navmesh a
/// patient may lie and still be walked to (`NearestPatient`).
///
/// ⛔ A CONST, NOT A TUNABLE: every machine must give the same answer, and a console value is one machine's (§1's own rule — what
/// must not drift is a `const`).
/// </summary>
internal const float SamaritanMatch = 96f;
public const string Model = "models/nz/vr11_human/vr11_human.vmdl";
// ══ the effect ═══════════════════════════════════════════════════════════
/// <summary>
/// The mod went off on a kill. THE KILLER'S MACHINE (`KillMods`): done here on the host, asked of it otherwise.
/// </summary>
/// <param name="killer">Whose mod it is, for its upgrades (2026-10-05). Null reads as level 0.</param>
public static void Fire( GameObject victim, Vector3 at, NZPlayer killer = null )
{
// ⚠️ III IS ARMED FIRST, AND HERE: the gun can only be read on this machine, and the human rises from the broadcast. IV's
// tick with it (2026-10-06).
ArmLastStand( killer, at );
if ( Networking.IsActive && !NZGame.IsHost )
{
// ⚠️ NO LEVEL TRAVELS WITH THE ASK: the host reads I off its caller's synced levels (§36).
NZNet.ReanimatorAsk( at, victim.IsValid() ? victim.Id : Guid.Empty );
return;
}
Start( at, victim, killer );
}
/// <summary>
/// Raise the human where the zombie died, and send it running. THE HOST, or solo.
/// </summary>
/// <param name="killer">
/// Whose Re-Animator it is: the host's own player, or the client that asked (`NZNet.ReanimatorAsk`'s caller). Its level I
/// sets the life every machine is told, and its level V whether the human heads for the downed (2026-10-06). Null reads as
/// level 0.
/// </param>
public static void Start( Vector3 at, GameObject corpse, NZPlayer killer = null )
{
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) return;
var from = ZombieAI.NavGround( scene, at );
// ⚠️ V GOOD SAMARITAN (2026-10-06): the nearest downed player it can reach, in place of a random spot — and the broadcast says
// it is V (`samaritan`, 2026-10-07). Nobody down, or nobody it can reach: off as before, a plain human.
var patient = AmmoModUpgrades.Has( killer, "reanimator", 5 ) ? NearestPatient( scene, from, from ) : null;
var spot = patient.IsValid() ? ZombieAI.NavGround( scene, patient.WorldPosition ) : from;
// ⛔ ONLY A SPOT EVERY MACHINE WILL FIND THE PATIENT AT: within half of `SamaritanMatch` of them — a margin no crawl in flight
// can eat — and somewhere it actually runs to. A body further off the mesh (on a crate), or lying where the zombie fell, could
// be found by some machine and not another; it runs off as a plain human instead.
if ( patient.IsValid() && (spot.Distance( patient.WorldPosition ) > SamaritanMatch * 0.5f || spot.Distance( from ) <= 1f) )
patient = null;
var to = patient.IsValid() ? spot : PickFlee( scene, from );
// ⚠️ THE ZOMBIE BECAME THE HUMAN, so its body goes — the host's, which every machine's copy follows.
if ( corpse.IsValid() && corpse.Components.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors ) is { } ai )
SWB.Shared.GameObjectExtensions.DestroyAsync( ai.GameObject, 0.05f );
var life = MathF.Max( 1f, LifeFor( killer ) );
// ⚠️ EVERYBODY BUILDS ONE, THIS MACHINE INCLUDED — a broadcast runs here too, and solo it simply runs. V said outright.
NZNet.ReanimatorFx( from, to, life, patient.IsValid() );
Log.Info( $"[nz-ammo] RE-ANIMATOR — a human at {from} runs for {to} ({from.Distance( to ):0}u) · lures {LureRadius:0}u for {life:0.#}s"
+ (patient.IsValid() ? $" · V GOOD SAMARITAN, for {patient.GameObject.Name}, who is down" : "") );
}
/// <summary>How long this killer's human lasts: I's, or the base. THE HOST, which broadcasts it to everyone.</summary>
static float LifeFor( NZPlayer killer ) => AmmoModUpgrades.Has( killer, "reanimator", 1 ) ? LongLure : Life;
/// <summary>
/// Where to run: a random direction, `Flee` away, onto the navmesh, with a complete path to it. Up to eight tries, then
/// the reachable point the first one got closest to; with nothing at all, where it stands.
///
/// ⚠️ UNCHANGED BY V (2026-10-07): the first build kept every plain human clear of the downed, because V was read off the spot;
/// V is sent outright now (`NZNet.ReanimatorFx`'s `samaritan`), so this is the 2026-10-04 picker again.
/// </summary>
static Vector3 PickFlee( Scene scene, Vector3 from )
{
var nav = scene.NavMesh;
if ( nav is null ) return from;
Vector3? partial = null;
for ( var i = 0; i < 8; i++ )
{
var yaw = Game.Random.Float( 0f, 360f );
var aim = from + Rotation.FromYaw( yaw ).Forward * MathF.Max( 64f, Flee );
var spot = nav.GetRandomPoint( aim, 200f ) ?? nav.GetClosestPoint( aim );
if ( spot is not Vector3 to ) continue;
var path = nav.CalculatePath( new Sandbox.Navigation.CalculatePathRequest { Start = from, Target = to } );
if ( !path.IsValid || path.Points.Count < 2 ) continue;
if ( path.Status == Sandbox.Navigation.NavMeshPathStatus.Complete ) return to;
partial ??= path.Points[path.Points.Count - 1].Position;
}
return partial ?? from;
}
// ══ V GOOD SAMARITAN (2026-10-06) ════════════════════════════════════════
//
// ⛔ EVERY MACHINE ASKS THESE OF ITS OWN COPY, ON THE SAME SYNCED FACTS (`NZPlayer.IsDown`, mirrored from each owner, and the
// bodies' positions), so the copies choose alike without a message. The host's choice of the FIRST patient is the one that is
// sent (`Start`); after that each copy asks for itself, from where it stands — the same spot on every machine, near enough.
/// <summary>
/// V: a player the human may pick up — down, not out of the round, alive. `ReviveAugments.TargetFor`'s patient test, less what
/// is a rescuer's own (the reach, the one just picked up): the human keeps its own of those.
/// </summary>
internal static bool Revivable( NZPlayer p )
=> p.IsValid() && p.IsDown && !p.IsOutOfRound && p.Hp.IsValid() && !p.Hp.IsDead;
/// <summary>
/// V: the downed player nearest <paramref name="near"/>, within <paramref name="within"/> of it, that the human can walk to
/// from <paramref name="here"/> (a whole navmesh path), and not <paramref name="except"/>. Null for nobody.
///
/// ⚠️ A PLAYER IT CANNOT WALK TO IS SKIPPED, NOT CHASED (one on a ledge): a partial path would park the human at its end for the
/// rest of its life. The same mesh everywhere gives every machine the same verdict.
/// </summary>
internal static NZPlayer NearestPatient( Scene scene, Vector3 here, Vector3 near, float within = float.MaxValue,
NZPlayer except = null )
{
var nav = scene?.NavMesh;
if ( nav is null ) return null;
NZPlayer best = null;
var bestDist = float.MaxValue;
foreach ( var p in scene.GetAllComponents<NZPlayer>() )
{
if ( !Revivable( p ) || p == except ) continue;
var dist = near.Distance( p.WorldPosition );
if ( dist > within || dist >= bestDist ) continue;
// ⚠️ THE PATH ONLY FOR ONE THAT WOULD WIN, and none at all for one already in reach.
if ( here.Distance( p.WorldPosition ) > MathF.Max( 0f, SamaritanReach ) )
{
var spot = ZombieAI.NavGround( scene, p.WorldPosition );
// ⛔ NOT ONE LYING OFF THE MESH (2026-10-07, the review): on a crate or a car, the walkable point under them can be far
// from the body, and the human would stop at its path's end out of reach and stand there the rest of its life. Every
// leg asks this, not only the first (`Start`'s own test is the same margin).
if ( spot.Distance( p.WorldPosition ) > SamaritanMatch * 0.5f ) continue;
var path = nav.CalculatePath( new Sandbox.Navigation.CalculatePathRequest { Start = here, Target = spot } );
if ( !path.IsValid || path.Status != Sandbox.Navigation.NavMeshPathStatus.Complete ) continue;
}
best = p;
bestDist = dist;
}
return best;
}
// ══ III LAST STAND (2026-10-05) AND IV LIVE BAIT (2026-10-06) ═══════════════
//
// ⛔ THE DAMAGE IS DEALT ONCE, BY THE KILLER'S MACHINE, AND NOTHING NEW CROSSES THE WIRE FOR IT. Every machine runs its own
// human, and only the killer's can read the gun. So that machine arms the burst at the kill, and its own copy, when the
// broadcast raises it there, takes it up by where it rises: the host raises it at `ZombieAI.NavGround` of the very point
// this machine sends, which is where this machine armed it. Every other copy finds nothing and bursts harmlessly.
//
// ⚠️ IV RIDES ON THE SAME ARMING (2026-10-06): Live Bait's tick is a share of the same gun, read at the same kill, for the same
// human — `Bait` beside `Damage` — and the same copy deals it (`ReanimatorHuman.TickBait`), so it too is dealt once. The host's
// copy is the lure the zombies bunch round; the killer's walks the same path, so it stands among the same zombies.
/// <summary>
/// One kill's arming on THIS machine, waiting for its human: where it will rise, when, III's burst and IV's tick (each 0 below
/// its level), whose kill, and the gun.
/// </summary>
internal sealed record LastStand( Vector3 From, float Armed, float Damage, NZPlayer Killer, GameObject Weapon, float Bait );
/// <summary>
/// This machine's armed Last Stands. ⛔ A PROPERTY OVER A LAZY FIELD, NOT A `static readonly` INITIALISER (§1, the
/// `BulletDecals.Holes` shape): runtime state, emptied as each one is taken up or goes stale.
/// </summary>
static List<LastStand> _lastStands;
static List<LastStand> LastStands => _lastStands ??= new();
/// <summary>How long an armed Last Stand waits for its human: a client's ask goes to the host and back. Room to spare.</summary>
const float LastStandWait = 3f;
/// <summary>How near the point it was armed for a human must rise to take it. The same point either side, so normally 0.</summary>
const float LastStandMatch = 128f;
/// <summary>
/// III and IV: snapshot the killing gun's damage for the human about to rise, as Radioactive Decay snapshots its dose. THE
/// KILLER'S MACHINE, the only one that can read the gun (`AmmoMods.WeaponDamage`). Below III nothing is armed (IV has III).
/// </summary>
static void ArmLastStand( NZPlayer killer, Vector3 at )
{
if ( !AmmoModUpgrades.Has( killer, "reanimator", 3 ) ) return;
// ⚠️ ONE READ OF THE GUN FOR BOTH (2026-10-06): III's burst and IV's tick are shares of the same snapshot.
var gun = AmmoMods.WeaponDamage( killer );
var damage = gun * MathF.Max( 0f, LastStandScale );
var bait = LiveBaitFor( killer, gun );
var scene = Game.ActiveScene;
if ( (damage <= 0f && bait <= 0f) || !scene.IsValid() ) return;
PruneLastStands();
LastStands.Add( new LastStand( ZombieAI.NavGround( scene, at ), Time.Now, damage, killer,
VultureAugments.HeldWeapon( killer )?.GameObject, bait ) );
}
/// <summary>
/// IV LIVE BAIT: what each tick deals for this killer, out of the gun's damage <paramref name="gun"/> — 0 below IV, where the
/// human hurts nobody. THE KILLER'S MACHINE (`ArmLastStand`); the report asks it too, for you.
/// </summary>
static float LiveBaitFor( NZPlayer killer, float gun )
=> AmmoModUpgrades.Has( killer, "reanimator", 4 ) ? gun * MathF.Max( 0f, LiveBaitScale ) : 0f;
/// <summary>
/// The Last Stand this machine armed for a human rising at <paramref name="from"/>, taken off the list; null for every other
/// copy and every human below III. `ReanimatorHuman.Spawn`.
/// </summary>
internal static LastStand ClaimLastStand( Vector3 from )
{
if ( _lastStands is null || _lastStands.Count == 0 ) return null;
PruneLastStands();
var stand = _lastStands
.Where( s => s.From.Distance( from ) <= LastStandMatch )
.OrderBy( s => s.From.Distance( from ) )
.FirstOrDefault();
if ( stand is not null ) _lastStands.Remove( stand );
return stand;
}
/// <summary>Drop what no human rose for in time, and anything stamped by an earlier scene's clock (`Time.Now` restarts at 0).</summary>
static void PruneLastStands()
=> _lastStands?.RemoveAll( s => s.Armed > Time.Now || Time.Now - s.Armed > LastStandWait );
/// <summary>
/// III: the burst, for real, on the killer's machine, as the kill mods' splashes are dealt (`TechBlast.ModBlast`: zombies
/// only, line of sight, a blast and not a bullet, so what it kills sets off no mod). Its explosion is announced at its reach
/// (`BlastEffect`), so every machine sees how far it went.
/// </summary>
internal static void LastStandBurst( LastStand stand, Vector3 at )
{
// ⚠️ AND NOTHING FOR AN ARMING WITH NO BURST IN IT (2026-10-06): IV's tick alone can arm one, with III tuned to 0.
if ( stand is null || stand.Damage <= 0f || !stand.Killer.IsValid() ) return;
var weapon = stand.Weapon.IsValid() ? stand.Weapon : VultureAugments.HeldWeapon( stand.Killer )?.GameObject;
var hitAt = new List<Vector3>();
// ⚠️ ITS HITS ROLL NO MOD (2026-10-05): a human's whole life after the kill (III owns I's 10s), the hand may hold a hit-mod
// gun off its cooldown, and a mod's splash must not set one off (`AmmoMods.WithoutProcs`).
AmmoMods.WithoutProcs( stand.Killer, () =>
TechBlast.ModBlast( at, stand.Damage, MathF.Max( 0f, LastStandRadius ), stand.Killer.GameObject, weapon, hitAt ) );
Log.Info( $"[nz-ammo] RE-ANIMATOR LAST STAND — {stand.Damage:0} to {hitAt.Count} zombie(s) within {LastStandRadius:0}u" );
}
// ══ diagnostics ══════════════════════════════════════════════════════════
/// <summary>`nz_reanimator` — the resolved numbers, and whether the model is there.</summary>
[ConCmd( "nz_reanimator" )]
public static void Report()
{
var mod = AmmoMods.Find( "reanimator" );
var model = Sandbox.Model.Load( Model );
// ⚠️ YOUR COOLDOWN, II's in it (2026-10-05, `AmmoMods.BaseCooldown`): the catalogue's at level 0.
Log.Info( $"[nz-ammo] RE-ANIMATOR · {(mod?.Chance ?? 0f) * 100f:0.#}% a kill,"
+ $" {AmmoMods.BaseCooldown( NZPlayer.Local, mod ):0.#}s cooldown"
+ $" · {Life:0.#}s · runs {Speed:0} u/s for a spot {Flee:0}u off · lures {LureRadius:0}u"
+ $" · model {(model is null || model.IsError ? "MISSING (has the editor compiled it?)" : "ok")}"
+ $" · {ReanimatorHuman.Lures.Count} human(s) luring on this machine" );
Log.Info( $"[nz-ammo] upgrades: I lasts {LongLure:0.#}s · II {AmmoMods.QuickReturnCooldown:0.#}s cooldown"
+ $" · III bursts for {LastStandScale * 100f:0}% of the gun within"
+ $" {LastStandRadius:0}u · you: level {AmmoModUpgrades.Level( NZPlayer.Local, "reanimator" )}"
+ $" · {_lastStands?.Count ?? 0} Last Stand(s) armed here" );
// ⚠️ IV AND V (2026-10-06). Your tick is `LiveBaitFor` on the gun in your hand NOW; the real one is snapshotted at the kill.
var me = NZPlayer.Local;
var humans = Game.ActiveScene?.GetAllComponents<ReanimatorHuman>().Where( h => h.IsValid() ).ToList()
?? new List<ReanimatorHuman>();
Log.Info( $"[nz-ammo] IV hurts {LiveBaitScale * 100f:0}% of the gun within {LiveBaitRadius:0}u every {LiveBaitEvery:0.#}s"
+ $" ({LiveBaitFor( me, AmmoMods.WeaponDamage( me ) ):0} a tick for you now)"
+ $" · V heads for the downed and picks them up within {SamaritanReach:0}u in {ReviveAugments.ReviveSeconds:0.#}s"
+ $" · {humans.Count} human(s) on this machine, {humans.Count( h => h.IsSamaritan )} of them Good Samaritans" );
}
/// <summary>
/// `nz_reanimator_test [down]` — raise a human in front of you, no kill needed. Your upgrades count, as on a kill.
///
/// ⚠️ V NEEDS SOMEBODY DOWN WHEN IT RISES (2026-10-06). Solo that can only be you, with Quick Revive (`nz_quickrevive 1`) —
/// whose self-revive comes 5s after the fall (`NZPlayer.SelfReviveTime`), too soon to type a second command. So `down` puts you
/// down first and raises the human in the same breath: 200u off, it has you up in about 4.5s, just ahead of the perk. With
/// `nz_ammomod_level reanimator 5`; solo or on the host (a client's own fall can reach the host after its ask does).
/// </summary>
[ConCmd( "nz_reanimator_test" )]
public static void TestCmd( string mode = "" )
{
var p = NZPlayer.Local;
if ( !p.IsValid() ) return;
// ⚠️ DOWN THROUGH THE REVIVE TEST'S OWN COMMAND (`nz_revive_down`). It needs somebody who could revive you — Quick Revive
// solo, or a teammate up (`NZPlayer.CanBeRevived`, unchanged) — or that hit KILLS, and the human has nobody to come for.
if ( mode.Equals( "down", StringComparison.OrdinalIgnoreCase ) && !p.IsDown ) ReviveAugments.DownCmd();
var at = p.WorldPosition + p.EyeAngles.Forward.WithZ( 0f ).Normal * 200f;
// ⚠️ THROUGH `Fire`, the kill's own path (2026-10-05): the host raises it or is asked to, and III and IV are armed at your
// level — V's patient is the host's to find, as on a kill.
Fire( null, at, p );
}
/// <summary>`nz_reanimator_set <key> <value>` — retune one number live.</summary>
[ConCmd( "nz_reanimator_set" )]
public static void SetCmd( string key = "", float value = 0f )
{
switch ( key.ToLowerInvariant() )
{
case "life": Life = value; break;
case "speed": Speed = value; break;
case "flee": Flee = value; break;
case "lure": LureRadius = value; break;
case "react": ReactSeconds = value; break;
case "runrate": RunRate = value; break;
case "longlure": LongLure = value; break;
case "laststand": LastStandScale = value; break;
case "laststandradius": LastStandRadius = value; break;
case "livebait": LiveBaitScale = value; break;
case "livebaitradius": LiveBaitRadius = value; break;
case "livebaitevery": LiveBaitEvery = value; break;
case "samaritanreach": SamaritanReach = value; break;
default:
Log.Info( "[nz-ammo] nz_reanimator_set <life|speed|flee|lure|react|runrate|longlure|laststand|laststandradius"
+ "|livebait|livebaitradius|livebaitevery|samaritanreach> <value>" );
return;
}
Report();
}
}
/// <summary>
/// One re-animated human, on THIS machine: the VR-11's agent, standing in `react`, then running `run_b` along the navmesh
/// to the spot the host picked, screaming, until it bursts. The host's copy is also a lure (<see cref="Lures"/>).
///
/// ⚠️ AT V A GOOD SAMARITAN'S (2026-10-06) runs to the downed instead, stands over each in `react` for the revive's length — its
/// clock held — and runs off when nobody is left (`TickSamaritan`). Only the host's copy stands anybody up.
/// </summary>
public sealed class ReanimatorHuman : Component
{
/// <summary>The humans the zombies may chase — the HOST'S copies only.</summary>
public static readonly List<ReanimatorHuman> Lures = new();
public bool Lure { get; set; }
public float Life { get; set; } = 6f;
/// <summary>The spot the host picked: a random one, or at V the downed player's own (`Reanimator.Start`).</summary>
Vector3 _to;
List<Vector3> _path = new();
int _leg;
float _born;
bool _running;
SkinnedModelRenderer _r;
TimeUntil _nextScream;
TimeUntil _nextPull;
/// <summary>III and IV: the arming this copy carries to its burst. Set on the killer's machine only; null on every other.</summary>
Reanimator.LastStand _stand;
/// <summary>IV: Live Bait's ticks paid, counted from the rise, and the hits they made (the burst's log line).</summary>
int _baitTicks, _baitHits;
// ── V GOOD SAMARITAN (2026-10-06) ──
//
// ⚠️ PLAIN FIELDS ON A COMPONENT: one built before a hotload gets their defaults (a plain human, nobody to go to) and carries on.
/// <summary>Where it rose — with `_to`, the heading it runs off along once nobody is left to pick up (`OffSpot`).</summary>
Vector3 _from;
/// <summary>V: read at the rise (`Spawn`), the same way on every machine. Never set afterwards.</summary>
bool _samaritan;
/// <summary>The downed player it is going to, or picking up. Null while it runs off.</summary>
NZPlayer _patient;
/// <summary>How long it has been picking `_patient` up, by this copy's own clock. 0 when it isn't.</summary>
float _reviving;
/// <summary>How long pick-ups have held its clock, all of them together (`HoldCap` at most).</summary>
float _held;
/// <summary>The last one it picked up, left alone for a moment (`ReviveAugments.JustRevivedGrace`), and since when.</summary>
NZPlayer _justRevived;
TimeSince _sinceRevived;
/// <summary>Where its path was last asked to end, and when it may ask again: a patient crawls.</summary>
Vector3 _pathFor;
TimeUntil _nextRepath;
/// <summary>When it next looks for somebody down while it runs off.</summary>
TimeUntil _nextLook;
/// <summary>Where it runs off to once nobody is left to pick up. Picked once (`OffSpot`).</summary>
Vector3? _off;
/// <summary>
/// V: the most pick-ups can hold its clock, all together. 30s — ten revives, more than any crew has downed at once. A bound, so
/// no copy can outlive its life by more, and the safety destroy (`Spawn`) knows how long to wait.
/// </summary>
const float HoldCap = 30f;
/// <summary>V: how often a copy running off looks for somebody newly down. Every machine looks at about the same moment.</summary>
const float LookEvery = 0.25f;
/// <summary>V: how often the path to a crawling patient may be asked again, and how far they must have crawled off its end.</summary>
const float RepathEvery = 0.5f;
const float RepathMove = 32f;
/// <summary>V: is this a Good Samaritan's human — `nz_reanimator` counts them.</summary>
public bool IsSamaritan => _samaritan;
/// <summary>Build this machine's human at <paramref name="from"/>, running for <paramref name="to"/>. `NZNet.ReanimatorFx`.</summary>
/// <param name="samaritan">V Good Samaritan's, as the host decided at the rise (2026-10-07).</param>
public static void Spawn( Vector3 from, Vector3 to, float life, bool lure, bool samaritan = false )
{
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) return;
var model = Sandbox.Model.Load( Reanimator.Model );
if ( model is null || model.IsError )
{
Log.Warning( $"[nz-ammo] re-animator: '{Reanimator.Model}' is missing or not compiled — no human" );
return;
}
var go = scene.CreateObject();
go.Name = "nz_reanimated_human";
go.Flags |= GameObjectFlags.NotSaved;
go.NetworkMode = NetworkMode.Never; // ⛔ THIS MACHINE'S OWN — every machine builds one (§39)
go.WorldPosition = from;
go.WorldRotation = Rotation.LookAt( (to - from).WithZ( 0f ).IsNearlyZero() ? Vector3.Forward : (to - from).WithZ( 0f ) );
var r = go.Components.Create<SkinnedModelRenderer>();
r.Model = model;
// ⚠️ NO ANIMGRAPH: the clips are played by name, and a graph would own the skeleton (INSTRUCTIONS, Step 4b's table).
r.UseAnimGraph = false;
r.Sequence.Name = "react";
var h = go.Components.Create<ReanimatorHuman>();
h.Lure = lure;
h.Life = life;
h._to = to;
h._from = from;
h._r = r;
// ⚠️ III (2026-10-05): the Last Stand this machine armed for a human rising here, if this is the killer's machine.
h._stand = Reanimator.ClaimLastStand( from );
// ⛔ V IS SAID OUTRIGHT (2026-10-07, the review): the host sends `samaritan`, so no plain human is ever taken for one and
// `Reanimator.PickFlee` no longer has to keep plain humans clear of the downed. Its first patient is whoever lies where the
// host sent it, the one the host chose; found by nobody here (a crawl carried them off), the first leg looks again (`NextLeg`).
h._samaritan = samaritan;
h._patient = samaritan && from.Distance( to ) > 1f
? Reanimator.NearestPatient( scene, from, to, Reanimator.SamaritanMatch )
: null;
// ⚠️ THE SAFETY NET WAITS OUT A HELD CLOCK TOO: a Good Samaritan's can stand still for `HoldCap` more.
SWB.Shared.GameObjectExtensions.DestroyAsync( go, life + (h._samaritan ? HoldCap : 0f) + 0.5f );
}
protected override void OnStart()
{
_born = Time.Now;
_nextScream = 0.2f;
if ( Lure )
{
Lures.Add( this );
// ⚠️ THE ONES IN REACH TURN NOW, not on their next acquire 3-15 seconds away (`BananaStand.PullNearby`).
PullNearby();
}
// ⚠️ THE VR-11'S HIT, AS THE ZOMBIE TURNS.
Sound.Play( "nz.vr11.impact", WorldPosition + Vector3.Up * 40f );
}
protected override void OnDestroy()
{
// ⚠️ V: GONE IN THE MIDDLE OF A PICK-UP (its hold's cap, a new game) — the patient's bar goes with it, not on to nothing.
LetGo();
if ( !Lures.Remove( this ) ) return;
// ⚠️ AND WHEN IT GOES, EVERY ZOMBIE LOOKS AGAIN — the ones that were after it would otherwise walk on to where it was.
ZombieAI.ForceRetargetAll();
}
protected override void OnUpdate()
{
// ⚠️ V: ITS CLOCK IS THE TIME SINCE IT ROSE LESS WHAT PICK-UPS HELD (2026-10-06) — a revive stops it, and it stays till done.
var age = Time.Now - _born - _held;
// ⚠️ IV BEFORE THE END, so a tick due on the burst's own frame is paid (ten in I's 10s).
TickBait();
if ( age >= Life )
{
Burst();
return;
}
// ── the beat of `react`, then the run ──
if ( !_running && age >= MathF.Max( 0f, Reanimator.ReactSeconds ) )
{
_running = true;
// ⚠️ V: TO ITS PATIENT, wherever they have crawled since it rose — or, if somebody got to them first, the next, or off.
if ( !_samaritan ) Go( _to );
else if ( Reanimator.Revivable( _patient ) ) Go( PatientSpot( _patient ) );
else Go( NextLeg() );
Sound.Play( "nz.vr11.timer", WorldPosition + Vector3.Up * 40f );
}
// ⚠️ V: PICKING SOMEBODY UP HOLDS IT WHERE IT STANDS.
var holding = _running && _samaritan && TickSamaritan();
if ( _running && !holding ) Step();
// ⚠️ A SCREAM EVERY TWO TO FOUR SECONDS, GMod's own cadence (`math.Rand( 2, 4 )`).
if ( _nextScream )
{
_nextScream = Game.Random.Float( 2f, 4f );
Sound.Play( "nz.vr11.scream", WorldPosition + Vector3.Up * 60f );
}
// ⚠️ THE HOST'S COPY KEEPS PULLING: zombies that come into reach mid-run turn as they do (the chase tick's own check
// covers most; this covers the ones whose retarget is still seconds off). GMod's MonkeyBomb ran each scream.
if ( Lure && _nextPull )
{
_nextPull = 0.5f;
PullNearby();
}
}
/// <summary>One frame of the run along the path: `Speed`, facing the way it goes. At the end of the path it stands in `react`.</summary>
void Step()
{
if ( _leg >= _path.Count )
{
if ( _r.IsValid() && _r.Sequence.Name != "react" )
{
_r.Sequence.Name = "react";
_r.PlaybackRate = 1f;
}
return;
}
var here = WorldPosition;
var next = _path[_leg];
var step = MathF.Max( 0f, Reanimator.Speed ) * Time.Delta;
var to = next - here;
var flat = to.WithZ( 0f );
if ( to.Length <= step )
{
WorldPosition = next;
_leg++;
}
else
{
WorldPosition = here + to.Normal * step;
}
Face( flat );
}
/// <summary>Turn toward <paramref name="flat"/>, a heading along the ground: the run's, or (V) the patient's.</summary>
void Face( Vector3 flat )
{
if ( flat.Length > 1f )
WorldRotation = Rotation.Slerp( WorldRotation, Rotation.LookAt( flat.Normal ), MathF.Min( 1f, Time.Delta * 12f ) );
}
/// <summary>
/// Run for <paramref name="target"/> along the navmesh, from here, in `run_b`: a plain human's one run, and each of a Good
/// Samaritan's (2026-10-06). ⚠️ A RUN ALREADY UNDER WAY KEEPS ITS CLIP, so a path asked again does not restart the stride.
/// </summary>
void Go( Vector3 target )
{
_path = PathTo( WorldPosition, target );
_leg = 0;
_pathFor = target;
_nextRepath = RepathEvery;
if ( _r.IsValid() && _r.Sequence.Name != "run_b" )
{
_r.Sequence.Name = "run_b";
_r.PlaybackRate = MathF.Max( 0.05f, Reanimator.RunRate );
}
}
/// <summary>The navmesh route, as points to walk. A straight line when there is no mesh or no route.</summary>
static List<Vector3> PathTo( Vector3 from, Vector3 to )
{
var nav = Game.ActiveScene?.NavMesh;
if ( nav is not null )
{
var path = nav.CalculatePath( new Sandbox.Navigation.CalculatePathRequest { Start = from, Target = to } );
if ( path.IsValid && path.Points.Count > 1 )
return path.Points.Skip( 1 ).Select( p => p.Position ).ToList();
}
return new List<Vector3> { to };
}
/// <summary>Turn every zombie in reach that is not after it yet. HOST.</summary>
void PullNearby()
{
var reach = MathF.Max( 0f, Reanimator.LureRadius );
var at = WorldPosition;
foreach ( var z in ZombieAI.All.ToList() )
{
if ( !z.IsValid() || z.State == ZombieState.Dead ) continue;
if ( z.Target == GameObject ) continue;
if ( at.Distance( z.WorldPosition ) > reach ) continue;
z.ForceRetarget();
}
}
// ══ V GOOD SAMARITAN (2026-10-06) ════════════════════════════════════════
/// <summary>
/// V, one frame: after its patient, picking them up once in reach, then the next — or off. True while it is picking somebody
/// up: it stands, and its clock holds (`_held`).
///
/// ⛔ EVERY COPY RUNS THIS ALIKE, ON THE SAME SYNCED FACTS, SO THEY GO THE SAME WAY — AND ONLY THE HOST'S COPY STANDS ANYBODY UP,
/// through `NZPlayer.Revive`: the funnel every revive takes, a teammate's included (`ReviveAugments.Complete`), which relays to
/// the patient's own machine. The others run their own pick-up clock alongside and go on when it ends, as the host's does.
///
/// ⚠️ THE REVIVE'S OWN LENGTH, `ReviveAugments.ReviveSeconds` (3s): a player's bare speed, as the human holds no Quick Revive.
/// </summary>
bool TickSamaritan()
{
// ⚠️ GONE — stood up by somebody else, bled out, or left the game: let go, and the next.
if ( _patient is not null && !Reanimator.Revivable( _patient ) )
{
LetGo();
Go( NextLeg() );
}
// ── running off: somebody going down turns it round ──
if ( _patient is null )
{
if ( !_nextLook ) return false;
_nextLook = LookEvery;
var next = NextPatient();
if ( !next.IsValid() ) return false;
_patient = next;
Go( PatientSpot( next ) );
return false;
}
var at = _patient.WorldPosition;
// ── not there yet: after them ──
if ( WorldPosition.Distance( at ) > MathF.Max( 0f, Reanimator.SamaritanReach ) )
{
// ⚠️ A PICK-UP THEY CRAWLED OUT OF STARTS OVER, as a player's does when they step away (`ReviveAugments.Tick`).
if ( _reviving > 0f ) LetGo( keep: true );
// ⚠️ AND THE PATH FOLLOWS THEM, asked again once they have crawled off its end.
if ( _nextRepath )
{
_nextRepath = RepathEvery;
var spot = PatientSpot( _patient );
if ( spot.Distance( _pathFor ) > RepathMove ) Go( spot );
}
return false;
}
// ── in reach: picking them up ──
if ( _reviving <= 0f ) BeginRevive();
_reviving += Time.Delta;
_held = MathF.Min( HoldCap, _held + Time.Delta );
Face( (at - WorldPosition).WithZ( 0f ) );
if ( _reviving < MathF.Max( 0.05f, ReviveAugments.ReviveSeconds ) ) return true;
// ⚠️ DONE. The host's copy stands them up; every copy leaves them be for a moment and goes on.
var patient = _patient;
_patient = null;
_reviving = 0f;
_justRevived = patient;
_sinceRevived = 0f;
if ( Lure )
{
patient.Revive();
Log.Info( $"[nz-ammo] RE-ANIMATOR GOOD SAMARITAN — {patient.GameObject.Name} back up after {ReviveAugments.ReviveSeconds:0.#}s"
+ $" · {MathF.Max( 0f, Life - (Time.Now - _born - _held) ):0.#}s left in it, {_held:0.#}s held in all" );
}
Go( NextLeg() );
return false;
}
/// <summary>
/// A pick-up starts: it stands in `react`, and the patient is shown the bar (`NZPlayer.BeingRevived`).
///
/// ⚠️ THE BAR COMES FROM THE PATIENT'S OWN MACHINE'S COPY — the local half of `ReviveAugments.Announce`. Every machine runs a
/// human, and theirs reaches them when the host's does, so nothing has to be sent to tell them help is here.
/// </summary>
void BeginRevive()
{
if ( _r.IsValid() && _r.Sequence.Name != "react" )
{
_r.Sequence.Name = "react";
_r.PlaybackRate = 1f;
}
if ( Mine( _patient ) ) _patient.BeingRevivedBy( MathF.Max( 0.05f, ReviveAugments.ReviveSeconds ) );
}
/// <summary>Stop picking the patient up — their bar stops with it — and let go of them too unless <paramref name="keep"/>.</summary>
void LetGo( bool keep = false )
{
if ( _reviving > 0f && Mine( _patient ) ) _patient.BeingRevivedBy( 0f );
_reviving = 0f;
if ( !keep ) _patient = null;
}
/// <summary>V: where now — the nearest downed player it can reach (not the one just picked up, for a moment), or off.</summary>
Vector3 NextLeg()
{
_patient = NextPatient();
_nextLook = LookEvery;
return _patient.IsValid() ? PatientSpot( _patient ) : OffSpot();
}
/// <summary>
/// V: the nearest downed player it can reach from where it stands. ⚠️ THE ONE IT JUST PICKED UP IS LEFT ALONE FOR
/// `ReviveAugments.JustRevivedGrace`, as a player rescuer leaves theirs (`ReviveAugments.TargetFor`): until their own machine's
/// stand-up arrives, every copy here still reads them as down.
/// </summary>
NZPlayer NextPatient()
=> Reanimator.NearestPatient( Scene, WorldPosition, WorldPosition,
except: _sinceRevived < ReviveAugments.JustRevivedGrace ? _justRevived : null );
/// <summary>Where a patient lies on the navmesh: what a path is asked for.</summary>
static Vector3 PatientSpot( NZPlayer p ) => ZombieAI.NavGround( Game.ActiveScene, p.WorldPosition );
/// <summary>
/// V: where it runs off once nobody is left to pick up — onward, along the heading from where it rose (`_from`) to its first
/// patient (`_to`), `Flee` on. Picked once.
///
/// ⛔ NO RANDOM HERE: every machine must pick the same spot, and only the host could roll one (`Reanimator.PickFlee`) — the one
/// spot it sent was the patient's. So the heading comes from the two points every machine was sent, the spot is the navmesh's
/// nearest to the aim, and the turns are tried in one fixed order until one has a whole path. Nowhere at all: it stays, the lure.
/// </summary>
Vector3 OffSpot()
{
if ( _off is Vector3 picked ) return picked;
var here = WorldPosition;
var heading = (_to - _from).WithZ( 0f );
var yaw = heading.Length >= 64f ? Rotation.LookAt( heading ).Yaw() : 0f;
var far = MathF.Max( 64f, Reanimator.Flee );
var nav = Scene?.NavMesh;
_off = here;
if ( nav is null ) return here;
for ( var i = 0; i < 8; i++ )
{
// ⚠️ 0, +45, −45, +90, −90, +135, −135, 180: straight on first, then ever wider.
var turn = (i + 1) / 2 * 45f * (i % 2 == 1 ? 1f : -1f);
var aim = _to + Rotation.FromYaw( yaw + turn ).Forward * far;
if ( nav.GetClosestPoint( aim ) is not Vector3 spot ) continue;
var path = nav.CalculatePath( new Sandbox.Navigation.CalculatePathRequest { Start = here, Target = spot } );
if ( !path.IsValid || path.Points.Count < 2 || path.Status != Sandbox.Navigation.NavMeshPathStatus.Complete ) continue;
_off = spot;
break;
}
return _off.Value;
}
/// <summary>Is this player this machine's own body — the one whose bar this machine shows. Solo, the only one.</summary>
static bool Mine( NZPlayer p ) => p.IsValid() && (!Networking.IsActive || PlayerPresence.Mine( p.GameObject ));
// ══ IV LIVE BAIT (2026-10-06) ════════════════════════════════════════════
/// <summary>
/// IV, one frame: every `LiveBaitEvery` of its life, each zombie within `LiveBaitRadius` of it takes the killer's snapshot
/// (`Reanimator.LastStand.Bait`), credited to the killer. THE KILLER'S MACHINE'S COPY ONLY — the one that claimed the arming;
/// every other copy holds none, so it is dealt once.
///
/// ⚠️ COUNTED FROM THE RISE, NOT A TIMER RE-ARMED AS IT FIRES (Gravity Well's shreds): a late frame pays the tick it owes. And by
/// the time it has LIVED, held pick-ups included — the crowd round a Good Samaritan's patient keeps taking it.
///
/// ⚠️ NO LINE OF SIGHT, unlike III's burst: an aura round the human, as the knot's shreds are, and its zombies are the ones on
/// its heels.
/// </summary>
void TickBait()
{
var stand = _stand;
if ( stand is null || stand.Bait <= 0f || !stand.Killer.IsValid() ) return;
var every = MathF.Max( 0.1f, Reanimator.LiveBaitEvery );
var owed = (int)MathF.Floor( (Time.Now - _born) / every + 0.001f );
var rounds = owed - _baitTicks;
if ( rounds <= 0 ) return;
// ⚠️ CLAIMED BEFORE THE DAMAGE, as a cooldown is stamped before its effect: a death inside cannot have them paid twice.
_baitTicks = owed;
var at = WorldPosition;
var reach = MathF.Max( 0f, Reanimator.LiveBaitRadius );
var hits = 0;
// ⛔ ITS HITS ROLL NO MOD (`AmmoMods.WithoutProcs`): ten seconds and more of hits after the kill, with any gun in the hand.
AmmoMods.WithoutProcs( stand.Killer, () =>
{
for ( var n = 0; n < rounds; n++ )
{
// ⚠️ DOWNWARD, WITH A BOUNDS CHECK (`TechBlast.Detonate`): a kill can take a zombie out of `All` mid-walk.
for ( var 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 ( at.Distance( z.WorldPosition ) > reach ) continue;
var hp = z.Components.Get<Health>( FindMode.EverythingInSelf );
if ( !hp.IsValid() || hp.IsDead ) continue;
// ⚠️ NO WEAPON, as Gravity Well's shreds have none: a mod's damage must not pick up the gun's tech tree
// (`TechEffects.Of`). A blast, not a bullet: it pays no hit points (`Health.Pays`), and what it kills carries no
// mod (`AmmoMods.HitModOf`), so no kill mod goes off on it — another human least of all.
hp.OnDamage( new SWB.Shared.DamageInfo
{
Attacker = stand.Killer.GameObject,
Damage = stand.Bait,
Position = z.WorldPosition + Vector3.Up * (z.BodyHeight * 0.5f),
Origin = at,
Tags = [TechBlast.BlastTag],
} );
hits++;
}
}
} );
_baitHits += hits;
}
/// <summary>Its end: the VR-11's burst, on this machine, and gone. At III, on the killer's machine, it hurts.</summary>
void Burst()
{
var at = WorldPosition + Vector3.Up * 36f;
Sound.Play( "nz.vr11.explode", at );
BlastEffect.Spawn( at, 90f, announce: false );
// ⚠️ IV's TALLY (2026-10-06), on the killer's machine: the one copy that hurt.
if ( _stand is { Bait: > 0f } bait )
Log.Info( $"[nz-ammo] RE-ANIMATOR LIVE BAIT — {_baitTicks} tick(s) of {bait.Bait:0} within {Reanimator.LiveBaitRadius:0}u,"
+ $" {_baitHits} hit(s) in all" );
// ⚠️ TAKEN BEFORE IT IS DEALT, so it can only go off once.
var stand = _stand;
_stand = null;
Reanimator.LastStandBurst( stand, at );
GameObject.Destroy();
}
// ══ the AI's questions ═══════════════════════════════════════════════════
/// <summary>Add the humans luring <paramref name="from"/> to a zombie's candidate list (`ZombieAI.GetTargetables`).</summary>
public static void AddTargetsFor( Vector3 from, List<GameObject> into )
{
var reach = MathF.Max( 0f, Reanimator.LureRadius );
foreach ( var h in Lures )
if ( h.IsValid() && h.GameObject.IsValid() && from.Distance( h.WorldPosition ) <= reach )
into.Add( h.GameObject );
}
/// <summary>Is a point inside any human's lure — the chase tick's cheap test (`BananaStand.Luring`'s twin).</summary>
public static bool Luring( Vector3 at )
{
var reach = MathF.Max( 0f, Reanimator.LureRadius );
foreach ( var h in Lures )
if ( h.IsValid() && at.Distance( h.WorldPosition ) <= reach ) return true;
return false;
}
/// <summary>Is this a re-animated human — a swing at it lands on nothing (`ZombieAI`'s attack).</summary>
public static bool IsHuman( GameObject go )
=> go.IsValid() && go.Components.Get<ReanimatorHuman>( FindMode.EverythingInSelf ) is not null;
}