Code/DirectorRitmo.cs

A game-wide rhythm director GameObjectSystem that emits a pulse event on a fixed interval (4.8s) and handles a 2s silence when a "horror phase" toggles. It tracks current pulse number, phase, and exposes an event for characters to subscribe to.

namespace SBH;

using Sandbox;
using System;

/// <summary>
/// [SBHBase] El reloj del juego original, calcado del "loop controller"
/// del Sprunki de Scratch: un pulso cada 4.8 segundos (100 BPM, 2 compases
/// de 4/4) alternando "Loop 1" y "Loop 2". Los personajes RE-DISPARAN su
/// sonido en cada pulso — la sincronía es por retrigger, no hay loops
/// continuos que mantener en fase.
/// Cambio de fase (horror): corta todo, espera 2s y rearranca el reloj en
/// el pulso 0, igual que el broadcast "Phase 2" del original.
/// Es un GameObjectSystem: existe solo en toda escena, sin setup.
/// </summary>
public sealed class DirectorRitmo : GameObjectSystem
{
	/// <summary> Segundos entre pulsos: la variable Delay = 4.8 del original. </summary>
	public const float DuracionPulso = 4.8f;

	/// <summary> Silencio al cambiar de fase, como el wait 2 del original. </summary>
	public const float SilencioCambioFase = 2f;

	/// <summary> Número de pulso actual (0, 1, 2...); par = "Loop 1", impar = "Loop 2". -1 = todavía no arrancó. </summary>
	public int NumeroPulso { get; private set; } = -1;

	/// <summary> Fase horror activa (espejo estable de RitmoConVars.FaseHorror). </summary>
	public bool FaseHorror { get; private set; }

	/// <summary> Se dispara en cada pulso con su número. Los RitmoPersonaje se suscriben acá. </summary>
	public event Action<int> AlPulso;

	float _proximoPulso;
	float _silencioHasta = -1f;

	public DirectorRitmo( Scene scene ) : base( scene )
	{
		Listen( Stage.StartUpdate, 10, Latir, "DirectorRitmoPersonaje" );
		_proximoPulso = Time.Now;
	}

	/// <summary> Avance 0..1 dentro del pulso actual (para sincronizar bailes). </summary>
	public float FraccionPulso => 1f - ((_proximoPulso - Time.Now) / DuracionPulso).Clamp( 0f, 1f );

	void Latir()
	{
		// Apagado: reloj detenido; al reactivar arranca limpio desde el pulso 0
		if ( !RitmoConVars.RitmoActivado )
		{
			NumeroPulso = -1;
			_proximoPulso = Time.Now;
			return;
		}

		// Cambio de fase estilo "Phase 2": corte, 2s de silencio y reloj a cero
		if ( RitmoConVars.FaseHorror != FaseHorror )
		{
			FaseHorror = RitmoConVars.FaseHorror;
			NumeroPulso = -1;
			_silencioHasta = Time.Now + SilencioCambioFase;
			_proximoPulso = Time.Now + SilencioCambioFase;
		}

		if ( Time.Now < _silencioHasta ) return;

		if ( Time.Now >= _proximoPulso )
		{
			NumeroPulso++;
			_proximoPulso += DuracionPulso;

			// Tras una pausa larga (editor, hitch) no disparar ráfagas de pulsos
			if ( Time.Now >= _proximoPulso )
				_proximoPulso = Time.Now + DuracionPulso;

			AlPulso?.Invoke( NumeroPulso );
		}
	}
}