A game component that implements the Fireworks ammo mod. It spawns a floating visual copy (SkinnedModelRenderer) above a hit zombie, waits, then periodically picks nearby zombies and applies damage on behalf of the owner while playing whistle/pop sounds and optional tracers, with configurable tunables and upgrade rules.
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
namespace NZombies;
/// <summary>
/// Fire Works — the ammo mod that spawns a copy of your gun in mid-air and lets it shoot for you.
///
/// It rises 36 units from the zombie you hit, waits half a second, then picks off one zombie at a
/// time within 200 units until its clip or its kill budget runs out. It tumbles as it fires and
/// whistles and pops the whole time.
///
/// ⚠️ THE UPSTREAM VERSION HAS TWO CODE PATHS AND WE ARE PORTING THE SECOND. On TFA weapons it
/// spawns a real clone weapon and calls `PrimaryAttack()` on it — the comment there admits *"i
/// literally have no idea why this works"*. On any other base (ArcCW, ARC9) there is no clone gun
/// and the effect drives itself off a timer, damaging one zombie per shot directly. Our weapons are
/// SWB, so the self-driven path is the honest port: a floating model plus a timer, not a live
/// second weapon.
///
/// ⚠️ SO THE MODEL IS A `SkinnedModelRenderer`, NOT A WEAPON. It has no ammo, no fire logic and no
/// owner — it is scenery that happens to look like your gun. Spawning an actual `Weapon` would give
/// it an inventory slot, a viewmodel and a reload, none of which it should have.
///
/// ⛔ NO PARTICLES, AND THAT IS THE WHOLE REASON THIS MOD WENT FIRST. Every other ammo mod needs
/// VFX we do not have; Fire Works needs a model we already own, two sounds, and a timer.
///
/// ⚠️ THE UPGRADES (2026-10-05, `Sbox nzombies/Docs/AMMO_MODS.md` "Upgrades"), by the OWNER'S level: I Longer Volley, 50
/// hits (24) with the clip floor raised to match; II Long Reach, 350u (200); III Twin Fire, two copies side by side, each
/// with I and II. The user: *"Make upgrade one 50 shots / Upgrade 2 as you stated / And upgrade 3 as you stated"*.
///
/// ⚠️ IV AND V (2026-10-06, "Tiers IV and V"): IV Black Powder, each copy shot deals 200% of your damage (100%); V Headliner,
/// every copy shot is a headshot, everywhere one is decided. The user: *"for V i'd do the hits count as headshots"*. With both, a
/// copy shot is 500% of your damage before perks.
/// </summary>
public sealed class Fireworks : Component
{
// ══ tuning ═══════════════════════════════════════════════════════════════
//
// ⛔ NULLABLE-BACKED GETTERS, as everywhere else: a static's VALUE survives a hotload but its
// initialiser does not re-run, so `= 200f` is not what a live session holds. §1.
static bool? _tracers;
/// <summary>
/// Draw a coloured tracer per shot. On.
///
/// ⚠️ A SWITCH BECAUSE IT IS THE ONLY VISUAL. If the tracers ever read wrong in play,
/// `nz_fireworks_set tracers 0` gets back to the model-and-sound version without a rebuild
/// — and tells us whether a complaint is about the tracers or about the effect itself.
/// </summary>
public static bool Tracers { get => _tracers ?? true; set => _tracers = value; }
static float? _range;
/// <summary>How far it can reach for a target. 200u, as upstream.</summary>
public static float Range { get => _range ?? 200f; set => _range = value; }
static float? _rise;
/// <summary>How far it climbs from the zombie it spawned on. 36u, as upstream.</summary>
public static float Rise { get => _rise ?? 36f; set => _rise = value; }
static float? _armTime;
/// <summary>Delay before it starts shooting. 0.5s, as upstream.</summary>
public static float ArmTime { get => _armTime ?? 0.5f; set => _armTime = value; }
static float? _lifetime;
/// <summary>Hard cap on how long it can exist. 40s, as upstream's delayed remove.</summary>
public static float Lifetime { get => _lifetime ?? 40f; set => _lifetime = value; }
static float? _rpm;
/// <summary>
/// Shots per minute. 400 floor, as upstream.
///
/// ⚠️ THE HELD WEAPON'S RATE WINS IF IT IS FASTER, clamped to 1200. A pistol firework that
/// fires slower than the pistol would read as broken.
/// </summary>
public static float MinRpm { get => _rpm ?? 400f; set => _rpm = value; }
static float? _maxRpm;
/// <summary>Ceiling on the inherited rate. 1200, as upstream.</summary>
public static float MaxRpm { get => _maxRpm ?? 1200f; set => _maxRpm = value; }
static int? _clip;
/// <summary>Shots before it stops. 20 floor, the held weapon's clip if bigger, capped 100.</summary>
public static int MinClip { get => _clip ?? 20; set => _clip = value; }
static int? _maxClip;
/// <summary>Ceiling on the inherited clip. 100, as upstream.</summary>
public static int MaxClip { get => _maxClip ?? 100; set => _maxClip = value; }
static int? _maxKills;
/// <summary>How many zombies it will engage before shutting down. 24, as upstream.</summary>
public static int MaxKills { get => _maxKills ?? 24; set => _maxKills = value; }
static float? _damageFraction;
/// <summary>
/// Fraction of your weapon's per-shot damage each firework hit deals. 1.0 — every firework shot
/// hits as hard as one of yours.
/// </summary>
///
/// ⛔ THIS USED TO BE AN INSTAKILL. Upstream's original dealt `Health() + 666` per hit — every shot
/// a guaranteed kill — and Phase 7 cut it to 10% of the held weapon's per-shot damage. Raised to
/// 100% on 2026-09-24, asked for as *"make it deal 100% of the weapon's damage"*. Still a share of
/// YOUR gun, so it scales with what you hold rather than killing whatever it touches.
public static float DamageFraction { get => _damageFraction ?? 1f; set => _damageFraction = value; }
static float? _noWeaponDamage;
/// <summary>
/// Per-hit damage assumed when the held weapon cannot be read. 50.
///
/// ⚠️ NOT ZERO, for the reason PhD's blast and Napalm's pit both carry: an empty-handed player
/// is reachable, and an effect that silently does nothing reads as broken rather than as an
/// edge case.
/// </summary>
public static float NoWeaponDamage { get => _noWeaponDamage ?? 50f; set => _noWeaponDamage = value; }
// ══ upgrades (2026-10-05) ════════════════════════════════════════════════
//
// ⚠️ EACH UPGRADED NUMBER IS ITS OWN TUNABLE BESIDE THE BASE, and each is resolved in ONE helper below (§3), for the
// OWNER: on the owner's machine for the shots, and on every machine for III's second copy. Levels are synced, so each
// machine builds what the owner's builds (`NZNet.WorldFx`).
const string ModId = "fireworks";
static int? _volleyHits;
/// <summary>I LONGER VOLLEY: the hit budget. 50 (`MaxKills`, 24): the user's *"50 shots"*, counted as `Landed` counts.</summary>
public static int VolleyHits { get => _volleyHits ?? 50; set => _volleyHits = value; }
static int? _volleyClip;
/// <summary>
/// I LONGER VOLLEY: the clip floor. 50 (`MinClip`, 20).
///
/// ⚠️ IT RISES WITH THE BUDGET (the doc's building note): a shot with nothing in reach is still spent, so a floor under
/// 50 runs a small-magazine copy dry before its 50th hit.
/// </summary>
public static int VolleyClip { get => _volleyClip ?? 50; set => _volleyClip = value; }
static float? _longReach;
/// <summary>II LONG REACH: how far it can reach for a target. 350u (`Range`, 200).</summary>
public static float LongReach { get => _longReach ?? 350f; set => _longReach = value; }
static float? _twinGap;
/// <summary>III TWIN FIRE: how far each copy rises from the middle, side by side across your line to the zombie. 30u.</summary>
public static float TwinGap { get => _twinGap ?? 30f; set => _twinGap = value; }
// ── tiers IV and V (2026-10-06, `AMMO_MODS.md` "Tiers IV and V") ──
static float? _blackPowderFraction;
/// <summary>
/// IV BLACK POWDER: the share of your weapon's per-shot damage each copy shot deals. 2.0 (`DamageFraction`, 1.0).
///
/// ⚠️ EVERY COPY'S, so both of III's fire it. Resolved into `PerHit` at the spawn, as the base share is.
/// </summary>
public static float BlackPowderFraction { get => _blackPowderFraction ?? 2f; set => _blackPowderFraction = value; }
/// <summary>The share of a shot each of this owner's copy shots deals: IV's, or the base.</summary>
static float FractionFor( NZPlayer owner ) => AmmoModUpgrades.Has( owner, ModId, 4 ) ? BlackPowderFraction : DamageFraction;
/// <summary>Do this owner's copies shoot headshots: V Headliner. A rule, not a number: the copy's hit carries the `head` tag.</summary>
static bool HeadlinerFor( NZPlayer owner ) => AmmoModUpgrades.Has( owner, ModId, 5 );
/// <summary>The hit budget of this owner's copies: I's, or the base.</summary>
static int HitBudgetFor( NZPlayer owner ) => AmmoModUpgrades.Has( owner, ModId, 1 ) ? VolleyHits : MaxKills;
/// <summary>The clip floor of this owner's copies: I's, or the base.</summary>
static int ClipFloorFor( NZPlayer owner ) => AmmoModUpgrades.Has( owner, ModId, 1 ) ? VolleyClip : MinClip;
/// <summary>How far this owner's copies reach: II's, or the base.</summary>
static float RangeFor( NZPlayer owner ) => AmmoModUpgrades.Has( owner, ModId, 2 ) ? LongReach : Range;
/// <summary>Does this owner get two copies: III.</summary>
static bool TwinFor( NZPlayer owner ) => AmmoModUpgrades.Has( owner, ModId, 3 );
// ══ instance state ═══════════════════════════════════════════════════════
/// <summary>Who gets the kills and the points.</summary>
public NZPlayer Owner { get; set; }
/// <summary>Damage per hit, resolved once at spawn.</summary>
public float PerHit { get; set; }
/// <summary>Shots left.</summary>
public int Clip { get; set; }
/// <summary>
/// Hits landed so far, counted against the hit budget: `MaxKills`, or I's `VolleyHits` (`HitBudgetFor`).
///
/// ⚠️ HITS, NOT DISTINCT ZOMBIES, and that changed when the ignore list started
/// clearing. Upstream could call this a kill count because every hit killed; here a
/// firework alone with one zombie spends its whole budget on that zombie. Measured: a
/// Galil firework landed 23 hits on a single walker and killed it (23 x 3 = 69 of 75).
///
/// ⚠️ SO `MaxKills` IS A HIT BUDGET, not a target count. The console key stays
/// `kills` for continuity with upstream's name, but the log says hits.
/// </summary>
public int Landed { get; set; }
/// <summary>Where it is climbing to.</summary>
Vector3 _target;
TimeUntil _armed;
TimeUntil _dies;
TimeUntil _nextShot;
TimeUntil _nextWhistle;
float _shotGap;
/// <summary>
/// Zombies hit since the last time the list was cleared.
///
/// ⛔ UPSTREAM NEVER CLEARS THIS, AND THAT IS A BUG THE PHASE 7 REBALANCE LEFT BEHIND. The
/// original firework dealt `Health() + 666` per hit — every hit killed, so "one target
/// once" meant 24 KILLS and the field is literally named `Kills`. Phase 7 dropped the damage
/// to 10% of per-shot and kept the rule, which turns 24 kills into 24 taps that kill nothing.
///
/// Measured before this changed: a Galil firework spent all 35 shots on ONE zombie for 3
/// damage and retired with `engaged 1/24`.
///
/// ⚠️ SO IT CLEARS WHEN EVERY REACHABLE ZOMBIE HAS BEEN HIT, rather than being deleted
/// outright. That keeps the half of upstream's rule that is good — spread fire across the
/// crowd before doubling back — and drops the half that only made sense when a hit was
/// lethal. `Engaged` still counts every engagement against `MaxKills`, so the lifetime budget
/// is unchanged.
/// </summary>
readonly HashSet<GameObject> _hit = new();
// ══ spawning ═════════════════════════════════════════════════════════════
/// <summary>
/// Put a firework above a zombie.
///
/// ⚠️ A RUNTIME-CREATED COMPONENT DOES NOT SURVIVE A HOTLOAD, and that is acceptable here for
/// the same reason the napalm pit accepts it: this thing lives forty seconds at most. `Slide`
/// carries the warning for the case where it does matter.
///
/// ⚠️ THE MODEL IS THE OWNER'S WORLD MODEL, read at spawn. Reading it later would fail the
/// moment the player swaps weapons, which is exactly what a player does after proccing
/// something that shoots for them.
/// </summary>
public static Fireworks Spawn( NZPlayer owner, GameObject zombie, bool announce = true )
{
// ⚠️ EVERY MACHINE DRAWS IT; ONLY THE OWNER'S TICKS ITS DAMAGE. See `NZNet.WorldFx`.
if ( announce && Networking.IsActive && Connection.Local is not null )
NZNet.WorldFx( Connection.Local.Id.ToString(),
NZPlayers.OwnerOf( owner.IsValid() ? owner.GameObject : null ),
(int)NZNet.FxKind.Fireworks, zombie.IsValid() ? zombie.WorldPosition : Vector3.Zero, zombie.IsValid() ? zombie.Id : Guid.Empty );
if ( !owner.IsValid() || !zombie.IsValid() ) return null;
var scene = zombie.Scene;
if ( !scene.IsValid() ) return null;
var wep = VultureAugments.HeldWeapon( owner );
var si = wep.IsValid() ? wep.Primary : null;
// ⚠️ `AmmoMods.WeaponDamage` — point blank, no hit tags, × Bullets. The firework has no distance
// to a muzzle and no hitgroup of its own; it wants the weapon's clean per-shot number, the same
// reading the stats card makes and every other mod now shares.
var perShot = si is not null
? AmmoMods.WeaponDamage( owner )
: MathF.Max( 0f, NoWeaponDamage );
// ⛔ `WorldModel` IS NULL ON ALL 31 WEAPON PREFABS, so the first version of
// this was an invisible firework. It read `wep.WorldModel`, found null, skipped the
// renderer inside a silent `if`, and the effect still damaged zombies — so it looked
// like it worked and showed nothing. The project ships VIEWMODELS ONLY; there is not a
// single `w_*.vmdl` under Assets.
//
// ⚠️ SO THE VIEWMODEL IS THE MODEL, and it is a compromise worth naming: a
// viewmodel is authored for a camera two feet away, so its scale and origin are not
// world-correct. It is a recognisable gun in the air, which is the point, and the fix
// is world models rather than anything in this file.
//
// ⚠️ HANDS ARE A SEPARATE MODEL (`ViewModelHands`) and deliberately not used
// — a floating pair of arms holding the gun would be a different and much
// funnier effect than the one requested.
// ⚠️ ITS DISPLAY MODEL WHEN IT HAS ONE (`WeaponDisplay`): an MW viewmodel with no clip playing flies in pieces
var mdl = wep.IsValid() ? WeaponDisplay.For( wep.WorldModel ?? wep.ViewModel ) : null;
if ( mdl is null )
{
// ⛔ LOUD, NOT SILENT. An invisible firework that still deals damage is the
// exact bug this replaced, and it survived a play test because nothing said so.
Log.Warning( "[nz-ammo] fire works has NO MODEL — the held weapon has neither a"
+ " WorldModel nor a ViewModel. It will be invisible." );
}
// Rate and clip inherit upward only, never downward — see MinRpm.
var rpm = si is not null && wep.IsValid()
? 60f / MathF.Max( 0.0001f, wep.GetRealRPM( si.RPM ) )
: MinRpm;
// ⛔ THE BOUNDS ARE ORDERED BEFORE THEY ARE USED, because `Math.Clamp` THROWS when
// min > max rather than doing something sensible. `nz_fireworks_set clip 400` against a
// max of 100 crashed `Spawn` outright with "'400' cannot be greater than 100" — the
// firework spawned, threw mid-setup, and then reported "timed out, 0 hit(s)", which looks
// like a broken effect rather than a bad number. Any console tuning can do this.
var rpmLo = MathF.Max( 1f, MinRpm );
var rpmHi = MathF.Max( rpmLo, MaxRpm );
var clipLo = Math.Max( 1, ClipFloorFor( owner ) );
var clipHi = Math.Max( clipLo, MaxClip );
var shotGap = 60f / Math.Clamp( MathF.Max( rpm, rpmLo ), rpmLo, rpmHi );
var clip = Math.Clamp( si?.ClipSize ?? clipLo, clipLo, clipHi );
// ⚠️ IV BLACK POWDER COMES IN HERE (2026-10-06), through `FractionFor`: 200% of the shot from IV, for every copy.
var share = FractionFor( owner );
var perHit = MathF.Max( 1f, perShot * MathF.Max( 0f, share ) );
var at = zombie.WorldPosition + Vector3.Up * 48f;
// ⚠️ III TWIN FIRE (2026-10-05): a second copy, the same in every number, side by side with the first across the
// owner's line to the zombie. Each keeps its own clip, budget and hit list, so each fires I's volley at II's reach.
// The second's shots fall half a gap behind the first's, so two guns are heard rather than one louder one.
var twin = TwinFor( owner );
var side = twin ? TwinSide( owner, zombie ) * MathF.Max( 0f, TwinGap ) : Vector3.Zero;
var fw = Build( scene, owner, at - side, mdl, perHit, shotGap, clip, 0f );
if ( twin ) Build( scene, owner, at + side, mdl, perHit, shotGap, clip, shotGap * 0.5f );
Log.Info( $"[nz-ammo] FIRE WORKS{(twin ? " x2 (Twin Fire)" : "")} — {perHit:0} per hit"
+ $" ({perShot:0} x {share:0.##})"
+ (HeadlinerFor( owner ) ? ", every one a headshot (Headliner)" : "")
+ $" · {clip} shots at {60f / shotGap:0} rpm"
+ $" · {RangeFor( owner ):0}u reach · up to {HitBudgetFor( owner )} hit(s)" );
return fw;
}
/// <summary>
/// One copy at <paramref name="at"/>, climbing `Rise` from there, with the numbers `Spawn` resolved.
///
/// ⛔ THIS MACHINE'S OWN (§39): every machine builds its own from `NZNet.WorldFx`, so a joiner must not also get a
/// frozen one in the snapshot.
/// </summary>
/// <param name="stagger">How much later than `ArmTime` its first shot comes: half a shot gap for III's second copy.</param>
static Fireworks Build( Scene scene, NZPlayer owner, Vector3 at, Model mdl, float perHit, float shotGap, int clip,
float stagger )
{
var go = scene.CreateObject();
go.Name = "nz_firework";
go.Flags |= GameObjectFlags.NotSaved;
go.NetworkMode = NetworkMode.Never;
go.WorldPosition = at;
if ( mdl is not null )
{
var r = go.Components.Create<SkinnedModelRenderer>();
r.Model = mdl;
}
var fw = go.Components.Create<Fireworks>();
fw.Owner = owner;
fw.PerHit = perHit;
fw._target = at + Vector3.Up * MathF.Max( 0f, Rise );
fw._shotGap = shotGap;
fw.Clip = clip;
fw._armed = MathF.Max( 0f, ArmTime );
fw._dies = MathF.Max( 1f, Lifetime );
fw._nextShot = MathF.Max( 0f, ArmTime ) + stagger;
fw._nextWhistle = 0f;
Sound.Play( "nz.pop.fireworks.launch", go.WorldPosition );
return fw;
}
/// <summary>III's side-by-side axis: flat, across the owner's line to the zombie, so the two stand left and right of it.</summary>
static Vector3 TwinSide( NZPlayer owner, GameObject zombie )
{
var line = (zombie.WorldPosition - owner.WorldPosition).WithZ( 0f );
if ( line.IsNearlyZero() ) line = owner.WorldRotation.Forward.WithZ( 0f );
return line.IsNearlyZero() ? Vector3.Left : Vector3.Cross( line.Normal, Vector3.Up ).Normal;
}
// ══ the loop ═════════════════════════════════════════════════════════════
protected override void OnUpdate()
{
// ⚠️ THE LIFETIME CAP IS CHECKED FIRST and independently of the clip. A firework that
// spawns with nothing in range would otherwise hang in the air forever holding a full clip.
//
// ⛔ AND IT IS NOW ABOVE THE OWNERSHIP GATE, WHICH IS WHERE "FIRST" HAS TO MEAN. It was
// below it, so "checked first" was only ever true on the machine that fired: everywhere
// else the gate returned and the firework hung in the air forever, exactly the outcome this
// comment says it exists to prevent. Same defect the fallout pit was reported for.
if ( _dies )
{
Retire( "timed out" );
return;
}
// ⛔ ONE MACHINE DAMAGES, EVERY MACHINE DRAWS. This effect now exists on all of them so
// everybody can see it — but each copy ticking would hurt every zombie inside it once PER
// MACHINE, and on a client each of those is relayed to the host separately.
//
// ⚠️ THE OWNER'S MACHINE, NOT THE HOST'S, so the damage carries the owner's own perks
// through `Health.AttackerScale`.
if ( Networking.IsActive
&& (!Owner.IsValid() || !PlayerPresence.Mine( Owner.GameObject )) ) return;
Climb();
Whistle();
if ( !_armed ) return;
if ( Clip <= 0 ) { Retire( "out of shots" ); return; }
if ( Landed >= MathF.Max( 1, HitBudgetFor( Owner ) ) ) { Retire( "hit budget spent" ); return; }
if ( !_nextShot ) return;
_nextShot = _shotGap;
Shoot();
}
/// <summary>Lerp toward the hover point, as upstream does.</summary>
void Climb()
{
var at = WorldPosition;
if ( at.AlmostEqual( _target, 0.5f ) ) return;
WorldPosition = Vector3.Lerp( at, _target, Time.Delta * 5f );
}
/// <summary>
/// The whistle and pop loop.
///
/// ⚠️ ONE TIMER FOR BOTH, not two. Upstream runs separate 0.4–0.8s timers for the whistle and
/// the explosion, which drift into each other and sometimes fire together; alternating on one
/// timer keeps them interleaved.
/// </summary>
void Whistle()
{
if ( !_nextWhistle ) return;
_nextWhistle = Game.Random.Float( 0.4f, 0.8f );
Sound.Play( Game.Random.Int( 0, 1 ) == 0
? "nz.pop.fireworks.whistle"
: "nz.pop.fireworks.expl", WorldPosition );
}
/// <summary>
/// One shot: find a zombie it has not hit yet and hurt it.
///
/// ⚠️ IT SPENDS A SHOT EVEN WITH NOTHING IN RANGE, which is upstream's behaviour and the right
/// one: otherwise a firework in an empty corridor holds its clip until the 40s cap and keeps
/// whistling. Running dry is how it ends.
///
/// ⚠️ THE DAMAGE IS ATTRIBUTED TO THE OWNER, not to the firework. Points, Deadshot's streak,
/// Napalm's chain and Death Perception's headshot bonus all read the attacker — a firework kill
/// is your kill, and upstream sets the attacker for exactly this reason.
/// </summary>
// ══ the firework tracer ══════════════════════════════
/// <summary>
/// The colours a firework shot can be.
///
/// ⚠️ FULLY SATURATED AND BRIGHT, because the tracer's own gradient is replaced rather
/// than tinted — see `Trace`. Mid-tones read as dull smoke at the speed a tracer moves.
///
/// ⚠️ NO YELLOW-ORANGE. That is exactly what a normal bullet tracer already looks like in
/// this project, so a firework using it would be indistinguishable from ordinary fire.
/// </summary>
static Color[] Palette => new[]
{
new Color( 1f, 0.15f, 0.35f ), // rose
new Color( 0.30f, 0.55f, 1f ), // cornflower
new Color( 0.35f, 1f, 0.45f ), // green
new Color( 1f, 0.35f, 1f ), // magenta
new Color( 0.35f, 1f, 1f ), // cyan
new Color( 1f, 0.85f, 0.25f ), // gold
};
/// <summary>
/// Draw one coloured tracer from the floating gun to what it just shot.
///
/// ⛔ UPSTREAM HAS NO TRACERS ON THIS PATH AT ALL. Its TFA branch gets them for free by
/// calling `wep:PrimaryAttack()` on a real cloned weapon; its base-agnostic branch (the one
/// this port resembles) says so outright — "there is no cloned visual gun, that part is
/// TFA-only". The Source particle it attaches, `bo3_aat_fireworks`, is a PCF we do not have.
/// So the whole visual is this.
///
/// ⚠️ THE GRADIENT IS OVERWRITTEN, NOT TINTED. `tracer.prefab`'s `ParticleEffect` ramps
/// white to yellow to orange, and `Tint` MULTIPLIES that — so a blue tint over a yellow
/// ramp comes out muddy green rather than blue. Assigning the gradient a flat colour is what
/// makes the colour the one actually asked for.
///
/// ⚠️ THE TRAIL RENDERER CARRIES ITS OWN COLOUR and has to be set too. It is a separate
/// component with a separate gradient; setting only the effect leaves a yellow trail behind a
/// blue head, which looks like a bug rather than a firework.
///
/// ⚠️ ONE COLOUR PER SHOT, not per firework. A single firework cycling colours as it works
/// through a crowd is the effect; one colour for its whole life is just a coloured gun.
/// </summary>
void Trace( Vector3 from, Vector3 to )
{
if ( !Tracers ) return;
// ⚠️ THE DRAWING LIVES IN `ColourTracer` NOW, not here. It was written in
// this file first and moved out the moment Dead Wire wanted the same thing — the two
// prefab quirks it has to know about (the gradient multiplies, the trail renderer
// carries its own colour) are exactly the kind that drift between copies.
//
// ⚠️ THE PALETTE STAYS HERE. It is Fire Works' identity, not a shared
// resource: Dead Wire is electric blue and always the same colour, because an arc
// that changes hue per hop would not read as one chain.
ColourTracer.Draw( from, to,
Palette[Game.Random.Int( 0, Palette.Length - 1 )],
"nz_firework_tracer" );
}
void Shoot()
{
Clip--;
var at = WorldPosition;
var reach = MathF.Max( 0f, RangeFor( Owner ) );
var target = Pick( at, reach );
// ⚠️ ONE RETRY AFTER CLEARING, not a loop. If nothing fresh is in range but the list
// is non-empty, everything reachable has already been hit once — so forget them and
// pick again. A single retry is enough because the second `Pick` runs against an empty
// set, and a genuinely empty room still falls through and spends the shot.
if ( !target.IsValid() && _hit.Count > 0 )
{
_hit.Clear();
target = Pick( at, reach );
}
// ⚠️ A SLOW IDLE SPIN WHEN THERE IS NOTHING IN RANGE, so a firework in an
// empty corridor still reads as active rather than as a stuck prop. This is the
// only place upstream's tumble survives, and it is the only place it helps.
if ( !target.IsValid() )
{
WorldRotation = WorldRotation.Angles().WithYaw(
WorldRotation.Angles().yaw + 45f ).ToRotation();
return;
}
var hp = target.Components.Get<Health>( FindMode.EverythingInSelfAndAncestors );
if ( !hp.IsValid() || hp.IsDead ) return;
// ⛔ IT AIMS. UPSTREAM DOES NOT — it sets a random angle on every
// shot (`SetAngles(math.random(-90,90) x3)`) and never looks at what it is shooting,
// because with an instakill per hit nobody watches the gun. Ported faithfully it read
// as a spinning prop that happened to coincide with zombies dying.
//
// ⚠️ AIMED AT THE CHEST, NOT THE DAMAGE POSITION. The damage is credited
// above the zombie's origin so the numbers pop at head height; pointing the barrel
// there would tilt the gun upward at close range for no reason.
var toward = target.WorldPosition + Vector3.Up * 40f - at;
if ( !toward.IsNearlyZero() )
WorldRotation = Rotation.LookAt( toward.Normal, Vector3.Up );
// ⚠️ DRAWN TO THE AIM POINT, the same `toward` the barrel was just pointed along, so
// the tracer leaves the muzzle rather than crossing the model diagonally.
Trace( at, at + toward );
// ⚠ UPSTREAM HAS A THIRD CUE, `NZ.POP.Fireworks.Shoot`, WHICH WE NEVER PLAYED. Its TFA
// path gets a firing sound for free from the cloned weapon; ours has no real weapon, so
// without this the gun in the air was silent between whistles.
//
// ⚠ THE FILE IS NOT EXTRACTED (`wpn_pap_first.wav`), so this reuses the Pack-a-Punch
// shoot cue — which is what that wav IS upstream, by its own filename.
Sound.Play( NZSound.PapShoot, at );
_hit.Add( target.GameObject );
Landed++;
// ⚠️ AIMED AT THE HEAD POSITION, as upstream aims at the head bone for the visual and the force. BELOW V IT CARRIES NO
// "head" TAG: a headshot would hand every copy hit the head multiplier and Death Perception's bonus on top, which is not
// what a share of per-shot damage was meant to be — until the user asked for exactly that at V (below).
var info = new DamageInfo
{
Damage = PerHit,
Attacker = Owner.IsValid() ? Owner.GameObject : null,
Position = target.WorldPosition + Vector3.Up * 64f,
Tags = new TagSet(),
};
// ⛔ V HEADLINER (2026-10-06): *"for V i'd do the hits count as headshots"*. THE TAG, NOT A MULTIPLIER, because the tag is
// what `Health.OnDamage` decides a headshot by (`IsHeadshot`), and everything a real one gets hangs off that one local:
// the ×2.5 (`HeadshotDamageScale`) with Death Perception's and Deadshot's head terms, Concussion and Focus on the hit, the
// headshot kill award (`Difficulty.PointsKillHeadshot`) and the kill augments that ask for a headshot. A multiplier would
// have handed over the ×2.5 and none of the rest, the reason Lucky Shot promotes there too.
//
// ⚠️ NOT THE WEAPON TECH'S HEAD NODES: this DamageInfo carries no weapon, so `TechEffects.Of` resolves no tree, and
// Precision Rounds, Deadeye, the class heads and One Shot One Kill stay out, as every other node always has.
//
// ⚠️ A CLIENT'S COPY KEEPS IT THROUGH THE RELAY (read 2026-10-06): on a client `OnDamage` reads the tag into its `head`,
// applies the shooter's head terms (`AttackerScale`), and `NZNet.HurtRemote`'s `headshot` puts the tag back on the host's
// DamageInfo, where the ×2.5 and the award are applied.
//
// ⚠️ AND THE GORE TAKES IT AS THE HEAD (`Health.GorePartOf` reads the tag as the hitbox): a copy's headshot kill pops the
// head, as a real one does.
if ( HeadlinerFor( Owner ) ) info.Tags.Add( "head" );
// ⛔ FROM LEVEL I THE COPY'S OWN SHOT ROLLS NO MOD (2026-10-05, the review). Only the cooldown stamped at the proc turned
// its hits away, and I's 50 hits outlast the 6.5 s: at the 400 rpm floor the last land about 7.9 s in, and the late
// ones re-rolled Fire Works — a new copy, which could do the same (two copies at III, more late hits). Level 0's 24 end
// by about 4 s and roll as they always did (`AmmoMods.WithoutProcs`).
if ( AmmoModUpgrades.Has( Owner, ModId, 1 ) ) AmmoMods.WithoutProcs( Owner, () => hp.OnDamage( info ) );
else hp.OnDamage( info );
Sound.Play( "nz.pop.fireworks.expl", at );
}
/// <summary>The nearest live zombie in reach that has not been hit yet.</summary>
ZombieAI Pick( Vector3 at, float reach )
=> ZombieAI.All
.Where( z => z.IsValid() && z.GameObject.IsValid() )
.Where( z => z.State != ZombieState.Dead )
.Where( z => !_hit.Contains( z.GameObject ) )
.Where( z => at.Distance( z.WorldPosition ) <= reach )
.OrderBy( z => at.Distance( z.WorldPosition ) )
.FirstOrDefault();
/// <summary>Log why it stopped, then go.</summary>
void Retire( string why )
{
Log.Info( $"[nz-ammo] fire works done — {why}"
+ $" · {Landed}/{HitBudgetFor( Owner )} hit(s) · {Clip} shot(s) left" );
GameObject?.Destroy();
}
// ══ diagnostics ══════════════════════════════════════════════════════════
/// <summary>
/// `nz_fireworks` — every live firework, and what it is doing.
///
/// ⚠️ IT PRINTS THE RESOLVED SHOT GAP, not the RPM knob. `GetRealRPM` returns an INTERVAL, not
/// a rate, and that inversion has already cost this project a hundredfold error in the napalm
/// pit. Printing the number actually used is the only way that stays honest.
/// </summary>
[ConCmd( "nz_fireworks" )]
public static void Report()
{
var scene = Game.ActiveScene;
var all = scene?.GetAllComponents<Fireworks>().ToList() ?? new List<Fireworks>();
Log.Info( $"[nz-ammo] FIRE WORKS · {all.Count} live"
+ $" · {Range:0}u reach · {DamageFraction:0.##} of per-shot damage"
+ $" · rpm {MinRpm:0}-{MaxRpm:0} · clip {MinClip}-{MaxClip} · cap {MaxKills}" );
// ⚠️ IV AND V (2026-10-06): IV's share, and V's rule, which has no number.
Log.Info( $"[nz-ammo] upgrades: I {VolleyHits} hits, clip floor {VolleyClip} · II {LongReach:0}u reach"
+ $" · III two copies {TwinGap:0}u either side · IV {BlackPowderFraction:0.##} of per-shot damage"
+ $" · V every copy shot a headshot · you: level {AmmoModUpgrades.Level( NZPlayer.Local, ModId )}" );
foreach ( var f in all )
{
if ( !f.IsValid() ) continue;
Log.Info( $"[nz-ammo] {f.PerHit:0} per hit{(HeadlinerFor( f.Owner ) ? " (headshots)" : "")} · {f.Clip} shot(s) left"
+ $" · {f.Landed}/{HitBudgetFor( f.Owner )} hit(s) at {RangeFor( f.Owner ):0}u · every {f._shotGap:0.###}s"
+ $" · {(f._armed ? $"arming {(float)f._armed:0.0}s" : "firing")}"
+ $" · dies in {MathF.Max( 0f, f._dies ):0.0}s" );
}
}
/// <summary>`nz_fireworks_set <key> <value>` — retune one number live.</summary>
[ConCmd( "nz_fireworks_set" )]
public static void SetCmd( string key = "", float value = 0f )
{
switch ( key.ToLowerInvariant() )
{
case "range": Range = value; break;
case "rise": Rise = value; break;
case "arm": ArmTime = value; break;
case "life": Lifetime = value; break;
case "rpm": MinRpm = value; break;
case "maxrpm": MaxRpm = value; break;
case "clip": MinClip = (int)value; break;
case "maxclip": MaxClip = (int)value; break;
case "kills": MaxKills = (int)value; break;
case "fraction": DamageFraction = value; break;
case "tracers": Tracers = value > 0.5f; break;
case "volley": VolleyHits = (int)value; break;
case "volleyclip": VolleyClip = (int)value; break;
case "longreach": LongReach = value; break;
case "twingap": TwinGap = value; break;
// ⚠️ IV'S NUMBER (2026-10-06). V is a rule and has none.
case "powder": BlackPowderFraction = value; break;
default:
Log.Info( "[nz-ammo] nz_fireworks_set <range|rise|arm|life|rpm|maxrpm|clip"
+ "|maxclip|kills|fraction|volley|volleyclip|longreach|twingap|powder> <value>" );
return;
}
Log.Info( $"[nz-ammo] fireworks {key} = {value:0.###}" );
Report();
}
}