Npcs/Layers/SpeechLayer.cs

NPC speech layer. Manages playing voice sounds, showing subtitles above the NPC, reserving speech in the NPC system, handling look-at while talking, and choosing non-repeating sound files.

Networking
namespace Sandbox.Npcs.Layers;

/// <summary>
/// Manages NPC speech state, plays sound files, and renders subtitle text above their head.
/// </summary>
public class SpeechLayer : BaseNpcLayer
{
	/// <summary>
	/// The subtitle text currently being shown, if any. Synced to all clients.
	/// </summary>
	[Sync] public string CurrentSpeech { get; set; }

	/// <summary>
	/// Whether the NPC is currently speaking. Voice lines usually have no subtitle,
	/// so this has to watch the sound itself, not just the subtitle text.
	/// </summary>
	public bool IsSpeaking => CurrentSpeech is not null || (_soundHandle.IsValid() && !_soundHandle.IsStopped);

	/// <summary>
	/// Minimum seconds between speeches.
	/// </summary>
	public float Cooldown { get; set; } = 8f;

	/// <summary>
	/// A generic fallback sound (e.g. a grunt or mumble) played when we're talking without a specific sound.
	/// </summary>
	public SoundEvent FallbackSound { get; set; }

	private SoundHandle _soundHandle;
	private TimeSince _lastSpoke;
	private TimeUntil _speechEnd;
	private GameObject _speechTarget;
	private bool _hasSpeechReservation;
	private readonly Dictionary<SoundEvent, List<SoundFile>> _remainingSounds = [];
	private readonly Dictionary<SoundEvent, SoundFile> _lastSounds = [];

	/// <summary>
	/// Whether the cooldown has elapsed and the NPC can speak again.
	/// </summary>
	public bool CanSpeak => _lastSpoke > Cooldown;

	/// <summary>
	/// Play a sound event and show its subtitle (if one exists) above the NPC.
	/// If <paramref name="lookAt"/> is given, the NPC looks them in the eyes
	/// while talking.
	/// </summary>
	public void Say( SoundEvent sound, float duration = 0f, GameObject lookAt = null )
	{
		TrySay( sound, duration, lookAt );
	}

	/// <summary>Attempts to play a tagged sound event.</summary>
	public bool TrySay( SoundEvent sound, float duration = 0f, GameObject lookAt = null,
		TagSet tags = null, int priority = 1000, float radius = 1500f )
	{
		return TrySay( sound, null, duration, lookAt, tags, priority, radius );
	}

	/// <summary>
	/// Play a sound event with an explicit subtitle override.
	/// </summary>
	public void Say( SoundEvent sound, string subtitle, float duration = 0f, GameObject lookAt = null )
	{
		TrySay( sound, subtitle, duration, lookAt );
	}

	/// <summary>Attempts to play a tagged sound event with a subtitle.</summary>
	public bool TrySay( SoundEvent sound, string subtitle, float duration = 0f, GameObject lookAt = null,
		TagSet tags = null, int priority = 1000, float radius = 1500f )
	{
		if ( sound is null || !Npc.IsValid() || Npc.Health < 1f ) return false;

		// Resolve the sound file host-side so every client plays the same one.
		var soundFile = PickSound( sound );
		if ( !soundFile.IsValid() ) return false;
		var speechDuration = GetSoundDuration( soundFile, duration );

		var system = Npc.Scene.GetSystem<NpcSystem>();
		if ( !system.TryBeginSpeech( Npc, speechDuration, tags, priority, radius ) )
			return false;

		StopPlayback( releaseReservation: false );
		_hasSpeechReservation = true;
		_speechTarget = lookAt;
		MarkSoundPlayed( sound, soundFile );

		PlaySound( soundFile, sound.Volume.GetValue(), sound.Pitch.GetValue() );

		if ( !string.IsNullOrEmpty( subtitle ) )
		{
			CurrentSpeech = subtitle;
		}

		_speechEnd = speechDuration;
		_lastSpoke = 0;
		return true;
	}

	// AI runs host-side, so broadcast the sound to every client -- otherwise only the host hears it.
	[Rpc.Broadcast]
	private void PlaySound( SoundFile soundFile, float volume, float pitch )
	{
		if ( !soundFile.IsValid() ) return;
		if ( _soundHandle.IsValid() )
			_soundHandle.Stop();

		// Speak through the renderer so the NPC lipsyncs to the sound
		if ( Npc.IsValid() && Npc.Renderer.IsValid() )
		{
			_soundHandle = Npc.Renderer.SpeakSound( soundFile, volume, pitch, Npc.GameObject );
			return;
		}

		_soundHandle = Sound.PlayFile( soundFile, volume, pitch );

		if ( _soundHandle.IsValid() )
		{
			_soundHandle.Parent = Npc.GameObject;
		}
	}

