23 abril 2025

Cómo detectar obstáculos en Unity 2D

En los juegos es bastante habitual tener que averiguar si un punto del escenario está libre de obstáculos para poder ubicar a un personaje o a otro elemento del juego. Piensa por ejemplo en un RTS: a la hora de construir un edificio tienes que elegir una sección de terreno libre ¿pero cómo puede saber tu código si un emplazamiento tiene o no ya un edificio u otro tipo de objeto?

En un juego 3D, la solución más habitual suele ser proyectar un rayo desde el foco de visión de la cámara, pasando por el punto donde se sitúa el cursor del ratón, en el plano de la pantalla, hasta chocar con un collider. Si el collider es el del suelo, ese punto está libre, y si no es que hay un obstáculo.

Claro, si el objeto que queremos colocar es más grande que un punto, proyectar un simple rayo se nos queda corto. Imagina que queremos colocar un edificio rectangular y el punto donde iría su centro está libre, pero la zona de la esquina no. Afortunadamente, para esos casos, Unity nos permite proyectar formas complejas, más allá de un mero punto. Por ejemplo, los métodos SphereCast permiten desplazar una esfera invisible a lo largo de una línea, devolviendo el primer collider con el que tope, Otro de los métodos, BoxCast, permitiría resolver la problemática del edificio rectangular proyectando una caja de base rectangular a lo largo de una línea. No tendríamos más que hacer esa proyección a lo largo de una línea vertical a la posición del suelo que queremos revisar.

En 2D, también hay métodos de proyección, BoxCast y CircleCast, pero sólo sirven cuando la proyección tiene lugar en el plano XY (el de la pantalla). Es decir, equivalen a mover una caja o un círculo en línea recta, a lo largo de la pantalla, a ver si tocan un collider. Por supuesto, eso tiene su utilidad. Imagina que estás haciendo un juego cenital y quieres comprobar si el personaje será capaz de atravesar una abertura en un muro. En ese caso te bastará con hacer un CircleCast de un círculo, con un diámetro como el ancho de hombros de nuestro personaje, proyectando a través de la abertura para ver si el círculo llega a rozar los collider del muro. 

Un CircleCast, proyectando un círculo a lo largo de un vector.
Un CircleCast, proyectando un círculo a lo largo de un vector.

¿Pero qué pasa cuando tienes que proyectar en el eje Z en un juego 2D? Por ejemplo, para un caso en 2D equivalente al ejemplo que poníamos antes para 3D. Ahí ya no nos servirían ni BoxCast ni CircleCast porque esos métodos definen el vector de proyección mediante un parámetro Vector2, limitado al plano XY. En esos casos, se usa una familia diferente de métodos: los métodos "Overlap".

Los métodos Overlap colocan una forma geométrica en un punto determinado del espacio 2D y, en caso de que la forma se superponga con algún collider, lo devuelven. Al igual que las proyecciones, hay métodos especializados en diferentes formas geométricas: OverlapBox, OverlapCapsule u OverlapCircle entre otros.

Supongamos un caso como el de la figura siguiente. Queremos saber si una forma con el tamaño del círculo rojo tocaría algún obstáculo (en negro) en caso de ubicarse en el punto marcado en la figura. 

Ejemplo de uso de OverlapCircle.
Ejemplo de uso de OverlapCircle.


En ese caso, usaríamos OverlapCircle para "dibujar" un circulo invisible en ese punto (el círculo que se ve en la figura es un mero gizmo) y comprobaríamos si el método devuelve algún collider. De no ser así, significaría que el emplazamiento elegido estaría libre de obstáculos.

Un método que llamase a OverlapCircle podría ser tan sencillo como el siguiente:

Llamada a OverlapCircle
Llamada a OverlapCircle

