Code/BaileProcedural.cs

Component that procedurally overrides skeleton bones for a character called Clukr to make its top head bounce and eyes tilt in sync with a global rhythm. It hooks to a RitmoPersonaje event to play only while singing, reads the evaluated animation pose, computes a phase from Time.Now, and sets per-bone overrides on the SkinnedModelRenderer.SceneModel each frame.

Native Interop
namespace SBH;

using Sandbox;

/// <summary>
/// [SBHBase] El bailecito del Clukr: MIENTRAS SUENA SU LOOP, la tapa
/// (hueso TopHead) rebota hacia arriba y cae en ciclos (medio período sube,
/// medio cae, sin parar), y los ojos (eye.L / eye.R) rotan hasta 45° en el
/// eje X del hueso (el X de Blender) al mismo tempo.
/// Procedural por OVERRIDE de huesos sobre la animación ya evaluada
/// (TryGetBoneTransformAnimation lee la pose SIN nuestros overrides, así no
/// hay retroalimentación) — nada de animgraph compartido (lección aprendida:
/// huesos por-personaje ahí congelan a los demás personajes).
/// El reloj es Time.Now: todas las instancias del Clukr bailan clavadas
/// entre sí. Lo inyecta RitmoInyector junto con el ritmo.
/// </summary>
public sealed class BaileProcedural : Component
{
	[Property, Description( "Hueso que rebota hacia arriba (la tapa del Clukr)" )]
	public string HuesoRebote { get; set; } = "TopHead";

	[Property, Description( "Unidades que sube el rebote" )]
	public float Altura { get; set; } = 6f;

	[Property, Description( "Huesos de ojos que rotan al ritmo" )]
	public string OjoIzquierdo { get; set; } = "eye.L";

	[Property, Description( "Huesos de ojos que rotan al ritmo" )]
	public string OjoDerecho { get; set; } = "eye.R";

	[Property, Description( "Inclinación máxima del tambaleo de los ojos (grados) — como un platillo de batería" )]
	public float AnguloOjos { get; set; } = 45f;

	[Property, Description( "Vueltas por segundo del eje de inclinación (la precesión del platillo)" )]
	public float VueltasTambaleo { get; set; } = 2.5f;

	[Property, Description( "Ciclo completo en segundos: la mitad sube, la mitad cae (1.0 = sube 0.5s y cae 0.5s)" )]
	public float Periodo { get; set; } = 1f;

	SkinnedModelRenderer _renderer;
	RitmoPersonaje _ritmo;
	bool _enganchado;
	bool _aplicado;
	TimeUntil _cantando;

	protected override void OnEnabled()
	{
		_renderer = Components.Get<SkinnedModelRenderer>();
	}

	protected override void OnDisabled()
	{
		Desenganchar();
		Limpiar();
	}

	void Desenganchar()
	{
		if ( _enganchado && _ritmo.IsValid() )
			_ritmo.AlCantar -= AlCantar;
		_enganchado = false;
	}

	void AlCantar( float segundos ) => _cantando = segundos;

	protected override void OnUpdate()
	{
		// El RitmoPersonaje lo inyecta el RitmoInyector (puede llegar después):
		// engancharse apenas exista
		if ( !_enganchado )
		{
			_ritmo = Components.Get<RitmoPersonaje>();
			if ( _ritmo.IsValid() )
			{
				_ritmo.AlCantar += AlCantar;
				_enganchado = true;
			}
		}

		if ( !_renderer.IsValid() || _renderer.SceneModel == null ) return;

		// Solo baila mientras su música suena (la ventana la renueva cada
		// retrigger del loop); en silencio, huesos a la animación pura
		if ( _cantando || GameObject.Tags.Has( "sbh_muerto" ) )
		{
			Limpiar();
			return;
		}

		// Onda 0→1→0 por período, con el reloj GLOBAL: todos en sincro
		var fase = (Time.Now % Periodo) / Periodo;
		var onda = 0.5f - 0.5f * System.MathF.Cos( fase * System.MathF.Tau );

		var sm = _renderer.SceneModel;
		sm.ClearBoneOverrides();

		AplicarRebote( sm, HuesoRebote, onda );
		AplicarOjo( sm, OjoIzquierdo, onda );
		AplicarOjo( sm, OjoDerecho, onda );
		_aplicado = true;
	}

	void Limpiar()
	{
		if ( !_aplicado ) return;
		_aplicado = false;

		if ( _renderer.IsValid() && _renderer.SceneModel != null )
			_renderer.SceneModel.ClearBoneOverrides();
	}

	bool PoseAnimada( string nombre, out int indice, out Transform local )
	{
		indice = -1;
		local = default;

		var hueso = _renderer.Model?.Bones?.GetBone( nombre );
		if ( hueso == null ) return false;

		// Pose de la ANIMACIÓN (mundo), sin nuestros overrides — sin feedback
		if ( !_renderer.TryGetBoneTransformAnimation( hueso, out var mundo ) ) return false;

		indice = hueso.Index;
		local = _renderer.SceneModel.Transform.ToLocal( mundo );
		return true;
	}

	void AplicarRebote( SceneModel sm, string nombre, float onda )
	{
		if ( !PoseAnimada( nombre, out var indice, out var local ) ) return;

		local.Position += Vector3.Up * (Altura * onda); // arriba del modelo
		sm.SetBoneOverride( indice, local );
	}

	void AplicarOjo( SceneModel sm, string nombre, float onda )
	{
		if ( !PoseAnimada( nombre, out var indice, out var local ) ) return;

		// Tambaleo de PLATILLO: inclinación en los dos ejes locales x/y del
		// hueso, con el eje de inclinación girando (precesión). Rotar en un
		// solo eje era invisible si coincidía con la normal del disco; con
		// dos ejes ortogonales el bamboleo se ve sea cual sea la orientación.
		// La amplitud respira con la misma onda del rebote de la tapa.
		var amplitud = AnguloOjos * onda;
		var giro = Time.Now * VueltasTambaleo * System.MathF.Tau;

		local.Rotation = local.Rotation
			* Rotation.FromAxis( Vector3.Forward, amplitud * System.MathF.Sin( giro ) )
			* Rotation.FromAxis( Vector3.Left, amplitud * System.MathF.Cos( giro ) );

		sm.SetBoneOverride( indice, local );
	}
}