SWB/swb_base/Weapon.Shoot.cs

Partial Weapon class handling shooting: fire-rate and ammo checks, bullet traces, recoil, muzzle and impact effects, shell ejection, and decal spawning.

Networking
using SWB.Base.Particles;
using SWB.Shared;
using System;
using System.Collections.Generic;

namespace SWB.Base;

public partial class Weapon
{
	public static readonly string[] BulletTraceIgnoreTags =
	{
		TagsHelper.Trigger,
		TagsHelper.PlayerClip,
		TagsHelper.PassBullets,
		TagsHelper.ViewModel,
		TagsHelper.Sky
	};

	public static readonly string[] TuckingTraceIgnoreTags =
	[
		..BulletTraceIgnoreTags,
		TagsHelper.Player,
		TagsHelper.DeadPlayer
	];

	public static readonly string[] PenetrationBulletTraceIgnoreTags =
	[
		..BulletTraceIgnoreTags,
		TagsHelper.DeadPlayer
	];

	/// <summary>
	/// Checks if the weapon can do the provided attack
	/// </summary>
	/// <param name="shootInfo">Attack information</param>
	/// <param name="lastAttackTime">Time since this attack</param>
	/// <param name="inputButton">The input button for this attack</param>
	/// <returns></returns>
	public virtual bool CanShoot( ShootInfo shootInfo, TimeSince lastAttackTime, string inputButton )
	{
		if ( (IsReloading && !ShellReloading) || (IsReloading && ShellReloading && !ShellReloadingShootCancel) || InBoltBack ) return false;
		if ( shootInfo is null || !Owner.IsValid() || (!Owner.IsBot && !Input.Down( inputButton )) || ((IsRunning || TimeSinceRunning < 0.1f) && Secondary is null) ) return false;
		if ( !HasAmmo() )
		{
			if ( Input.Pressed( inputButton ) )
			{
				// Check for auto reloading
				if ( Settings.AutoReload && Owner.AmmoCount( shootInfo.AmmoType ) > 0 && lastAttackTime > GetRealRPM( shootInfo.RPM ) )
				{
					TimeSincePrimaryShoot = 999;
					TimeSinceSecondaryShoot = 999;

					if ( ShellReloading )
						OnShellReload();
					else
						Reload();

					return false;
				}

				// Dry fire
				if ( shootInfo.DryShootSound is not null )
					PlaySound( shootInfo.DryShootSound );
			}

			return false;
		}

		if ( shootInfo.FiringType == FiringType.semi && !Owner.IsBot && !Input.Pressed( inputButton ) ) return false;
		if ( shootInfo.FiringType == FiringType.burst )
		{
			if ( burstCount > 2 ) return false;

			if ( (Owner.IsBot || Input.Down( inputButton )) && lastAttackTime > GetRealRPM( shootInfo.RPM ) )
			{
				burstCount++;
				return true;
			}

			return false;
		}
		;

		if ( shootInfo.RPM <= 0 ) return true;

		return lastAttackTime > GetRealRPM( shootInfo.RPM );
	}

	/// <summary>
	/// Checks if weapon can do the primary attack
	/// </summary>
	public virtual bool CanPrimaryShoot()
	{
		return CanShoot( Primary, TimeSincePrimaryShoot, InputButtonHelper.PrimaryAttack );
	}

	/// <summary>
	/// Checks if weapon can do the secondary attack
	/// </summary>
	public virtual bool CanSecondaryShoot()
	{
		return CanShoot( Secondary, TimeSinceSecondaryShoot, InputButtonHelper.SecondaryAttack );
	}

