SaltoNextbot.cs

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.

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