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, can be recruited to follow a player and engages hostile NPCs instead.
/// </summary>
public class CombatNpc : Npc, Component.IPressable
{
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;
/// <summary>
/// The weapon this NPC uses to attack. Its NPC usage decides the engagement range and burst
/// cadence - the same soldier fights differently with a shotgun than an SMG.
/// </summary>
[Property]
public BaseSandboxWeapon Weapon { get; set; }
/// <summary>
/// Skill multiplier on the weapon's NPC spread - 1 is a average shot, lower is more accurate.
/// </summary>
[Property, Group( "Balance" )]
public float AimSpreadScale { get; set; } = 1f;
[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;
/// <summary>
/// How far a friendly NPC will follow a player before stopping.
/// </summary>
[Property, Group( "Balance" )]
public float FollowDistance { get; set; } = 150f;
/// <summary>
/// The player this friendly NPC is following, if any.
/// </summary>
[Sync]
public GameObject Leader { get; set; }
private Vector3? _lastKnownPosition;
private TimeSince _timeSinceLastSeen;
IPressable.Tooltip? IPressable.GetTooltip( IPressable.Event e )
{
if ( !Friendly )
return null;
return Leader.IsValid()
? new IPressable.Tooltip( "Stop following", "person_off", DisplayName )
: new IPressable.Tooltip( "Follow me", "follow_the_signs", DisplayName );
}
bool IPressable.CanPress( IPressable.Event e ) => Friendly;
bool IPressable.Press( IPressable.Event e )
{
ToggleFollow( e.Source.GameObject );
return true;
}
[Rpc.Host]
private void ToggleFollow( GameObject presserObject )
{
if ( !Friendly || !presserObject.IsValid() )
return;
var player = presserObject.Root.GetComponent<Player>();
if ( !player.IsValid() )
return;
Leader = Leader == player.GameObject ? null : player.GameObject;
EndCurrentSchedule();
}
protected override void OnStart()
{
base.OnStart();
if ( Weapon.IsValid() && Renderer.IsValid() )
{
Weapon.CreateWorldModel( Renderer );
// The weapon says how forgiving it is in NPC hands; we say how good we are with it.
Weapon.SpreadScale = Weapon.Npc.SpreadScale * AimSpreadScale;
if ( !IsProxy )
Animation.SetHoldType( Weapon.HoldType );
}
}
// Friendly soldiers fight alongside the player; the rest are enemies.
public override string Faction => Friendly ? Factions.Ally : Factions.Enemy;
protected override void SetupRelationships()
{
if ( Friendly )
{
Likes( Factions.Player );
Hates( Factions.Enemy, Factions.Monster );
}
else
{
Hates( Factions.Player, Factions.Ally, Factions.Citizen );
}
}
public override ScheduleBase GetSchedule()
{
var visible = Senses.GetBestTarget();
if ( visible.IsValid() && Weapon.IsValid() )
{
_lastKnownPosition = visible.WorldPosition;
_timeSinceLastSeen = 0;
var engage = GetSchedule<CombatEngageSchedule>();
engage.Target = visible;
engage.Weapon = Weapon;
engage.EngageSpeed = EngageSpeed;
return engage;
}
// Search last known position if recent enough
if ( _lastKnownPosition.HasValue && _timeSinceLastSeen < SearchTimeout )
{
var search = GetSchedule<InvestigateSchedule>();
search.Target = _lastKnownPosition.Value;
return search;
}
// Heard gunfire or some other disturbance but can't see the source -- go check it out.
if ( Senses.Disturbance is { } disturbance )
{
var investigate = GetSchedule<InvestigateSchedule>();
investigate.Target = disturbance.Position;
return investigate;
}
// Combat and investigation above take priority; once clear, resume following our leader.
if ( Friendly && Leader.IsValid() )
{
var follow = GetSchedule<FollowSchedule>();
follow.Target = Leader;
follow.FollowDistance = FollowDistance;
return follow;
}
// No intel — patrol
var patrol = GetSchedule<CombatPatrolSchedule>();
patrol.PatrolRadius = PatrolRadius;
return patrol;
}
protected override void OnHurt( in DamageInfo damage )
{
if ( damage.Attacker.IsValid() )
{
// Turn on whoever hurt us, even a former ally, and prioritise them as a target.
// This is the runtime "turn nasty" path the crime/aggro systems reuse.
SetDisposition( damage.Attacker, Disposition.Hostile, priority: 10 );
// If we can hear the attacker, treat their position as the last known location.
if ( WorldPosition.Distance( damage.Attacker.WorldPosition ) <= Senses.HearingRange )
{
_lastKnownPosition = damage.Attacker.WorldPosition;
_timeSinceLastSeen = 0;
}
}
if ( Health >= 1f && Speech.CanSpeak && Game.Random.Float() < 0.5f )
Speech.TrySay( Game.Random.FromArray( PainLines ), 1.5f, tags: ["reaction", "pain"], priority: 2000 );
// React immediately.
EndCurrentSchedule();
}
protected override void Die( in DamageInfo damage )
{
if ( Speech.CanSpeak )
Speech.TrySay( Game.Random.FromArray( DeathLines ), 2f, tags: ["reaction", "death"], priority: 3000 );
DropWeapon();
base.Die( damage );
}
/// <summary>
/// Drop the held weapon into the world where the hand was, so it survives the NPC's death and can
/// be picked up.
/// </summary>
private void DropWeapon()
{
if ( !Weapon.IsValid() )
return;
var position = Weapon.WorldModel.IsValid()
? Weapon.WorldModel.WorldPosition
: WorldPosition + Vector3.Up * 32f;
Weapon.DropIntoWorld( position, Vector3.Up * 50f );
}
}