CabezaNextbot.cs

Component for a nextbot head look system. It reads a PercepcionNextbot target and Vida state, computes a clamped yaw/pitch toward the target, smooths direction and weight, and calls SkinnedModelRenderer.SetLookDirection to drive an animgraph Look At parameter.

Native Interop
namespace SBH;

using Sandbox;
using System;

/// <summary>
/// [SBHBase] La cabeza del nextbot sigue al objetivo, estilo DRGBase.
/// Alimenta el nodo Look At del animgraph compartido (sbh_nextbot.vanmgrph)
/// vía <see cref="SkinnedModelRenderer.SetLookDirection"/>: escribe el
/// parámetro vector (dirección de mirada) y su peso, y el engine gira la
/// cadena de huesos LookChain del vmdl ENCIMA de la animación viva — la
/// cabeza conserva el rebote de correr mientras mira al objetivo, sin
/// bone-overrides ni anclas (el enfoque anterior peleaba con el snapshot
/// de huesos del engine y quedó descartado).
/// Es el equivalente del autoplay blend del QC de GMod.
/// Usa el objetivo de PercepcionNextbot; sin ese componente no hace nada.
/// Requiere que el modelo tenga animgraph con el parámetro y una LookChain;
/// si no los tiene, SetLookDirection simplemente no hace nada (inofensivo).
/// </summary>
public sealed class CabezaNextbot : Component
{
	[RequireComponent] SkinnedModelRenderer Renderer { get; set; }

	[Property, Description( "Giro horizontal máximo hacia cada lado, en grados" )]
	public float LimiteYaw { get; set; } = 70f;

	[Property, Description( "Inclinación vertical máxima (arriba/abajo), en grados" )]
	public float LimitePitch { get; set; } = 35f;

	[Property, Description( "Velocidad de seguimiento (más alto = más brusco)" )]
	public float Velocidad { get; set; } = 8f;

	[Property, Description( "Altura sobre el origen del objetivo a la que mira (sus ojos)" )]
	public float AlturaMirada { get; set; } = 55f;

	[Property, Description( "Nombre del parámetro vector de mirada en el animgraph (el peso va en <nombre>_weight)" )]
	public string ParametroMirada { get; set; } = "aim_head";

	[Property, Description( "Calibración fina: grados que se suman al yaw calculado (positivo = más a la izquierda). Para corregir desvíos si el frame del modelo no quedó perfecto" )]
	public float DesvioYaw { get; set; } = 0f;

	[Property, Description( "Calibración fina: grados que se suman al pitch calculado (positivo = más abajo)" )]
	public float DesvioPitch { get; set; } = 0f;

	PercepcionNextbot _percepcion;
	Vida _vida;
	Vector3 _direccion;   // dirección de mirada suavizada, en espacio de mundo
	float _peso;          // 0 = la animación manda, 1 = mirada plena
	string _personaje;    // para el interruptor SinCabeza de EventosAnim

	protected override void OnStart()
	{
		_percepcion = Components.Get<PercepcionNextbot>();
		_vida = Components.Get<Vida>();
		_direccion = WorldRotation.Forward;
		_personaje = EventosAnim.PersonajeDe( Renderer );
	}

	protected override void OnDisabled()
	{
		// Devolverle la cabeza a la animación (muerte → ragdoll, IA apagada...)
		_peso = 0f;
		_direccion = WorldRotation.Forward;

		if ( Renderer.IsValid() )
			Renderer.SetLookDirection( ParametroMirada, _direccion, 0f );
	}

	protected override void OnUpdate()
	{
		if ( !Renderer.IsValid() ) return;

		var puntoMirada = BuscarPuntoMirada();

		var dirDeseada = WorldRotation.Forward;
		var pesoDeseado = 0f;

		if ( puntoMirada != null )
		{
			var origen = WorldPosition + Vector3.Up * AlturaMirada;
			var (yaw, pitch) = CalcularAngulos( puntoMirada.Value, origen );

			// Demasiado a la espalda: soltar la mirada en vez de retorcer el cuello
			if ( yaw != null )
			{
				// Reconstruir la dirección ya clampeada a los límites del cuello,
				// con la calibración fina encima (corrige frames imperfectos)
				dirDeseada = WorldRotation * Rotation.From( pitch.Value + DesvioPitch, yaw.Value + DesvioYaw, 0f ) * Vector3.Forward;
				pesoDeseado = 1f;
			}
		}

		// Suavizado independiente del framerate; el nodo del animgraph trae su
		// propio amortiguador (spring), esto solo gobierna entrar/salir de mirada
		var t = 1f - MathF.Exp( -Velocidad * Time.Delta );
		_direccion = Vector3.Slerp( _direccion, dirDeseada, t ).Normal;
		_peso = _peso.LerpTo( pesoDeseado, t );

		Renderer.SetLookDirection( ParametroMirada, _direccion, _peso );
	}

	/// <summary> Dónde mirar, o null si no hay nada que mirar (o estamos muertos). </summary>
	Vector3? BuscarPuntoMirada()
	{
		// Interruptor por personaje (2026-08-10): rigs GMod donde "head" es
		// una parte especial (tapa del libro de Buuik, tapa del pastel de
		// Cake) — el peso baja a 0 con el suavizado de siempre
		if ( EventosAnim.CabezaApagada( _personaje ) ) return null;

		if ( _vida.IsValid() && _vida.EstaMuerto ) return null;
		if ( !_percepcion.IsValid() || !_percepcion.Enabled ) return null;

		var objetivo = _percepcion.ObjetivoActual;
		if ( objetivo == null || !objetivo.IsValid() || objetivo.EstaMuerto ) return null;

		var punto = _percepcion.ObjetivoVisible
			? objetivo.WorldPosition
			: _percepcion.UltimaPosicionConocida;

		return punto + Vector3.Up * AlturaMirada;
	}

	/// <summary>
	/// Ángulos hacia el punto en espacio del cuerpo, ya clampeados a los
	/// límites, o (null, null) si el objetivo queda demasiado a la espalda.
	/// </summary>
	(float? yaw, float? pitch) CalcularAngulos( Vector3 puntoMirada, Vector3 origen )
	{
		var dirLocal = WorldRotation.Inverse * (puntoMirada - origen).Normal;

		var yaw = MathF.Atan2( dirLocal.y, dirLocal.x ).RadianToDegree();
		var pitch = -MathF.Asin( dirLocal.z.Clamp( -1f, 1f ) ).RadianToDegree();

		if ( MathF.Abs( yaw ) > 120f ) return (null, null);

		return (yaw.Clamp( -LimiteYaw, LimiteYaw ), pitch.Clamp( -LimitePitch, LimitePitch ));
	}
}