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