ClassTech and TechMarkOutline. Implements per-class weapon augment effects: victim-side damage multipliers and statuses (stuns, suppress, marker/share, silver bullets, point blank, follow-through, bank shot, etc.), kill payouts routing to the killer, holder-side effects (juggernaut, pain reload, lifeline, stamina/dynamo), ammo mod Catalyst, and outline handling for status marks. TechMarkOutline is a component that shows and updates highlight outlines for marked/spotted/local targets.
using Sandbox;
using System;
using System.Collections.Generic;
namespace NZombies;
/// <summary>
/// THE PER-CLASS WEAPON TECH'S EFFECTS THAT ARE NOT THE GUN'S OWN STATE (2026-10-04,
/// `Sbox nzombies/Docs/WEAPON_TECH_BY_CLASS.md`): what a hit does to a zombie (the stuns, Suppressive Fire, Spotter,
/// Marker, Silver Bullets, Point Blank, One Shot One Kill), what a kill gives back (Quartermaster, Recycler, the
/// Underbarrel Launcher's charge), and what the gun in your hands does to YOU (Juggernaut, Pain Reload, Lifeline,
/// Holster Reload, Skeleton Stock, Dynamo, Catalyst). The gun's own state — Select Fire's mode, the aim timers, the
/// cylinder Spin the Cylinder rolled — is in `SWB.Base.Weapon` (`Weapon.ClassTech.cs`).
///
/// ⛔ EACH HALF RUNS ON THE MACHINE THAT OWNS IT, the rule every hook in this project learned the hard way:
/// • a hit's effect on a zombie runs on the HOST, in `Health.OnDamage`, where a client's relayed hit arrives with its
/// weapon as a prefab path (`TechEffects.TechRef`) — the zombie's AI, its statuses and its health are all there;
/// • a kill's payout runs on the KILLER'S machine, because only it has the gun (`NZNet.TechKill`);
/// • what the held gun does to its holder runs on the HOLDER'S machine, where the gun exists at all.
///
/// ⚠️ EVERY NUMBER IS THE CATALOGUE'S (`WeaponTech`), read through `TechEffects.Mag`/`TechStats`, so `nz_tech` prints
/// what the game does.
/// </summary>
public static class ClassTech
{
// ══ the victim's side (HOST, `Health.OnDamage`) ═════════════════════════════════════════════
/// <summary>
/// The shooter's extra head multiplier: every owned `s.head` (Marksman Conversion ×1.5, Match Grade ×1.25,
/// Precision ×1.35), and One Shot One Kill's ×5. Rides the head product, so it multiplies everything else there.
/// </summary>
public static float HeadScale( in TechEffects.TechRef tech )
=> !tech.Valid ? 1f
: TechStats.Mul( tech, "s.head" ) * TechEffects.Mag( tech, "t5_sn_oneshot", "head" );
/// <summary>One Shot One Kill's fifth on everything that is not the head. 1 without it.</summary>
public static float BodyScale( in TechEffects.TechRef tech )
=> !tech.Valid ? 1f : TechEffects.Mag( tech, "t5_sn_oneshot", "body" );
/// <summary>
/// Silver Bullets (×3 bosses, ×2 specials, ×0.75 the rest) and Point Blank (×2 within 150 units): conditions on
/// WHO was hit and from WHERE, so they are the victim's half and run beside Boss Slayer.
/// </summary>
public static float VictimScale( in TechEffects.TechRef tech, GameObject victim, Vector3 hitAt )
{
if ( !tech.Valid || !victim.IsValid() ) return 1f;
var m = 1f;
if ( TechEffects.Has( tech, "t4_rv_silver" ) )
{
var ai = victim.Components.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors );
// ⚠️ BOSS FIRST: Brutus is on `SpecialEnemies.Names` too, so asking "special" first would pay him ×2.
if ( ai.IsValid() )
m *= DeathAugments.IsBoss( victim ) ? TechEffects.Mag( tech, "t4_rv_silver", "boss" )
: SpecialEnemies.IsSpecial( ai.Variant ) ? TechEffects.Mag( tech, "t4_rv_silver", "special" )
: TechEffects.Mag( tech, "t4_rv_silver", "normal" );
}
if ( InPointBlank( tech, hitAt ) )
m *= TechEffects.Mag( tech, "t5_sg_pointblank", "dmg" );
// ⚠️ TIERS 1–3's WHO-AND-FROM-WHERE TERMS (2026-10-04, WEAPON_TECH_TIERS_1_3.md), beside the two above and multiplied
// with them and with everything else in the hit: CQB Barrel's ×1.6, Solid Slug's ×4, Boss Slayer's, Match Grade's.
//
// ── Close Shave (SMG, tier 2): ×1.1 within 300 units of the shooter — Point Blank's range test ──
if ( InRange( tech, "t2_smg_closeshave", hitAt ) )
m *= TechEffects.Mag( tech, "t2_smg_closeshave", "dmg" );
// ── Hard Target (AR, tier 3): ×1.25 on bosses and special zombies — Silver Bullets' classification ──
if ( TechEffects.Has( tech, "t3_ar_hardtarget" ) && BossOrSpecial( victim ) )
m *= TechEffects.Mag( tech, "t3_ar_hardtarget", "dmg" );
// ── Range Finder (marksman, tier 3): +1% for every WHOLE 100 units from the shooter's eyes, up to +25% ──
if ( TechEffects.Has( tech, "t3_br_rangefinder" ) )
{
var step = MathF.Max( 1f, TechEffects.Mag( tech, "t3_br_rangefinder", "step", 0f ) );
var steps = MathF.Floor( tech.Player.EyePos.Distance( hitAt ) / step );
m *= 1f + MathF.Min( TechEffects.Mag( tech, "t3_br_rangefinder", "cap", 0f ),
steps * TechEffects.Mag( tech, "t3_br_rangefinder", "per", 0f ) );
}
// ── Riot Guard (shotgun, tier 3): ×1.25 within 150 units — Point Blank's own range test, so inside it the two make
// ×2.5 (the doc's note). Its 15% less damage taken is its `s.taken`, in `OnPlayerDamaged` ──
if ( InRange( tech, "t3_sg_riotguard", hitAt ) )
m *= TechEffects.Mag( tech, "t3_sg_riotguard", "dmg" );
// ── Silver Tips (revolver, tier 2): ×1.15 on bosses and special zombies, multiplied with Silver Bullets' ×3 / ×2 ──
if ( TechEffects.Has( tech, "t2_rv_silvertips" ) && BossOrSpecial( victim ) )
m *= TechEffects.Mag( tech, "t2_rv_silvertips", "dmg" );
// ── Finisher (handgun, tier 3): ×1.5 on a zombie below 25% of its health BEFORE this hit, bosses included ──
//
// ⚠️ THE HOST'S FIGURE. A client's copy of a zombie is never hurt (`Health.OnDamage` relays), so its own damage number
// leaves this term out; the hit itself is scaled here, on the host, where the health is real (Coup de Grâce's rule).
if ( TechEffects.Has( tech, "t3_hg_finisher" )
&& Wounded( victim, TechEffects.Mag( tech, "t3_hg_finisher", "health", 0f ) ) )
m *= TechEffects.Mag( tech, "t3_hg_finisher", "dmg" );
return m;
}
/// <summary>Is this a zombie below <paramref name="share"/> of its max health. False for anything that is not a zombie.</summary>
static bool Wounded( GameObject victim, float share )
{
var ai = victim.Components.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors );
return ai.IsValid() && ai.HealthMax > 0f && ai.HealthNow < ai.HealthMax * share;
}
/// <summary>Is this hit inside Point Blank's range of the shooter's eyes. False without the node.</summary>
static bool InPointBlank( in TechEffects.TechRef tech, Vector3 hitAt ) => InRange( tech, "t5_sg_pointblank", hitAt );
/// <summary>
/// Is this hit within the node's own `range` of the shooter's eyes — Point Blank's test, for any node that declares a
/// `range` (Close Shave, 2026-10-04). False without the node.
/// </summary>
static bool InRange( in TechEffects.TechRef tech, string nodeId, Vector3 hitAt )
=> TechEffects.Has( tech, nodeId )
&& tech.Player.EyePos.Distance( hitAt ) <= TechEffects.Mag( tech, nodeId, "range", 0f );
/// <summary>
/// A boss or a special zombie, as Silver Bullets tells them apart (`IsBoss`, then `SpecialEnemies`). Hard Target's
/// test. False for anything that is not a zombie.
/// </summary>
static bool BossOrSpecial( GameObject victim )
{
var ai = victim.Components.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors );
return ai.IsValid() && (DeathAugments.IsBoss( victim ) || SpecialEnemies.IsSpecial( ai.Variant ));
}
/// <summary>
/// A hit landed on a zombie: the statuses the per-class augments put on it. HOST ONLY — `Health.OnDamage`'s own path.
///
/// ⛔ ON THE HOST AND NOT ON THE SHOOTER, BECAUSE A REFRESH DOES NOT TRAVEL. `StatusEffects.Apply` relays only a
/// NEW status; a client re-stunning a zombie it already saw stunned refreshes its own copy and tells nobody, so a
/// pistol holding a lane (Flashbang Rounds) would stop it once on the host and never again. Here the host refreshes
/// its own copy, which is the one the AI reads.
///
/// ⛔ NO STUN ON A BOSS, ANY OF THEM. Flashbang Rounds is the user's rule (*"does not affect bosses"*); Stopping Power,
/// Point Blank and Bolt Strike follow it, or a semi-auto rifle would hold Brutus still for the whole fight.
/// </summary>
public static void OnZombieHit( in TechEffects.TechRef tech, GameObject attacker, Vector3 hitAt, GameObject victim )
{
if ( !tech.Valid || !victim.IsValid() ) return;
if ( !victim.Components.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors ).IsValid() ) return;
// ── the stuns: Stopping Power, Flashbang Rounds, Point Blank — the longest wins ──
var stun = 0f;
if ( TechEffects.Has( tech, "t5_br_stopping" ) )
stun = MathF.Max( stun, TechEffects.Mag( tech, "t5_br_stopping", "seconds", 0f ) );
if ( TechEffects.Has( tech, "t5_hg_flashbang" ) )
stun = MathF.Max( stun, TechEffects.Mag( tech, "t5_hg_flashbang", "seconds", 0f ) );
if ( InPointBlank( tech, hitAt ) )
stun = MathF.Max( stun, TechEffects.Mag( tech, "t5_sg_pointblank", "seconds", 0f ) );
if ( stun > 0f && !DeathAugments.IsBoss( victim ) )
StatusEffects.Apply( victim, "stun", attacker, seconds: stun );
// ── Suppressive Fire: -5% speed a hit for 2 s, down to half ──
//
// ⚠️ ONE STATUS WHOSE SPEED STEPS DOWN, NOT A STACK OF STATUSES. A status refreshes rather than stacks (its own
// rule), so each hit re-applies it one step slower; two seconds without a hit and it is gone, speed and all.
if ( TechEffects.Has( tech, "t5_smg_suppress" ) )
{
var per = TechEffects.Mag( tech, "t5_smg_suppress", "per", 0f );
var floor = 1f - TechEffects.Mag( tech, "t5_smg_suppress", "cap", 0f );
var now = StatusEffects.Has( victim, "suppressed" ) ? StatusEffects.SpeedOf( victim, "suppressed" ) : 1f;
// ⚠️ NEVER 0: `Apply` reads a speed of 0 as "the rule's own".
var next = MathF.Max( MathF.Max( 0.05f, floor ), now - per );
StatusEffects.Apply( victim, "suppressed", attacker, speedScale: next,
seconds: TechEffects.Mag( tech, "t5_smg_suppress", "seconds", 0f ) );
}
// ── Slowing Rounds (marksman, tier 2): ×0.8 speed for 1 s a hit, Suppressive Fire's status (see `Slow`) ──
if ( TechEffects.Has( tech, "t2_br_slowing" ) )
Slow( victim, attacker, TechEffects.Mag( tech, "t2_br_slowing", "speed" ),
TechEffects.Mag( tech, "t2_br_slowing", "seconds", 0f ) );
// ── Spotter Rounds and Marker: marks with a timer ──
if ( TechEffects.Has( tech, "t5_ar_spotter" ) )
StatusEffects.Apply( victim, "spotted", attacker, seconds: TechEffects.Mag( tech, "t5_ar_spotter", "seconds", 0f ) );
if ( TechEffects.Has( tech, "t5_hg_marker" ) )
StatusEffects.Apply( victim, "marked", attacker, seconds: TechEffects.Mag( tech, "t5_hg_marker", "seconds", 0f ) );
}
/// <summary>
/// MARKER (`t5_hg_marker`): damage dealt to a marked zombie, by anyone, is dealt to every other marked zombie.
/// HOST ONLY, from `Health.OnDamage` after the hit lands.
///
/// ⛔ THE COPIES GO THROUGH `Apply`, NOT `OnDamage`, AND THAT IS WHAT STOPS THEM COPYING. Only `OnDamage` shares,
/// so a copy cannot share again — two marked zombies would otherwise bounce one hit between them forever (the
/// doc's rule: *"the copies must not copy again"*).
///
/// ⚠️ `ApplyUnpaid`, so a hundred copies do not pay a hundred hit drips; the kills they cause still pay.
///
/// ⚠️ THE FULL HIT, `amount`, not what the first zombie had left: *"if i deal 100 damage to a marked zombie, all
/// marked zombies take 100 damage"*.
///
/// ⛔ AND IT RENEWS EVERY MARK IT TOUCHES (2026-10-06), so the link holds while the group is being hit. The user: *"it seems
/// to disapear after one of them dies or gets shot? this should not be the case"*. Each mark ran out 5 s after ITS OWN last
/// hit from a Marker gun and a copied hit never renewed it, so focusing one zombie, or moving on from one that died, let the
/// others' marks lapse within seconds: the link looked broken by the death or the shot. Now any hit on a marked zombie, from
/// anyone, renews the mark of the zombie hit and of every zombie it reaches; 5 s with no hit on any of them ends the link.
/// </summary>
public static void ShareMarked( Health victim, float amount, GameObject attacker )
{
if ( !victim.IsValid() || amount <= 0f ) return;
var seconds = WeaponTech.MagOf( "t5_hg_marker", "seconds", 5f );
// ⚠️ THE ZOMBIE HIT, IF IT LIVED: a killing hit has already cleared its statuses (`ZombieAI.Die`), and `Has` says so
if ( !victim.IsDead && StatusEffects.Has( victim.GameObject, "marked" ) ) RenewMark( victim.GameObject, attacker, seconds );
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 ( !StatusEffects.Has( z.GameObject, "marked" ) ) continue;
var hp = z.Components.Get<Health>( FindMode.EverythingInSelfAndAncestors );
if ( !hp.IsValid() || hp == victim || hp.IsDead ) continue;
hp.ApplyUnpaid( amount, attacker );
// ⚠️ AFTER THE COPY, AND ONLY IF IT LIVED: a copy that kills has cleared the mark, and a corpse is not re-marked
if ( !hp.IsDead && StatusEffects.Has( z.GameObject, "marked" ) ) RenewMark( z.GameObject, attacker, seconds );
}
}
// ⚠️ NULLABLE-BACKED (INSTRUCTIONS §1)
static Dictionary<GameObject, float> _markSent;
/// <summary>
/// Renew a zombie's Marker mark: here at once, and on the other machines at most once a second.
///
/// ⚠️ THE RENEWAL HAS TO TRAVEL, AND NOT ON EVERY HIT. `StatusEffects.Apply` sends only a NEW status, so a renewal stayed on the
/// host, and every client's copy of the mark (its purple outline) ran out on its own 5-second clock while the host's link
/// held. One message a second per zombie keeps a 5-second mark from lapsing anywhere, where a message per hit would be a flood
/// under an automatic weapon.
/// </summary>
static void RenewMark( GameObject zombie, GameObject attacker, float seconds )
{
StatusEffects.Apply( zombie, "marked", attacker, seconds: seconds );
if ( !Networking.IsActive || Connection.Local is null ) return;
var sent = _markSent ??= new Dictionary<GameObject, float>();
var now = RealTime.Now;
if ( sent.TryGetValue( zombie, out var at ) && now - at < 1f ) return;
// ⚠️ THE DEAD ARE DROPPED NOW AND THEN, so a long game does not keep a key for every zombie ever marked
if ( sent.Count > 64 )
foreach ( var gone in sent.Keys.Where( k => !k.IsValid() ).ToList() ) sent.Remove( gone );
sent[zombie] = now;
NZNet.ZombieStatus( Connection.Local.Id.ToString(), zombie.Id, "marked",
attacker.IsValid() ? attacker.Id : Guid.Empty, 0f, seconds, -1f, 0f );
}
/// <summary>
/// BOLT STRIKE (`t5_act_boltstrike`): every zombie within <paramref name="radius"/> of <paramref name="at"/> is
/// stunned. HOST ONLY — the shooter asks through `NZNet.TechStun`. Bosses are never stunned.
/// </summary>
public static void StunAround( Vector3 at, float radius, float seconds )
{
if ( radius <= 0f || seconds <= 0f ) return;
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 ( z.WorldPosition.Distance( at ) > radius ) continue;
if ( DeathAugments.IsBoss( z.GameObject ) ) continue;
StatusEffects.Apply( z.GameObject, "stun", null, seconds: seconds );
}
}
/// <summary>
/// STAND-OFF (`t3_sn_standoff`, sniper tier 3): while its gun is aimed, every zombie within <paramref name="radius"/>
/// of <paramref name="at"/> is slowed to <paramref name="speed"/>. HOST ONLY — the aiming player renews it a few times a
/// second (`Weapon.TickStandOff`, through `NZNet.TechSlow` from a client), and each renewal lasts a moment past the next,
/// so it ends a moment after the sights come down or the zombie walks out.
///
/// ⚠️ BOSSES ARE SLOWED TOO, as Suppressive Fire slows them: the "not bosses" rule is the stuns'.
/// </summary>
public static void SlowAround( Vector3 at, float radius, float speed, float seconds )
{
if ( radius <= 0f || seconds <= 0f ) return;
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 ( z.WorldPosition.Distance( at ) > radius ) continue;
Slow( z.GameObject, null, speed, seconds );
}
}
/// <summary>
/// SLOWING ROUNDS AND STAND-OFF (tiers 2–3, 2026-10-04): Suppressive Fire's `suppressed` status at a fixed speed, the
/// doc's "Suppressive Fire's slow". HOST ONLY.
///
/// ⛔ ONE STATUS, THE SLOWER WINS, AND NOTHING MULTIPLIES. A deeper `suppressed` already on the zombie (Suppressive
/// Fire's steps) is left exactly as it is, timer and all: re-applying it here would either raise the zombie's speed
/// back to this one or, refreshed by a sniper who keeps aiming, hold someone else's deeper slow on it for as long as
/// they aim. A shallower one is replaced by this speed, and Suppressive Fire's next hit steps down from it.
/// </summary>
static void Slow( GameObject victim, GameObject source, float speed, float seconds )
{
if ( !victim.IsValid() || seconds <= 0f ) return;
if ( StatusEffects.Has( victim, "suppressed" ) && StatusEffects.SpeedOf( victim, "suppressed" ) < speed ) return;
// ⚠️ NEVER 0: `Apply` reads a speed of 0 as "the rule's own" (Suppressive Fire's note).
StatusEffects.Apply( victim, "suppressed", source, speedScale: MathF.Max( 0.05f, speed ), seconds: seconds );
}
// ══ the hit itself (`Health.OnDamage`): tiers 1–3 (2026-10-04, WEAPON_TECH_TIERS_1_3.md) ═══════════════════════
/// <summary>
/// COUP DE GRÂCE (`t3_sn_coupdegrace`, sniper tier 3): any hit on a zombie ALREADY below 30% of its health kills it. What
/// `Health.Apply` is handed in place of <paramref name="amount"/>. Never a boss. HOST ONLY, at `Health.OnDamage`'s `Apply`,
/// where the zombie's health is real.
///
/// ⛔ ITS HEALTH BEFORE THE HIT, NOT AFTER IT (review, 2026-10-04): the doc's "kills a zombie below 30% health" and the
/// node's own lever. Read after the hit, a 1,500 body hit on a full 2,000-health zombie (25% left) killed it outright, and a
/// sniper one-shot zombies with 1.43× the health it could. Finisher and Vigor Rush's Executioner test the same way.
///
/// ⚠️ THE REST OF ITS HEALTH, NOT A STAND-IN, AND ONLY WHAT THE ZOMBIE LAYS DOWN. `amount` itself stays the hit, so
/// Marker's copies, Bleeder's bleed and Bouncy Rounds' chain (the same class's tier 5) still carry what the shot dealt
/// rather than an execution.
/// </summary>
public static float CoupDeGrace( in TechEffects.TechRef tech, Health victim, float amount )
{
if ( amount <= 0f || !victim.IsValid() || victim.IsDead ) return amount;
if ( !TechEffects.Has( tech, "t3_sn_coupdegrace" ) ) return amount;
if ( victim.Current >= victim.Max * TechEffects.Mag( tech, "t3_sn_coupdegrace", "health", 0f ) ) return amount;
if ( !victim.Components.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors ).IsValid() ) return amount;
if ( DeathAugments.IsBoss( victim.GameObject ) ) return amount;
return MathF.Max( amount, victim.Current );
}
/// <summary>The tag a Bullseye shot puts on its first zombie's hit (`BulletInfo.HitScan`).</summary>
public const string BullseyeTag = "bullseye";
/// <summary>
/// BULLSEYE (`t3_sn_bullseye`, sniper tier 3): does this hit count as a headshot. Asked where Wide Bore and Lucky Shot
/// promote one (`Health.OnDamage`, both branches): on the shooter's machine, which sends the verdict on with the hit.
///
/// ⛔ ITS OWN TAG, NOT `head`. The gore reads the hitbox's tags to decide what comes off (`Health.GorePartOf`), and a
/// chest hit that SCORES as a headshot must not pop the head — Wide Bore's and Lucky Shot's rule.
/// </summary>
public static bool BullseyeHead( in Sandbox.DamageInfo damage )
=> damage?.Tags is { } tags && tags.Has( BullseyeTag );
/// <summary>
/// FOLLOW-THROUGH (`t3_br_followthrough`, marksman tier 3): the leftover of a kill this same bullet made, added to its
/// damage on the next zombie it reaches — once. HOST ONLY, onto the hit's raw figure, before this victim's multipliers.
///
/// ⛔ THE BULLET IS ITS `ShotId`, AND IT REACHES THE HOST ON A CLIENT'S HIT TOO (`NZNet.HurtRemote`'s `shot`). Only the
/// host knows a kill — a client's copy of a zombie never takes the damage — so the host carries the leftover from one
/// hit of the bullet to the next, on the shooter's own body (`NZPlayer.FollowThroughLeft`).
/// </summary>
public static float FollowThroughCarry( in TechEffects.TechRef tech, in Sandbox.DamageInfo damage, Health victim )
{
if ( !tech.Valid || tech.Player.FollowThroughLeft <= 0f || !victim.IsValid() || victim.IsDead ) return 0f;
var shot = ShotOf( damage );
if ( shot == Guid.Empty || shot != tech.Player.FollowThroughShot ) return 0f;
if ( !victim.Components.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors ).IsValid() ) return 0f;
var left = tech.Player.FollowThroughLeft;
tech.Player.FollowThroughLeft = 0f;
return left;
}
/// <summary>
/// FOLLOW-THROUGH: this hit killed, so keep what it did not need for the bullet's next zombie. <paramref name="bullet"/>
/// is the hit's raw figure, any carry already in it; <paramref name="taken"/> is what `Apply` returned.
///
/// ⚠️ IN THE BULLET'S OWN DAMAGE, NOT IN HEALTH: the share of the hit the zombie did not need, (1 − lost ÷ dealt) of what
/// the bullet brought. Carried as health, a headshot kill's ×2.5 would ride into the next zombie and meet its head
/// multiplier again.
/// </summary>
public static void FollowThroughKill( in TechEffects.TechRef tech, in Sandbox.DamageInfo damage, float bullet,
Health victim, float taken )
{
if ( taken <= 0f || bullet <= 0f || !victim.IsValid() || !victim.IsDead ) return;
if ( !TechEffects.Has( tech, "t3_br_followthrough" ) ) return;
var shot = ShotOf( damage );
var dealt = victim.LastDamage;
if ( shot == Guid.Empty || dealt <= 0f ) return;
tech.Player.FollowThroughShot = shot;
tech.Player.FollowThroughLeft = bullet * Math.Clamp( 1f - victim.LastLost / dealt, 0f, 1f );
}
/// <summary>
/// FOLLOW-THROUGH: the bullet's id for the host, on a client's relayed hit from a gun that owns the node — "" for every
/// other hit, so they send nothing more.
/// </summary>
public static string FollowThroughShot( in TechEffects.TechRef tech, in Sandbox.DamageInfo damage )
{
if ( !TechEffects.Has( tech, "t3_br_followthrough" ) ) return "";
var shot = ShotOf( damage );
return shot == Guid.Empty ? "" : shot.ToString( "N" );
}
/// <summary>The id every hit of one fired bullet shares (`SWB.Shared.DamageInfo.ShotId`), or empty.</summary>
static Guid ShotOf( in Sandbox.DamageInfo damage ) => (damage as SWB.Shared.DamageInfo)?.ShotId ?? Guid.Empty;
// ══ the bullet's path (the SHOOTER, `HitScanBulletInfo`) ═══════════════════════════════════════════
/// <summary>
/// How many of the nearest zombies Bank Shot traces a line of sight to before giving up. ⚠️ THE SEARCH'S BOUND, NOT A NODE
/// NUMBER: a wall hit in a crowd would otherwise trace every zombie on the map.
/// </summary>
const int BankShotSightlines = 4;
/// <summary>
/// BANK SHOT (`t3_rv_bankshot`, revolver tier 3, 2026-10-04): which way a round that struck the world at
/// <paramref name="from"/> bounces — at the middle of the nearest living zombie in sight of that point, or null when none is.
/// <paramref name="passed"/> is what this bullet already pierced, never a target again.
///
/// ⚠️ TECHBLAST'S LINE OF SIGHT (`TechBlast.Detonate`): the target's own body ignored, anything stopping short of it by
/// more than its `HitRadius` blocks. Nearest first, so a zombie standing in the way is the nearer one and is taken instead.
///
/// ⛔ ONLY ZOMBIES ON THE OPEN SIDE OF THE SURFACE (<paramref name="normal"/>, review 2026-10-04). The ones beyond the wall —
/// tearing at the boards outside a window — are often the nearest to the bounce point, and their lines run back through the
/// wall: they used up the four traces below and the round stopped with a zombie in plain sight in the room.
/// </summary>
public static Vector3? BankShotAim( Vector3 from, Vector3 normal, List<GameObject> passed )
{
var scene = Game.ActiveScene;
if ( scene is null ) return null;
var near = new List<(ZombieAI Z, float D)>();
foreach ( var z in ZombieAI.All )
{
if ( !z.IsValid() || z.State == ZombieState.Dead || Pierced( z.GameObject, passed ) ) continue;
var body = BodyOf( z );
if ( Vector3.Dot( body - from, normal ) <= 0f ) continue;
var d = body.Distance( from );
if ( d <= SWB.Base.Weapon.TraceRange ) near.Add( (z, d) );
}
near.Sort( ( a, b ) => a.D.CompareTo( b.D ) );
for ( int i = 0; i < near.Count && i < BankShotSightlines; i++ )
{
var (z, d) = near[i];
var at = BodyOf( z );
var tr = scene.Trace.Ray( from, at )
.WithoutTags( "player", "trigger" )
.IgnoreGameObjectHierarchy( z.GameObject )
.Run();
if ( tr.Hit && tr.Distance < d - z.HitRadius ) continue;
return (at - from).Normal;
}
return null;
}
/// <summary>The middle of a zombie's body, TechBlast's target point.</summary>
static Vector3 BodyOf( ZombieAI z ) => z.WorldPosition + Vector3.Up * (z.BodyHeight * 0.5f);
/// <summary>Did this bullet already pass through this zombie (a hitbox of it is on its ignore list).</summary>
static bool Pierced( GameObject zombie, List<GameObject> passed )
{
if ( passed is null ) return false;
foreach ( var go in passed )
if ( go.IsValid() && ZombieAI.RootOf( go ) == zombie ) return true;
return false;
}
// ══ kills (HOST → the killer's machine) ═════════════════════════════════════════════════════
/// <summary>
/// A zombie died; <paramref name="tech"/> is the gun that last hit it (`Health.LastTech`). Pays the gun's kill
/// augments on its owner's machine, the only one that has the gun.
///
/// ⚠️ A KILL BY ANYTHING WITHOUT A WEAPON — a grenade, the knife, a trap — has no tech and pays nothing here. That
/// is the reading of "each kill puts rounds back in the magazine": the magazine of the gun that killed.
///
/// ⚠️ <paramref name="headshot"/> IS `ZombieAI.Die`'s, the promoted flag (Lucky Shot, Wide Bore, Bullseye), and it travels
/// with the kill (`NZNet.TechKill`): Trick Shot pays on a headshot kill alone (2026-10-04).
/// </summary>
public static void OnZombieKilled( in TechEffects.TechRef tech, bool headshot = false )
{
if ( !tech.Valid ) return;
// ⚠️ ONLY A GUN WITH A KILL AUGMENT HEARS ABOUT IT, so a client's kills are not a broadcast each. The host reads the
// client's tree off the wire (`NZPlayer.TechStore`).
// ⚠️ AND TIERS 1–3's KILL NODES (2026-10-04): Catch Breath's stamina and Rechamber Rush's faster cycle; Shell
// Recovery's shell, Blood Oath's health, and Trick Shot's round on a headshot kill.
if ( !TechEffects.Has( tech, "t5_ar_quartermaster" ) && !TechEffects.Has( tech, "t5_mag_recycler" )
&& !TechEffects.Has( tech, "t5_ar_underbarrel" )
&& !TechEffects.Has( tech, "t3_smg_catchbreath" ) && !TechEffects.Has( tech, "t3_rechamber" )
&& !TechEffects.Has( tech, "t3_sg_shellrecovery" ) && !TechEffects.Has( tech, "t2_rv_bloodoath" )
// ⚠️ AND RAMPAGE'S STACK (auto action, tier 3, 2026-10-04): the gun counts it only while its trigger pull lasts.
&& !TechEffects.Has( tech, "t3_act_rampage" )
// ⚠️ AND THE MAGAZINE SETS' (tier 2, 2026-10-04): Kill Feed's rounds from the reserve, Brass Saver's round on a headshot kill.
&& !TechEffects.Has( tech, "t2_mag_killfeed" )
&& !(headshot && TechEffects.Has( tech, "t2_mag_brasssaver" ))
&& !(headshot && TechEffects.Has( tech, "t3_rv_trickshot" )) ) return;
if ( Networking.IsActive && PlayerPresence.Theirs( tech.Player.GameObject ) )
{
var owner = NZPlayers.OwnerOf( tech.Player.GameObject );
if ( !string.IsNullOrEmpty( owner ) ) NZNet.TechKill( owner, tech.Prefab, headshot );
return;
}
ApplyKill( tech.Player, tech.Prefab, headshot );
}
/// <summary>Pay a kill to the gun of this prefab that this player carries. The killer's own machine.</summary>
public static void ApplyKill( NZPlayer player, string prefab, bool headshot = false )
{
var wep = WeaponOf( player, prefab );
if ( wep.IsValid() ) wep.ClassTechKill( headshot );
}
/// <summary>
/// DEAD OR ALIVE (`t3_rv_deadoralive`, revolver tier 3, 2026-10-04): the points a boss or special zombie's death adds for the
/// gun that killed it — where Bounty pays (`ZombieAI.AwardPoints`, HOST), so Double Points doubles it too. 0 otherwise.
/// Silver Bullets' classification (`BossOrSpecial`).
/// </summary>
public static int DeadOrAlivePoints( in TechEffects.TechRef tech, GameObject victim )
=> victim.IsValid() && TechEffects.Has( tech, "t3_rv_deadoralive" ) && BossOrSpecial( victim )
? (int)MathF.Round( TechEffects.Mag( tech, "t3_rv_deadoralive", "points", 0f ) )
: 0;
/// <summary>The carried gun of this prefab, held or holstered, or null.</summary>
static SWB.Base.Weapon WeaponOf( NZPlayer player, string prefab )
{
if ( !player.IsValid() || string.IsNullOrEmpty( prefab ) ) return null;
foreach ( var w in player.Components.GetAll<SWB.Base.Weapon>( FindMode.EverythingInSelfAndDescendants ) )
if ( w.IsValid() && Rarity.PrefabOf( w ) == prefab ) return w;
return null;
}
// ══ the holder's side (the HOLDER'S machine) ═════════════════════════════════════════════════
/// <summary>
/// The player is about to lose <paramref name="amount"/> health. JUGGERNAUT (`t5_lmg_juggernaut`) cuts it while its
/// gun is in hand; PAIN RELOAD (`t4_mag_painreload`) reloads the gun in hand, instantly. Returns the amount.
///
/// ⚠️ IN `Health.Apply` BEFORE THE AUGMENTS AND ARMOR, beside Tortoise, which is the same kind of reduction: armor
/// pays only for what got through.
///
/// ⛔ PAIN RELOAD HAS NO COOLDOWN (the user: *"no"*). In a crowd it reloads on every claw.
/// </summary>
public static float OnPlayerDamaged( NZPlayer victim, float amount )
{
if ( !victim.IsValid() || amount <= 0f ) return amount;
// ⚠️ REFLEX (handgun tier 3, 2026-10-04): a hit arms the next 3 shots of EVERY carried gun that owns it, held or not —
// the doc says "when you take damage", not "while you hold it" — refreshed to 3, never added up (`ClassTechReflex`).
// ⚠️ AND RELOAD SHIELD (21–40 rounds, tier 2, 2026-10-04, `Weapon.ReloadShieldTaken`): ×0.8 for 2 s after a reload of any
// carried gun that owns it landed — the strongest one, never a product: it is one shield, whichever gun raised it. Read
// HERE, before Pain Reload's reload below raises it again, so no hit is cut by the shield its own reload raised; it
// multiplies with the gun in hand's `s.taken` (Juggernaut, Gunner's Plate, Riot Guard) and applies with no gun in hand.
var shield = 1f;
foreach ( var w in victim.Components.GetAll<SWB.Base.Weapon>( FindMode.EverythingInSelfAndDescendants ) )
{
if ( !w.IsValid() ) continue;
w.ClassTechReflex();
shield = MathF.Min( shield, w.ReloadShieldTaken() );
}
amount *= shield;
var held = VultureAugments.HeldWeapon( victim );
if ( !held.IsValid() ) return amount;
amount *= TechStats.Mul( held, "s.taken" );
if ( TechEffects.Has( held, "t4_mag_painreload" ) )
{
held.CancelAnyReload();
held.ClassTechRefill();
}
return amount;
}
/// <summary>LIFELINE (`t5_hg_lifeline`): the max health the gun in hand adds. 0 without it.</summary>
public static float LifelineBonus( NZPlayer player )
{
var held = VultureAugments.HeldWeapon( player );
return held.IsValid() ? MathF.Max( 0f, TechStats.Add( held, "s.hp+" ) ) : 0f;
}
/// <summary>
/// A gun was just drawn (`NZInventory.Commit`). HOLSTER RELOAD (`t5_hg_holsterreload`): drawing it reloads every
/// other gun you carry, each from its own reserve. And Lifeline's max health follows the gun in hand.
///
/// ⚠️ MY OWN BODY ONLY. Another player's copy here has no guns to fill and no health this machine owns.
/// </summary>
public static void OnWeaponDrawn( NZPlayer player, GameObject drawn )
{
if ( !player.IsValid() ) return;
if ( Networking.IsActive && !PlayerPresence.Mine( player.GameObject ) ) return;
var wep = drawn.IsValid() ? drawn.Components.Get<SWB.Base.Weapon>( FindMode.EverythingInSelf ) : null;
if ( wep.IsValid() && TechEffects.Has( wep, "t5_hg_holsterreload" ) )
{
foreach ( var other in player.Components.GetAll<SWB.Base.Weapon>( FindMode.EverythingInSelfAndDescendants ) )
if ( other.IsValid() && other != wep ) other.ClassTechRefill();
}
// ⚠️ EVERY SWITCH, NOT ONLY TO OR FROM A LIFELINE GUN: the bonus has to leave when the gun does.
JuggAugments.RefreshHealth( player );
}
/// <summary>SKELETON STOCK (`t4_smg_skeleton`): how much faster sprinting drains stamina with the gun in hand.</summary>
public static float StaminaDrainScale( NZPlayer player )
{
var held = VultureAugments.HeldWeapon( player );
return held.IsValid() ? TechStats.Mul( held, "s.stamina" ) : 1f;
}
/// <summary>DYNAMO (`t5_smg_dynamo`): stamina was spent sprinting, so the gun in hand gets its share as rounds.</summary>
public static void OnStaminaSpent( NZPlayer player, float spent )
{
if ( spent <= 0f ) return;
var held = VultureAugments.HeldWeapon( player );
if ( held.IsValid() && TechEffects.Has( held, "t5_smg_dynamo" ) )
held.ClassTechDynamo( spent );
}
/// <summary>
/// PISTOL WHIP (`t3_hg_pistolwhip`, handgun tier 3, 2026-10-04): the knife's damage with this gun in hand, ×3; 1 otherwise.
/// The swinger's machine (`Knife.Strike`), whose figure the hit and Widow's cleave both carry. The knife never puts the gun
/// away (`Knife.ShowGuns`), so the gun in hand is the one the swing is made with.
/// </summary>
public static float MeleeScale( NZPlayer player )
{
var held = VultureAugments.HeldWeapon( player );
return held.IsValid() ? TechEffects.Mag( held, "t3_hg_pistolwhip", "melee" ) : 1f;
}
/// <summary>
/// A slide began (`Slide.Start`). TUMBLEWEED (`t3_rv_tumbleweed`, revolver tier 3, 2026-10-04): a round from the gun in
/// hand's own reserve into its cylinder (`Weapon.ClassTechTumbleweed`).
///
/// ⚠️ MY OWN BODY ONLY, `OnWeaponDrawn`'s rule: the guns are on the owner's machine and nowhere else.
/// </summary>
public static void OnSlideStarted( NZPlayer player )
{
if ( !player.IsValid() ) return;
if ( Networking.IsActive && !PlayerPresence.Mine( player.GameObject ) ) return;
var held = VultureAugments.HeldWeapon( player );
if ( held.IsValid() ) held.ClassTechTumbleweed();
}
// ══ ammo mods: CATALYST (`t5_ar_catalyst`) ═══════════════════════════════════════════════════
/// <summary>
/// The gun in hand's ammo-mod cooldown multiplier. 0.5 with Catalyst, 1 without.
///
/// ⚠️ AND MOD RAIL'S ×0.85 (`t2_ar_modrail`, assault rifles tier 2, 2026-10-04): "cool down 15% faster" in Catalyst's
/// convention. The two MULTIPLY (×0.425 with both), here, so `AmmoMods.CooldownFor` composes them with Timeslip and
/// Elemental Pop in its one place.
/// </summary>
public static float CatalystCooldown( NZPlayer player )
{
var held = VultureAugments.HeldWeapon( player );
return held.IsValid()
? TechEffects.Mag( held, "t5_ar_catalyst", "cooldown" ) * TechEffects.Mag( held, "t2_ar_modrail", "cooldown" )
: 1f;
}
/// <summary>The gun in hand's flat ammo-mod chance bonus. +0.05 with Catalyst, 0 without.</summary>
public static float CatalystChance( NZPlayer player )
{
var held = VultureAugments.HeldWeapon( player );
return held.IsValid() ? TechEffects.Mag( held, "t5_ar_catalyst", "chance", 0f ) : 0f;
}
/// <summary>Blast Furnace's damage on the gun in hand: ×1.5 with Catalyst, which gives a passive mod nothing else.</summary>
public static float CatalystFurnace( NZPlayer player )
{
var held = VultureAugments.HeldWeapon( player );
return held.IsValid() ? TechEffects.Mag( held, "t5_ar_catalyst", "furnace" ) : 1f;
}
// ══ outlines ══════════════════════════════════════════════════════════════════════════════
/// <summary>
/// A status was just put on <paramref name="go"/> (`StatusEffects.Add`, on every machine it reaches). Spotter's and
/// Marker's marks wear an outline for as long as they last — *"it's outlined for your team"*.
/// </summary>
public static void OnStatusAdded( GameObject go, string id )
{
var color = id switch
{
"spotted" => new Color( 1f, 0.45f, 0.15f ),
"marked" => new Color( 0.75f, 0.35f, 1f ),
// ⚠️ BLOODHOUND'S MARK (ammo mod, 2026-10-04): blood red, on every machine — the ×2 is everybody's.
"bloodhound" => new Color( 0.9f, 0.08f, 0.08f ),
_ => Color.Transparent,
};
if ( color.a <= 0f || !go.IsValid() ) return;
var mark = go.Components.Get<TechMarkOutline>( FindMode.EverythingInSelf )
?? go.Components.Create<TechMarkOutline>();
mark.Show( id, color );
}
/// <summary>Outline one zombie for this machine alone (High Noon's marks), or take the outline off.</summary>
public static void SetLocalOutline( GameObject go, Color color, bool on )
{
if ( !go.IsValid() ) return;
var mark = go.Components.Get<TechMarkOutline>( FindMode.EverythingInSelf );
if ( on ) (mark ?? go.Components.Create<TechMarkOutline>()).Show( "local", color );
else mark?.Hide( "local" );
}
}
/// <summary>
/// The outline the per-class augments put on a zombie: Spotter's and Marker's marks (every machine) and High Noon's
/// targets (the shooter's alone). It takes itself off when the marks it is showing have ended.
///
/// ⚠️ IT OWNS ITS `HighlightOutline` AND ONLY THAT ONE. Death Perception's X-Ray Sense outlines zombies too and tracks
/// the ones it made; a zombie that already wears somebody else's outline is left as it is, the rule that file sets.
/// </summary>
public sealed class TechMarkOutline : Component
{
readonly Dictionary<string, Color> _shown = new();
HighlightOutline _outline;
public void Show( string id, Color color )
{
_shown[id] = color;
Refresh();
}
public void Hide( string id )
{
if ( _shown.Remove( id ) ) Refresh();
}
readonly List<string> _gone = new();
protected override void OnUpdate()
{
// ⚠️ A STATUS ENDS ON ITS OWN CLOCK, ON EVERY MACHINE, so the outline asks rather than being told. No allocation:
// this runs every frame on every marked zombie.
_gone.Clear();
foreach ( var id in _shown.Keys )
if ( id != "local" && !StatusEffects.Has( GameObject, id ) ) _gone.Add( id );
if ( _gone.Count == 0 ) return;
foreach ( var id in _gone ) _shown.Remove( id );
Refresh();
}
void Refresh()
{
if ( _shown.Count == 0 )
{
if ( _outline.IsValid() ) _outline.Destroy();
_outline = null;
Destroy();
return;
}
if ( !_outline.IsValid() )
{
// ⚠️ SOMEBODY ELSE'S OUTLINE STAYS. See the class note.
if ( Components.Get<HighlightOutline>( FindMode.EverythingInSelf ).IsValid() ) return;
WallBuyManager.EnsureHighlight( Scene );
_outline = Components.Create<HighlightOutline>();
_outline.InsideColor = Color.Transparent;
_outline.InsideObscuredColor = Color.Transparent;
_outline.Width = 0.25f;
}
// ⚠️ THE LOCAL MARK WINS, THEN THE LATEST: High Noon's targets are the ones the shooter acts on next.
var color = _shown.TryGetValue( "local", out var local ) ? local : Last();
_outline.Color = color;
_outline.ObscuredColor = color.WithAlpha( 0.5f );
}
Color Last()
{
var c = Color.White;
foreach ( var kv in _shown ) c = kv.Value;
return c;
}
protected override void OnDestroy()
{
if ( _outline.IsValid() ) _outline.Destroy();
}
}