NPC component for monsters/AI. It manages model, collision, vision, target acquisition, wandering via a grid A* navigation, door interaction, attacking players/Doob, animations, and proximity tagging on the level grid.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Sandbox;
using GridAStar;
namespace BrickJam;
/// <summary>
/// Base monster/AI. Scene-System port of the legacy <c>NPC : AnimatedEntity</c>. Navigation uses the
/// re-ported <see cref="GridAStar"/> grid + A* (see NPC.Navigation.cs) and a custom <see cref="MoveHelper"/>
/// (see NPC.Controller.cs) - the s&box NavMesh is no longer used.
/// </summary>
[Title( "NPC" )]
[Category( "NPC" )]
public partial class NPC : Component, IPushable
{
public virtual string ModelPath { get; set; } = "models/citizen/citizen.vmdl";
public virtual float CollisionRadius { get; set; } = 9f; // fits the tightened grid clearance (8) so monsters pass narrow doorways
public virtual float CollisionHeight { get; set; } = 72f;
public virtual float MaxVisionRange { get; set; } = 1024f;
public virtual float MaxVisionAngle { get; set; } = 120f;
public virtual float MaxVisionRangeWhenChasing { get; set; } = 2048f;
public virtual float MaxVisionAngleWhenChasing { get; set; } = 180f;
/// <summary>How long the NPC keeps pursuing a target's position after losing line of sight (it follows
/// you/the Doob into rooms and corridors). Was 1s, which made monsters give up almost instantly and
/// oscillate (chase -> lose -> wander -> re-acquire) - looking "scared" of a fast-fleeing Doob.</summary>
public virtual float MaxRememberTime { get; set; } = 3f;
public virtual float AttackAnimationDuration { get; set; } = 1.5f;
public virtual float KillAfterAttackTime { get; set; } = 1f;
public virtual float SetDistanceWhenAttacking { get; set; } = 40f;
public virtual float KillRange { get; set; } = 60f;
public virtual string IdleSound => "sounds/nyobo/nyobo_laugh.sound";
public virtual float IdleVolume => 2f;
public virtual string AttackSound => "sounds/nyobo/nyobo_attack.sound";
public virtual float AttackVolume => 2f;
public float PushForce { get; set; } = 3000f;
public SkinnedModelRenderer Body { get; private set; }
public Dictionary<Component, TimeSince> InVision { get; private set; } = new();
public Component Target { get; set; }
public Component LastTarget { get; set; }
public Component CurrentlyMurdering { get; set; }
public bool Blocked { get; set; }
/// <summary>Driven by the controller each fixed update.</summary>
public Vector3 Velocity { get; set; }
public bool IsOnGround { get; set; }
protected TimeUntil nextIdle = 0f;
protected TimeUntil nextIdleSound = 2f;
public Capsule CollisionCapsule => new( Vector3.Up * CollisionRadius, Vector3.Up * (CollisionHeight - CollisionRadius), CollisionRadius );
public Vector3 Center => WorldPosition + Vector3.Up * CollisionHeight * 0.5f;
/// <summary>The level's A* grid (host-side). Null until the level has generated it.</summary>
public Grid CurrentGrid => MansionGame.Instance?.CurrentLevel?.Grid;
public static T Create<T>( Vector3 position, Rotation rotation ) where T : NPC, new()
{
var go = new GameObject( true, typeof( T ).Name );
go.WorldPosition = position;
go.WorldRotation = rotation;
var npc = go.Components.Create<T>();
go.NetworkSpawn();
return npc;
}
protected override void OnStart()
{
Body = GetComponentInChildren<SkinnedModelRenderer>();
if ( Body is null )
{
var bodyGo = new GameObject( true, "Body" ) { Parent = GameObject };
Body = bodyGo.Components.Create<SkinnedModelRenderer>();
}
Body.Model = Model.Load( ModelPath );
// Physical presence so players collide with / trace against / can ping the monster. The mover ignores
// the "npc" tag so monsters don't collide with themselves or each other.
if ( !Components.TryGet<CapsuleCollider>( out _ ) )
{
var capsule = Components.Create<CapsuleCollider>();
capsule.Start = Vector3.Up * CollisionRadius;
capsule.End = Vector3.Up * (CollisionHeight - CollisionRadius);
capsule.Radius = CollisionRadius;
}
GameObject.Tags.Add( "npc" );
}
protected override void OnFixedUpdate()
{
if ( !Networking.IsHost )
return;
Think();
}
public virtual void Think()
{
FindTargets();
ComputeIdleAndSeek();
ComputeOpenDoors();
ComputeNavigation();
ComputeMotion();
ComputeAnimations();
AssignNearbyTags();
CheckStuck();
}
public virtual void ComputeIdleAndSeek()
{
if ( InVision.Count > 0 )
{
Target = InVision.OrderBy( x => x.Key.WorldPosition.Distance( WorldPosition ) ).FirstOrDefault().Key;
if ( Target is Player player && player.Doob.IsValid() )
Target = player.Doob;
LastTarget = Target;
}
else
{
Target = null;
// Wander to a random reachable cell (grid A*). Long idles roam further.
if ( !IsFollowingPath && nextIdle && CurrentGrid is not null )
{
var isLongIdle = MansionGame.Random.NextSingle() <= 0.2f;
var allCells = CurrentGrid.AllCells.ToList();
Cell chosenCell = null;
for ( var tried = 0; tried < 20 && allCells.Count > 0; tried++ )
{
var candidate = MansionGame.Random.FromList( allCells, null );
if ( candidate is null )
break;
var dist = candidate.Position.Distance( WorldPosition );
if ( isLongIdle ? dist >= 1000f : (dist >= 200f && dist <= 1000f) )
{
chosenCell = candidate;
break;
}
}
if ( chosenCell != null )
NavigateTo( chosenCell );
nextIdle = MansionGame.Random.NextSingle() * 1f + 1f;
LastTarget = null;
}
}
if ( Target.IsValid() && Target.WorldPosition.Distance( WorldPosition ) <= KillRange )
{
if ( Target is Player p )
_ = CatchPlayer( p );
else if ( Target is Doob d )
_ = CatchDoob( d );
}
if ( nextIdleSound && !string.IsNullOrEmpty( IdleSound ) )
{
SoundExtensions.BroadcastPlay( IdleSound, WorldPosition, IdleVolume );
nextIdleSound = MansionGame.Random.NextSingle() * 4f + 4f;
}
}
/// <summary>
/// Shove open any nearby closed door so monsters can chase through them, then let it swing shut again.
/// </summary>
public virtual void ComputeOpenDoors()
{
foreach ( var door in Scene.GetAllComponents<Door>() )
{
if ( door.WorldPosition.Distance( WorldPosition ) > 60f )
continue;
if ( door.State is DoorState.Closed or DoorState.Closing )
OpenDoorBriefly( door );
}
}
private async void OpenDoorBriefly( Door door )
{
door.Open( this );
await Task.DelayRealtimeSeconds( 1.5f );
if ( door.IsValid() )
door.Close();
}
public virtual async Task CatchPlayer( Player player )
{
if ( CurrentlyMurdering.IsValid() )
return;
Body?.Set( "attack", true );
SoundExtensions.BroadcastPlay( "sounds/screams/scream.sound", player.WorldPosition );
if ( !string.IsNullOrEmpty( AttackSound ) )
SoundExtensions.BroadcastPlay( AttackSound, WorldPosition, AttackVolume );
var direction = (player.WorldPosition - WorldPosition).Normal;
player.WorldPosition = WorldPosition + direction * SetDistanceWhenAttacking;
player.CameraTarget = this;
player.Blocked = true;
CurrentlyMurdering = player;
Blocked = true;
await Task.DelayRealtimeSeconds( KillAfterAttackTime );
player.Kill();
CreditMonsterKill( player );
await Task.DelayRealtimeSeconds( AttackAnimationDuration );
player.CameraTarget = null;
player.Blocked = false;
CurrentlyMurdering = null;
Blocked = false;
}
public virtual async Task CatchDoob( Doob doob )
{
if ( CurrentlyMurdering.IsValid() )
return;
Body?.Set( "attack", true );
if ( !string.IsNullOrEmpty( AttackSound ) )
SoundExtensions.BroadcastPlay( AttackSound, WorldPosition, AttackVolume );
var direction = (doob.WorldPosition - WorldPosition).Normal;
doob.WorldPosition = WorldPosition + direction * SetDistanceWhenAttacking;
doob.Blocked = true;
CurrentlyMurdering = doob;
Blocked = true;
// Bodyguard: the monster committed its attack on the dog instead of its owner - Doob took the hit.
if ( doob.Owner.IsValid() )
doob.Owner.TrackAchievement( GameStats.AchBodyguard );
await Task.DelayRealtimeSeconds( KillAfterAttackTime );
doob.Kill();
await Task.DelayRealtimeSeconds( AttackAnimationDuration );
doob.Blocked = false;
CurrentlyMurdering = null;
Blocked = false;
}
public virtual void ComputeAnimations()
{
Body?.Set( "move_x", MathX.Remap( Velocity.WithZ( 0 ).Length, 0f, RunSpeed, 0, 3 ) );
}
public virtual void FindTargets()
{
foreach ( var stale in InVision.Where( x => !x.Key.IsValid() || (x.Key is Player p && !p.IsAlive) ).ToList() )
InVision.Remove( stale.Key );
var candidates = Scene.GetAllComponents<Player>().Where( x => x.IsAlive ).Cast<Component>()
.Concat( Scene.GetAllComponents<Doob>().Cast<Component>() );
foreach ( var candidate in candidates )
{
if ( IsInVision( candidate ) )
{
InVision[candidate] = 0f;
}
else if ( InVision.TryGetValue( candidate, out var seen ) && seen >= MaxRememberTime )
{
// Outsmarted: a player we had actually locked onto (LastTarget) stayed out of sight long enough
// to fall out of memory, alive and uncaught -> they broke line of sight and shook us off.
if ( candidate is Player evader && evader.IsAlive
&& LastTarget == candidate && CurrentlyMurdering != candidate )
{
evader.TrackAchievement( GameStats.AchOutsmarted );
}
InVision.Remove( candidate );
}
}
}
/// <summary>
/// Host-side achievement crediting when this monster kills <paramref name="victim"/>: Slipped on a Soap
/// (early Bathrooms death) for the victim, and Manslaughter by Proxy for any nearby Doob-owning survivor.
/// </summary>
private void CreditMonsterKill( Player victim )
{
if ( !victim.IsValid() )
return;
if ( MansionGame.Instance?.CurrentLevel is { Type: LevelType.Bathrooms } level && level.SinceStarted < 30f )
victim.TrackAchievement( GameStats.AchSlippedOnSoap );
foreach ( var ally in Scene.GetAllComponents<Player>() )
{
if ( ally == victim || !ally.IsAlive || !ally.Doob.IsValid() )
continue;
if ( ally.WorldPosition.Distance( victim.WorldPosition ) <= 300f )
ally.TrackAchievement( GameStats.AchManslaughterByProxy );
}
}
/// <summary>Toggle with the <c>npc_vision</c> console command to visualise why NPCs do/don't see you.</summary>
public static bool DebugVision { get; set; }
[ConCmd( "npc_vision" )]
public static void ToggleVisionDebug()
{
DebugVision = !DebugVision;
Log.Info( $"NPC vision debug: {DebugVision}" );
}
public virtual bool IsInVision( Component entity )
{
var visionRange = entity == Target ? MaxVisionRangeWhenChasing : MaxVisionRange;
var visionAngle = entity == Target ? MaxVisionAngleWhenChasing : MaxVisionAngle;
var distance = entity.WorldPosition.Distance( WorldPosition );
var relativePosition = WorldTransform.PointToLocal( entity.WorldPosition );
var angle = Vector3.GetAngle( relativePosition, Vector3.Forward );
var inRange = distance < visionRange;
var inCone = angle <= visionAngle / 2f;
var head = entity.WorldPosition + Vector3.Up * 64f;
var trace = Scene.Trace.Ray( Center, head )
.IgnoreGameObjectHierarchy( GameObject )
.IgnoreGameObjectHierarchy( entity.GameObject )
.WithoutTags( "player", "npc", "loot", "nocollide" )
.Run();
var clearLos = !trace.Hit;
var visible = inRange && inCone && clearLos;
if ( DebugVision && entity is Player )
{
Scene.DebugOverlay.Line( Center, head, visible ? Color.Green : Color.Red );
Scene.DebugOverlay.Text( Center + Vector3.Up * 24f,
$"dist {distance:0}/{visionRange:0} {(inRange ? "OK" : "FAR")} | " +
$"angle {angle:0}/{visionAngle / 2f:0} {(inCone ? "OK" : "OUT")} | " +
$"los {(clearLos ? "CLEAR" : "BLOCKED by " + (trace.GameObject?.Name ?? "?"))}", 14 );
}
return visible;
}
// Monster-proximity cell tags so companions (Doob) can path away from danger. Throttled.
protected List<Cell> currentCells = new();
internal TimeUntil nextTagsCheck = 0.2f;
public virtual void AssignNearbyTags()
{
if ( CurrentGrid is null || !nextTagsCheck )
return;
const float nearRadius = 50f;
const float midRadius = 100f;
const float longRadius = 150f;
var bbox = BBox.FromPositionAndSize( WorldPosition, longRadius * 2f );
foreach ( var oldCell in currentCells.ToList() )
{
oldCell.Tags.Remove( "monsterNearRange" );
oldCell.Tags.Remove( "monsterMidRange" );
oldCell.Tags.Remove( "monsterLongRange" );
currentCells.Remove( oldCell );
}
foreach ( var cell in CurrentGrid.GetCellsInBBox( bbox ) )
{
var dist = cell.Position.Distance( WorldPosition );
if ( dist <= nearRadius )
{
cell.Tags.Add( "monsterNearRange" );
currentCells.Add( cell );
}
else if ( dist <= midRadius )
{
cell.Tags.Add( "monsterMidRange" );
currentCells.Add( cell );
}
else if ( dist <= longRadius )
{
cell.Tags.Add( "monsterLongRange" );
currentCells.Add( cell );
}
}
nextTagsCheck = 0.5f;
}
protected override void OnDestroy()
{
foreach ( var oldCell in currentCells.ToList() )
{
oldCell.Tags.Remove( "monsterNearRange" );
oldCell.Tags.Remove( "monsterMidRange" );
oldCell.Tags.Remove( "monsterLongRange" );
}
}
}