Npcs/Combat/CombatNpc.cs
using Sandbox.Npcs.Schedules;
namespace Sandbox.Npcs.CombatNpc;
/// <summary>
/// A combat NPC that searches for players, advances on them, fires in bursts, and repositions.
/// When friendly, follows players and engages hostile NPCs instead.
/// </summary>
public class CombatNpc : Npc, Component.IDamageable
{
private static readonly string[] PainLines =
{
"Argh!",
"They got me!",
"I'm hit!",
"Taking fire!",
"Ugh!",
};
private static readonly string[] DeathLines =
{
"Tell them... I fought...",
"Not like this...",
"I can't...",
};
/// <summary>
/// When true, this NPC is friendly to players and will follow them, engaging hostile NPCs.
/// When false, this NPC targets players and friendly NPCs.
/// </summary>
[Property, ClientEditable, Sync]
public bool Friendly { get; set; } = false;
[Property, ClientEditable, Range( 1, 250 ), Sync]
public float Health { get; set; } = 100f;
/// <summary>
/// The weapon this NPC uses to attack.
/// </summary>
[Property]
public BaseWeapon Weapon { get; set; }
[Property, Group( "Balance" ), Range( 512, 4096 ), Step( 1 ), ClientEditable, Sync]
public float AttackRange { get; set; } = 1024f;
[Property, Group( "Balance" ), Range( 90, 250f ), Step( 1 ), ClientEditable, Sync]
public float EngageSpeed { get; set; } = 180f;
/// <summary>
/// How long after losing sight of a player to keep searching their last known position.
/// </summary>
[Property, Group( "Balance" )]
public float SearchTimeout { get; set; } = 8f;
[Property, Group( "Balance" )]
public float PatrolRadius { get; set; } = 400f;
[Property, Group( "Balance" )]
public float BurstDuration { get; set; } = 1.5f;
[Property, Group( "Balance" )]
public float BurstPause { get; set; } = 0.8f;
/// <summary>
/// How far a friendly NPC will follow a player before stopping.
/// </summary>
[Property, Group( "Balance" )]
public float FollowDistance { get; set; } = 150f;
/// <summary>
/// When true, the weapon never dry-fires from running out of ammo. NPCs have no ammo
/// inventory to seed a reserve pool, so without this a BaseWeapon's TakeAmmo() fails and
/// the NPC silently never shoots. Leave ON for cops; control rate-of-fire via the weapon's
/// FireRate and the engage schedule's BurstDuration / BurstPause instead.
/// </summary>
[Property, Group( "Balance" )]
public bool InfiniteAmmo { get; set; } = true;
/// <summary>
/// A specific target this NPC prioritises hunting (e.g. the wanted criminal set by
/// <c>PoliceDispatcher</c>). If visible it is engaged ahead of nearer hostiles; the NPC
/// still engages anyone else it encounters. Set at runtime, not in the inspector.
/// </summary>
public GameObject PriorityTarget { get; set; }
/// <summary>
/// When true, an out-of-sight <see cref="PriorityTarget"/>'s live position is fed in as
/// fresh "last known" intel each tick, so the NPC actively closes on the suspect
/// ("dispatch radioing the location"). Turn off for a less omniscient hunt.
/// </summary>
[Property, Group( "Balance" )]
public bool DispatchTracksPriority { get; set; } = true;
private Vector3? _lastKnownPosition;
private TimeSince _timeSinceLastSeen;
protected override void OnStart()
{
base.OnStart();
if ( !IsProxy )
{
Senses.ScanTags = new TagSet { "player", "friendly_npc", "hostile_npc", "culprit" };
if ( Friendly )
{
GameObject.Tags.Add( "friendly_npc" );
Senses.TargetTags = new TagSet { "hostile_npc" };
}
else
{
GameObject.Tags.Add( "hostile_npc" );
// "culprit" lets a non-player suspect (e.g. the rhino) become a valid target too.
Senses.TargetTags = new TagSet { "player", "friendly_npc", "culprit" };
}
}
if ( Weapon.IsValid() && Renderer.IsValid() )
{
Weapon.CreateWorldModel( Renderer );
// NPCs have no ammo inventory, so stop the weapon gating on an empty reserve pool.
if ( !IsProxy && InfiniteAmmo )
Weapon.UsesAmmo = false;
if ( !IsProxy )
Animation.SetHoldType( Weapon.HoldType );
}
}
public override ScheduleBase GetSchedule()
{
var visible = GetPreferredTarget();
if ( visible.IsValid() )
{
_lastKnownPosition = visible.WorldPosition;
_timeSinceLastSeen = 0;
var engage = GetSchedule<CombatEngageSchedule>();
engage.Target = visible;
engage.Weapon = Weapon;
engage.AttackRange = AttackRange;
engage.EngageSpeed = EngageSpeed;
engage.BurstDuration = BurstDuration;
engage.BurstPause = BurstPause;
return engage;
}
// Out of sight, but dispatch has flagged a suspect: treat their live position as fresh
// intel so we keep closing on them rather than giving up at SearchTimeout.
if ( PriorityTarget.IsValid() && DispatchTracksPriority )
{
_lastKnownPosition = PriorityTarget.WorldPosition;
_timeSinceLastSeen = 0;
}
// Search last known position if recent enough
if ( _lastKnownPosition.HasValue && _timeSinceLastSeen < SearchTimeout )
{
var search = GetSchedule<ScientistSearchSchedule>();
search.Target = _lastKnownPosition.Value;
return search;
}
// Friendly NPCs follow the nearest player when idle
if ( Friendly )
{
var follow = GetSchedule<CombatFollowSchedule>();
follow.FollowDistance = FollowDistance;
return follow;
}
// No intel — patrol
var patrol = GetSchedule<CombatPatrolSchedule>();
patrol.PatrolRadius = PatrolRadius;
return patrol;
}
/// <summary>
/// Pick who to engage: a visible target flagged "culprit" (the wanted suspect) takes
/// priority, otherwise the nearest visible hostile. Keeps cops focused on the suspect
/// while still defending themselves against anyone else in view.
/// </summary>
private GameObject GetPreferredTarget()
{
GameObject nearestCulprit = null;
float bestDist = float.MaxValue;
foreach ( var t in Senses.VisibleTargets )
{
if ( !t.IsValid() || !t.Tags.Has( "culprit" ) ) continue;
var d = WorldPosition.Distance( t.WorldPosition );
if ( d < bestDist )
{
bestDist = d;
nearestCulprit = t;
}
}
return nearestCulprit.IsValid() ? nearestCulprit : Senses.GetNearestVisible();
}
void IDamageable.OnDamage( in DamageInfo damage )
{
if ( IsProxy )
return;
Health -= damage.Damage;
// If we can hear the attacker, treat their position as the last known location
if ( damage.Attacker.IsValid() )
{
var dist = WorldPosition.Distance( damage.Attacker.WorldPosition );
if ( dist <= Senses.HearingRange )
{
_lastKnownPosition = damage.Attacker.WorldPosition;
_timeSinceLastSeen = 0;
}
}
if ( Health < 1f )
{
if ( Speech.CanSpeak )
Speech.Say( Game.Random.FromArray( DeathLines ), 2f );
Die( damage );
return;
}
if ( Speech.CanSpeak && Game.Random.Float() < 0.5f )
Speech.Say( Game.Random.FromArray( PainLines ), 1.5f );
// Interrupt current schedule so we react immediately
EndCurrentSchedule();
}
}