En esta parte final de la serie de tutoriales, desarrollaremos el primer tutorial y aprenderemos sobre la implementación de recolecciones, disparadores, cambio de nivel, búsqueda de rutas, seguimiento de ruta, desplazamiento de nivel, altura isométrica y proyectiles isométricos.
Las recolecciones son elementos que se pueden recolectar dentro del nivel, normalmente al caminar sobre ellos, por ejemplo, monedas, gemas, efectivo, municiones, etc..
Los datos de recolección se pueden acomodar directamente en nuestros datos de nivel de la siguiente manera:
[[1,1,1,1,1,1], [1,0,0,0,0,1], [1,0,8,0,0,1], [1,0,0, 8,0,1], [1,0,0,0,0,1], [1,1,1,1,1,1]]
En este nivel de datos, utilizamos 8
para denotar una recogida en un azulejo de hierba (1
y 0
representan paredes y azulejos transitables respectivamente, como antes). Esta podría ser una imagen de mosaico único con un mosaico de césped superpuesto con la imagen de recogida. Siguiendo esta lógica, necesitaremos dos estados de mosaico diferentes para cada mosaico que tenga un pickup, es decir, uno con pickup y otro sin mostrar después de que se recolecte el pickup..
El arte isométrico típico tendrá múltiples mosaicos transitables, supongamos que tenemos 30. El enfoque anterior significa que si tenemos N pastillas, necesitaremos N x 30 mosaicos además de los 30 mosaicos originales, ya que cada mosaico deberá tener una versión con pastillas y una sin ella. Esto no es muy eficiente; En su lugar, deberíamos tratar de crear dinámicamente estas combinaciones..
Para resolver esto, podríamos usar el mismo método que usamos para colocar al héroe en el primer tutorial. Cada vez que nos topemos con una baldosa de recolección, colocaremos una baldosa de hierba primero y luego colocaremos la recolección sobre la baldosa de hierba. De esta manera, solo necesitamos N mosaicos de recolección además de 30 mosaicos transitables, pero necesitaríamos valores numéricos para representar cada combinación en los datos de nivel. Para resolver la necesidad de valores de representación N x 30, podemos mantener una pickupArray
para almacenar exclusivamente los datos de recogida, aparte de la levelData
. El nivel completado con la recogida se muestra a continuación:
Para nuestro ejemplo, mantengo las cosas simples y no uso una matriz adicional para recolecciones.
La detección de recolecciones se realiza de la misma manera que la detección de losetas de colisión, pero después moviendo el personaje.
if (onPickupTile ()) pickupItem (); function onPickupTile () // verifica si hay una recuperación en el retorno de azulejo de héroe (levelData [heroMapTile.y] [heroMapTile.x] == 8);
En la funcion onPickupTile ()
, comprobamos si el levelData
valor de matriz en el heroMapTile
coordenada es una baldosa de recogida o no. El numero en el levelData
la matriz en esa coordenada de mosaico denota el tipo de recolección. Verificamos si hay colisiones antes de mover el personaje, pero luego necesitamos buscar recolecciones, porque en el caso de las colisiones, el personaje no debe ocupar el lugar si ya está ocupado por la ficha de colisión, pero en el caso de las recolecciones, el personaje puede moverse libremente. encima de eso.
Otra cosa a tener en cuenta es que los datos de colisión generalmente nunca cambian, pero los datos de recolección cambian cada vez que recogemos un artículo. (Esto generalmente implica cambiar el valor en el levelData
matriz de, digamos, 8
a 0
.)
Esto conduce a un problema: ¿qué sucede cuando necesitamos reiniciar el nivel y, por lo tanto, restablecer todas las pastillas de nuevo a sus posiciones originales? No tenemos la información para hacer esto, ya que el levelData
Se ha cambiado la matriz cuando el jugador recogió objetos. La solución es usar una matriz duplicada para el nivel mientras está en juego y mantener el original levelData
matriz intacta. Por ejemplo, usamos levelData
y levelDataLive []
, clone este último desde el principio al inicio del nivel, y solo cambie levelDataLive []
durante el juego.
Para el ejemplo, estoy generando una recolección aleatoria en un mosaico de césped vacío después de cada recolección e incrementando el pickupCount
. los pickuptem
la función se ve así.
function pickupItem () pickupCount ++; levelData [heroMapTile.y] [heroMapTile.x] = 0; // genera la siguiente recolección spawnNewPickup ();
Debes notar que revisamos las recolecciones cuando el personaje está en esa casilla. Esto puede suceder varias veces en un segundo (verificamos solo cuando el usuario se mueve, pero podemos dar vueltas y vueltas dentro de una casilla), pero la lógica anterior no fallará; desde que configuramos el levelData
datos de matriz a 0
La primera vez que detectamos un pickup, todos los posteriores. onPickupTile ()
los cheques regresaran falso
por ese azulejo. Echa un vistazo al siguiente ejemplo interactivo:
Como su nombre lo sugiere, las fichas disparadas causan que algo suceda cuando el jugador las pisa o presiona una tecla cuando está en ellas. Podrían teletransportar al jugador a una ubicación diferente, abrir una puerta o engendrar un enemigo, para dar algunos ejemplos. En cierto sentido, las pastillas son solo una forma especial de fichas disparadoras: cuando el jugador pisa una ficha que contiene una moneda, la moneda desaparece y su contador de monedas aumenta..
Veamos cómo podemos implementar una puerta que lleve al jugador a un nivel diferente. La baldosa junto a la puerta será una baldosa de disparo; cuando el jugador presiona el X Clave, pasarán al siguiente nivel..
Para cambiar los niveles, todo lo que tenemos que hacer es cambiar la corriente levelData
matriz con la del nuevo nivel, y establecer el nuevo heroMapTile
Posición y dirección para el personaje héroe. Supongamos que hay dos niveles con puertas para permitir el paso entre ellos. Dado que la baldosa de tierra junto a la puerta será la baldosa de disparo en ambos niveles, podemos usar esta como la nueva posición para el personaje cuando aparezcan en el nivel.
La lógica de implementación aquí es la misma que para las recolecciones, y nuevamente usamos el levelData
matriz para almacenar valores de disparo. Para nuestro ejemplo, 2
denota un azulejo de la puerta, y el valor al lado es el disparador. he utilizado 101
y 102
con la convención básica de que cualquier ficha con un valor mayor que 100 es una ficha de activación y el valor menos 100 puede ser el nivel al que lleva:
var level1Data = [[1,1,1,1,1,1], [1,1,0,0,0,1], [1,0,0,0,0,1], [2,102,0 , 0,0,1], [1,0,0,0,1,1], [1,1,1,1,1,1]]; var level2Data = [[1,1,1,1,1,1], [1,0,0,0,0,1], [1,0,8,0,0,1], [1,0 , 0,0,101,2], [1,0,1,0,0,1], [1,1,1,1,1,1]];
El código para verificar un evento desencadenante se muestra a continuación:
var xKey = game.input.keyboard.addKey (Phaser.Keyboard.X); xKey.onUp.add (triggerListener); // agrega un detector de señal para la función de evento up triggerListener () var trigger = levelData [heroMapTile.y] [heroMapTile.x]; if (disparador> 100) // disparador de mosaico de disparador válido- = 100; if (trigger == 1) // cambia a nivel 1 levelData = level1Data; else // cambia a nivel 2 levelData = level2Data; para (var i = 0; i < levelData.length; i++) for (var j = 0; j < levelData[0].length; j++) trigger=levelData[i][j]; if(trigger>100) // encuentra la nueva casilla de disparo y coloca al héroe allí heroMapTile.y = j; heroMapTile.x = i; heroMapPos = nuevo Phaser.Point (heroMapTile.y * tileWidth, heroMapTile.x * tileWidth); heroMapPos.x + = (tileWidth / 2); heroMapPos.y + = (tileWidth / 2);
La función triggerListener ()
verifica si el valor de la matriz de datos de disparo en la coordenada dada es mayor que 100. Si es así, encontramos a qué nivel debemos cambiar al restar 100 del valor de mosaico. La función encuentra la ficha disparadora en el nuevo. levelData
, que será la posición de engendro de nuestro héroe. He hecho que el gatillo se active cuando X es publicado; Si solo escuchamos la tecla presionada, terminamos en un bucle en el que intercambiamos niveles siempre que la tecla se mantenga presionada, ya que el personaje siempre aparece en el nuevo nivel en la parte superior de una ficha disparadora..
Aquí hay una demostración de trabajo. Trata de recoger objetos caminando sobre ellos e intercambiando niveles de pie junto a las puertas y golpeando X.
UNA proyectil es algo que se mueve en una dirección particular con una velocidad particular, como una bala, un hechizo mágico, una bola, etc. Todo sobre el proyectil es igual que el personaje héroe, aparte de la altura: en lugar de rodar por el suelo, Los proyectiles a menudo flotan sobre ella a cierta altura. Una bala viajará por encima del nivel de la cintura del personaje, e incluso una bola puede necesitar rebotar.
Una cosa interesante a tener en cuenta es que la altura isométrica es la misma que la altura en una vista lateral 2D, aunque de menor valor. No hay conversiones complicadas involucradas. Si una bola está 10 píxeles por encima del suelo en coordenadas cartesianas, podría estar 10 o 6 píxeles por encima del suelo en coordenadas isométricas. (En nuestro caso, el eje relevante es el eje y).
Intentemos implementar una pelota que rebota en nuestro prado amurallado. Como un toque de realismo, añadiremos una sombra para el balón. Todo lo que tenemos que hacer es agregar el valor de altura de rebote al valor isométrico Y de nuestra bola. El valor de la altura de salto cambiará de un cuadro a otro dependiendo de la gravedad, y una vez que la pelota toque el suelo, cambiaremos la velocidad actual a lo largo del eje y.
Antes de abordar el rebote en un sistema isométrico, veremos cómo podemos implementarlo en un sistema cartesiano 2D. Representemos el poder de salto de la pelota con una variable. zValue
. Imagina que, para empezar, la pelota tiene un poder de salto de 100, por lo que zValue = 100
.
Usaremos dos variables más: incrementValue
, que comienza en 0
, y gravedad
, que tiene un valor de -1
. En cada cuadro, restamos. incrementValue
desde zValue
, y restar gravedad
desde incrementValue
con el fin de crear un efecto de amortiguación. Cuando zValue
alcanza 0
, significa que la pelota ha llegado al suelo; En este punto, volteamos el signo de incrementValue
multiplicándolo por -1
, convirtiéndolo en un número positivo. Esto significa que la bola se moverá hacia arriba desde el siguiente cuadro, rebotando así..
Así es como se ve en el código:
if (game.input.keyboard.isDown (Phaser.Keyboard.X)) zValue = 100; incrementValue- = gravedad; zValue- = incrementValue; if (zValue<=0) zValue=0; incrementValue*=-1;
El código sigue siendo el mismo para la vista isométrica, con la ligera diferencia de que puede usar un valor más bajo para zValue
para empezar. Vea a continuación cómo zValue
Se añade a la isométrica. y
Valor de la bola mientras se rinde..
function drawBallIso () var isoPt = new Phaser.Point (); // No es recomendable crear puntos en el bucle de actualización var ballCornerPt = new Phaser.Point (ballMapPos.x-ball2DVolume.x / 2, ballMapPos.y ball2DVolume .y / 2); isoPt = cartesianToIsometric (ballCornerPt); // encuentra la nueva posición isométrica para el héroe en la posición del mapa 2D gameScene.renderXY (ballShadowSprite, isoPt.x + borderOffset.x + shadowOffset.x, isoPt.y + borderOffset.y + shadowOffset.y false ); // dibujar la sombra para representar la textura gameScene.renderXY (ballSprite, isoPt.x + borderOffset.x + ballOffset.x, isoPt.y + borderOffset.y-ballOffset.y-zValue, false); // héroe dibujar textura
Echa un vistazo al siguiente ejemplo interactivo:
Comprenda que el papel desempeñado por la sombra es muy importante y se suma al realismo de esta ilusión. Además, tenga en cuenta que ahora estamos usando las dos coordenadas de pantalla (x e y) para representar tres dimensiones en coordenadas isométricas; el eje y en las coordenadas de pantalla también es el eje z en las coordenadas isométricas. Esto puede ser confuso!
Pathfinding y ruta siguiente son procesos bastante complicados. Hay varios enfoques que utilizan diferentes algoritmos para encontrar la ruta entre dos puntos, pero como nuestro levelData
es una matriz 2D, las cosas son más fáciles de lo que podrían ser. Tenemos nodos bien definidos y únicos que el jugador puede ocupar, y podemos verificar fácilmente si se pueden caminar.
Una descripción detallada de los algoritmos de búsqueda de rutas está fuera del alcance de este artículo, pero intentaré explicar la forma más común en que funciona: el algoritmo de ruta más corta, del cual A * y los algoritmos de Dijkstra son implementaciones famosas..
Nuestro objetivo es encontrar nodos que conecten un nodo inicial y un nodo final. Desde el nodo inicial, visitamos los ocho nodos vecinos y los marcamos como visitados; este proceso central se repite para cada nodo recién visitado, recursivamente.
Cada hilo rastrea los nodos visitados. Al saltar a los nodos vecinos, los nodos que ya han sido visitados se omiten (la recursión se detiene); de lo contrario, el proceso continúa hasta que alcanzamos el nodo final, donde finaliza la recursión y la ruta completa seguida se devuelve como una matriz de nodos. A veces, nunca se llega al nodo final, en cuyo caso el pathfinding falla. Por lo general, terminamos encontrando múltiples rutas entre los dos nodos, en cuyo caso tomamos la que tiene el menor número de nodos..
No es prudente reinventar la rueda cuando se trata de algoritmos bien definidos, por lo que utilizaríamos las soluciones existentes para nuestros propósitos de búsqueda de ruta. Para usar Phaser, necesitamos una solución de JavaScript, y la que he elegido es EasyStarJS. Inicializamos el motor de búsqueda de caminos como abajo.
easystar = new EasyStar.js (); easystar.setGrid (levelData); easystar.setAcceptableTiles ([0]); easystar.enableDiagonals (); // queremos que la ruta tenga diagonales easystar.disableCornerCutting (); // no hay una ruta diagonal cuando se camina en las esquinas de la pared
Como el nuestro levelData
Sólo tiene 0
y 1
, Podemos pasarlo directamente como la matriz de nodos. Establecemos el valor de 0
como el nodo transitable. Habilitamos la capacidad de caminar en diagonal, pero deshabilitamos esto al caminar cerca de las esquinas de las baldosas no transitables.
Esto se debe a que, si está habilitado, el héroe puede cortar la ficha no transitable mientras realiza una caminata diagonal. En tal caso, nuestra detección de colisión no permitirá que el héroe pase. Además, tenga en cuenta que en el ejemplo he eliminado por completo la detección de colisiones, ya que ya no es necesario para un ejemplo de paseo basado en AI.
Detectaremos el toque en cualquier ficha libre dentro del nivel y calcularemos la ruta usando el findPath
función. El método de devolución de llamada plotAndMove
recibe la matriz de nodos de la ruta resultante. Marcamos el minimapa
con el camino recién encontrado.
game.input.activePointer.leftButton.onUp.add (findPath) La función findPath () if (isFindingPath || isWalking) devuelve; var pos = game.input.activePointer.position; var isoPt = nuevo Phaser.Point (pos.x-borderOffset.x, pos.y-borderOffset.y); tapPos = isometricToCartesian (isoPt); tapPos.x- = tileWidth / 2; // ajuste para encontrar el mosaico correcto para el error debido al redondeo de tapPos.y + = tileWidth / 2; tapPos = getTileCoordinates (tapPos, tileWidth); if (tapPos.x> -1 && tapPos.y> -1 && tapPos.x<7&&tapPos.y<7)//tapped within grid if(levelData[tapPos.y][tapPos.x]!=1)//not wall tile isFindingPath=true; //let the algorithm do the magic easystar.findPath(heroMapTile.x, heroMapTile.y, tapPos.x, tapPos.y, plotAndMove); easystar.calculate(); function plotAndMove(newPath) destination=heroMapTile; path=newPath; isFindingPath=false; repaintMinimap(); if (path === null) console.log("No Path was found."); else path.push(tapPos); path.reverse(); path.pop(); for (var i = 0; i < path.length; i++) var tmpSpr=minimap.getByName("tile"+path[i].y+"_"+path[i].x); tmpSpr.tint=0x0000ff; //console.log("p "+path[i].x+":"+path[i].y);
Una vez que tenemos la ruta como una matriz de nodos, debemos hacer que el carácter la siga.
Digamos que queremos hacer que el personaje camine hacia una casilla en la que hacemos clic. Primero debemos buscar una ruta entre el nodo que ocupa el personaje actualmente y el nodo donde hicimos clic. Si se encuentra una ruta exitosa, entonces necesitamos mover el carácter al primer nodo en la matriz de nodos configurándolo como el destino. Una vez que llegamos al nodo de destino, verificamos si hay más nodos en la matriz de nodos y, si es así, configuramos el siguiente nodo como destino, y así sucesivamente hasta que lleguemos al nodo final.
También cambiaremos la dirección del jugador según el nodo actual y el nuevo nodo de destino cada vez que alcancemos un nodo. Entre nodos, solo caminamos en la dirección requerida hasta que alcancemos el nodo de destino. Esta es una IA muy simple, y en el ejemplo se hace en el método aiWalk
se muestra parcialmente abajo.
función aiWalk () if (path.length == 0) // path ha finalizado if (heroMapTile.x == destination.x && heroMapTile.y == destination.y) dX = 0; dY = 0; isWalking = false; regreso; isWalking = true; if (heroMapTile.x == destination.x && heroMapTile.y == destination.y) // alcanzó el destino actual, establecer nuevo, cambiar de dirección // espere hasta que estemos unos pasos en el mosaico antes de girar los pasosTabla ++; sidestination.x) dX = -1; else dX = 0; si (heroMapTile.y destination.y) dY = -1; else dY = 0; if (heroMapTile.x == destination.x) dX = 0; else if (heroMapTile.y == destination.y) dY = 0; //…
Nosotros hacer es necesario filtrar los puntos de clic válidos al determinar si hemos hecho clic dentro del área transitable, en lugar de un mosaico de pared u otro mosaico no transitable.
Otro punto interesante para codificar la IA: no queremos que el personaje se gire para enfrentar la siguiente casilla en la matriz de nodos tan pronto como haya llegado a la actual, ya que un giro tan inmediato da como resultado que nuestro personaje camine por los bordes de azulejos En su lugar, deberíamos esperar hasta que el personaje esté unos pocos pasos dentro de la casilla antes de buscar el siguiente destino. También es mejor colocar manualmente al héroe en el centro de la ficha actual justo antes de que giremos, para que todo se sienta perfecto..
Echa un vistazo a la demostración de trabajo a continuación:
Cuando el área de nivel es mucho más grande que el área de pantalla disponible, tendremos que hacerlo voluta.
El área visible de la pantalla se puede considerar como un rectángulo más pequeño dentro del rectángulo más grande del área de nivel completo. El desplazamiento es, esencialmente, simplemente mover el rectángulo interior dentro del más grande. Por lo general, cuando ocurre este desplazamiento, la posición del héroe sigue siendo la misma con respecto al rectángulo de la pantalla, comúnmente en el centro de la pantalla. Curiosamente, todo lo que necesitamos para implementar el desplazamiento es rastrear el punto de la esquina del rectángulo interior.
Este punto de esquina, que representamos en las coordenadas cartesianas, caerá dentro de un mosaico en los datos de nivel. Para el desplazamiento, incrementamos las posiciones x e y del punto de esquina en coordenadas cartesianas. Ahora podemos convertir este punto en coordenadas isométricas y usarlo para dibujar la pantalla..
Los valores recién convertidos, en el espacio isométrico, también deben ser la esquina de nuestra pantalla, lo que significa que son los nuevos (0, 0)
. Entonces, al analizar y dibujar los datos de nivel, restamos este valor de la posición isométrica de cada mosaico y podemos determinar si la nueva posición del mosaico cae dentro de la pantalla..
Alternativamente, podemos decidir que vamos a dibujar sólo un X x y Rejilla isométrica de azulejos en la pantalla para que el bucle de dibujo sea eficiente para niveles más grandes.
Podemos expresar esto en pasos como tal:
var cornerMapPos = nuevo Phaser.Point (0,0); var cornerMapTile = nuevo Phaser.Point (0,0); var visibleTiles = nuevo Phaser.Point (6,6); // ... actualización de la función () // ... if (isWalkable ()) heroMapPos.x + = heroSpeed * dX; heroMapPos.y + = heroSpeed * dY; // mueve la esquina en dirección opuesta cornerMapPos.x - = heroSpeed * dX; cornerMapPos.y - = heroSpeed * dY; cornerMapTile = getTileCoordinates (cornerMapPos, tileWidth); // obtener el nuevo mapa de mapas de héroe heroMapTile = getTileCoordinates (heroMapPos, tileWidth); // depthsort & draw new scene renderScene (); function renderScene () gameScene.clear (); // borra el cuadro anterior y luego dibuja nuevamente var tileType = 0; // Limitemos los bucles dentro del área visible var startTileX = Math.max (0,0-cornerMapTile.x); var startTileY = Math.max (0,0-cornerMapTile.y); var endTileX = Math.min (levelData [0] .length, startTileX + visibleTiles.x); var endTileY = Math.min (levelData.length, startTileY + visibleTiles.y); startTileX = Math.max (0, endTileX-visibleTiles.x); startTileY = Math.max (0, endTileY-visibleTiles.y); // comprueba la condición del borde para (var i = startTileY; i < endTileY; i++) for (var j = startTileX; j < endTileX; j++) tileType=levelData[i][j]; drawTileIso(tileType,i,j); if(i==heroMapTile.y&&j==heroMapTile.x) drawHeroIso(); function drawHeroIso() var isoPt= new Phaser.Point();//It is not advisable to create points in update loop var heroCornerPt=new Phaser.Point(heroMapPos.x-hero2DVolume.x/2+cornerMapPos.x,heroMapPos.y-hero2DVolume.y/2+cornerMapPos.y); isoPt=cartesianToIsometric(heroCornerPt);//find new isometric position for hero from 2D map position gameScene.renderXY(sorcererShadow,isoPt.x+borderOffset.x+shadowOffset.x, isoPt.y+borderOffset.y+shadowOffset.y, false);//draw shadow to render texture gameScene.renderXY(sorcerer,isoPt.x+borderOffset.x+heroWidth, isoPt.y+borderOffset.y-heroHeight, false);//draw hero to render texture function drawTileIso(tileType,i,j)//place isometric level tiles var isoPt= new Phaser.Point();//It is not advisable to create point in update loop var cartPt=new Phaser.Point();//This is here for better code readability. cartPt.x=j*tileWidth+cornerMapPos.x; cartPt.y=i*tileWidth+cornerMapPos.y; isoPt=cartesianToIsometric(cartPt); //we could further optimise by not drawing if tile is outside screen. if(tileType==1) gameScene.renderXY(wallSprite, isoPt.x+borderOffset.x, isoPt.y+borderOffset.y-wallHeight, false); else gameScene.renderXY(floorSprite, isoPt.x+borderOffset.x, isoPt.y+borderOffset.y, false);
Tenga en cuenta que el punto de esquina se incrementa en el opuesto dirección a la posición del héroe se actualiza mientras se mueve. Esto asegura que el héroe se quede donde está con respecto a la pantalla. Echa un vistazo a este ejemplo (usa las flechas para desplazarte, toca para aumentar la cuadrícula visible).
Un par de notas:
Esta serie está especialmente dirigida a los principiantes que intentan explorar mundos de juegos isométricos. Muchos de los conceptos explicados tienen enfoques alternativos que son un poco más complicados, y he elegido deliberadamente los más fáciles.
Es posible que no cumplan con la mayoría de los escenarios que puede encontrar, pero el conocimiento adquirido se puede utilizar para desarrollar estos conceptos y crear soluciones más complicadas. Por ejemplo, la simple clasificación en profundidad implementada se interrumpirá cuando tengamos niveles de varios pisos y mosaicos de plataformas que se mueven de una historia a otra.
Pero eso es un tutorial para otro momento..