06 junio 2025

Cómo implementar un cono de visión en Unity

Un cono de visión
Un cono de visión en videojuegos es un mecanismo utilizado principalmente en juegos de sigilo o estrategia para simular el campo de visión de un personaje no jugable (PNJ), como un enemigo o un guardia. Se representa como un área cónica que se origina en los ojos del PNJ y se extiende hacia adelante en un ángulo determinado, definiendo el rango y la dirección en la que el PNJ puede detectar al jugador u otros objetos. Si no hay obstáculos por medio, todos los objetos que se encuentren dentro del cono de visión del jugador son visibles para este.

Puedes encontrar algunos ejemplos célebres de uso de este concepto en juegos como Commandos o Metal Gear Solid. En Commandos el cono de visión de los enemigos es visible en la ventana principal  para mostrar el área de vigilancia de los soldados enemigos.

Conos de visión en Commandos
Conos de visión en Commandos

En Metal Gear Solid los conos de visión no se muestran en la ventana principal sino en el minimapa de la esquina superior derecha, para permitir que el jugador pueda planear sus movimientos para desplazarse por el escenario sin ser detectado.


Conos de visión en Metal GearSolid

En general, el cono de visión es clave para crear mecánicas de sigilo, ya que obliga al jugador a planificar movimientos, usar el entorno (como coberturas o distracciones) y gestionar el tiempo para evitar ser detectado. También añade realismo, ya que simula cómo los humanos o criaturas tienen un campo de visión limitado, no omnidireccional.

En este artículo vamos a ver cómo podemos implementar un cono de visión en Unity. La idea es crear un sensor que simule este modo de detección, de tal manera que podamos añadirlo a nuestros PNJ.

Características principales de los conos de visión


  • Ángulo: El cono suele tener forma de triángulo o sector circular en 2D, o un cono tridimensional en juegos 3D. El ángulo de visión (por ejemplo, 60° o 90°) determina la amplitud de campo que el PNJ puede "ver".
  • Distancia: El cono tiene un alcance máximo, más allá del cual el PNJ no detectará al jugador, incluso si está dentro del ángulo de visión.
Puedes añadirles más adornos, pero un cono de visión se define sólo por esos dos factores.

En muchos juegos, el cono de visión se muestra gráficamente al jugador (especialmente en vista cenital o en interfaces específicas) para indicar dónde debe evitar ser visto. Puede cambiar de color (por ejemplo, verde para "sin alerta" y rojo para "alerta"). En este artículo no cubriré la parte visual porque tampoco aporta mucho. Quiero enfocarme a implementar lo que el PNJ puede ver y lo que no, no a la representación del cono.

En Unity, lo normal es que el componente que implemente el cono de visión exponga esas dos características en el inspector, tal y como se puede ver en la captura siguiente:

Campos básicos para implementar un cono de visión
Campos básicos para implementar un cono de visión

En mi caso, detectionRange (línea 15) implementa la distancia, mientras que detectionSemiconeAngle (línea 18) implementa el ángulo.

En el caso del ángulo, mi código parte de unas premisas que hay que tener en cuenta. La primera es que he usado un atributo [Range] (línea 17) para poder configurar el valor de este campo con un control deslizante y para limitar el rango de valores pasibles al intervalo entre 0 y 90 grados. Si bien es cierto que el ángulo de visión de una persona es superior a los 90º grados laterales, en los juegos sería demasiado difícil esquivar a un personaje con ese cono de visión así que lo normal es no superar los 90º, siendo los 45º lo más habitual. La segunda premisa es que trato al ángulo como un semiángulo. Es decir, lo mido desde la dirección que considere frontal (Forward, en mi caso) en un sentido, y luego se replica especularmente en el otro sentido para generar un cono simétrico. 

Los dos parámetros que definen un cono de visión
Los dos parámetros que definen un cono de visión


En mi ejemplo, estoy trabajando en 2D, así que he definido que Forward sea el eje +Y local, tal y como se puede ver en la siguiente captura. 
 
Definición del vector frontal (Forward)
Definición del vector frontal (Forward)


En la línea 20 de la captura de código he incluido un campo más, layersToDetect, que vamos a usar de filtro, como veremos un poco más adelante.


Cómo detectar si una posición está dentro del cono de visión


