PercepcionNextbot.cs

Nextbot perception component. Implements sight cone with line-of-sight and short-range hearing, remembers last known position for a timeout, responds to damage by marking attacker, supports omniscience mode and external perception filters via IFiltroPercepcion.

Native Interop
namespace SBH;

using Sandbox;
using System;

/// <summary>
/// Filtro de percepción por objetivo, para que los packs modifiquen los
/// sentidos sin tocar la base (el sigilo de Toy Zombies: te ven según tu
/// velocidad y tu linterna). Un componente del mismo GameObject lo
/// implementa y PercepcionNextbot lo consulta en cada chequeo.
/// </summary>
public interface IFiltroPercepcion
{
	/// <summary>
	/// ¿Puede percibir a este objetivo a esta distancia? true = sí (aunque
	/// esté fuera del cono: el ruido delata — la línea de visión igual se
	/// exige), false = no (silencioso/invisible), null = sin opinión (siguen
	/// las reglas normales de la base).
	/// </summary>
	bool? PuedeVer( IVida objetivo, float distancia );
}

/// <summary>
/// [SBHBase] Sentidos del nextbot: vista en cono con línea de visión real
/// (las paredes bloquean) y oído de corto alcance en cualquier dirección.
/// Recuerda al objetivo un rato después de perderlo de vista, guardando su
/// última posición conocida, y lo olvida pasado TiempoOlvido.
/// Si un GameObject no tiene este componente, NextbotBrain vuelve al
/// comportamiento omnisciente clásico.
/// </summary>
public sealed class PercepcionNextbot : Component
{
	// ---------- Configurable desde el Inspector ----------
	[Property, Description( "Distancia máxima de visión" )]
	public float RangoVista { get; set; } = 1500f;

	[Property, Description( "Ángulo total del cono de visión, en grados" )]
	public float AnguloVista { get; set; } = 140f;

	[Property, Description( "Distancia a la que siente enemigos aunque estén fuera del cono de visión (una pared igual lo bloquea)" )]
	public float RangoOido { get; set; } = 150f;

	[Property, Description( "Altura de los ojos respecto al piso del nextbot" )]
	public float AlturaOjos { get; set; } = 60f;

	[Property, Description( "Segundos que recuerda al objetivo tras perderlo de vista" )]
	public float TiempoOlvido { get; set; } = 5f;

	[Property, Description( "Segundos entre escaneos de enemigos (rendimiento)" )]
	public float IntervaloEscaneo { get; set; } = 0.2f;

	[Property, Description( "Multiplicador del rango de visión mientras está en alerta (tras recibir daño)" )]
	public float MultiplicadorAlerta { get; set; } = 2f;

	[Property, Description( "Segundos que dura la alerta tras recibir daño (cada golpe la renueva)" )]
	public float DuracionAlerta { get; set; } = 15f;

	// ---------- Estado que consumen otros componentes ----------
	/// <summary>
	/// Omnisciencia temporal: mientras esté en true percibe a TODOS sus
	/// enemigos sin cono, sin paredes y sin límite de distancia (el
	/// SetOmniscient de DRGBase). La prenden skills de pack — el grito de
	/// manada del Mono — y la apagan ellas mismas al terminar.
	/// </summary>
	public bool ModoOmnisciente { get; set; }

	/// <summary> Enemigo actual (percibido ahora o recordado). Null si no hay. </summary>
	public IVida ObjetivoActual { get; private set; }

	/// <summary> ¿Está percibiendo al objetivo AHORA MISMO? </summary>
	public bool ObjetivoVisible { get; private set; }

	/// <summary> Último lugar donde percibió al objetivo. Válido si ObjetivoActual no es null. </summary>
	public Vector3 UltimaPosicionConocida { get; private set; }

	/// <summary> Posición de los ojos, para traces de visión. </summary>
	public Vector3 Ojos => WorldPosition + Vector3.Up * AlturaOjos;

	/// <summary> ¿Está en alerta? (lo dañaron hace poco: ve más lejos mientras busca) </summary>
	public bool EnAlerta => _desdeAlerta < DuracionAlerta;

