Weapons/Components/ShootComponent.cs
/// <summary>
/// Handles firing (hitscan bullets or spawning projectiles) for a weapon.
/// </summary>
[Title( "Shoot Component" ), Icon( "gps_fixed" )]
public sealed class ShootComponent : WeaponComponent
{
	[Property] public string FireButton { get; set; } = "Attack1";
	[Property] public GameObject MuzzlePoint { get; set; }
	[Property] public WeaponData Data { get; set; }

	// Driven by WeaponData at runtime
	public float BaseDamage { get; set; }
	public float BulletRange { get; set; } = 5000f;
	public int BulletCount { get; set; } = 1;
	public float BulletForce { get; set; } = 1f;
	public float BulletSize { get; set; } = 2f;
	public float BulletSpread { get; set; } = 0f;
	public float FireDelay { get; set; } = 0.1f;
	public bool DisableBulletImpacts { get; set; }
	public SoundEvent FireSound { get; set; }
	public bool FireSoundLoop { get; set; }
	public bool FireSoundOnlyOnStart { get; set; }
	public string ActivateSound { get; set; }
	public string DryFireSound { get; set; }
	public string ProjectilePrefab { get; set; }
	public string ShootEffectPrefab { get; set; }
	public string ImpactEffectPrefab { get; set; }
	public string MuzzleFlashPrefab { get; set; }
	public Color TracerColor { get; set; } = Color.Yellow;
	public float TracerSpeed { get; set; } = 8000f;
	public float TracerLength { get; set; } = 300f;

	private TimeUntil _timeUntilCanFire;
	private SoundHandle _activeSound;
	private SoundHandle _fireSoundHandle;
	private bool _isFiring;

	protected override void OnStart()
	{
		if ( Data == null ) return;
		BaseDamage           = Data.BaseDamage;
		BulletRange          = Data.BulletRange;
		BulletCount          = Data.BulletCount;
		BulletForce          = Data.BulletForce;
		BulletSize           = Data.BulletSize;
		BulletSpread         = Data.BulletSpread;
		FireDelay            = Data.FireDelay;
		DisableBulletImpacts = Data.DisableBulletImpacts;
		FireSoundLoop        = Data.FireSoundLoop;
		FireSoundOnlyOnStart = Data.FireSoundOnlyOnStart;
		TracerColor          = Data.TracerColor;
		TracerSpeed          = Data.TracerSpeed;
		TracerLength         = Data.TracerLength;

		if ( !string.IsNullOrEmpty( Data.ProjectilePrefab ) )   ProjectilePrefab   = Data.ProjectilePrefab;
		if ( Data.FireSound.IsValid() )                          FireSound          = Data.FireSound;
		if ( !string.IsNullOrEmpty( Data.ActivateSound ) )      ActivateSound      = Data.ActivateSound;
		if ( !string.IsNullOrEmpty( Data.DryFireSound ) )       DryFireSound       = Data.DryFireSound;
		if ( !string.IsNullOrEmpty( Data.ShootEffectPrefab ) )  ShootEffectPrefab  = Data.ShootEffectPrefab;
		if ( !string.IsNullOrEmpty( Data.ImpactEffectPrefab ) ) ImpactEffectPrefab = Data.ImpactEffectPrefab;
		if ( !string.IsNullOrEmpty( Data.MuzzleFlashPrefab ) )  MuzzleFlashPrefab  = Data.MuzzleFlashPrefab;
	}

	public override void Simulate()
	{
		base.Simulate();

		if ( Player == null ) return;

		if ( WishesToFire() )
		{
			if ( CanFire() )
			{
				if ( !_isFiring )
				{
					_isFiring = true;
					if ( FireSound.IsValid() )
					{
						if ( FireSoundLoop )
							_fireSoundHandle = GameObject.PlaySound( FireSound );
						else if ( FireSoundOnlyOnStart )
							GameObject.PlaySound( FireSound );
					}
				}

				Fire();
			}
			else
			{
				// Dry fire if no ammo
				var ammo = GetComponent<AmmoComponent>();
				if ( ammo != null && !ammo.HasEnoughAmmo() && Input.Pressed( FireButton ) )
				{
					if ( !string.IsNullOrEmpty( DryFireSound ) )
						Sound.Play( DryFireSound, Player.WorldPosition );
				}
			}
		}
		else
		{
			StopFiring();
		}
	}

