Npcs/Scientist/ScientistNpc.cs

An NPC class for a scientist character. Handles fear tracking, fleeing, following a player when toggled, idle behaviors, voice lines, reacting to being hurt and dying, and schedule selection for AI behavior.

NetworkingFile Access
using Sandbox.Npcs.Layers;
using Sandbox.Npcs.Schedules;

namespace Sandbox.Npcs.Scientist;

public sealed class ScientistNpc : Npc, Component.IPressable
{
	/// <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;
	private TimeSince _timeSinceStruggling;
	private GameObject _farewellTarget;

	// Voice lines by situation. Each SoundEvent picks a random clip. These replace the old
	// on-screen text -- the scientist speaks instead.
	[Property, Group( "Voice" )] public SoundEvent FollowVoice { get; set; }
	[Property, Group( "Voice" )] public SoundEvent StayVoice { get; set; }
	[Property, Group( "Voice" )] public SoundEvent ScaredVoice { get; set; }
	[Property, Group( "Voice" )] public SoundEvent IdleVoice { get; set; }
	[Property, Group( "Voice" )] public SoundEvent StuckVoice { get; set; }

	/// <summary>
	/// The player this scientist is following, if any. Press USE on the scientist to toggle.
	/// </summary>
	[Sync]
	public GameObject Leader { get; set; }

	IPressable.Tooltip? IPressable.GetTooltip( IPressable.Event e )
	{
		return Leader.IsValid()
			? new IPressable.Tooltip( "Stop following", "person_off", DisplayName )
			: new IPressable.Tooltip( "Follow me", "follow_the_signs", DisplayName );
	}

	bool IPressable.CanPress( IPressable.Event e ) => true;

	bool IPressable.Press( IPressable.Event e )
	{
		ToggleFollow( e.Source.GameObject );
		return true;
	}

	[Rpc.Host]
	private void ToggleFollow( GameObject presserObject )
	{
		if ( !presserObject.IsValid() )
			return;

		var player = presserObject.Root.GetComponent<Player>();
		if ( !player.IsValid() )
			return;

		if ( Leader == player.GameObject )
		{
			// Say goodbye to their face before wandering off - the farewell
			// schedule stands still, looks at them and delivers the line
			Leader = null;
			_farewellTarget = player.GameObject;
		}
		else
		{
			Leader = player.GameObject;
			_timeSinceStruggling = 0;
			SayVoice( FollowVoice, ["reaction", "acknowledgement"], int.MaxValue, player.GameObject );
		}

		// Re-think now so following starts/stops immediately.
		EndCurrentSchedule();
	}

	// Normal lines respect the personal cooldown. Max priority always speaks.
	// If lookAt is given, the scientist looks them in the eyes while talking.
	private void SayVoice( SoundEvent voice, TagSet tags = null,
		int priority = 1000, GameObject lookAt = null )
	{
		if ( voice is null ) return;
		if ( priority <= 1000 && !Speech.CanSpeak ) return;

		Speech.TrySay( voice, lookAt: lookAt, tags: tags, priority: priority );
	}

	// Defenceless -- flees anything dangerous, indifferent to everyone else.
	public override string Faction => Factions.Citizen;

	protected override void SetupRelationships() => Fears( Factions.Enemy, Factions.Monster );

	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" );
			}

			SayVoice( ScaredVoice, ["reaction", "fear"], 2000, _attacker );

			var flee = GetSchedule<ScientistFleeSchedule>();
			flee.Source = _attacker;
			flee.PanicLevel = fear;
			return flee;
		}

		// Flee from anything we're scared of on sight, before it even hurts us.
		var threat = Senses.GetNearestVisible( Disposition.Fearful );
		if ( threat.IsValid() )
		{
			SayVoice( ScaredVoice, ["reaction", "fear"], 2000, threat );

			var sightFlee = GetSchedule<ScientistFleeSchedule>();
			sightFlee.Source = threat;
			sightFlee.PanicLevel = 0.5f;
			return sightFlee;
		}

		// Not fleeing anything.
		_isFleeing = false;

		// A player is walking into us - they want to get past, step out of their way
		var pusher = Senses.GetPushingPlayer();
		if ( pusher.IsValid() )
		{
			var moveAside = GetSchedule<MoveAsideSchedule>();
			moveAside.Blocker = pusher;
			return moveAside;
		}

		// Someone just dismissed us - stand, face them, say goodbye, then move on.
		// Fear above still preempts; no goodbyes mid panic.
		if ( _farewellTarget.IsValid() )
		{
			var farewell = GetSchedule<ScientistFarewellSchedule>();
			farewell.Target = _farewellTarget;
			farewell.Voice = StayVoice;
			_farewellTarget = null;
			return farewell;
		}

		// Following a player who recruited us with USE. (Fear above takes priority, so we
		// still cower from danger, then resume following once it passes.)
		if ( Leader.IsValid() )
		{
			// Keep track of whether we're keeping up; if we fall too far behind for too long,
			// give up and stay put.
			if ( WorldPosition.Distance( Leader.WorldPosition ) < 350f )
				_timeSinceStruggling = 0;

			if ( _timeSinceStruggling > 6f )
			{
				var leader = Leader;
				Leader = null;
				SayVoice( StuckVoice, ["reaction", "stuck"], 2000, leader );
				return GetIdleSchedule();
			}

			var follow = GetSchedule<FollowSchedule>();
			follow.Target = Leader;
			return follow;
		}

		return GetIdleSchedule();
	}

	/// <summary>
	/// Pick a random idle behavior so the scientist doesn't just stand around.
	/// </summary>
	private ScheduleBase GetIdleSchedule()
	{
		// Occasionally mutter something while pottering about.
		SayVoice( IdleVoice );

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

	protected override void OnHurt( in DamageInfo damage )
	{
		// Escalate fear — each hit stacks, clamped to 1.
		_peakFear = MathF.Min( _peakFear + damage.Damage / 50f, 1f );
		_attacker = damage.Attacker;
		_timeSinceHurt = 0;

		// React immediately so flee picks up.
		EndCurrentSchedule();
	}

	protected override void Die( in DamageInfo damage )
	{
		damage.Attacker?.GetComponent<Player>()?.PlayerData?.AddStat( "npc.scientist.kill" );
		base.Die( damage );
	}
}