El método de la figura devuelve verdadero si no hay ningún collider en un radio  (MinimumCleanRadius) de la posición candidateHidingPoint. En caso de que haya algún collider, el método devolverá false. Para eso, el método IsCleanHidingPoint se limita a llamar a OverlapCircle, pasándoles como parámetros:

  • candidateHidingPoint (línea 224): Un Vector2 con la posición del centro del círculo a trazar.
  • MinimumCleanRadius (línea 225): Un float con el radio del círculo.
  • NotEmpyGroundLayers (línea 226): Un LayerMask con las capas de los collider que queremos detectar. Sirve para filtrar los collider que no queramos detectar. OverlapCircle descartará un collider que no se encuentre en una de las capas que hayamos pasado en el LayerMask.

En caso de que el área esté limpia de colliders, OverlapCircle devolverá null. En caso de que soí los haya, devolverá el primer collider que encuentre. Si te interesase conseguir el listado de todos los collider que pudiera haber en el área podríamos usar la variante OverlapCircleAll, que devuelve una lista con todos ellos.

Podríamos finalizar aquí, pero no quiero hacerlo sin advertirte de un quebradero de cabeza que te encontrarás sin duda en 2D. Por suerte, puede solucionarse fácilmente si estás avisado. 

El problema puede darse si usas tilemaps. Estos son muy habituales para dar forma a los escenarios en 2D. La cuestión es que para conformar los collider de un tilemap, lo normal es usar un componente "Tilemap Collider 2D", y también es bastante habitual añadir un componente "Composite Collider 2D" para sumar todos los collider individuales de cada tile en uno sólo y mejorar así el rendimiento. El problema viene de que por defecto el componente "Composite Collider 2D" genera un collider hueco, sólo definido por su perfil. Supongo que lo hace por rendimiento.  Eso ocurre cuando el parámetro "Geometry Type" tiene el valor Outlines.

Valores posibles del parámetro Geometry Type.
Valores posibles del parámetro Geometry Type.

¿Por qué supone un problema que el collider sea hueco? porque en ese caso la llamada a OverlapCircle sólo detectará el collider si el círculo que dibuje llega a cruzarse con el borde del collider. Si por el contrario, el círculo encajase limpiamente dentro del collider, sin tocar ninguno de sus bordes, entonces OverlapCircle no devolvería collider alguno y supondríamos erróneamente que la zona estaría limpia.

La solución es sencilla, una vez que te lo han explicado. Hay que cambiar el valor por defecto de "Geometry Type" a Polygons. Este valor hace que el collider generado sea "macizo" por lo que OverlapCollider lo detectará aunque el círculo dibujado encaje dentro sin tocar sus bordes.

Parece una tontería, porque lo es, pero fue una tontería que me llevó un par de horas resolver hasta que conseguí dar con la tecla. Espero que este artículo sirva para que no te pase a ti también. 

18 febrero 2025

Asignación de datos a Tiles en Unity

Es muy habitual recurrir a Tilemaps para dar forma a juegos 2D. La sencillez que ofrecen los hacen ideales a la hora de darle forma a un escenario de estética retro. 

Sin embargo, a primera vista, toda la implementación que hace Unity de los Tilemap parece limitarse a su aspecto estético. Por eso, es bastante frecuente encontrarse a gente en los foros preguntando cómo se puede asociar datos a los diferentes Tiles utilizados en un Tilemap.

¿Para que podemos necesitar asociarle datos a un tile? Por múltiples razones. Por ejemplo, supongamos que estemos usando un Tilemap para conformar el mapa de un juego en perspectiva cenital. En ese caso, es probable que queramos añadirle un valor de "resistencia al avance" a los diferentes tiles, de manera que nuestro personaje se mueva más lento en los tiles que representen una ciénaga, más rápido en los tiles que representen un camino, y que no pueda travesar los tiles que muestren piedras infranqueables.

Para nuestros ejemplos, vamos a suponer un escenario como el de la captura:

Escenario de nuestros ejemplos
Escenario de nuestros ejemplos


Representa un recinto cerrado que incluye tres obstáculos en su interior, uno a la izquierda (con una sola baldosa), otro en el centro (con cuatro baldosas) y otro a la derecha (con tres). El origen de coordenadas del escenario está en su centro; lo he señalado con la cruceta de un GameObject vacío.

