NPC airborne and impact handling logic. Manages airborne state, detects collisions while in air, computes fall/crush damage, and spawns client-side blood decals for cosmetic splatter.
namespace Sandbox.Npcs;
public partial class Npc : Component.ICollisionListener
{
/// <summary>
/// Hitting something at or below this speed while airborne doesn't hurt - landing from a throw or
/// a fall, or being slammed into a wall.
/// </summary>
[Property, Group( "Impact Damage" )]
public float MaxSafeImpactSpeed { get; set; } = 350f;
/// <summary>
/// Hitting something at or above this speed is fatal.
/// </summary>
[Property, Group( "Impact Damage" )]
public float FatalImpactSpeed { get; set; } = 1100f;
/// <summary>
/// Off our feet - thrown, dropped, or dangling from a physgun. Only NPCs that walk on the navmesh;
/// ones that roll around on physics (rollermines) are never "airborne".
/// </summary>
public bool IsAirborne => _navAgent.IsValid() && !IsOnGround;
private TimeSince _timeSinceImpact;
private bool _wasAirborne;
private bool _wasCarried;
// Our velocity last frame in the air - by the time we land or hit something, it's already slowed us
private Vector3 _airVelocity;
/// <summary>
/// While airborne the animation falls with the body - the animgraph's in-air pose, driven by the
/// rigidbody rather than the (disabled) nav agent.
/// </summary>
private void UpdateAirborne()
{
var airborne = IsAirborne;
// The ground check catches us a few units up, a frame before the body touches down - so a
// landing often never reports a collision. Take the hit here, from how fast we were falling.
if ( isPickedUp )
_wasCarried = true;
if ( _wasAirborne && !airborne )
{
TakeImpact( MathF.Max( -_airVelocity.z, 0f ), WorldPosition, Vector3.Up, GroundObject, DamageTags.Fall );
if ( _wasCarried && Health >= 1f )
OnPutDown();
_wasCarried = false;
}
_wasAirborne = airborne;
Animation.Grounded = !airborne;
if ( !airborne || !_rigidbody.IsValid() )
return;
_airVelocity = _rigidbody.Velocity;
Animation.SetMove( _airVelocity, WorldRotation );
}
/// <summary>
/// Back on our feet after a physgun carried us - keep whichever way it turned us, rather than
/// snapping back to whatever we were looking at or doing before.
/// </summary>
protected virtual void OnPutDown()
{
Animation.ClearLookTarget();
EndCurrentSchedule();
}
/// <summary>
/// Hitting something hard while airborne - a wall we've been thrown into, or the floor if the
/// collision beats the ground check to it. Only the speed straight into the surface counts, so
/// skidding along it doesn't.
/// </summary>
void ICollisionListener.OnCollisionStart( Collision collision )
{
if ( IsProxy || !IsAirborne )
return;
var point = collision.Contact.Point;
var normal = collision.Contact.Normal;
var speed = MathF.Max( MathF.Abs( collision.Contact.NormalSpeed ), MathF.Abs( Vector3.Dot( _airVelocity, normal ) ) );
// Face the normal out of the surface, toward us
if ( Vector3.Dot( WorldPosition + Vector3.Up * 40f - point, normal ) < 0f )
normal = -normal;
// Hitting the floor is a fall, being slammed into a wall (or ceiling) is a crush
TakeImpact( speed, point, normal, collision.Other.GameObject, MathF.Abs( normal.z ) > 0.7f ? DamageTags.Fall : DamageTags.Crush );
}
/// <summary>
/// A hard hit hurts - harsher than a player's fall damage, being flung around by a physgun
/// should hurt. Nothing at or below <see cref="MaxSafeImpactSpeed"/>, fatal at <see cref="FatalImpactSpeed"/>.
/// </summary>
private void TakeImpact( float speed, Vector3 position, Vector3 normal, GameObject surface, string tag )
{
if ( Health < 1f || speed <= MaxSafeImpactSpeed || FatalImpactSpeed <= MaxSafeImpactSpeed )
return;
// One hit, however many ways it gets reported
if ( _timeSinceImpact < 0.25f )
return;
var amount = MathX.Remap( speed, MaxSafeImpactSpeed, FatalImpactSpeed, 0f, 100f );
if ( amount < 1f )
return;
_timeSinceImpact = 0;
// Before the damage - it might kill us, and then there's nobody to send it from
SplatterBlood( position, normal, surface, amount / 100f );
var damage = new DamageInfo( amount, null, null ) { Position = position };
damage.Tags.Add( tag );
(this as IDamageable).OnDamage( damage );
}
private static readonly string[] BloodDecals =
[
"decals/blood/blood_splatter_01.decal",
"decals/blood/blood_splatter_02.decal",
"decals/blood/blood_splatter_03.decal",
"decals/blood/blood_splatter_04.decal",
];
/// <summary>
/// Paint the surface we smacked into - a big splat where we hit, with droplets around it. Harder
/// hits make more mess. Purely cosmetic, so every client makes its own.
/// </summary>
[Rpc.Broadcast( NetFlags.HostOnly )]
private void SplatterBlood( Vector3 point, Vector3 normal, GameObject surface, float severity )
{
if ( Application.IsDedicatedServer )
return;
// A knock that only bruises doesn't bleed
severity = severity.Clamp( 0f, 1f );
if ( severity < 0.15f )
return;
var facing = Rotation.LookAt( normal );
// Splats on walls are half the size of the ones on the floor - a body lands flat on the floor,
// but only part of it hits a wall. On the floor the spatter stays close round the body too.
var floor = MathF.Abs( normal.z ) > 0.7f;
var size = floor ? 1f : 0.5f;
// The mark where we hit grows with the hit - a smear for a solid knock, body-sized for a brutal one
var main = PickBloodDecal();
if ( main is null )
return;
var mainScale = MathX.Lerp( 1f, 6f, severity ) * size;
SpawnBloodDecal( main, point, normal, surface, mainScale );
// Spatter only really flies on the violent ones - none on a moderate hit, a handful on a hard
// slam, the lot when it's near fatal - scattered anywhere out to a reach that grows with the
// hit. On the floor it stays close round the body; on a wall it's thrown further. Droplets can
// land over the main splat, but not on top of each other.
var mainRadius = BloodRadius( main, mainScale );
var reach = floor ? mainRadius * 0.8f + 16f * severity : mainRadius + 48f * severity;
var placed = new List<(Vector3 Position, float Radius)>();
var droplets = (int)(10f * severity * severity);
for ( var i = 0; i < droplets; i++ )
{
var definition = PickBloodDecal();
var scale = Game.Random.Float( 0.6f, 1.6f ) * MathX.Lerp( 0.6f, 1.2f, severity ) * size;
var radius = BloodRadius( definition, scale );
for ( var attempt = 0; attempt < 6; attempt++ )
{
var direction = facing * new Vector3( 0f, Game.Random.Float( -1f, 1f ), Game.Random.Float( -1f, 1f ) ).Normal;
var distance = reach * MathF.Sqrt( Game.Random.Float( 0.02f, 1f ) );
var spot = point + direction * distance;
if ( placed.Any( x => x.Position.Distance( spot ) < (x.Radius + radius) * 0.6f ) )
continue;
// Find the surface again, so droplets follow it round corners instead of hanging in the air
var tr = Scene.Trace.Ray( spot + normal * 8f, spot - normal * 16f )
.IgnoreGameObjectHierarchy( GameObject )
.WithoutTags( "npc", "player", "ragdoll" )
.Run();
if ( !tr.Hit )
continue;
SpawnBloodDecal( definition, tr.HitPosition, tr.Normal, tr.GameObject, scale );
placed.Add( (spot, radius) );
break;
}
}
}
private static DecalDefinition PickBloodDecal()
{
return ResourceLibrary.Get<DecalDefinition>( Game.Random.FromArray( BloodDecals ) );
}
// Roughly how far the visible blood reaches from the decal's centre - the textures have a clear
// border around the splat, so a bit under half the decal's width
private static float BloodRadius( DecalDefinition definition, float scale )
{
return definition is null ? 0f : definition.Width * scale * 0.35f;
}
private static void SpawnBloodDecal( DecalDefinition definition, Vector3 point, Vector3 normal, GameObject surface, float scale )
{
if ( definition is null )
return;
var go = new GameObject( true, "blood" );
go.Flags |= GameObjectFlags.NotNetworked | GameObjectFlags.NotSaved;
go.WorldPosition = point + normal;
go.WorldRotation = Rotation.LookAt( -normal );
// Stick to whatever we hit, so it moves with props
if ( surface.IsValid() )
go.SetParent( surface, true );
var decal = go.Components.Create<Decal>();
decal.Decals = [definition];
decal.Scale = scale;
decal.Rotation = Game.Random.Float( 0f, 360f );
decal.Transient = true;
}
}