Conveyance/SimpleAnimationController.cs
using Sandbox;
using System.Collections.Generic;
/// <summary>
/// Lightweight roaming-hazard controller for non-citizen creatures (rhino, giant ants,
/// robogator, ...). AnimGraph-agnostic: it drives animation by Set()-ing a handful of named
/// parameters on the SkinnedModelRenderer, so it works with any creature rig — just point the
/// param-name fields at whatever your model actually exposes.
///
/// STATES
/// Hibernating → dormant. The NavMeshAgent is DISABLED (almost free to tick) and the
/// Hibernate anim param is set so the model can play a sleep/coiled/dormant
/// loop. Optionally the creature becomes a pushable physics prop while asleep.
/// Patrol → walk a fixed PatrolWaypoints route (if any waypoints are assigned).
/// Roam → wander to random navmesh points around HomeArea (when there are no waypoints).
/// Following → chase a target (a player who walked into the trigger, or a forced target).
/// Returning → after losing the target, head home, then re-hibernate or resume roaming.
///
/// WAKING
/// • A player entering TriggerZone wakes it and is chased (toggle: WakeOnPlayerInTrigger).
/// • Game code can call Wake() / WakeAndPursue(target) / Hibernate() directly — e.g. a
/// wanted-level escalation, the creature getting shot, or a scripted reveal.
/// The "switch to the navagent on wake" is literal: the agent is enabled and the body is
/// snapped onto the navmesh so it can path immediately.
///
/// DEPENDENCIES: a NavMeshAgent on this GameObject.
///
/// QUICK SETUP
/// 1. Add NavMeshAgent + this component to the creature GameObject.
/// 2. Assign BodyRenderer (the SkinnedModelRenderer on the mesh).
/// 3. (Optional) Assign TriggerZone — a trigger Collider on a child/sibling.
/// 4. (Optional) Assign PatrolWaypoints, or leave empty to free-roam around HomeArea.
/// 5. Match SpeedWalk/SpeedFollow and the anim param names to your model.
/// </summary>
[Title( "Simple Animal Controller" )]
[Category( "Game / NPC" )]
public sealed class SimpleAnimalController : Component
{
// ── Inspector ──────────────────────────────────────────────────────────
[Property] public SkinnedModelRenderer BodyRenderer { get; set; }
[Property] public NavMeshAgent Agent { get; set; }
[Header( "Activation" )]
/// <summary>Start dormant and wait to be woken (the usual case for an ambush hazard).</summary>
[Property] public bool StartHibernating { get; set; } = true;
/// <summary>A player entering the trigger zone wakes the creature and gets chased.</summary>
[Property] public bool WakeOnPlayerInTrigger { get; set; } = true;
/// <summary>After losing its target and returning home, drop back into hibernation.
/// If false, it keeps patrolling/roaming once woken until Hibernate() is called.</summary>
[Property] public bool ReHibernateWhenIdle { get; set; } = true;
[Header( "Trigger Zone" )]
[Property] public Collider TriggerZone { get; set; }
[Header( "Movement" )]
[Property, Range( 20f, 300f )] public float SpeedWalk { get; set; } = 80f;
[Property, Range( 20f, 600f )] public float SpeedFollow { get; set; } = 160f;
/// <summary>Min distance to maintain from the followed target.</summary>
[Property, Range( 30f, 200f )] public float FollowDistance { get; set; } = 80f;
[Header( "Patrol / Roam" )]
[Property] public List<GameObject> PatrolWaypoints { get; set; } = new();
[Property, Range( 0.5f, 5f )] public float WaypointPauseDuration { get; set; } = 1.5f;
/// <summary>Centre of the free-roam area (auto-set to spawn position if left at zero).</summary>
[Property] public Vector3 HomeArea { get; set; }
[Property, Range( 100f, 2000f )] public float RoamRadius { get; set; } = 500f;
[Header( "Physics While Hibernating" )]
/// <summary>Optional. If assigned and PhysicsWhileHibernating is on, this Rigidbody is
/// enabled while dormant (a pushable sleeping beast) and disabled while active so the
/// navmesh agent drives movement without the two fighting.</summary>
[Property] public Rigidbody Body { get; set; }
[Property] public bool PhysicsWhileHibernating { get; set; } = false;
[Header( "Animation Parameter Names" )]
[Property] public string AnimParamSpeed { get; set; } = "Speed";
[Property] public string AnimParamIsMoving { get; set; } = "IsMoving";
/// <summary>Bool set true while hibernating — drive a sleep/coiled/dormant loop off it.</summary>
[Property] public string AnimParamHibernate { get; set; } = "Hibernate";
// ── State ──────────────────────────────────────────────────────────────
private enum AnimalState { Hibernating, Patrol, Roam, Following, Returning }
private AnimalState _state;
private readonly HashSet<GameObject> _zoneOccupants = new();
private GameObject _followTarget;
private int _waypointIndex;
private float _pauseUntil;
private Vector3 _roamTarget;
private bool _hasRoamTarget;
public bool IsHibernating => _state == AnimalState.Hibernating;
public bool IsAwake => _state != AnimalState.Hibernating;
// ── Lifecycle ──────────────────────────────────────────────────────────
protected override void OnAwake()
{
Agent ??= Components.Get<NavMeshAgent>();
if ( Agent.IsValid() )
Agent.UpdateRotation = false; // we rotate manually
if ( HomeArea == Vector3.Zero )
HomeArea = WorldPosition;
if ( TriggerZone.IsValid() )
{
TriggerZone.OnTriggerEnter += OnTriggerEntered;
TriggerZone.OnTriggerExit += OnTriggerExited;
}
if ( StartHibernating )
EnterHibernate();
else
EnterActive();
}
protected override void OnUpdate()
{
switch ( _state )
{
case AnimalState.Hibernating: break; // dormant — nothing to tick
case AnimalState.Patrol: UpdatePatrol(); break;
case AnimalState.Roam: UpdateRoam(); break;
case AnimalState.Following: UpdateFollowing(); break;
case AnimalState.Returning: UpdateReturning(); break;
}
UpdateAnimation();
if ( IsAwake )
UpdateFacing();
}
// ── Public activation API ────────────────────────────────────────────────
/// <summary>Wake the creature; it will patrol (if it has waypoints) or free-roam.</summary>
public void Wake()
{
if ( IsAwake ) return;
EnterActive();
}
/// <summary>Wake and immediately chase a specific target (e.g. whoever just shot it).</summary>
public void WakeAndPursue( GameObject target )
{
_followTarget = target;
EnterActive();
_state = AnimalState.Following;
}
/// <summary>Force the creature back to sleep right now.</summary>
public void Hibernate() => EnterHibernate();
// Back-compat with the previous API.
public void FollowTarget( GameObject target ) => WakeAndPursue( target );
public void StopFollowing()
{
_followTarget = null;
_state = ReHibernateWhenIdle
? AnimalState.Returning
: ( PatrolWaypoints.Count > 0 ? AnimalState.Patrol : AnimalState.Roam );
}
// ── State entry ────────────────────────────────────────────────────────
private void EnterActive()
{
// Switch from "physics prop" to "navmesh agent": drop physics, snap to the mesh, enable.
if ( PhysicsWhileHibernating && Body.IsValid() )
Body.Enabled = false;
if ( Agent.IsValid() )
{
if ( Scene.NavMesh is not null && Scene.NavMesh.GetClosestPoint( WorldPosition ) is { } navPos )
WorldPosition = navPos;
Agent.Enabled = true;
}
_hasRoamTarget = false;
_state = PatrolWaypoints.Count > 0 ? AnimalState.Patrol : AnimalState.Roam;
}
private void EnterHibernate()
{
_followTarget = null;
_hasRoamTarget = false;
if ( Agent.IsValid() )
{
Agent.Stop();
Agent.Enabled = false;
}
if ( PhysicsWhileHibernating && Body.IsValid() )
Body.Enabled = true;
_state = AnimalState.Hibernating;
}
// ── State behaviours ───────────────────────────────────────────────────
private void UpdatePatrol()
{
if ( PatrolWaypoints.Count == 0 ) { _state = AnimalState.Roam; return; }
if ( !Agent.IsValid() ) return;
if ( Time.Now < _pauseUntil ) return;
var wp = PatrolWaypoints[_waypointIndex];
if ( !wp.IsValid() )
{
_waypointIndex = (_waypointIndex + 1) % PatrolWaypoints.Count;
return;
}
var target = wp.WorldPosition;
if ( WorldPosition.Distance( target ) < 30f )
{
_pauseUntil = Time.Now + WaypointPauseDuration;
_waypointIndex = (_waypointIndex + 1) % PatrolWaypoints.Count;
Agent.Stop();
}
else
{
Agent.MaxSpeed = SpeedWalk;
Agent.MoveTo( target );
}
}
private void UpdateRoam()
{
if ( !Agent.IsValid() ) return;
if ( Time.Now < _pauseUntil ) return;
if ( !_hasRoamTarget )
{
var dir = Vector3.Random.WithZ( 0 ).Normal;
var candidate = HomeArea + dir * Game.Random.Float( RoamRadius * 0.3f, RoamRadius );
_roamTarget = Scene.NavMesh is not null && Scene.NavMesh.GetClosestPoint( candidate ) is { } nav
? nav
: candidate;
_hasRoamTarget = true;
Agent.MaxSpeed = SpeedWalk;
Agent.MoveTo( _roamTarget );
return;
}
if ( WorldPosition.Distance( _roamTarget ) < 40f )
{
_hasRoamTarget = false;
_pauseUntil = Time.Now + WaypointPauseDuration;
Agent.Stop();
}
}
private void UpdateFollowing()
{
if ( !_followTarget.IsValid() )
{
_state = AnimalState.Returning;
return;
}
if ( !Agent.IsValid() ) return;
Agent.MaxSpeed = SpeedFollow;
if ( WorldPosition.Distance( _followTarget.WorldPosition ) > FollowDistance + 20f )
Agent.MoveTo( _followTarget.WorldPosition );
else
Agent.Stop();
}
private void UpdateReturning()
{
if ( !Agent.IsValid() ) { EnterHibernate(); return; }
// A new target showed up while we were heading home.
if ( _followTarget.IsValid() )
{
_state = AnimalState.Following;
return;
}
var home = PatrolWaypoints.Count > 0 && PatrolWaypoints[_waypointIndex].IsValid()
? PatrolWaypoints[_waypointIndex].WorldPosition
: HomeArea;
if ( WorldPosition.Distance( home ) < 40f )
{
if ( ReHibernateWhenIdle )
EnterHibernate();
else
_state = PatrolWaypoints.Count > 0 ? AnimalState.Patrol : AnimalState.Roam;
return;
}
Agent.MaxSpeed = SpeedWalk;
Agent.MoveTo( home );
}
// ── Animation ──────────────────────────────────────────────────────────
private void UpdateAnimation()
{
if ( BodyRenderer is null ) return;
var speed = Agent.IsValid() ? Agent.Velocity.Length : 0f;
var moving = speed > 5f;
if ( !string.IsNullOrEmpty( AnimParamSpeed ) )
BodyRenderer.Set( AnimParamSpeed, speed );
if ( !string.IsNullOrEmpty( AnimParamIsMoving ) )
BodyRenderer.Set( AnimParamIsMoving, moving );
if ( !string.IsNullOrEmpty( AnimParamHibernate ) )
BodyRenderer.Set( AnimParamHibernate, IsHibernating );
}
private void UpdateFacing()
{
if ( !Agent.IsValid() ) return;
var vel = Agent.Velocity;
if ( vel.LengthSquared < 1f ) return;
var targetRot = Rotation.LookAt( vel.Normal, Vector3.Up );
WorldRotation = Rotation.Lerp( WorldRotation, targetRot, Time.Delta * 8f );
}
// ── Trigger callbacks ────────────────────────────────────────────────────
private void OnTriggerEntered( Collider other )
{
if ( !WakeOnPlayerInTrigger ) return;
if ( !other.Tags.Has( "player" ) ) return;
_zoneOccupants.Add( other.GameObject );
_followTarget = other.GameObject;
if ( IsHibernating )
EnterActive();
_state = AnimalState.Following;
}
private void OnTriggerExited( Collider other )
{
_zoneOccupants.Remove( other.GameObject );
// Someone else still in the zone? Chase them instead.
var next = AnyOccupant();
if ( next.IsValid() )
{
_followTarget = next;
if ( IsAwake )
_state = AnimalState.Following;
return;
}
_followTarget = null;
if ( IsAwake )
{
_state = ReHibernateWhenIdle
? AnimalState.Returning
: ( PatrolWaypoints.Count > 0 ? AnimalState.Patrol : AnimalState.Roam );
}
}
private GameObject AnyOccupant()
{
foreach ( var go in _zoneOccupants )
{
if ( go.IsValid() )
return go;
}
return null;
}
}