NextbotBrain.cs

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.

NetworkingFile Access
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;
	}
}