	/// <summary> Rango de visión vigente: el normal, o el ampliado si está en alerta. </summary>
	public float RangoVistaEfectivo => EnAlerta ? RangoVista * MultiplicadorAlerta : RangoVista;

	TimeSince _desdeEscaneo;
	TimeSince _desdeVisto;
	TimeSince _desdeAlerta;
	Vida _vida;
	NextbotBrain _cerebro;

	// La IA de este bot está apagada, sea por el switch global o por la
	// suspensión individual del cerebro (tecla apuntándolo). Sin sentidos ni
	// memoria — y de rebote SaltoNextbot tampoco salta (bebe de ObjetivoActual)
	bool IaApagada => !SbhConVars.IaActivada
		|| (_cerebro.IsValid() && _cerebro.IaSuspendida);

	protected override void OnStart()
	{
		_desdeEscaneo = 999f;
		_desdeVisto = 999f;
		_desdeAlerta = 999f;
	}

	protected override void OnEnabled()
	{
		_vida = Components.Get<Vida>();
		_cerebro = Components.Get<NextbotBrain>();
		if ( _vida.IsValid() )
			_vida.AlRecibirDanio += AlSerDaniado;
	}

	protected override void OnDisabled()
	{
		if ( _vida.IsValid() )
			_vida.AlRecibirDanio -= AlSerDaniado;
	}

	/// <summary>
	/// Venganza: al recibir daño, fija como objetivo a quien lo hizo aunque
	/// esté fuera de los sentidos (un francotirador por la espalda, p. ej.).
	/// Sabe DÓNDE estaba el atacante, no necesariamente lo ve.
	/// </summary>
	void AlSerDaniado( float cantidad )
	{
		var atacante = _vida?.UltimoAtacante;
		if ( !atacante.IsValid() || atacante == GameObject ) return;

		// El atacante puede ser el arma (hija del jugador): buscar hacia arriba
		var objetivo = atacante.Components.Get<IVida>( FindMode.EverythingInSelfAndAncestors );

		if ( objetivo == null || objetivo.EstaMuerto ) return;
		if ( IaApagada ) return;
		if ( SbhConVars.IgnorarJugadores && objetivo.EsJugador ) return; // notarget total, ni con balazos

		// Alerta: mientras busca al agresor ve más lejos; se calma sola si no lo encuentra
		_desdeAlerta = 0f;

		ObjetivoActual = objetivo;
		ObjetivoVisible = false;
		UltimaPosicionConocida = objetivo.WorldPosition;
		_desdeVisto = 0f;
	}

	protected override void OnUpdate()
	{
		if ( _desdeEscaneo < IntervaloEscaneo ) return;
		_desdeEscaneo = 0f;

		Escanear();
	}

	void Escanear()
	{
		// IA apagada (global o suspensión individual): sin sentidos ni memoria
		if ( IaApagada )
		{
			Olvidar();
			return;
		}

		// Los muertos se olvidan al instante; los jugadores también si ahora los
		// ignoramos, y lo mismo quien salió del combate (posesión, cinemáticas)
		if ( ObjetivoActual != null && (!ObjetivoActual.IsValid() || ObjetivoActual.EstaMuerto
			|| ObjetivoActual.FueraDeCombate
			|| (SbhConVars.IgnorarJugadores && ObjetivoActual.EsJugador)) )
			Olvidar();

		// Buscar el enemigo percibido más cercano
		var miFaccion = Components.Get<Faccion>();

		IVida mejor = null;
		var mejorDistancia = float.MaxValue;

		foreach ( var vida in Vidas.Todas( Scene ) )
		{
			if ( vida.GameObject == GameObject ) continue;
			if ( vida.EstaMuerto ) continue;
			if ( vida.FueraDeCombate ) continue;
			if ( SbhConVars.IgnorarJugadores && vida.EsJugador ) continue;
			if ( !Faccion.SonEnemigos( miFaccion, vida.GameObject.Components.Get<Faccion>() ) ) continue;
			if ( !Percibe( vida ) ) continue;

			var distancia = vida.WorldPosition.Distance( WorldPosition );
			if ( distancia < mejorDistancia )
			{
				mejorDistancia = distancia;
				mejor = vida;
			}
		}

		if ( mejor != null )
		{
			ObjetivoActual = mejor;
			ObjetivoVisible = true;
			UltimaPosicionConocida = mejor.WorldPosition;
			_desdeVisto = 0f;
			return;
		}

		// No percibe a nadie: mantener el recuerdo un rato y después olvidar
		ObjetivoVisible = false;

		if ( ObjetivoActual != null && _desdeVisto > TiempoOlvido )
			Olvidar();
	}

