Npcs/Diner/DinerNpc.cs
using Sandbox.Npcs.Layers;
using Sandbox.Npcs.Schedules;
namespace Sandbox.Npcs.Diner;
/// <summary>
/// A civilian enjoying breakfast at an outdoor cafe — until someone drives through it.
///
/// Behaviour states:
/// Sitting → vehicle gets too close → Fleeing
/// Fleeing → fear decays → Milling (looking for a chair)
/// Milling → finds a free chair → Sitting
/// Milling → no chair found → keeps milling + complaining
/// </summary>
public class DinerNpc : Npc, Component.IDamageable
{
// ── Inspector ─────────────────────────────────────────────────────────────
[Property, Sync]
public float Health { get; set; } = 100f;
/// <summary>
/// The chair this NPC considers "theirs" at spawn. They'll try to return to it.
/// Drag a BaseChair GameObject in here in the editor.
/// </summary>
[Property]
public GameObject AssignedChair { get; set; }
/// <summary>
/// The area the NPC mills around in when they can't find a seat.
/// Centre point — they'll wander within MillRadius of this.
/// </summary>
[Property]
public Vector3 HomeArea { get; set; }
[Property, Range( 100f, 1000f )]
public float MillRadius { get; set; } = 300f;
/// <summary>
/// How close a vehicle has to get before this NPC panics.
/// </summary>
[Property, Range( 50f, 500f )]
public float PanicRange { get; set; } = 200f;
/// <summary>
/// Chance, each time the customer settles, to take an ambient stroll instead of sitting.
/// 0 = always sit when a seat is free (original behaviour); 1 = always wander.
/// Raise it to make the crowd livelier.
/// </summary>
[Property, Group( "Balance" ), Range( 0f, 1f )]
public float WanderChance { get; set; } = 0.15f;
/// <summary>
/// Chance, when settling, to go inspect a nearby food item instead of sitting/wandering.
/// Only fires if there's actually food (tagged <see cref="FoodTag"/>) within MillRadius.
/// </summary>
[Property, Group( "Balance" ), Range( 0f, 1f )]
public float InspectFoodChance { get; set; } = 0.5f;
/// <summary>
/// Tag that marks something as edible/inspectable. Tag your served-food prefabs (and, for
/// laughs, peaches) with this. Karts and other props won't carry it, so customers won't
/// grab them. Peaches additionally tagged "peach" get a different reaction.
/// </summary>
[Property, Group( "Balance" )]
public string FoodTag { get; set; } = "food";
/// <summary>
/// How long a customer stays seated before getting up to re-evaluate (and maybe wander).
/// Set to 0 to sit indefinitely until scared (the original behaviour).
/// </summary>
[Property, Group( "Balance" ), Range( 0f, 120f )]
public float MaxSeatedTime { get; set; } = 18f;
/// <summary>
/// Seconds at full fear before decay starts.
/// </summary>
[Property, Group( "Balance" )]
public float FearGracePeriod { get; set; } = 4f;
/// <summary>
/// Fear units lost per second after grace period.
/// </summary>
[Property, Group( "Balance" )]
public float FearDecayRate { get; set; } = 0.12f;
// ── State ─────────────────────────────────────────────────────────────────
public float AfraidLevel
{
get
{
if ( _peakFear <= 0f ) return 0f;
if ( _timeSinceScared <= FearGracePeriod ) return _peakFear;
var decay = _timeSinceScared - FearGracePeriod;
return MathF.Max( _peakFear - decay * FearDecayRate, 0f );
}
}
public bool IsSitting { get; private set; }
public bool IsFleeing { get; private set; }
/// <summary>
/// The vehicle (or other threat) that scared this NPC.
/// </summary>
public GameObject ThreatSource { get; private set; }
private float _peakFear;
private TimeSince _timeSinceScared;
/// <summary>The chair this diner has reserved (tagged "diner_taken") while settling/sitting.</summary>
private GameObject _claimedChair;
// ── Lifecycle ─────────────────────────────────────────────────────────────
protected override void OnStart()
{
base.OnStart();
if ( HomeArea == Vector3.Zero )
HomeArea = WorldPosition;
GameObject.Tags.Add( "diner_npc" );
}
protected override void OnUpdate()
{
base.OnUpdate();
if ( IsProxy ) return;
CheckForNearbyVehicles();
}
// ── Schedule selection ────────────────────────────────────────────────────
public override ScheduleBase GetSchedule()
{
var fear = AfraidLevel;
if ( fear <= 0f && _peakFear > 0f )
{
_peakFear = 0f;
ThreatSource = null;
IsFleeing = false;
}
if ( fear > 0f && ThreatSource.IsValid() )
{
IsFleeing = true;
IsSitting = false;
ReleaseChair();
var flee = GetSchedule<DinerFleeSchedule>();
flee.Source = ThreatSource;
flee.PanicLevel = fear;
return flee;
}
IsFleeing = false;
return GetIdleSchedule();
}
/// <summary>
/// Calm behaviour: usually settle into a free seat, but sometimes take a stroll around
/// the café so customers aren't glued to chairs. Falls back to milling when there's no
/// free seat. Mirrors ScientistNpc.GetIdleSchedule's variety so the crowd stays alive.
/// </summary>
private ScheduleBase GetIdleSchedule()
{
// Sometimes wander instead of settling.
if ( Game.Random.Float() < WanderChance )
{
IsSitting = false;
ReleaseChair();
return GetSchedule<DinerWanderSchedule>();
}
// Sometimes go investigate food that's been served (or a stray peach).
if ( Game.Random.Float() < InspectFoodChance )
{
var food = FindNearbyFood();
if ( food.IsValid() )
{
IsSitting = false;
ReleaseChair();
var inspect = GetSchedule<DinerInspectFoodSchedule>();
inspect.FoodTarget = food;
inspect.IsPeach = food.Tags.Has( "peach" );
return inspect;
}
}
var chair = FindAvailableChair();
if ( chair.IsValid() )
{
IsSitting = true;
ClaimChair( chair );
var sit = GetSchedule<DinerSitSchedule>();
sit.Chair = chair;
sit.MaxSeatedTime = MaxSeatedTime;
return sit;
}
IsSitting = false;
ReleaseChair();
return GetSchedule<DinerMillSchedule>();
}
// ── Vehicle proximity check ───────────────────────────────────────────────
private void CheckForNearbyVehicles()
{
if ( AfraidLevel >= 0.8f ) return;
var nearby = Scene.FindInPhysics( new Sphere( WorldPosition, PanicRange ) );
foreach ( var obj in nearby )
{
if ( !obj.Tags.HasAny( ["vehicle", "wheel", "kart"] ) ) continue;
var rb = obj.GetComponent<Rigidbody>();
if ( rb.IsValid() && rb.Velocity.Length < 30f ) continue;
Scare( obj, 0.7f + Game.Random.Float( 0f, 0.3f ) );
EndCurrentSchedule();
return;
}
}
/// <summary>
/// Trigger fear from an external source.
/// </summary>
public void Scare( GameObject source, float intensity )
{
_peakFear = MathF.Min( _peakFear + intensity, 1f );
ThreatSource = source;
_timeSinceScared = 0;
IsSitting = false;
}
// ── Chair finding ─────────────────────────────────────────────────────────
/// <summary>
/// Find a chair to sit in. Prefers assigned chair, falls back to any free chair nearby.
/// Uses IsOccupied to check availability.
/// </summary>
public GameObject FindAvailableChair()
{
if ( AssignedChair.IsValid() )
{
var assignedSeat = AssignedChair.GetComponent<BaseChair>();
if ( IsChairFree( AssignedChair, assignedSeat ) )
return AssignedChair;
}
var nearby = Scene.FindInPhysics( new Sphere( HomeArea, MillRadius ) );
GameObject best = null;
float bestDist = float.MaxValue;
foreach ( var obj in nearby )
{
var seat = obj.GetComponent<BaseChair>();
if ( !IsChairFree( obj, seat ) ) continue;
var dist = WorldPosition.Distance( obj.WorldPosition );
if ( dist < bestDist )
{
bestDist = dist;
best = obj;
}
}
return best;
}
/// <summary>
/// Find the nearest food item (tagged <see cref="FoodTag"/>) within the mill area. Tag-gated
/// so customers only ever pick up actual food, never karts or scenery. Skips anything a
/// player is currently holding/carrying so we don't yank food out of someone's hands.
/// </summary>
public GameObject FindNearbyFood()
{
var nearby = Scene.FindInPhysics( new Sphere( HomeArea, MillRadius ) );
GameObject best = null;
float bestDist = float.MaxValue;
foreach ( var obj in nearby )
{
if ( !obj.IsValid() || !obj.Tags.Has( FoodTag ) ) continue;
// Don't grab food that's already in someone's hands (parented under a player).
if ( obj.GetComponentInParent<Player>( true ).IsValid() ) continue;
var dist = WorldPosition.Distance( obj.WorldPosition );
if ( dist < bestDist )
{
bestDist = dist;
best = obj;
}
}
return best;
}
/// <summary>
/// A chair is available to this diner if it isn't player-occupied (BaseChair.IsOccupied)
/// and isn't already reserved by a *different* diner. Our own claimed chair always reads
/// as free to us, so we can re-select it after standing up.
/// </summary>
private bool IsChairFree( GameObject chair, BaseChair seat )
{
if ( !seat.IsValid() || seat.IsOccupied ) return false;
if ( chair.Tags.Has( "diner_taken" ) && chair != _claimedChair ) return false;
return true;
}
/// <summary>
/// Reserve a chair so other diners route to a different (further) seat. NPC AI only runs
/// on the host (proxies bail early), so this scene-tag bookkeeping is race-free.
/// </summary>
private void ClaimChair( GameObject chair )
{
if ( _claimedChair == chair ) return;
ReleaseChair();
if ( !chair.IsValid() ) return;
_claimedChair = chair;
_claimedChair.Tags.Add( "diner_taken" );
}
/// <summary>Release any reserved chair so another diner can take it.</summary>
private void ReleaseChair()
{
if ( _claimedChair.IsValid() )
_claimedChair.Tags.Remove( "diner_taken" );
_claimedChair = null;
}
// ── IDamageable ───────────────────────────────────────────────────────────
void IDamageable.OnDamage( in DamageInfo damage )
{
if ( IsProxy ) return;
Health -= damage.Damage;
Scare( damage.Attacker, damage.Damage / 40f );
EndCurrentSchedule();
// A diner has been harmed — report it so the police dispatcher can raise an APB.
var attacker = damage.Attacker;
Scene.RunEvent<ICrimeEvents>( x => x.OnCrimeReported( attacker, GameObject ) );
if ( Health < 1 )
{
ReleaseChair();
Die( damage );
}
}
protected override void OnDestroy()
{
// Don't leave a chair reserved forever if we're cleaned up while seated.
ReleaseChair();
}
}