Weapons/Projectile.cs
/// <summary>
/// Physics projectile with gravity, drag, bouncing, and explosion support.
/// Configure via properties and add to a prefab; spawn with Launch().
/// </summary>
[Title( "Projectile" ), Icon( "sports_volleyball" )]
public sealed class Projectile : Component
{
	// -------------------------------------------------------------------------
	// Properties (configure in prefab inspector)
	// -------------------------------------------------------------------------
	[Property, Category( "General" ), ResourceType( "sound" )] public string ActiveSoundPath { get; set; }
	[Property, Category( "General" )] public Vector2 InitialForce { get; set; }
	[Property, Category( "General" )] public float Gravity { get; set; } = 0f;
	[Property, Category( "General" )] public float Radius { get; set; } = 8f;
	[Property, Category( "General" )] public float Lifetime { get; set; } = 5f;

	[Property, Category( "Explosion" )] public bool ExplodeOnDeath { get; set; }
	[Property, Category( "Explosion" )] public bool ExplodeOnImpact { get; set; }
	[Property, Category( "Explosion" )] public List<string> ExplodeHitTags { get; set; } = new() { "player" };
	[Property, Category( "Explosion" )] public float ExplosionRadius { get; set; } = 512f;
	[Property, Category( "Explosion" )] public float ExplosionDamage { get; set; } = 100f;
	[Property, Category( "Explosion" ), ResourceType( "vpcf" )] public string ExplosionParticlePath { get; set; }
	[Property, Category( "Explosion" ), ResourceType( "sound" )] public string ExplosionSoundPath { get; set; }
	[Property, Category( "Explosion" ), Range( 0, 1 )] public float SelfDamageScale { get; set; } = 1f;
	[Property, Category( "Explosion" )] public Curve ExplosionDamageFalloff { get; set; }
	[Property, Category( "Explosion" )] public bool NoDeleteOnExplode { get; set; } = false;

	[Property, Category( "Bounce" ), Range( 0, 1 )] public float Bounciness { get; set; }
	[Property, Category( "Bounce" ), ResourceType( "sound" )] public string BounceSoundPath { get; set; }
	[Property, Category( "Bounce" ), Range( 0, 1000 )] public float BounceSoundMinVelocity { get; set; } = 300f;

	[Property, Category( "Drag" )] public float DragScale { get; set; } = 0;

	[Property, Category( "Data" )] public WeaponData Data { get; set; }

	// -------------------------------------------------------------------------
	// Runtime state (set by Launch)
	// -------------------------------------------------------------------------

	public PlayerPawn Owner { get; set; }
	public ProjectileSimulator Simulator { get; set; }

	private Vector3 _velocity;
	private float _gravityModifier;
	private TimeUntil _destroyTime;
	private bool _hasExploded;

	private SoundHandle _activeSound;

	// -------------------------------------------------------------------------
	// Launch
	// -------------------------------------------------------------------------

	/// <summary>
	/// Call after cloning the prefab to set the owner, position it at the aim ray, and start simulation.
	/// </summary>
	public void Launch( PlayerPawn owner, ProjectileSimulator simulator = null )
	{
		Owner = owner;
		Simulator = simulator;

		if ( Data != null )
		{
			ExplosionDamage      = Data.ExplosionDamage;
			ExplosionRadius      = Data.ExplosionRadius;
			Lifetime             = Data.Lifetime;
			InitialForce         = new Vector2( Data.InitialForceForward, Data.InitialForceUp );

			if ( !string.IsNullOrEmpty( Data.ProjectileActiveSound ) ) ActiveSoundPath    = Data.ProjectileActiveSound;
			if ( !string.IsNullOrEmpty( Data.ExplosionParticle ) )     ExplosionParticlePath = Data.ExplosionParticle;
			if ( !string.IsNullOrEmpty( Data.ExplosionSound ) )        ExplosionSoundPath = Data.ExplosionSound;
		}

		_destroyTime = Lifetime;

		Simulator?.Add( this );

		var aimRay = Owner?.AimRay ?? new Ray( WorldPosition, WorldRotation.Forward );
		var tr = Scene.Trace.Ray( aimRay, 50f )
			.IgnoreGameObject( Owner?.GameObject )
			.Run();

		WorldPosition = tr.EndPosition;
		_velocity = aimRay.Forward * InitialForce.x + Vector3.Up * InitialForce.y;
		WorldRotation = Rotation.LookAt( _velocity.Normal );

		CreateEffects();
	}

	private void CreateEffects()
	{

		if ( !string.IsNullOrEmpty( ActiveSoundPath ) )
		{
			_activeSound = Sound.Play( ActiveSoundPath, WorldPosition );
			if ( _activeSound != null )
			{
				_activeSound.Volume = 0.75f;
				_activeSound.Pitch = 1.5f;
			}
		}
	}

	// -------------------------------------------------------------------------
	// Simulate (called by ProjectileSimulator or fixed update)
	// -------------------------------------------------------------------------

	protected override void OnFixedUpdate()
	{
		if ( Simulator != null ) return; // simulated by ProjectileSimulator instead
		if ( IsProxy ) return;           // only host simulates networked projectiles
		Simulate();
	}

