NextbotBrain component for a nextbot NPC. It steers a NavMeshAgent to wander, chase the nearest enemy (using optional PercepcionNextbot), animate via SkinnedModelRenderer (sequence or animgraph), handle attack timing, apply damage and push impulses, and manage separation/unsticking between bots.
namespace SBH;
using Sandbox;
using System.Linq;
/// <summary>
/// [SBHBase] Estados del cerebro de un nextbot.
/// </summary>
public enum EstadoNextbot
{
/// <summary> Sin objetivo: pasea por el navmesh cerca de su posición. </summary>
Deambulando,
/// <summary> Percibe a un enemigo y lo persigue. </summary>
Persiguiendo,
/// <summary> Perdió de vista al enemigo: va a su última posición conocida. </summary>
Buscando,
}
/// <summary>
/// [SBHBase] Cerebro de nextbot: persigue por navmesh al enemigo vivo más
/// cercano (cualquier IVida que no sea de su bando, según Faccion — incluye
/// componentes Vida y jugadores de sandbox),
/// anima según velocidad, lo mira cuando está quieto y lo daña al alcanzarlo.
/// Sin enemigo a la vista, deambula por el mapa.
/// Requiere en el mismo GameObject: NavMeshAgent + SkinnedModelRenderer.
/// Opcionales: PercepcionNextbot (sentidos), Faccion (bandos).
/// </summary>
public sealed class NextbotBrain : Component
{
[RequireComponent] NavMeshAgent Agent { get; set; }
[RequireComponent] SkinnedModelRenderer Renderer { get; set; }
// ---------- Configurable desde el Inspector ----------
[Property, Description( "Nombre visible del personaje (el PrintName de GMod): se usa como nombre del GameObject, así aparece en el kill feed y los logs. Vacío = deja el nombre del prefab" )]
public string NombreVisible { get; set; } = "";
[Property, Description( "Distancia a la que golpea al jugador" )]
public float RangoAtaque { get; set; } = 70f;
[Property, Description( "Daño por golpe" )]
public float Danio { get; set; } = 20f;
[Property, Description( "Segundos entre ataques" )]
public float CooldownAtaque { get; set; } = 1.5f;
[Property, Description( "Empuje del golpe sobre la víctima (0 = sin empuje)" )]
public float FuerzaEmpuje { get; set; } = 300f;
[Property, Description( "Segundos desde que arranca la anim de ataque hasta que el golpe conecta — el equivalente al frame del evento melee en el QC de GMod" )]
public float RetardoImpacto { get; set; } = 0.25f;
[Property, Description( "Velocidad con la que sale despedido el ragdoll cuando el golpe es mortal — la ejecución de los nextbots 3D de GMod (0 = desactivado)" )]
public float FuerzaEjecucion { get; set; } = 1500f;
[Property, Description( "Velocidad mínima para considerar que camina" )]
public float UmbralCaminar { get; set; } = 5f;
[Property, Description( "Velocidad de giro al mirar al jugador estando quieto" )]
public float VelocidadGiro { get; set; } = 8f;
[Property, Description( "Nombre de la secuencia de caminar en el vmdl" )]
public string AnimCaminar { get; set; } = "walk";
[Property, Description( "Nombre de la secuencia de reposo en el vmdl" )]
public string AnimIdle { get; set; } = "idle";
[Property, Description( "Secuencia de CORRER (vacío = no tiene: se acelera la de caminar hasta el tope de ritmo, como GMod). El addon la escribe sola si el personaje trae la anim" )]
public string AnimCorrer { get; set; } = "";
[Property, Description( "Velocidad a partir de la cual usa la anim de correr en vez de caminar" )]
public float UmbralCorrer { get; set; } = 150f;
[Property, Description( "Cuántas veces más rápido que caminar está autorada la anim de correr (referencia para su ritmo natural)" )]
public float FactorAnimCorrer { get; set; } = 2f;
[Property, Description( "Nombre de la secuencia de ataque en el vmdl (vacío = sin animación)" )]
public string AnimAtaque { get; set; } = "attack";
[Property, Description( "Segundos que dura la animación de ataque en pantalla" )]
public float DuracionAnimAtaque { get; set; } = 0.5f;
[Property, Description( "Parámetro bool del animgraph que activa caminar (solo se usa si el modelo trae animgraph; si no, secuencias directas)" )]
public string ParamCaminar { get; set; } = "b_move";
[Property, Description( "Parámetro bool auto-reset del animgraph que dispara el ataque (solo se usa si el modelo trae animgraph)" )]
public string ParamAtaque { get; set; } = "b_attack";
// --- Graph POR PERSONAJE (2026-08-09): parámetros que solo existen en
// los graphs generados por tools/generar_graph_personaje.py. En el graph
// compartido no están y el Set se ignora en silencio: cero cambio.
[Property, Description( "Parámetro bool del animgraph que activa correr (solo en graphs por personaje con estado Run)" )]
public string ParamCorrer { get; set; } = "b_run";
[Property, Description( "Parámetro int del animgraph que elige la variante de ataque (el Brain sortea, el selector del graph obedece)" )]
public string ParamIndiceAtaque { get; set; } = "i_attack";
[Property, Description( "Sonido al golpear al jugador (opcional)" )]
public SoundEvent SonidoAtaque { get; set; }
// --- Cámara de posesión autorada (2026-07-29): las escribe el addon
// desde el objeto '3rd_person' del blend. 0 = defaults de SbhPosesion
// (los sprunkis existentes no cambian nada).
[Property, Description( "Cámara de posesión: distancia de la órbita (0 = default de SbhPosesion)" )]
public float CamaraDistancia { get; set; } = 0f;
[Property, Description( "Cámara de posesión: altura del punto que orbita, sobre los pies (0 = default)" )]
public float CamaraAltura { get; set; } = 0f;
[Property, Description( "Cámara de posesión: FOV en grados (0 = el de la escena)" )]
public float CamaraFov { get; set; } = 0f;
[Property, Description( "Distancia mínima que intenta mantener con otros nextbots — evita la 'torta' de personajes al spawnear juntos o al converger sobre la presa (0 = sin separación)" )]
public float RadioSeparacion { get; set; } = 44f;
[Property, Description( "Cuánto se desvía para separarse de sus compañeros (unidades del paso al costado)" )]
public float FuerzaSeparacion { get; set; } = 80f;
[Property, Description( "Radio en el que elige puntos al deambular sin objetivo" )]
public float RadioDeambular { get; set; } = 500f;
[Property, Description( "Velocidad al deambular (al perseguir usa la del NavMeshAgent)" )]
public float VelocidadDeambular { get; set; } = 100f;
[Property, Description( "Velocidad a la que la anim de caminar se ve natural; a más velocidad real, la anim se acelera en proporción, estilo GMod (0 = desactivado)" )]
public float VelocidadAnimacion { get; set; } = 100f;
[Property, Description( "Tope de aceleración de la anim al correr — sin tope la marcha rápida se veía en cámara rápida" )]
public float RitmoAnimMax { get; set; } = 1.6f;
[Property, Description( "Pausa mínima entre paseos, en segundos" )]
public float PausaDeambularMin { get; set; } = 2f;
[Property, Description( "Pausa máxima entre paseos, en segundos" )]
public float PausaDeambularMax { get; set; } = 6f;
/// <summary> Estado actual del cerebro (solo lectura, para debug y otros componentes). </summary>
public EstadoNextbot Estado { get; private set; } = EstadoNextbot.Deambulando;
/// <summary>
/// Interruptor individual: en true ESTE bot queda congelado en idle — no
/// piensa, no percibe, no salta — aunque la IA global esté encendida. Lo
/// togglea la tecla SbhAiTarget apuntándolo; mientras dura lleva un
/// contorno celeste tenue para distinguirlo de los bots vivos.
/// </summary>
public bool IaSuspendida { get; set; }
/// <summary>
/// Velocidad de persecución: la afinada por personaje en el editor Anim
/// Events si existe (2026-08-10, "el Siren patina"), si no la MaxSpeed
/// original del agente. El cerebro alterna la del agente entre esta y
/// VelocidadDeambular; quien tome el control del bot (posesión) puede
/// leer acá la velocidad "real" — y hereda el ajuste EN VIVO.
/// </summary>
public float VelocidadCaza
{
get
{
var afinada = EventosAnim.VelocidadDe( _personaje );
return afinada > 0f ? afinada : _velocidadPersecucion;
}
}
/// <summary>
/// Disparado cuando un golpe conecta de verdad (daño ya aplicado). Pasa la
/// víctima (puede ya no ser válida si el golpe la mató) y el punto del
/// impacto, que siempre vale. Lo consumen efectos, sonidos, estadísticas.
/// </summary>
public event System.Action<IVida, Vector3> AlGolpear;
/// <summary>
/// Disparado cuando ARRANCA un ataque (la anim recién empieza; el golpe
/// conecta después, con AlGolpear). Lo usan las expresiones para poner la
/// cara de ataque mientras dura la animación — el equivalente de los
/// eventos SmileState/AngryState del QC de GMod.
/// </summary>
public event System.Action AlAtacar;
// ---------- Estado interno ----------
TimeSince _tiempoDesdeAtaque;
TimeSince _desdeDesapilo;
TimeSince _desdeSeparacion;
TimeSince _desdeArranque;
Vector3 _separacion; // empuje horizontal lejos de los compañeros (0..1 de intensidad)
IVida _golpePendiente; // víctima del cabezazo en curso; conecta a los RetardoImpacto segundos
HighlightOutline _marcaSuspension; // contorno celeste mientras IaSuspendida
PercepcionNextbot _percepcion;
bool _usaAnimgraph; // el modelo trae animgraph: parámetros en vez de secuencias
float _velocidadPersecucion;
Vector3? _puntoDeambulo;
TimeSince _desdeDeambulo;
float _pausaActual;
protected override void OnStart()
{
// El nombre visible manda: kill feed, logs y hierarchy lo muestran
if ( !string.IsNullOrWhiteSpace( NombreVisible ) )
GameObject.Name = NombreVisible;
_tiempoDesdeAtaque = 999f; // listo para atacar desde el arranque
_desdeArranque = 0f; // gracia del paso de apertura (recién nacido)
_percepcion = Components.Get<PercepcionNextbot>();
_personaje = EventosAnim.PersonajeDe( Renderer ); // clave de los eventos pintados
_usaAnimgraph = Renderer.UseAnimGraph
&& (Renderer.AnimationGraph != null || Renderer.Model?.AnimGraph != null);
_velocidadPersecucion = Agent.MaxSpeed;
_aceleracionOriginal = Agent.Acceleration;
_pausaActual = Game.Random.Float( PausaDeambularMin, PausaDeambularMax );
_desdeDeambulo = 0f;
}
float _aceleracionOriginal = 300f;
/// <summary>
/// Aceleración acorde a la velocidad (2026-08-10, "cerca mío corre a mi
/// velocidad"): la zona de frenado del agente es v²/2a — con la velocidad
/// de coloso y la aceleración de sprunki (300), el bot frenaba desde ~800
/// unidades y llegaba gateando. Escalarla con la marcha achica esa zona
/// al cuadrado y le da arranques dignos en las correcciones cortas.
/// </summary>
public float AceleracionPara( float velocidad )
=> System.MathF.Max( _aceleracionOriginal, velocidad * 2f );
protected override void OnUpdate()
{
if ( Agent == null || !Agent.IsValid() ) return;
if ( Renderer == null || !Renderer.IsValid() ) return;
// Interruptor individual (tecla apuntando al bot): estatua total en
// idle — a diferencia del switch global acá NI la separación corre,
// la gracia es poder dejarlo clavado donde está
ActualizarMarcaSuspension();
if ( IaSuspendida )
{
_golpePendiente = null;
Agent.Stop();
Animar( AnimIdle );
AjustarRitmo( false );
return;
}
// Interruptor global: IA apagada = quieto en idle, PERO la separación
// sigue viva — la torta de personajes se abre igual (solo el paso al
// costado: sin caza, sin deambular, sin golpes)
if ( !SbhConVars.IaActivada )
{
_golpePendiente = null;
CalcularSeparacion();
if ( _separacion.Length > 0.4f && _desdeArranque > 0.5f )
{
Agent.MaxSpeed = VelocidadDeambular;
Agent.Acceleration = AceleracionPara( VelocidadDeambular ); // vuelve a la original
Agent.MoveTo( WorldPosition + _separacion * FuerzaSeparacion );
var abriendose = Agent.Velocity.Length > UmbralCaminar;
Animar( AnimLocomocion( abriendose, EstaCorriendo ) );
AjustarRitmo( abriendose );
}
else
{
Agent.Stop();
Animar( AnimIdle );
AjustarRitmo( false );
}
return;
}
// El golpe en curso conecta aunque el objetivo muera o se pierda después
ProcesarImpactoPendiente();
// Si quedó parado encima de un compañero, bajarse (si no, se traban los dos)
Desapilarse();
// Y si está apretujado contra compañeros, empuje para abrirse
CalcularSeparacion();
// 1. Elegir enemigo: con percepción usa los sentidos (cono de visión,
// oído, memoria); sin ella, modo clásico omnisciente.
IVida enemigo;
Vector3 destino;
bool loPercibe;
if ( _percepcion.IsValid() )
{
// IsValid además de null: el objetivo puede haber sido destruido
// (jugador muerto) y la percepción tarda un escaneo en olvidarlo
enemigo = _percepcion.ObjetivoActual;
if ( enemigo == null || !enemigo.IsValid() )
{
Deambular();
return;
}
loPercibe = _percepcion.ObjetivoVisible;
// Si lo perdió de vista, va a donde lo vio por última vez
destino = loPercibe ? enemigo.WorldPosition : _percepcion.UltimaPosicionConocida;
}
else
{
enemigo = BuscarEnemigoMasCercano();
if ( enemigo == null )
{
Deambular();
return;
}
loPercibe = true;
destino = enemigo.WorldPosition;
}
// 2. Perseguir a velocidad de caza (la afinada del editor si existe),
// con la aceleración escalada — si no, la zona de frenado v²/2a de un
// bicho rápido lo hace llegar gateando
Estado = loPercibe ? EstadoNextbot.Persiguiendo : EstadoNextbot.Buscando;
Agent.MaxSpeed = VelocidadCaza;
Agent.Acceleration = AceleracionPara( VelocidadCaza );
_puntoDeambulo = null;
// Ya encima del objetivo: frenar en vez de seguir empujando hacia su
// posición exacta — evita traspasar al jugador y el "baile" en círculos
// cuando dos bots intentan ocupar el mismo punto. El agente no frena en
// seco: hay que ordenar el freno con su distancia de frenada (v²/2a) de
// anticipación o se pasa de largo por inercia.
var distancia = enemigo.WorldPosition.Distance( WorldPosition );
var rapidez = Agent.Velocity.Length;
var frenada = Agent.Acceleration > 0f ? (rapidez * rapidez) / (2f * Agent.Acceleration) : 0f;
if ( loPercibe && distancia < RangoAtaque * 0.85f + frenada )
{
// Muy apretujado alrededor de la presa: paso al costado en vez de
// quedarse clavado adentro de un compañero
if ( _separacion.Length > 0.6f )
Agent.MoveTo( WorldPosition + _separacion * FuerzaSeparacion );
else
Agent.Stop();
}
else
{
// El empuje de separación desvía el destino: la manada rodea a la
// presa en vez de converger TODOS al mismo punto exacto
Agent.MoveTo( destino + _separacion * FuerzaSeparacion );
}
// 3. Animar: si acaba de golpear, la animación de ataque manda;
// si no, según velocidad real del agente
var moviendose = Agent.Velocity.Length > UmbralCaminar;
if ( !string.IsNullOrEmpty( AnimAtaque )
&& _tiempoDesdeAtaque < DuracionAnimAtaque / RitmoDeAnim( _ataqueActual ?? AnimAtaque ) )
{
Animar( _ataqueActual ?? AnimAtaque );
Renderer.PlaybackRate = RitmoDeAnim( _ataqueActual ?? AnimAtaque );
}
else
{
Animar( AnimLocomocion( moviendose, EstaCorriendo ) );
AjustarRitmo( moviendose );
}
// 3b. Si está quieto, girar suavemente hacia el destino
if ( !moviendose )
{
MirarHacia( destino );
}
// 4. Atacar si está al alcance y pasó el cooldown. SIN exigir línea de
// visión: a distancia de cabezazo el contacto alcanza — el cono de
// visión fallando con el enemigo encima causaba ataques "que no salen".
if ( distancia < RangoAtaque && _tiempoDesdeAtaque > CooldownEfectivo )
{
Atacar( enemigo );
}
}
// ---------- Lógica interna ----------
/// <summary>
/// Sin objetivo: alterna pausas y paseos cortos a puntos aleatorios
/// del navmesh alrededor de su posición.
/// </summary>
void Deambular()
{
Estado = EstadoNextbot.Deambulando;
Agent.MaxSpeed = VelocidadDeambular;
Agent.Acceleration = AceleracionPara( VelocidadDeambular ); // vuelve a la original
// ¿Sin punto elegido? Esperar la pausa y elegir uno nuevo
if ( _puntoDeambulo == null )
{
// Apretujado (recién spawneados en el mismo punto): abrirse
// caminando — el paso al costado se maneja como un mini-paseo.
// Medio segundo de gracia al nacer: la conversión de fase recrea
// el bot y el paso inmediato lo giraba apenas aparecer (la
// percepción tarda un escaneo en retomar la caza); la "pasta"
// del spawn masivo se abre igual, medio segundo después
if ( _separacion.Length > 0.4f && _desdeArranque > 0.5f )
{
_puntoDeambulo = WorldPosition + _separacion * FuerzaSeparacion;
_desdeDeambulo = 0f;
Agent.MoveTo( _puntoDeambulo.Value );
return;
}
Animar( AnimIdle );
AjustarRitmo( false );
if ( _desdeDeambulo < _pausaActual ) return;
var punto = Scene.NavMesh?.GetRandomPoint( WorldPosition, RadioDeambular );
if ( punto == null )
{
_desdeDeambulo = 0f; // navmesh no disponible: reintentar luego
return;
}
_puntoDeambulo = punto.Value;
_desdeDeambulo = 0f;
Agent.MoveTo( punto.Value );
return;
}
// Caminando hacia el punto
var moviendose = Agent.Velocity.Length > UmbralCaminar;
Animar( AnimLocomocion( moviendose, EstaCorriendo ) );
AjustarRitmo( moviendose );
var llego = WorldPosition.Distance( _puntoDeambulo.Value ) < 50f;
var atascado = _desdeDeambulo > 15f; // por si el punto es inalcanzable
if ( llego || atascado )
{
_puntoDeambulo = null;
_desdeDeambulo = 0f;
_pausaActual = Game.Random.Float( PausaDeambularMin, PausaDeambularMax );
Agent.Stop();
}
}
IVida BuscarEnemigoMasCercano()
{
var miFaccion = Components.Get<Faccion>();
return Vidas.Todas( Scene )
.Where( v => v.IsValid() ) // destruidos fuera
.Where( v => v.GameObject != GameObject ) // no atacarse a sí mismo
.Where( v => !v.EstaMuerto ) // los cadáveres no interesan
.Where( v => !v.FueraDeCombate ) // posesión/cinemáticas: invisible para la IA
.Where( v => !(SbhConVars.IgnorarJugadores && v.EsJugador) )
.Where( v => Faccion.SonEnemigos( miFaccion, v.GameObject.Components.Get<Faccion>() ) )
.OrderBy( v => v.WorldPosition.Distance( WorldPosition ) )
.FirstOrDefault();
}
/// <summary>
/// Empuje horizontal lejos de los compañeros demasiado cerca (la regla de
/// separación de un boid): suma de direcciones de escape ponderadas por
/// cercanía, recalculada cada 0.15s. Dos bots EXACTAMENTE superpuestos
/// (spawn en el mismo punto) escapan cada uno hacia un rumbo estable
/// derivado de su Id — así la torta se abre en abanico y no tiembla.
/// </summary>
void CalcularSeparacion()
{
if ( _desdeSeparacion < 0.15f ) return;
_desdeSeparacion = 0f;
_separacion = Vector3.Zero;
if ( RadioSeparacion <= 0f || FuerzaSeparacion <= 0f ) return;
foreach ( var otro in Scene.GetAllComponents<NextbotBrain>() )
{
if ( otro == this || !otro.IsValid() ) continue;
var delta = WorldPosition - otro.WorldPosition;
if ( delta.z < -72f || delta.z > 72f ) continue; // otro piso: no molesta
var plano = delta.WithZ( 0 );
var distancia = plano.Length;
if ( distancia >= RadioSeparacion ) continue;
// Superpuestos de verdad: rumbo estable por Id (no aleatorio por
// frame, que haría vibrar a los dos)
var dir = distancia > 1f
? plano / distancia
: Rotation.FromYaw( System.Math.Abs( GameObject.Id.GetHashCode() ) % 360 ).Forward;
_separacion += dir * (1f - distancia / RadioSeparacion);
}
if ( _separacion.Length > 1f )
_separacion = _separacion.Normal;
}
/// <summary>
/// Anti-apilamiento: si este nextbot quedó parado ENCIMA de otro (aterrizó
/// ahí, lo empujaron...), ninguno de los dos puede moverse bien. Detectarlo
/// y bajarse con un paso lateral; el de abajo no hace nada (el de arriba
/// resuelve por los dos).
/// </summary>
void Desapilarse()
{
if ( _desdeDesapilo < 0.3f ) return;
_desdeDesapilo = 0f;
foreach ( var otro in Scene.GetAllComponents<NextbotBrain>() )
{
if ( otro == this || !otro.IsValid() ) continue;
var delta = WorldPosition - otro.WorldPosition;
if ( delta.WithZ( 0 ).Length > 30f ) continue; // no estamos superpuestos
if ( delta.z < 30f || delta.z > 90f ) continue; // no estoy parado encima suyo
var dir = delta.WithZ( 0 ).Length > 1f
? delta.WithZ( 0 ).Normal
: WorldRotation.Forward.WithZ( 0 ).Normal;
Agent.SetAgentPosition( otro.WorldPosition + dir * 50f );
return;
}
}
/// <summary>
/// Estilo GMod: misma animación de caminar para todo, acelerada según la
/// velocidad real del agente. Correr se ve como caminar al doble de fps.
/// </summary>
// El contorno celeste acompaña al flag: aparece al suspender y se va al
// reactivar. Si el componente se apaga (muerte, posesión), el contorno
// también — un cadáver brillando confunde.
void ActualizarMarcaSuspension()
{
if ( IaSuspendida && !_marcaSuspension.IsValid() )
{
_marcaSuspension = Components.Create<HighlightOutline>();
_marcaSuspension.Color = new Color( 0.35f, 0.75f, 1f, 0.55f );
_marcaSuspension.ObscuredColor = new Color( 0.35f, 0.75f, 1f, 0.08f );
_marcaSuspension.Width = 0.15f;
}
else if ( !IaSuspendida && _marcaSuspension.IsValid() )
{
_marcaSuspension.Destroy();
_marcaSuspension = null;
}
}
protected override void OnDisabled()
{
if ( _marcaSuspension.IsValid() )
_marcaSuspension.Destroy();
_marcaSuspension = null;
}
/// <summary> ¿Hay un golpe en el aire ahora mismo? </summary>
public bool AtacandoAhora =>
!string.IsNullOrEmpty( AnimAtaque ) && _tiempoDesdeAtaque < DuracionAnimAtaque;
/// <summary> ¿El modelo trae animgraph? (lo consulta la posesión) </summary>
public bool UsaAnimgraph => _usaAnimgraph;
/// <summary> ¿Va lo bastante rápido como para correr? </summary>
public bool EstaCorriendo => Agent.IsValid() && Agent.Velocity.Length > UmbralCorrer;
/// <summary>
/// Clip de locomoción que corresponde: reposo, caminar o CORRER. Vale
/// también en modo animgraph desde el graph por personaje (2026-08-09):
/// Animar traduce AnimCorrer a b_run; en el graph compartido (sin estado
/// run) el parámetro no existe y todo sigue como siempre (walk acelerada).
/// </summary>
public string AnimLocomocion( bool moviendose, bool corriendo )
{
if ( !moviendose ) return AnimIdle;
if ( corriendo && !string.IsNullOrEmpty( AnimCorrer ) )
return AnimCorrer;
return AnimCaminar;
}
/// <summary>
/// Ritmo de la locomoción, estilo GMod: la anim se acelera en proporción
/// a la velocidad real. Si está corriendo CON su anim de correr, la
/// referencia es más alta (esa anim ya está autorada rápida), así que no
/// sale en cámara rápida.
/// </summary>
/// <summary>
/// VelocidadAnimacion con el afinado por personaje del editor Anim Events
/// encima (2026-08-10, "la distancia de pasos no coincide con el suelo"):
/// ESTA es la perilla anti-patinaje — a cuántas unidades/seg de suelo
/// corresponde la anim de marcha a ritmo 1.
/// </summary>
public float VelocidadAnimacionEfectiva
{
get
{
var afinada = EventosAnim.VelocidadAnimDe( _personaje );
return afinada > 0f ? afinada : VelocidadAnimacion;
}
}
/// <summary> Multiplicador POR ANIMACIÓN del editor Anim Events (1 = natural). </summary>
public float RitmoDeAnim( string anim ) => EventosAnim.RitmoAnimacionDe( _personaje, anim );
public float RitmoLocomocion( bool moviendose, bool corriendo )
{
// El multiplicador del artista aplica SIEMPRE (idle incluido) y por
// FUERA del clamp: su palabra explícita le gana al tope RitmoAnimMax
var multArtista = RitmoDeAnim( AnimLocomocion( moviendose, corriendo ) );
if ( VelocidadAnimacionEfectiva <= 0f || !moviendose ) return multArtista;
var referencia = VelocidadAnimacionEfectiva;
// El factor de correr aplica también en modo GRAPH desde que el graph
// por personaje corre con el clip real (2026-08-09) — el gate
// !_usaAnimgraph era de la era "el graph no corre" y dejaba al Siren
// trotando con la referencia de caminar
if ( corriendo && !string.IsNullOrEmpty( AnimCorrer ) )
referencia *= System.MathF.Max( FactorAnimCorrer, 0.1f );
return (Agent.Velocity.Length / referencia).Clamp( 0.5f, RitmoAnimMax ) * multArtista;
}
void AjustarRitmo( bool moviendose )
{
// BLINDAJE (pedido de Héctor 2026-08-04): mientras hay un golpe en el
// aire, la anim de ataque va a velocidad NATURAL. Antes el ritmo de
// marcha se le aplicaba encima y el cabezazo salía en cámara rápida,
// desincronizado del frame del marcador Melee. El gate estaba solo en
// la rama de persecución; ahora es imposible saltearlo.
if ( AtacandoAhora )
{
// Velocidad NATURAL del golpe... por el multiplicador EXPLÍCITO
// del artista si lo pintó (2026-08-10): la regla "ataque nunca
// acelerado" protege del ritmo de MARCHA, no de una decisión
// consciente — y el RetardoImpactoActual acompaña (se divide)
Renderer.PlaybackRate = RitmoDeAnim( _ataqueActual ?? AnimAtaque );
return;
}
if ( VelocidadAnimacionEfectiva <= 0f ) return;
Renderer.PlaybackRate = RitmoLocomocion( moviendose, EstaCorriendo );
}
// --- Pool de variantes de ataque (2026-07-27, pedido Sea Eater) ---
// AnimAtaque acepta lista separada por comas ("attack, attack_2, ..."):
// cada golpe elige una al azar. Un solo nombre = comportamiento clásico.
string[] _poolAtaques;
string _poolCrudo;
string _ataqueActual;
string[] PoolAtaques()
{
if ( _poolAtaques == null || _poolCrudo != AnimAtaque )
{
_poolCrudo = AnimAtaque ?? "";
_poolAtaques = _poolCrudo.Split( ',',
System.StringSplitOptions.RemoveEmptyEntries | System.StringSplitOptions.TrimEntries );
}
return _poolAtaques;
}
bool EsAnimAtaque( string secuencia )
{
foreach ( var a in PoolAtaques() )
if ( a == secuencia ) return true;
return false;
}
void Animar( string secuencia )
{
// Con animgraph la máquina de estados manda: solo le decimos si camina.
// El ataque no pasa por acá — se dispara con el trigger en DispararAnimAtaque.
if ( _usaAnimgraph )
{
// Correr también es "moverse" para el graph (aunque el graph
// compartido no tenga estado run, el bot no debe quedar en idle)
if ( !EsAnimAtaque( secuencia ) )
{
var corriendo = !string.IsNullOrEmpty( AnimCorrer ) && secuencia == AnimCorrer;
Renderer.Set( ParamCaminar, secuencia == AnimCaminar || corriendo );
// Graph por personaje: b_run distingue el trote real
Renderer.Set( ParamCorrer, corriendo );
}
// ESPEJO de secuencia (2026-08-09): el reloj de Sequence corre en
// paralelo aunque la pose la mande el graph (verificado en el
// editor del oso) — y es LO QUE LEE EventosAnimSistema. Sin el
// espejo, los eventos pintados no disparan en modelos con graph.
if ( Renderer.Sequence.Name != secuencia )
{
Renderer.Sequence.Name = secuencia;
Renderer.Sequence.Blending = true;
}
return;
}
if ( Renderer.Sequence.Name == secuencia ) return;
Renderer.Sequence.Name = secuencia;
Renderer.Sequence.Blending = true; // crossfade usando los Fade In/Out del vmdl
}
/// <summary>
/// Arranca la animación de ataque: trigger del animgraph (la máquina de
/// estados la reproduce entera y vuelve sola) o secuencia directa.
/// </summary>
void DispararAnimAtaque()
{
var pool = PoolAtaques();
if ( pool.Length == 0 ) return;
_ataqueActual = pool[Game.Random.Int( 0, pool.Length - 1 )];
// Con animgraph el trigger clásico. En los graphs POR PERSONAJE el
// selector de ataques obedece i_attack — el Brain ya sorteó la
// variante Y su frame de Impact, así que graph y daño van sincro
// (en el graph compartido i_attack no existe y se ignora). OJO: NO
// usar DirectPlayback — no está expuesto en SkinnedModelRenderer
// (CS1061, roto en vivo el 2026-07-28)
if ( _usaAnimgraph )
{
Renderer.Set( ParamIndiceAtaque, System.Array.IndexOf( pool, _ataqueActual ) );
Renderer.Set( ParamAtaque, true );
// Espejo para los eventos pintados (ver Animar): el reloj arranca
// justo con el trigger — daño y anim en el mismo compás
Renderer.Sequence.Name = _ataqueActual;
Renderer.Sequence.Blending = true;
}
else
Animar( _ataqueActual );
}
void MirarHacia( Vector3 posicion )
{
var direccion = posicion - WorldPosition;
direccion.z = 0; // solo giro horizontal, que no se incline
if ( direccion.Length < 1f ) return;
var rotacionObjetivo = Rotation.LookAt( direccion.Normal, Vector3.Up );
WorldRotation = Rotation.Slerp( WorldRotation, rotacionObjetivo, Time.Delta * VelocidadGiro );
}
/// <summary>
/// Lanza el cabezazo: arranca la animación ya, pero el impacto (daño,
/// empujón, sonido) conecta a los RetardoImpacto segundos, como el
/// evento melee del QC en GMod.
/// </summary>
// Impact del golpe EN CURSO: el evento pintado de la variante que salió
// sorteada (EventosAnim), o el RetardoImpacto clásico del prefab. Antes
// el pool compartía UN retardo y 3 de 4 variantes pegaban fuera de frame.
float _retardoActual;
string _personaje = "";
/// <summary>
/// Retardo de impacto del golpe en curso (con eventos pintados, el de SU
/// variante), acompañando la velocidad de la anim: si el artista puso el
/// ataque a 1.5×, el golpe conecta 1.5× antes (mismo frame visual).
/// </summary>
public float RetardoImpactoActual
{
get
{
var retardo = _retardoActual > 0f ? _retardoActual : RetardoImpacto;
var ritmo = RitmoDeAnim( _ataqueActual ?? AnimAtaque );
return ritmo > 0f ? retardo / ritmo : retardo;
}
}
void Atacar( IVida vida )
{
_tiempoDesdeAtaque = 0f;
_golpePendiente = vida;
DispararAnimAtaque();
_retardoActual = 0f;
// AlAtacar ANTES de la supresión Melee (2026-08-09): el cine lee
// VictimaPendiente para robar la escena — con Melee pintado la
// supresión la borraba primero y el bot mataba CRUDO sin película
// (Beeb -1000 sin cine). Si el cine agarra, apaga el cerebro y el
// golpe pendiente queda congelado como siempre.
AlAtacar?.Invoke();
// Semántica de Héctor (2026-08-05): Impact = EFECTO sin daño (lo
// despacha EventosAnimSistema); Melee = el daño. Si la variante
// sorteada tiene Melee pintado, el golpe clásico se SUPRIME — los
// pulsos pintados pegan en sus frames (multihit real). Sin pintar,
// el cabezazo clásico de RetardoImpacto, como siempre.
if ( EventosAnim.TieneEvento( _personaje, _ataqueActual ?? AnimAtaque, "Melee" ) )
_golpePendiente = null;
if ( RetardoImpactoActual <= 0f )
ProcesarImpactoPendiente();
}
void ProcesarImpactoPendiente()
{
if ( _golpePendiente == null ) return;
if ( _tiempoDesdeAtaque < RetardoImpactoActual ) return;
var victima = _golpePendiente;
_golpePendiente = null;
EjecutarImpacto( victima );
}
/// <summary>
/// Cooldown real entre ataques: el configurado, pero nunca menos que la
/// animación completa (+0.1s de aire) — re-disparar a mitad de anim la
/// reiniciaba y se veía tartamuda.
/// </summary>
public float CooldownEfectivo => System.MathF.Max( CooldownAtaque, DuracionAnimAtaque + 0.1f );
/// <summary> ¿Hay un cabezazo en el aire esperando su frame de impacto? </summary>
public bool GolpePendiente => _golpePendiente != null;
/// <summary> La víctima del cabezazo en el aire (null si no hay golpe pendiente) — la lee el jumpscare al arrancar la anim. </summary>
public IVida VictimaPendiente => _golpePendiente;
/// <summary>
/// Cabezazo con el timing del marcador Melee, para controladores externos
/// (la posesión): la anim arranca YA y el impacto conecta a los
/// RetardoImpacto segundos — el frame exacto marcado en Blender. Quien
/// llama debe bombear ProcesarGolpePendiente() por frame mientras este
/// componente esté deshabilitado (su OnUpdate no corre) y mantener al bot
/// encarado a la víctima hasta que conecte.
/// </summary>
public void GolpeMarcado( IVida victima )
{
Atacar( victima );
}
/// <summary> Bombea el impacto pendiente cuando el cerebro está apagado (posesión). </summary>
public void ProcesarGolpePendiente() => ProcesarImpactoPendiente();
/// <summary>
/// Golpe inmediato sin retardo, pensado para el contacto en pleno salto
/// (el choque ya ES el momento del impacto). Funciona con el componente
/// deshabilitado y respeta el cooldown de ataque, salvo que quien lo llame
/// lleve su propio cooldown y pida ignorarlo — si no, el timer que dejó la
/// última pelea del bot se come los primeros golpes.
/// </summary>
public bool GolpeDirecto( IVida victima, bool ignorarCooldown = false )
{
if ( !ignorarCooldown && _tiempoDesdeAtaque < CooldownAtaque ) return false;
_tiempoDesdeAtaque = 0f;
_golpePendiente = null;
DispararAnimAtaque();
AlAtacar?.Invoke();
return EjecutarImpacto( victima );
}
/// <summary>
/// Pulso de daño de un animevent "Melee" (EventosAnim): pega al enemigo
/// más cercano dentro del rango SIN tocar la animación ni el cooldown —
/// es lo que permite multihits dentro de un ataque y locuras como
/// pisotones que lastiman en walk (el sueño de Héctor, 2026-08-05).
/// Funciona con el componente apagado (posesión: el jugador camina y
/// los eventos pintados siguen pegando).
/// </summary>
/// <summary>
/// Pulso de un animevent "Impact": la PRESENTACIÓN estándar de sandbox
/// del golpe — SIN daño. Traza corto hacia adelante y, contra lo que
/// haya (enemigo, pared, piso), suena el ImpactHard de ESA superficie y
/// salpica su prefab de impacto — la receta exacta del MeleeWeapon del
/// juego (carne suena a carne, metal a metal). Golpe al aire = silencio.
/// Semántica de Héctor (2026-08-05): "Impact tiene el efecto de daño
/// pero no hace daño; Melee tiene efecto y hace daño".
/// </summary>
// El thunk genérico de sandbox (el hit del crowbar): el respaldo para que
// Impact SIEMPRE suene, pegue donde pegue
static SoundEvent _thunkGenerico;
public void PulsoImpacto()
{
var altura = Components.Get<CabezaNextbot>()?.AlturaMirada * 0.7f ?? 40f;
var origen = WorldPosition + Vector3.Up * altura;
var tr = Scene.Trace
.Ray( origen, origen + WorldRotation.Forward * System.MathF.Max( RangoAtaque, 50f ) )
.IgnoreGameObjectHierarchy( GameObject )
.UseHitboxes()
.Run();
_thunkGenerico ??= ResourceLibrary.Get<SoundEvent>( "weapons/crowbar/sounds/crowbar.hit.sound" );
// SIEMPRE suena (pedido de Héctor: el golpe se escucha aunque pegue
// al aire): el ImpactHard de la superficie golpeada si existe, si no
// el thunk genérico. El prefab de impacto solo si golpeó algo.
var punto = tr.Hit ? tr.HitPosition : origen + WorldRotation.Forward * (RangoAtaque * 0.5f);
var sonido = (tr.Hit && tr.Surface != null
? tr.Surface.SoundCollection.ImpactHard
?? tr.Surface.GetBaseSurface()?.SoundCollection.ImpactHard
: null)
?? _thunkGenerico;
if ( sonido != null )
Sound.Play( sonido, punto );
if ( !tr.Hit || tr.Surface is null ) return;
var prefab = tr.Surface.PrefabCollection.BulletImpact
?? tr.Surface.GetBaseSurface()?.PrefabCollection.BulletImpact;
if ( prefab.IsValid() )
prefab.Clone( tr.HitPosition, Rotation.LookAt( tr.Normal ) );
}
public bool PulsoMelee()
{
var miFaccion = Components.Get<Faccion>();
IVida mejor = null;
// Mismo perdón que el impacto clásico (él también avanzó durante la
// anim): con el rango pelado, un TZ de rango 80 fallaba TODOS los
// pulsos apenas la víctima retrocedía medio paso (tz_bear 2026-08-09)
var mejorDist = RangoAtaque * 1.25f;
foreach ( var vida in Vidas.Todas( Scene ) )
{
if ( vida == null || !vida.IsValid() || vida.EstaMuerto ) continue;
if ( vida.GameObject == GameObject ) continue;
if ( vida.FueraDeCombate ) continue;
if ( SbhConVars.IgnorarJugadores && vida.EsJugador ) continue;
if ( !Faccion.SonEnemigos( miFaccion, vida.GameObject.Components.Get<Faccion>() ) ) continue;
var d = vida.WorldPosition.Distance( WorldPosition );
if ( d <= mejorDist )
{
mejorDist = d;
mejor = vida;
}
}
if ( mejor == null && SbhConVars.EventosDebug )
Log.Info( $"🎞 [debug] {GameObject.Name}: pulso Melee SIN víctima en rango {RangoAtaque * 1.25f:0}u" );
return mejor != null && EjecutarImpacto( mejor );
}
bool EjecutarImpacto( IVida victima )
{
if ( victima == null || !victima.IsValid() || victima.EstaMuerto ) return false;
// ¿Sigue al alcance? Si se apartó durante el cabezazo, el golpe falla
// (margen extra porque él también avanzó durante la animación)
if ( victima.WorldPosition.Distance( WorldPosition ) > RangoAtaque * 1.25f )
return false;
if ( SonidoAtaque != null )
Sound.Play( SonidoAtaque, WorldPosition );
// Capturar ANTES del daño: si el golpe lo mata, sandbox puede destruir
// su GameObject dentro de RecibirDanio y ya no queda nada que leer
var nombre = victima.GameObject.Name;
var puntoImpacto = victima.WorldPosition + Vector3.Up * 40f;
var dir = (victima.WorldPosition - WorldPosition).WithZ( 0 ).Normal;
if ( dir.Length < 0.5f ) dir = WorldRotation.Forward.WithZ( 0 ).Normal;
// Ejecución: si este golpe mata a otro bot, su ragdoll sale despedido
// (los jugadores tienen su propio camino en el adaptador Player.Vida)
if ( FuerzaEjecucion > 0f && victima.VidaActual <= Danio && victima is Vida vidaComponente )
vidaComponente.RegistrarImpulsoMortal( dir * FuerzaEjecucion + Vector3.Up * (FuerzaEjecucion * 0.35f) );
victima.RecibirDanio( Danio, GameObject );
Empujar( victima, dir );
AlGolpear?.Invoke( victima, puntoImpacto );
Log.Info( $"👹 {GameObject.Name} golpeó a {nombre}: -{Danio}" );
return true;
}
/// <summary>
/// Empujón del cabezazo: jugadores salen despedidos por su controller,
/// otros nextbots se desplazan vía su agente de navmesh (que los mantiene
/// sobre el navmesh), y cualquier cosa con física recibe el impulso.
/// </summary>
void Empujar( IVida vida, Vector3 dir )
{
if ( FuerzaEmpuje <= 0f ) return;
// Murió con el golpe: no queda a quién empujar
if ( !vida.IsValid() || !vida.GameObject.IsValid() ) return;
var impulso = dir * FuerzaEmpuje + Vector3.Up * (FuerzaEmpuje * 0.25f);
var go = vida.GameObject;
var controller = go.Components.Get<PlayerController>();
if ( controller.IsValid() )
{
// Velocity del controller es de solo lectura: Jump() agrega velocidad
// en la dirección dada, y PreventGrounding evita que la fricción del
// piso se coma el empujón al instante
controller.PreventGrounding( 0.2f );
controller.Jump( impulso );
return;
}
var agente = go.Components.Get<NavMeshAgent>();
if ( agente.IsValid() )
{
agente.SetAgentPosition( go.WorldPosition + dir * (FuerzaEmpuje * 0.15f) );
return;
}
var rb = go.Components.Get<Rigidbody>();
if ( rb.IsValid() )
rb.Velocity += impulso;
}
}