	/// <summary>
	/// Say a string message using the fallback sound, with the string shown as a subtitle.
	/// </summary>
	public void Say( string message, float duration = 3f, GameObject lookAt = null )
	{
		TrySay( message, duration, lookAt );
	}

	/// <summary>Attempts to show a tagged message using the fallback sound.</summary>
	public bool TrySay( string message, float duration = 3f, GameObject lookAt = null,
		TagSet tags = null, int priority = 1000, float radius = 1500f )
	{
		if ( string.IsNullOrEmpty( message ) || !Npc.IsValid() || Npc.Health < 1f ) return false;

		if ( FallbackSound is not null )
		{
			return TrySay( FallbackSound, message, duration, lookAt, tags, priority, radius );
		}

		var system = Npc.Scene.GetSystem<NpcSystem>();
		if ( !system.TryBeginSpeech( Npc, duration, tags, priority, radius ) )
			return false;

		StopPlayback( releaseReservation: false );
		_hasSpeechReservation = true;
		_speechTarget = lookAt;
		CurrentSpeech = message;
		_speechEnd = duration;
		_lastSpoke = 0;
		return true;
	}

	private SoundFile PickSound( SoundEvent sound )
	{
		if ( !_remainingSounds.TryGetValue( sound, out var remaining ) || remaining.Count == 0 )
		{
			remaining = sound.Sounds.Where( x => x.IsValid() ).Distinct().ToList();

			if ( remaining.Count > 1 && _lastSounds.TryGetValue( sound, out var last ) )
				remaining.Remove( last );

			_remainingSounds[sound] = remaining;
		}

		if ( remaining.Count == 0 )
			return null;

		return remaining[Game.Random.Int( 0, remaining.Count - 1 )];
	}

	private static float GetSoundDuration( SoundFile soundFile, float fallback )
	{
		if ( soundFile.IsLoaded )
			return soundFile.Duration;

		return fallback > 0f ? fallback : 3f;
	}

	private void MarkSoundPlayed( SoundEvent sound, SoundFile soundFile )
	{
		_remainingSounds[sound].Remove( soundFile );
		_lastSounds[sound] = soundFile;
	}

	/// <summary>
	/// Stop any current speech and sound.
	/// </summary>
	public void Stop()
	{
		StopPlayback();
	}

	private void StopPlayback( bool releaseReservation = true )
	{
		if ( releaseReservation && !IsProxy && _hasSpeechReservation && Npc.IsValid() )
			Npc.Scene.GetSystem<NpcSystem>().StopSpeech( Npc );

		if ( IsProxy )
			StopSoundLocal();
		else
			StopSound();

		CurrentSpeech = null;
		_speechTarget = null;
		_hasSpeechReservation = false;
	}

	[Rpc.Broadcast]
	private void StopSound()
	{
		StopSoundLocal();
	}

	private void StopSoundLocal()
	{
		if ( _soundHandle.IsValid() )
		{
			_soundHandle.Stop();
		}
	}

	protected override void OnUpdate()
	{
		// The Npc can be gone while we're being torn down (death destroys the hierarchy).
		if ( !Npc.IsValid() )
			return;

		// The selected sound length is authoritative, including on dedicated servers.
		if ( !IsProxy && _hasSpeechReservation && _speechEnd )
		{
			StopPlayback( releaseReservation: false );
		}

		// Look whoever we're talking to in the eyes while we speak. Re-armed each
		// frame so the gaze lingers for a moment after the line ends, then hands
		// back to whatever the NPC was looking at before.
		if ( !IsProxy && IsSpeaking && _speechTarget.IsValid() )
		{
			Npc.Animation?.AddLookTarget( _speechTarget, 1f );
		}

		// All clients draw the subtitle when speech is active
		if ( CurrentSpeech is not null )
		{
			DrawSpeech();
		}
	}

	/// <summary>
	/// Draw a simple speech bubble above the NPC.
	/// </summary>
	private void DrawSpeech()
	{
		var camera = Npc.Scene.Camera;
		if ( !camera.IsValid() ) return;

		var worldPos = Npc.WorldPosition + Vector3.Up * 80f;
		var screenPos = camera.PointToScreenPixels( worldPos, out var behind );
		if ( behind ) return;

		// Don't show subtitles through walls
		var tr = Npc.Scene.Trace.Ray( camera.WorldPosition, worldPos )
			.WithTag( "world" )
			.Run();

		if ( tr.Hit ) return;

		var text = TextRendering.Scope.Default;
		text.Text = CurrentSpeech;
		text.FontSize = 14;
		text.FontName = "Poppins";
		text.FontWeight = 500;
		text.TextColor = Color.White;
		text.Outline = new TextRendering.Outline { Color = Color.Black.WithAlpha( 0.8f ), Size = 3, Enabled = true };
		text.FilterMode = Rendering.FilterMode.Point;

		Npc.DebugOverlay.ScreenText( screenPos, text, TextFlag.CenterBottom );
	}

	public override void ResetLayer()
	{
		Stop();
	}
}