Definida la distancia y el ángulo, tenemos que valorar si la posición a comprobar está a menos de esa distancia y si el ángulo entre el vector de posición relativa y el vector Forward del cono es inferior al ángulo de este. En Unity, es muy fácil calcular ambas cosas.

 
Método para determinar si una posición está dentro del cono de visión
Método para determinar si una posición está dentro del cono de visión

La manera más sencilla de calcular la distancia es usar el método Vector2.Distance(), tal y como hago en la línea 126 de la captura, pasándole como parámetros la posición del cono de visión (coincidente con su vértice) y la posición a chequear.

Para el ángulo, podemos usar el método Vector2.Angle(), tal y como se ve en la línea 127. Este método devuelve el ángulo absoluto entre dos vectores, por eso le paso por un lado Forward (línea 128) y por otro el vector de la posición a chequear, relativa al origen del cono (línea 129).

Si tanto la distancia, como el ángulo, están por debajo de los umbrales definidos en el cono, entonces la posición chequeada está dentro de este.


Filtrado de objetos


Podríamos dejar aquí el artículo y tendrías un cono de visión funcional. Te bastaría con recopilar todos los objetos potencialmente visibles del escenario, e ir pasando sus posiciones (uno a uno) al método PositionIsInConeRange() definido antes. Habría que hacer esta comprobación periódicamente, quizás en el método Update() o en el FixedUpdate().

Sin embargo, esto no sería muy eficiente ya que el escenario podría ser enorme y y contener multitud de objetos. Sería mucho mejor si pudiéremos hacer un filtrado previo, de manera que sólo le pasásemos a PositionIsInConeRange() los objetos mínimos e imprescindibles.

Filtrado por capas


El primer filtrado que podríamos aplicar es por capa. Podemos distribuir los objetos del escenario en distintas capas y configurar el cono de visión para que sólo tuviese en cuenta los objetos de una capa determinada. Ese era el propósito del campo layersToDetect mencionado antes. Extendido, este campo tiene un aspecto como el de la captura.

Campo layersToDetect de tipo LayerMask
Campo layersToDetect de tipo LayerMask

Este tipo de campos permite selección múltiple, por lo que puedes definir que tu cono analice varias capas simultáneamente.

Una vez que sepas qué capas quieres analizar, discriminar si un objeto está o no en alguna de esas capas es aparentemente sencillo, como puedes ver en la siguiente captura.

Cómo saber si un objeto está en alguna de las capas de un LayerMask
Cómo saber si un objeto está en alguna de las capas de un LayerMask

Digo "aparentemente" sencillo porque, aunque puedes limitarte a copia-pegar este código en el tuyo, entenderlo del todo tiene su miga.

Para empezar, un LayerMask tiene un campo value que es un entero de 32 bits en el que cada uno de ellos representa las 32 capas posibles en una escena de Unity. Puedes imaginarte una sucesión de 32 unos y ceros. Si en el campo layerMask incluyes dos capas, en el campo value aparecerán 2 bits con valor uno y el resto serán ceros. El valor entero final del campo dependerá de la posición de esos unos aunque, en realidad, dicho valor es indiferente porque lo importante es qué posiciones tienen un uno.

Por otro lado, todos los objetos de Unity tienen un campo llamado layer que contiene un entero con valores que van desde el 0 al 31. Este entero lo que señala es el índice de la capa a la que pertenece el objeto, dentro del LayerMask de todas las capas posibles de la escena. Por ejemplo, si el campo layer de un objeto tiene un valor 3, y esa capa está incluida en un LayerMask, entonces dicho LayerMask tendrá un uno en su bit de índice 3.

Para saber si la capa de un objeto está dentro de las marcadas en un LayerMask tenemos que hacer una comparación, usando para ello la capa del objeto como máscara. El truco es generar un entero cuyo valor binario esté lleno de ceros y meter un uno en la posición que corresponde a la capa a comprobar. Ese entero es lo que llamamos la máscara. Compararemos dicha máscara con el LayerMask, haciendo un AND binario, y veremos si el valor resultante es o no distinto de cero. Si fuese cero significaría que el LayerMask no incluía la capa que queríamos comprobar.

De ve mejor representando el ejemplo de antes. Mira la siguiente captura. 

