Projectile.cs
using Sandbox;
using System;
using System.Numerics;
public sealed class Projectile : Component
{
public Vector3 Velocity { get; set; }
/// <summary>
/// Reference to the weapon to ignore
/// </summary>
public GameObject WeaponObject { get; set; }
[Property] public float Damage { get; set; } = 25f;
/// <summary>Seconds until the projectile is destroyed, including after it sticks when Stay In World is enabled.</summary>
[Property, Group( "Lifetime" )]
public float Lifetime { get; set; } = 5f;
/// <summary>Keeps the projectile mesh in the world until Lifetime expires instead of destroying it on impact. Enable for crossbow bolts.</summary>
[Property, Group( "Lifetime" )]
public bool StayInWorld { get; set; }
/// <summary>Impulse applied at the hit point of dynamic props and existing ragdolls; live players are excluded.</summary>
[Property] public float ImpactImpulse { get; set; } = 50f;
/// <summary>Used when the hit surface has no bullet impact effect assigned.</summary>
[Property] public GameObject FallbackImpactEffect { get; set; }
/// <summary>Used when the hit surface has no bullet impact sound assigned.</summary>
[Property] public SoundEvent FallbackImpactSound { get; set; }
/// <summary>Plays instead of the surface sound when the bullet hits a player.</summary>
[Property] public SoundEvent FleshImpactSound { get; set; }
private Dictionary<string, float> HitboxDamageMultipliers { get; set; } = new()
{
["ankle_L"] = 0.6f,
["ankle_R"] = 0.6f,
["leg_lower_L"] = 0.6f,
["leg_lower_R"] = 0.6f,
["leg_upper_L"] = 0.6f,
["leg_upper_R"] = 0.6f,
["pelvis"] = 0.75f,
["spine_0"] = 1f,
["spine_1"] = 1f,
["spine_2"] = 1f,
["clavicle_L"] = 1f,
["clavicle_R"] = 1f,
["neck_0"] = 1f,
["head"] = 2f,
["arm_upper_L"] = 0.75f,
["arm_upper_R"] = 0.75f,
["arm_lower_L"] = 0.75f,
["arm_lower_R"] = 0.75f,
["hand_L"] = 0.6f,
["hand_R"] = 0.6f,
};
private float _deathTime;
private bool _stuck;
protected override void OnStart()
{
_deathTime = Time.Now + Lifetime;
}
protected override void OnUpdate()
{
if ( IsProxy )
return;
if ( Time.Now >= _deathTime )
{
GameObject.Destroy();
return;
}
if ( _stuck )
return;
Vector3 frameMovement = Velocity * Time.Delta;
Vector3 nextPosition = WorldPosition + frameMovement;
// Configure the ray trace path
var trace = Scene.Trace.Ray( WorldPosition, nextPosition )
.Size( 1f )
.UseHitboxes()
.WithoutTags("dead")
.IgnoreGameObject( GameObject ) // Ignore the bullet mesh itself
.IgnoreGameObject( WeaponObject )
.IgnoreGameObjectHierarchy( Player.CurrentPlayer.GameObject ) // Ignore the shooter's entire gun/avatar tree
.Run();
if ( trace.Hit )
{
HandleImpact( trace );
return;
}
WorldPosition = nextPosition;
}
private void HandleImpact( SceneTraceResult trace )
{
var health = trace.GameObject?.Components.Get<HealthComponent>( FindMode.InSelf );
var hitFlesh = health.IsValid();
if ( hitFlesh )
{
var damageMultiplier = HitboxDamageMultipliers[trace.Hitbox.Bone.Name];
var damage = Damage * damageMultiplier;
var headshot = trace.Hitbox.Bone.Name == "head";
var weaponIcon = WeaponObject?.Components.Get<InventoryItem>()?.ItemIconPath ?? "";
health.TakeDamage( damage, Vector3.Zero, headshot, weaponIcon );
}
else
{
ApplyPhysicsImpact( trace );
}
PlayImpactSound( trace, hitFlesh );
SpawnImpactEffect( trace );
if ( StayInWorld )
{
StickInWorld( trace );
return;
}
GameObject.Destroy();
}
private void StickInWorld( SceneTraceResult trace )
{
Velocity = Vector3.Zero;
WorldPosition = trace.HitPosition;
_stuck = true;
var player = trace.GameObject?.GetComponent<Player>();
if ( player.IsValid() )
{
var bone = player.Avatar.GetBoneObject( trace.Hitbox.Bone );
GameObject.SetParent( bone, true );
return;
}
if ( trace.GameObject.IsValid() )
GameObject.SetParent( trace.GameObject, true );
}
private void ApplyPhysicsImpact( SceneTraceResult trace )
{
if ( trace.Body is null )
return;
var impulse = Velocity.Normal * ImpactImpulse;
var networkTarget = FindNetworkTarget( trace.Body.GameObject );
if ( networkTarget.IsValid() && DeathmatchGame.Instance.IsValid() )
{
// One small targeted broadcast lets whichever peer controls this prop
// apply the impulse. No component is required on the hit object.
DeathmatchGame.Instance.ApplyProjectileImpulse( networkTarget, trace.HitPosition, impulse );
return;
}
// Non-networked physics cannot be addressed across peers, so preserve the
// useful local reaction without spending network bandwidth.
trace.Body.ApplyImpulseAt( trace.HitPosition, impulse );
}
private static GameObject FindNetworkTarget( GameObject hitObject )
{
for ( var current = hitObject; current.IsValid(); current = current.Parent )
{
if ( current.Network.Active )
return current;
}
return null;
}
private void SpawnImpactEffect( SceneTraceResult trace )
{
var prefab = GetImpactPrefab( trace );
if ( !prefab.IsValid() )
return;
var decal = GetImpactDecalPrefab( trace );
var effect = prefab.Clone( trace.HitPosition, Rotation.LookAt( -trace.Normal ) );
decal?.Clone( parent: effect, position: Vector3.Zero, rotation: Rotation.Identity, scale: Vector3.One );
var temporaryEffect = effect.GetComponent<TemporaryEffect>();
if ( temporaryEffect.IsValid() )
{
temporaryEffect.WaitForChildEffects = false;
temporaryEffect.DestroyAfterSeconds = 10f;
}
var player = trace.GameObject.GetComponent<Player>();
if ( player.IsValid() )
{
var bone = player.Avatar.GetBoneObject( trace.Hitbox.Bone );
effect.SetParent( bone, true );
}
else if ( trace.GameObject.IsValid() )
effect.SetParent( trace.GameObject, true );
effect.NetworkSpawn();
}
private GameObject GetImpactPrefab( SceneTraceResult trace )
{
var surface = trace.Surface;
if ( surface is not null )
{
var impactPrefab = surface.PrefabCollection.BulletImpact;
if ( impactPrefab.IsValid() )
return impactPrefab;
var baseSurface = surface.GetBaseSurface();
impactPrefab = baseSurface?.PrefabCollection.BulletImpact;
if ( impactPrefab.IsValid() )
return impactPrefab;
}
return FallbackImpactEffect;
}
private GameObject GetImpactDecalPrefab( SceneTraceResult trace )
{
var surface = trace.Surface;
if ( surface is not null )
{
var impactDecal = surface.PrefabCollection.BulletImpactDecal;
if ( impactDecal.IsValid() )
return impactDecal;
}
return null;
}
private void PlayImpactSound( SceneTraceResult trace, bool hitFlesh )
{
var sound = GetImpactSound( trace, hitFlesh );
if ( sound is null )
return;
var position = trace.HitPosition + trace.Normal * 2f;
if ( DeathmatchGame.Instance.IsValid() )
DeathmatchGame.Instance.PlayBulletImpactSound( sound, position );
else
Sound.Play( sound, position );
}
private SoundEvent GetImpactSound( SceneTraceResult trace, bool hitFlesh )
{
if ( hitFlesh && FleshImpactSound is not null )
return FleshImpactSound;
var surface = trace.Surface;
if ( surface is not null )
{
var bulletSound = surface.SoundCollection.Bullet;
if ( bulletSound is not null )
return bulletSound;
var baseSurface = surface.GetBaseSurface();
bulletSound = baseSurface?.SoundCollection.Bullet;
if ( bulletSound is not null )
return bulletSound;
}
return FallbackImpactSound;
}
}