NPC/Doob.cs

An NPC companion class Doob derived from NPC. It follows its Owner when idle, uses a grid A* pathfinder to flee from nearby monsters by avoiding tagged cells, plays sounds for idle, running and death, and handles being destroyed.

NetworkingFile Access
using System.Linq;
using Sandbox;
using GridAStar;

namespace BrickJam;

/// <summary>
/// "Cartoony Sidekick" companion that monsters prefer to target. Scene-System port of legacy <c>Doob</c>:
/// grid A* flee away from the nearest monster (avoiding the monster-range cell tags), loiter near the owner
/// otherwise.
/// </summary>
[Title( "Doob" )]
[Category( "NPC" )]
public sealed partial class Doob : NPC
{
	public override string ModelPath { get; set; } = "models/doob/doob.vmdl";
	[Property] public override float WalkSpeed { get; set; } = 140f;
	[Property] public override float RunSpeed { get; set; } = 380f;
	public override float CollisionHeight { get; set; } = 24f;
	public override float CollisionRadius { get; set; } = 8f; // small dog - squeezes through tight doorways
	public override string IdleSound => "sounds/dooblaugh.sound";
	public override float IdleVolume => 0.6f;
	public override string AttackSound => "";

	[Sync] public bool IsBeingChased { get; set; }
	public Player Owner { get; set; }

	private bool isDead;
	private SoundHandle runningSound;

	public override float WishSpeed => Direction.IsNearlyZero() ? 0f : (HasArrivedDestination ? 0f : (IsBeingChased ? RunSpeed : WalkSpeed));

	protected override void OnStart()
	{
		base.OnStart();
		GameObject.Tags.Add( "doob" );
	}

	public override void Think()
	{
		base.Think();
		IsBeingChased = Scene.GetAllComponents<NPC>().Any( x => x.Target == this );
	}

	protected override void OnUpdate()
	{
		// Runs on EVERY client: play/stop the looping sprint sound off the replicated IsBeingChased flag,
		// positioned at Doob. The AI (Think) is host-only, so a host-side Sound.Play wouldn't be heard by
		// remote players.
		if ( IsBeingChased && !isDead )
		{
			if ( runningSound is null || runningSound.IsStopped )
				runningSound = Sound.Play( "sounds/running.sound", WorldPosition );

			if ( runningSound is not null )
				runningSound.Position = WorldPosition;
		}
		else
		{
			runningSound?.Stop();
			runningSound = null;
		}
	}

	protected override void OnDestroy()
	{
		runningSound?.Stop();
	}

	// Doob never attacks and never tags cells (it reads the monster tags to flee).
	public override void FindTargets() { }
	public override void AssignNearbyTags() { }

	public override void ComputeIdleAndSeek()
	{
		if ( CurrentGrid is not null )
		{
			if ( !IsBeingChased )
			{
				// FOLLOW the owner. Re-target their CURRENT position periodically - even while still walking a
				// path - whenever we've arrived OR drifted far from them, so we actually keep up with a moving
				// player. (Legacy parity: look up the cell with onlyBelow:false + an upward nudge + circular
				// jitter, so the owner-cell lookup doesn't fail and leave Doob standing still.)
				if ( nextIdle && Owner.IsValid() )
				{
					if ( !IsFollowingPath || WorldPosition.Distance( Owner.WorldPosition ) > 120f )
					{
						var jitter = Rotation.FromYaw( MansionGame.Random.NextSingle() * 360f ).Forward
							* (MansionGame.Random.NextSingle() * 80f);
						var cell = CurrentGrid.GetNearestCell( Owner.WorldPosition + jitter + Vector3.Up * 50f, false );
						if ( cell != null )
							NavigateTo( cell );
					}

					nextIdle = MansionGame.Random.NextSingle() * 0.5f + 0.5f;
				}
			}
			else if ( !IsFollowingPath && nextIdle )
			{
				// FLEE: pick a fresh escape cell only once we've finished the current escape path.
				var monster = Scene.GetAllComponents<NPC>().FirstOrDefault( x => x.Target == this );
				if ( monster.IsValid() )
				{
					var allCells = CurrentGrid.AllCells.ToList();
					Cell chosen = null;

					for ( var tried = 0; tried < 40 && allCells.Count > 0; tried++ )
					{
						var candidate = MansionGame.Random.FromList( allCells, null );
						if ( candidate is null )
							break;

						var minDistance = tried > 20 ? 300f : 600f; // get desperate after 20 tries
						if ( candidate.Position.Distance( monster.WorldPosition ) < minDistance )
							continue;

						// Only pick cells on Doob's side, not past the monster.
						var local = monster.WorldTransform.PointToLocal( candidate.Position ).WithZ( 0 );
						var acceptableAngle = tried > 20 ? 90f : 140f;
						if ( Vector3.GetAngle( local, Vector3.Forward ) <= acceptableAngle )
						{
							chosen = candidate;
							break;
						}
					}

					if ( chosen != null )
						NavigateTo( chosen );
				}

				nextIdle = MansionGame.Random.NextSingle() * 0.3f + 0.3f;
			}
		}

		if ( nextIdleSound && !string.IsNullOrEmpty( IdleSound ) )
		{
			SoundExtensions.BroadcastPlay( IdleSound, WorldPosition, IdleVolume );
			nextIdleSound = MansionGame.Random.NextSingle() * 4f + 4f;
		}
	}

	// Flee paths additionally route around the monster-proximity cell bands.
	public override AStarPathBuilder PathBuilder => base.PathBuilder
		.AvoidTag( "monsterNearRange", 200f )
		.AvoidTag( "monsterMidRange", 100f )
		.AvoidTag( "monsterLongRange", 50f );

	public void Kill()
	{
		if ( isDead || !this.IsValid() )
			return;

		isDead = true;

		runningSound?.Stop();
		MansionGame.Instance?.PlayEffect( "prefabs/particles/blood_explosion.prefab", WorldPosition + Vector3.Up * CollisionHeight * 0.5f, Rotation.Identity );
		SoundExtensions.BroadcastPlay( "sounds/death.sound", WorldPosition );

		if ( Owner.IsValid() )
			Owner.Doob = null;

		GameObject.Destroy();
	}
}