La problemática que queremos resolver en nuestro ejemplo es cómo hacer para que un script pueda analizar el escenario, identificar las losetas negras y tomar nota de sus posiciones.

Como en muchos otros casos, no hay una solución única a esta problemática. Tenemos una opción rápida de implementar y que ofrece más posibilidades, pero que puede suponer una sobrecarga excesiva para el juego. Por otro lado, tenemos otra opción que es más ardua de implementar y está más limitada, pero que sobrecargará menos el juego. Vamos a analizar ambas.

Asociar un GameObject a un tile

Generalmente, cuando queremos identificar de una tacada los GameObject que pertenecen a una misma categoría, lo más sencillo sería marcarlos con una etiqueta y buscarlos por el escenario con el método estático GameObject.FindGameObjectsWithTag(). El problema es que los tiles son ScriptableObjects, así que no pueden marcarse con etiquetas.

Los ScriptableObjects de los tiles se crean cuando arrastramos sprites sobre la pestaña del Tile Palette. En ese momento, el editor nos deja elegir el nombre y la ubicación del asset con el ScriptableObject que queramos crear, asociado al tile. A partir de ese momento, si pinchamos en el asset de ese ScriptableObject podremos editar sus parámetros a través del inspector. Por ejemplo, para el tile que he utilizado para los muros perimetrales, los parámetros son:

Configuración de un Tile
Configuración de un Tile

Los campos que se pueden configurar son:
  • Sprite: Es el sprite con la apariencia visual del tile. Una vez fijado el sprite podemos pulsar el botón "Sprite Editor" de debajo para configurar tanto el pivot point como el collider asociado al sprite.
  • Color: Permite colorear el sprite con el color que fijemos aquí. El color neutro es el blanco; si lo usamos, Unity entenderá que no queremos forzar el color del sprite.
  • Collider Type: Define si queremos asociar un Collider al tile. Si elegimos "None" significará que no queremos que el Tile tenga un Collider asociado; si fijamos "Sprite", el collider será el que hayamos definido a través del Sprite Editor; por último si el valor elegido es "Grid", el colider tendrá la forma de las celdas del Tilemap.
  • GameObject to Instantiate: Es el parámetro que nos interesa. Lo explicaremos en un momento.
  • Flags: Sirven para modificar cómo se comporta un tile cuando se coloca en un Tilemap. Para nuestra explicación, basta con que lo dejes en su valor por defecto.
Como avanzaba, el parámetro que nos interesa para nuestro propósito es "GameObject to instantiate" si arrastramos un prefab a este campo, el Tilemap se encargará  de crear una instancia de ese prefab en cada ubicación donde aparezca ese Tile.

Por ejemplo, para poder localizar fácilmente los tiles negros, los de los obstáculos, le he asociado a ese parámetro de su Tile un prefab que he denominado ObstacleTileData.

Configuración del Tile de los obstáculos
Configuración del Tile de los obstáculos

Como lo único que quiero es poder asociarle a los tiles una etiqueta, para poder localizarlos con  FindGameObjectsWithTag(), me ha bastado con que ObstacleTileData sea un simple transform con la etiqueta que me interesa. En la captura se puede ver que he usado la etiqueta InnerObstacle.

ObstacleTileData con la etiqueta InnerObstacle


Hecho eso, y una vez desplegados por el escenario los tiles que queremos localizar, nos basta el siguiente código para hacer inventario de los tiles con la etiqueta InnerObstacle.

Código para localizar los tiles que hayamos marcado con la etiqueta InnerObstacle
Código para localizar los tiles que hayamos marcado con la etiqueta InnerObstacle

Basta que coloquemos el script anterior en cualquier GameObject situado junto al Tilemap del escenario. Yo, por ejemplo, lo tengo colgando del mismo transform que el componente Grid de los Tilemaps de mi escenario.

