Animation events dispatcher system for nextbot actors. Each tick it reads skinned model renderers, tracks per-entity animation time, detects when authored animation events cross the frame boundary and dispatches effects: melee/impact pulses to NextbotBrain, step sounds (surface-based or explicit), arbitrary sounds, and particle prefab effects attached to bones (spawned as temporary clones). Handles editor mode quirks and caches resources and warnings.
namespace SBH;
using Sandbox;
using System;
using System.Collections.Generic;
/// <summary>
/// [SBHBase] El DESPACHADOR de eventos de animación: cada frame lee qué
/// secuencia reproduce cada nextbot y en qué punto va (Sequence.Duration +
/// TimeNormalized, las dos APIs conquistadas del accessor), y al CRUZAR el
/// frame de un evento pintado lo dispara. Es independiente de quién maneja
/// la animación — IA, posesión o el editor: los pasos suenan igual.
///
/// Despacha: Melee (pulso de daño del Brain — multihits, pisotones que
/// lastiman), Step (sonido de pisada: Dato → convención
/// sounds/steps/<personaje>.sound → ConVar sbh.eventos.paso) y Sound
/// (el .sound del Dato) y Efecto (un prefab de partículas montado en un
/// HUESO: Dato = "ruta.prefab@hueso" — el attach de GMod). ImpactKill NO
/// pasa por acá: es el gate del cine (dispararlo doble = muerte doble).
///
/// El tag "sbh_edicion" (el actor adoptado por el editor Anim Events) muta
/// el Melee — pintar pisotones no te tiene que matar mientras editás — y el
/// scrub hacia atrás no dispara nada (solo avance real).
/// </summary>
public sealed class EventosAnimSistema : GameObjectSystem
{
const float Fps = 30f;
class Estado
{
public string Anim;
public float T;
public bool PieIzquierdo; // alternar pie en cada Step (sonidos L/R de la superficie)
}
readonly Dictionary<Guid, Estado> _estados = new();
readonly Dictionary<string, SoundEvent> _sonidos = new();
readonly HashSet<string> _avisados = new(); // rutas rotas: avisar una vez
public EventosAnimSistema( Scene scene ) : base( scene )
{
Listen( Stage.StartUpdate, 28, Despachar, "EventosAnimSistema" );
}
void Despachar()
{
foreach ( var renderer in Scene.GetAllComponents<SkinnedModelRenderer>() )
{
var go = renderer.GameObject;
if ( !go.IsValid() || !go.Tags.Has( "sbh_nextbot" ) ) continue;
if ( go.Tags.Has( "sbh_accesorio" ) ) continue;
var personaje = EventosAnim.PersonajeDe( renderer );
if ( personaje == "" ) continue;
var datos = EventosAnim.Cargar( personaje );
if ( datos.Eventos.Count == 0 ) continue;
var anim = renderer.Sequence.Name;
if ( string.IsNullOrEmpty( anim ) ) continue;
var dur = renderer.Sequence.Duration;
if ( dur <= 0.01f ) continue;
if ( !_estados.TryGetValue( go.Id, out var estado ) )
{
estado = new Estado();
_estados[go.Id] = estado;
}
// Cambio de anim: baseline a 0 — los eventos del arranque disparan
if ( estado.Anim != anim )
{
estado.Anim = anim;
estado.T = 0f;
}
// El tiempo actual dentro de la anim. Con el ANIMGRAPH puesto,
// TimeNormalized del accessor queda CONGELADO en 0 (medido en
// tz_bear, 2026-08-09): el espejo de secuencia da el NOMBRE pero
// el reloj lo llevamos NOSOTROS, como el reloj propio del editor.
// Sin graph (secuencias, editor, cine) el accessor manda como
// siempre — ahí el scrub y la pausa tienen que seguir valiendo.
float t;
if ( renderer.UseAnimGraph )
t = (estado.T + Time.Delta * renderer.PlaybackRate.Clamp( 0f, 10f )) % dur;
else
t = (renderer.Sequence.TimeNormalized.Clamp( 0f, 1f )) * dur;
var t0 = estado.T;
estado.T = t;
// Cadáveres no pisan ni suenan
var vida = go.Components.Get<Vida>();
if ( vida.IsValid() && vida.EstaMuerto ) continue;
var edicion = go.Tags.Has( "sbh_edicion" );
// Scrub hacia atrás en el editor: solo mover el baseline, sin
// disparar el "wrap" fantasma
if ( edicion && t < t0 ) continue;
foreach ( var ev in datos.Eventos )
{
if ( !string.Equals( ev.Anim, anim, StringComparison.OrdinalIgnoreCase ) ) continue;
var tiempoEv = (ev.Frame / Fps).Clamp( 0f, dur - 0.001f );
// ¿Se cruzó este frame desde el tick pasado? (con vuelta de
// loop incluida: t volvió a empezar)
var cruzado = t0 <= t
? tiempoEv > t0 && tiempoEv <= t
: tiempoEv > t0 || tiempoEv <= t;
if ( !cruzado ) continue;
if ( SbhConVars.EventosDebug )
Log.Info( $"🎞 [debug] {go.Name}: {ev.Nombre} @{anim} f{ev.Frame} (t {t0:0.000}→{t:0.000} de {dur:0.00}s)" );
switch ( ev.Nombre?.ToLowerInvariant() )
{
// Impact = la PRESENTACIÓN del golpe (sonido + fx), sin
// daño — se puede despachar tranquilo, incluso editando
case "impact":
go.Components.Get<NextbotBrain>( true )?.PulsoImpacto();
break;
case "melee":
if ( !edicion )
go.Components.Get<NextbotBrain>( true )?.PulsoMelee();
break;
case "step":
SonarPaso( go, estado, ev.Dato );
break;
case "sound":
Sonar( go, ev.Dato );
break;
case "efecto":
Efecto( go, renderer, ev.Dato );
break;
// ImpactKill: gate del Cine (dispararlo acá = muerte doble).
}
}
}
}
// Step = las pisadas DE SANDBOX: la superficie que pisa decide el sonido
// (pasto, metal, madera... — la misma receta de los NPCs del juego:
// trace al piso → Surface.SoundCollection.FootLeft/Right, alternando
// pie). Con Dato, el creador puede pisar con SU .sound en vez del de la
// superficie (pisadas de monstruo).
void SonarPaso( GameObject go, Estado estado, string dato )
{
// Pisada de gigante: el Step también sacude la pantalla (Siren Head)
go.Components.Get<PisadaGigante>()?.AlPaso( go.WorldPosition );
if ( !string.IsNullOrWhiteSpace( dato ) )
{
if ( CargarSonido( dato ) is { } propio )
Sound.Play( propio, go.WorldPosition );
return;
}
var pos = go.WorldPosition;
var trace = Scene.Trace
.Ray( pos + Vector3.Up * 20f, pos - Vector3.Up * 20f )
.IgnoreGameObjectHierarchy( go )
.Run();
if ( !trace.Hit || trace.Surface is null ) return;
estado.PieIzquierdo = !estado.PieIzquierdo;
var sonido = estado.PieIzquierdo
? trace.Surface.SoundCollection.FootLeft
: trace.Surface.SoundCollection.FootRight;
if ( sonido is null ) return;
Sound.Play( sonido, trace.HitPosition );
}
void Sonar( GameObject go, string dato )
{
if ( string.IsNullOrWhiteSpace( dato ) )
{
if ( _avisados.Add( "sound:vacio" ) )
Log.Warning( "EventosAnim: evento Sound sin ruta en Dato — pintarlo con la cajita de sonido del editor" );
return;
}
if ( CargarSonido( dato ) is { } sonido )
Sound.Play( sonido, go.WorldPosition );
}
// Efecto = un prefab (partículas, normalmente) que NACE EN UN HUESO en el
// frame pintado: Dato = "ruta/al.prefab@hueso". Es el hermano del attach
// de GMod — polvo en el pie que pisa, chispas en el arma, sangre en la
// mano que golpea. Sin "@hueso" nace en el origen del bot.
//
// One-shot como los efectos de EfectosNextbot: se clona en el lugar (NO
// se cuelga del hueso) y se limpia solo con TemporaryEffect. La escala
// sale en 1 a propósito — el hueso de un coloso trae la escala del vmdl
// adentro y multiplicaría el efecto por 10.
void Efecto( GameObject go, SkinnedModelRenderer renderer, string dato )
{
if ( string.IsNullOrWhiteSpace( dato ) )
{
if ( _avisados.Add( "efecto:vacio" ) )
Log.Warning( "EventosAnim: evento Efecto sin prefab en Dato — pintarlo con la cajita del editor (prefab@hueso)" );
return;
}
var corte = dato.LastIndexOf( '@' );
var ruta = (corte >= 0 ? dato[..corte] : dato).Trim();
var hueso = corte >= 0 ? dato[(corte + 1)..].Trim() : "";
if ( ruta.Length == 0 ) return;
// Sin el hueso (o con un nombre que este modelo no tiene) el efecto
// igual sale, en el origen del bot: mejor eso que nada invisible
var lugar = new Transform( go.WorldPosition, go.WorldRotation );
if ( hueso.Length > 0 )
{
if ( renderer.IsValid() && renderer.TryGetBoneTransform( hueso, out var tx ) )
lugar = new Transform( tx.Position, tx.Rotation );
else if ( _avisados.Add( $"hueso:{ruta}@{hueso}" ) )
Log.Warning( $"EventosAnim: el modelo no tiene el hueso '{hueso}' — el efecto '{ruta}' sale en el origen del bot (ver sbh_huesos_lista)" );
}
if ( ResourceLibrary.Get<PrefabFile>( ruta ) == null )
{
if ( _avisados.Add( $"efecto:{ruta}" ) )
Log.Warning( $"EventosAnim: no existe el prefab de efecto '{ruta}'" );
return;
}
var fx = GameObject.Clone( ruta, new CloneConfig { Transform = lugar, StartEnabled = true } );
if ( !fx.IsValid() ) return;
fx.Components.Create<TemporaryEffect>().DestroyAfterSeconds = 5f;
}
SoundEvent CargarSonido( string ruta )
{
if ( _sonidos.TryGetValue( ruta, out var cacheado ) ) return cacheado;
var sonido = ResourceLibrary.Get<SoundEvent>( ruta );
if ( sonido == null && _avisados.Add( ruta ) )
Log.Warning( $"EventosAnim: no existe el sonido '{ruta}'" );
_sonidos[ruta] = sonido;
return sonido;
}
}