Npcs/Scientist/ScientistNpc.cs
using Sandbox.Npcs.Layers;
using Sandbox.Npcs.Schedules;
namespace Sandbox.Npcs.Scientist;
public sealed class ScientistNpc : Npc, Component.IDamageable
{
[Property, ClientEditable, Range( 1, 100 ), Sync]
public float Health { get; set; } = 100f;
/// <summary>
/// Current fear level (0–1). Computed from peak fear and time since last hurt.
/// </summary>
public float AfraidLevel
{
get
{
if ( _peakFear <= 0f ) return 0f;
if ( _timeSinceHurt <= FearGracePeriod ) return _peakFear;
var decayTime = _timeSinceHurt - FearGracePeriod;
return MathF.Max( _peakFear - decayTime * FearDecayRate, 0f );
}
}
/// <summary>
/// Seconds at full fear before decay begins.
/// </summary>
[Property, Group( "Balance" )]
private float FearGracePeriod { get; set; } = 3f;
/// <summary>
/// Fear units lost per second after the grace period.
/// </summary>
[Property, Group( "Balance" )]
private float FearDecayRate { get; set; } = 0.15f;
private float _peakFear;
private GameObject _attacker;
private TimeSince _timeSinceHurt;
private bool _isFleeing;
public override ScheduleBase GetSchedule()
{
var fear = AfraidLevel;
// Fear fully decayed — clear state
if ( fear <= 0f && _peakFear > 0f )
{
_peakFear = 0f;
_attacker = null;
}
// Afraid — flee from the attacker
if ( fear > 0f && _attacker.IsValid() )
{
if ( !_isFleeing )
{
_isFleeing = true;
_attacker.GetComponent<Player>()?.PlayerData?.AddStat( "npc.scientist.scare" );
}
var flee = GetSchedule<ScientistFleeSchedule>();
flee.Source = _attacker;
flee.PanicLevel = fear;
return flee;
}
_isFleeing = false;
return GetIdleSchedule();
}
/// <summary>
/// Pick a random idle behavior so the scientist doesn't just stand around.
/// </summary>
private ScheduleBase GetIdleSchedule()
{
var roll = Game.Random.Float();
if ( roll < 0.35f )
{
return GetSchedule<ScientistWanderSchedule>();
}
if ( roll < 0.60f )
{
var prop = FindNearbyProp();
if ( prop.IsValid() )
{
var inspect = GetSchedule<ScientistInspectPropSchedule>();
inspect.PropTarget = prop;
return inspect;
}
}
return GetSchedule<ScientistIdleSchedule>();
}
/// <summary>
/// Find the nearest prop within range to inspect.
/// </summary>
private GameObject FindNearbyProp()
{
// TODO: I feel like the senses layer should be able to hand all of this in a cost effective way.
var nearby = Scene.FindInPhysics( new Sphere( WorldPosition, 2048 ) );
GameObject best = null;
float bestDist = float.MaxValue;
foreach ( var obj in nearby )
{
if ( obj == GameObject ) continue;
if ( obj.GetComponent<Prop>() is null ) continue;
var dist = WorldPosition.Distance( obj.WorldPosition );
if ( dist < bestDist )
{
bestDist = dist;
best = obj;
}
}
return best;
}
void IDamageable.OnDamage( in DamageInfo damage )
{
if ( IsProxy )
return;
Health -= damage.Damage;
// Escalate fear — each hit stacks, clamped to 1
_peakFear = MathF.Min( _peakFear + damage.Damage / 50f, 1f );
_attacker = damage.Attacker;
_timeSinceHurt = 0;
// Interrupt whatever we're doing so flee picks up immediately
EndCurrentSchedule();
if ( Health < 1 )
{
_attacker?.GetComponent<Player>()?.PlayerData?.AddStat( "npc.scientist.kill" );
Die( damage );
}
}
}