Explosive.cs
using Sandbox;
using System;
/// <summary>
/// Detonates the object after a fuse, dealing radial damage to players with a
/// line-of-sight check (walls and props block damage) and pushing nearby physics
/// objects. Wire <see cref="StartTimer"/> to a GrabPoint action (e.g. OnTriggerPressed)
/// to arm it. The fuse runs on the client that armed it, so it keeps ticking after
/// the object is thrown and network ownership is dropped.
/// </summary>
public sealed class Explosive : Component
{
/// <summary>Seconds between StartTimer() and detonation.</summary>
[Property] public float FuseSeconds { get; set; } = 3f;
/// <summary>Explosion radius in units; targets beyond this take no damage or push.</summary>
[Property] public float Radius { get; set; } = 250f;
/// <summary>Damage dealt to a player standing at the explosion origin.</summary>
[Property] public float MaxDamage { get; set; } = 100f;
/// <summary>Damage dealt to a player at the edge of the radius.</summary>
[Property] public float MinDamage { get; set; } = 15f;
/// <summary>Velocity (units/sec) added to rigidbodies at the explosion origin, falling off to zero at the radius edge.</summary>
[Property] public float PropPushForce { get; set; } = 500f;
/// <summary>Total impulse applied to a lethally damaged ragdoll at the explosion origin, with distance falloff.</summary>
[Property] public float RagdollImpulse { get; set; } = 1500f;
/// <summary>Optional effect prefab (particles/light) spawned at the explosion origin on every client.</summary>
[Property] public GameObject ExplosionEffectPrefab { get; set; }
/// <summary>Optional sound played at the explosion origin on every client.</summary>
[Property] public SoundPointComponent ExplosionSound { get; set; }
/// <summary>Optional sound player when the timer is started</summary>
[Property] public SoundEvent TimerStartSound { get; set; }
// Body-relative heights (feet, chest, head) sampled for the line-of-sight check,
// so partial cover still lets damage through if any point is visible.
private static readonly float[] LosSampleHeights = [8f, 36f, 60f];
private bool _armed;
private bool _exploded;
private float _explodeAt;
/// <summary>
/// Arms the explosive: it detonates FuseSeconds from now. Runs on the calling
/// client, so call this from the holder's input path (e.g. GrabPoint.OnTriggerPressed).
/// Repeat calls while armed are ignored.
/// </summary>
public void StartTimer()
{
if ( _armed )
return;
_armed = true;
_explodeAt = Time.Now + FuseSeconds;
var modelRenderer = GetComponent<ModelRenderer>();
modelRenderer?.SetBodyGroup( "spoon", "" );
modelRenderer?.SetBodyGroup( "pin", "" );
Sound.Play( TimerStartSound );
}
protected override void OnUpdate()
{
if ( !_armed || _exploded )
return;
if ( Time.Now >= _explodeAt )
Explode();
}
private void Explode()
{
_exploded = true;
var origin = WorldPosition;
// Damage is dealt only by the arming client so kill credit (Rpc.Caller in
// HealthComponent.TakeDamage) lands on the thrower.
DamagePlayersInRadius( origin );
BroadcastExplosion( origin );
}
private void DamagePlayersInRadius( Vector3 origin )
{
foreach ( var health in Scene.GetAllComponents<HealthComponent>() )
{
if ( health.IsDead )
continue;
var playerObject = health.GameObject;
float distance = origin.Distance( playerObject.WorldPosition );
if ( distance > Radius )
continue;
if ( !HasLineOfSight( origin, playerObject ) )
continue;
float falloff = Math.Clamp( distance / Radius, 0f, 1f );
float damage = MathX.Lerp( MaxDamage, MinDamage, falloff );
var offset = playerObject.WorldPosition - origin;
var direction = offset.LengthSquared > 1f ? offset.Normal : Vector3.Up;
direction = (direction + Vector3.Up * 0.5f).Normal;
var impulse = direction * RagdollImpulse * (1f - falloff);
var weaponIcon = Components.Get<InventoryItem>()?.ItemIconPath ?? "";
health.TakeDamage( damage, impulse, false, weaponIcon );
}
}
/// <summary>
/// True if at least one body point (feet/chest/head) can be reached from the
/// explosion origin without hitting a wall or prop first.
/// </summary>
private bool HasLineOfSight( Vector3 origin, GameObject playerObject )
{
foreach ( var height in LosSampleHeights )
{
var targetPoint = playerObject.WorldPosition + Vector3.Up * height;
var trace = Scene.Trace.Ray( origin, targetPoint )
.UsePhysicsWorld()
.WithoutTags( "ragdoll" )
.IgnoreGameObjectHierarchy( GameObject )
.Run();
// Nothing in the way, or the first thing hit is the player themselves
if ( !trace.Hit || trace.GameObject?.Root == playerObject )
return true;
}
return false;
}
/// <summary>
/// Plays effects and pushes physics objects on every client, then the host
/// destroys the explosive object.
/// </summary>
[Rpc.Broadcast]
private void BroadcastExplosion( Vector3 origin )
{
_exploded = true;
var modelRenderer = GameObject.GetComponent<ModelRenderer>();
modelRenderer?.Enabled = false;
foreach (var collider in GetComponents<Collider>())
collider.Enabled = false;
foreach (var rigidbody in GetComponents<Rigidbody>())
rigidbody.Enabled = false;
ExplosionEffectPrefab?.Clone( origin );
ExplosionSound?.StartSound();
PushRigidbodies( origin );
GameObject.AddComponent<TemporaryEffect>();
}
private void PushRigidbodies( Vector3 origin )
{
foreach ( var rigidbody in Scene.GetAllComponents<Rigidbody>() )
{
if ( rigidbody.GameObject.Root == GameObject.Root )
continue;
// Networked objects are only pushed by the client with authority over
// them; purely local physics objects are pushed everywhere.
if ( rigidbody.Network.Active && rigidbody.IsProxy )
continue;
var offset = rigidbody.WorldPosition - origin;
float distance = offset.Length;
if ( distance > Radius )
continue;
float falloff = 1f - Math.Clamp( distance / Radius, 0f, 1f );
var direction = distance > 1f ? offset / distance : Vector3.Up;
// Slight upward bias so props pop instead of just sliding
direction = (direction + Vector3.Up * 0.5f).Normal;
rigidbody.Velocity += direction * PropPushForce * falloff;
}
}
}