NextBot.cs
using Sandbox;
using Sandbox.Navigation;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
[Title( "Adaptive Horror Monster AI" )]
[Category( "AI" )]
public sealed class AdaptiveHorrorAI : Component, Component.ICollisionListener
{
// ─────────────────────────────────────────────
// INSPECTOR PROPERTIES
// ─────────────────────────────────────────────
[Header( "Patrol" )]
[Property] public float PatrolSpeed { get; set; } = 70f;
[Property] public float PatrolRadius { get; set; } = 600f;
[Property] public float WaitTime { get; set; } = 2.5f;
[Header( "Chase" )]
[Property] public float ChaseSpeed { get; set; } = 210f;
[Property] public float ChaseRange { get; set; } = 480f;
[Property] public float LoseRange { get; set; } = 680f;
[Header( "Perception" )]
[Property] public float HearingRange { get; set; } = 220f;
[Property] public float SightRange { get; set; } = 400f;
[Property, Range( 30f, 180f )] public float SightFovDegrees { get; set; } = 90f;
[Header( "Adaptation" )]
[Property] public float AdaptInterval { get; set; } = 15f;
[Property] public float MaxSpeedBonus { get; set; } = 80f;
[Property] public float MaxHearBonus { get; set; } = 120f;
[Property] public int AdaptStepsMax { get; set; } = 5;
[Header( "Horror" )]
[Property] public float RetargetInterval { get; set; } = 0.8f;
[Property] public float SearchDuration { get; set; } = 8f;
[Property] public SoundEvent CatchSound { get; set; }
[Property] public SoundEvent FootstepSound { get; set; }
[Property] public SoundEvent AlertSound { get; set; }
[Property] public SoundEvent AmbientBreathSound { get; set; }
[Header( "Reset" )]
[Property] public float ResetDelay { get; set; } = 4f;
// ─────────────────────────────────────────────
// STATE MACHINE
// ─────────────────────────────────────────────
private enum AIState { Patrol, Alert, Hunt, Chase, Searching, Caught }
private AIState _state = AIState.Patrol;
// ─────────────────────────────────────────────
// INTERNAL FIELDS
// ─────────────────────────────────────────────
private NavMeshAgent _agent;
private GameObject _target;
private Vector3 _patrolTarget;
private Vector3 _startPosition;
private float _waitTimer;
private Vector3 _lastKnownPosition;
private float _searchTimer;
private float _retargetTimer;
private Vector3 _lastCheckedPos;
private float _stuckTimer;
private float _footstepTimer;
private float _footstepInterval = 0.45f;
private float _breathTimer;
private float _adaptTimer;
private int _adaptSteps;
private float _currentChaseSpeed;
private float _currentHearingRange;
private int _timesPlayerEscaped;
private int _timesPlayerCaught;
private List<Vector3> _searchedAreas = new();
private float _personalityAggression;
private float _personalityPersistence;
private bool _touched;
// ─────────────────────────────────────────────
// LIFECYCLE
// ─────────────────────────────────────────────
protected override void OnStart()
{
_touched = false;
_state = AIState.Patrol;
if ( Networking.IsHost )
GameObject.NetworkSpawn();
else return;
_agent = Components.Get<NavMeshAgent>( FindMode.EverythingInSelf );
if ( _agent is null ) { Log.Error( "AdaptiveHorrorAI: NavMeshAgent missing!" ); return; }
_startPosition = WorldPosition;
_lastCheckedPos = WorldPosition;
var rng = new Random( GameObject.Id.GetHashCode() );
_personalityAggression = (float)rng.NextDouble();
_personalityPersistence = (float)rng.NextDouble();
_currentChaseSpeed = ChaseSpeed + _personalityAggression * 40f;
_currentHearingRange = HearingRange + (1f - _personalityAggression) * 80f;
_agent.MaxSpeed = PatrolSpeed;
PickNewPatrolTarget();
}
protected override void OnFixedUpdate()
{
if ( !Networking.IsHost ) return;
if ( _agent is null || _touched ) return;
TickRetarget();
TickAdaptation();
TickFootstep();
TickBreathing();
TickStuckDetection();
TickStateMachine();
}
// ─────────────────────────────────────────────
// STATE MACHINE
// ─────────────────────────────────────────────
private void TickStateMachine()
{
switch ( _state )
{
case AIState.Patrol: StatePatrol(); break;
case AIState.Alert: StateAlert(); break;
case AIState.Hunt: StateHunt(); break;
case AIState.Chase: StateChase(); break;
case AIState.Searching: StateSearching(); break;
}
}
private void StatePatrol()
{
_agent.MaxSpeed = PatrolSpeed;
if ( _waitTimer > 0f )
{
_waitTimer -= Time.Delta;
if ( _waitTimer <= 0f ) PickNewPatrolTarget();
return;
}
if ( WorldPosition.Distance( _patrolTarget ) < 60f )
_waitTimer = WaitTime;
if ( CanHearTarget() || CanSeeTarget() )
TransitionTo( AIState.Alert );
}
private void StateAlert()
{
_agent.MaxSpeed = PatrolSpeed * 1.5f;
if ( _target is not null )
_agent.MoveTo( _target.WorldPosition );
if ( _target is not null && WorldPosition.Distance( _target.WorldPosition ) <= ChaseRange )
TransitionTo( AIState.Chase );
else if ( !CanHearTarget() && !CanSeeTarget() )
TransitionTo( AIState.Searching );
}
private void StateHunt()
{
_agent.MaxSpeed = _currentChaseSpeed * 0.75f;
_agent.MoveTo( _lastKnownPosition );
if ( CanSeeTarget() || (CanHearTarget() && _target is not null &&
WorldPosition.Distance( _target.WorldPosition ) <= ChaseRange) )
TransitionTo( AIState.Chase );
if ( WorldPosition.Distance( _lastKnownPosition ) < 80f )
TransitionTo( AIState.Searching );
}
private void StateChase()
{
_agent.MaxSpeed = _currentChaseSpeed;
if ( _target is not null )
{
_lastKnownPosition = _target.WorldPosition;
_agent.MoveTo( _target.WorldPosition );
}
bool lostSight = !CanSeeTarget();
bool tooFar = _target is null || WorldPosition.Distance( _target.WorldPosition ) > LoseRange + (_personalityPersistence * 100f);
if ( lostSight && tooFar )
{
_timesPlayerEscaped++;
TransitionTo( AIState.Hunt );
}
}
private void StateSearching()
{
_searchTimer -= Time.Delta;
_agent.MaxSpeed = PatrolSpeed * 1.2f;
if ( WorldPosition.Distance( _patrolTarget ) < 60f )
{
var pt = Scene.NavMesh.GetRandomPoint( _lastKnownPosition, 200f );
if ( pt.HasValue )
{
_patrolTarget = pt.Value;
_agent.MoveTo( _patrolTarget );
}
}
if ( CanSeeTarget() || CanHearTarget() )
TransitionTo( AIState.Chase );
if ( _searchTimer <= 0f )
{
_searchedAreas.Add( _lastKnownPosition );
if ( _searchedAreas.Count > 10 ) _searchedAreas.RemoveAt( 0 );
TransitionTo( AIState.Patrol );
}
}
private void TransitionTo( AIState next )
{
if ( _state == next ) return;
switch ( next )
{
case AIState.Alert:
BroadcastAlertSound();
break;
case AIState.Chase:
_footstepInterval = 0.25f;
break;
case AIState.Patrol:
_footstepInterval = 0.5f;
PickNewPatrolTarget();
break;
case AIState.Searching:
_searchTimer = SearchDuration * (0.5f + _personalityPersistence * 0.8f);
break;
case AIState.Hunt:
_footstepInterval = 0.35f;
_agent.MoveTo( _lastKnownPosition );
break;
}
_state = next;
}
// ─────────────────────────────────────────────
// PERCEPTION
// ─────────────────────────────────────────────
private bool CanSeeTarget()
{
if ( _target is null ) return false;
float dist = WorldPosition.Distance( _target.WorldPosition );
if ( dist > SightRange ) return false;
Vector3 toTarget = (_target.WorldPosition - WorldPosition).Normal;
float dot = WorldRotation.Forward.Dot( toTarget );
float halfFov = MathF.Cos( MathF.PI * SightFovDegrees / 360f );
if ( dot < halfFov ) return false;
Vector3 eyePos = WorldPosition + Vector3.Up * 60f;
Vector3 targetPos = _target.WorldPosition + Vector3.Up * 60f;
var tr = Scene.Trace
.Ray( new Ray( eyePos, (targetPos - eyePos).Normal ), dist + 10f )
.WithoutTags( "monster" )
.Run();
return !tr.Hit || tr.GameObject == _target;
}
private bool CanHearTarget()
{
if ( _target is null ) return false;
float dist = WorldPosition.Distance( _target.WorldPosition );
float noise = EstimatePlayerNoise();
return dist <= _currentHearingRange * noise;
}
private float EstimatePlayerNoise()
{
if ( _target is null ) return 0f;
var pc = _target.Components.Get<PlayerController>( FindMode.EverythingInSelf );
if ( pc is null ) return 0.6f;
// Hook into pc.Velocity magnitude here for crouching/sprinting awareness
return 1.0f;
}
// ─────────────────────────────────────────────
// ADAPTATION
// ─────────────────────────────────────────────
private void TickAdaptation()
{
if ( _adaptSteps >= AdaptStepsMax ) return;
_adaptTimer -= Time.Delta;
if ( _adaptTimer > 0f ) return;
_adaptTimer = AdaptInterval;
if ( _timesPlayerEscaped == 0 ) return;
_adaptSteps++;
_timesPlayerEscaped = 0;
float t = (float)_adaptSteps / AdaptStepsMax;
_currentChaseSpeed = ChaseSpeed + _personalityAggression * 40f + t * MaxSpeedBonus;
_currentHearingRange = HearingRange + (1f - _personalityAggression) * 80f + t * MaxHearBonus;
Log.Info( $"[HorrorAI] Adapted! Step {_adaptSteps}/{AdaptStepsMax} | Speed={_currentChaseSpeed:F0} | Hearing={_currentHearingRange:F0}" );
BroadcastAdaptEvent( _adaptSteps );
}
// ─────────────────────────────────────────────
// PATROL HELPERS
// ─────────────────────────────────────────────
private void PickNewPatrolTarget()
{
if ( _agent is null ) return;
for ( int attempts = 0; attempts < 5; attempts++ )
{
var pt = Scene.NavMesh.GetRandomPoint( _startPosition, PatrolRadius );
if ( !pt.HasValue ) break;
bool tooClose = false;
foreach ( var area in _searchedAreas )
{
if ( pt.Value.Distance( area ) < 150f ) { tooClose = true; break; }
}
if ( !tooClose )
{
_patrolTarget = pt.Value;
_agent.MoveTo( _patrolTarget );
return;
}
}
var fallback = Scene.NavMesh.GetClosestPoint( _startPosition );
if ( fallback.HasValue )
{
_patrolTarget = fallback.Value;
_agent.MoveTo( _patrolTarget );
}
}
private void TickRetarget()
{
_retargetTimer -= Time.Delta;
if ( _retargetTimer > 0f ) return;
_retargetTimer = RetargetInterval;
_target = FindNearestPlayer();
}
private GameObject FindNearestPlayer()
{
GameObject nearest = null;
float nearestDist = float.MaxValue;
foreach ( var pc in Scene.GetAllComponents<PlayerController>() )
{
if ( pc.GameObject is null || !pc.GameObject.IsValid() ) continue;
float d = WorldPosition.Distance( pc.WorldPosition );
if ( d < nearestDist ) { nearestDist = d; nearest = pc.GameObject; }
}
return nearest;
}
// ─────────────────────────────────────────────
// STUCK DETECTION
// ─────────────────────────────────────────────
private void TickStuckDetection()
{
_stuckTimer += Time.Delta;
if ( _stuckTimer < 2.5f ) return;
_stuckTimer = 0f;
if ( (_state == AIState.Patrol || _state == AIState.Searching) &&
WorldPosition.Distance( _lastCheckedPos ) < 15f )
PickNewPatrolTarget();
_lastCheckedPos = WorldPosition;
}
// ─────────────────────────────────────────────
// AUDIO
// ─────────────────────────────────────────────
private void TickFootstep()
{
_footstepTimer -= Time.Delta;
if ( _footstepTimer > 0f || FootstepSound is null ) return;
_footstepTimer = _footstepInterval;
if ( WorldPosition.Distance( _lastCheckedPos ) > 1f )
BroadcastFootstep();
}
private void TickBreathing()
{
_breathTimer -= Time.Delta;
if ( _breathTimer > 0f || AmbientBreathSound is null ) return;
_breathTimer = _state == AIState.Chase ? 2f : 5f;
BroadcastBreath();
}
// ─────────────────────────────────────────────
// COLLISION / CATCH
// ─────────────────────────────────────────────
public void OnCollisionStart( Collision collision )
{
if ( !Networking.IsHost || _touched ) return;
var hit = collision.Other.GameObject;
var pc = hit.Components.Get<PlayerController>( FindMode.EverythingInSelf )
?? hit.Parent?.Components.Get<PlayerController>( FindMode.EverythingInSelf );
if ( pc is null ) return;
_touched = true;
_timesPlayerCaught++;
_state = AIState.Caught;
if ( CatchSound is not null ) BroadcastCatchSound();
var owner = pc.GameObject.Network.Owner;
if ( owner is not null )
KickPlayer( owner.SteamId );
else
Log.Warning( "AdaptiveHorrorAI: Caught player has no network owner." );
_ = ResetAfterCatch();
}
private async Task ResetAfterCatch()
{
await Task.DelaySeconds( ResetDelay );
if ( !this.IsValid() ) return;
_touched = false;
_target = null;
_state = AIState.Patrol;
_agent.MaxSpeed = PatrolSpeed;
PickNewPatrolTarget();
}
// ─────────────────────────────────────────────
// RPCs
// ─────────────────────────────────────────────
[Rpc.Broadcast]
private void KickPlayer( ulong targetId )
{
if ( Connection.Local.SteamId != targetId ) return;
Log.Info( "You were caught by the monster!" );
Game.Disconnect();
}
[Rpc.Broadcast]
private void BroadcastCatchSound() => Sound.Play( CatchSound, WorldPosition );
[Rpc.Broadcast]
private void BroadcastAlertSound()
{
if ( AlertSound is not null ) Sound.Play( AlertSound, WorldPosition );
}
[Rpc.Broadcast]
private void BroadcastFootstep()
{
if ( FootstepSound is not null ) Sound.Play( FootstepSound, WorldPosition );
}
[Rpc.Broadcast]
private void BroadcastBreath()
{
if ( AmbientBreathSound is not null ) Sound.Play( AmbientBreathSound, WorldPosition );
}
[Rpc.Broadcast]
private void BroadcastAdaptEvent( int step )
{
Log.Info( $"[HorrorAI] Monster adapted! (step {step}) — it's getting smarter..." );
}
public void OnCollisionUpdate( Collision collision ) { }
public void OnCollisionStop( CollisionStop collision ) { }
// ─────────────────────────────────────────────
// GIZMOS
// ─────────────────────────────────────────────
protected override void DrawGizmos()
{
Gizmo.Draw.Color = Color.Red.WithAlpha( 0.12f );
Gizmo.Draw.LineSphere( Vector3.Zero, ChaseRange );
Gizmo.Draw.Color = Color.Orange.WithAlpha( 0.10f );
Gizmo.Draw.LineSphere( Vector3.Zero, SightRange );
Gizmo.Draw.Color = Color.Yellow.WithAlpha( 0.10f );
Gizmo.Draw.LineSphere( Vector3.Zero, _currentHearingRange );
Gizmo.Draw.Color = Color.Cyan.WithAlpha( 0.08f );
Gizmo.Draw.LineSphere( Vector3.Zero, PatrolRadius );
string adaptLabel = _adaptSteps >= AdaptStepsMax ? "FULLY ADAPTED" : $"Adapt {_adaptSteps}/{AdaptStepsMax}";
string stateLabel = _state.ToString().ToUpper();
string targetName = _target is not null ? _target.Name : "none";
string personality = _personalityAggression > 0.6f ? "RUSHER" : (_personalityAggression < 0.4f ? "STALKER" : "BALANCED");
Gizmo.Draw.Color = Color.White;
Gizmo.Draw.ScreenText(
$"[ {stateLabel} ] → {targetName}\n" +
$"Type: {personality} | Spd: {_currentChaseSpeed:F0} | Hear: {_currentHearingRange:F0}\n" +
$"{adaptLabel} | Escapes tracked: {_timesPlayerEscaped}",
new Vector2( 10, 10 )
);
}
}