Operación para comprobar si una capa está contenida dentro de un LayerMask
Operación para comprobar si una capa está contenida dentro de un LayerMask

En ella he representado un LayerMask que con dos capas, la de índice 1 y la de índice 3 (son las posiciones que tiene a uno). Supón ahora que queremos comprobar si el LayerMask contiene la capa 3. 

Lo que hemos hecho ha sido genera una máscara con todo ceros, salvo el uno de la posición 3, y hemos hecho AND con el LayerMask. El hacer AND con una máscara hace depender el resultado final del valor que tuvieran los dígitos del LayerMask en las posiciones marcadas por la máscara. En este caso, la máscara señala la posición 3 por lo que el resultado final será cero o diferente a cero dependiendo de si la posición 3 del LayerMask es cero o diferente de cero. En este caso será diferente de cero.

Filtrado por cercanía


Con el filtrado por capas evitaremos llamar a PositionIsInConeRange() por objetos que estén en capas que no nos interesen. Eso supondrá una mejora en el rendimiento, pero podemos mejorarlo más.

Otro filtrado previo que podemos hacer es descartar aquellos objetos que estén demasiado lejos del cono como para que exista la posibilidad de estar en él.

Como puedes ver en la captura, todo cono de visión puede encajarse en un caja que lo envuelve.

Caja envolvente de un cono de visión
Caja envolvente de un cono de visión


Si esa caja fuese un sensor volumétrico (en términos de Unity: un collider en modo trigger), podríamos pasarle a PositionIsInConeRange() sólo los objetos que entrasen en el sensor volumétrico y fuesen de las capas que nos interesasen.

Método para procesar los objetos que entrasen en la caja


En el código de la captura, OnObjectEnteredCone() sería un event handler que se aplicaría en caso de que un objeto entrase en la caja. En mi caso, el collider en modo trigger lleva asociado un script que emite un UnityEvent cuando el trigger hace saltar su OnTriggerEnter2D. Lo que he hecho es asociar OnObjectEnteredCone() a ese UnityEvent.

Partiendo de ahí, el código de la captura es sencillo. En la línea 159 comprobamos si el objeto está en una de las capas que nos interesa, usando el método ObjectIsInLayerMask() que analizamos antes. En caso afirmativo, en la línea 161 se comprueba si el objeto se encuentra en el área cubierta por el cono de visión, usando el método PositionIsInConeRange() que veíamos al principio. Y, finalmente, si ambas comprobaciones resultan positivas, se añade al objeto a la lista de los objetos detectados por el cono de visión (línea 164) y se emite un evento para que los scripts que usen el cono de visión sepan que este ha hecho una nueva detección.

Como te puedes imaginar, hace falta implementar un método recíproco para procesar los objetos que salgan de la caja de detección; sí como otro método para procesar los que pudieran mantenerse dentro de la caja de detección pero se hubiesen salido del área cubierta para el cono. Bastará con enlazar eventHandler a los métodos OnTriggerExit2D() y OnTriggerStay2D() del script del trigger collider de la caja de detección. Ninguna de esas casuísticas tiene especial complejidad, una vez entendido el código de OnObjectEnteredCone(), pero de todos modos te muestro mi implementación de la comprobación de un objeto que permanezca en el área de detección.

Comprobación de un objeto que permanezca en el área de detección
Comprobación de un objeto que permanezca en el área de detección


Llegados a este punto, probablemente te estés preguntando cómo dimensionar la caja para que se ajuste al cono de visión. 

Si te fijas en la captura que ponía antes, con la caja envolviendo en cono de visión, verás que la altura de la caja coincide con el parámetro que he denominado detectionRange. 

Lo que tiene algo más de miga es la anchura de la caja, ya que tenemos que recurrir a trigonometría básica. Fíjate en la captura:

Algo de trigonometría para calcular el ancho de la caja
Algo de trigonometría para calcular el ancho de la caja


Partiendo de la captura, para encontrar la anchura de la caja de detección necesitamos calcular la longitud de B, que se corresponderá con la mitad de dicha anchura. 

