Component attached to a character that plays rhythmic loop sounds on every beat from DirectorRitmo. Chooses between normal and horror variants, alternates A/B on even/odd pulses, keeps two sound handles so variants overlap, moves sounds with the entity, and fades/stops them when the entity dies or is out of range.
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;
}
}