EventosAnim.cs

Static utility for editable per-character animation events. It loads/saves JSON event data from FileSystem.Data under sbh_animevents/, caches results, provides query helpers (has event, event time), and exposes tuning fields like head-tracking off, jump force, speed and per-animation speed multipliers.

File Access
namespace SBH;

using Sandbox;
using System.Collections.Generic;
using System.Linq;

/// <summary>
/// [SBHBase] EVENTOS DE ANIMACIÓN — los animevents de GMod (Impact,
/// ImpactKill, sonidos...) como datos editables en vivo: por personaje, un
/// mapa anim → eventos en FRAMES a 30fps (el idioma de Blender y del QC).
///
/// Es el hermano del sistema de partituras de caras (PartituraStore), pero
/// la línea de tiempo es cada ANIMACIÓN en vez del pulso musical. Capas de
/// origen (mismo diseño): JSON local de FileSystem.Data (lo pinta el editor
/// en vivo, o Notepad mientras tanto) → de fábrica horneada en la librería
/// (EventosDeFabrica, tools/hornear_eventos.bat) → fallback del prefab
/// (RetardoImpacto clásico: quien no tiene eventos no cambia en nada).
///
/// Consumidores hoy: NextbotBrain (Impact de CADA variante del pool de
/// ataques — antes un solo RetardoImpacto desincronizaba a 3 de 4 golpes) y
/// SbhJumpscareCine (ImpactKill de execution). Mañana: pasos, efectos, y el
/// editor visual estilo Beat.
/// </summary>
public static class EventosAnim
{
	public class Evento
	{
		public string Anim { get; set; }
		public int Frame { get; set; }
		public string Nombre { get; set; }

		/// <summary> Parámetro opcional del evento: la ruta del .sound en Sound/Step; a futuro "prefab@hueso" en Efecto. </summary>
		public string Dato { get; set; }

		/// <summary>
		/// Capa visual del editor (0 = la primera). Eventos simultáneos viven
		/// en capas distintas para verse todos; el DESPACHADOR la ignora — se
		/// disparan todas las capas por igual. JSON viejos: sin el campo, 0.
		/// </summary>
		public int Capa { get; set; }
	}

	public class Datos
	{
		public List<Evento> Eventos { get; set; } = new();

		/// <summary>
		/// Head-tracking APAGADO para este personaje (2026-08-10): en rigs de
		/// GMod donde "head" es una parte especial — la tapa del libro de
		/// Buuik, la tapa del pastel de Cake — la mirada la retuerce feo.
		/// Lo togglea el editor de Anim Events y viaja con el horneado.
		/// </summary>
		public bool SinCabeza { get; set; }

		/// <summary>
		/// Fuerza del salto EN POSESIÓN de este personaje (2026-08-10).
		/// 0 = la que trae su prefab (SaltoNextbot.FuerzaSaltoPosesion, que
		/// escribe el addon de Blender). Se afina desde el editor Anim Events
		/// y viaja horneada, como todo lo demás.
		/// </summary>
		public float FuerzaSalto { get; set; }

		/// <summary>
		/// Velocidad de CAZA afinada (2026-08-10, "el Siren patina"): unidades
		/// por segundo al perseguir y al correr poseído. 0 = la del prefab
		/// (NavMeshAgent.MaxSpeed, que escribe el addon). Editor → JSON →
		/// horneado, la tripleta de siempre.
		/// </summary>
		public float Velocidad { get; set; }

		/// <summary>
		/// VelocidadAnimacion afinada (2026-08-10): a cuántas unidades/seg de
		/// suelo corresponde la anim de marcha a ritmo 1 — LA perilla del
		/// patinaje. 0 = la del prefab.
		/// </summary>
		public float VelocidadAnim { get; set; }

		/// <summary>
		/// Multiplicador de velocidad POR ANIMACIÓN (2026-08-10, la feature
		/// agendada): anim (minúsculas) → mult. 1.5 = "el run de este bicho
		/// va 50% más rápido, para siempre". Ausente = 1 (natural). Gana
		/// sobre el tope RitmoAnimMax y sobre la regla "ataque nunca
		/// acelerado": es la palabra explícita del artista.
		/// </summary>
		public Dictionary<string, float> RitmosAnim { get; set; } = new();
	}

	/// <summary> ¿Este personaje tiene el head-tracking apagado por datos? </summary>
	public static bool CabezaApagada( string personaje )
		=> !string.IsNullOrEmpty( personaje ) && Cargar( personaje ).SinCabeza;

	/// <summary> Fuerza de salto afinada para este personaje, o 0 si manda el prefab. </summary>
	public static float FuerzaSaltoDe( string personaje )
		=> string.IsNullOrEmpty( personaje ) ? 0f : Cargar( personaje ).FuerzaSalto;

