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.
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();
}
}