Npcs/Combat/CombatEngageSchedule.cs

Schedule class for an NPC combat engage behavior. It sets up tasks to look at and move toward a visible target, fire the NPC weapon in bursts, optionally speak spot/taunt lines, wait between bursts, and then reposition to a flanking point; it cancels if target is no longer visible.

Networking
using Sandbox.Npcs.Tasks;

namespace Sandbox.Npcs.CombatNpc;

/// <summary>
/// Engages a visible player: close the gap, fire a burst, pause, then reposition to a flanking point.
/// Cancels immediately if the target leaves sight.
/// </summary>
public class CombatEngageSchedule : ScheduleBase
{
	private static readonly string[] SpotLines =
	{
		"Contact!",
		"There!",
		"I see you!",
		"Got one!",
		"Enemy spotted!",
		"Don't move!",
		"Found you.",
	};

	private static readonly string[] TauntLines =
	{
		"You're not getting away!",
		"Stay down!",
		"Take cover!",
		"Suppressing fire!",
		"Keep the pressure on!",
		"Don't let up!",
		"That's for my squad!",
		"You picked the wrong fight.",
	};

	/// <summary>
	/// The player to engage.
	/// </summary>
	public GameObject Target { get; set; }

	/// <summary>
	/// Weapon to fire. Should be a child component on the NPC's GameObject. Its NPC usage decides
	/// the engagement range, burst length and the rest between bursts.
	/// </summary>
	public BaseSandboxWeapon Weapon { get; set; }

	/// <summary>
	/// Speed the NPC moves when engaging.
	/// </summary>
	public float EngageSpeed { get; set; } = 180f;

	/// <summary>
	/// Radius around the current position to pick a flanking point.
	/// </summary>
	public float FlankRadius { get; set; } = 250f;

	public override int Priority => SchedulePriority.Combat;

	// Stay focused while fighting -- we end via ShouldCancel (lost sight) or when the
	// task sequence finishes, not because of incidental stimuli.
	public override NpcAwareness InterruptedBy => NpcAwareness.None;

	protected override void OnStart()
	{
		if ( !Target.IsValid() || !Weapon.IsValid() )
			return;

		// Set look target now so the NPC tracks the player through all tasks,
		// movement, firing, waiting, and repositioning.
		Npc.Animation.SetLookTarget( Target );

		// Spot the target on engage start
		if ( Npc.Speech.CanSpeak )
			AddTask( new Say( Game.Random.FromArray( SpotLines ), 1.5f, Target )
			{
				Tags = ["combat", "spot"],
				Priority = 2000
			} );

		AddTask( new LookAt( Target ) );

		// Advance to comfortably inside the weapon's reach, not right on the edge of it.
		AddTask( new MoveTo( Target, Weapon.Npc.MaxRange * 0.8f ) { Speed = EngageSpeed, FaceTarget = Target } );
		AddTask( new FireWeapon( Weapon, Target ) );

		// Rest between bursts, as the weapon dictates - with a random combat taunt sometimes
		var rest = Game.Random.Float( Weapon.Npc.RestMin, Weapon.Npc.RestMax );
		if ( Npc.Speech.CanSpeak && Game.Random.Float() < 0.4f )
			AddTask( new Say( Game.Random.FromArray( TauntLines ), rest, Target )
			{
				Tags = ["combat", "taunt"]
			} );
		else
			AddTask( new Wait( rest ) );

		AddTask( new MoveTo( GetFlankPosition(), 20f ) { Speed = EngageSpeed, FaceTarget = Target } );
	}

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

	protected override bool ShouldCancel()
	{
		if ( !Target.IsValid() || !Weapon.IsValid() )
			return true;

		return !Npc.Senses.VisibleTargets.Contains( Target );
	}

	/// <summary>
	/// Pick a random position perpendicular to the NPC→target axis at <see cref="FlankRadius"/>.
	/// Snaps to navmesh if possible.
	/// </summary>
	private Vector3 GetFlankPosition()
	{
		Vector3 toTarget = Target.IsValid()
			? (Target.WorldPosition - Npc.WorldPosition).WithZ( 0 ).Normal
			: Npc.WorldRotation.Forward;

		// Perpendicular + slight forward bias, randomized left/right
		var perp = new Vector3( -toTarget.y, toTarget.x, 0 );
		var side = Game.Random.Float() > 0.5f ? 1f : -1f;
		var flankDir = (perp * side + toTarget * 0.3f).WithZ( 0 ).Normal;
		var candidate = Npc.WorldPosition + flankDir * Game.Random.Float( FlankRadius * 0.5f, FlankRadius );

		if ( Npc.Scene.NavMesh.GetClosestPoint( candidate ) is { } nav )
			return nav;

		return candidate;
	}
}