RitmoPersonaje.cs

Component attached to a character that plays two alternating loop sound variants (A and B) on each beat from DirectorRitmo. It chooses normal or horror loop variants, restarts the same-slot sound on retrigger, positions sounds at the character, fades/stops them when the character dies, and emits an event when it sings.

Native Interop
namespace SBH;

using Sandbox;

/// <summary>
/// [SBHBase] El ritmo de un sprunki: en CADA pulso del DirectorRitmo
/// dispara su loop (variante A en "Loop 1", B en "Loop 2", como el juego
/// original) si está "en pantalla" (cerca de la cámara) y vivo. Cada
/// variante suena entonces cada 9.6s, intercaladas — el solape de sus colas
/// es lo que hace el loop continuo (clave en los sets horror de ~7s).
/// Re-disparar la MISMA variante corta su disparo anterior (semántica de
/// Scratch: "start sound" reinicia el sonido), así los audios que pasan de
/// 9.6s (creepy, better_windup...) se recortan solos como en el original.
/// No depende de SBHBase: detecta la muerte por la presencia del ragdoll
/// (ModelPhysics o el Rigidbody del plan B de MuerteNextbot).
/// </summary>
public sealed class RitmoPersonaje : Component
{
	[Property, Description( "Loop normal, variante A (pulsos 'Loop 1'). Vacío = personaje mudo en fase normal (como Black en el original)" )]
	public SoundEvent LoopNormalA { get; set; }

	[Property, Description( "Loop normal, variante B (pulsos 'Loop 2'). Vacío = repite la A" )]
	public SoundEvent LoopNormalB { get; set; }

	[Property, Description( "Loop de la fase horror, variante A. Vacío = sigue con el normal" )]
	public SoundEvent LoopHorrorA { get; set; }

	[Property, Description( "Loop de la fase horror, variante B. Vacío = repite la A" )]
	public SoundEvent LoopHorrorB { get; set; }

	[Property, Description( "Distancia máxima a la cámara para que dispare su ritmo — el 'estar en pantalla'" )]
	public float DistanciaAudible { get; set; } = 3000f;

	[Property, Description( "Segundos del fade de silencio al morir (0 = corte seco)" )]
	public float FadeMuerte { get; set; } = 0.35f;

	DirectorRitmo _director;

	// Un handle por variante (0 = A/pulsos pares, 1 = B/impares): igual que los
	// pares de sonidos del original (bass3/bass4, amen3/amen4...), cada slot se
	// reinicia a sí mismo cada 9.6s sin cortar al otro
	readonly SoundHandle[] _handles = new SoundHandle[2];
	readonly float[] _volumenes = { 1f, 1f }; // volumen al disparar (respeta el del .sound)
	float _fade;              // avance del fade de muerte
	bool _muriendo;

	protected override void OnEnabled()
	{
		_director = Scene.GetSystem<DirectorRitmo>();
		if ( _director != null )
			_director.AlPulso += EnPulso;
	}

	protected override void OnDisabled()
	{
		if ( _director != null )
			_director.AlPulso -= EnPulso;

		for ( var i = 0; i < _handles.Length; i++ )
		{
			_handles[i]?.Stop();
			_handles[i] = null;
		}
	}

	protected override void OnUpdate()
	{
		// Los loops viajan con el personaje (si no, quedan sonando donde arrancaron)
		for ( var i = 0; i < _handles.Length; i++ )
		{
			if ( _handles[i] != null && _handles[i].IsValid() )
				_handles[i].Position = WorldPosition;
		}

		// Muerte: el ritmo se apaga YA, con un fade cortito para que no haga
		// click — la cola musical no debe sobrevivir al ragdoll
		var muerto = EstaMuerto();

		if ( muerto && !_muriendo )
		{
			_muriendo = true;
			_fade = 0f;
		}
		else if ( !muerto )
		{
			_muriendo = false; // por si algo lo revive: vuelve a sonar
		}

		if ( _muriendo )
		{
			_fade += Time.Delta;
			var restante = FadeMuerte > 0f ? 1f - (_fade / FadeMuerte) : 0f;

			for ( var i = 0; i < _handles.Length; i++ )
			{
				if ( _handles[i] == null || !_handles[i].IsValid() ) continue;

				if ( restante <= 0f )
				{
					_handles[i].Stop();
					_handles[i] = null;
				}
				else
				{
					_handles[i].Volume = _volumenes[i] * restante;
				}
			}
		}
	}

	void EnPulso( int numero )
	{
		if ( !PuedeSonar() ) return;

		var horror = _director.FaseHorror && (LoopHorrorA != null || LoopHorrorB != null);

		// Cada pulso dispara: A en los pares ("Loop 1"), B en los impares
		// ("Loop 2"). Con B vacía, el mismo sonido ocupa los dos slots — igual
		// que los pares duplicados del original (amen3/amen4, kick/kick2)
		var esA = numero % 2 == 0;

		var sonido = Elegir( horror, esA );
		if ( sonido == null ) return;

		var slot = esA ? 0 : 1;

		// Re-disparar la variante REINICIA su disparo anterior (semántica
		// Scratch); la cola de la OTRA variante sigue sonando — ese solape
		// intercalado es el loop continuo del original
		_handles[slot]?.Stop();
		_handles[slot] = Sound.Play( sonido, WorldPosition );

		if ( _handles[slot] != null && _handles[slot].IsValid() )
			_volumenes[slot] = _handles[slot].Volume;

		// Aviso genérico "estoy cantando este pulso" (lo consume el puente
		// del juego para la cara de canto de las expresiones)
		AlCantar?.Invoke( DirectorRitmo.DuracionPulso );
	}

	/// <summary>
	/// Disparado en cada retrigger del loop, con los segundos que cubre hasta
	/// el próximo (un pulso, 4.8). Quien quiera animar el canto se cuelga acá.
	/// </summary>
	public event System.Action<float> AlCantar;

	SoundEvent Elegir( bool horror, bool esA )
	{
		if ( horror )
			return esA ? (LoopHorrorA ?? LoopHorrorB) : (LoopHorrorB ?? LoopHorrorA);

		return esA ? (LoopNormalA ?? LoopNormalB) : (LoopNormalB ?? LoopNormalA);
	}

	/// <summary>
	/// Cadáver = tag "sbh_muerto" (lo pone MuerteNextbot) o ragdoll activo.
	/// OJO: no mirar Rigidbody — un bot alzado por la physgun (AgarreNextbot)
	/// lleva Rigidbody temporal y está bien vivo: que siga sonando en el aire.
	/// </summary>
	bool EstaMuerto()
	{
		if ( GameObject.Tags.Has( "sbh_muerto" ) ) return true;
		if ( Components.Get<ModelPhysics>().IsValid() ) return true;
		return false;
	}

	bool PuedeSonar()
	{
		if ( EstaMuerto() ) return false;

		// "En pantalla": cerca de la cámara (el falloff del .sound hace el resto)
		var cam = Scene.Camera;
		if ( cam.IsValid() && cam.WorldPosition.Distance( WorldPosition ) > DistanciaAudible )
			return false;

		return true;
	}
}