SWB/swb_base/bullets/BulletInfo.HitScan.cs

HitScan bullet implementation for SWB weapons. Traces from the player's eye, applies damage, handles penetration through players, ricochets off shallow world surfaces, glass windows, tracers, and broadcasts impact effects.

Networking
using SWB.Shared;
using System;
using System.Collections.Generic;
using System.Linq;

namespace SWB.Base;

[Group( "SWB" )]
[Title( "HitScan Bullet Info" )]
public class HitScanBulletInfo : BulletInfo
{
	private const int MaxPenetrations = 10; // safety valve, also acts as a practical per-bullet pierce cap

	public override void Shoot( Weapon weapon, bool isPrimary, Vector3 spreadOffset )
	{
		if ( !weapon.IsValid() ) return;

		var player = weapon.Owner;
		if ( !player.IsValid() ) return;

		var forward = player.EyeAngles.Forward + spreadOffset;
		forward = forward.Normal;
		var endPos = player.EyePos + forward * 999999;
		var shootInfo = weapon.GetShootInfo( isPrimary );
		var hasTracer = ShouldSpawnTracer( shootInfo );

		var traceStart = player.EyePos;
		List<GameObject> ignoreGameObjects = null; // only needed once we penetrate a player
		var traceIgnoreTags = shootInfo.Penetration ? Weapon.PenetrationBulletTraceIgnoreTags : null;
		var ricochetCount = 0;
		var shotId = Guid.NewGuid(); // correlates every hit from this single bullet (penetration + ricochet)
		Vector3? tracerSegmentStart = null; // null = draw from the muzzle (first segment)

		for ( int i = 0; i < MaxPenetrations; i++ )
		{
			var bulletTr = weapon.TraceBullet( traceStart, endPos, ignoreTags: traceIgnoreTags, extraIgnoreGOs: ignoreGameObjects );
			weapon.OnBulletTrace( traceStart, bulletTr );
			var hitObj = bulletTr.GameObject;
			var hasImpact = !SurfaceUtil.IsSkybox( bulletTr.Surface ) && bulletTr.HitPosition != Vector3.Zero;
			IPlayerBase penetratedPlayer = null;

			// Horizon RP : une vitre de la map n'arrête pas la balle (World/MapGlass.cs) : les éclats
			// et le son du verre, puis la balle continue derrière le carreau.
			if ( hasImpact && HorizonRP.MapGlass.Contains( bulletTr.HitPosition ) )
			{
				var glass = ResourceLibrary.Get<Surface>( "surfaces/glass.surface" );
				if ( weapon.IsBatchingHitEffects )
					weapon.QueueHitEffect( this, true, false, null, bulletTr.EndPosition, bulletTr.Normal, glass );
				else
					SpawnEffects( weapon, isPrimary, true, false, null, bulletTr.EndPosition, bulletTr.Normal, glass?.SoundCollection.Bullet, glass?.PrefabCollection.BulletImpact, glass?.PrefabCollection.BulletImpactDecal );
				traceStart = bulletTr.HitPosition + forward * 2f;
				continue;
			}

			// Damage
			if ( hitObj is not null )
			{
				var target = hitObj.Components.GetInAncestorsOrSelf<IDamageable>();
				var hitTags = Array.Empty<string>();

				if ( bulletTr.Hitbox is not null )
					hitTags = bulletTr.Hitbox.Tags.TryGetAll().ToArray();

				var force = forward * 100 * shootInfo.Force;

				// Kill details (dictionary + strings) only matter to players - skip them for walls/props
				var killDetails = target is IPlayerBase ? weapon.GetKillDetails() : null;
				var dmgInfo = Shared.DamageInfo.FromBullet(
					weapon.Owner.GameObject,
					weapon.GameObject,
					bulletTr.Hitbox,
					bulletTr.EndPosition,
					bulletTr.Shape,
					weapon.ClassName,
					shootInfo.Damage,
					bulletTr.HitPosition,
					force,
					shootInfo.HitFlinch,
					Weapon.GetMovementImpactFromForce( shootInfo.Force ),
					hitTags,
					killDetails,
					shotId
				);
				target?.OnDamage( dmgInfo );

				if ( shootInfo.Penetration && target is IPlayerBase playerTarget )
					penetratedPlayer = playerTarget;
			}

			// A ricochet can only happen off world geometry (not mid-penetration) that's angled shallow enough
			var canRicochet = penetratedPlayer is null
				&& hasImpact
				&& shootInfo.Ricochet
				&& ricochetCount < shootInfo.MaxRicochets
				&& SurfaceUtil.CanRicochet( bulletTr.Surface )
				&& SurfaceUtil.GetGrazingAngle( forward, bulletTr.Normal ) <= shootInfo.RicochetAngle
				&& Game.Random.Float( 0f, 1f ) < shootInfo.RicochetChance;

			// Effects
			var isFinalHit = penetratedPlayer is null && !canRicochet;
			var tracerSegmentEnds = isFinalHit || canRicochet; // penetration keeps the same straight tracer segment going
			var tracerThisIteration = hasTracer && tracerSegmentEnds;

			if ( hasImpact || tracerThisIteration )
			{
				// Weapon.Shoot batches all pellets of a shot into one message
				if ( weapon.IsBatchingHitEffects )
					weapon.QueueHitEffect( this, hasImpact, tracerThisIteration, tracerSegmentStart, bulletTr.EndPosition, bulletTr.Normal, bulletTr.Surface );
				else
					SpawnEffects( weapon, isPrimary, hasImpact, tracerThisIteration, tracerSegmentStart, bulletTr.EndPosition, bulletTr.Normal, bulletTr.Surface?.SoundCollection.Bullet, bulletTr.Surface?.PrefabCollection.BulletImpact, bulletTr.Surface?.PrefabCollection.BulletImpactDecal );
			}

			if ( isFinalHit )
				break;

			if ( canRicochet )
			{
				forward = Vector3.Reflect( forward, bulletTr.Normal ).Normal;
				endPos = bulletTr.EndPosition + forward * 999999;
				traceStart = bulletTr.EndPosition + bulletTr.Normal * 1.0f; // nudge off the surface so we don't immediately re-hit it
				tracerSegmentStart = bulletTr.EndPosition;
				ricochetCount++;
			}
			else
			{
				ignoreGameObjects ??= new();
				ignoreGameObjects.Add( penetratedPlayer.GameObject );
				traceStart = bulletTr.EndPosition + forward * 1.0f;
			}
		}
	}

