An NPC subclass implementing a Specter enemy. It controls model, movement speeds, vision, idle/seeking behavior on a Grid A* system, teleports by sinking into the floor and reappearing elsewhere, and creates a local point light that flickers on clients.
using System;
using System.Linq;
using System.Threading.Tasks;
using Sandbox;
using GridAStar;
namespace BrickJam;
/// <summary>
/// Specter monster - sinks into the floor and rises elsewhere. Scene-System port of legacy <c>Specter</c>,
/// now on grid A*: it chases directly, and on a long idle teleports to a random grid cell (sink → reposition
/// → rise). The lamp light flicker (legacy CapsuleLightEntity) is deferred to the effects system.
/// </summary>
[Title( "Specter" )]
[Category( "NPC" )]
public sealed partial class Specter : NPC
{
public override string ModelPath { get; set; } = "models/specter/specter.vmdl";
public override float WalkSpeed { get; set; } = 120f;
public override float RunSpeed { get; set; } = 320f;
public override float MaxVisionAngle { get; set; } = 240f;
public override float MaxVisionRange { get; set; } = 1000f;
public override float MaxVisionRangeWhenChasing { get; set; } = 1000f;
public override float MaxVisionAngleWhenChasing { get; set; } = 180f;
public override float MaxRememberTime { get; set; } = 3f;
public override string IdleSound => "sounds/specter/spectermoan.sound";
public override float IdleVolume => 1.5f;
public override string AttackSound => "sounds/specter/spectermoan.sound";
public override float AttackVolume => 2f;
public float TimeToTeleport => 2f;
public bool IsLowering => LastTeleport <= TimeToTeleport / 2f + 0.5f;
public bool IsRising => LastTeleport <= TimeToTeleport + 1f && LastTeleport > TimeToTeleport / 2f + 0.5f;
public bool IsTeleporting => IsLowering || IsRising;
[Sync] public TimeSince LastTeleport { get; set; } = 999f;
private static readonly Color LampColor = new( 1f, 0.55f, 0.2f );
private PointLight lamp;
protected override void OnStart()
{
base.OnStart();
// Atmospheric lamp light (legacy CapsuleLightEntity). Purely visual, so each client builds its OWN
// non-networked light and flickers it locally in OnUpdate - NPC logic (Think) is host-only and the
// flicker shouldn't depend on replicated state.
var lampGo = new GameObject( true, "Lamp" ) { Parent = GameObject };
lampGo.LocalPosition = Vector3.Up * 40f;
lamp = lampGo.Components.Create<PointLight>();
lamp.LightColor = LampColor;
lamp.Radius = 350f;
lamp.Shadows = false; // atmospheric glow - skip the (6-face) shadow pass for this point light
}
protected override void OnUpdate()
{
if ( !lamp.IsValid() )
return;
// Eerie flicker; fade the lamp out while the specter is sunk into the floor (teleporting).
var t = Time.Now;
var flicker = 0.7f + 0.18f * MathF.Sin( t * 27f ) + 0.12f * MathF.Sin( t * 11.3f );
var visible = IsTeleporting ? 0f : 1f;
lamp.LightColor = LampColor * (flicker * visible * 4f);
}
public override void ComputeIdleAndSeek()
{
// While sinking/rising we don't make new decisions; ComputeMotion drives the vertical move.
if ( IsTeleporting )
return;
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;
if ( !IsFollowingPath && nextIdle && CurrentGrid is not null )
{
var isLongIdle = MansionGame.Random.NextSingle() <= 0.2f;
var allCells = CurrentGrid.AllCells.ToList();
Cell chosen = 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 >= 400f && dist <= 1000f) )
{
chosen = candidate;
break;
}
}
if ( chosen != null )
{
if ( isLongIdle )
Teleport( chosen.Position );
else
NavigateTo( chosen );
}
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;
}
}
public override void ComputeMotion()
{
if ( !IsTeleporting )
{
base.ComputeMotion();
return;
}
// Sink into / rise out of the floor under manual control (off the grid).
if ( IsLowering )
WorldPosition += Vector3.Down * CollisionHeight * Time.Delta / (TimeToTeleport / 2f);
else if ( IsRising )
WorldPosition += Vector3.Up * CollisionHeight * Time.Delta / (TimeToTeleport / 2f);
}
public async void Teleport( Vector3 position )
{
LastTeleport = 0;
MansionGame.Instance?.PlayEffect( "prefabs/particles/specter_teleport.prefab", WorldPosition, Rotation.Identity );
await Task.DelayRealtimeSeconds( TimeToTeleport * 0.5f + 0.5f );
WorldPosition = position + Vector3.Down * CollisionHeight;
}
}