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.
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 ));
}
}