Component implementing the NapalmZombie enemy and its NapalmBlaze fire pool. Controls explosion wind-up, burst sequence, cooldown/charging behavior, speed/animation tier selection, damage application with LOS and falloff, sound and visual effects, debug/console commands and a static heat overlay value.
using Sandbox;
using System;
using System.Linq;
namespace NZombies;
/// <summary>
/// THE NAPALM ZOMBIE — BO1 Shangri-La's suicide bomber.
///
/// It walks at you like anything else, gets faster the more you hurt it, and when it gets close
/// enough it ERUPTS — a blast that is its attack, not its death. Kill it and the last blast leaves
/// the ground burning.
///
/// ⛔ EXPLODING IS HOW IT ATTACKS, AND IT SURVIVES DOING IT. Upstream's is a suicide bomber that
/// dies on contact; this one blows up in your face, takes nothing from it, and walks at you again.
/// That is a deliberate departure and it changes what the enemy IS: a suicide bomber is a trap you
/// avoid once, and this is a fight you have to keep backing out of.
///
/// ⛔ EVERYTHING IT CAN DO AND EVERYTHING YOU CAN DO TO IT HANGS ON ONE FLAG: `_cooling`. While it
/// cannot explode it charges at <see cref="ChargeSpeed"/>, it swings, and it takes FULL damage;
/// the rest of the time it shambles, never swings, and shrugs off nine tenths of everything. So the
/// five seconds after an eruption are simultaneously the most dangerous it gets and the only window
/// in which it can be killed, and the player's whole read of the fight is "is it running?".
///
/// ⚠️ THE SPRINT ANIMATION IS THEREFORE LOAD-BEARING, NOT DECORATION. `BrutusHelmet` had to invent
/// an armour ping because a helmeted headshot and a body shot looked identical; here the tell was
/// already there the moment the charge got its own clips. Anything that makes the charge harder to
/// recognise — a gait that reads as walking, a speed that does not cross the sprint tier — quietly
/// removes the only feedback the armour has.
///
/// ⚠️ AND THE MELEE IS UPSTREAM'S RULE, RESTORED. `OnTargetInAttackRange` lets it swing only while
/// the explosion is on cooldown. This briefly forbade the swing outright; that made the cooldown
/// pure respite, which is the opposite of what it is now for.
///
/// ⛔ A COMPONENT, NOT A `ZombieBehaviour` ENUM ENTRY. `ZombieVariant`'s own remarks say to keep
/// that enum tiny, and a suicide attack with a blast, a lingering fire and an enrage threshold is
/// not one flag — it is the shape `BrutusHelmet` already established for an enemy whose mechanics
/// are its own. `ZombieAI.ApplyVariantBody` attaches it the same way, off the variant's model path.
///
/// Numbers are the original's, from `nz_zombie_boss_napalm.lua` and `nz_zombiebase_moo.lua:5555`:
/// blast 200 damage over 200 units with linear falloff, suicide inside 100 units, enrage under half
/// health, fire pit 15 damage per tick over 100 units for 20 seconds.
/// </summary>
public sealed class NapalmZombie : Component
{
// ── the blast ────────────────────────────────────────────────────────────
/// <summary>
/// Damage at the centre of the blast, falling linearly to nothing at <see cref="BlastRadius"/>.
///
/// ⛔ 400, NOT UPSTREAM'S 200, AND THAT IS A DELIBERATE DEPARTURE. Asked for a blast that
/// "easily kills the player": the player has 150 health, so at 200 the falloff meant anything
/// past a third of the radius was survivable and the enemy read as a loud inconvenience. At 400
/// it is lethal out to roughly 60% of the radius, which is what makes the two-second fuse a
/// decision rather than a noise.
///
/// ⚠️ THE FALLOFF IS WHY THE NUMBER LOOKS EXTREME. Nobody takes 400 unless they are standing on
/// it — at half the radius it is 200, at three quarters 100.
/// </summary>
[Property] public float BlastDamage { get; set; } = 400f;
/// <summary>
/// How far the blast reaches. Upstream is 200; this is nearly double.
///
/// ⚠️ IT IS ALSO HOW FAR THE PLAYER HAS TO GET IN 2.5 SECONDS. Since the wind-up cannot be
/// cancelled, this radius and `FuseSeconds` together ARE the escape: 380 units is a comfortable
/// sprint if you move the moment it starts and impossible if you hesitate. Change either and you
/// have changed how survivable the enemy is, whatever the damage says.
/// </summary>
[Property] public float BlastRadius { get; set; } = 380f;
/// <summary>
/// How close it has to be before it blows itself up.
///
/// ⚠️ SHORTER THAN ITS OWN ATTACK RANGE (72) IS WRONG, AND UPSTREAM'S 100 IS DELIBERATELY
/// LONGER. The suicide is meant to pre-empt the melee, not follow it: at 100 it commits while
/// you can still back away, which is the whole decision the enemy poses. Drop it below the
/// attack range and it becomes an ordinary zombie that happens to explode on death.
/// </summary>
[Property] public float SuicideRange { get; set; } = 100f;
/// <summary>Seconds after spawning before it may detonate. Stops one that spawns on top of a
/// player from killing them with no warning at all.</summary>
[Property] public float ArmDelay { get; set; } = 3f;
/// <summary>
/// How long it winds up before it goes off, and how long its explode animation runs.
///
/// ⛔ IT DOES NOT DETONATE THE INSTANT YOU ARE IN RANGE, AND THAT IS THE WHOLE ENEMY. Upstream
/// plays `nz_napalm_attack_0X` and blows up at the end of it; the animation IS the warning, and
/// without it a napalm zombie is an instant-death trap you cannot react to.
///
/// ⛔ 2.5 SECONDS, FROM THE ENGINE — NOT THE 2.0 I ARITHMETICKED. I counted 61 `time` lines in
/// `nz_napalm_attack_01.smd` and divided by 30, which assumed a framerate the clip does not
/// have: `nz_seq` reports the compiled sequence as **2.5s**, so it is authored at 24fps. The
/// 2.0 cut the wind-up off a fifth of the way from its end, which reads as the animation being
/// interrupted rather than as a detonation landing on it. **Ask the model, not the source file.**
///
/// ⚠️ IT CANNOT BE CALLED OFF, MATCHING UPSTREAM'S `self.Suicide = true`. A cancel-on-distance
/// was tried and removed — it let a player defuse the thing by walking backwards, so the
/// eruption never happened. The 2.5 seconds are the escape; see `BlastRadius`.
/// </summary>
[Property] public float FuseSeconds { get; set; } = 2.5f;
/// <summary>The wind-up clips. Imported from `moo_codz_animations_attack_base`.</summary>
static readonly string[] FuseClips =
{
"nz_napalm_attack_01", "nz_napalm_attack_02", "nz_napalm_attack_03",
};
/// <summary>
/// Seconds between eruptions.
///
/// ⛔ WITHOUT THIS IT IS NOT AN ATTACK, IT IS A GRINDER. An explosion that can retrigger the
/// moment its wind-up ends means standing anywhere near it is a continuous 400 damage, and there
/// is no window in which backing off is a decision rather than the only move. The cooldown is
/// what leaves room to shoot it.
///
/// ⚠️ MEASURED FROM THE BLAST, NOT FROM THE WIND-UP, so the 2.5s animation is not part of the
/// wait — the gap you get is this, in full, after the bang.
/// </summary>
[Property] public float BlastCooldown { get; set; } = 5f;
/// <summary>
/// How many blasts one eruption fires.
///
/// ⛔ THREE IS NOT "ONE BLAST WITH MORE DAMAGE", AND THE SPACING IS WHY. Each one is a separate
/// 400-damage sphere evaluated at the moment it goes off, so a player already running is caught
/// by the first and outside the second — while one who stood still eats all three. A single
/// larger blast cannot make that distinction: it is one snapshot and everybody in it takes the
/// same hit whatever they were doing.
/// </summary>
[Property] public int BlastCount { get; set; } = 3;
/// <summary>
/// Seconds between the blasts of one eruption.
///
/// ⚠️ SHORT ENOUGH TO READ AS ONE EVENT, LONG ENOUGH TO OUTRUN. At 0.35s the three land inside
/// a second — plainly a burst rather than three attacks — but a sprinting player still covers
/// real ground between them, which is the whole point of splitting it up.
/// </summary>
[Property] public float BurstInterval { get; set; } = 0.35f;
/// <summary>
/// How fast it walks at full health, in UNITS PER SECOND.
///
/// ⛔ AN ABSOLUTE SPEED, WHICH IS THE ONLY THING THAT ALSO PICKS AN ANIMATION TIER.
/// `RefreshTier` resolves clips from `EffectiveFixedSpeed` when one is set and from the ROUND's
/// `SpeedRating` when it is not — and the round rating has nothing to do with how fast THIS
/// enemy moves, because its speed comes from its own health. Left on the rating, a high enough
/// `SpeedCap` in some map's config would drop a walking napalm zombie into the sprint clips and
/// nothing here would know. Stating the speed outright makes the enemy's gait its own business.
///
/// ⚠️ 55 BECAUSE THAT IS WHAT IT ALREADY WALKED AT. The old chain came out as
/// `max( 46 x ramp, MinMoveSpeed )` and `MinMoveSpeed` is 55, so full health measured 55 in
/// game. This reproduces that exactly — and incidentally repairs the ramp, which the floor had
/// been quietly eating: 3x of 46 is 138, which against a floored baseline of 55 is 2.5x, not the
/// 3x `MaxSpeedScale` promises. Now 3x means 3x.
/// </summary>
[Property] public float WalkSpeed { get; set; } = 55f;
/// <summary>
/// How fast it moves while the eruption is on cooldown, in UNITS PER SECOND.
///
/// ⛔ IT CHARGES THE MOMENT IT CANNOT EXPLODE. The cooldown used to be pure respite — it blew
/// up, then shambled. Now the five seconds it cannot hurt you are the five seconds it closes the
/// distance fastest, so backing out of a blast buys you position and not safety, and the fight
/// becomes a thing you have to keep solving rather than a timer you wait out.
///
/// ⛔ THIS WAS A MULTIPLIER (5x) AND IS NOW A SPEED, AND THE NUMBER IS THE SAME EVENT. 5x of the
/// 46 u/s walk clip measured 230 in game, so 230 is what it has always done — but a multiplier
/// cannot name an animation tier, and a charging zombie playing a walk cycle was the whole
/// complaint. Anything reading `ChargeSpeed` as a factor is reading a stale note.
///
/// ⚠️ IT STILL MULTIPLIES THE HEALTH RAMP. A nearly-dead one charging is 3 x 230 = 690 —
/// genuinely frightening, and deliberately so; that is what being nearly dead has cost you the
/// whole time.
///
/// ⚠️ AND IT MUST STAY ABOVE THE SPRINT TIER'S `MinSpeed` IN `napalm.zvar` (200), which is what
/// hands it the walker's sprint clips. Drop it below that and it charges at 230 playing a walk.
/// </summary>
[Property] public float ChargeSpeed { get; set; } = 230f;
/// <summary>
/// What incoming damage is multiplied by while it is NOT on cooldown — 0.1, a tenth.
///
/// ⛔ IT IS NOT A HEALTH POOL, AND THE DIFFERENCE IS THE ENTIRE FIGHT. Raising
/// `HealthMultiplier` would make it take longer to kill from anywhere at any time, which is just
/// a bigger number. A resistance that OPENS turns the same total effort into a question of
/// WHEN — you cannot chip it down from across the map, you have to let it reach you, eat the
/// eruption, and then burn it while it is running at you. That is a decision the player makes
/// every five seconds instead of a bar they drain.
///
/// ⚠️ IT MULTIPLIES THE x8 HEALTH IT ALREADY HAS, so outside the window it is effectively 80x a
/// walker and meant to read as bulletproof. If it reads as buggy instead rather than as armoured,
/// the number to move is this one, not the health.
///
/// ⚠️ APPLIED IN `Health.OnDamage`, SO IT COVERS EVERY SOURCE — bullets, explosives, another
/// napalm zombie's fire pool. It is the last victim-side term before the subtraction.
/// </summary>
[Property] public float ArmourScale { get; set; } = 0.1f;
/// <summary>
/// What a hit from a Cryofreeze weapon is multiplied by, on top of everything else.
///
/// ⛔ FIRE HAS A COUNTER NOW, AND IT IS THE OBVIOUS ONE. This enemy is otherwise answered only
/// by timing — wait for the charge, unload, back off — and timing is the same answer for every
/// player carrying any gun. A weakness to the cold mod turns the Arsenal into part of the
/// solution: the round a napalm zombie shows up is a reason to have fitted something specific,
/// which is exactly what ammo mods are for and what they mostly are not used for.
///
/// ⚠️ IT STACKS WITH THE PHASE RATHER THAN REPLACING IT, which is the whole range: 0.2 into the
/// armour and 2.0 during the charge. Cryo does not let you shoot it while it is walking — it
/// doubles what you already get — so the fight still reads "wait for the run", only faster.
/// </summary>
[Property] public float CryoScale { get; set; } = 2f;
/// <summary>
/// The ammo mod that doubles damage to it.
///
/// ⚠️ A STRING BECAUSE `AmmoMods` HAS NO CONSTANTS AND ITS OWN CALLERS COMPARE LITERALS —
/// `BlastFurnace` does `Held( player )?.Id != "blastfurnace"` for the same reason. Named once
/// here rather than inline so the compare has something to point at, and `nz_napalm_tune`
/// checks it against the real table: a typo in an id is a feature that silently never fires,
/// which is the failure mode this project keeps meeting.
/// </summary>
public const string CryoMod = "cryofreeze";
/// <summary>
/// Is it in the window — charging, swinging, and killable?
///
/// ⚠️ ONE PROPERTY SO THE THREE BEHAVIOURS CANNOT DISAGREE. Speed, melee and armour all ask this
/// and nothing else, which is what makes "is it running?" a reliable read for the player rather
/// than three rules that happen to line up today.
/// </summary>
public bool Charging => _cooling > 0f;
// ── the fire it leaves ───────────────────────────────────────────────────
//
// ⛔ ONLY ON DEATH. Its attack blast leaves nothing behind: a pool of fire per eruption would
// carpet the floor the player is being pushed across, and the thing that makes the fire read
// as a reward for killing it is that it only ever appears once.
[Property] public float FireRadius { get; set; } = 100f;
[Property] public float FireSeconds { get; set; } = 20f;
[Property] public float FireDamage { get; set; } = 15f;
/// <summary>Seconds between fire ticks. Upstream re-thinks on `Rand( 0.25, 0.36 )`.</summary>
[Property] public float FireInterval { get; set; } = 0.3f;
// ── what it looks like ───────────────────────────────────────────────────
/// <summary>
/// How hard the blast kicks the camera, and how far that reaches.
///
/// ⚠️ THE RANGE IS WIDER THAN THE BLAST ON PURPOSE. Upstream shakes at 400 against a 200-unit
/// explosion — you are meant to feel one go off across the room, not only when it hits you.
/// Keeping the two equal would make the shake a damage indicator, which the overlay already is.
/// </summary>
[Property] public float ShakeStrength { get; set; } = 1.4f;
[Property] public float ShakeRange { get; set; } = 1200f;
// ── enrage ───────────────────────────────────────────────────────────────
/// <summary>
/// What its speed is multiplied by when its health reaches zero — the top of a CONTINUOUS ramp
/// from 1x at full health.
///
/// ⛔ A RAMP, NOT A THRESHOLD. Upstream flips once under half health and this used to copy that;
/// asked for "faster the less hp it has left, up to 3x". The difference is readable: a threshold
/// is a surprise that happens once, and a ramp is pressure the player can feel building while
/// they decide whether to finish it or run.
///
/// ⚠️ APPLIED EVERY TIME ITS HEALTH MOVES, AND SET RATHER THAN MULTIPLIED. Multiplying into
/// `ExtraSpeedMultiplier` on each hit compounds — three shots would cube it — so the base is
/// captured once at spawn and the ramp is written over it.
/// </summary>
[Property] public float MaxSpeedScale { get; set; } = 3f;
ZombieAI _ai;
Health _health;
TimeSince _alive;
TimeUntil _fuse;
TimeUntil _cooling;
TimeUntil _nextBlast;
int _blastsLeft;
float _baseSpeedScale = 1f;
float _appliedSpeed = -1f;
float _appliedOverride = -1f;
/// <summary>
/// Which tier the clips were last picked for, or -1 for "never".
///
/// ⛔ A FIELD RATHER THAN `TierOf( _appliedOverride )`, AND THE DIFFERENCE IS A REAL BUG I
/// WATCHED HAPPEN. Deriving it meant the "no speed applied yet" sentinel of -1 answered TIER 0 —
/// the walk tier — so any change INTO the walk tier from an unknown state was not a change at
/// all and the clips were never re-picked. `nz_napalm_tune` resets the guards to force a rewrite,
/// and the zombie came back at 55 u/s still playing the sprint cycle it had been tuned into a
/// moment earlier. -1 has to mean "unknown", and a number that also has to be a speed cannot.
/// </summary>
int _appliedTier = -1;
/// <summary>The burning loop, kept so it can be stopped on a machine that never sees it die.</summary>
SoundHandle _loop;
bool _lit;
bool _died;
/// <summary>
/// 0 when nothing is burning near you, 1 when a napalm zombie is on top of you — what
/// `NapalmOverlay` reads.
///
/// ⚠️ A STATIC ON THE COMPONENT, WRITTEN BY THE NEAREST ONE. The overlay is a single panel and
/// there can be several of these alive, so somebody has to reduce "how many are near me" to one
/// number. Doing it here keeps the panel free of any search of its own.
/// </summary>
public static float Heat { get; private set; }
/// <summary>
/// Raise the glow from outside: this machine's player burning in a Fire Margwa's line (2026-10-06, `MargwaBurn`).
/// Raise-only, as <see cref="Heatwave"/> is, and `NapalmOverlay` decays it the same way.
/// </summary>
public static void RaiseHeat( float value )
{
var v = value.Clamp( 0f, 1f );
if ( v > Heat ) Heat = v;
}
/// <summary>
/// How close before the screen starts to glow.
///
/// ⛔ 2700 — THREE TIMES THE 900 THAT REPLACED THE ORIGINAL 320. Each widening was asked for
/// after seeing the previous one in play, which is the right way round: the number that matters
/// is "when can I first tell one is coming", and that is a question about a room, not about a
/// radius anyone can reason about from a file.
///
/// ⚠️ NOW THAT THE FUSE CANNOT BE CANCELLED, THE WARNING IS THE ONLY DEFENCE. There is no
/// backing out once it starts, so everything the player gets has to arrive BEFORE it starts —
/// which is what this range buys them.
/// </summary>
[ConVar( "nz_napalm_heat_range" )] public static float HeatRange { get; set; } = 2700f;
protected override void OnStart()
{
_ai = Components.Get<ZombieAI>( FindMode.EverythingInSelf );
_health = Components.Get<Health>( FindMode.EverythingInSelf );
_alive = 0f;
_cooling = 0f;
// ⚠️ CAPTURED BEFORE THE RAMP EVER WRITES IT. Whatever the spawner set — a round modifier, a
// `nz_zombie` argument — is the 1x this scales from, so the ramp cannot eat it.
if ( _ai.IsValid() )
{
_baseSpeedScale = _ai.ExtraSpeedMultiplier;
// ⛔ IT STARTS UNABLE TO SWING, AND `TickMelee` DECIDES FROM THERE. The eruption is its
// attack while it can erupt; the fists are what it has in the five seconds it cannot.
// Setting this once here was the bug the restored rule replaces — a napalm zombie that
// never swings turns its cooldown into pure respite, and the whole point of the charge
// is that backing off buys you position rather than safety.
//
// ⚠️ THE FLAG STOPS THE PLAYER SWING AND NOTHING ELSE — it still tears barricades, or it
// would stand at a boarded window unable to reach you or get in. That stays true in both
// phases, which is why this is a swing gate and not an "attack" gate.
_ai.MeleeDisabled = true;
}
Ignite();
}
/// <summary>
/// Start the sound of it burning, and hand it to the AI to carry.
///
/// ⛔ THE LOOP IS WHAT MAKES THIS ENEMY FAIR. Everything else about it kills you from outside
/// melee range after a wind-up you may not have been looking at; the burning is the part that
/// tells you one is in the room before any of that starts. The screen glow does the same job
/// but only once you can nearly see it — this arrives through a wall.
///
/// ⛔ PLAYED LOCALLY, NOT THROUGH `PlayShared`, AND THAT IS THE OPPOSITE OF THE RULE FOR EVERY
/// OTHER ZOMBIE SOUND. `PlayShared` exists because a zombie's AI is host-only, so the host has
/// to tell everyone else what it did — but it broadcasts a fire-and-forget ONE-SHOT, and a
/// remote copy of a LOOP is a sound nobody can move, stop or reach. This component's lifecycle
/// runs on every machine that has the body (that is how `Heatwave` feeds the overlay at all), so
/// each one starts and owns its own copy and there is nothing to send.
///
/// ⚠️ `TrackVoice` RATHER THAN A FOLLOW OF OUR OWN. `Sound.Play( cue, position )` samples the
/// position ONCE and the emitter then stays where the zombie was born — 250 units behind it on a
/// sprint. `ZombieAI` already keeps a list of voices, moves them to head height every frame
/// (including on a puppet, which is exactly the client case) and stops them when the zombie
/// dies. Writing a second follower here would be a worse copy of a solved thing.
/// </summary>
void Ignite()
{
if ( !_ai.IsValid() ) return;
// ⚠️ NO `SoundGate` CATEGORY, DELIBERATELY. The gate's budgets are built for one-shots —
// `Feedback` allows 8 CONCURRENT — and a loop never ends, so a categorised loop would hold a
// slot for the zombie's whole life and the ninth napalm zombie would be silent forever.
// Uncategorised cues are passed through untouched.
_loop = NZSound.Play( NZSound.NapalmLoop, WorldPosition + Vector3.Up * 55f );
_ai.TrackVoice( _loop );
}
/// <summary>
/// Stop the loop when the body goes away.
///
/// ⛔ BELT AND BRACES, AND THE BRACES ARE FOR THE CLIENT. `ZombieAI` stops every tracked voice
/// in its death path, which covers the host — but a puppet never runs that path, because its
/// `State` never advances. On every other machine the object simply vanishes, and without this
/// the loop would be left burning at the spot where a zombie used to be, forever.
/// </summary>
protected override void OnDestroy()
{
if ( _loop.IsValid() ) _loop.Stop();
}
protected override void OnUpdate()
{
if ( !_ai.IsValid() ) return;
// ⛔ THE DEATH WATCH IS FIRST AND IT IS A POLL, NOT A HOOK. Every route to a dead zombie —
// shot, exploded, gibbed, tesla'd, killed by another napalm's blast — has to detonate this
// one, and `ZombieAI` exposes no single death event that all of them pass through. Watching
// the state catches every one of them; subscribing to any particular kill path would catch
// the one it was written for.
if ( _ai.State == ZombieState.Dead )
{
// ⛔ THE ONLY BLAST THAT LEAVES FIRE. Every other one is an attack it walked away from.
if ( !_died )
{
_died = true;
Detonate( fatal: true );
}
return;
}
TickMelee();
ApplySpeed();
Fuse();
Heatwave();
}
/// <summary>
/// Its fists are only out while it cannot explode.
///
/// ⚠️ WRITTEN EVERY FRAME RATHER THAN ON THE EDGE, and that is safe here in a way the speed is
/// not: `MeleeDisabled` is a plain bool that `TickAttack` reads, with none of the re-pick cost
/// `RefreshSpeed` carries. An edge-triggered version would need its own remembered state and
/// would go wrong the first time anything else touched the flag.
/// </summary>
void TickMelee() => _ai.MeleeDisabled = !Charging;
/// <summary>
/// Push the screen-glow value for whoever is nearest.
///
/// ⚠️ IT ONLY EVER RAISES, AND `NapalmOverlay` DECAYS IT. Several zombies writing every frame
/// would otherwise mean the LAST one to tick won, so standing between two would flicker rather
/// than burn. Raise-only plus decay at the reader is the same shape the fog weight uses.
/// </summary>
void Heatwave()
{
var p = NZPlayer.Local;
if ( !p.IsValid() || HeatRange <= 1f ) return;
var d = WorldPosition.Distance( p.WorldPosition );
if ( d > HeatRange ) return;
// ⛔ `t^1.5`, NOT `t*t`. Squared was right for a 320u range and wrong for a 900u one: it
// stays near zero for the first two thirds, so widening the range alone would have moved the
// number without moving the moment you can SEE anything. This ramps in early and still
// leaves most of its strength for the last few metres — faint at 600, obvious at 300.
var t = 1f - ( d / HeatRange );
var want = t * MathF.Sqrt( t );
// ⛔ LIT ONES BURN HARDER. The fuse is the moment the warning has to be unmissable — that is
// what the two seconds are FOR — so the glow roughly doubles once it has committed.
if ( _lit ) want = MathF.Min( 1f, want * 2f );
if ( want > Heat ) Heat = want;
}
/// <summary>Let the reader drop it — unless a test is holding it up.</summary>
public static void CoolTo( float v )
{
// ⛔ A HELD VALUE IGNORES THE DECAY, AND THE TEST COMMAND IS USELESS WITHOUT IT. `Heat` falls
// at `FadePerSecond`, so forcing it and then looking at the screen loses the race every
// time: by the time a screenshot or a pair of eyes arrives it is already zero, and the
// overlay reads as broken when it is working.
if ( _hold > 0f ) { Heat = _held; return; }
Heat = v;
}
static TimeUntil _hold;
static float _held;
/// <summary>Pin the glow for a few seconds so it can be looked at.</summary>
public static void HoldHeat( float value, float seconds )
{
_held = value.Clamp( 0f, 1f );
_hold = seconds;
Heat = _held;
}
/// <summary>
/// Its speed: the health ramp, times the charge while it is reloading.
///
/// ⛔ ONE PLACE COMPUTES BOTH, AND THE GUARD IS ON THE RESULT. An earlier version guarded on the
/// HEALTH fraction having moved, which meant the charge could never switch on or off — health
/// does not change at the moment a cooldown starts, so the multiplier was simply never
/// recalculated and the 5x would have silently done nothing. Comparing the final number catches
/// every reason it might differ, including the ones added next.
/// </summary>
void ApplySpeed()
{
if ( !_health.IsValid() || _health.Max <= 0f ) return;
var frac = ( _health.Current / _health.Max ).Clamp( 0f, 1f );
var ramp = MathX.Lerp( 1f, MaxSpeedScale, 1f - frac );
// ⚠️ THE CHARGE MULTIPLIES THE RAMP RATHER THAN REPLACING IT, so a nearly-dead one on
// cooldown is 3 x 230. That is the intended top end.
var speed = ( Charging ? ChargeSpeed : WalkSpeed ) * ramp;
// ⛔ THE MULTIPLIER IS STILL WRITTEN, AND IT IS NOT REDUNDANT — IT SETS THE ANIMATION RATE.
// `SpeedOverride` decides how fast the BODY travels and bypasses `_clipGroundSpeed`
// entirely, but the LEGS run at `velocity / _clipGroundSpeed`, and that is where
// `ExtraSpeedMultiplier` lands. Leaving the charge factor in it would tell the animation
// system this clip is authored five times faster than it is, the ratio would collapse toward
// 1.0, and the zombie would slide across the floor with its legs turning at a walking pace —
// the SKATE `ApplyGroundSpeed` warns about eight lines above where it applies the tier
// scale. So the multiplier carries the health ramp only; the charge lives in the override.
var mult = _baseSpeedScale * ramp;
// ⚠️ GUARDED ON BOTH RESULTS, NOT ON THE HEALTH FRACTION. An earlier version compared the
// health fraction, which meant the charge could never switch on or off — health does not
// change at the moment a cooldown starts, so the new speed was computed into a local and
// never written. Compare what is actually about to be assigned.
if ( MathF.Abs( mult - _appliedSpeed ) < 0.05f
&& MathF.Abs( speed - _appliedOverride ) < 0.05f ) return;
// ⛔ A TIER CHANGE NEEDS `RepickAnimations`, AND A RETUNE MUST NOT HAVE IT. `RefreshSpeed`
// only re-reads the numbers around the clip already playing, which is what the health ramp
// wants — re-picking every time the zombie is shot would restart its walk cycle on every
// bullet. But crossing the sprint threshold IS a new clip pool, and `RefreshSpeed` would
// leave it running the walk clips at 230 u/s, which is the exact bug this is fixing.
var tier = TierOf( speed );
var crossed = tier != _appliedTier;
_appliedSpeed = mult;
_appliedOverride = speed;
_appliedTier = tier;
// ⚠️ THROUGH `ExtraSpeedMultiplier`, THE PER-SPAWN KNOB. The variant's own `SpeedMultiplier`
// belongs to the asset and is shared by every napalm zombie alive; writing there would speed
// up the whole species the first time one of them was shot.
_ai.ExtraSpeedMultiplier = mult;
_ai.SpeedOverride = speed;
if ( crossed ) _ai.RepickAnimations();
else _ai.RefreshSpeed();
}
/// <summary>
/// Which tier of `napalm.zvar` a speed lands in — the walk clips, or the walker's sprint set.
///
/// ⚠️ IT MIRRORS A NUMBER THAT LIVES IN THE ASSET, which is a duplication worth naming out loud.
/// The real threshold is the sprint tier's `MinSpeed` and `ZombieVariant.TierForSpeed` is what
/// applies it; this only decides whether re-picking is worth the cost. Getting it wrong costs a
/// missed clip change, never a wrong speed — so it is a cheap copy rather than a dangerous one.
/// </summary>
static int TierOf( float speed ) => speed >= 200f ? 1 : 0;
/// <summary>Wind up when the target is close enough, then erupt — and live through it.</summary>
void Fuse()
{
if ( _lit )
{
// ⛔ ONCE IT STARTS IT GOES OFF, WHATEVER YOU DO. A `CancelRange` was tried and removed:
// it meant walking backwards defused the thing entirely, so the eruption never happened
// and the enemy had no teeth. What the player gets instead is the 2.5 seconds — enough
// to clear 380 units if they move the moment the wind-up starts, and not enough if they
// hesitate. That is the decision; cancelling it was letting them skip the decision.
//
// ⚠️ SO THE FUSE DOES NOT CARE WHERE THE TARGET IS, or whether there still is one. A
// napalm zombie whose target dies mid-wind-up still erupts, which is correct: the thing
// is already committed and the blast is a fact about the world, not about you.
// ⚠️ THE CLIP IS NOT WHAT ENDS IT — THE CLOCK IS. `PlaySpecial` holds the pose for the
// seconds it was given and a clip that fails to load returns false, so waiting on the
// animation would leave a zombie frozen forever the day a clip name drifts.
if ( _fuse > 0f ) return;
// ⛔ IT DOES NOT DIE AND IT TAKES NOTHING. The blast is its attack; `Detonate` skips this
// GameObject, so the only thing that can kill a napalm zombie is the player.
_lit = false;
_blastsLeft = BlastCount.Clamp( 1, 12 );
_nextBlast = 0f;
return;
}
// ── the burst ────────────────────────────────────────────────────────
if ( _blastsLeft > 0 )
{
if ( _nextBlast > 0f ) return;
var count = BlastCount.Clamp( 1, 12 );
Detonate( fatal: false, shot: count - _blastsLeft + 1, of: count );
_blastsLeft--;
_nextBlast = BurstInterval;
// ⛔ THE COOLDOWN STARTS ON THE LAST BLAST, NOT THE FIRST. Starting it up front would
// have the charge overlap its own eruption — the thing sprinting away from explosions it
// is still firing — and would shorten the actual gap by the length of the burst.
if ( _blastsLeft == 0 ) _cooling = BlastCooldown;
return;
}
if ( _alive < ArmDelay || _cooling > 0f ) return;
var target = _ai.Target;
if ( !target.IsValid() ) return;
if ( _ai.WorldPosition.Distance( target.WorldPosition ) > SuicideRange ) return;
Wind();
}
/// <summary>
/// Start the wind-up, target or no target.
///
/// ⛔ SPLIT OUT SO `nz_napalm_erupt` CAN REACH IT. The eruption is otherwise only reachable by
/// standing in front of one, which means the whole burst — three blasts, the cooldown, the
/// charge — cannot be looked at in Creative, where nothing gives them a target. A behaviour you
/// can only see by playing properly is a behaviour that gets shipped unverified.
/// </summary>
public void Wind()
{
if ( _lit || _blastsLeft > 0 ) return;
_lit = true;
_fuse = FuseSeconds;
// ⚠️ THE POSE IS HELD THROUGH THE BURST, NOT JUST THE WIND-UP. `PlaySpecial` freezes it for
// the seconds given; ending at `FuseSeconds` would have it walk away mid-eruption and trail
// its own explosions behind it. The blasts belong at the place the player was backing away
// from.
var hold = FuseSeconds + ( BlastCount.Clamp( 1, 12 ) - 1 ) * BurstInterval;
// ⚠️ IF THE CLIP WILL NOT PLAY THE FUSE STILL BURNS. `PlaySpecial` refuses on a model that
// lacks the sequence and says so; losing the animation should cost the warning, not the
// enemy.
_ai.PlaySpecial( Game.Random.FromArray( FuseClips ), hold );
// ⛔ `PlayShared`, BECAUSE THE WIND-UP IS DECIDED BY THE HOST ALONE. `Fuse` reads
// `_ai.Target`, and a puppet never acquires one — the host thinks and everyone else watches
// — so a client that played this locally would never play it at all. This is the case the
// broadcast is for, and the exact opposite of the burning loop above.
//
// ⚠️ A STATIC EMITTER IS RIGHT HERE. `PlaySpecial` freezes the body for the whole wind-up
// and burst, so there is nothing to follow; the cue and the thing making it are in the same
// place for its entire length.
NZSound.PlayShared( NZSound.NapalmCharge, WorldPosition + Vector3.Up * 55f );
Log.Info( $"[nz-napalm] winding up — {FuseSeconds:0.#}s, then {BlastCount}"
+ $" blast(s) {BurstInterval:0.##}s apart" );
}
/// <summary>
/// The blast. <paramref name="fatal"/> is the death blast, and the only one that leaves fire.
/// </summary>
void Detonate( bool fatal, int shot = 0, int of = 1 )
{
var scene = Scene;
if ( !scene.IsValid() ) return;
// ⛔ A CLIENT'S COPY ONLY SHOWS THE BLAST; IT NEVER DEALS IT (2026-10-05). `ZombieAI.DieAsPuppet` sets `State` to Dead on
// every client, so the death watch in `OnUpdate` fired this on each of them as well as on the host: every machine dealt the
// 400-damage blast, its own player took it twice, and each client sent it on for the other players (refused by the host
// since 23:30, `NZNet.HurtRemote`). Each also broadcast a second fireball and its own explosion sounds. The host's blast
// already reaches everyone (damage forwarded to each body's owner, `BlastEffect` and the sounds shared), so a client adds
// only what never travels: the camera shake and the fire pool's look (`NapalmBlaze` burns on the host alone).
if ( _ai.IsValid() && _ai.IsPuppet )
{
CameraShake.Punch( WorldPosition, ShakeStrength, ShakeRange );
if ( fatal ) Blaze( WorldPosition );
return;
}
var pos = WorldPosition + Vector3.Up * 32f;
int hurt = 0, inRange = 0;
foreach ( var hp in scene.GetAllComponents<Health>().ToList() )
{
if ( !hp.IsValid() ) continue;
// ⛔ NEVER ITSELF, AND NEVER ANOTHER NAPALM ZOMBIE. Itself because the blast is an
// attack it survives; the others because a 400-damage blast that reaches 380 units would
// set off every one of them in the room, and a chain of eruptions nobody triggered is
// not a fight, it is a cutscene.
if ( hp.GameObject == GameObject ) continue;
if ( hp.Components.Get<NapalmZombie>( FindMode.EverythingInSelfAndAncestors ).IsValid() ) continue;
var at = hp.WorldPosition + Vector3.Up * 32f;
var dist = pos.Distance( at );
if ( dist > BlastRadius ) continue;
inRange++;
// ⚠️ LINE OF SIGHT, AS UPSTREAM DOES — `if tr1.HitWorld then continue`. A blast that
// reaches through a wall is one the player cannot learn to avoid, and on a map built
// out of platforms that is most of the arena.
//
// ⛔ MEASURED AS "HOW FAR SHORT DID THE RAY STOP", NOT AS `tr.Distance < dist - 8f`.
// That form — copied from `Grenade.Detonate` — threw the blast away every time: the ray
// hits the victim's CAPSULE, whose surface is about 16 units before the centre it was
// aimed at, so `dist - 16 < dist - 8` is true for a target standing in the open with
// nothing between you at all. A napalm zombie detonated in the player's face for zero
// damage while its fire pool burned them normally, which reads as "the explosion does
// nothing" rather than as a geometry test.
//
// ⚠️ 24 UNITS OF SLACK, WHICH IS THE CAPSULE PLUS MARGIN. Asking WHICH object was hit
// would be the other fix, but a player is a body with children and a zombie corpse can
// leave a ragdoll in the way; "did the ray get there" needs no object identity at all.
var tr = scene.Trace.Ray( pos, at ).IgnoreGameObject( GameObject ).Run();
if ( tr.Hit && tr.HitPosition.Distance( at ) > 24f ) continue;
// Linear falloff to nothing at the rim: `1 - clamp( dist / 200 )`.
var falloff = 1f - MathX.Clamp( dist / MathF.Max( BlastRadius, 1f ), 0f, 1f );
// ⚠️ A PLAYER TAKES THE MATCH'S ZOMBIE DAMAGE ON IT (the lobby's Difficulty, 2026-10-05); a zombie caught in it does not
var amount = BlastDamage * falloff;
if ( hp.Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors ).IsValid() ) amount *= Difficulty.ZombieDamage;
hp.OnDamage( new DamageInfo { Damage = amount, Position = at } );
hurt++;
}
// ⛔ THE VISUAL COMES AFTER THE DAMAGE AND BEFORE THE LOG, so a blast that throws on a missing
// asset cannot cost anybody their hit points. `BlastEffect` is already written to swallow a
// missing prefab for exactly that reason — decoration must not be able to break a gameplay
// path — but the ORDER here is what makes that guarantee mean something.
//
// ⚠️ `BlastEffect` IS THE ENGINE'S ONLY EXPLOSION PREFAB AND BOTH OTHER CALLERS SHARE IT.
// Its own header says searching the whole asset system returns exactly one; authoring a
// second fireball for this enemy would be the "worse version of something that already
// exists" mistake for the third time today.
//
// ⚠️ SIZED TO `BlastRadius`, NOT TO THE PREFAB. It is authored for 256 units and this blast
// is 380, so it scales up — a fireball visibly smaller than the radius that kills you is
// worse than none, because it teaches the wrong distance.
BlastEffect.Spawn( pos, BlastRadius );
CameraShake.Punch( WorldPosition, ShakeStrength, ShakeRange );
// ⚠️ SHARED FOR THE SAME REASON THE WIND-UP IS: `Detonate` is only ever reached from host
// state — a target in range, or a death the host declared — so the other machines have to be
// told. An explosion does not move, so a static emitter is not a compromise here.
NZSound.PlayShared( NZSound.NapalmExplode, pos );
if ( fatal )
{
Blaze( WorldPosition );
// ⚠️ THE FLARE IS THE FIRE CATCHING, NOT A SECOND EXPLOSION, so it plays under the blast
// rather than instead of it — and only on the death burst, because that is the only one
// that leaves anything burning.
NZSound.PlayShared( NZSound.NapalmFlare, WorldPosition );
}
// ⚠️ IT SAYS WHICH BLAST OF THE BURST THIS IS, AND ONLY THE LAST ONE MENTIONS THE COOLDOWN.
// Three identical "cooling 5s" lines read as three cooldowns starting, which is exactly the
// bug the code does NOT have — a log that describes behaviour nobody implemented is worse
// than no log, because the next person debugging this will believe it.
Log.Info( $"[nz-napalm] {( fatal ? "died and burst" : $"erupted {shot}/{of}" )} at {WorldPosition:0}"
+ $" — {hurt} hurt of {inRange} in range ({BlastRadius:0}u)"
+ ( fatal ? $", fire for {FireSeconds:0}s"
: shot >= of ? $", cooling {BlastCooldown:0.#}s at {ChargeSpeed:0} u/s" : "" ) );
}
/// <summary>Leave the pool of fire burning.</summary>
void Blaze( Vector3 at )
{
var scene = Scene;
if ( !scene.IsValid() || FireSeconds <= 0f ) return;
var go = scene.CreateObject();
go.Name = "Napalm Blaze";
go.WorldPosition = at;
go.Flags |= GameObjectFlags.NotSaved;
go.NetworkMode = NetworkMode.Never;
// ⛔ `PitVisual` IN ITS `Fire` STYLE, NOT A NEW EFFECT. That style IS this enemy's fire —
// its own header says the look is a straight port of the `DynamicLight` that GMod's three
// fire-pit entities create, the napalm zombie's among them. Authoring a second one would be
// a worse copy of something already ported, which is a mistake this project has made before
// and written up.
//
// ⚠️ THE VISUAL IS ATTACHED FIRST BECAUSE IT DROPS THE OBJECT TO THE FLOOR, and the burn
// below is a sphere around `WorldPosition`. Attaching after would burn a volume centred
// where the flames are not — the same ordering `FireAugments.SpawnPit` documents.
PitVisual.Attach( go, FireRadius, PitVisual.Style.Fire, FireSeconds );
var blaze = go.Components.Create<NapalmBlaze>();
blaze.Radius = FireRadius;
blaze.Damage = FireDamage;
blaze.Interval = FireInterval;
blaze.Life = FireSeconds;
}
/// <summary>
/// The scale to apply to a hit on this object, or 1 when it is not a napalm zombie.
///
/// ⚠️ A STATIC LOOKUP SO `Health.OnDamage` NEED NOT KNOW WHAT A NAPALM ZOMBIE IS — the same
/// shape `BrutusHelmet.ScaleOn` established, and the same reason: every other victim-side
/// modifier in that method reads a component off the victim.
///
/// ⚠️ `EverythingInSelfAndAncestors` BECAUSE THE `Health` MAY NOT BE ON THE SAME OBJECT. The
/// blast loop upstairs uses the same search for the same reason, and a `Get` limited to self
/// would return 1 for a body whose health component lives on a child — which would look like
/// the armour simply not working.
/// </summary>
public static float ScaleOn( GameObject victim, GameObject attacker = null )
{
if ( !victim.IsValid() ) return 1f;
var z = victim.Components.Get<NapalmZombie>( FindMode.EverythingInSelfAndAncestors );
if ( !z.IsValid() ) return 1f;
// ⚠️ CLAMPED AT ZERO, NOT AT SOME SMALL POSITIVE NUMBER. `nz_napalm_tune armour 0` making it
// genuinely invulnerable is a legitimate thing to want to test, and a silent floor would
// make that test lie.
var scale = z.Charging ? 1f : MathF.Max( 0f, z.ArmourScale );
// ⛔ MULTIPLIED IN AFTER THE PHASE, SO IT DOUBLES BOTH ENDS RATHER THAN OVERRIDING ONE.
// Applying it instead of the armour would let a cryo weapon kill it mid-walk and delete the
// whole charge-window fight for anybody who fitted one mod.
if ( IsCryo( attacker ) ) scale *= MathF.Max( 0f, z.CryoScale );
return scale;
}
/// <summary>
/// Was this fired by someone holding a Cryofreeze weapon?
///
/// ⚠️ THE MOD IS PER WEAPON AND PER PLAYER, WHICH IS WHY IT IS ASKED OF THE ATTACKER AND NOT OF
/// A STATIC. `AmmoMods.Held` reads the mod fitted to the gun that player is holding right now —
/// so in a four-player game the one who brought the cold does double and the others do not,
/// and swapping to a second weapon drops the bonus mid-fight. Both are correct.
///
/// ⚠️ `EverythingInSelfAndAncestors` MATCHES `AmmoMods.OnZombieHit`, which resolves the same
/// attacker the same way. A bullet's attacker is not reliably the player's own GameObject.
/// </summary>
static bool IsCryo( GameObject attacker )
{
if ( !attacker.IsValid() ) return false;
var p = attacker.Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors );
return p.IsValid() && AmmoMods.Held( p )?.Id == CryoMod;
}
// ── commands ─────────────────────────────────────────────────────────────
/// <summary>`nz_napalm [n]` — spawn napalm zombies around you.</summary>
[ConCmd( "nz_napalm" )]
public static void SpawnCmd( int count = 1 )
{
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) { Log.Warning( "[nz-napalm] no active scene" ); return; }
var variant = SpecialEnemies.VariantFor( SpecialEnemies.Napalm );
if ( variant is null )
{
Log.Warning( $"[nz-napalm] '{SpecialEnemies.PathFor( SpecialEnemies.Napalm )}' did not load" );
return;
}
var p = NZPlayer.Local;
var origin = p.IsValid() ? p.WorldPosition : Vector3.Zero;
int made = 0;
for ( int i = 0; i < count.Clamp( 1, 16 ); i++ )
{
var angle = ( i / (float)MathF.Max( 1, count ) ) * MathF.PI * 2f;
var at = origin + new Vector3( MathF.Cos( angle ), MathF.Sin( angle ), 0f ) * 260f;
if ( ZombieCommands.SpawnAt( scene, at, variant ) is not null ) made++;
}
Log.Info( $"[nz-napalm] spawned {made} — they blow up within {260}u of you,"
+ " so do not stand still" );
}
/// <summary>
/// `nz_napalm_erupt` — make every live napalm zombie wind up and blow, without needing a target.
///
/// ⚠️ IT DOES NOT SKIP THE WIND-UP. Firing the blasts directly would test the damage and hide
/// everything the player actually experiences — the 2.5 seconds, the pose, the three bangs and
/// the charge that follows. The point is to watch the sequence, not to trigger the effect.
/// </summary>
[ConCmd( "nz_napalm_erupt" )]
public static void EruptCmd()
{
var live = Game.ActiveScene?.GetAllComponents<NapalmZombie>().ToList();
if ( live is null || live.Count == 0 ) { Log.Warning( "[nz-napalm] none alive" ); return; }
foreach ( var z in live ) z.Wind();
Log.Info( $"[nz-napalm] {live.Count} told to erupt" );
}
/// <summary>
/// `nz_napalm_tune [blast] [radius] [suicide] [firedmg] [walk] [charge] [armour]` — 0 leaves a
/// field alone, and no arguments at all just reports.
///
/// ⚠️ `walk` AND `charge` ARE UNITS PER SECOND, NOT MULTIPLIERS. `charge` was a x5 factor until
/// the sprint animation landed; anyone typing 5 into it now will get a napalm zombie that
/// charges slower than it walks, which is a confusing enough result to be worth the sentence.
///
/// ⚠️ `armour` IS THE ONE FIELD WHOSE USEFUL VALUE IS ZERO, so it takes -1 as "leave alone"
/// rather than 0. Testing an invulnerable one and testing a paper one are both things to want,
/// and a 0-means-skip rule would silently refuse half of that.
/// </summary>
[ConCmd( "nz_napalm_tune" )]
public static void Tune( float blast = 0f, float radius = 0f, float suicide = 0f, float fire = 0f,
float walk = 0f, float charge = 0f, float armour = -1f )
{
var live = Game.ActiveScene?.GetAllComponents<NapalmZombie>().ToList();
int n = 0;
foreach ( var z in live ?? Enumerable.Empty<NapalmZombie>().ToList() )
{
if ( blast > 0f ) z.BlastDamage = blast;
if ( radius > 0f ) z.BlastRadius = radius;
if ( suicide > 0f ) z.SuicideRange = suicide;
if ( fire > 0f ) z.FireDamage = fire;
if ( walk > 0f ) z.WalkSpeed = walk;
if ( charge > 0f ) z.ChargeSpeed = charge;
if ( armour >= 0f ) z.ArmourScale = armour;
// ⚠️ FORCE THE NEXT `ApplySpeed` TO WRITE. It compares against what it last applied and
// returns early when nothing moved — so a retuned speed would sit in the property doing
// nothing until the zombie happened to take damage, and the command would read as broken.
z._appliedSpeed = -1f;
z._appliedOverride = -1f;
z._appliedTier = -1;
n++;
}
// ⛔ THE PHASE IS PRINTED PER ZOMBIE, BECAUSE IT IS THE ONLY THING THAT EXPLAINS A BULLET
// DOING NOTHING. Outside the window this thing is x8 health at a tenth damage — 80x a walker
// — and a player or a tester who does not know which phase it is in has no way to tell
// "armoured" from "broken". The one line that answers it belongs in the one command that
// already enumerates them.
foreach ( var z in live ?? Enumerable.Empty<NapalmZombie>().ToList() )
{
var phase = z.Charging ? 1f : MathF.Max( 0f, z.ArmourScale );
Log.Info( $"[nz-napalm] {( z.Charging ? "CHARGING" : "armoured" )}"
+ $" dmg x{phase:0.##}"
+ $" (cryo x{phase * MathF.Max( 0f, z.CryoScale ):0.##})"
+ $" melee {( z.Charging ? "on " : "off" )}"
+ $" speed {( z.Charging ? z.ChargeSpeed : z.WalkSpeed ):0} u/s base" );
}
// ⛔ THE ID IS CHECKED AGAINST THE REAL TABLE, BECAUSE A WRONG ONE IS SILENT. `Held(...)?.Id
// == "typo"` is false for every weapon ever fitted, so the doubling would simply never
// happen and would look exactly like the hook not being wired up — the same shape as a
// missing sound event, and the reason `nz_sound_check` exists at all.
if ( AmmoMods.Find( CryoMod ) is null )
Log.Warning( $"[nz-napalm] ammo mod '{CryoMod}' is not in the AmmoMods table —"
+ $" the x{( live?.FirstOrDefault()?.CryoScale ?? 2f ):0.##} can never fire."
+ $" Ids: {string.Join( ", ", AmmoMods.Ids )}" );
// ⚠️ THE LIVE ONES ONLY, AND THE REPORT SAYS SO. These are `[Property]` defaults on a
// component created per zombie, so a new one spawns with the authored numbers again — this
// is for watching a change land, not for keeping it.
Log.Info( $"[nz-napalm] retuned {n} live napalm zombie(s); newly spawned ones use the"
+ " component's own defaults" );
}
/// <summary>
/// `nz_napalm_sounds` — play the four cues this component fires, one after the next.
///
/// ⛔ BECAUSE A MISSING SOUND EVENT IS SILENT, NOT LOUD. `Sound.Play` on a cue that does not
/// resolve prints one Info line, returns a dead handle and plays nothing — indistinguishable
/// from "that part is not wired up yet". `nz_sound_check` proves the assets RESOLVE; this is the
/// only way to hear that the right sample is behind each name, which is a different question and
/// the one that catches a mis-filtered folder. `_napalm/explosion/` is five files and three
/// cues, so it is exactly the case where resolving and being correct come apart.
///
/// ⚠️ 2D AND AT THE LISTENER, not out in the world. The point is to audition the samples, and a
/// cue placed 4000 units away to test its falloff is a cue you cannot hear.
/// </summary>
[ConCmd( "nz_napalm_sounds" )]
public static void SoundTest()
{
string[] cues =
{
NZSound.NapalmCharge, NZSound.NapalmExplode,
NZSound.NapalmFlare, NZSound.NapalmLoop,
};
foreach ( var cue in cues )
{
var ok = NZSound.Exists( cue );
if ( ok ) NZSound.Play( cue );
Log.Info( $"[nz-napalm] {( ok ? "playing" : "MISSING " )} {cue}" );
}
Log.Info( "[nz-napalm] the variant's six (idle/close/hit/spawn/step/behind) are named by"
+ " napalm.zvar — nz_sound_check covers those" );
}
}
/// <summary>
/// The pool of fire a napalm zombie leaves behind.
///
/// ⛔ ITS OWN COMPONENT RATHER THAN `NapalmPit`, WHICH BURNS THE OTHER WAY. That one belongs to the
/// Napalm Nectar augment: it is owned by a PLAYER and it ignites ZOMBIES. This one has no owner and
/// burns whoever walks into it. They share the look — `PitVisual.Style.Fire` — because that is the
/// part that was already solved; pointing the augment's pit at the player would have meant an
/// ownership flag threaded through networking, damage attribution and the augment multipliers, to
/// save a damage loop that is fifteen lines.
/// </summary>
public sealed class NapalmBlaze : Component
{
public float Radius { get; set; } = 100f;
public float Damage { get; set; } = 15f;
public float Interval { get; set; } = 0.3f;
public float Life { get; set; } = 20f;
TimeSince _alive;
TimeSince _lastTick;
protected override void OnStart()
{
_alive = 0f;
_lastTick = 0f;
}
protected override void OnUpdate()
{
if ( _alive > Life ) { GameObject?.Destroy(); return; }
// ⛔ THE HOST'S FIRE IS THE ONE THAT BURNS (2026-10-05). A client's pool is the picture of the host's (`Detonate`): the
// host's ticks reach every player through the body's owner, so a client's own ticks were a second burn on its player.
if ( NZGame.IsClient ) return;
if ( _lastTick < Interval ) return;
_lastTick = 0f;
var scene = Scene;
if ( !scene.IsValid() ) return;
foreach ( var p in scene.GetAllComponents<NZPlayer>().ToList() )
{
if ( !p.IsValid() ) continue;
// ⚠️ MEASURED FROM THE FEET. A pool of fire on the floor burns you for standing in it,
// and a body-centre test lets a player stand in flames up to the waist untouched — the
// same distinction `Pickup` records about `FindInSphere` taking the player's origin.
if ( p.WorldPosition.Distance( WorldPosition ).Clamp( 0f, 100000f ) > Radius ) continue;
var hp = p.Components.Get<Health>( FindMode.EverythingInSelf );
if ( !hp.IsValid() ) continue;
// ⚠️ THE MATCH'S ZOMBIE DAMAGE (the lobby's Difficulty, 2026-10-05): the fire burns players only
hp.OnDamage( new DamageInfo { Damage = Damage * Difficulty.ZombieDamage, Position = p.WorldPosition } );
}
}
}