Npcs/Layers/SensesLayer.cs

NPC senses layer. Scans the scene periodically for nearby objects with certain tags, tracks nearest object, builds lists of visible and audible targets, and performs line-of-sight traces.

NetworkingFile Access
namespace Sandbox.Npcs.Layers;

/// <summary>
/// Handles awareness and environmental scanning
/// </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;

	[Property]
	public TagSet TargetTags { get; set; } = ["player"];

	// Current awareness state
	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();

	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 objects of interest
	/// </summary>
	private void ScanEnvironment()
	{
		// Clear previous scan results
		VisibleTargets.Clear();
		AudibleTargets.Clear();
		Nearest = null;
		DistanceToNearest = float.MaxValue;

		// Find all potential targets in hearing range
		var nearbyObjects = Npc.Scene.FindInPhysics( new Sphere( Npc.WorldPosition, HearingRange ) );

		foreach ( var obj in nearbyObjects )
		{
			if ( !obj.Tags.HasAny( TargetTags ) ) continue;

			var distance = Npc.WorldPosition.Distance( obj.WorldPosition );

			// Track nearest
			if ( distance < DistanceToNearest )
			{
				DistanceToNearest = distance;
				Nearest = obj;
			}

			// Check if within hearing range
			if ( distance <= HearingRange )
			{
				AudibleTargets.Add( obj );
			}

			// Check if within sight range and has line of sight
			if ( distance <= SightRange && HasLineOfSight( obj ) )
			{
				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
	/// </summary>
	public GameObject GetNearestVisible()
	{
		GameObject nearest = null;
		float nearestDistance = float.MaxValue;

		foreach ( var target in VisibleTargets )
		{
			var distance = Npc.WorldPosition.Distance( target.WorldPosition );
			if ( distance < nearestDistance )
			{
				nearestDistance = distance;
				nearest = target;
			}
		}

		return nearest;
	}

	public override void Reset()
	{
		VisibleTargets.Clear();
		AudibleTargets.Clear();
		Nearest = null;
		DistanceToNearest = float.MaxValue;
	}
}