NPC/Specter.cs

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.

NetworkingFile Access
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;
	}
}