Code/SaltoNextbot.cs

Nextbot jump component for an NPC. Detects when chase target is vertically offset or bot is stuck, computes ballistic arc, disables NavMeshAgent and NextbotBrain during flight, moves the bot through air, attempts an aerial attack, and re-enables/repositions agent on landing.

Native Interop
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;
	}
}