Component that simulates a physical projectile. It updates velocity with drag and gravity, traces movement each fixed update, spawns decals on impact, applies damage via DamageInfo.FromBullet, and supports penetration by ignoring already-hit GameObjects up to a cap.
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;
}
}