	public virtual void Shoot( ShootInfo shootInfo, bool isPrimary )
	{
		// Ammo
		if ( shootInfo.InfiniteAmmo != InfiniteAmmoType.clip )
			shootInfo.Ammo -= 1;

		// Animations
		var shootAnim = GetShootAnimation( shootInfo );
		if ( !string.IsNullOrEmpty( shootAnim ) )
			ApplyShootAnimation( shootAnim );

		// Sound + particles (Shooter's sound is sent along so silencers stay owner-authoritative)
		HandleShootEffects( isPrimary, shootInfo.ShootSound );

		if ( !Owner.IsBot )
		{
			// Barrel smoke
			barrelHeat += 1;

			// Recoil
			Owner.ApplyEyeAnglesOffset( GetRecoilAngles( shootInfo ) );

			// Screenshake
			if ( shootInfo.ScreenShake is not null )
				Owner.ShakeScreen( shootInfo.ScreenShake );

			// UI
			BroadcastUIEvent( "shoot", GetRealRPM( shootInfo.RPM ) );
		}

		// Bullet - impact/tracer effects of every pellet are collected and sent together (see FlushHitEffects)
		BeginHitEffectBatch();

		try
		{
			for ( int i = 0; i < shootInfo.Bullets; i++ )
			{
				var realSpread = GetRealSpread( shootInfo.Spread );
				var spreadOffset = shootInfo.BulletType.GetRandomSpread( realSpread );
				shootInfo?.BulletType?.Shoot( this, isPrimary, spreadOffset );
			}
		}
		finally
		{
			FlushHitEffects( isPrimary );
		}
	}

	protected virtual void ApplyShootAnimation( string anim )
	{
		ViewModelRenderer?.Set( anim, true );
	}

	/// <summary> A single bullet trace from start to end with a certain radius.</summary>
	public static SceneTraceResult TraceBullet( GameObject toIgnoreGO, Vector3 start, Vector3 end, float radius = 2.0f, string[] ignoreTags = null, IEnumerable<GameObject> extraIgnoreGOs = null )
	{
		// TODO: find another solution when water becomes more available
		// var startsInWater = SurfaceUtil.IsPointWater( start );
		// if ( startsInWater )
		//	 withoutTags.Add( TagsHelper.Water );

		var trace = Game.ActiveScene.Trace.Ray( start, end )
				.UseHitboxes()
				.WithoutTags( ignoreTags ?? BulletTraceIgnoreTags )
				.Size( radius )
				.IgnoreGameObjectHierarchy( toIgnoreGO );

		if ( extraIgnoreGOs is not null )
		{
			foreach ( var go in extraIgnoreGOs )
				trace = trace.IgnoreGameObjectHierarchy( go );
		}

		var tr = trace.Run();

		// Log.Info( tr.GameObject );

		// Horizon RP : le jeu peut corriger une touche (un véhicule n'a qu'une boîte de collision ;
		// le point est reporté sur sa carrosserie, ou la balle continue si elle passe à côté).
		if ( RefineBulletTrace is not null )
			tr = RefineBulletTrace( tr, toIgnoreGO, start, end, radius, ignoreTags ?? BulletTraceIgnoreTags );

		return tr;
	}

	/// <summary>Horizon RP : correction d'une touche de balle (voir TraceBullet). Null : aucune.</summary>
	public static Func<SceneTraceResult, GameObject, Vector3, Vector3, float, string[], SceneTraceResult> RefineBulletTrace { get; set; }

	/// <summary> A single bullet trace from start to end with a certain radius.</summary>
	public virtual SceneTraceResult TraceBullet( Vector3 start, Vector3 end, float radius = 2.0f, string[] ignoreTags = null, IEnumerable<GameObject> extraIgnoreGOs = null )
	{
		return TraceBullet( Owner.GameObject, start, end, radius, ignoreTags, extraIgnoreGOs );
	}

	// Called once per traced segment of an actually-fired hitscan bullet (see HitScanBulletInfo.Shoot).
	// Do not call this from other traces (aim assist, tuck check, bots, etc.).
	public virtual void OnBulletTrace( Vector3 start, SceneTraceResult result ) { }

	/// <summary>Plays the owner's attack animation, once per shot on every client (from <see cref="HandleShootEffects"/>, before the muzzle effects)</summary>
	protected virtual void TriggerOwnerAttackAnimation()
	{
		Owner.TriggerAnimation( Shared.Animations.Attack );
	}

