Component that plays a looping panic sound for a Nextbot. It starts a positional looping SoundEvent on enable, updates its position and volume each tick based on the nearest AudioListener/player distance, exposes current intensity, and logs diagnostics when enabled.
namespace SBH;
using Sandbox;
using System.Linq;
/// <summary>
/// Reproduce un sonido en loop cuyo volumen crece cuando el jugador
/// está cerca. El clásico "pánico" de nextbot.
/// Va en el mismo GameObject que el NextbotBrain.
/// </summary>
public sealed class PanicSound : Component
{
[Property, Description( "Sonido en loop (el SoundEvent debe tener Looping activado)" )]
public SoundEvent Sonido { get; set; }
[Property, Description( "Distancia a la que el sonido está a volumen máximo" )]
public float DistanciaMinima { get; set; } = 150f;
[Property, Description( "Distancia a la que el sonido se deja de oír" )]
public float DistanciaMaxima { get; set; } = 1500f;
[Property, Description( "Volumen máximo (0 a 1)" )]
public float VolumenMaximo { get; set; } = 1f;
/// <summary>
/// Qué tan fuerte está sonando el pánico ahora mismo (0 a 1).
/// Otros componentes lo usan, p.ej. ExpresionesNextbot mueve la boca
/// cuando el sprunki "canta".
/// </summary>
public float Intensidad { get; private set; }
SoundHandle _handle;
protected override void OnEnabled()
{
if ( Sonido == null ) return;
_handle = Sound.Play( Sonido, WorldPosition );
// Posicional (así el estéreo dice de qué lado viene el bicho); el
// volumen por distancia lo pone OnUpdate contra el oyente real.
if ( _handle != null ) _handle.Volume = 0f;
}
protected override void OnDisabled()
{
_handle?.Stop();
_handle = null;
}
protected override void OnUpdate()
{
if ( _handle == null || !_handle.IsValid() ) return;
// El sonido viaja pegado al bot
_handle.Position = WorldPosition;
if ( !BuscarOyente( out var oyente ) )
{
Intensidad = 0f;
_handle.Volume = 0f;
return;
}
// Volumen: 1 cuando está a DistanciaMinima o menos, 0 a DistanciaMaxima o más
var distancia = oyente.Distance( WorldPosition );
var t = 1f - ( (distancia - DistanciaMinima) / (DistanciaMaxima - DistanciaMinima) );
Intensidad = t.Clamp( 0f, 1f );
_handle.Volume = Intensidad * VolumenMaximo;
// Diagnóstico: sbh.panic.debug 1
if ( PanicDebug.Activo && Time.Now >= _proximoLog )
{
_proximoLog = Time.Now + 1f;
var oido = Scene.GetAllComponents<AudioListener>()
.Where( a => a.Active )
.Select( a => a.WorldPosition )
.FirstOrDefault();
Log.Info( $"[panic] {GameObject.Name} · bot {WorldPosition} · cámara {(Scene.Camera.IsValid() ? Scene.Camera.WorldPosition.ToString() : "-")} " +
$"· oído {oido} · fuente {_handle.Position} · dist {distancia:0} · vol {_handle.Volume:0.00} " +
$"· listenLocal {_handle.ListenLocal} · sonando {_handle.IsPlaying} · finished {_handle.Finished}" );
}
}
/// <summary>
/// Desde DÓNDE ESCUCHA el jugador. Se pregunta al AudioListener, que es lo
/// que el motor usa de verdad para oír; ni el cuerpo del jugador ni la
/// cámara sirven:
/// - el CUERPO se queda estacionado mientras poseés un bot (era el bug
/// histórico: la música se apagaba al alejarte de tu propio cuerpo);
/// - la CÁMARA no mueve su GameObject en posesión (SbhPosesion es un
/// ICameraModifier: la reubica recién al dibujar), así que su
/// WorldPosition se queda clavado donde estabas.
/// Medido en juego el 2026-08-17: el AudioListener SÍ viaja con el bot.
/// </summary>
bool BuscarOyente( out Vector3 pos )
{
var oido = Scene.GetAllComponents<AudioListener>().FirstOrDefault( a => a.IsValid() && a.Active );
if ( oido.IsValid() )
{
pos = oido.WorldPosition;
return true;
}
if ( Scene.Camera.IsValid() )
{
pos = Scene.Camera.WorldPosition;
return true;
}
var jugador = Scene.GetAllComponents<PlayerController>()
.OrderBy( p => p.WorldPosition.Distance( WorldPosition ) )
.FirstOrDefault();
pos = jugador.IsValid() ? jugador.WorldPosition : Vector3.Zero;
return jugador.IsValid();
}
float _proximoLog;
}
/// <summary> ConVar de diagnóstico de la música de pánico: sbh.panic.debug 1 </summary>
public static class PanicDebug
{
[ConVar( "sbh.panic.debug" )]
public static bool Activo { get; set; }
}