EventosAnimSistema.cs

Animation events dispatcher system for nextbot characters. Each frame it reads active animation and time for skinned model renderers, detects when authored animation events (Impact, Melee, Step, Sound, Efecto) cross and triggers the appropriate responses (brain pulses, footstep sound selection, play sound events, spawn temporary particle prefabs). It tracks per-entity state, avoids duplicate warnings, and handles editor mode special-cases.

File AccessNative Interop
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;
	}
}