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;
		}
	}
}