Npcs/Combat/CombatEngageSchedule.cs
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.
	/// </summary>
	public BaseWeapon Weapon { get; set; }

	/// <summary>
	/// Distance at which the NPC stops advancing and begins shooting.
	/// </summary>
	public float AttackRange { get; set; } = 300f;

	/// <summary>
	/// How long each shooting burst lasts.
	/// </summary>
	public float BurstDuration { get; set; } = 1.5f;

	/// <summary>
	/// Pause between burst end and repositioning.
	/// </summary>
	public float BurstPause { get; set; } = 0.8f;

	/// <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;

	protected override void OnStart()
	{
		Npc.Navigation.WishSpeed = EngageSpeed;

		// 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 ) );

		AddTask( new LookAt( Target ) );
		AddTask( new MoveTo( Target, AttackRange ) );
		AddTask( new FireWeapon( Weapon, Target, BurstDuration ) );

		// Random combat taunt during the pause after firing
		if ( Npc.Speech.CanSpeak && Game.Random.Float() < 0.4f )
			AddTask( new Say( Game.Random.FromArray( TauntLines ), BurstPause ) );
		else
			AddTask( new Wait( BurstPause ) );

		AddTask( new MoveTo( GetFlankPosition(), 20f ) );
	}

	protected override void OnEnd()
	{
		Npc.Navigation.WishSpeed = 100f;
		Npc.Animation.ClearLookTarget();
	}

	protected override bool ShouldCancel()
	{
		if ( !Target.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;
	}
}