Npcs/Schedules/FollowSchedule.cs

An NPC follow schedule that keeps an NPC at a respectful distance from a target. It computes distances to decide whether to back away, stand and watch, or move toward the target, and sets a look target while active.

using Sandbox.Npcs.Tasks;

namespace Sandbox.Npcs.Schedules;

/// <summary>
/// Follow a target around, keeping a respectful distance. Re-evaluates each cycle so it
/// tracks a moving target. Higher-priority schedules (like fleeing danger) preempt it.
/// </summary>
public sealed class FollowSchedule : ScheduleBase
{
	public GameObject Target { get; set; }

	/// <summary>
	/// How close we walk up to the target before stopping.
	/// </summary>
	public float FollowDistance { get; set; } = 200f;

	/// <summary>
	/// How far the target gets before we start walking again. The band between
	/// this and FollowDistance is "close enough" - without it, a crowd of
	/// followers endlessly jockeys and shoves to close the last few units.
	/// </summary>
	public float CatchUpDistance { get; set; } = 320f;

	/// <summary>
	/// If the target walks right up into our face, back off to arm's length --
	/// facing them and walking backwards, not turning our back on them.
	/// </summary>
	public float TooCloseDistance { get; set; } = 80f;

	// Everyone settles at a slightly different range, so a group forms loose
	// layers instead of fighting over the same ring around the target
	private readonly float _personalSpace = Game.Random.Float( 0f, 60f );

	public override int Priority => SchedulePriority.Ambient;

	protected override void OnStart()
	{
		if ( !Target.IsValid() )
		{
			AddTask( new Wait( 0.5f ) );
			return;
		}

		// Keep looking at whoever we're following. While we stand waiting, the body
		// turns to face them too, so we idle fronting our leader rather than
		// wherever we last walked.
		Npc.Animation.SetLookTarget( Target );

		var distance = Npc.WorldPosition.Distance( Target.WorldPosition );

		// They've walked into us - backpedal out of their face without turning away
		if ( distance < TooCloseDistance )
		{
			var away = (Npc.WorldPosition - Target.WorldPosition).WithZ( 0 ).Normal;
			if ( away.Length < 0.5f )
				away = Npc.WorldRotation.Backward.WithZ( 0 ).Normal;

			var backTo = Target.WorldPosition + away * (TooCloseDistance + 50f);
			AddTask( new MoveTo( backTo, 10f ) { FaceTarget = Target } );
			AddTask( new Wait( 0.25f ) );
			return;
		}

		// Close enough - stand and watch them rather than pushing in
		if ( distance <= CatchUpDistance + _personalSpace )
		{
			AddTask( new Wait( 0.5f ) );
			return;
		}

		AddTask( new MoveTo( Target, FollowDistance + _personalSpace ) );
		AddTask( new Wait( 0.5f ) );
	}

	protected override void OnEnd()
	{
		Npc.Animation.ClearLookTarget();
	}

	protected override bool ShouldCancel() => !Target.IsValid();
}