Nextbot jump component for AI characters. Decides when to perform a ballistic jump toward the perceived target, disables NavMeshAgent and NextbotBrain during flight, simulates trajectory under custom gravity, attempts an aerial hit, and re-enables/reset agent on landing.
namespace SBH;
using Sandbox;
using System;
/// <summary>
/// [SBHBase] Salto de nextbot: cuando persigue y el objetivo está a otro
/// nivel — arriba de un contenedor, abajo de un puente — o el camino dejó
/// de avanzar, se lanza hacia él con una parábola balística. Sin miedo a
/// caer: si el objetivo está abajo, salta desde el borde directamente.
/// Durante el vuelo toma el control del movimiento (apaga NavMeshAgent y
/// NextbotBrain) y lo devuelve al aterrizar, reencajando el agente en el
/// navmesh. Usa el objetivo de PercepcionNextbot; sin ella no salta.
/// </summary>
public sealed class SaltoNextbot : Component
{
[RequireComponent] NavMeshAgent Agent { get; set; }
[RequireComponent] NextbotBrain Cerebro { get; set; }
[Property, Description( "Desnivel mínimo con el objetivo para plantearse saltar" )]
public float DesnivelMinimo { get; set; } = 80f;
[Property, Description( "Distancia horizontal máxima que cubre un salto" )]
public float DistanciaMaxima { get; set; } = 500f;
[Property, Description( "Altura extra del arco por encima del punto más alto del salto" )]
public float AlturaArco { get; set; } = 60f;
[Property, Description( "Gravedad durante el vuelo" )]
public float Gravedad { get; set; } = 800f;
[Property, Description( "Segundos mínimos entre saltos" )]
public float Cooldown { get; set; } = 2f;
[Property, Description( "Si persiguiendo no avanza durante este tiempo (seg), salta hacia el objetivo aunque no haya desnivel" )]
public float TiempoAtascado { get; set; } = 1.5f;
[Property, Description( "Distancia de contacto para conectar el cabezazo en pleno vuelo (0 = sin golpe aéreo)" )]
public float RangoGolpeAereo { get; set; } = 60f;
[Property, Description( "Parámetro bool del animgraph para la anim de caída durante el vuelo (vacío = sin anim)" )]
public string ParametroCaida { get; set; } = "b_fall";
[Property, Description( "Velocidad vertical del salto CUANDO LO POSEE UN JUGADOR (la IA usa el arco balístico de arriba). 330 = salto de persona; un coloso necesita mucho más" )]
public float FuerzaSaltoPosesion { get; set; } = 330f;
// --- Multiplicador global en vivo (sbh.salto.mult) ---
// Escala arco y alcance sin tocar los prefabs: se tunea jugando y después
// se hornean los valores por personaje.
float Mult => System.MathF.Max( SbhConVars.SaltoMultiplicador, 0.05f );
float DistanciaEfectiva => DistanciaMaxima * Mult;
float ArcoEfectivo => AlturaArco * Mult;
/// <summary> La fuerza de salto para la posesión, ya con el multiplicador global. </summary>
public float FuerzaSaltoPosesionEfectiva => FuerzaSaltoPosesion * Mult;
/// <summary> ¿Está en el aire ahora mismo? (para que otros componentes lo consulten) </summary>
public bool Volando { get; private set; }
PercepcionNextbot _percepcion;
Vida _vida;
Vector3 _velocidad;
TimeSince _desdeSalto;
TimeSince _desdeDespegue;
Vector3 _posicionAnterior;
TimeSince _desdeAvance;
protected override void OnStart()
{
_percepcion = Components.Get<PercepcionNextbot>();
_vida = Components.Get<Vida>();
_desdeSalto = 999f;
_posicionAnterior = WorldPosition;
}
protected override void OnDisabled()
{
if ( Volando )
Aterrizar( WorldPosition );
}
protected override void OnFixedUpdate()
{
if ( Volando )
{
Volar();
return;
}
if ( _vida.IsValid() && _vida.EstaMuerto ) return;
if ( _desdeSalto < Cooldown ) return;
var destino = BuscarDestino();
if ( destino == null )
{
_posicionAnterior = WorldPosition;
_desdeAvance = 0f;
return;
}
// Detección de atasco: ¿avanzamos algo desde la última vez?
if ( WorldPosition.Distance( _posicionAnterior ) > 30f )
{
_posicionAnterior = WorldPosition;
_desdeAvance = 0f;
}
var desnivel = destino.Value.z - WorldPosition.z;
var distanciaH = (destino.Value - WorldPosition).WithZ( 0 ).Length;
if ( distanciaH > DistanciaEfectiva ) return;
var hayDesnivel = MathF.Abs( desnivel ) > DesnivelMinimo;
var atascado = _desdeAvance > TiempoAtascado;
if ( !hayDesnivel && !atascado ) return;
if ( !hayDesnivel && distanciaH < 60f ) return; // ya está encima, no hace falta
// ¿El arco CABE? Con el objetivo arriba de un techo/losa, el bot de
// abajo saltaba una y otra vez contra el cielorraso queriendo
// traspasarlo. Si algo del mundo tapa la vertical hasta el ápice del
// salto, no hay salto — se sigue por navmesh como cualquier hijo de
// vecino (y el próximo intento será desde otro lugar).
var apice = MathF.Max( WorldPosition.z, destino.Value.z ) + ArcoEfectivo;
var alturaVuelo = apice - WorldPosition.z;
if ( alturaVuelo > 24f )
{
var techo = Scene.Trace
.Ray( WorldPosition + Vector3.Up * 24f, WorldPosition + Vector3.Up * alturaVuelo )
.Radius( 14f )
.IgnoreGameObjectHierarchy( GameObject )
.WithoutTags( "sbh_nextbot", "player" )
.Run();
if ( techo.Hit ) return; // hay techo: el salto no cabe
}
Saltar( destino.Value );
}
/// <summary> Hacia dónde saltar, o null si no hay persecución activa. </summary>
Vector3? BuscarDestino()
{
if ( !_percepcion.IsValid() || !_percepcion.Enabled ) return null;
var objetivo = _percepcion.ObjetivoActual;
if ( objetivo == null || !objetivo.IsValid() || objetivo.EstaMuerto ) return null;
return _percepcion.ObjetivoVisible
? objetivo.WorldPosition
: _percepcion.UltimaPosicionConocida;
}
void Saltar( Vector3 destino )
{
var origen = WorldPosition;
// Balística: subir hasta AlturaArco por encima del punto más alto y
// caer justo en el destino. vz por energía, tiempo por cinemática.
var apex = MathF.Max( 0f, destino.z - origen.z ) + ArcoEfectivo;
var vz = MathF.Sqrt( 2f * Gravedad * apex );
var raiz = vz * vz - 2f * Gravedad * (destino.z - origen.z);
if ( raiz < 0f ) return;
var tiempo = (vz + MathF.Sqrt( raiz )) / Gravedad;
if ( tiempo < 0.1f ) return;
var horizontal = (destino - origen).WithZ( 0 );
_velocidad = horizontal / tiempo + Vector3.Up * vz;
if ( horizontal.Length > 1f )
WorldRotation = Rotation.LookAt( horizontal.Normal, Vector3.Up );
Agent.Enabled = false;
Cerebro.Enabled = false;
// En el aire: anim de caída (la misma que usa la physgun)
if ( !string.IsNullOrEmpty( ParametroCaida ) )
Components.Get<SkinnedModelRenderer>()?.Set( ParametroCaida, true );
Volando = true;
_desdeDespegue = 0f;
_desdeSalto = 0f;
}
void Volar()
{
var dt = Time.Delta;
_velocidad += Vector3.Down * Gravedad * dt;
// Avanzar con una esfera a la altura del torso para chocar con el mundo
var desde = WorldPosition + Vector3.Up * 24f;
var hasta = desde + _velocidad * dt;
var trace = Scene.Trace.Ray( desde, hasta )
.Radius( 14f )
.IgnoreGameObjectHierarchy( GameObject )
.WithoutTags( "sbh_nextbot" ) // otro nextbot no es piso: atravesarlo, no aterrizar encima
.Run();
if ( trace.Hit )
{
// Piso (o algo pisable) mientras cae: aterrizar
if ( _velocidad.z <= 0f || trace.Normal.z > 0.5f )
{
Aterrizar( trace.EndPosition - Vector3.Up * 24f );
return;
}
// Pared en plena subida: cortar el avance y dejar que caiga
_velocidad = _velocidad.WithX( 0f ).WithY( 0f );
WorldPosition = trace.EndPosition - Vector3.Up * 24f;
}
else
{
WorldPosition = hasta - Vector3.Up * 24f;
}
// Cabezazo en vuelo: si el arco pasa por encima del objetivo, conecta
IntentarGolpeAereo();
// Seguridad: nunca volar para siempre (agujeros, caídas al vacío)
if ( _desdeDespegue > 4f )
Aterrizar( WorldPosition );
}
/// <summary>
/// El pounce: en pleno vuelo, si el objetivo queda a distancia de cuerpo,
/// el golpe conecta ahí mismo — sin retardo, el contacto ES el impacto.
/// El cooldown de ataque del cerebro evita golpear dos veces por salto.
/// </summary>
void IntentarGolpeAereo()
{
if ( RangoGolpeAereo <= 0f || !Cerebro.IsValid() ) return;
var objetivo = _percepcion.IsValid() ? _percepcion.ObjetivoActual : null;
if ( objetivo == null || !objetivo.IsValid() || objetivo.EstaMuerto ) return;
// Medir desde el torso en vuelo contra el torso del objetivo
var torso = WorldPosition + Vector3.Up * 24f;
var torsoObjetivo = objetivo.WorldPosition + Vector3.Up * 32f;
if ( torso.Distance( torsoObjetivo ) > RangoGolpeAereo ) return;
Cerebro.GolpeDirecto( objetivo );
}
void Aterrizar( Vector3 posicion )
{
Volando = false;
WorldPosition = posicion;
if ( !string.IsNullOrEmpty( ParametroCaida ) )
Components.Get<SkinnedModelRenderer>()?.Set( ParametroCaida, false );
// Si murió en el aire, MuerteNextbot ya apagó la IA: no revivirla
if ( _vida.IsValid() && _vida.EstaMuerto ) return;
// Pisada de gigante: el aterrizaje retumba (más fuerte que el paso)
Components.Get<PisadaGigante>()?.AlAterrizar();
if ( Agent.IsValid() )
{
Agent.Enabled = true;
Agent.SetAgentPosition( posicion );
}
if ( Cerebro.IsValid() )
Cerebro.Enabled = true;
}
}