Npcs/Layers/SensesLayer.cs
namespace Sandbox.Npcs.Layers;
/// <summary>
/// Handles awareness and environmental scanning.
/// Scans for all objects matching <see cref="ScanTags"/> and caches them by tag.
/// <see cref="TargetTags"/> filters which cached objects are treated as hostile targets.
/// </summary>
public class SensesLayer : BaseNpcLayer
{
public float ScanInterval { get; set; } = 0.1f; // Scan every 100ms
[Property]
public float SightRange { get; set; } = 500f;
[Property]
public float HearingRange { get; set; } = 300f;
public float PersonalSpace { get; set; } = 80f;
/// <summary>
/// All tags the NPC should scan for and cache. Results are bucketed by tag.
/// </summary>
[Property]
public TagSet ScanTags { get; set; } = ["player"];
/// <summary>
/// Tags that are treated as hostile targets. <see cref="VisibleTargets"/> and
/// <see cref="AudibleTargets"/> only contain objects matching these tags.
/// </summary>
[Property]
public TagSet TargetTags { get; set; } = ["player"];
// Hostile-only lists (backward compat)
public GameObject Nearest { get; private set; }
public float DistanceToNearest { get; private set; } = float.MaxValue;
public List<GameObject> VisibleTargets { get; private set; } = new();
public List<GameObject> AudibleTargets { get; private set; } = new();
// Tag-bucketed caches
private readonly Dictionary<string, List<GameObject>> _visibleByTag = new();
private readonly Dictionary<string, List<GameObject>> _audibleByTag = new();
private TimeSince _lastScan;
protected override void OnUpdate()
{
if ( IsProxy ) return;
if ( _lastScan > ScanInterval )
{
ScanEnvironment();
_lastScan = 0;
}
}
public override string GetDebugString()
{
if ( VisibleTargets.Count == 0 && AudibleTargets.Count == 0 ) return null;
return $"Senses: {VisibleTargets.Count} visible, {AudibleTargets.Count} audible";
}
/// <summary>
/// Scan for all objects matching <see cref="ScanTags"/>, bucket by tag,
/// and populate the hostile-filtered <see cref="VisibleTargets"/>/<see cref="AudibleTargets"/>.
/// </summary>
private void ScanEnvironment()
{
VisibleTargets.Clear();
AudibleTargets.Clear();
ClearTagCache( _visibleByTag );
ClearTagCache( _audibleByTag );
Nearest = null;
DistanceToNearest = float.MaxValue;
if ( NpcConVars.NoTarget )
return;
var nearbyObjects = Npc.Scene.FindInPhysics( new Sphere( Npc.WorldPosition, HearingRange ) );
foreach ( var obj in nearbyObjects )
{
if ( !obj.Tags.HasAny( ScanTags ) ) continue;
var distance = Npc.WorldPosition.Distance( obj.WorldPosition );
bool isTarget = obj.Tags.HasAny( TargetTags );
// Track nearest hostile target
if ( isTarget && distance < DistanceToNearest )
{
DistanceToNearest = distance;
Nearest = obj;
}
bool isAudible = distance <= HearingRange;
bool isVisible = distance <= SightRange && HasLineOfSight( obj );
if ( isAudible )
{
AddToTagCache( _audibleByTag, obj );
if ( isTarget ) AudibleTargets.Add( obj );
}
if ( isVisible )
{
AddToTagCache( _visibleByTag, obj );
if ( isTarget ) VisibleTargets.Add( obj );
}
}
}
/// <summary>
/// Check if we have line of sight to target
/// </summary>
private bool HasLineOfSight( GameObject target )
{
var eyePosition = Npc.WorldPosition + Vector3.Up * 64f; // Eye height
var targetPosition = target.WorldPosition + Vector3.Up * 32f; // Target center
var trace = Npc.Scene.Trace.Ray( eyePosition, targetPosition )
.IgnoreGameObjectHierarchy( Npc.GameObject )
.WithoutTags( "trigger" )
.Run();
return !trace.Hit || trace.GameObject == target || target.IsDescendant( trace.GameObject );
}
/// <summary>
/// Get the nearest visible hostile target (matching <see cref="TargetTags"/>).
/// </summary>
public GameObject GetNearestVisible()
{
return GetNearestIn( VisibleTargets );
}
/// <summary>
/// Get the nearest visible object with a specific tag.
/// </summary>
public GameObject GetNearestVisible( string tag )
{
return GetNearestIn( GetVisible( tag ) );
}
/// <summary>
/// Get all visible objects with a specific tag from the cache.
/// </summary>
public List<GameObject> GetVisible( string tag )
{
return _visibleByTag.TryGetValue( tag, out var list ) ? list : _empty;
}
/// <summary>
/// Get all audible objects with a specific tag from the cache.
/// </summary>
public List<GameObject> GetAudible( string tag )
{
return _audibleByTag.TryGetValue( tag, out var list ) ? list : _empty;
}
public override void ResetLayer()
{
VisibleTargets.Clear();
AudibleTargets.Clear();
ClearTagCache( _visibleByTag );
ClearTagCache( _audibleByTag );
Nearest = null;
DistanceToNearest = float.MaxValue;
}
private GameObject GetNearestIn( List<GameObject> list )
{
GameObject nearest = null;
float nearestDist = float.MaxValue;
foreach ( var obj in list )
{
var dist = Npc.WorldPosition.Distance( obj.WorldPosition );
if ( dist < nearestDist )
{
nearestDist = dist;
nearest = obj;
}
}
return nearest;
}
private static void AddToTagCache( Dictionary<string, List<GameObject>> cache, GameObject obj )
{
foreach ( var tag in obj.Tags )
{
if ( !cache.TryGetValue( tag, out var list ) )
{
list = new List<GameObject>();
cache[tag] = list;
}
list.Add( obj );
}
}
private static void ClearTagCache( Dictionary<string, List<GameObject>> cache )
{
foreach ( var list in cache.Values )
list.Clear();
}
private static readonly List<GameObject> _empty = new();
}