Grenade component and LiveGrenade flight component for NZombies. Handles cooking, throwing, spawning a physical grenade entity, countdown, area damage with line-of-sight checks, visual and sound effects, and console commands for testing and giving grenades.
using Sandbox;
using System.Linq;
namespace NZombies;
/// <summary>
/// Frag grenades — G to throw, hold to cook.
///
/// ⛔ SWB HAS NO EXPLOSIVES AT ALL. Unlike tracers and decals, which were implemented
/// upstream and merely switched off by the port, there is not one reference to an
/// explosion anywhere in `swb_base`. All of this is new.
///
/// ⚠️ NOT AN SWB WEAPON AND NOT IN THE INVENTORY, for the same reason the knife is
/// not: the two weapon slots are owned by the player and read by the wall-buy, the
/// box, Pack-a-Punch and the save. A grenade is equipment, not a third slot.
///
/// Throw mechanics are ARC9's authored M67 values rather than invented ones —
/// `FuseTimer 5`, `ThrowForceMin 500`, `ThrowForceMax 1000`, `ThrowChargeTime 1`,
/// `TossForce 250`.
/// </summary>
public sealed class Grenade : Component
{
/// <summary>Grenades in hand.</summary>
[Property] public int Count { get; set; } = 2;
/// <summary>The most you can carry. Black Ops zombies' own cap.</summary>
[Property] public int MaxCount { get; set; } = 4;
/// <summary>
/// The prefab's own `MaxCount`, latched the first time an augment reconciles it.
///
/// ⚠️ Same reason as `NZAmmo.AuthoredMaxReserve`: Mule Kick's m3 Grenadier raises this
/// and reconciles repeatedly, so the bonus is rebuilt from a remembered base rather than
/// added to the live value. -1 means "not yet seen".
/// </summary>
public int AuthoredMaxCount { get; set; } = -1;
/// <summary>
/// Seconds from the pin to the bang.
///
/// ⛔ THE FUSE RUNS FROM THE PULL, NOT FROM THE THROW — that is what makes cooking
/// a real decision and a real risk. ARC9's M67 is explicit that a held grenade
/// explodes in your hand, and a fuse that started on release would make holding
/// the key strictly free.
/// </summary>
[Property] public float Fuse { get; set; } = 5f;
/// <summary>
/// Damage to everything inside <see cref="Radius"/>.
///
/// ⛔ FLAT, WITH NO FALLOFF — chosen deliberately over a round-scaled rule. It
/// means a frag is devastating early and becomes utility later, and that everything
/// caught in the blast takes the same hit wherever it stood. Simple to reason about
/// and simple to tune from the weapon editor; the trade is that placement inside
/// the radius stops mattering.
/// </summary>
[Property] public float Damage { get; set; } = 500f;
/// <summary>Blast radius in units (1 unit = 1 inch), so ~5.5 metres.</summary>
[Property] public float Radius { get; set; } = 220f;
/// <summary>
/// The player's share of <see cref="Damage"/>, before distance falloff.
///
/// ⚠️ 0.25 — a frag that does 500 to a zombie would delete a 100hp player five
/// times over at point blank. This makes a badly-thrown grenade a serious mistake
/// (125 at the centre, on 100 health) without making every near miss fatal: at
/// half the radius it is 62, which hurts and teaches.
/// </summary>
[Property] public float SelfDamage { get; set; } = 0.25f;
/// <summary>Throw force, uncharged to fully charged. ARC9's M67 numbers.</summary>
[Property] public float ThrowForceMin { get; set; } = 500f;
[Property] public float ThrowForceMax { get; set; } = 1000f;
/// <summary>Seconds of holding G to reach <see cref="ThrowForceMax"/>.</summary>
[Property] public float ChargeTime { get; set; } = 1f;
/// <summary>
/// The thrown grenade's model — BO1's M67, ported from the pack.
///
/// ⚠️ A STATIC PROP, so it went through `mdl_to_obj.py` straight to OBJ with no
/// Crowbar and no Blender. The rigid-prop route only works because there is no
/// skeleton to lose; a viewmodel would have needed the DMX pipeline.
/// </summary>
[Property] public string ModelPath { get; set; } = "models/nz/grenade/frag.vmdl";
bool _cooking;
TimeSince _sinceCook;
bool _throwing;
TimeSince _sinceThrow;
/// <summary>How long the throw animation stays on screen after the release.</summary>
[Property] public float ThrowAnimTime { get; set; } = 0.6f;
GrenadeViewModel ViewModel => Components.GetOrCreate<GrenadeViewModel>();
/// <summary>
/// Hide or restore the held weapon while a grenade is out.
///
/// ⛔ VIA `ShouldDraw`, NOT BY DISABLING THE WEAPON — disabling is how this
/// project expresses HOLSTERING, and running a gun through carry-stop and redeploy
/// for a two-second animation is what broke the two-slot inventory the first time.
///
/// ⚠️ `EverythingInSelf`, because a holstered weapon is DISABLED and the plain
/// `Get<T>()` skips disabled components.
/// </summary>
void ShowGuns( bool show )
{
var inv = Components.Get<NZInventory>( FindMode.EverythingInSelf );
if ( !inv.IsValid() ) return;
foreach ( var go in inv.Weapons )
{
if ( !show && go != inv.Active ) continue;
var wep = go.Components.Get<SWB.Base.Weapon>( FindMode.EverythingInSelf );
var handler = wep?.ViewModelHandler;
if ( handler.IsValid() ) handler.ShouldDraw = show;
}
}
/// <summary>Is a grenade currently cooking in hand?</summary>
public bool Cooking => _cooking;
/// <summary>Bled out: no body in the world and the camera on somebody else (`SpectateOthers`), so no grenade either (2026-10-05).</summary>
bool SittingOut => Components.Get<NZPlayer>() is { IsOutOfRound: true };
/// <summary>How long the current cook has run.</summary>
public float CookTime => _cooking ? _sinceCook : 0f;
protected override void OnUpdate()
{
if ( Input.Pressed( "Grenade" ) && !_cooking && Count > 0 && !SittingOut )
{
_cooking = true;
_sinceCook = 0f;
// The pin comes out, and the gun goes away while it does.
ViewModel.Show( "pullpin" );
ShowGuns( false );
}
// ⛔ THE GUN STAYS HIDDEN FOR THE WHOLE COOK, re-asserted every frame — a
// weapon that deploys mid-cook (a wall buy, a Pack-a-Punch return) sets its
// own `ShouldDraw = true` on the way in and would appear alongside the
// grenade. Same fix the knife needed.
if ( _cooking ) ShowGuns( false );
// ⚠️ The throw clip has to finish AFTER the grenade has left, so putting the
// viewmodel away is on its own timer rather than on the release.
if ( !_cooking && _throwing && _sinceThrow >= ThrowAnimTime )
{
_throwing = false;
ViewModel.Hide();
ShowGuns( true );
}
if ( !_cooking ) return;
// ⛔ COOKED TOO LONG AND IT GOES OFF WHERE YOU STAND. The fuse is the fuse; it
// does not politely wait for you to let go. Without this the only cost of
// cooking is patience.
if ( _sinceCook >= Fuse )
{
var eye = EyePos();
Detonate( eye );
Count--;
_cooking = false;
// ⚠️ The viewmodel goes with it — it just exploded.
ViewModel.Hide();
ShowGuns( true );
_throwing = false;
Log.Info( "[nz] grenade cooked off in your hand" );
return;
}
if ( Input.Released( "Grenade" ) ) Throw();
}
Vector3 EyePos()
{
var c = Components.Get<PlayerController>();
return c?.EyePosition ?? WorldPosition + Vector3.Up * 64f;
}
Rotation EyeRot()
{
var c = Components.Get<PlayerController>();
return c?.EyeAngles.ToRotation() ?? WorldRotation;
}
/// <summary>
/// Let it go. Remaining fuse carries over to the thrown object.
/// </summary>
public void Throw()
{
if ( !_cooking || Count <= 0 ) return;
var held = (float)_sinceCook;
_cooking = false;
Count--;
var eye = EyePos();
var rot = EyeRot();
// Charge maps the hold time onto the force range, capped at ChargeTime.
var t = ChargeTime > 0f ? MathX.Clamp( held / ChargeTime, 0f, 1f ) : 1f;
var force = MathX.Lerp( ThrowForceMin, ThrowForceMax, t );
var go = new GameObject( true, "grenade" );
// ⚠️ Spawned AHEAD of the eye, not at it. A rigidbody created inside the
// player's own collider resolves the overlap by launching one of them.
go.WorldPosition = eye + rot.Forward * 24f;
go.WorldRotation = rot;
var model = Model.Load( ModelPath );
if ( model is not null && !model.IsError )
{
var r = go.Components.Create<ModelRenderer>();
r.Model = model;
// ⚠️ NO SCALE OVERRIDE NOW THAT THE MODEL IS REAL. The 0.25 here was
// shrinking `models/dev/sphere.vmdl`, which is a metre across; the M67 is
// authored at its actual size and scaling it would make a grenade the size
// of a marble.
}
var body = go.Components.Create<Rigidbody>();
var col = go.Components.Create<SphereCollider>();
col.Radius = 4f;
body.Velocity = rot.Forward * force + Vector3.Up * (force * 0.15f);
// ⚠️ Tumbling, because a frag that flies like an arrow reads as a dart. ARC9
// sets `ThrowTumble` for the same reason.
body.AngularVelocity = Vector3.Random * 10f;
// The hand follows through after the grenade has gone.
ViewModel.Play( "throw" );
_throwing = true;
_sinceThrow = 0f;
var live = go.Components.Create<LiveGrenade>();
live.Owner = this;
live.Remaining = System.MathF.Max( 0.1f, Fuse - held );
Log.Info( $"[nz] grenade thrown — {live.Remaining:0.0}s left, force {force:0}, "
+ $"{Count} in hand" );
}
/// <summary>
/// The blast. Flat damage to every <see cref="Health"/> inside the radius.
///
/// ⛔ TRACES TO EACH TARGET BEFORE HURTING IT. Without a line of sight check a
/// grenade kills through walls, floors and closed doors — which on a map built of
/// small rooms is most of its kills. The trace is the difference between a frag
/// and a building-wide smart bomb.
/// </summary>
public void Detonate( Vector3 pos )
{
var scene = Scene ?? Game.ActiveScene;
if ( scene is null ) return;
// ⛔ THE TRACE STARTS ABOVE THE BLAST, NOT AT IT. A thrown grenade comes to
// rest ON the floor, so a ray from that exact point to a zombie's chest leaves
// the ground at a shallow angle and clips it within a few units — every target
// then reads as blocked and the grenade damages NOTHING. Observed as
// "0 caught in 220u" during a round with eight zombies alive.
//
// ⚠️ Lifting the ORIGIN is the fix, not widening the slack at the far end:
// the occlusion was happening in the first few units, where a tolerance
// measured from the target cannot reach.
var eye = pos + Vector3.Up * 12f;
int hits = 0;
int inRange = 0;
foreach ( var hp in scene.GetAllComponents<Health>().ToList() )
{
if ( !hp.IsValid() ) continue;
var target = hp.WorldPosition + Vector3.Up * 32f;
var dist = target.Distance( pos );
// ⚠ NAPALM NECTAR M2 (area damage x3) / m4 (radius +20%). One of the six sites listed in
// `FireAugments.AreaDamageScale` — there is no explosive damage TYPE here, so "all area
// damage" is a register of call sites. A new AoE that is not wrapped is not covered.
if ( dist > Radius * FireAugments.AreaRadiusScale( GameObject ) ) continue;
inRange++;
// ⛔ IGNORE THE TARGET'S OWN BODY. Without this a zombie BLOCKS ITS OWN
// LINE OF SIGHT: the ray aimed at its chest strikes its front surface a
// few units short, the "did something get in the way" test sees a hit
// closer than the target, and the zombie is skipped as though it were
// behind a wall. Diagnosed from `1 hurt of 2 in range` — the player took
// the blast and the zombie standing beside it took nothing.
//
// ⚠️ This is why the check must exclude the TARGET rather than widen its
// tolerance. The occluder here is the thing being tested.
var tr = scene.Trace.Ray( eye, target )
.WithoutTags( "player", "trigger" )
.IgnoreGameObjectHierarchy( hp.GameObject )
.Run();
// ⚠️ A little slack — the trace starts at the blast centre, which is often
// resting ON the floor, so an exact hit test clips the ground and shields
// everything. Anything within a few units of the target counts as seen.
if ( tr.Hit && tr.Distance < dist - 8f ) continue;
// ⛔ THE PLAYER TAKES DISTANCE-SCALED DAMAGE; ZOMBIES TAKE THE FLAT
// FIGURE. Two rules on purpose:
//
// Zombies flat, as chosen — a frag either clears a group or it does not,
// and making a horde's survivors depend on where each one stood turns a
// thrown grenade into a lottery you cannot read.
//
// The PLAYER is the one target whose exact distance you control, so
// scaling is the difference between "I misjudged that" and "I died to a
// grenade I threw across the room". Full damage at the centre falling
// linearly to nothing at the edge.
//
// ⚠️ `SelfDamage` scales the player's share separately, so the blast can
// be lethal to zombies without one-shotting whoever threw it.
var isPlayer = hp.Components.Get<NZPlayer>( FindMode.EverythingInSelf ) is not null;
// ⛔ ONLY THE THROWER (2026-10-04). "The player" above was written for one player; in co-op this blast reached every
// body in range, and it carries no attacker, so `Health.IsFriendlyFire` could not stop it — a teammate's grenade hurt
// you. Your own still does: this component lives on the thrower's body, beside its `Health`.
if ( isPlayer && hp.GameObject != GameObject ) continue;
var dmg = Damage;
if ( isPlayer )
{
var falloff = 1f - MathX.Clamp( dist / System.MathF.Max( Radius, 1f ), 0f, 1f );
dmg = Damage * SelfDamage * falloff;
if ( dmg < 1f ) continue;
}
// ⚠️ TAGGED AS AN EXPLOSION, for the gore: an explosive kill tears an arm off (`ZombieAI.GoreOnDeath`, moo:4278).
// Nothing else reads the tag.
var blast = new DamageInfo
{
Damage = dmg,
Position = target,
Tags = new TagSet(),
};
blast.Tags.Add( Health.ExplosionTag );
hp.OnDamage( blast );
hits++;
}
Effect( pos );
// ⚠️ Reports how many were IN RANGE alongside how many were hurt. "0 caught"
// on its own cannot distinguish "nothing was near it" from "the line-of-sight
// check rejected everything", which is exactly the bug that reading the two
// numbers together would have caught immediately.
Log.Info( $"[nz] grenade detonated — {hits} hurt of {inRange} in range ({Radius:0}u)" );
}
/// <summary>
/// The bang: fireball, light and sound.
///
/// ⚠️ `prefabs/engine/explosion_med.prefab` is the engine's own, and it is the
/// ONLY explosion prefab that ships — searching the whole asset system returns
/// exactly one. Hand-authoring a particle system to sit beside it would be work
/// spent matching something already there.
/// </summary>
/// <summary>
/// The visible half.
///
/// ⛔ MOVED TO `BlastEffect` AND THAT FIXED A BUG THIS FILE HAD ALL ALONG. The engine
/// prefab ships a `RadiusDamage` component - 100 damage, 256u, physics force, fires on
/// enable - and cloning it enabled meant every grenade dealt that flat 100 on top of the
/// falloff loop above, and shoved the thrower. This method already does its own damage
/// through `Health.OnDamage`, so the engine component was never wanted here either. See
/// `BlastEffect` for why the strip has to happen before the clone is enabled.
/// </summary>
void Effect( Vector3 pos )
{
BlastEffect.Spawn( pos, Radius );
// ⚠ The pack's only explosion wav belongs to the RPG - see NZSound.
NZSound.Play( NZSound.GrenadeExplode, pos );
}
/// <summary>Top up, e.g. from Max Ammo. Returns how many were added.</summary>
public int Refill()
{
var before = Count;
Count = MaxCount;
return Count - before;
}
// ── commands ─────────────────────────────────────────────────────────────
static Grenade Of()
{
var p = NZPlayer.Local;
return p.IsValid() ? p.Components.GetOrCreate<Grenade>() : null;
}
/// <summary>Throw one: `nz_nade [cook]` — seconds to cook before release.</summary>
[ConCmd( "nz_nade" )]
public static void Cmd( float cook = 0f )
{
var g = Of();
if ( g is null ) { Log.Warning( "[nz] no player" ); return; }
if ( g.Count <= 0 ) { Log.Info( "[nz] no grenades" ); return; }
g._cooking = true;
g._sinceCook = MathX.Clamp( cook, 0f, g.Fuse - 0.2f );
g.Throw();
}
/// <summary>Blow one up where you stand: `nz_nade_here`.</summary>
[ConCmd( "nz_nade_here" )]
public static void Here()
{
var g = Of();
if ( g is null ) { Log.Warning( "[nz] no player" ); return; }
g.Detonate( g.EyePos() );
}
/// <summary>Give grenades: `nz_nade_give [n]`.</summary>
[ConCmd( "nz_nade_give" )]
public static void Give( int n = -1 )
{
var g = Of();
if ( g is null ) { Log.Warning( "[nz] no player" ); return; }
g.Count = n < 0 ? g.MaxCount : System.Math.Clamp( n, 0, g.MaxCount );
Log.Info( $"[nz] grenades: {g.Count}/{g.MaxCount}" );
}
/// <summary>State: `nz_nade_info`.</summary>
[ConCmd( "nz_nade_info" )]
public static void Info()
{
var g = Of();
if ( g is null ) { Log.Warning( "[nz] no player" ); return; }
Log.Info( $"[nz] grenades {g.Count}/{g.MaxCount} — {g.Damage:0} flat damage in "
+ $"{g.Radius:0}u, {g.Fuse:0.#}s fuse"
+ (g.Cooking ? $" COOKING {g.CookTime:0.0}s" : "") );
}
}
/// <summary>
/// A grenade in flight, counting down.
///
/// ⚠️ ITS OWN COMPONENT rather than a timer on the player, because the fuse belongs to
/// the OBJECT — a player who throws two in quick succession has two independent
/// countdowns, and a single field on the player would have the second overwrite the
/// first.
/// </summary>
public sealed class LiveGrenade : Component
{
public Grenade Owner { get; set; }
public float Remaining { get; set; } = 5f;
TimeSince _alive;
protected override void OnStart() => _alive = 0f;
protected override void OnUpdate()
{
if ( _alive < Remaining ) return;
Owner?.Detonate( WorldPosition );
GameObject.Destroy();
}
}