SWB/swb_base/bullets/PhysicalBullet.Mover.cs

Physical bullet mover component for SWB weapons. Advances the bullet each fixed update with drag and gravity, traces the path, applies damage and decals, and continues through penetrated players up to a cap.

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

namespace SWB.Base;

[Group( "SWB" )]
[Title( "Physical Bullet Mover" )]
public class PhysicalBulletMover : Component
{
	private const int MaxPenetrations = 10; // safety valve, also acts as a practical per-bullet pierce cap
	private readonly List<GameObject> ignoreGameObjects = new();
	private readonly Guid shotId = Guid.NewGuid(); // correlates every hit from this single physical bullet (penetration)

	public IPlayerBase Owner { get; set; }
	public string ClassName { get; set; }
	public Vector3 BulletVelocity { get; set; }
	public Weapon Weapon { get; set; }
	public ShootInfo ShootInfo { get; set; }
	public PhysicalBulletInfo BulletInfo { get; set; }
	public float BulletGravity => BulletInfo.Gravity;
	public float BulletDrag => BulletInfo.Drag;
	public bool HasImpacted { get; private set; } = false;

	public void Initialize( PhysicalBulletInfo bulletInfo, Weapon weapon, ShootInfo shootInfo, Vector3 bulletVelocity )
	{
		BulletInfo = bulletInfo;
		Owner = weapon.Owner;
		ClassName = weapon.ClassName;
		Weapon = weapon;
		ShootInfo = shootInfo;
		BulletVelocity = bulletVelocity;

		// If a bullet is flying this long it probably got bugged
		GetOrAddComponent<TemporaryEffect>().DestroyAfterSeconds = 20f;
	}

	protected override void OnFixedUpdate()
	{
		if ( IsProxy || HasImpacted || Owner is null ) return;

		// Apply drag (before gravity so we aren't immediately dragging on gravity)
		BulletVelocity *= 1 - BulletDrag;

		// Apply gravity
		BulletVelocity += Vector3.Down * BulletGravity * Time.Delta;

		var bulletMovement = BulletVelocity * Time.Delta;

		// Trace along path to see if we hit anything
		var traceIgnoreTags = ShootInfo.Penetration ? Weapon.PenetrationBulletTraceIgnoreTags : null;
		var bulletTrace = Weapon.TraceBullet( Owner.GameObject, WorldPosition, WorldPosition + bulletMovement, ignoreTags: traceIgnoreTags, extraIgnoreGOs: ignoreGameObjects );
		if ( bulletTrace.Hit )
		{
			var penetratedPlayer = HandleImpact( bulletTrace );

			if ( penetratedPlayer is not null && ignoreGameObjects.Count < MaxPenetrations )
			{
				ignoreGameObjects.Add( penetratedPlayer.GameObject );
				WorldPosition = bulletTrace.HitPosition + BulletVelocity.Normal * 1.0f;
				return;
			}

			WorldPosition = bulletTrace.HitPosition;

			// Allows for graceful ending of effects
			HasImpacted = true;
			GetOrAddComponent<TemporaryEffect>().DestroyAfterSeconds = 0.5f;
			ITemporaryEffect.DisableLoopingEffects( GameObject );
			return;
		}

		// If we didn't hit anything, we can move the bullet
		WorldPosition += bulletMovement;
	}

	protected IPlayerBase HandleImpact( SceneTraceResult traceResult )
	{
		var hitObject = traceResult.GameObject;

		if ( SurfaceUtil.IsSkybox( traceResult.Surface ) || traceResult.HitPosition == Vector3.Zero ) return null;

		// Impact
		var decal = Weapon.CreateBulletImpact( traceResult );
		decal?.NetworkSpawn();

		// Damage
		IPlayerBase penetratedPlayer = null;

		if ( hitObject is not null )
		{
			var target = hitObject.Components.GetInAncestorsOrSelf<IDamageable>();

			var hitTags = Array.Empty<string>();

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

			var forward = BulletVelocity.Normal;

			// Assume force from shoot info is the force at firing
			var force = BulletVelocity.Length.Remap( 0, BulletInfo.Velocity, 0, ShootInfo.Force );

			var dmgInfo = Shared.DamageInfo.FromBullet(
				Owner.GameObject,
				Weapon.GameObject,
				traceResult.Hitbox,
				traceResult.EndPosition,
				traceResult.Shape,
				ClassName,
				ShootInfo.Damage,
				traceResult.HitPosition,
				forward * 100 * force,
				ShootInfo.HitFlinch,
				Weapon.GetMovementImpactFromForce( ShootInfo.Force ),
				hitTags,
				Weapon.GetKillDetails(),
				shotId
			);
			target?.OnDamage( dmgInfo );

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

		return penetratedPlayer;
	}
}