Cuando arranque el nivel, el Tilemap creará una instancia del prefab ObstacleTileData en cada una de las posiciones del escenario donde aparezca una loseta negra de obstáculo. Dado que el prefab ObstacleTileData no tiene componente visual, sus instancias serán invisibles para el jugador, pero no para nuestros scripts. Como esas instancias están marcadas con el tag "InnerObstacle" nuestro script puede localizarlas llamando a FindGameObjectsWithTag(), en la línea 16 del código. 

Como demostración de que el código localiza correctamente las ubicaciones de los tiles de obstáculos, he fijado un punto de interrupción en la línea 17, para que podamos analizar el contenido de la variable "obstacles" tras llamar a FindGameObjectsWithTag(). Al ejecutar el juego en modo debug, el contenido de esa variable es el siguiente:

Posiciones de los tiles de obstáculos
Posiciones de los tiles de obstáculos

Si comparamos las posiciones de los GameObjects con las de los tiles, veremos que obstacles[7] es el obstáculo que estaba la izquierda, con un único tile. Los GameObjects obstacle[2], [3], [5] y [6] se corresponden con los cuatro tiles del obstáculo central. Los tres GameObjects restantes ([0], [1] y [4]) son los tiles del obstáculo de la derecha, el que tiene forma de codo.

De esta forma, hemos conseguido un inventario rápido y sencillo de todos los tiles de un determinado tipo.

De todos modos, tirar de etiquetas no es la única manera de localizar las instancias de los GameObject asociados a cada Tile. Los objetos Tilemap ofrecen el método GetInstantiatedObject(), al que se le pasa una posición dentro del Tilemap y, a cambio, devuelve el GameObject instanciado para el tile de esa pasición. Usar este método es menos directo que localizar los objetos por etiqueta, ya que te obliga a ir examinando una a una las posiciones del Tilemap, pero habrá situaciones en las que no te quede otro remedio.

Por último, antes de abandonar esta sección del artículo, es necesario que seas consciente de que puede haber situaciones en las que instanciar un GameObject por tile puede pesar sobre el rendimiento del juego. En el caso del ejemplo se trata unos pocos tiles, pero en escenarios mucho más grandes puede que hablemos de cientos de tiles, por lo que instanciar cientos de GameObject puede ser algo que debamos pensar dos veces.

Extender la clase Tile

Por defecto, yo usaría la estrategia anterior; pero puede ser que haya situaciones en las que no te convenga instanciar un número elevado de GameObjects. En ese caso, puede convenirte el enfoque que voy a explicar ahora.

Los Tiles son una clase que hereda de ScriptableObject. Podemos extender la clase Tile para añadir los parámetros que queramos. Por ejemplo, podríamos crear un Tile especializado con un booleano para definir si el tile es un obstáculo o no.

Tile con un parámetro especializado
Tile con un parámetro especializado

Ese tile se puede instanciar como cualquier ScriptableObject para crear un asset. Cuando lo hagamos veremos que aparecerá el parámetro especializado y lo podremos configurar a través del inspector.

Configuración del tile con el parámetro especializado
Configuración del tile con el parámetro especializado

La clave está en que los assets que creemos así se pueden arrastrar hacia el Tile Palette para poder ser dibujados en el escenario.

Hecho eso, podríamos utilizar el método Tilemap.GetTile() para ir recuperando los tiles de cada una de la posición, hacerles cast a nuestro tipo de tile personalizado (en nuestro caso CourtyardTile) y, acto seguido, analizar el valor del parámetro personalizado.

La pega de este método es que no podemos usar ni etiquetas ni capas para buscar los datos asociados a los tiles, lo que nos condena a recorrer los tilemap celda a celda para encontrarlos, pero tiene la ventaja de librar a nuestro juego de la carga de crear un GameObject por tile.

Conclusión

Ya sea creando un GameObject por tile o extendiendo la clase Tile, ya tienes los recursos necesarios para asociar datos a cada uno de los tiles. Esto te permitirá dotar a los tiles de una semántica imprescindible para multitud de algoritmos, como por ejemplo los de pathfinding.