Npcs/Layers/SpeechLayer.cs
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.
/// </summary>
public bool IsSpeaking => CurrentSpeech is not null;
/// <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 _subtitleEnd;
/// <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.
/// </summary>
public void Say( SoundEvent sound, float duration = 0f )
{
Say( sound, null, duration );
}
/// <summary>
/// Play a sound event with an explicit subtitle override.
/// </summary>
public void Say( SoundEvent sound, string subtitle, float duration = 0f )
{
if ( sound is null ) return;
// Stop any existing speech
Stop();
// Resolve the next sound file from the event
var soundFile = Game.Random.FromList( sound.Sounds );
; if ( !soundFile.IsValid() ) return;
// Play using the event's volume and pitch
_soundHandle = Sound.PlayFile( soundFile, sound.Volume.GetValue(), sound.Pitch.GetValue() );
if ( _soundHandle.IsValid() )
{
_soundHandle.Parent = Npc.GameObject;
}
if ( !string.IsNullOrEmpty( subtitle ) )
{
CurrentSpeech = subtitle;
}
_subtitleEnd = duration;
_lastSpoke = 0;
}
/// <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 )
{
if ( string.IsNullOrEmpty( message ) ) return;
if ( FallbackSound is not null )
{
Say( FallbackSound, message, duration );
}
else
{
// No fallback sound — just show the subtitle for the duration
Stop();
CurrentSpeech = message;
_subtitleEnd = duration;
_lastSpoke = 0;
}
}
/// <summary>
/// Stop any current speech and sound.
/// </summary>
public void Stop()
{
if ( _soundHandle.IsValid() )
{
_soundHandle.Stop();
}
CurrentSpeech = null;
}
/// <summary>
/// Whether the sound has finished and the subtitle duration has elapsed.
/// </summary>
private bool IsFinished
{
get
{
var soundDone = !_soundHandle.IsValid() || _soundHandle.IsStopped;
return soundDone && _subtitleEnd;
}
}
protected override void OnUpdate()
{
// Only the host manages speech state (sound playback, duration tracking)
if ( !IsProxy && CurrentSpeech is not null && IsFinished )
{
CurrentSpeech = null;
}
// 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();
}
}