	private void StopFiring()
	{
		if ( !_isFiring ) return;
		_isFiring = false;
		_activeSound?.Stop();
		_activeSound = null;
		if ( _fireSoundHandle.IsValid() )
		{
			_fireSoundHandle.Stop();
			_fireSoundHandle = default;
		}
	}

	protected override void OnDeactivate() => StopFiring();

	private void Fire()
	{
		_timeUntilCanFire = FireDelay;
		TimeSinceActivated = 0;

		RunGameEvent( $"{Name}.fire" );

		// Local feedback: fire sound and activate effects
		if ( FireSound.IsValid() && !FireSoundOnlyOnStart && !FireSoundLoop )
			GameObject.PlaySound( FireSound );

		// Muzzle flash
		if ( !string.IsNullOrEmpty( MuzzleFlashPrefab ) )
		{
			var muzzlePos = MuzzlePoint?.WorldPosition ?? Player.WorldPosition;
			var muzzleRot = MuzzlePoint?.WorldRotation ?? Player.WorldRotation;
			var flashFile = ResourceLibrary.Get<PrefabFile>( MuzzleFlashPrefab );
			if ( flashFile != null )
			{
				var flash = SceneUtility.GetPrefabScene( flashFile )?.Clone();
				if ( flash != null )
				{
					flash.WorldPosition = muzzlePos;
					flash.WorldRotation = muzzleRot;
					flash.NetworkSpawn();
				}
			}
		}

		// Shoot effect for projectile weapons (no hit endpoint available)
		if ( !string.IsNullOrEmpty( ShootEffectPrefab ) && Data?.Mode == WeaponData.FiringMode.Projectile )
		{
			var muzzlePos = MuzzlePoint?.WorldPosition ?? Player.WorldPosition;
			var muzzleRot = MuzzlePoint?.WorldRotation ?? Player.WorldRotation;
			BroadcastShootEffect( muzzlePos, muzzleRot, muzzlePos + muzzleRot.Forward * BulletRange );
		}

		if ( _activeSound == null && !string.IsNullOrEmpty( ActivateSound ) )
			_activeSound = Sound.Play( ActivateSound, Player.WorldPosition );

		// Route shot processing through host
		if ( Data?.Mode == WeaponData.FiringMode.Projectile )
		{
			if ( Networking.IsHost )
				SpawnProjectile();
			else
				ServerSpawnProjectile();
		}
		else
		{
			ShootBullet();
		}
	}

	private bool WishesToFire() => (Player?.IsBot == true ? Player.BotFirePrimary : Input.Down( FireButton )) && Player?.ActiveWeapon == Weapon;

	private bool CanFire()
	{
		if ( _timeUntilCanFire > 0 ) return false;
		if ( Weapon != null && Weapon.GameObject.Tags.Has( "reloading" ) ) return false;

		var ammo = GetComponent<AmmoComponent>();
		if ( ammo != null && !ammo.HasEnoughAmmo() ) return false;

		return true;
	}

	private void ShootBullet()
	{
		if ( Player == null ) return;

		Game.SetRandomSeed( (int)Time.Now );
		var aimRay = Player.AimRay;

		for ( int i = 0; i < BulletCount; i++ )
		{
			var forward = aimRay.Forward;
			if ( BulletSpread > 0 )
				forward += (Vector3.Random + Vector3.Random + Vector3.Random + Vector3.Random) * BulletSpread * 0.25f;
			forward = forward.Normal;

			var start = aimRay.Position;

			if ( Networking.IsHost )
				ProcessShot( start, forward );
			else
				ServerShootBullet( start, forward );
		}
	}

	/// <summary>Sent from owner client to host for authoritative shot processing.</summary>
	[Rpc.Host]
	private void ServerShootBullet( Vector3 start, Vector3 forward )
	{
		ProcessShot( start, forward );
	}

