AnimationLayer component for NPCs. Manages animation parameters, look-at (eyes/head/body) logic, aim smoothing/clamping, body turning, footstep sound events, and synchronised movement/look state replication to clients.
using Sandbox.Citizen;
namespace Sandbox.Npcs.Layers;
/// <summary>
/// Provides animation parameters and helpers for behaviors.
/// Also handles look-at (eyes/head) and body turning via animator parameters.
/// Synced properties replicate animation state to all clients.
/// </summary>
public sealed partial class AnimationLayer : BaseNpcLayer
{
public float Speed { get; set; } = 1.0f;
public bool IsGrounded { get; set; } = true;
/// <summary>
/// How fast the body turns to face a look target, in degrees per second.
/// Constant rate - an exponential turn covers most of a half-circle in its
/// first moments, which reads as a jolting spin.
/// </summary>
public float BodyTurnSpeed { get; set; } = 120f;
public float MaxHeadAngle { get; set; } = 45f;
public float AimStrengthEyes { get; set; } = 1.0f;
public float AimStrengthHead { get; set; } = 1.0f;
public float AimStrengthBody { get; set; } = 1.0f;
/// <summary>How far the head can aim away from the body, in degrees of yaw.</summary>
public float MaxAimYaw { get; set; } = 80f;
/// <summary>How quickly the head sweeps onto (and lets go of) a look target.</summary>
public float AimSmoothSpeed { get; set; } = 6f;
/// <summary>Play footstep sounds from the model's footstep animation events.</summary>
[Property]
public bool EnableFootsteps { get; set; } = true;
/// <summary>Overall footstep volume multiplier.</summary>
[Property]
public float FootstepVolume { get; set; } = 1f;
private TimeSince _timeSinceStep;
/// <summary>
/// Current world-space target the Npc is looking at (if any). Host-only.
/// </summary>
public Vector3? LookTarget { get; private set; }
/// <summary>
/// The GameObject being tracked as the look target, if any. Host-only.
/// </summary>
public GameObject LookTargetObject { get; private set; }
// A temporary look target that overrides the persistent one until it expires,
// like HL2's AddLookTarget. Speech uses this to look whoever we're talking to
// in the eyes for the duration of the line.
private GameObject _addedLookTarget;
private TimeUntil _addedLookExpires;
private SkinnedModelRenderer _renderer => Npc.IsValid() ? Npc.Renderer : null;
private float _lastYaw = float.NaN;
private bool _turningToTarget;
// The aim we're actually feeding the animgraph. Look targets appear out of
// nowhere (senses noticing someone) and jump around; the neck eases toward
// them and releases the same way, instead of teleporting.
private Vector3 _aimDirection;
private float _aimWeight;
// Which side the head swung for a target directly behind us. Straight behind,
// the shortest way round is ambiguous and noise flips it left/right every
// frame -- pick a side and stay on it until the target comes back around.
private int _aimSide;
[Sync] public Vector3 MoveVelocity { get; set; }
[Sync] public Rotation MoveRotation { get; set; }
[Sync] public bool Grounded { get; set; } = true;
[Sync] public Vector3 LookWorldPos { get; set; }
[Sync] public bool IsLooking { get; set; }
[Sync] public string HoldType { get; set; } = "none";
// The object being looked at, if the look target is an object. Synced so every
// client resolves the eye position locally - that way the player being looked
// at sees the NPC looking at their camera, while everyone else sees it looking
// at that player's avatar's eyes.
[Sync] public GameObject LookObject { get; set; }
protected override void OnEnabled()
{
_lastYaw = float.NaN;
// Footstep animation events fire on every client, so each hears them locally (like the player).
if ( _renderer.IsValid() )
{
_renderer.OnFootstepEvent -= OnFootstepEvent;
_renderer.OnFootstepEvent += OnFootstepEvent;
}
}
protected override void OnDisabled()
{
if ( _renderer.IsValid() )
_renderer.OnFootstepEvent -= OnFootstepEvent;
}
// Play a footstep when the model's animation hits a footstep event -- same as the player:
// find the surface underfoot and play its footstep sound, scaled by speed.
private void OnFootstepEvent( SceneModel.FootstepEvent e )
{
if ( !EnableFootsteps ) return;
if ( _timeSinceStep < 0.2f ) return;
var volume = e.Volume * MoveVelocity.Length.Remap( 0, 400, 0, 1 );
if ( volume <= 0.1f ) return;
_timeSinceStep = 0;
PlayFootstepSound( e.Transform.Position, volume, e.FootId );
}
private void PlayFootstepSound( Vector3 position, float volume, int foot )
{
var trace = Scene.Trace
.Ray( position + Vector3.Up * 20f, position - Vector3.Up * 20f )
.IgnoreGameObjectHierarchy( Npc.GameObject )
.Run();
if ( !trace.Hit || trace.Surface is null )
return;
var soundEvent = foot == 0
? trace.Surface.SoundCollection.FootLeft
: trace.Surface.SoundCollection.FootRight;
if ( soundEvent is null )
return;
var handle = GameObject.PlaySound( soundEvent, 0 );
if ( !handle.IsValid() )
return;
handle.FollowParent = false;
handle.Volume *= volume * FootstepVolume;
}
protected override void OnUpdate()
{
if ( !IsProxy )
{
// A timed look target wins while it's active (eg speech looking at
// whoever we're talking to), then we fall back to the persistent one
LookObject = _addedLookTarget.IsValid() && !_addedLookExpires
? _addedLookTarget
: LookTargetObject;
if ( LookTargetObject.IsValid() )
LookTarget = GetEyePosition( LookTargetObject );
var lookPos = LookObject.IsValid() ? (Vector3?)GetEyePosition( LookObject ) : LookTarget;
IsLooking = lookPos.HasValue;
if ( lookPos.HasValue )
{
LookWorldPos = lookPos.Value;
UpdateLookDirection( lookPos.Value );
}
else
{
// No target - release the head, or it stays stuck wherever the
// last glance left it
ClearAim();
}
if ( _heldProp.IsValid() )
UpdateHeldPropIk();
}
else
{
// Resolve object targets locally, so this client's own view of the
// target (first person camera vs avatar eyes) is what gets looked at
if ( LookObject.IsValid() )
ApplyLookToRenderer( GetEyePosition( LookObject ) );
else if ( IsLooking )
ApplyLookToRenderer( LookWorldPos );
else
ClearAim();
}
ApplyMoveToRenderer( MoveVelocity, MoveRotation );
if ( !string.IsNullOrEmpty( HoldType ) )
_renderer?.Set( "holdtype", HoldType );
_renderer?.Set( "b_grounded", Grounded );
if ( DebugJitter )
DrawJitterDebug();
}
/// <summary>
/// Shows what's feeding the head every frame, to hunt down rhythmic jitter.
/// Watch which value pulses: body yaw (someone rotating the object), aim
/// (look target moving or flickering), or move params (animgraph input).
/// </summary>
[ConVar( "npc_debug_jitter" )]
public static bool DebugJitter { get; set; }
float _dbgLastBodyYaw = float.NaN;
Vector3 _dbgLastAimDir;
bool _dbgLastHadAim;
TimeSince _dbgLastYawSpike;
TimeSince _dbgLastAimSpike;
TimeSince _dbgLastAimToggle;
private void DrawJitterDebug()
{
if ( !Npc.IsValid() || !_renderer.IsValid() )
return;
var camera = Npc.Scene.Camera;
if ( !camera.IsValid() ) return;
// Body rotation changes
var yaw = Npc.WorldRotation.Angles().yaw;
var yawDelta = float.IsNaN( _dbgLastBodyYaw ) ? 0f : MathF.Abs( Angles.NormalizeAngle( yaw - _dbgLastBodyYaw ) );
_dbgLastBodyYaw = yaw;
if ( yawDelta > 0.1f ) _dbgLastYawSpike = 0;
// Aim direction changes and set/released flicker
var hasAim = LookObject.IsValid() || IsLooking;
var aimDir = hasAim ? (LookWorldPos - Npc.WorldPosition).Normal : Npc.WorldRotation.Forward;
var aimDelta = Vector3.GetAngle( _dbgLastAimDir, aimDir );
_dbgLastAimDir = aimDir;
if ( aimDelta > 1f ) _dbgLastAimSpike = 0;
if ( hasAim != _dbgLastHadAim ) _dbgLastAimToggle = 0;
_dbgLastHadAim = hasAim;
var worldPos = Npc.WorldPosition + Vector3.Up * 90f;
var screenPos = camera.PointToScreenPixels( worldPos, out var behind );
if ( behind ) return;
var text = TextRendering.Scope.Default;
text.Text =
$"yaw {yaw:F1} d {yawDelta:F2} spike {_dbgLastYawSpike.Relative:F2}s ago\n" +
$"aim {(hasAim ? "on" : "off")} d {aimDelta:F2} spike {_dbgLastAimSpike.Relative:F2}s ago toggle {_dbgLastAimToggle.Relative:F1}s ago\n" +
$"vel {MoveVelocity.Length:F1} turning {_turningToTarget}";
text.FontSize = 12;
text.TextColor = Color.Yellow;
Npc.DebugOverlay.ScreenText( screenPos, text, TextFlag.CenterBottom );
}
/// <summary>
/// Look at a target for a limited time, overriding the persistent look target.
/// The NPC aims at the target's eyes. Call again to extend - when it expires we
/// fall back to the persistent target, if any.
/// </summary>
public void AddLookTarget( GameObject target, float duration )
{
_addedLookTarget = target;
_addedLookExpires = duration;
}
/// <summary>
/// Where a GameObject's eyes are - the "eyes" attachment if it has a model with
/// one (players, NPCs), otherwise roughly head height above its position.
/// The local player in first person is really "at" their camera, so on their
/// own screen we look straight down the lens.
/// </summary>
public static Vector3 GetEyePosition( GameObject go )
{
if ( !go.IsValid() )
return default;
var controller = go.GetComponentInChildren<PlayerController>();
if ( controller.IsValid() && !controller.IsProxy && !controller.ThirdPerson && go.Scene?.Camera.IsValid() == true )
return go.Scene.Camera.WorldPosition;
var renderer = go.GetComponentInChildren<SkinnedModelRenderer>();
if ( renderer.IsValid() && renderer.GetAttachment( "eyes" ) is { } eyes )
return eyes.Position;
return go.WorldPosition + Vector3.Up * 60f;
}
/// <summary>
/// Where this NPC looks from.
/// </summary>
private Vector3 GetOwnEyePosition()
{
if ( _renderer.IsValid() && _renderer.GetAttachment( "eyes" ) is { } eyes )
return eyes.Position;
return Npc.WorldPosition + Vector3.Up * 60f;
}
/// <summary>
/// Set a persistent look target that tracks a GameObject each frame.
/// </summary>
public void SetLookTarget( GameObject target )
{
LookTargetObject = target;
LookTarget = target.IsValid() ? target.WorldPosition : null;
}
/// <summary>
/// Set a persistent look target at a fixed world position.
/// </summary>
public void SetLookTarget( Vector3 target )
{
LookTargetObject = null;
LookTarget = target;
}
/// <summary>
/// Clear the persistent look target. The NPC will stop tracking.
/// </summary>
public void ClearLookTarget()
{
LookTargetObject = null;
LookTarget = null;
LookObject = null;
IsLooking = false;
ClearAim();
}
// Release the aim back to the animation - ease the weight off toward body
// forward rather than dropping it, so the head settles instead of popping.
// Called every frame while there's no target, which is what drives the ease.
private void ClearAim()
{
if ( _aimWeight <= 0.01f )
{
ResetAim();
return;
}
if ( Npc.IsValid() )
ApplyAim( Npc.WorldRotation.Forward, 0f );
}
// Hard-drop the aim with no easing. Uses body forward rather than a zero
// vector - zero isn't a direction, and the graph does strange things with it.
private void ResetAim()
{
_aimWeight = 0f;
_aimSide = 0;
if ( !_renderer.IsValid() || !Npc.IsValid() )
return;
var forward = Npc.WorldRotation.Forward;
_aimDirection = forward;
_renderer.SetLookDirection( "aim_eyes", forward, 0f );
_renderer.SetLookDirection( "aim_head", forward, 0f );
_renderer.SetLookDirection( "aim_body", forward, 0f );
}
/// <summary>
/// Ease the applied aim toward a desired direction and weight, clamped to what
/// a neck can actually do. Every look path feeds through here, so the head
/// sweeps onto targets instead of snapping, and never flip-flops on a target
/// directly behind us.
/// </summary>
private void ApplyAim( Vector3 desiredDirection, float desiredWeight )
{
if ( !_renderer.IsValid() || !Npc.IsValid() )
return;
desiredDirection = ClampAimYaw( desiredDirection );
// Starting to look from rest - sweep out from where the head naturally sits
if ( _aimWeight <= 0.01f && desiredWeight > 0f )
_aimDirection = Npc.WorldRotation.Forward;
var t = 1f - MathF.Exp( -AimSmoothSpeed * Time.Delta );
_aimDirection = Vector3.Slerp( _aimDirection, desiredDirection, t, clamp: false ).Normal;
_aimWeight = _aimWeight.LerpTo( desiredWeight, t );
_renderer.SetLookDirection( "aim_eyes", _aimDirection, AimStrengthEyes * _aimWeight );
_renderer.SetLookDirection( "aim_head", _aimDirection, AimStrengthHead * _aimWeight );
_renderer.SetLookDirection( "aim_body", _aimDirection, AimStrengthBody * _aimWeight );
}
// Keep the aim within reach of the neck. A target beyond MaxAimYaw pins the
// head at the limit on that side while the body turns to catch up; directly
// behind, the side is ambiguous, so we stick with the one we already chose.
private Vector3 ClampAimYaw( Vector3 direction )
{
var forward = Npc.WorldRotation.Forward.WithZ( 0 ).Normal;
var flat = direction.WithZ( 0 );
if ( flat.Length < 0.001f )
return direction;
var flatNormal = flat.Normal;
var yaw = MathF.Atan2( forward.Cross( flatNormal ).z, forward.Dot( flatNormal ) ).RadianToDegree();
if ( MathF.Abs( yaw ) > 150f )
{
if ( _aimSide == 0 )
_aimSide = yaw >= 0f ? 1 : -1;
yaw = _aimSide * MaxAimYaw;
}
else
{
_aimSide = yaw >= 0f ? 1 : -1;
yaw = Math.Clamp( yaw, -MaxAimYaw, MaxAimYaw );
}
var clampedFlat = Rotation.FromAxis( Vector3.Up, yaw ) * forward * flat.Length;
return (clampedFlat + Vector3.Up * direction.z).Normal;
}
/// <summary>
/// Command this layer to look at a target (one-shot, no tracking).
/// </summary>
public void LookAt( Vector3 target ) => LookTarget = target;
/// <summary>Stop looking.</summary>
public void StopLooking() => ClearLookTarget();
/// <summary>
/// Returns true if the NPC body is facing the current look target within MaxHeadAngle.
/// </summary>
public bool IsFacingTarget()
{
if ( !LookTarget.HasValue ) return true;
if ( _renderer is null ) return true;
var direction = (LookTarget.Value.WithZ( 0 ) - Npc.WorldPosition.WithZ( 0 )).Normal;
var angleToTarget = Vector3.GetAngle( Npc.WorldRotation.Forward.WithZ( 0 ), direction );
return angleToTarget <= MaxHeadAngle;
}
private void UpdateLookDirection( Vector3 targetPosition )
{
if ( _renderer is null ) return;
// Aim eyes and head from our eyes, so we meet the target's gaze rather
// than tilting at them from our feet
var fullDirection = (targetPosition - GetOwnEyePosition()).Normal;
var flatDirection = (targetPosition - Npc.WorldPosition).WithZ( 0 ).Normal;
ApplyAim( fullDirection, 1f );
// While travelling, NavigationLayer faces the body along the movement direction, so the
// look-at just tracks with the head/eyes -- turning the body too would make it run
// sideways. Standing still (or strafing, in combat) the body is ours to turn.
if ( Npc.Navigation.IsValid() && Npc.Navigation.FaceMovementDirection && Npc.Navigation.IsMoving )
return;
var angleToTarget = Vector3.GetAngle( Npc.WorldRotation.Forward, flatDirection );
// Hysteresis: start turning when the target is beyond what the head can
// reach, and keep turning until we're comfortably facing it. If we stop
// exactly at the threshold, the turn-in-place animation nudges the body
// back across it and we shuffle-step forever - a rhythmic head jitter.
if ( angleToTarget > MaxHeadAngle )
_turningToTarget = true;
else if ( angleToTarget < MaxHeadAngle * 0.5f )
_turningToTarget = false;
if ( _turningToTarget )
{
var targetRotation = Rotation.LookAt( flatDirection, Vector3.Up );
var remaining = Npc.WorldRotation.Distance( targetRotation );
if ( remaining > 0.1f )
{
var step = MathF.Min( 1f, BodyTurnSpeed * Time.Delta / remaining );
Npc.GameObject.WorldRotation = Rotation.Slerp( Npc.WorldRotation, targetRotation, step );
}
}
}
private void ApplyLookToRenderer( Vector3 lookWorldPos )
{
if ( !_renderer.IsValid() || !Npc.IsValid() ) return;
var fullDirection = (lookWorldPos - GetOwnEyePosition()).Normal;
ApplyAim( fullDirection, 1f );
}
public void SetAim( Vector3 direction )
{
_renderer?.SetLookDirection( "aim_eyes", direction, AimStrengthEyes );
_renderer?.SetLookDirection( "aim_head", direction, AimStrengthHead );
_renderer?.SetLookDirection( "aim_body", direction, AimStrengthBody );
}
public void SetHead( Vector3 direction ) => _renderer?.SetLookDirection( "aim_head", direction, AimStrengthHead );
public void SetEyes( Vector3 direction ) => _renderer?.SetLookDirection( "aim_eyes", direction, AimStrengthEyes );
/// <summary>
/// Records move state for replication. Called by NavigationLayer on the host.
/// All clients apply this each frame in OnUpdate.
/// </summary>
public void SetMove( Vector3 velocity, Rotation reference )
{
MoveVelocity = velocity;
MoveRotation = reference;
}
private void ApplyMoveToRenderer( Vector3 velocity, Rotation reference )
{
if ( _renderer is null ) return;
if ( reference.w == 0f ) return;
var forward = reference.Forward.Dot( velocity );
var sideward = reference.Right.Dot( velocity );
var angle = MathF.Atan2( sideward, forward ).RadianToDegree().NormalizeDegrees();
var yaw = reference.Angles().yaw.NormalizeDegrees();
float rotationSpeed = 0f;
if ( float.IsNaN( _lastYaw ) )
{
_lastYaw = yaw;
}
else
{
var deltaYaw = Angles.NormalizeAngle( yaw - _lastYaw );
rotationSpeed = Time.Delta > 0f ? MathF.Abs( deltaYaw ) / Time.Delta : 0f;
_lastYaw = yaw;
}
_renderer.Set( "move_direction", angle );
_renderer.Set( "move_speed", velocity.Length );
_renderer.Set( "move_groundspeed", velocity.WithZ( 0 ).Length );
_renderer.Set( "move_y", sideward );
_renderer.Set( "move_x", forward );
_renderer.Set( "move_z", velocity.z );
_renderer.Set( "speed_move", Speed );
_renderer.Set( "move_rotationspeed", rotationSpeed );
}
/// <summary>
/// Broadcasts the attack trigger to all clients so the animation plays everywhere.
/// </summary>
[Rpc.Broadcast]
public void TriggerAttack()
{
_renderer?.Set( "b_attack", true );
}
/// <summary>
/// Sets the holdtype so the NPC poses its arms for the held item - an option name on the
/// animgraph's holdtype enum (e.g. "pistol"). Synced to all clients via HoldType.
/// </summary>
public void SetHoldType( string holdType )
{
HoldType = string.IsNullOrEmpty( holdType ) ? "none" : holdType;
}
public override void ResetLayer()
{
if ( _renderer is null ) return;
IsGrounded = false;
Speed = 1.0f;
LookTarget = null;
LookTargetObject = null;
LookObject = null;
IsLooking = false;
MoveVelocity = default;
HoldType = "none";
_lastYaw = float.NaN;
ClearHeldProp();
_renderer.Set( "b_attack", false );
_renderer.Set( "holdtype", "none" );
_renderer.Set( "move_speed", 0f );
_renderer.Set( "move_groundspeed", 0f );
_renderer.Set( "move_y", 0f );
_renderer.Set( "move_x", 0f );
_renderer.Set( "move_z", 0f );
_renderer.Set( "b_grounded", false );
_renderer.Set( "speed_move", 1f );
_renderer.Set( "move_rotationspeed", 0f );
ResetAim();
}
}