Player/Grenade.cs

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.

File AccessNetworking
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&lt;T&gt;()` 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();
	}
}