	/// <summary> Velocidad de caza afinada para este personaje, o 0 si manda el prefab. </summary>
	public static float VelocidadDe( string personaje )
		=> string.IsNullOrEmpty( personaje ) ? 0f : Cargar( personaje ).Velocidad;

	/// <summary> VelocidadAnimacion afinada para este personaje, o 0 si manda el prefab. </summary>
	public static float VelocidadAnimDe( string personaje )
		=> string.IsNullOrEmpty( personaje ) ? 0f : Cargar( personaje ).VelocidadAnim;

	/// <summary> Multiplicador de velocidad de UNA anim de este personaje (1 = natural). </summary>
	public static float RitmoAnimacionDe( string personaje, string anim )
	{
		if ( string.IsNullOrEmpty( personaje ) || string.IsNullOrEmpty( anim ) ) return 1f;

		var ritmos = Cargar( personaje ).RitmosAnim;
		if ( ritmos == null || ritmos.Count == 0 ) return 1f;

		return (ritmos.TryGetValue( anim, out var m ) || ritmos.TryGetValue( anim.ToLowerInvariant(), out m ))
			&& m > 0f ? m : 1f;
	}

	const string Carpeta = "sbh_animevents";
	const float Fps = 30f;

	static readonly Dictionary<string, Datos> _cache = new();

	/// <summary> El personaje según la convención de todo el proyecto: el nombre del vmdl. </summary>
	public static string PersonajeDe( SkinnedModelRenderer renderer )
		=> System.IO.Path.GetFileNameWithoutExtension( renderer?.Model?.ResourcePath ?? "" );

	static string Ruta( string personaje ) => $"{Carpeta}/{personaje}.json";

	public static Datos Cargar( string personaje )
	{
		if ( string.IsNullOrEmpty( personaje ) ) return new Datos();
		if ( _cache.TryGetValue( personaje, out var datos ) ) return datos;

		datos = FileSystem.Data.FileExists( Ruta( personaje ) )
			? FileSystem.Data.ReadJson<Datos>( Ruta( personaje ) )
			: null;

		// Sin JSON local mandan los eventos DE FÁBRICA horneados en la
		// librería (tools/hornear_eventos.bat): lo pintado acá viaja al
		// publicar y al equipo, igual que las partituras.
		datos ??= EventosDeFabrica.Obtener( personaje ) ?? new Datos();

		_cache[personaje] = datos;
		return datos;
	}

	public static void Guardar( string personaje, Datos datos )
	{
		FileSystem.Data.CreateDirectory( Carpeta );
		FileSystem.Data.WriteJson( Ruta( personaje ), datos );
		_cache[personaje] = datos;
	}

	/// <summary> El editor (o un reload a mano) invalida y los bots releen al próximo golpe. </summary>
	public static void Invalidar( string personaje = null )
	{
		if ( personaje == null ) _cache.Clear();
		else _cache.Remove( personaje );
	}

	/// <summary> ¿Esta anim tiene pintado al menos un evento de este nombre? </summary>
	public static bool TieneEvento( string personaje, string anim, string evento )
	{
		if ( string.IsNullOrEmpty( anim ) || string.IsNullOrEmpty( evento ) ) return false;

		return Cargar( personaje ).Eventos.Any( e =>
			string.Equals( e.Anim, anim, System.StringComparison.OrdinalIgnoreCase ) &&
			string.Equals( e.Nombre, evento, System.StringComparison.OrdinalIgnoreCase ) );
	}

	/// <summary>
	/// Segundos (a ritmo 1) del evento pedido en esa anim, o null si no está
	/// pintado — el consumidor cae a su fallback clásico.
	/// </summary>
	public static float? TiempoDe( string personaje, string anim, string evento )
	{
		if ( string.IsNullOrEmpty( anim ) || string.IsNullOrEmpty( evento ) ) return null;

		var ev = Cargar( personaje ).Eventos.FirstOrDefault( e =>
			string.Equals( e.Anim, anim, System.StringComparison.OrdinalIgnoreCase ) &&
			string.Equals( e.Nombre, evento, System.StringComparison.OrdinalIgnoreCase ) );

		return ev != null ? ev.Frame / Fps : null;
	}

	[ConCmd( "sbh_eventos_recargar", Help = "Relee los JSON de eventos de animación (sbh_animevents/) — para editar con Notepad sin reiniciar." )]
	public static void Recargar()
	{
		Invalidar();
		Log.Info( "🎞 EventosAnim: caché limpiada, los JSON se releen al próximo uso" );
	}
}