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.
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;
}
}