	public void Simulate()
	{
		// Drag
		var drag = CalculateDrag( _velocity ) * DragScale;
		_velocity += drag * Time.Delta;

		WorldRotation = Rotation.LookAt( _velocity.Normal );

		var newPosition = GetTargetPosition();

		var trace = Scene.Trace.Ray( WorldPosition, newPosition )
			.Size( Radius )
			.IgnoreGameObject( GameObject )
			.IgnoreGameObject( Owner?.GameObject )
			.WithAnyTags( "solid", "player" )
			.Run();

		// Sync sound position
		if ( _activeSound != null ) _activeSound.Position = trace.EndPosition;

		WorldPosition = trace.EndPosition;

		if ( _destroyTime )
		{
			if ( ExplodeOnDeath ) Explode();
			Destroy();
			return;
		}

		if ( trace.Hit || trace.StartedSolid )
		{
			if ( ExplodeOnImpact )
			{
				Explode();
				Destroy();
				return;
			}

			var hitTags = trace.Tags;
			if ( hitTags != null && ExplodeHitTags.Any( t => hitTags.Contains( t ) ) )
			{
				Explode();
				if ( !NoDeleteOnExplode )
					Destroy();
				return;
			}

			if ( Bounciness > 0f )
			{
				var reflect = Vector3.Reflect( _velocity.Normal, trace.Normal );
				_gravityModifier = 0f;
				_velocity = reflect * _velocity.Length * Bounciness;

				if ( !string.IsNullOrEmpty( BounceSoundPath ) && _velocity.Length > BounceSoundMinVelocity )
					Sound.Play( BounceSoundPath, WorldPosition );
			}
		}
	}

	private Vector3 GetTargetPosition()
	{
		var newPos = WorldPosition + _velocity * Time.Delta;
		_gravityModifier += Gravity;
		newPos -= new Vector3( 0f, 0f, _gravityModifier * Time.Delta );
		return newPos;
	}

	private static readonly float _airDensity = 1.204f * 0.0164f;

	private static Vector3 CalculateDrag( Vector3 velocity )
	{
		var dragForce = 0.1f * _airDensity * velocity.LengthSquared * 0.5f;
		return -dragForce * velocity.Normal;
	}

	// -------------------------------------------------------------------------
	// Explosion
	// -------------------------------------------------------------------------

	public void Explode()
	{
		if ( _hasExploded ) return;
		_hasExploded = true;

		// Broadcast visual effects to all clients
		BroadcastExplosionEffects( WorldPosition );

		// Damage is always run on host (projectiles are only spawned/simulated on host)
		var hits = Scene.FindInPhysics( new Sphere( WorldPosition, ExplosionRadius ) );

		// First pass: check if any enemy was caught in the blast
		bool hitEnemy = false;
		foreach ( var go in hits )
		{
			var pawn = go.Components.Get<PlayerPawn>( FindMode.EnabledInSelfAndDescendants );
			if ( pawn == null || !pawn.IsAlive || pawn == Owner ) continue;

			var dist = Vector3.DistanceBetween( WorldPosition, pawn.WorldPosition );
			if ( dist > ExplosionRadius ) continue;

			var losTr = Scene.Trace.Ray( WorldPosition, pawn.WorldPosition ).WithTag( "world" ).Run();
			if ( losTr.Hit ) continue;

			hitEnemy = true;
			break;
		}

		foreach ( var go in hits )
		{
			var pawn = go.Components.Get<PlayerPawn>( FindMode.EnabledInSelfAndDescendants );
			if ( pawn == null || !pawn.IsAlive ) continue;

			var dist = Vector3.DistanceBetween( WorldPosition, pawn.WorldPosition );
			if ( dist > ExplosionRadius ) continue;

			var losTr = Scene.Trace.Ray( WorldPosition, pawn.WorldPosition )
				.WithTag( "world" )
				.Run();
			if ( losTr.Hit ) continue;

			// Skip self-damage if the explosion also caught an enemy
			if ( pawn == Owner && hitEnemy ) continue;

			var distanceMul = ExplosionDamageFalloff.Evaluate( dist / ExplosionRadius );
			var dmg = ExplosionDamage * distanceMul;

			if ( pawn == Owner ) dmg *= SelfDamageScale;

			pawn.TakeDamage( dmg, Owner );

			var rb = go.Components.Get<Rigidbody>( FindMode.EnabledInSelfAndDescendants );
			if ( rb != null )
			{
				var forceDir = (pawn.WorldPosition - WorldPosition).Normal;
				rb.ApplyImpulse( forceDir * distanceMul * rb.PhysicsBody.Mass );
			}
		}
	}

	[Rpc.Broadcast( NetFlags.HostOnly )]
	private void BroadcastExplosionEffects( Vector3 position )
	{
		if ( !string.IsNullOrEmpty( ExplosionSoundPath ) )
			Sound.Play( ExplosionSoundPath, position );

		if ( !string.IsNullOrEmpty( ExplosionParticlePath ) )
		{
			var prefabFile = ResourceLibrary.Get<PrefabFile>( ExplosionParticlePath );
			if ( prefabFile != null )
				SceneUtility.GetPrefabScene( prefabFile )?.Clone( new CloneConfig
				{
					Transform = new Transform( position ),
					StartEnabled = true
				} );
		}
	}

	// -------------------------------------------------------------------------
	// Cleanup
	// -------------------------------------------------------------------------

	private void Destroy()
	{
		_activeSound?.Stop();
		Simulator?.Remove( this );
		GameObject.Destroy();
	}

	protected override void OnDestroy()
	{
		_activeSound?.Stop();
		Simulator?.Remove( this );
	}
}