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.
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 );
}
}