	[Rpc.Broadcast( NetFlags.Unreliable )]
	public virtual void HandleShootEffects( bool isPrimary, SoundEvent shootSound )
	{
		if ( !IsValid || Owner is null || Application.IsDedicatedServer ) return;

		// Player
		TriggerOwnerAttackAnimation();

		// Weapon
		var shootInfo = GetShootInfo( isPrimary );
		if ( shootInfo is null ) return;

		// Sound
		if ( shootSound is not null )
			PlaySoundLocal( shootSound );

		// Bullet eject
		if ( shootInfo.BulletEjectParticle is not null )
		{
			if ( !BoltBack )
			{
				if ( !ShellReloading || (ShellReloading && ShellEjectDelay == 0) )
				{
					CreateBulletEjectParticle( shootInfo.BulletEjectParticle, "ejection_point" );
				}
				else
				{
					var delayedEject = async () =>
					{
						await GameTask.DelaySeconds( ShellEjectDelay );
						if ( !IsValid ) return;
						CreateBulletEjectParticle( shootInfo.BulletEjectParticle, "ejection_point" );
					};
					delayedEject();
				}
			}
			else if ( shootInfo.Ammo > 0 )
			{
				AsyncBoltBack( GetRealRPM( shootInfo.RPM ) );
			}
		}

		var muzzleObj = GetMuzzleObject();
		if ( muzzleObj is null ) return;

		var muzzleScale = CanSeeViewModel ? shootInfo.VMParticleScale : shootInfo.WMMuzzleParticleScale;

		// Muzzle flash
		if ( shootInfo.MuzzleFlashParticle is not null )
			CreateParticle( shootInfo.MuzzleFlashParticle, muzzleObj, muzzleScale );

		// Barrel smoke
		if ( !IsProxy && !Owner.IsBot && shootInfo.BarrelSmokeParticle is not null && barrelHeat >= shootInfo.ClipSize * 0.75 )
			CreateParticle( shootInfo.BarrelSmokeParticle, muzzleObj, muzzleScale );
	}

	/// <summary>Create a bullet impact effect</summary>
	public static GameObject CreateBulletImpact( SceneTraceResult tr )
	{
		return CreateBulletImpact( tr.HitPosition, tr.Normal, tr.Surface?.SoundCollection.Bullet, tr.Surface?.PrefabCollection.BulletImpact, tr.Surface?.PrefabCollection.BulletImpactDecal );
	}

	/// <summary>Create a bullet impact effect</summary>
	public static GameObject CreateBulletImpact( Vector3 pos, Vector3 normal, SoundEvent sound, GameObject particles, GameObject decal = null )
	{
		// Horizon RP : le trou de la balle. SWB ne posait que les éclats (BulletImpact) ; le trou est
		// le second prefab de la surface (BulletImpactDecal). Sans lui, rien ne restait sur les murs
		// de la map (Julien, 30/10/2026). Pas sur un véhicule (Vehicle.Bullets pose ses propres
		// trous, qui suivent la caisse) ni sur un joueur (le trou resterait en l'air).
		PlaceBulletHole( pos, normal, decal );

		// Sound
		SoundHandle soundHandle = null;

		if ( sound is not null )
		{
			soundHandle = Sound.Play( sound );

			if ( soundHandle is not null )
				soundHandle.Distance = 10000;
		}

		soundHandle ??= Sound.Play( "impact-bullet-generic" );
		soundHandle.Position = pos;

		// Decal & Particles
		if ( !particles.IsValid() ) return null;

		var cloneConfig = new CloneConfig()
		{
			Name = "bullet_decal",
			StartEnabled = true,
			Transform = new()
			{
				Position = pos,
				Rotation = Rotation.LookAt( -normal ),
			},
			//Parent = tr.GameObject,
		};
		var decalGO = particles.Clone( cloneConfig );
		decalGO.NetworkMode = NetworkMode.Never;
		decalGO.DestroyAsync( 30f );

		WeaponParticleManager.Instance?.AddDecal( decalGO );

		return decalGO;
	}

	static void PlaceBulletHole( Vector3 pos, Vector3 normal, GameObject decal )
	{
		if ( !decal.IsValid() || Game.ActiveScene is not { } scene )
			return;
		foreach ( var near in scene.FindInPhysics( new Sphere( pos, 6f ) ) )
			if ( near.GetComponentInParent<HorizonRP.Vehicle>() is not null || near.GetComponentInParent<HorizonRP.RoleplayPlayer>() is not null )
				return;
		var hole = decal.Clone( new CloneConfig
		{
			Name = "bullet_hole",
			StartEnabled = true,
			Transform = new() { Position = pos + normal * 0.5f, Rotation = Rotation.LookAt( -normal ) },
		} );
		hole.NetworkMode = NetworkMode.Never;
		hole.Flags |= GameObjectFlags.NotSaved;
		hole.DestroyAsync( 60f );
		WeaponParticleManager.Instance?.AddDecal( hole );
	}

