Static helper for animation events per character. Defines data shapes (Evento, Datos), loads/saves JSON files under Data/sbh_animevents, caches in-memory, provides lookup helpers for head-tracking, jump force, speeds, per-animation tempo, and utilities to query event times and existence. Includes a console command to invalidate cache.
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" );
}
}