PercepcionNextbot component for a nextbot AI. It implements sight cone with LOS and short-range hearing, alert scaling, remembering last known position, periodic scanning for enemies, and reacts to damage to mark attackers; supports omniscience and external perception filters via IFiltroPercepcion.
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;
}
}