	/// <summary>
	/// Le marca un objetivo desde afuera, aunque no lo haya percibido: el
	/// equivalente al SpotEntity/SetEnemy de DRGBase. Lo usan las skills de
	/// manada (el grito del Mono señala a la víctima para todo el pack).
	/// Entra como recuerdo — el escaneo siguiente decide si además lo ve.
	/// </summary>
	public void Marcar( IVida objetivo )
	{
		if ( objetivo == null || !objetivo.IsValid() || objetivo.EstaMuerto ) return;
		if ( IaApagada ) return;
		if ( SbhConVars.IgnorarJugadores && objetivo.EsJugador ) return;

		ObjetivoActual = objetivo;
		ObjetivoVisible = false;
		UltimaPosicionConocida = objetivo.WorldPosition;
		_desdeVisto = 0f;
	}

	/// <summary> ¿Este enemigo entra por alguno de los sentidos? </summary>
	public bool Percibe( IVida objetivo )
	{
		// Omnisciente: lo ve esté donde esté, incluso atrás y tras una pared
		// (pisa también al filtro: el grito de manada anula el sigilo).
		// Por skill (GritoManada) o por el interruptor GLOBAL de la base.
		if ( ModoOmnisciente || SbhConVars.Omnisciencia ) return true;

		var distancia = objetivo.WorldPosition.Distance( WorldPosition );

		// Filtro del pack (sigilo): false = no lo percibe por más cerca que
		// esté; true = percibido aunque esté fuera del cono (el ruido delata
		// la dirección), pero una pared igual lo esconde
		var filtro = Components.Get<IFiltroPercepcion>();
		if ( filtro != null )
		{
			var veredicto = filtro.PuedeVer( objetivo, distancia );
			if ( veredicto == false ) return false;
			if ( veredicto == true )
				return distancia <= RangoVistaEfectivo && TieneLineaDeVision( objetivo );
		}

		// Oído: corto alcance sin importar el ángulo, pero las paredes
		// SÍ bloquean (si no, es un radar y se siente omnisciente)
		if ( distancia <= RangoOido )
			return TieneLineaDeVision( objetivo );

		// Vista: alcance + cono + línea de visión libre
		if ( distancia > RangoVistaEfectivo )
			return false;

		var direccion = (objetivo.WorldPosition - WorldPosition).WithZ( 0 );
		if ( direccion.Length > 1f )
		{
			var cosLimite = MathF.Cos( AnguloVista * 0.5f * MathF.PI / 180f );
			var cosAngulo = Vector3.Dot( WorldRotation.Forward.WithZ( 0 ).Normal, direccion.Normal );
			if ( cosAngulo < cosLimite )
				return false;
		}

		return TieneLineaDeVision( objetivo );
	}

	bool TieneLineaDeVision( IVida objetivo )
	{
		// Apuntar al torso del objetivo, no a sus pies
		var puntoObjetivo = objetivo.WorldPosition + Vector3.Up * 40f;

		var trace = Scene.Trace.Ray( Ojos, puntoObjetivo )
			.IgnoreGameObjectHierarchy( GameObject )
			.IgnoreGameObjectHierarchy( objetivo.GameObject )
			.Run();

		// Si el rayo no chocó con nada en el camino, lo ve
		return !trace.Hit;
	}

	public void Olvidar()
	{
		ObjetivoActual = null;
		ObjetivoVisible = false;
	}
}