	private void ProcessShot( Vector3 start, Vector3 forward )
	{
		var end = start + forward.Normal * BulletRange;

		var tr = Scene.Trace.Ray( start, end )
			.WithAnyTags( "solid", "player" )
			.IgnoreGameObject( Player?.GameObject )
			.Size( BulletSize )
			.Run();

		if ( !DisableBulletImpacts && tr.Hit )
		{
			BroadcastImpactEffect( tr.EndPosition, tr.Normal );
		}

		// Shoot effect for hitscan — use ray start and actual hit endpoint
		if ( !string.IsNullOrEmpty( ShootEffectPrefab ) )
		{
			BroadcastShootEffect( start, Rotation.Identity, tr.EndPosition );
		}

		if ( tr.Hit )
		{
			var hitPawn = tr.GameObject?.Components.Get<PlayerPawn>( FindMode.EnabledInSelfAndDescendants );
			if ( hitPawn != null )
				hitPawn.TakeDamage( BaseDamage, Player );
		}

		if ( string.IsNullOrEmpty( ShootEffectPrefab ) )
			BroadcastTracerEffect( start, tr.EndPosition );
	}

	private void SpawnProjectile()
	{
		if ( string.IsNullOrEmpty( ProjectilePrefab ) ) return;
		var prefabFile = ResourceLibrary.Get<PrefabFile>( ProjectilePrefab );
		if ( prefabFile == null )
		{
			Log.Warning( $"[ShootComponent] ProjectilePrefab not found: {ProjectilePrefab}" );
			return;
		}
		var go = SceneUtility.GetPrefabScene( prefabFile )?.Clone();
		if ( go != null )
		{
			var proj = go.Components.Get<Projectile>( FindMode.EnabledInSelfAndDescendants );
			if ( proj != null && Data != null )
				proj.Data = Data;
			proj?.Launch( Player, null );
			go.NetworkSpawn();
		}
	}

	/// <summary>Sent from owner client to host to spawn and simulate the projectile authoritatively.</summary>
	[Rpc.Host]
	private void ServerSpawnProjectile()
	{
		SpawnProjectile();
	}

	[Rpc.Broadcast( NetFlags.HostOnly )]
	private void BroadcastShootEffect( Vector3 position, Rotation rotation, Vector3 endPosition )
	{
		if ( string.IsNullOrEmpty( ShootEffectPrefab ) ) return;
		var prefabFile = ResourceLibrary.Get<PrefabFile>( ShootEffectPrefab );
		if ( prefabFile == null ) return;

		var go = SceneUtility.GetPrefabScene( prefabFile )?.Clone();
		if ( go == null ) return;
		go.WorldPosition = position;
		go.WorldRotation = rotation;

		// Find BeamEffect in prefab (configured in editor), fallback to creating one
		var beam = go.GetComponent<BeamEffect>( true );

		//Log.Info( $"Shoot effect beam: {go}, start: {position}, end: {endPosition}" );

		beam.TargetGameObject.WorldPosition = endPosition;
		beam.TargetGameObject.Transform.ClearInterpolation();
	}

	[Rpc.Broadcast( NetFlags.HostOnly )]
	private void BroadcastImpactEffect( Vector3 position, Vector3 normal )
	{
		if ( string.IsNullOrEmpty( ImpactEffectPrefab ) ) return;
		var prefabFile = ResourceLibrary.Get<PrefabFile>( ImpactEffectPrefab );
		if ( prefabFile == null ) return;
		var go = SceneUtility.GetPrefabScene( prefabFile )?.Clone();
		if ( go == null ) return;
		go.WorldPosition = position;
		go.WorldRotation = Rotation.LookAt( normal );
	}

	[Rpc.Broadcast( NetFlags.HostOnly )]
	private void BroadcastTracerEffect( Vector3 start, Vector3 end )
	{
		var go = new GameObject( true, "BulletTracer" );
		go.WorldPosition = start;
		var tracer = go.Components.Create<Tracer>();
		tracer.EndPoint = end;
		tracer.DistancePerSecond = TracerSpeed;
		tracer.Length = TracerLength;
		tracer.LineColor = new Gradient(
			new Gradient.ColorFrame( 0, TracerColor ),
			new Gradient.ColorFrame( 1, TracerColor.WithAlpha( 0 ) )
		);
	}

	public override void OnGameEvent( string eventName )
	{
		if ( eventName == "sprint.stop" ) _timeUntilCanFire = 0.2f;
		if ( eventName == "aimcomponent.start" ) _timeUntilCanFire = 0.15f;
		if ( eventName == "shootcomponent.fire" ) TimeSinceActivated = 0;
	}
}