Hay dos enfoques básicos para construir niveles de plataformas. Una
de ellas es usar una cuadrícula y colocar las fichas apropiadas en las
celdas, y la otra es de forma más libre, en la que puede colocar
libremente la geometría de nivel sin importar donde desee.
Hay ventajas y
desventajas para ambos enfoques. Usaremos la grilla, así que veamos qué
tipo de ventajas tiene sobre el otro método:
Mejor
detección de colisión de rendimiento contra la red es más barato que
contra objetos colocados libremente en la mayoría de los casos.
Hace
que sea mucho más fácil manejar buscatrazos.
Los
mosaicos son más precisos y predecibles que los objetos colocados
libremente, especialmente cuando se consideran terrenos
destructibles.
Construyendo una clase de mapa
Comencemos creando una clase
Map. Retendrá todos los datos específicos del mapa.
public class Map
{
}
Ahora
tenemos que definir todas las fichas que contiene el mapa, pero antes de
hacerlo, necesitamos saber qué tipos de fichas existen en nuestro
juego. Por ahora, estamos planeando solo tres: un mosaico vacío, un mosaico sólido y una plataforma de una sola vía.
public enum TileType
{
Empty,
Block,
OneWay
}
En la
demostración, los tipos de mosaicos corresponden directamente al tipo de
colisión que nos gustaría tener con un mosaico, pero en un juego real
eso no es necesariamente así. Como tiene más
mosaicos visualmente diferentes, sería mejor agregar nuevos tipos como
GrassBlock, GrassOneWay, etc., para permitir que la enumeración de
TileType defina no solo el tipo de colisión sino también la apariencia
del mosaico.
Ahora en la clase de mapa podemos agregar una matriz de mosaicos.
public class Map
{
private TileType[,] mTiles;
}
Por
supuesto, un mapa de mosaico que no podemos ver no nos sirve de mucho,
por lo que también necesitamos sprites para hacer una copia de seguridad
de los datos del mosaico. Normalmente en Unity es
extremadamente ineficiente tener cada tesela como un objeto separado,
pero como solo estamos usando esto para probar nuestra física, está bien
hacerlo de esta manera en la demostración.
private SpriteRenderer[,] mTilesSprites;
El mapa
también necesita una posición en el espacio mundial, de modo que si
necesitamos tener más de uno solo, podemos separarlos.
public Vector3 mPosition;
Ancho y alto, en mosaicos.
public int mWidth = 80;
public int mHeight = 60;
Y el tamaño
del mosaico: en la demostración, trabajaremos con un tamaño de mosaico
bastante pequeño, que es de 16 por 16 píxeles.
public const int cTileSize = 16;
Eso sería todo. Ahora necesitamos un par de funciones de ayuda para permitirnos acceder a los datos del mapa fácilmente. Comencemos haciendo una función que convertirá las coordenadas del mundo a las coordenadas del mosaico del mapa.
public Vector2i GetMapTileAtPoint(Vector2 point)
{
}
Como puede
ver, esta función toma un Vector2 como parámetro y devuelve un Vector2i,
que es básicamente un vector 2D que opera en enteros en lugar de
flotantes.
La conversión de la posición
mundial a la posición del mapa es muy sencilla: simplemente tenemos que
cambiar el point por mPosición, de modo que devolvemos el mosaico
relativo a la posición del mapa y luego dividimos el resultado por el
tamaño del mosaico.
Tenga en
cuenta que tuvimos que cambiar point adicionalmente por cTileSize / 2.0f, porque el pivote de la pieza está en el centro. Hagamos también dos funciones adicionales que devolverán solo el
componente X e Y de la posición en el espacio del mapa. Será útil más
tarde.
public int GetMapTileYAtPoint(float y)
{
return (int)((y - mPosition.y + cTileSize / 2.0f) / (float)(cTileSize));
}
public int GetMapTileXAtPoint(float x)
{
return (int)((x - mPosition.x + cTileSize / 2.0f) / (float)(cTileSize));
}
También debemos crear una función complementaria que, dado un mosaico, devolverá su posición en el espacio mundial.
public Vector2 GetMapTilePosition(int tileIndexX, int tileIndexY)
{
return new Vector2(
(float)(tileIndexX * cTileSize) + mPosition.x,
(float)(tileIndexY * cTileSize) + mPosition.y
);
}
public Vector2 GetMapTilePosition(Vector2i tileCoords)
{
return new Vector2(
(float)(tileCoords.x * cTileSize) + mPosition.x,
(float)(tileCoords.y * cTileSize) + mPosition.y
);
}
Además de
traducir posiciones, también necesitamos tener un par de funciones para
ver si un mosaico en cierta posición está vacío, si es un mosaico sólido
o si es una plataforma de un solo sentido. Comencemos con una función GetTile muy genérica, que devolverá un tipo de mosaico específico.
public TileType GetTile(int x, int y)
{
if (x < 0 || x >= mWidth
|| y < 0 || y >= mHeight)
return TileType.Block;
return mTiles[x, y];
}
Como puede ver, antes de devolver el tipo de mosaico, verificamos si la posición dada está fuera de límites. Si es así, entonces queremos tratarlo como un bloque sólido; de lo contrario, devolveremos un tipo verdadero.
El siguiente en cola es una función para verificar si un azulejo es un obstáculo.
public bool IsObstacle(int x, int y)
{
if (x < 0 || x >= mWidth
|| y < 0 || y >= mHeight)
return true;
return (mTiles[x, y] == TileType.Block);
}
De la misma
manera que antes, comprobamos si el mosaico está fuera de límites, y si
es así, volvemos a verdadero, por lo que cualquier loseta fuera de
límites se trata como un obstáculo.
Ahora vamos a verificar si el azulejo es una loseta de tierra. Podemos
pararnos tanto en un bloque como en una plataforma unidireccional, por
lo que debemos volver verdadero si el mosaico es uno de estos dos.
public bool IsGround(int x, int y)
{
if (x < 0 || x >= mWidth
|| y < 0 || y >= mHeight)
return false;
return (mTiles[x, y] == TileType.OneWay || mTiles[x, y] == TileType.Block);
}
Finalmente, agreguemos las funciones IsOneWayPlatform e IsEmpty de la misma manera.
public bool IsOneWayPlatform(int x, int y)
{
if (x < 0 || x >= mWidth
|| y < 0 || y >= mHeight)
return false;
return (mTiles[x, y] == TileType.OneWay);
}
public bool IsEmpty(int x, int y)
{
if (x < 0 || x >= mWidth
|| y < 0 || y >= mHeight)
return false;
return (mTiles[x, y] == TileType.Empty);
}
Eso es todo lo que necesitamos que haga nuestra clase de mapas. Ahora podemos avanzar e implementar la colisión de personajes contra ella.
Colisión en mapa de caracteres
Regresemos a la clase MovingObject.
Necesitamos crear un par de funciones que detectarán si el personaje
está colisionando con el mapa de mosaicos.
El método por el cual sabremos
si el personaje colisiona con un mosaico o no es muy simple.
Comprobaremos todas las fichas que existen justo fuera de la AABB del
objeto en movimiento.
El cuadro amarillo representa el AABB del
personaje, y vamos a verificar los mosaicos a lo largo de las líneas
rojas. Si
alguno de ellos se superpone con un mosaico, establecemos una variable
de colisión correspondiente en verdadero (como mOnGround, mPushesLeftWall, mAtCeiling o mPushesRightWall).
Comencemos creando una
función HasGround, que verificará si el personaje colisiona con una
ficha de tierra.
public bool HasGround(Vector2 oldPosition, Vector2 position, Vector2 speed, out float groundY)
{
}
Esta función devuelve verdadero si el carácter se superpone con cualquiera de los mosaicos inferiores. Toma
la posición anterior, la posición actual y la velocidad actual como
parámetros, y también devuelve la posición Y de la parte superior de la
casilla con la que estamos colisionando y si la ficha colisionada es una
plataforma unidireccional o no.
Lo primero que queremos hacer es calcular el centro de AABB.
public bool HasGround(Vector2 oldPosition, Vector2 position, Vector2 speed, out float groundY)
{
var center = position + mAABBOffset;
}
Ahora que
lo tenemos, para el control de colisión inferior necesitaremos calcular
el principio y el final de la línea del sensor inferior. La línea del sensor está a solo un píxel por debajo del contorno inferior del AABB.
public bool HasGround(Vector2 oldPosition, Vector2 position, Vector2 speed, out float groundY)
{
var center = position + mAABBOffset;
var bottomLeft = center - mAABB.halfSize - Vector2.up + Vector2.right;
var bottomRight = new Vector2(bottomLeft.x + mAABB.halfSize.x * 2.0f - 2.0f, bottomLeft.y);
}
El bottomLeft y bottomRight representan los dos extremos del sensor. Ahora que tenemos esto, podemos calcular qué fichas debemos verificar. Comencemos creando un bucle en el que revisaremos las teselas de izquierda a derecha.
Tenga en cuenta que no hay ninguna condición para salir del bucle aquí; lo haremos al final del bucle.
Lo primero que debemos hacer en el ciclo es asegurarnos de que
checkedTile.x no sea mayor que el extremo derecho del sensor. Este
podría ser el caso porque movemos el punto marcado por múltiplos del
tamaño del mosaico, por lo que, por ejemplo, si el personaje tiene 1.5
azulejos de ancho, necesitamos verificar el mosaico en el borde
izquierdo del sensor, luego un mosaico a la derecha , y luego 1.5 fichas
a la derecha en lugar de 2.
Finalmente, verifiquemos si ya miramos a través de todos los mosaicos que se cruzan con el sensor. Si ese es el caso, entonces podemos salir del circuito de forma segura. Después
de que salimos del bucle y no encontramos una ficha con la que
colisionamos, necesitamos devolverlfalse para que la persona
que llama sepa que no hay tierra debajo del objeto.
Esa es la versión más básica del cheque. Intentemos que funcione ahora. De vuelta en la función UpdatePhysics, nuestra verificación de tierra antigua se ve así.
Reemplácelo usando el método recién creado. Si
el personaje se cae y hemos encontrado un obstáculo en nuestro camino,
entonces tenemos que sacarlo de la colisión y establecer el mOnGround en
verdadero. Comencemos con la condición.
float groundY = 0;
if (mSpeed.y <= 0.0f
&& HasGround(mOldPosition, mPosition, mSpeed, out groundY))
{
}
Si la condición se cumple, tenemos que mover el personaje en la parte superior del azulejo con el que colisionamos.
Como puede ver, es muy simple porque la función devuelve el nivel del suelo al que debemos alinear el objeto. Después de esto, solo tenemos que establecer la velocidad vertical en cero y establecer mOnGround en verdadero.
Como puede ver, ¡funciona bien! La
detección de colisión para las paredes en ambos lados y en la parte
superior del personaje todavía no está allí, pero el personaje se
detiene cada vez que toca el suelo. Todavía tenemos que poner un poco
más de trabajo en la función de comprobación de colisión para que sea
robusto.
Uno
de los problemas que debemos resolver es visible si la compensación del
personaje de un cuadro al otro es demasiado grande para detectar la
colisión correctamente. Esto se ilustra en la siguiente imagen.
Esta
situación no ocurre ahora porque hemos bloqueado la máxima velocidad de
caída a un valor razonable y actualizamos la física con una frecuencia
de 60 FPS, por lo que las diferencias en las posiciones entre los
fotogramas son bastante pequeñas. Veamos qué sucede si actualizamos la
física solo 30 veces por segundo.
Como puede ver, en este escenario
nuestra prueba de colisión de tierra nos falla. Para
solucionar esto, no podemos simplemente verificar si el personaje tiene
tierra debajo de él en la posición actual, sino que más bien
necesitamos ver si hubo obstáculos en el camino desde la posición del
cuadro anterior.
Volvamos a nuestra función HasGround. Aquí, además de
calcular el centro, también queremos calcular el centro del cuadro
anterior.
public bool HasGround(Vector2 oldPosition, Vector2 position, Vector2 speed, out float groundY)
{
var oldCenter = oldPosition + mAABBOffset;
var center = position + mAABBOffset;
También necesitaremos obtener la posición del sensor del cuadro anterior.
public bool HasGround(Vector2 oldPosition, Vector2 position, Vector2 speed, out float groundY)
{
var oldCenter = oldPosition + mAABBOffset;
var center = position + mAABBOffset;
var oldBottomLeft = oldCenter - mAABB.halfSize - Vector2.up + Vector2.right;
var bottomLeft = center - mAABB.halfSize - Vector2.up + Vector2.right;
var bottomRight = new Vector2(bottomLeft.x + mAABB.halfSize.x * 2.0f - 2.0f, bottomLeft.y);
Ahora
tenemos que calcular en qué mosaico verticalmente vamos a comenzar a
verificar si hay una colisión o no, y en el que nos detendremos.
public bool HasGround(Vector2 oldPosition, Vector2 position, Vector2 speed, out float groundY)
{
var oldCenter = oldPosition + mAABBOffset;
var center = position + mAABBOffset;
var oldBottomLeft = oldCenter - mAABB.halfSize - Vector2.up + Vector2.right;
var bottomLeft = center - mAABB.halfSize - Vector2.up + Vector2.right;
var bottomRight = new Vector2(bottomLeft.x + mAABB.halfSize.x * 2.0f - 2.0f, bottomLeft.y);
int endY = mMap.GetMapTileYAtPoint(bottomLeft.y);
int begY = Mathf.Max(mMap.GetMapTileYAtPoint(oldBottomLeft.y) - 1, endY);
Comenzamos
la búsqueda desde el mosaico en la posición del sensor del cuadro
anterior, y lo terminamos en la posición del sensor del fotograma
actual. Eso
es, por supuesto, porque cuando verificamos una colisión en el suelo
suponemos que estamos cayendo, y eso significa que nos estamos moviendo
de la posición más alta a la inferior.
Finalmente, necesitamos tener otro ciclo de iteración. Ahora, antes de
llenar el código para este ciclo externo, consideremos el siguiente
escenario.
Aquí puedes ver una flecha moviéndose rápidamente. Este
ejemplo muestra que no solo necesitamos iterar a través de todas las
teselas que necesitaríamos pasar verticalmente, sino también interpolar
la posición del objeto para cada tesela por la que pasamos para
aproximar la ruta desde la posición del marco anterior al actual. Si
simplemente seguimos usando la posición del objeto actual, en el caso
anterior se detectaría una colisión, aunque no debería ser así.
Cambiemos
el nombre de bottomLeft y bottomRight como newBottomLeft y
newBottomRight, por lo que sabemos que estas son las posiciones del
sensor del nuevo fotograma.
public bool HasGround(Vector2 oldPosition, Vector2 position, Vector2 speed, out float groundY)
{
var oldCenter = oldPosition + mAABBOffset;
var center = position + mAABBOffset;
var oldBottomLeft = oldCenter - mAABB.halfSize - Vector2.up + Vector2.right;
var newBottomLeft = center - mAABB.halfSize - Vector2.up + Vector2.right;
var newBottomRight = new Vector2(newBottomLeft.x + mAABB.halfSize.x * 2.0f - 2.0f, newBottomLeft.y);
int endY = mMap.GetMapTileYAtPoint(newBottomLeft.y);
int begY = Mathf.Max(mMap.GetMapTileYAtPoint(oldBottomLeft.y) - 1, endY);
int tileIndexX;
for (int tileIndexY = begY; tileIndexY >= endY; --tileIndexY)
{
}
return false;
}
Ahora,
dentro de este nuevo bucle, vamos a interpolar las posiciones de los
sensores, de modo que al comienzo del bucle asumimos que el sensor está
en la posición del fotograma anterior, y al final va a estar en la
posición del fotograma actual.
public bool HasGround(Vector2 oldPosition, Vector2 position, Vector2 speed, out float groundY)
{
var oldCenter = oldPosition + mAABBOffset;
var center = position + mAABBOffset;
var oldBottomLeft = oldCenter - mAABB.halfSize - Vector2.up + Vector2.right;
var newBottomLeft = center - mAABB.halfSize - Vector2.up + Vector2.right;
var newBottomRight = new Vector2(newBottomLeft.x + mAABB.halfSize.x * 2.0f - 2.0f, newBottomLeft.y);
int endY = mMap.GetMapTileYAtPoint(newBottomLeft.y);
int begY = Mathf.Max(mMap.GetMapTileYAtPoint(oldBottomLeft.y) - 1, endY);
int dist = Mathf.Max(Mathf.Abs(endY - begY), 1);
int tileIndexX;
for (int tileIndexY = begY; tileIndexY >= endY; --tileIndexY)
{
var bottomLeft = Vector2.Lerp(newBottomLeft, oldBottomLeft, (float)Mathf.Abs(endY - tileIndexY) / dist);
var bottomRight = new Vector2(bottomLeft.x + mAABB.halfSize.x * 2.0f - 2.0f, bottomLeft.y);
}
return false;
}
Tenga en cuenta que interpolamos los vectores en función de la diferencia en los mosaicos en el eje Y. Cuando
las posiciones viejas y nuevas están dentro del mismo mosaico, la
distancia vertical será cero, por lo que en ese caso no podríamos
dividir por la distancia. Entonces, para resolver este
problema, queremos que la distancia tenga un valor mínimo de 1, de modo
que si ocurriera tal escenario (y va a suceder muy a menudo),
simplemente usaremos la nueva posición para detección de colisión.
Finalmente,
para cada iteración, necesitamos ejecutar el mismo código que ya
hicimos para verificar la colisión de tierra a lo largo del ancho del
objeto.
public bool HasGround(Vector2 oldPosition, Vector2 position, Vector2 speed, out float groundY)
{
var oldCenter = oldPosition + mAABBOffset;
var center = position + mAABBOffset;
var oldBottomLeft = oldCenter - mAABB.halfSize - Vector2.up + Vector2.right;
var newBottomLeft = center - mAABB.halfSize - Vector2.up + Vector2.right;
var newBottomRight = new Vector2(newBottomLeft.x + mAABB.halfSize.x * 2.0f - 2.0f, newBottomLeft.y);
int endY = mMap.GetMapTileYAtPoint(newBottomLeft.y);
int begY = Mathf.Max(mMap.GetMapTileYAtPoint(oldBottomLeft.y) - 1, endY);
int dist = Mathf.Max(Mathf.Abs(endY - begY), 1);
int tileIndexX;
for (int tileIndexY = begY; tileIndexY >= endY; --tileIndexY)
{
var bottomLeft = Vector2.Lerp(newBottomLeft, oldBottomLeft, (float)Mathf.Abs(endY - tileIndexY) / dist);
var bottomRight = new Vector2(bottomLeft.x + mAABB.halfSize.x * 2.0f - 2.0f, bottomLeft.y);
for (var checkedTile = bottomLeft; ; checkedTile.x += Map.cTileSize)
{
checkedTile.x = Mathf.Min(checkedTile.x, bottomRight.x);
tileIndexX = mMap.GetMapTileXAtPoint(checkedTile.x);
groundY = (float)tileIndexY * Map.cTileSize + Map.cTileSize / 2.0f + mMap.mPosition.y;
if (mMap.IsObstacle(tileIndexX, tileIndexY))
return true;
if (checkedTile.x >= bottomRight.x)
break;
}
}
return false;
}
Eso es practicamente todo. Como
se puede imaginar, si los objetos del juego se mueven realmente rápido,
esta forma de verificar la colisión puede ser un poco más cara, pero
también nos asegura que no habrá fallas extrañas con objetos que se
muevan a través de paredes sólidas.
Resumen
Uff, eso fue más código de lo que pensamos que necesitaríamos,
¿no? Si detecta algún error o atajos posibles, ¡déjenmelo a todos en los
comentarios! La
prueba de colisión debe ser lo suficientemente robusta como para que no
tengamos que preocuparnos por los eventos desafortunados de los objetos
que se deslizan por los bloques del mosaico.
Gran
parte del código fue escrito para asegurarnos de que no haya objetos
que pasen a través de los mosaicos a gran velocidad, pero si eso no es
un problema para un juego en particular, podríamos eliminar el código
adicional de manera segura para aumentar el rendimiento. Incluso
podría ser una buena idea tener una bandera para objetos específicos
que se mueven rápidamente, de modo que solo aquellos usen las versiones
más costosas de los chequeos.
Todavía
tenemos muchas cosas por cubrir, pero logramos hacer un control de
colisión confiable para el terreno, que puede reflejarse de manera
bastante directa en las otras tres direcciones. Lo haremos en la
siguiente parte.