Code/EventosAnimSistema.cs

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.

Native InteropFile Access
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/&lt;personaje&gt;.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;
	}
}