B es uno de los lados del rectángulo creado usando detectionRange como diagonal. Todo rectángulo está compuesto a su vez de dos triángulos rectángulo cuya hipotenusa será precisamente detectionRange. Si nos fijamos en el triángulo rectángulo superior (el de la zona roja), y repasamos la trigonometría que dimos en el colegio, estaremos de acuerdo de que el seno de detectionSemiConeAngle es igual a B dividido entre detectionRange. Por lo tanto, podemos calcular B como el producto entre detectionRange y el seno de detectionSemiConeAngle; siendo la anchura total de la caja de detección el doble de B.

Traducido a código, las dimensiones de la caja de detección se calcularían de la siguiente forma:

Cálculo de las dimensiones de la caja de detección
Cálculo de las dimensiones de la caja de detección


Puedes hacer este cálculo cada vez que cambies los parámetros del cono de visión, y modificar manualmente las dimensiones del trigger collider con el resultado del cálculo; pero yo he preferido hacerlo automático enlazando el collider con un componente BoxRangeManager que he implementado yo, y que modifica dinámicamente el tamaño del collider según vayas cambiando los campos Range y Width de dicho BoxRangeManager. La implementación de ese componente se basa en lo que expliqué en mi artículo sobre "Sensores volumétricos con dimensiones dinámicas en Unity", así que no voy a repetirlo aquí.


Conclusión


Con esto ya tienes todo lo que necesitas para crear un cono de visión, sencillo, eficiente. Mi consejo es que crees un componente genérico que reutilices en tus diferentes proyectos. Se trata de un elemento tan habitual que no tiene sentido implementarlo desde cero cada vez. Este debería ser uno de los elementos de tu librería  personal de componentes reutilizables.

Espero que este artículo te haya resultado interesante y te sirva para crear mecánicas de juego emocionantes.

28 mayo 2025

Cómo ejecutar métodos desde el inspector de Godot

Botón en el inspector
Botón en el inspector
Hace unos días publiqué un artículo explicando cómo hacer en Unity para activar la ejecución de métodos desde el inspector. Vimos la posibilidad de utilizar un atributo que generaba una entrada en un menú del editor; y también cómo crear un editor personalizado para que el inspector del componente mostrase un botón con el que activar el método.

¿En Godot se puede hacer lo mismo? Bueno, hasta hace realmente poco no. No había una manera fácil que permitiese añadir un botón al inspector sin meterse a desarrollar un plugin y sin que el resultado fuera discutible. En lo que se refiere a personalización del GUI, a Godot aún le queda un largo camino por recorrer para estar al nivel de Unity. 

El atributo [ExportToolButton]

Sin embargo, Godot añadió recientemente un nuevo atributo, @export_tool_button, y su equivalente en C# [ExportToolButton]. Este atributo permite exportar al inspector un campo de tipo Callable y mostrarlo como un botón. Al pulsar el botón se activa el método al que apunte el Callable.

Vamos a ver un ejemplo en Godot C#. Supongamos que tenemos un método ResetBoxManager en nuestro script:

El método que queremos activar al pulsar el botón
El método que queremos activar al pulsar el botón

Lo que hace el método da igual. Se trata sólo de un ejemplo. Muestro una captura de su contenido para que veas que la declaración e implementación del método no tiene nada de particular.

Y ahora el botón. Para declararlo, sólo tienes que decorar un campo de tipo Callable con una etiqueta [ExportToolButton].

Declaración del botón con [ExportToolButton]
Declaración del botón con [ExportToolButton]

Entre los paréntesis del atributo pondremos el texto que queremos que muestre el botón. 

Por otro lado, en la captura puedes ver cómo inicializar el Callable. He llamado al campo ResetButton (línea 107) y lo he inicializado con una nueva instancia de Callable que apunta, por sus parámetros, al método ResetBoxManager de esa misma clase (por eso el "this"), tal y como se puede ver en la línea 108.

Con eso, tu inspector mostrará el botón en el lugar que le habría correspondido al campo, y cuando lo pulses se activará el método enlazado. Tienes una captura de cómo queda en la imagen que abre este artículo.

Conclusión

Como puedes ver, el atributo [ExportToolButton] hace realmente fácil añadir botones a tus inspectores. Combina la sencillez del atributo [ContextMenu] que vimos en Unity, con la apariencia visual de sus editores personalizados. Con esto podrás dar un paso adelante a la hora de dotar a tus inspectores de funcionalidad que agilice el desarrollo y te facilite la depuración de tus proyectos.