PanicSound.cs

Component attached to a Nextbot GameObject that plays a looping panic sound and adjusts its volume by distance to the active audio listener (player). It starts/stops the looping SoundEvent, updates the sound position and volume each frame, and exposes an Intensidad property (0..1) for other components to read.

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