	[Rpc.Broadcast( NetFlags.Unreliable )]
	public void SpawnEffects( Weapon weapon, bool isPrimary, bool hasImpact, bool hasTracer, Vector3? tracerStart, Vector3 hitPos, Vector3 hitNormal, SoundEvent hitSound, GameObject hitParticles, GameObject hitDecal = null )
	{
		if ( !weapon.IsValid() || Application.IsDedicatedServer ) return;

		// Impact
		if ( hasImpact )
			Weapon.CreateBulletImpact( hitPos, hitNormal, hitSound, hitParticles, hitDecal );

		// Tracer
		if ( hasTracer )
			TracerEffects( weapon, isPrimary, tracerStart, hitPos );
	}

	public virtual void TracerEffects( Weapon weapon, bool isPrimary, Vector3? tracerStart, Vector3 hitPos )
	{
		Vector3 startPos;

		if ( tracerStart.HasValue )
		{
			startPos = tracerStart.Value;
		}
		else
		{
			var muzzleTransform = weapon.GetMuzzleTransform();
			if ( !muzzleTransform.HasValue ) return;
			startPos = muzzleTransform.Value.Position;
		}

		var shootInfo = weapon.GetShootInfo( isPrimary );
		var scale = weapon.CanSeeViewModel ? shootInfo.VMParticleScale : shootInfo.WMParticleScale;
		var direction = (hitPos - startPos).Normal;
		var rotation = Rotation.LookAt( direction );
		var particleTransform = new Transform( startPos, rotation );
		weapon.CreateParticle( shootInfo.BulletTracerParticle, particleTransform, scale );
	}
}