Partial class Weapon implementing per-weapon, per-owner 'class tech' mechanics: select-fire modes, single-action trigger, momentum/unload fire rate modifiers, spin-the-cylinder rolls, reload behaviors (snap/pocket/close-call), dynamo/trickle rounds, reflex/high-noon/stand-off/swarm-fire effects, underbarrel grenade charge and firing, shot-opening window for penetration/pellets, damage multipliers from many tech nodes, HUD tag text and tag state.
using NZombies;
using SWB.Shared;
using System;
using System.Collections.Generic;
namespace SWB.Base;
/// <summary>
/// THE PER-CLASS WEAPON TECH, ON THE GUN (2026-10-04, `Sbox nzombies/Docs/WEAPON_TECH_BY_CLASS.md`): the tier-4/5
/// augments whose state is a fact about one gun in one player's hands — Select Fire's mode, Single Action's hold, the
/// aim timers (Steady Breath, Overwatch, High Noon), Momentum's count, the bonus Spin the Cylinder rolled, the
/// Underbarrel Launcher's charge, Dynamo's progress — and the shot arithmetic that reads them. What a hit does to a
/// zombie and what the gun does to its holder are in `NZombies.ClassTech`.
///
/// ⚠️ FIELDS ON THE WEAPON, NOT A STATIC TABLE, like `burstCount` and `AutoLoadProgress`: the state is per gun and
/// goes with it, and a table keyed by weapon would outlive Pack-a-Punch's respawn.
///
/// ⚠️ THE OWNER'S MACHINE ONLY. Weapons are `NetworkMode.Never`; the tick runs from `OnUpdate`'s owner block, and the
/// shot code from `Shoot`, which only the owner runs.
/// </summary>
public partial class Weapon
{
// ══ the per-frame tick (`OnUpdate`, owner block) ═════════════════════════════════════════════
bool _ctWasAiming;
TimeSince _ctAimedFor;
TimeSince _ctSinceShot = 999f;
/// <summary>How long the sights have been up without a break. 0 while they are down.</summary>
public float AimedFor => IsAiming ? (float)_ctAimedFor : 0f;
void TickClassTech()
{
if ( IsAiming && !_ctWasAiming ) _ctAimedFor = 0f;
_ctWasAiming = IsAiming;
// ⚠️ LETTING GO ENDS TWO THINGS: Single Action's hold (the next shot starts its own 0.2 s) and Momentum's
// climb (*"resets when i let go of the shoot button or reload"*).
if ( !Input.Down( InputButtonHelper.PrimaryAttack ) )
{
_saArmed = false;
_momentum = 0;
}
if ( IsReloading ) _momentum = 0;
TickSelectFire();
TickUnderbarrel();
TickHighNoon();
TickStandOff();
TickSwarmFire();
TickCloseCall();
// ⚠️ THE ACTION SETS (tiers 1–3, 2026-10-04, `Weapon.ActionTech.cs`): the trigger pull ends, the action's cycle is watched.
TickActionTech();
}
/// <summary>
/// The gun's in-hand state starts over (`ClearState`: holstered, drawn, Pack-a-Punched, dropped). What belongs to the
/// GUN stays: the cylinder Spin the Cylinder rolled, the Underbarrel charge, Quartermaster's count, Select Fire's mode,
/// Reflex's charges.
/// </summary>
void ClassTechClear()
{
_ctWasAiming = false;
_ctAimedFor = 0f;
_saArmed = false;
_momentum = 0;
_ubTap = 999f;
_bullseye = false;
ClearHighNoon();
// ⚠️ SHOWDOWN (revolver tier 2, 2026-10-04) IS ARMED BY THE DRAW: `ClearState` runs on every one (`OnEnabled`), and its
// run at the holster is harmless — a holstered gun fires nothing. The first shot after spends it (`ClassTechShotDamage`).
_showdown = true;
// ⚠️ SWARM FIRE COUNTS AGAIN ON THE FIRST TICK IN HAND, not with what it saw before the swap.
_swarm = 0;
_swarmScan = 999f;
// ⚠️ AND THE ACTION SETS' PULL AND CYCLE (`Weapon.ActionTech.cs`); their streaks are the gun's and stay.
ActionTechClear();
}
// ══ SELECT FIRE (`t4_ar_selectfire`) ══════════════════════════════════════════════════════════
/// <summary>-1 until first asked, so a gun starts in its own authored mode; then 0 auto, 1 burst, 2 semi.</summary>
int _selectFire = -1;
/// <summary>Since the last E+R switch — the HUD tag flashes for a moment after one.</summary>
public TimeSince SinceFireModeSwitch { get; private set; } = 999f;
/// <summary>Does this gun own Select Fire.</summary>
public bool HasSelectFire => TechEffects.Has( this, "t4_ar_selectfire" );
int SelectFireIndex( ShootInfo si )
{
if ( _selectFire < 0 )
_selectFire = si?.FiringType switch { FiringType.burst => 1, FiringType.semi => 2, _ => 0 };
return _selectFire;
}
/// <summary>Select Fire's chosen mode, or null on a gun without it. `EffectiveFiringType` puts it over everything.</summary>
FiringType? SelectFireMode( ShootInfo si )
{
if ( si is null || !HasSelectFire ) return null;
return SelectFireIndex( si ) switch { 1 => FiringType.burst, 2 => FiringType.semi, _ => FiringType.auto };
}
/// <summary>
/// Select Fire's damage for the mode: semi shots ×2, bursts and auto as they are.
///
/// ⛔ EACH MODE IS ONE GAIN AND ONE COST SINCE 2026-10-04 (the user: *"make it so burst has a 50% increased fire rate but
/// a 0.1s delay between bursts / and semi is 20% slower fire rate but even more damage"*). Bursts were ×1.2 damage
/// and semi ×1.5; burst's gain is its rate now (<see cref="SelectFireRate"/>, <see cref="SelectFireBurstGap"/>), and
/// semi pays 20% of its rate for ×2.
///
/// ⛔ THE MODE THE GUN ACTUALLY FIRES, NOT THE ONE CHOSEN. Double Tap's Full Auto augment turns semi into auto after
/// Select Fire has spoken (`EffectiveFiringType`), and the semi bonus riding on automatic fire would be the best of
/// both modes at once.
/// </summary>
float SelectFireDamage( ShootInfo si )
{
if ( !HasSelectFire ) return 1f;
return EffectiveFiringType( si ) == FiringType.semi ? TechEffects.Mag( this, "t4_ar_selectfire", "semi" ) : 1f;
}
/// <summary>
/// Select Fire's fire rate for the mode the gun fires: bursts ×1.5, semi ×0.8, auto as it is (2026-10-04). A RATE,
/// folded into <see cref="ClassTechRate"/>, so `GetRealRPM` divides by it like every other — and the stats card, which
/// reads `GetRealRPM`, shows the mode's own rate.
///
/// ⚠️ THE PRIMARY'S MODE, ON EVERY RATE `GetRealRPM` IS ASKED FOR, as Momentum and Unload in the same term are: an
/// authored underbarrel on a Select Fire gun would take it too. Rare (the Underbarrel Launcher is the same tier's
/// other pick, so the two never meet), and accepted rather than threading the ShootInfo through eight call sites.
/// </summary>
float SelectFireRate()
{
if ( !HasSelectFire || Primary is null ) return 1f;
return EffectiveFiringType( Primary ) switch
{
FiringType.burst => TechEffects.Mag( this, "t4_ar_selectfire", "burstrate" ),
FiringType.semi => TechEffects.Mag( this, "t4_ar_selectfire", "semirate" ),
_ => 1f,
};
}
/// <summary>
/// Seconds added before a burst BEGINS: Select Fire's 0.1 s between bursts (2026-10-04). Asked only by `CanShoot`'s
/// burst branch, at `burstCount == 0`, so the rounds inside a burst keep the ×1.5 rate and only the next burst waits.
///
/// ⛔ NOT IN `GetRealRPM`, WHERE BOLT GUN'S INTER-BURST WAIT LIVES. That method also feeds the stats card, which reads
/// it with the gun idle (`burstCount` 0), so a pause added there would show a 900 RPM burst as about 360.
/// </summary>
float SelectFireBurstGap( ShootInfo si )
{
if ( si != Primary || !HasSelectFire || EffectiveFiringType( si ) != FiringType.burst ) return 0f;
return TechEffects.Mag( this, "t4_ar_selectfire", "burstgap", 0f );
}
/// <summary>
/// Hold E and press R: auto → burst → semi (the user: *"pressing E+R like in gmod"*).
///
/// ⛔ NOTHING WHILE A USE PROMPT SHOWS, so a switch cannot happen mid-revive or mid-repair: E is held for those.
/// </summary>
void TickSelectFire()
{
if ( !Input.Pressed( InputButtonHelper.Reload ) || !Input.Down( "Use" ) ) return;
if ( !HasSelectFire ) return;
var nz = Components.Get<NZPlayer>( FindMode.InAncestors | FindMode.Enabled );
if ( nz.IsValid() && !string.IsNullOrEmpty( UsePrompt.Text( nz ) ) ) return;
_selectFire = (SelectFireIndex( Primary ) + 1) % 3;
SinceFireModeSwitch = 0f;
// ⚠️ A HALF-FIRED BURST DOES NOT CARRY INTO THE NEW MODE.
burstCount = 0;
NZSound.Play( NZSound.UiClick );
}
/// <summary>While E is held, R does not reload a Select Fire gun: E+R is the switch.</summary>
public bool SelectFireHoldsReload => Input.Down( "Use" ) && HasSelectFire;
// ══ SINGLE ACTION (`t5_rv_singleaction`) ══════════════════════════════════════════════════════
bool _saArmed;
TimeSince _saHeld;
/// <summary>
/// "Each shot needs the trigger held for 0.2 s; letting go early fires nothing." The press arms the hammer and
/// starts the clock; the shot leaves while the trigger is still down, and the next one needs a fresh press.
/// Replaces the semi press test in `CanShoot`, whose master gate has already required the trigger to be down.
/// </summary>
bool SingleActionReady( string inputButton, TimeSince lastAttackTime, ShootInfo si )
{
if ( Input.Pressed( inputButton ) )
{
_saArmed = true;
_saHeld = 0f;
}
if ( !_saArmed ) return false;
if ( _saHeld < WeaponTech.MagOf( "t5_rv_singleaction", "hold", 0.2f ) ) return false;
if ( si.RPM > 0 && lastAttackTime <= GetRealRPM( si.RPM ) ) return false;
_saArmed = false;
return true;
}
// ══ fire rate: MOMENTUM (`t5_smg_momentum`), UNLOAD (`t5_rv_unload`) ═══════════════════════════
int _momentum;
/// <summary>
/// The per-class augments' read-time fire RATE, which `GetRealRPM` divides its interval by: Momentum's +2.5% a shot
/// in one trigger hold (no cap, the user), Unload's ×10 inside its whole-cylinder burst, and Select Fire's mode
/// (bursts ×1.5, semi ×0.8, since 2026-10-04).
/// </summary>
float ClassTechRate()
{
var f = TechEffects.Mag( this, "t5_rv_unload", "rpm" );
if ( _momentum > 0 )
f *= 1f + _momentum * TechEffects.Mag( this, "t5_smg_momentum", "per", 0f );
f *= SelectFireRate();
// ⚠️ TIERS 1–3 (2026-10-04), MULTIPLIED WITH THE REST: Dual Role's hip fire ×1.15 (an AR's, so with Select Fire's mode
// rate too), and Rechamber Rush's ×1.5 on the shot after a kill (with Semi-Auto Action's or Salvo's `s.rpm`).
if ( !IsAiming ) f *= TechEffects.Mag( this, "t3_ar_dualrole", "rpm" );
f *= RechamberRate();
// ⚠️ AND FANNING PRACTICE'S HIP FIRE ×1.2 (revolver tier 2), with Fan the Hammer's ×3 `s.rpm` (written at equip) and
// Unload's ×10 above. Gunslinger never aims, so beside it every shot is a hip shot.
if ( !IsAiming ) f *= TechEffects.Mag( this, "t2_rv_fanning", "rpm" );
// ⚠️ AND SMOOTH ACTION'S ×1.15 (manual action, tier 1, `Weapon.ActionTech.cs`): the gap between shots, as `CycleRate` is
// its bolt or pump — with Fast Cycle's +100 flat RPM and Rechamber Rush's ×1.5.
f *= SmoothActionRate();
// ⚠️ AND THE MAGAZINE SETS' (2026-10-04, `Weapon.MagTech.cs`): Tight Ten's ×1.3 on the last 10 rounds, Opening Volley's ×2 on the
// first 10 since the reload, Spin-Up's +5% for every 20 fired — with every term above.
f *= MagTechRate();
return MathF.Max( 0.01f, f );
}
// ══ SPIN THE CYLINDER (`t4_rv_spin`) ══════════════════════════════════════════════════════════
/// <summary>-1 not rolled yet; 0 ×2 damage, 1 ×3 cylinder, 2 unlimited penetration, 3 five pellets at half damage.</summary>
int _spin = -1;
int _spinShots;
/// <summary>
/// Roll this cylinder's bonus. <paramref name="loading"/> is true when the roll comes with rounds going in — a reload,
/// or Blood Price's six shots — and false for the first roll of a cylinder that was loaded before the gun had the
/// node, which cannot be ×3 any more: its rounds are already in.
/// </summary>
void RollSpin( bool loading = true )
{
_spin = loading ? Game.Random.Next( 4 ) : new[] { 0, 2, 3 }[Game.Random.Next( 3 )];
_spinShots = 0;
}
/// <summary>
/// This cylinder's bonus, or -1 on a gun without the node. Rolled lazily for the first cylinder, so a gun just
/// bought has one too; after that every reload rolls (`ClassTechReloadStarted`), or with Blood Price, which never
/// reloads, every six shots (the user: OK).
/// </summary>
int Spin()
{
if ( !TechEffects.Has( this, "t4_rv_spin" ) ) return -1;
if ( _spin < 0 ) RollSpin( loading: false );
return _spin;
}
/// <summary>The magazine this cylinder holds: ×3 on that roll. `MaxClipSize` and the shell reload read it.</summary>
int ClassTechClip( int clip ) => clip > 0 && Spin() == 1 ? clip * 3 : clip;
static string SpinName( int spin ) => spin switch
{
0 => "×2 DMG",
1 => "×3 CYL",
2 => "PIERCE",
3 => "5 PELLETS",
_ => "",
};
// ══ reloads ════════════════════════════════════════════════════════════════════════════════════
/// <summary>A reload has just begun (`Reload`, `OnShellReload`): Momentum ends, the cylinder is spun again.</summary>
void ClassTechReloadStarted()
{
_momentum = 0;
// ⚠️ AND LONG PULL'S AND RAMPAGE'S TRIGGER PULL, the same way (`Weapon.ActionTech.cs`): Snap Reload's too.
EndPull();
// ⚠️ A NEW RELOAD SUPERSEDES A POCKETED ONE (Pocket Reload): this one fills the magazine.
_pocketPending = false;
// ⚠️ AND OVERFILL'S ROUND-AT-A-TIME COUNT STARTS OVER (9–20 rounds, tier 3, 2026-10-04, `Weapon.MagTech.cs`).
MagTechReloadStarted();
if ( TechEffects.Has( this, "t4_rv_spin" ) && !TechEffects.Has( this, "t5_rv_bloodprice" ) )
RollSpin();
}
/// <summary>
/// SNAP RELOAD (`t5_mag_snapreload`): an EMPTY magazine reloads instantly. True when it did, so the caller starts
/// no reload of its own.
/// </summary>
bool TrySnapReload()
{
var si = Primary;
if ( si is null || si.Ammo > 0 || si.ClipSize <= 0 || IsReloading || InBoltBack ) return false;
if ( si.InfiniteAmmo == InfiniteAmmoType.clip ) return false;
if ( !TechEffects.Has( this, "t5_mag_snapreload" ) ) return false;
// ⚠️ NOTHING TO LOAD, NO RELOAD — and no roll: an empty reserve must not be a free re-spin of the cylinder.
if ( si.InfiniteAmmo != InfiniteAmmoType.reserve
&& (Components.Get<NZAmmo>( FindMode.EverythingInSelf )?.Reserve ?? 0) <= 0 ) return false;
// ⚠️ THE NEW CYLINDER'S ROLL FIRST, THEN THE ROUNDS, as on a normal reload: a ×3 roll is filled to 18.
ClassTechReloadStarted();
ClassTechRefill();
return true;
}
// ══ CLOSE CALL (`t3_hg_closecall`, handgun tier 3, 2026-10-04) ═══════════════════════════════════════════
/// <summary>
/// "Below 30% health, this gun reloads instantly": a reload asked for (`Reload`, `OnShellReload`) fills the magazine from
/// the gun's own reserve at once, Snap Reload's way, with any rounds still in it. True when it did, so no reload starts.
///
/// ⚠️ SNAP RELOAD'S SHAPE, SO NONE OF A STARTED RELOAD'S SIDE EFFECTS RUN: no Elemental Pop, no Lucky Hands roll, no reload
/// animation for the others to see. With Snap Reload too, an empty magazine is Snap Reload's (asked first) — the same refill.
/// </summary>
bool TryCloseCall()
{
var si = Primary;
if ( si is null || si.ClipSize <= 0 || IsReloading || InBoltBack ) return false;
if ( si.InfiniteAmmo == InfiniteAmmoType.clip || si.Ammo >= MaxClipSize() ) return false;
if ( !BelowHealth( "t3_hg_closecall" ) ) return false;
// ⚠️ NOTHING TO LOAD, NO RELOAD (Snap Reload's rule).
if ( si.InfiniteAmmo != InfiniteAmmoType.reserve
&& (Components.Get<NZAmmo>( FindMode.EverythingInSelf )?.Reserve ?? 0) <= 0 ) return false;
ClassTechReloadStarted();
ClassTechRefill();
return true;
}
/// <summary>
/// CLOSE CALL: a reload already running when you drop below the line lands at once too — cut short and filled, Pain
/// Reload's way (`ClassTech.OnPlayerDamaged`). The cheap test first: this runs every frame in hand.
/// </summary>
void TickCloseCall()
{
if ( !IsReloading || !BelowHealth( "t3_hg_closecall" ) ) return;
CancelAnyReload();
ClassTechRefill();
}
/// <summary>
/// Is this gun's holder below the node's `health` share of their max — Close Call, Desperado. False without the node.
/// ⚠️ THE OWNER'S OWN BODY, whose health is real on this machine; overheal (Leech's) is above the max and never counts.
/// </summary>
bool BelowHealth( string nodeId )
{
if ( !TechEffects.Has( this, nodeId ) ) return false;
var hp = Components.Get<NZPlayer>( FindMode.InAncestors | FindMode.Enabled )?.Hp;
return hp.IsValid() && hp.Current < hp.Max * TechEffects.Mag( this, nodeId, "health", 0f );
}
/// <summary>
/// MOON CLIPS, DRUM FEED (`f.wholereload`) and STRIPPER CLIP (`f.wholereload1`): a gun that loads one round at a
/// time loads every round it is missing in one insert. Nothing to change on a gun that reloads a magazine.
/// </summary>
bool ClassTechWholeLoad
=> TechStats.Flag( this, "f.wholereload" ) || TechStats.Flag( this, "f.wholereload1" );
/// <summary>
/// Fill this gun's magazine from ITS OWN reserve, now. Returns the rounds loaded. Snap Reload, Pain Reload, Holster
/// Reload.
///
/// ⛔ ITS OWN `NZAmmo`, NOT `Owner.TakeAmmo`. That one resolves the ACTIVE gun's reserve, and Holster Reload fills
/// the guns that are put away — through it, the pistol in hand would pay for the rifle's magazine.
/// </summary>
public int ClassTechRefill()
{
var si = Primary;
if ( si is null || si.ClipSize <= 0 ) return 0;
if ( si.InfiniteAmmo == InfiniteAmmoType.clip ) return 0;
var wanted = MaxClipSize( requireRoundInChamber: true ) - si.Ammo;
if ( wanted <= 0 ) return 0;
// ⚠️ OVERFILL (9–20 rounds, tier 3, 2026-10-04, `Weapon.MagTech.cs`): an instant reload is still this gun's reload — a
// magazine on top of what is left — and still only below full, the line above.
wanted = Math.Max( wanted, OverfillRounds() );
var got = TakeOwnReserve( wanted );
si.Ammo += got;
// ⚠️ AND THE MAGAZINE SETS' "A RELOAD" LANDS, when a round went in (`MagTechReloaded`).
if ( got > 0 ) MagTechReloaded();
return got;
}
/// <summary>Rounds from this gun's own reserve, up to <paramref name="wanted"/>. A bottomless reserve gives them all.</summary>
int TakeOwnReserve( int wanted )
{
if ( wanted <= 0 ) return 0;
if ( Primary?.InfiniteAmmo == InfiniteAmmoType.reserve ) return wanted;
var ammo = Components.Get<NZAmmo>( FindMode.EverythingInSelf );
return ammo.IsValid() ? ammo.Take( wanted ) : 0;
}
/// <summary>Put rounds straight into the magazine from this gun's own reserve, up to full. Dynamo.</summary>
void TrickleRounds( int rounds )
{
var si = Primary;
if ( si is null || si.ClipSize <= 0 || rounds <= 0 ) return;
if ( si.InfiniteAmmo == InfiniteAmmoType.clip ) return;
var room = MaxClipSize( requireRoundInChamber: true ) - si.Ammo;
si.Ammo += TakeOwnReserve( Math.Min( rounds, room ) );
}
/// <summary>
/// Put rounds back in the magazine for free, up to full. Quartermaster, Recycler, Thrifty, Shell Recovery, Trick Shot,
/// Brass Saver.
///
/// ⛔ A ROUND-AT-A-TIME INSERT UNDER WAY KEEPS ITS ROOM (review, 2026-10-04). `OnShellReloadFinish` lands its round with no
/// clamp, so a refund that filled the tube mid-insert ended one round over full — common on a client, whose kill arrives a
/// round trip after the shot, while the R pressed straight after it is already inserting. The refund still pays whenever
/// there is room beside the round coming in.
/// </summary>
void RefundRounds( int rounds )
{
var si = Primary;
if ( si is null || si.ClipSize <= 0 || rounds <= 0 ) return;
if ( si.InfiniteAmmo == InfiniteAmmoType.clip ) return;
var room = MaxClipSize( requireRoundInChamber: true ) - si.Ammo;
if ( IsReloading && ShellReloading ) room--;
if ( room > 0 ) si.Ammo += Math.Min( rounds, room );
}
// ══ POCKET RELOAD (`t3_smg_pocketreload`, SMG tier 3, 2026-10-04) ══════════════════════════════════
bool _pocketPending;
int _pocketSerial;
/// <summary>
/// The gun is being cleared (`ClearState`, BEFORE it drops `IsReloading`): put away, Pack-a-Punched, dropped. POCKET
/// RELOAD: a reload cut short that way still lands, when what was left of it has run out — the doc's "swap away
/// mid-reload and the reload still finishes".
///
/// ⚠️ ON ITS OWN CLOCK, HOLSTERED OR NOT. Drawn again before then, the gun shows its old magazine until the moment comes;
/// a reload started in the meantime replaces this one (`ClassTechReloadStarted`). The knife cancels its reload before
/// it puts the gun away, so a knife swing is not a pocketed reload.
/// </summary>
void ClassTechPocketReload()
{
if ( !IsReloading || Primary is null || !TechEffects.Has( this, "t3_smg_pocketreload" ) ) return;
_pocketPending = true;
PocketReloadAfter( MathF.Max( 0f, -(float)TimeSinceReload ), ShellReloading, ++_pocketSerial );
}
/// <summary>
/// Land a pocketed reload after <paramref name="seconds"/>, from this gun's OWN reserve (`ClassTechRefill`).
///
/// ⛔ NOT `OnReloadFinish`, whose `Owner.TakeAmmo` pays from the gun in hand — the other gun's reserve, the trap
/// `ClassTechRefill` records. So a pocketed reload is the rounds alone: Conservation's free-reload roll stays with
/// `OnReloadFinish`.
///
/// ⚠️ A GUN THAT LOADS A ROUND AT A TIME FINISHES THE ROUND IT WAS LOADING: its reload's remaining time is one insert's.
/// </summary>
async void PocketReloadAfter( float seconds, bool shell, int serial )
{
if ( seconds > 0f ) await GameTask.DelaySeconds( seconds );
if ( !IsValid || !_pocketPending || serial != _pocketSerial ) return;
_pocketPending = false;
// ⚠️ DRAWN AGAIN AND RELOADING: that reload fills it.
if ( IsReloading ) return;
if ( shell && !ClassTechWholeLoad ) TrickleRounds( 1 );
else ClassTechRefill();
}
// ══ DYNAMO (`t5_smg_dynamo`) ═══════════════════════════════════════════════════════════════════
float _dynamo;
/// <summary>Stamina spent sprinting with this gun in hand: one round per ten, from its own reserve.</summary>
public void ClassTechDynamo( float staminaSpent )
{
_dynamo += staminaSpent * TechEffects.Mag( this, "t5_smg_dynamo", "rounds", 0f );
var rounds = (int)_dynamo;
if ( rounds <= 0 ) return;
_dynamo -= rounds;
TrickleRounds( rounds );
}
// ══ TUMBLEWEED (`t3_rv_tumbleweed`, revolver tier 3, 2026-10-04) ═══════════════════════════════════
/// <summary>
/// A slide began with this gun in hand (`ClassTech.OnSlideStarted`): a round from its own reserve into the cylinder, if
/// there is room and reserve (`TrickleRounds`, Dynamo's).
///
/// ⚠️ NOT DURING A RELOAD, which fills the cylinder anyway: a magazine reload would just take one round fewer at the end,
/// and a round-at-a-time one adds its own round on top of the insert, one past full.
/// </summary>
public void ClassTechTumbleweed()
{
if ( IsReloading ) return;
TrickleRounds( (int)TechEffects.Mag( this, "t3_rv_tumbleweed", "rounds", 0f ) );
}
// ══ REFLEX (`t3_hg_reflex`, handgun tier 3, 2026-10-04) ════════════════════════════════════════════
/// <summary>Shots left that Reflex strengthens. The GUN's, so a hit taken while it is holstered still waits for it.</summary>
int _reflex;
/// <summary>
/// You took damage (`ClassTech.OnPlayerDamaged`): this gun's next 3 shots deal ×1.5 (`ClassTechShotDamage`). REFRESHED to 3
/// on every hit, never added up. Nothing on a gun without the node.
/// </summary>
public void ClassTechReflex()
{
var shots = (int)TechEffects.Mag( this, "t3_hg_reflex", "shots", 0f );
if ( shots > 0 ) _reflex = shots;
}
// ══ kills: QUARTERMASTER, RECYCLER, the UNDERBARREL LAUNCHER's charge ══════════════════════════
int _qmKills;
int _ubKills;
/// <summary>
/// A kill by this gun (`ClassTech.ApplyKill`, on this machine, the killer's). <paramref name="headshot"/>: the killing
/// hit counted as a headshot (Trick Shot, 2026-10-04).
/// </summary>
public void ClassTechKill( bool headshot = false )
{
if ( TechEffects.Has( this, "t5_ar_quartermaster" ) )
{
RefundRounds( (int)TechEffects.Mag( this, "t5_ar_quartermaster", "rounds", 0f ) );
// ⚠️ EVERY 25TH KILL, A MAGAZINE'S WORTH OF RESERVE, capped where a Max Ammo would stop.
var every = (int)TechEffects.Mag( this, "t5_ar_quartermaster", "kills", 0f );
if ( every > 0 && ++_qmKills >= every )
{
_qmKills = 0;
var ammo = Components.Get<NZAmmo>( FindMode.EverythingInSelf );
if ( ammo.IsValid() && Primary is not null )
ammo.Reserve = Math.Min( ammo.MaxReserve, ammo.Reserve + Math.Max( 0, Primary.ClipSize ) );
}
}
if ( TechEffects.Has( this, "t5_mag_recycler" ) )
RefundRounds( (int)TechEffects.Mag( this, "t5_mag_recycler", "rounds", 0f ) );
if ( TechEffects.Has( this, "t5_ar_underbarrel" ) )
_ubKills = Math.Min( UnderbarrelKills, _ubKills + 1 );
// ── tiers 1–3 (2026-10-04, WEAPON_TECH_TIERS_1_3.md) ──
//
// ⚠️ CATCH BREATH (SMG): 5% of the stamina bar a kill, on top of anything else that fills it (Dynamo's shots).
if ( TechEffects.Has( this, "t3_smg_catchbreath" ) )
Components.Get<NZPlayer>( FindMode.InAncestors | FindMode.Enabled )
?.Components.Get<Stamina>( FindMode.EverythingInSelf )
?.AddShare( TechEffects.Mag( this, "t3_smg_catchbreath", "share", 0f ) );
// ⚠️ RECHAMBER RUSH (sniper, shotgun): the next shot's cycle is faster, if it comes soon (`RechamberRate`).
if ( TechEffects.Has( this, "t3_rechamber" ) )
{
_rechamber = true;
_rechamberSince = 0f;
}
// ⚠️ SHELL RECOVERY (shotgun): a shell back a kill, free, up to full — added to Quartermaster's and Recycler's rounds.
if ( TechEffects.Has( this, "t3_sg_shellrecovery" ) )
RefundRounds( (int)TechEffects.Mag( this, "t3_sg_shellrecovery", "rounds", 0f ) );
// ⚠️ TRICK SHOT (revolver): a HEADSHOT kill puts its round back, free. Blood Price's bottomless cylinder takes nothing.
if ( headshot && TechEffects.Has( this, "t3_rv_trickshot" ) )
RefundRounds( (int)TechEffects.Mag( this, "t3_rv_trickshot", "rounds", 0f ) );
// ⚠️ BLOOD OATH (revolver): 3 health a kill on this machine, the holder's own; `Heal` stops at the max, so no overheal.
// Beside Juggernog's Bloodthirst, which heals on any kill.
if ( TechEffects.Has( this, "t2_rv_bloodoath" ) )
Components.Get<NZPlayer>( FindMode.InAncestors | FindMode.Enabled )
?.Hp?.Heal( TechEffects.Mag( this, "t2_rv_bloodoath", "hp", 0f ) );
// ⚠️ RAMPAGE (auto action, tier 3): a kill during the trigger pull adds a stack (`Weapon.ActionTech.cs`).
ActionTechKill();
// ⚠️ THE MAGAZINE SETS (2026-10-04, `Weapon.MagTech.cs`): Brass Saver's round on a headshot kill, then Kill Feed's five from the
// reserve — after every free round above, so those fill the magazine first.
MagTechKill( headshot );
}
// ══ RECHAMBER RUSH (`t3_rechamber`, sniper and shotgun tier 3, 2026-10-04) ══════════════════════════
bool _rechamber;
TimeSince _rechamberSince;
/// <summary>
/// "A kill makes the next shot cycle 50% faster, if fired within 1.5 s": ×1.5 on the fire RATE (`ClassTechRate`) and on
/// the bolt or pump (`AsyncBoltCycle`) from a kill until the next shot (`ClassTechOnShot` spends it) or 1.5 s. 1 otherwise.
///
/// ⚠️ THE KILL ARRIVES WHILE THE KILLING SHOT'S OWN CYCLE RUNS — at once on the host, a round trip later on a client
/// (`NZNet.TechKill`) — so the bolt asks again as it waits, and the cycle that is shortened is the one before the next
/// shot, which is what "the next shot cycles faster" comes to. Multiplies with every other rate.
/// </summary>
float RechamberRate()
{
if ( !_rechamber ) return 1f;
if ( _rechamberSince > TechEffects.Mag( this, "t3_rechamber", "seconds", 0f ) ) return 1f;
return MathF.Max( 0.01f, TechEffects.Mag( this, "t3_rechamber", "cycle" ) );
}
// ══ UNDERBARREL LAUNCHER (`t5_ar_underbarrel`) ══════════════════════════════════════════════════
TimeSince _ubTap = 999f;
int UnderbarrelKills => Math.Max( 1, (int)WeaponTech.MagOf( "t5_ar_underbarrel", "kills", 20f ) );
/// <summary>Is a grenade charged.</summary>
public bool UnderbarrelReady => _ubKills >= UnderbarrelKills && TechEffects.Has( this, "t5_ar_underbarrel" );
/// <summary>
/// Two aim presses within 0.3 s fire the grenade — only when one is charged, so aiming normally cannot misfire it.
/// </summary>
void TickUnderbarrel()
{
if ( !Input.Pressed( InputButtonHelper.SecondaryAttack ) ) return;
if ( !TechEffects.Has( this, "t5_ar_underbarrel" ) ) return;
var window = WeaponTech.MagOf( "t5_ar_underbarrel", "window", 0.3f );
if ( _ubTap < window && _ubKills >= UnderbarrelKills )
{
_ubTap = 999f;
FireUnderbarrel();
return;
}
_ubTap = 0f;
}
/// <summary>
/// The grenade: down the sights, exploding where it lands for 10× the weapon's damage in 200 units. It uses no round
/// and hurts no player (`TechBlast` hits zombies only). A shot into the sky keeps the charge.
/// </summary>
void FireUnderbarrel()
{
var nz = Components.Get<NZPlayer>( FindMode.InAncestors | FindMode.Enabled );
if ( !nz.IsValid() || !Owner.IsValid() ) return;
var from = Owner.EyePos;
var tr = TraceBullet( from, from + Owner.EyeAngles.Forward * TraceRange );
if ( !tr.Hit ) return;
_ubKills = 0;
// ⚠️ "THE WEAPON'S DAMAGE" IS THE AMMO MODS' FIGURE (`AmmoMods.WeaponDamage`): one trigger pull, with
// Pack-a-Punch, rarity and Double Tap in it — the doc's rule for this launcher and for Final Round.
var damage = AmmoMods.WeaponDamage( nz ) * TechEffects.Mag( this, "t5_ar_underbarrel", "dmg", 0f );
var radius = TechEffects.Mag( this, "t5_ar_underbarrel", "radius", 0f );
TechBlast.Launch( this, tr.EndPosition, damage, radius );
if ( Primary?.ShootSound is not null )
{
PlayCue( Primary.ShootSound, Primary.ShootSoundCue );
RelayShotSound( Primary.ShootSound, Primary.ShootSoundCue );
}
}
// ══ HIGH NOON (`t5_rv_highnoon`) ═══════════════════════════════════════════════════════════════
List<GameObject> _noon;
TimeSince _noonScan;
/// <summary>
/// "Aim for 1.5 s to mark up to 6 zombies in view; your next trigger pull shoots them all." While the sights stay
/// up after the 1.5 s, the marks follow what is in view, nearest the crosshair first, outlined on this screen only.
/// Lowering the sights drops them.
/// </summary>
void TickHighNoon()
{
if ( !IsAiming || !TechEffects.Has( this, "t5_rv_highnoon" ) )
{
ClearHighNoon();
return;
}
if ( _ctAimedFor < WeaponTech.MagOf( "t5_rv_highnoon", "seconds", 1.5f ) ) return;
if ( _noonScan < 0.15f ) return;
_noonScan = 0f;
var count = Math.Max( 1, (int)WeaponTech.MagOf( "t5_rv_highnoon", "count", 6f ) );
var eye = Owner.EyePos;
var fwd = Owner.EyeAngles.Forward;
var picks = new List<(GameObject Go, float Dot)>();
foreach ( var z in ZombieAI.All )
{
if ( !z.IsValid() || z.State == ZombieState.Dead ) continue;
var at = NoonAim( z );
var to = at - eye;
var dist = to.Length;
if ( dist < 1f || dist > 4000f ) continue;
// ⚠️ "IN VIEW" IS A 35° CONE AROUND THE CROSSHAIR, then a clear line to the body.
var dot = Vector3.Dot( to / dist, fwd );
if ( dot < 0.82f ) continue;
var tr = Scene.Trace.Ray( eye, at )
.IgnoreGameObjectHierarchy( Owner.GameObject )
.WithoutTags( "player", "trigger" )
.Run();
if ( tr.Hit && tr.GameObject is { } hit && hit.Root != z.GameObject.Root ) continue;
picks.Add( (z.GameObject, dot) );
}
picks.Sort( ( a, b ) => b.Dot.CompareTo( a.Dot ) );
var next = new List<GameObject>();
for ( int i = 0; i < picks.Count && i < count; i++ ) next.Add( picks[i].Go );
if ( _noon is not null )
foreach ( var old in _noon )
if ( !next.Contains( old ) ) ClassTech.SetLocalOutline( old, Color.White, false );
foreach ( var go in next ) ClassTech.SetLocalOutline( go, new Color( 1f, 0.82f, 0.25f ), true );
_noon = next;
}
void ClearHighNoon()
{
if ( _noon is null || _noon.Count == 0 ) return;
foreach ( var go in _noon ) ClassTech.SetLocalOutline( go, Color.White, false );
_noon.Clear();
}
/// <summary>Where a High Noon line is aimed: high on the body, where a revolver round does its work.</summary>
static Vector3 NoonAim( ZombieAI z ) => z.WorldPosition + Vector3.Up * (z.BodyHeight * 0.8f);
/// <summary>
/// The marks this trigger pull fires at, as aim points, or null — and the marks are spent: a fresh 1.5 s for the
/// next volley.
/// </summary>
List<Vector3> ClassTechTakeHighNoon( bool isPrimary )
{
if ( !isPrimary || _noon is null || _noon.Count == 0 ) return null;
var aims = new List<Vector3>();
foreach ( var go in _noon )
{
var z = go.IsValid() ? go.Components.Get<ZombieAI>() : null;
if ( z.IsValid() && z.State != ZombieState.Dead ) aims.Add( NoonAim( z ) );
}
ClearHighNoon();
_ctAimedFor = 0f;
return aims.Count > 0 ? aims : null;
}
/// <summary>The spread offset that turns the eye's forward into a line through <paramref name="at"/>.</summary>
Vector3 ClassTechAimOffset( Vector3 at )
=> (at - Owner.EyePos).Normal - Owner.EyeAngles.Forward;
// ══ the shot ══════════════════════════════════════════════════════════════════════════════
bool _thriftyDone;
/// <summary>Called at the top of `Shoot`, before the magazine is touched.</summary>
void ClassTechBeginShot()
{
_thriftyDone = false;
// ⚠️ AND THE MAGAZINE BEFORE IT PAYS, for the magazine sets' round count (2026-10-04, `Weapon.MagTech.cs`).
MagTechBeginShot();
}
/// <summary>
/// After the magazine has paid for the shot: Blood Price takes its health, Spin the Cylinder counts toward its next
/// roll, Momentum climbs, Dynamo hands back stamina, and Bolt Strike stuns what stands around you.
/// </summary>
void ClassTechOnShot( ShootInfo si )
{
if ( si != Primary ) return;
// ⚠️ RECHAMBER RUSH IS SPENT BY THE SHOT IT HURRIED: this shot's own cycle runs at the normal rate.
_rechamber = false;
// ⚠️ THE ACTION SETS (2026-10-04): the trigger pull counts this shot, and the action starts its cycle (`Weapon.ActionTech.cs`).
ActionTechOnShot();
// ⚠️ AND THE MAGAZINE SETS COUNT ITS ROUNDS (2026-10-04, `Weapon.MagTech.cs`), before the kick and the damage read them.
MagTechOnShot( si );
var nz = Components.Get<NZPlayer>( FindMode.InAncestors | FindMode.Enabled );
// ⛔ BLOOD PRICE (`t5_rv_bloodprice`): NO AMMO, A SHOT COSTS HEALTH, AND IT CAN DOWN YOU (the user). The gun's
// magazine never empties (`NZPlayer.ApplyStoredUpgrades` makes it bottomless), so every shot is paid here.
// `tick` so the cost lands through a fresh hit's grace window and opens none; `cost` so nothing that answers a hit
// shrinks it or fires off it (`Health.Apply`).
if ( TechEffects.Has( this, "t5_rv_bloodprice" ) && nz.IsValid() )
{
var hp = nz.Components.Get<NZombies.Health>( FindMode.EverythingInSelf );
hp?.Apply( TechEffects.Mag( this, "t5_rv_bloodprice", "hp", 0f ), false, null, ignoreArmor: true, tick: true, cost: true );
// ⚠️ AND SPIN THE CYLINDER ROLLS EVERY SIX SHOTS, since this gun never reloads (the user: OK).
if ( Spin() >= 0 && ++_spinShots >= Math.Max( 1, (int)WeaponTech.MagOf( "t4_rv_spin", "shots", 6f ) ) )
RollSpin();
}
if ( TechEffects.Has( this, "t5_smg_momentum" ) ) _momentum++;
if ( nz.IsValid() && TechEffects.Has( this, "t5_smg_dynamo" ) )
nz.Components.Get<Stamina>( FindMode.EverythingInSelf )
?.Add( TechEffects.Mag( this, "t5_smg_dynamo", "stamina", 0f ) );
// ⛔ BOLT STRIKE (`t5_act_boltstrike`) ASKS THE HOST, which owns every zombie's AI — a stun written here on a
// client's copy would be read by nobody, and a refresh would never travel.
if ( Owner.IsValid() && TechEffects.Has( this, "t5_act_boltstrike" ) )
{
var radius = TechEffects.Mag( this, "t5_act_boltstrike", "radius", 0f );
var seconds = TechEffects.Mag( this, "t5_act_boltstrike", "seconds", 0f );
if ( NZGame.IsClient ) NZNet.TechStun( Owner.GameObject.WorldPosition, radius, seconds );
else ClassTech.StunAround( Owner.GameObject.WorldPosition, radius, seconds );
}
}
/// <summary>
/// The per-class augments' damage on THIS trigger pull, multiplied into `Shoot`'s trigger factor: Select Fire's
/// mode, Steady Breath's charge, Overwatch, Last Ten, Final Round, Long Haul, Lucky Six, the cylinder Spin the
/// Cylinder rolled, and Adrenaline Rounds' stacks.
///
/// ⚠️ <paramref name="roundsBefore"/> IS THE MAGAZINE BEFORE THIS SHOT PAID, because `Shoot` takes the round first:
/// the last round is the one fired from a magazine of one.
///
/// ⚠️ READ ONCE PER TRIGGER PULL, so every pellet of one blast carries the same figure, like the other factors there.
/// </summary>
float ClassTechShotDamage( ShootInfo si, int roundsBefore )
{
if ( si != Primary ) return 1f;
var f = SelectFireDamage( si );
// ── Steady Breath: ×1 → ×5 over 5 s of aiming without a shot ──
if ( TechEffects.Has( this, "t5_sn_steadybreath" ) )
f *= SteadyBreathFactor();
// ── Overwatch: aimed for a second, moving or not ──
if ( OverwatchOn() )
f *= TechEffects.Mag( this, "t5_ar_overwatch", "dmg" );
var counted = si.InfiniteAmmo != InfiniteAmmoType.clip && si.ClipSize > 0;
// ── Last Ten: the last ten rounds of a magazine ──
if ( counted && TechEffects.Has( this, "t5_mag_lastten" )
&& roundsBefore <= (int)TechEffects.Mag( this, "t5_mag_lastten", "rounds", 0f ) )
f *= TechEffects.Mag( this, "t5_mag_lastten", "dmg" );
// ── Final Round: the last round deals (magazine ÷ 2) × the weapon's damage ──
if ( FinalRound( si, roundsBefore ) )
f *= MathF.Max( 1f, si.ClipSize * TechEffects.Mag( this, "t5_br_finalround", "per", 0f ) );
// ── Long Haul: +1% for every round fired since the magazine was full, up to +150% ──
//
// ⚠️ DERIVED FROM THE MAGAZINE, NOT COUNTED: a reload refills it, which is the reset the node promises.
if ( counted && TechEffects.Has( this, "t5_mag_longhaul" ) )
{
var fired = Math.Max( 0, si.ClipSize - roundsBefore );
f *= 1f + MathF.Min( TechEffects.Mag( this, "t5_mag_longhaul", "cap", 0f ),
fired * TechEffects.Mag( this, "t5_mag_longhaul", "per", 0f ) );
}
// ── Lucky Six: 1 in 6 for ×6 ──
if ( TechEffects.Has( this, "t4_rv_luckysix" ) )
{
var odds = Math.Max( 1, (int)TechEffects.Mag( this, "t4_rv_luckysix", "odds", 6f ) );
if ( Game.Random.Next( odds ) == 0 ) f *= TechEffects.Factor( this, "t4_rv_luckysix" );
}
// ── Spin the Cylinder: ×2, or half for each of five pellets ──
var spin = Spin();
if ( spin == 0 ) f *= 2f;
else if ( spin == 3 ) f *= 0.5f;
// ── Adrenaline Rounds: +1% a stacked hit ──
f *= AdrenalineRounds.DamageScale( this );
// ── Dual Role (AR, tier 3, 2026-10-04): aimed shots ×1.15, multiplied with Overwatch's ×1.5 and the rest ──
if ( IsAiming ) f *= TechEffects.Mag( this, "t3_ar_dualrole", "dmg" );
// ── tiers 1–3, LMG, handgun and revolver (2026-10-04): every term multiplies with the ones above ──
//
// ⚠️ BRACE (LMG): crouched, ×1.2 — with Bipod's ×2 `s.dmg` (written at equip) and Long Haul's climb.
if ( Owner.IsValid() && IsCrouching ) f *= TechEffects.Mag( this, "t3_lmg_brace", "dmg" );
// ⚠️ SWARM FIRE (LMG): +5% for each zombie within 300 units, up to +40%, off the count `TickSwarmFire` keeps.
if ( _swarm > 0 )
f *= 1f + MathF.Min( TechEffects.Mag( this, "t3_lmg_swarmfire", "cap", 0f ),
_swarm * TechEffects.Mag( this, "t3_lmg_swarmfire", "per", 0f ) );
// ⚠️ REFLEX (handgun): ×1.5, one charge a shot (`ClassTechReflex` arms three).
if ( _reflex > 0 )
{
_reflex--;
f *= TechEffects.Mag( this, "t3_hg_reflex", "dmg" );
}
// ⚠️ SHOWDOWN (revolver): the first shot since the draw (`ClassTechClear`), ×3 — with Spin the Cylinder's ×2 and Lucky Six.
if ( _showdown )
{
_showdown = false;
f *= TechEffects.Mag( this, "t2_rv_showdown", "dmg" );
}
// ⚠️ LOADED DICE (revolver): one roll a shot, ×0.8 to ×1.4, so every pellet of it the same; Lucky Six rolls on its own.
if ( TechEffects.Has( this, "t2_rv_loadeddice" ) )
f *= Game.Random.Float( TechEffects.Mag( this, "t2_rv_loadeddice", "min" ),
TechEffects.Mag( this, "t2_rv_loadeddice", "max" ) );
// ⚠️ DESPERADO (revolver): you below half your health, ×1.3 — which Blood Price's cost takes you to.
if ( BelowHealth( "t3_rv_desperado" ) ) f *= TechEffects.Mag( this, "t3_rv_desperado", "dmg" );
// ⚠️ THE ACTION SETS (2026-10-04, `Weapon.ActionTech.cs`): Rampage, Paced Shot, Hot Hand and Perfect Cycle, read before the
// clock below restarts — Paced Shot's "0.5 s after the last" is that clock.
f *= ActionTechShotDamage();
// ⚠️ AND THE MAGAZINE SETS' (2026-10-04, `Weapon.MagTech.cs`): Crescendo, Front Load and Final Stretch, off the rounds this
// magazine has fired since its reload and the rounds left in it. With Last Ten and Long Haul above.
f *= MagTechShotDamage( si );
// ⚠️ BULLSEYE (sniper, tier 3, 2026-10-04) IS DECIDED HERE, BEFORE THE CLOCK BELOW RESTARTS: a second with the sights up
// and no shot, and this pull's first zombie counts as a headshot (`ClassTechTakeBullseye`). Every pull decides
// afresh, so a pull that hits nothing has spent it.
_bullseye = BullseyeReady();
_ctSinceShot = 0f;
return f;
}
// ══ BULLSEYE (`t3_sn_bullseye`, sniper tier 3, 2026-10-04) ══════════════════════════════════════════
bool _bullseye;
/// <summary>Would the next trigger pull be a Bullseye: the sights up for the node's `seconds` without a shot.</summary>
bool BullseyeReady()
=> IsAiming && TechEffects.Has( this, "t3_sn_bullseye" )
&& MathF.Min( (float)_ctAimedFor, (float)_ctSinceShot ) >= TechEffects.Mag( this, "t3_sn_bullseye", "seconds", 0f );
/// <summary>
/// Does this hit on <paramref name="hit"/> take the pull's Bullseye — true once, for the first ZOMBIE the pull hits, then
/// spent. Asked by `BulletInfo.HitScan`, on the shooter, which tags that hit for `Health` (`ClassTech.BullseyeHead`).
///
/// ⚠️ THE FIELD FIRST: this is asked for every body every bullet crosses, and `RootOf` walks the hitbox's parents.
///
/// ⛔ A LIVING ZOMBIE (review, 2026-10-04): a corpse keeps its `ZombieAI` and its hitboxes through the death clip, and the
/// trace does not skip it — so a line crossing one that just fell spent the headshot on it, and the living zombie behind
/// took a body hit. A corpse is no hit, `ActionTechHitFactor`'s and `ShotHitsZombie`'s rule.
/// </summary>
public bool ClassTechTakeBullseye( GameObject hit )
{
if ( !_bullseye || !hit.IsValid() ) return false;
var root = ZombieAI.RootOf( hit );
var ai = root.IsValid() ? root.Components.Get<ZombieAI>( FindMode.EverythingInSelf ) : null;
if ( !ai.IsValid() || ai.State == ZombieState.Dead ) return false;
_bullseye = false;
return true;
}
// ══ STAND-OFF (`t3_sn_standoff`, sniper tier 3, 2026-10-04) ══════════════════════════════════════════
TimeSince _standOffSent = 999f;
/// <summary>
/// How often an aimed Stand-Off gun renews its slow, and how long each renewal lasts: a little past the next one, so the
/// slow is unbroken while the sights are up and gone a moment after they come down.
///
/// ⚠️ THE AURA'S CADENCE, NOT THE NODE'S NUMBERS — its range and its speed are the catalogue's, read below. Tar Pit renews
/// its slow the same way (`tar`, 0.35 s).
/// </summary>
const float StandOffEvery = 0.2f;
const float StandOffLasts = 0.35f;
/// <summary>
/// While this gun is aimed, zombies within 250 units of you move 20% slower (`ClassTech.SlowAround`, Suppressive Fire's
/// slow). The owner knows the aim and the host owns the zombies, so a client asks the host (`NZNet.TechSlow`), as Bolt
/// Strike asks for its stun.
/// </summary>
void TickStandOff()
{
if ( !IsAiming || _standOffSent < StandOffEvery || !Owner.IsValid() ) return;
// ⚠️ THE CLOCK FIRST, so an aimed gun WITHOUT the node asks for it five times a second rather than every frame.
_standOffSent = 0f;
if ( !TechEffects.Has( this, "t3_sn_standoff" ) ) return;
var at = Owner.GameObject.WorldPosition;
var radius = TechEffects.Mag( this, "t3_sn_standoff", "range", 0f );
var speed = TechEffects.Mag( this, "t3_sn_standoff", "speed" );
if ( NZGame.IsClient ) NZNet.TechSlow( at, radius, speed, StandOffLasts );
else ClassTech.SlowAround( at, radius, speed, StandOffLasts );
}
// ══ SWARM FIRE (`t3_lmg_swarmfire`, LMG tier 3, 2026-10-04) ══════════════════════════════════════════
/// <summary>Living zombies within the node's `range` of you at the last count. 0 on a gun without the node.</summary>
int _swarm;
TimeSince _swarmScan = 999f;
/// <summary>
/// How often Swarm Fire counts the zombies around you. ⚠️ THE COUNT'S CADENCE, NOT THE NODE'S NUMBERS: read off this count
/// by every shot rather than walking every zombie per bullet, which on a 75-round belt is a walk a frame.
/// </summary>
const float SwarmEvery = 0.25f;
/// <summary>
/// Count the zombies within 300 units of you — Stand-Off's measure, from your feet, in 3D — for `ClassTechShotDamage`.
/// The owner's own copies of the zombies, which follow the host's.
/// </summary>
void TickSwarmFire()
{
if ( _swarmScan < SwarmEvery || !Owner.IsValid() ) return;
// ⚠️ THE CLOCK FIRST, Stand-Off's rule: a gun without the node asks four times a second, not every frame.
_swarmScan = 0f;
_swarm = 0;
if ( !TechEffects.Has( this, "t3_lmg_swarmfire" ) ) return;
var at = Owner.GameObject.WorldPosition;
var range = TechEffects.Mag( this, "t3_lmg_swarmfire", "range", 0f );
foreach ( var z in ZombieAI.All )
if ( z.IsValid() && z.State != ZombieState.Dead && z.WorldPosition.Distance( at ) <= range ) _swarm++;
}
// ══ SHOWDOWN (`t2_rv_showdown`, revolver tier 2, 2026-10-04) ══════════════════════════════════════════
/// <summary>The next shot is the first since the draw (`ClassTechClear` sets it; the shot spends it).</summary>
bool _showdown;
// ══ spread: SLIDE FIRE, STEADY SLING, LOCKED IN (tiers 1–3, 2026-10-04) — read by `GetRealSpread` ════════════
/// <summary>
/// SLIDE FIRE (`t3_smg_slidefire`, SMG tier 3): sliding or in the air, `GetRealSpread` leaves out what those add — the
/// mid-air addend, the jump term and the movement terms. Every other term stays, so Run 'n' Gun's aimed-hip cone is still
/// the cone it starts from (the doc's note).
/// </summary>
bool SlideFireOn()
{
if ( !Owner.IsValid() || !TechEffects.Has( this, "t3_smg_slidefire" ) ) return false;
if ( !Owner.IsOnGround ) return true;
var slide = Components.Get<NZPlayer>( FindMode.InAncestors | FindMode.Enabled )
?.Components.Get<Slide>( FindMode.EverythingInSelf );
return slide.IsValid() && slide.IsSliding;
}
/// <summary>STEADY SLING (`t3_br_steadysling`, marksman tier 3): aimed, walking adds no spread — the movement terms go.</summary>
bool SteadySlingOn( bool aimed ) => aimed && TechEffects.Has( this, "t3_br_steadysling" );
/// <summary>
/// LOCKED IN (`t3_ar_lockedin`, AR tier 3): aimed, sustained fire never WIDENS the spread — `SpreadMultShooting` held at
/// 1 at most, so a gun that authors it under 1 keeps its tightening.
/// </summary>
float LockedInShooting( bool aimed, float shooting )
=> shooting > 1f && aimed && TechEffects.Has( this, "t3_ar_lockedin" ) ? 1f : shooting;
/// <summary>Steady Breath's multiplier right now: 1 + (5 − 1) × (held ÷ 5 s), with the sights up since the last shot.</summary>
float SteadyBreathFactor()
{
if ( !IsAiming ) return 1f;
var max = WeaponTech.MagOf( "t5_sn_steadybreath", "max", 1f );
var seconds = MathF.Max( 0.01f, WeaponTech.MagOf( "t5_sn_steadybreath", "seconds", 1f ) );
var held = MathF.Min( (float)_ctAimedFor, (float)_ctSinceShot );
return 1f + (max - 1f) * Math.Clamp( held / seconds, 0f, 1f );
}
bool OverwatchOn()
=> IsAiming && TechEffects.Has( this, "t5_ar_overwatch" )
&& _ctAimedFor >= WeaponTech.MagOf( "t5_ar_overwatch", "seconds", 1f );
bool FinalRound( ShootInfo si, int roundsBefore )
=> roundsBefore == 1 && si.ClipSize > 0 && si.InfiniteAmmo != InfiniteAmmoType.clip
&& TechEffects.Has( this, "t5_br_finalround" );
// ── the per-shot window: penetration and pellets for this trigger pull alone ──
bool _ctOpen;
bool _ctPen;
float _ctPenDepth;
int _ctBullets;
/// <summary>
/// Open this trigger pull's changes to the `ShootInfo` — Final Round and the PIERCE spin pierce every zombie,
/// Overwatch pierces two more, the PELLETS spin fires five — beside `Shoot`'s own `DamageMultiplier` window, and
/// closed in the same `finally`.
///
/// ⛔ A WINDOW, NOT A WRITE THAT STAYS: `NZPlayer.ApplyStoredUpgrades` is the author of those fields and rewrites
/// them on every equip, and one shot's bonus must not become the gun's.
/// </summary>
void ClassTechOpenShot( ShootInfo si, int roundsBefore )
{
_ctOpen = false;
if ( si != Primary ) return;
// ⚠️ AND WHICH ZOMBIE THIS PULL LANDS ON, for Hot Hand and Grouping (2026-10-04, `Weapon.ActionTech.cs`).
OpenPullHits( si );
var spin = Spin();
var pierceAll = spin == 2 || FinalRound( si, roundsBefore );
var morePen = OverwatchOn() ? TechEffects.Mag( this, "t5_ar_overwatch", "pen", 0f ) : 0f;
var pellets = spin == 3 ? 5 : 0;
// ⚠️ AND FRESH MAG'S EXTRA PELLETS ON THE FIRST SHOT SINCE THE RELOAD (1–8 rounds, tier 3, 2026-10-04, `Weapon.MagTech.cs`).
var fresh = FreshMagPellets( si );
if ( !pierceAll && morePen <= 0f && pellets == 0 && fresh == 0 ) return;
_ctOpen = true;
_ctPen = si.Penetration;
_ctPenDepth = si.PenetrationDepth;
_ctBullets = si.Bullets;
// ⚠️ 0 IS UNLIMITED (`HitScanBulletInfo`), so "two more" adds only to a finite depth.
if ( pierceAll )
{
si.Penetration = true;
si.PenetrationDepth = 0f;
}
else if ( morePen > 0f )
{
si.Penetration = true;
if ( si.PenetrationDepth > 0f ) si.PenetrationDepth += morePen * DtapAugments.BodyDepth;
}
if ( pellets > 0 ) si.Bullets = pellets;
// ⚠️ ADDED TO WHAT THE PULL FIRES, so with the PELLETS spin it is five more than its five.
_freshPellets = fresh;
si.Bullets += fresh;
}
void ClassTechCloseShot( ShootInfo si )
{
// ⚠️ THE PULL'S HITS ARE TALLIED FIRST, above the window's early out: Hot Hand's streak, Grouping's burst.
ClosePullHits( si );
if ( !_ctOpen || si is null ) return;
_ctOpen = false;
si.Penetration = _ctPen;
si.PenetrationDepth = _ctPenDepth;
si.Bullets = _ctBullets;
_freshPellets = 0;
}
/// <summary>
/// THRIFTY (`t5_mag_thrifty`): a hit has a 25% chance to put the round back. One roll per trigger pull, at its first
/// zombie (`HitScanBulletInfo`, on the shooter).
/// </summary>
public void ClassTechThrifty()
{
if ( _thriftyDone ) return;
_thriftyDone = true;
if ( Game.Random.Float() < TechEffects.Mag( this, "t5_mag_thrifty", "chance", 0f ) )
RefundRounds( 1 );
}
// ══ the HUD's tag on the ammo row ═════════════════════════════════════════════════════════
/// <summary>
/// What the ammo row shows beside the clip for this gun: Select Fire's mode, the Spin the Cylinder bonus, the
/// Underbarrel Launcher's charge, Blood Price's cost, High Noon's marks, Steady Breath's charge. "" for most guns.
///
/// ⚠️ EVERY FIGURE IS STEPPED, because the HUD rebuilds when this string changes: Steady Breath in halves and
/// Adrenaline in tens, never a number that moves every frame.
/// </summary>
public string ClassTechTag
{
get
{
var tag = "";
void Add( string s ) => tag = tag.Length == 0 ? s : $"{tag} {s}";
// ⚠️ THE MODE IT FIRES, which is what E+R chose unless a perk overrides it (see `SelectFireDamage`).
if ( Primary is not null && HasSelectFire )
Add( EffectiveFiringType( Primary ) switch { FiringType.burst => "BURST", FiringType.semi => "SEMI", _ => "AUTO" } );
var spin = Spin();
if ( spin >= 0 ) Add( SpinName( spin ) );
if ( TechEffects.Has( this, "t5_ar_underbarrel" ) )
Add( _ubKills >= UnderbarrelKills ? "GL READY" : $"GL {_ubKills}/{UnderbarrelKills}" );
if ( TechEffects.Has( this, "t5_rv_bloodprice" ) )
Add( $"{TechEffects.Mag( this, "t5_rv_bloodprice", "hp", 0f ):0} HP/SHOT" );
if ( _noon is { Count: > 0 } ) Add( $"HIGH NOON ×{_noon.Count}" );
if ( IsAiming && TechEffects.Has( this, "t5_sn_steadybreath" ) )
Add( $"×{MathF.Floor( SteadyBreathFactor() * 2f ) / 2f:0.0}" );
// ⚠️ BULLSEYE'S HEADSHOT IS ARMED (2026-10-04): nothing else on screen says the next pull is one.
if ( BullseyeReady() ) Add( "BULLSEYE" );
// ⚠️ AND REFLEX'S CHARGES AND SHOWDOWN'S FIRST SHOT (2026-10-04), for the same reason.
if ( _reflex > 0 && TechEffects.Has( this, "t3_hg_reflex" ) ) Add( $"REFLEX ×{_reflex}" );
if ( _showdown && TechEffects.Has( this, "t2_rv_showdown" ) ) Add( "SHOWDOWN" );
// ⚠️ AND RAMPAGE'S AND HOT HAND'S STACKS (the action sets, 2026-10-04), for the same reason.
var action = ActionTechTag();
if ( action.Length > 0 ) Add( action );
var adrenaline = AdrenalineRounds.DamageScale( this ) - 1f;
if ( adrenaline >= 0.1f ) Add( $"+{(int)(adrenaline * 10f) * 10}%" );
return tag;
}
}
/// <summary>0 plain, 1 flashing (a Select Fire switch just happened), 2 lit (a grenade is charged).</summary>
public int ClassTechTagState
=> SinceFireModeSwitch < 0.4f ? 1 : UnderbarrelReady ? 2 : 0;
}