	/// <summary>Shell casings from other shooters further than this from the camera aren't spawned</summary>
	public const float MaxProxyEjectDistance = 1500f;

	/// <summary>Whether a shell casing at this position is worth spawning for another shooter</summary>
	public static bool ShouldSpawnProxyEject( Scene scene, Vector3 position )
	{
		var camera = scene?.Camera;
		if ( camera is null ) return true;

		return camera.WorldPosition.DistanceSquared( position ) <= MaxProxyEjectDistance * MaxProxyEjectDistance;
	}

	/// <summary>Create a bullet eject particle (always world)</summary>
	public virtual GameObject CreateBulletEjectParticle( GameObject particle, string attachment, Action<GameObject> OnParticleCreated = null )
	{
		if ( (IsProxy || Owner.IsBot) && !ShouldSpawnProxyEject( Scene, WorldPosition ) )
			return null;

		var effectRenderer = GetEffectRenderer();
		if ( effectRenderer is null || effectRenderer.SceneModel is null ) return null;

		var transform = effectRenderer.GetAttachment( attachment );
		if ( !transform.HasValue ) return null;

		// Rotate bullet with attachment yaw
		var spawnInViewSpace = CanSeeViewModel && !IsScoping;
		var pitch = spawnInViewSpace ? ViewModelHandler.WorldRotation.Pitch() : WorldRotation.Pitch();
		var yaw = transform.Value.Rotation.Yaw();
		var newRot = Rotation.From( new Angles( 0, yaw, -pitch ) );
		transform = transform.Value.WithRotation( newRot );

		if ( spawnInViewSpace )
		{
			var viewSpacePos = CameraUtil.ProjectToViewSpace( transform.Value.Position, Owner.ViewModelCamera, Owner.Camera );
			transform = transform.Value.WithPosition( viewSpacePos );
		}

		var go = CreateParticle( particle, null, transform.Value, 1, false, OnParticleCreated );
		WeaponParticleManager.Instance?.AddEject( go );

		// Attach owner
		var ejectParticle = go.GetComponentInChildren<BulletEjectParticle>();
		ejectParticle?.Owner = Owner;

		return go;
	}

	/// <summary>Particles parented to the weapon/viewmodel (muzzle flash, barrel smoke)</summary>
	readonly List<GameObject> attachedParticles = new();

	/// <summary>Destroys particles still parented to the weapon/viewmodel so they don't replay on re-deploy</summary>
	protected virtual void DestroyAttachedParticles()
	{
		foreach ( var p in attachedParticles )
		{
			if ( p.IsValid() )
				p.Destroy();
		}

		attachedParticles.Clear();
	}

	/// <summary>Create a weapon particle</summary>
	public virtual GameObject CreateParticle( GameObject particle, GameObject parent, float scale,Action<GameObject> OnParticleCreated = null )
	{
		return CreateParticle( particle, parent, new Transform(), scale, OnParticleCreated );
	}

	/// <summary>Create a weapon particle</summary>
	public virtual GameObject CreateParticle( GameObject particle, Transform transform, float scale, Action<GameObject> OnParticleCreated = null )
	{
		return CreateParticle( particle, null, transform, scale, OnParticleCreated );
	}

	/// <summary>Create a weapon particle</summary>
	public virtual GameObject CreateParticle( GameObject particle, GameObject parent, Transform transform, float scale, Action<GameObject> OnParticleCreated = null )
	{
		return CreateParticle( particle, parent, transform, scale, CanSeeViewModel, OnParticleCreated );
	}

	public virtual GameObject CreateParticle( GameObject particle, GameObject parent, Transform transform, float scale, bool forViewModel, Action<GameObject> OnParticleCreated = null )
	{
		var go = particle.Clone( transform.WithScale( scale ), parent );

		// Track parented particles, they get disabled (not finished) together with the weapon on holster
		if ( parent is not null )
		{
			attachedParticles.RemoveAll( p => !p.IsValid() );
			attachedParticles.Add( go );
		}

		if ( forViewModel )
			go.Tags.Add( TagsHelper.ViewModel );

		if ( OnParticleCreated is not null )
		{
			var p = go.GetComponentInChildren<ParticleEffect>();
			p.OnParticleCreated += ( p ) =>
			{
				OnParticleCreated.Invoke( go );
			};
		}

		return go;
	}
}