Weapons/HandGrenade/HandGrenadeWeapon.cs
using Sandbox.Rendering;
public enum ThrowType
{
Far = 0,
Near = 1
}
/// <summary>
/// A throwable grenade weapon
/// Cooks while held — explodes in hand if held too long
/// </summary>
public sealed class HandGrenadeWeapon : BaseWeapon
{
[Property] public GameObject Prefab { get; set; }
[Property] public float ThrowPower { get; set; } = 1200f;
/// <summary>
/// Sound played when the pin is pulled and cooking starts.
/// </summary>
[Property] public SoundEvent PinPullSound { get; set; }
/// <summary>
/// Sound played when the grenade is thrown.
/// </summary>
[Property] public SoundEvent ThrowSound { get; set; }
/// <summary>
/// Sound played when deploying the next grenade after a throw.
/// </summary>
[Property] public SoundEvent DeploySound { get; set; }
/// <summary>
/// Fuse time in seconds — grenade explodes after this, whether thrown or not.
/// </summary>
[Property] public float Lifetime { get; set; } = 3f;
/// <summary>
/// Explosion damage radius.
/// </summary>
[Property] public float Radius { get; set; } = 256f;
/// <summary>
/// Maximum damage at the center.
/// </summary>
[Property] public float MaxDamage { get; set; } = 125f;
/// <summary>
/// Physics force scale for the explosion.
/// </summary>
[Property] public float Force { get; set; } = 1f;
[Sync] TimeSince TimeSinceCooked { get; set; }
[Sync] bool IsCooking { get; set; }
[Sync] bool IsThrowing { get; set; }
[Sync] TimeUntil TimeUntilThrown { get; set; }
ThrowType CurrentThrowType { get; set; } = ThrowType.Far;
float ThrowBlend { get; set; }
public override bool IsInUse() => IsCooking;
protected override void OnEnabled()
{
base.OnEnabled();
AddShootDelay( 0.5f );
}
public override void OnPlayerDeath( PlayerDiedParams args )
{
if ( !IsCooking ) return;
// Drop the grenade at your feet
if ( HasOwner )
Throw( Owner, Vector3.Down, 0.2f );
}
public override void OnControl()
{
if ( ShootInput.Pressed() )
{
DropGrenade();
}
}
public override void OnControl( Player player )
{
// Wait for throw animation to finish
if ( IsThrowing )
{
if ( TimeUntilThrown )
{
IsThrowing = false;
if ( !HasAmmo() )
{
SwitchToBestWeapon();
DestroyGameObject();
return;
}
// Deploy next grenade
if ( DeploySound is not null )
GameObject.PlaySound( DeploySound );
WeaponModel?.Renderer?.Set( "b_reload", true );
}
return;
}
// Start cooking on press
if ( !IsCooking && CanPrimaryAttack() && (Input.Pressed( "Attack1" ) || Input.Pressed( "Attack2" )) )
{
IsCooking = true;
TimeSinceCooked = 0;
if ( PinPullSound is not null )
GameObject.PlaySound( PinPullSound );
WeaponModel?.Renderer?.Set( "b_charge", true );
WeaponModel?.Renderer?.Set( "charge_type", 0 );
}
if ( !IsCooking )
return;
// Update throw direction blend
UpdateThrowType();
// Cooked too long — explode in hand
if ( TimeSinceCooked > Lifetime )
{
IsCooking = false;
TakeAmmo( 1 );
ExplodeInHand();
if ( !HasAmmo() )
{
SwitchToBestWeapon();
DestroyGameObject();
}
return;
}
// Release both buttons to throw
if ( !Input.Down( "Attack1" ) && !Input.Down( "Attack2" ) )
{
Throw( player );
}
}
void UpdateThrowType()
{
bool attack1 = Input.Down( "Attack1" );
bool attack2 = Input.Down( "Attack2" );
float target = (attack1 && attack2) ? 0.5f : attack2 ? 1.0f : 0.0f;
ThrowBlend = ThrowBlend.LerpTo( target, Time.Delta * 3.0f );
CurrentThrowType = ThrowBlend < 0.4f ? ThrowType.Far : ThrowType.Near;
WeaponModel?.Renderer?.Set( "throw_blend", ThrowBlend );
WeaponModel?.Renderer?.Set( "throw_type", (int)CurrentThrowType );
}
void Throw( Player player, Vector3? overrideDirection = null, float powerScale = 1f )
{
IsCooking = false;
if ( !TakeAmmo( 1 ) )
{
SwitchToBestWeapon();
DestroyGameObject();
return;
}
var direction = overrideDirection ?? player.EyeTransform.Rotation.Forward;
if ( !overrideDirection.HasValue && CurrentThrowType == ThrowType.Near )
{
direction = (direction + Vector3.Up * 0.3f).Normal;
powerScale *= 0.5f;
}
var startPos = GetThrowPosition( player, direction );
SpawnProjectile( player, startPos, direction, powerScale );
// Play throw animation
if ( ThrowSound is not null )
GameObject.PlaySound( ThrowSound );
WeaponModel?.Renderer?.Set( "b_charge", false );
WeaponModel?.Renderer?.Set( "b_attack", true );
AddShootDelay( 1f );
IsThrowing = true;
TimeUntilThrown = 0.5f;
}
Vector3 GetThrowPosition( Player player, Vector3 direction )
{
var eye = player.EyeTransform;
var right = eye.Rotation.Right;
var forward = direction;
var origin = eye.Position;
// Underthrow starts lower (waist height)
if ( CurrentThrowType == ThrowType.Near )
origin -= Vector3.Up * 20f;
var target = origin + forward * 18f + right * 8f;
var tr = Scene.Trace.Box( BBox.FromPositionAndSize( Vector3.Zero, 8f ), origin, target )
.WithoutTags( "trigger", "ragdoll" )
.IgnoreGameObjectHierarchy( player.GameObject )
.Run();
return tr.Hit ? tr.EndPosition : target;
}
[Rpc.Host]
void DropGrenade()
{
if ( !Prefab.IsValid() ) return;
var go = Prefab.Clone( WorldPosition );
var explosive = go.GetOrAddComponent<TimedExplosive>();
if ( explosive.IsValid() )
{
explosive.Lifetime = Lifetime;
explosive.Radius = Radius;
explosive.Damage = MaxDamage;
explosive.Force = Force;
}
// Don't collide with the weapon we dropped from
var filter = go.AddComponent<PhysicsFilter>();
filter.Body = GameObject;
// No velocity — just drops in place
go.NetworkSpawn();
}
[Rpc.Host]
void SpawnProjectile( Player player, Vector3 startPos, Vector3 direction, float powerScale )
{
if ( !player.IsValid() ) return;
if ( !Prefab.IsValid() ) return;
var go = Prefab.Clone( startPos );
// Configure the timed explosive with remaining fuse
var explosive = go.GetOrAddComponent<TimedExplosive>();
if ( explosive.IsValid() )
{
explosive.Lifetime = MathF.Max( 0.1f, Lifetime - TimeSinceCooked );
explosive.Radius = Radius;
explosive.Damage = MaxDamage;
explosive.Force = Force;
}
var rb = go.GetComponent<Rigidbody>();
if ( rb.IsValid() )
{
var baseVelocity = player.GetComponent<PlayerController>().Velocity;
rb.Velocity = baseVelocity + direction * (ThrowPower * powerScale) + Vector3.Up * 100f;
rb.AngularVelocity = go.WorldRotation.Right * 10f;
}
// Don't collide with the weapon we threw from
var filter = go.AddComponent<PhysicsFilter>();
filter.Body = GameObject;
go.NetworkSpawn();
}
void SwitchToBestWeapon()
{
var inventory = Owner?.GetComponent<PlayerInventory>();
if ( !inventory.IsValid() ) return;
var best = inventory.GetBestWeapon();
if ( best.IsValid() )
inventory.SwitchWeapon( best );
}
[Rpc.Host]
void ExplodeInHand()
{
// Spawn the explosion directly
var explosionPrefab = ResourceLibrary.Get<PrefabFile>( "/prefabs/engine/explosion_med.prefab" );
if ( !explosionPrefab.IsValid() )
return;
var explosionPos = Owner.IsValid() ? Owner.EyeTransform.Position : WorldPosition;
var explosion = GameObject.Clone( explosionPrefab, new CloneConfig { Transform = new Transform( explosionPos ), StartEnabled = false } );
if ( !explosion.IsValid() )
return;
explosion.RunEvent<RadiusDamage>( x =>
{
x.Radius = Radius;
x.PhysicsForceScale = Force;
x.DamageAmount = MaxDamage;
x.Attacker = explosion;
}, FindMode.EverythingInSelfAndDescendants );
explosion.Enabled = true;
explosion.NetworkSpawn( true, null );
SwitchToBestWeapon();
DestroyGameObject();
}
public override void DrawCrosshair( HudPainter hud, Vector2 center )
{
var color = !HasAmmo() ? CrosshairNoShoot : CrosshairCanShoot;
hud.SetBlendMode( BlendMode.Lighten );
hud.DrawCircle( center, 6, color );
}
}