NPC/NPC.cs

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.

File AccessNetworking
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" );
		}
	}
}