In questa parte finale della serie di tutorial, costruiremo il primo tutorial e apprenderemo sull'implementazione di pickup, trigger, cambio di livello, ricerca del percorso, percorso seguito, scorrimento a livello, altezza isometrica e proiettili isometrici.
I pickup sono oggetti che possono essere raccolti all'interno del livello, normalmente semplicemente camminandoci sopra, ad esempio monete, gemme, denaro, munizioni, ecc..
I dati di ritiro possono essere inseriti direttamente nei nostri dati di livello come di seguito:
[[1,1,1,1,1,1], [1,0,0,0,0,1,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]]
In questo livello di dati, usiamo 8
per indicare un pickup su una tessera erba (1
e 0
rappresentano rispettivamente muri e tessere pedonabili, come in precedenza). Questa potrebbe essere una singola immagine di tessera con una tessera di erba sovrapposta all'immagine di ripresa. Seguendo questa logica, avremo bisogno di due stati di piastrella diversi per ogni tessera che ha un pickup, cioè uno con pickup e uno senza essere mostrato dopo che il pickup viene raccolto.
La tipica arte isometrica avrà più tessere calpestabili, supponiamo di avere 30. L'approccio sopra indica che se avremo N pickup, avremo bisogno di N x 30 tessere in aggiunta alle 30 tessere originali, poiché ogni tessera dovrà avere una versione con pickup e uno senza. Questo non è molto efficiente; invece, dovremmo provare a creare dinamicamente queste combinazioni.
Per risolvere questo, potremmo usare lo stesso metodo usato per piazzare l'eroe nel primo tutorial. Ogni volta che incontriamo una tessera pickup, piazzeremo prima una tessera erba e poi posizioneremo il pickup in cima alla tessera erba. In questo modo, abbiamo solo bisogno di tessere pickup N oltre a 30 tessere percorribili, ma avremmo bisogno di valori numerici per rappresentare ciascuna combinazione nei dati di livello. Per risolvere la necessità di valori di rappresentazione N x 30, possiamo tenere un separato pickupArray
per memorizzare esclusivamente i dati di ritiro oltre a levelData
. Il livello completato con il ritiro è mostrato di seguito:
Per il nostro esempio, sto mantenendo le cose semplici e non usando un array addizionale per i pickup.
Rilevare i pickup avviene allo stesso modo del rilevamento delle piastrelle di collisione, ma dopo spostando il personaggio.
if (onPickupTile ()) pickupItem (); function onPickupTile () // controlla se c'è un pickup sul ritorno dell'eroe (levelData [heroMapTile.y] [heroMapTile.x] == 8);
Nella funzione onPickupTile ()
, controlliamo se il levelData
valore dell'array al heroMapTile
coordinate è una tessera pickup o meno. Il numero nel levelData
la matrice in corrispondenza di tale coordinata denota il tipo di ripresa. Controlliamo le collisioni prima di spostare il personaggio, ma è necessario controllare i pickup in seguito, perché in caso di collisione il personaggio non dovrebbe occupare il posto se è già occupato dal riquadro di collisione, ma in caso di pickup il personaggio è libero di muoversi sopra.
Un'altra cosa da notare è che i dati di collisione di solito non cambiano mai, ma i dati di ritiro cambiano ogni volta che prendiamo un oggetto. (Questo di solito comporta solo la modifica del valore in levelData
matrice da, per esempio, 8
a 0
.)
Questo porta a un problema: cosa succede quando è necessario riavviare il livello e quindi ripristinare tutti i pickup nelle loro posizioni originali? Non abbiamo le informazioni per farlo, come il levelData
la matrice è stata cambiata quando il giocatore ha raccolto gli oggetti. La soluzione è utilizzare un array duplicato per il livello mentre è in gioco e mantenere l'originale levelData
matrice intatta. Ad esempio, usiamo levelData
e levelDataLive []
, clonare quest'ultimo dal primo all'inizio del livello, e solo cambiare levelDataLive []
durante il gioco.
Per esempio, sto generando un pickup casuale su una tessera di erba libera dopo ogni ripresa e incrementando il pickupCount
. Il pickupItem
la funzione è simile a questa.
function pickupItem () pickupCount ++; levelData [heroMapTile.y] [heroMapTile.x] = 0; // spawn next pickup spawnNewPickup ();
Dovresti notare che controlliamo i pickup ogni volta che il personaggio si trova su quella tessera. Questo può accadere più volte in un secondo (controlliamo solo quando l'utente si muove, ma possiamo andare in tondo all'interno di una tessera), ma la logica sopra non fallirà; da quando abbiamo impostato il levelData
dati di matrice a 0
la prima volta che rileviamo un ritiro, tutto il successivo onPickupTile ()
gli assegni torneranno falso
per quella tessera. Guarda l'esempio interattivo qui sotto:
Come suggerisce il nome, le tessere trigger fanno sì che qualcosa accada quando il giocatore le calpesta o preme un tasto quando su di esse. Potrebbero teletrasportare il giocatore in una posizione diversa, aprire un cancello o generare un nemico, per fare alcuni esempi. In un certo senso, i pickup sono solo una forma speciale di tessere trigger: quando il giocatore sale su una tessera contenente una moneta, la moneta scompare e il contatore delle monete aumenta.
Diamo un'occhiata a come possiamo implementare una porta che porta il giocatore a un livello diverso. La tessera accanto alla porta sarà una tessera trigger; quando il giocatore preme il X chiave, passeranno al livello successivo.
Per cambiare livello, tutto ciò che dobbiamo fare è scambiare la corrente levelData
allineare con quello del nuovo livello e impostare il nuovo heroMapTile
posizione e direzione per il personaggio dell'eroe. Supponiamo che ci siano due livelli con porte per consentire il passaggio tra di loro. Dato che la tessera terreno accanto alla porta sarà la tessera grilletto in entrambi i livelli, possiamo usarla come nuova posizione per il personaggio quando appaiono nel livello.
La logica di implementazione qui è la stessa dei pickup, e ancora usiamo il levelData
array per memorizzare i valori di trigger. Per il nostro esempio, 2
denota un riquadro di porta e il valore accanto è il grilletto. ho usato 101
e 102
con la convenzione di base che ogni tessera con un valore maggiore di 100 è una tessera trigger e il valore meno 100 può essere il livello a cui conduce:
var level1Data = [[1,1,1,1,1,1], [1,1,0,0,0,1,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]];
Il codice per il controllo di un evento trigger è mostrato di seguito:
var xKey = game.input.keyboard.addKey (Phaser.Keyboard.X); xKey.onUp.add (triggerListener); // aggiungi un listener di segnali per la funzione di evento up triggerListener () var trigger = levelData [heroMapTile.y] [heroMapTile.x]; if (trigger> 100) // trigger trigger tile valido- = 100; if (trigger == 1) // passa al livello 1 levelData = level1Data; else // passa al livello 2 levelData = level2Data; per (var i = 0; i < levelData.length; i++) for (var j = 0; j < levelData[0].length; j++) trigger=levelData[i][j]; if(trigger>100) // trova la nuova piastrella del grilletto e piazza l'eroe lì heroMapTile.y = j; heroMapTile.x = i; heroMapPos = new Phaser.Point (heroMapTile.y * tileWidth, heroMapTile.x * tileWidth); heroMapPos.x + = (tileWidth / 2); heroMapPos.y + = (tileWidth / 2);
La funzione triggerListener ()
controlla se il valore dell'array di dati del trigger sulla coordinata data è maggiore di 100. In tal caso, troviamo il livello a cui dobbiamo passare sottraendo 100 dal valore della piastrella. La funzione trova la piastrella trigger nel nuovo levelData
, quale sarà la posizione di spawn per il nostro eroe. Ho fatto in modo che il trigger si attivi quando X è rilasciato; se ascoltiamo semplicemente che il tasto viene premuto, finiamo in un ciclo in cui passiamo da un livello all'altro finché il tasto viene tenuto premuto, poiché il personaggio si trova sempre nel nuovo livello in cima a una tessera trigger.
Ecco una demo funzionante. Prova a raccogliere oggetti camminandoci sopra e scambiando livelli stando vicino alle porte e colpendo X.
UN proiettile è qualcosa che si muove in una particolare direzione con una particolare velocità, come un proiettile, un incantesimo, una palla, ecc. Tutto ciò che riguarda il proiettile è lo stesso del personaggio dell'eroe, a parte l'altezza: piuttosto che rotolare sul terreno, i proiettili spesso galleggiano sopra di esso ad una certa altezza. Una pallottola viaggerà sopra il livello della vita del personaggio, e anche una palla potrebbe aver bisogno di rimbalzare.
Una cosa interessante da notare è che l'altezza isometrica è uguale all'altezza in una vista laterale 2D, anche se di valore inferiore. Non ci sono conversioni complicate coinvolte. Se una palla è 10 pixel sopra il suolo in coordinate cartesiane, potrebbe essere 10 o 6 pixel sopra il terreno in coordinate isometriche. (Nel nostro caso, l'asse rilevante è l'asse y).
Proviamo a implementare una palla che rimbalza nelle nostre praterie murate. Come tocco di realismo, aggiungeremo un'ombra per la palla. Tutto ciò che dobbiamo fare è aggiungere il valore dell'altezza di rimbalzo al valore Y isometrico della nostra palla. Il valore di altezza del salto cambierà da cornice a cornice a seconda della gravità, e una volta che la palla colpisce il terreno, invertiremo la velocità attuale lungo l'asse y.
Prima di affrontare il rimbalzo in un sistema isometrico, vedremo come possiamo implementarlo in un sistema cartesiano 2D. Rappresentiamo il potere di salto della palla con una variabile zValue
. Immagina che, per cominciare, la palla abbia una potenza di salto di 100, quindi zValore = 100
.
Useremo altre due variabili: incrementValue
, che inizia a 0
, e gravità
, che ha un valore di -1
. Ogni fotogramma, sottraiamo incrementValue
a partire dal zValue
, e sottrarre gravità
a partire dal incrementValue
per creare un effetto smorzante. quando zValue
raggiunge 0
, significa che la palla ha raggiunto il suolo; a questo punto, capovolgiamo il segno di incrementValue
moltiplicandolo per -1
, trasformandolo in un numero positivo. Ciò significa che la palla si muoverà verso l'alto dal fotogramma successivo, saltando così.
Ecco come appare nel codice:
if (game.input.keyboard.isDown (Phaser.Keyboard.X)) zValue = 100; incrementValue- = gravità; zValue- = incrementValue; if (zValue<=0) zValue=0; incrementValue*=-1;
Il codice rimane lo stesso anche per la vista isometrica, con la leggera differenza che è possibile utilizzare un valore inferiore per zValue
iniziare con. Vedi sotto come zValue
viene aggiunto all'isometrico y
valore della palla durante il rendering.
function drawBallIso () var isoPt = new Phaser.Point (); // Non è consigliabile creare punti nel ciclo di aggiornamento var ballCornerPt = new Phaser.Point (ballMapPos.x-ball2DVolume.x / 2, ballMapPos.y-ball2DVolume .y / 2); isoPt = cartesianToIsometric (ballCornerPt); // trova una nuova posizione isometrica per l'eroe dalla posizione della mappa 2D gameScene.renderXY (ballShadowSprite, isoPt.x + borderOffset.x + shadowOffset.x, isoPt.y + borderOffset.y + shadowOffset.y, false ); // disegna l'ombra per renderizzare texture gameScene.renderXY (ballSprite, isoPt.x + borderOffset.x + ballOffset.x, isoPt.y + borderOffset.y-ballOffset.y-zValue, false); // disegna l'eroe per renderizzare struttura
Guarda l'esempio interattivo qui sotto:
Capisci che il ruolo giocato dall'ombra è molto importante e aumenta il realismo di questa illusione. Inoltre, tieni presente che ora utilizziamo le due coordinate dello schermo (xey) per rappresentare tre dimensioni in coordinate isometriche: l'asse y nelle coordinate dello schermo è anche l'asse z nelle coordinate isometriche. Questo può essere fonte di confusione!
Pathfinding e path following sono processi abbastanza complicati. Esistono vari approcci che utilizzano algoritmi diversi per trovare il percorso tra due punti, ma come il nostro levelData
è un array 2D, le cose sono più facili di quanto potrebbero essere altrimenti. Abbiamo nodi ben definiti e unici che il giocatore può occupare, e possiamo facilmente verificare se sono percorribili.
Una panoramica dettagliata degli algoritmi di path-finding è al di fuori degli scopi di questo articolo, ma cercherò di spiegare il modo più comune in cui funziona: l'algoritmo del percorso più breve, di cui gli algoritmi A * e Dijkstra sono implementazioni famose.
Miriamo a trovare nodi che collegano un nodo iniziale e un nodo finale. Dal nodo iniziale, visitiamo tutti e otto i nodi vicini e li contrassegniamo tutti come visitati; questo processo core viene ripetuto per ciascun nodo appena visitato, in modo ricorsivo.
Ogni thread tiene traccia dei nodi visitati. Saltando ai nodi vicini, i nodi che sono già stati visitati vengono saltati (la ricorsione si interrompe); altrimenti, il processo continua fino a raggiungere il nodo finale, dove termina la ricorsione e il percorso completo seguito viene restituito come una matrice di nodi. A volte il nodo finale non viene mai raggiunto, nel qual caso fallisce il path-finding. Di solito finiamo per trovare percorsi multipli tra i due nodi, nel qual caso prendiamo quello con il minor numero di nodi.
Non è saggio reinventare la ruota quando si tratta di algoritmi ben definiti, quindi dovremmo utilizzare le soluzioni esistenti per i nostri scopi di ricerca del percorso. Per utilizzare Phaser, abbiamo bisogno di una soluzione JavaScript e quella che ho scelto è EasyStarJS. Inizializziamo il motore di ricerca del percorso come di seguito.
easystar = new EasyStar.js (); easystar.setGrid (levelData); easystar.setAcceptableTiles ([0]); easystar.enableDiagonals (); // vogliamo che il percorso abbia diagonali easystar.disableCornerCutting (); // nessun percorso diagonale quando si cammina agli angoli delle pareti
Come il nostro levelData
ha solo 0
e 1
, possiamo passarlo direttamente come array di nodi. Diamo il valore di 0
come il nodo calpestabile. Abilitiamo la camminata diagonale ma disabilitiamo questo quando camminiamo vicino agli angoli delle tessere non percorribili.
Questo perché, se abilitato, l'eroe può tagliare la tessera non percorribile mentre fa una camminata diagonale. In tal caso, il nostro rilevamento delle collisioni non consentirà all'eroe di passare. Inoltre, tieni presente che nell'esempio ho rimosso completamente il rilevamento delle collisioni poiché non è più necessario per un esempio di passeggiata basato sull'IA.
Rilevereremo il tocco su qualsiasi tessera libera all'interno del livello e calcoleremo il percorso usando il findPath
funzione. Il metodo di callback plotAndMove
riceve la matrice di nodi del percorso risultante. Noi segniamo il minimappa
con il percorso appena trovato.
game.input.activePointer.leftButton.onUp.add (findPath) function findPath () if (isFindingPath || isWalking) return; var pos = game.input.activePointer.position; var isoPt = new Phaser.Point (pos.x-borderOffset.x, pos.y-borderOffset.y); tapPos = isometricToCartesian (isoPt); tapPos.x- = tileWidth / 2; // aggiustamento per trovare il riquadro corretto per errore a causa dell'arrotondamento 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 volta che abbiamo il percorso come array di nodi, dobbiamo far sì che il personaggio lo segua.
Diciamo che vogliamo far camminare il personaggio su una tessera su cui clicchiamo. Per prima cosa dobbiamo cercare un percorso tra il nodo che il personaggio occupa attualmente e il nodo su cui abbiamo fatto clic. Se viene trovato un percorso corretto, è necessario spostare il carattere sul primo nodo nell'array di nodi impostandolo come destinazione. Una volta arrivati al nodo di destinazione, controlliamo se ci sono altri nodi nell'array di nodi e, in tal caso, impostiamo il nodo successivo come destinazione e così via fino a raggiungere il nodo finale.
Cambierà anche la direzione del giocatore in base al nodo corrente e al nuovo nodo di destinazione ogni volta che raggiungiamo un nodo. Tra i nodi, camminiamo nella direzione richiesta fino a raggiungere il nodo di destinazione. Questa è un'IA molto semplice, e nell'esempio questo viene fatto nel metodo aiWalk
mostrato parzialmente sotto.
function aiWalk () if (path.length == 0) // percorso terminato if (heroMapTile.x == destination.x && heroMapTile.y == destination.y) dX = 0; dY = 0; isWalking = falso; ritorno; isWalking = true; if (heroMapTile.x == destination.x && heroMapTile.y == destination.y) // ha raggiunto la destinazione corrente, imposta nuova, cambia direzione // attendi finché non ci sono pochi passaggi nella tessera prima di passare ai passaggiTaken ++; if (stepsTakendestination.x) dX = -1; else dX = 0; if (heroMapTile.y destination.y) dY = -1; else dY = 0; if (heroMapTile.x == destination.x) dX = 0; else if (heroMapTile.y == destination.y) dY = 0; // ...
Noi fare è necessario filtrare i punti di clic validi determinando se abbiamo fatto clic all'interno dell'area percorribile, piuttosto che una piastrella o un'altra tessera non percorribile.
Un altro punto interessante per la codifica dell'IA: non vogliamo che il personaggio si gira per affrontare la prossima tessera nella schiera di nodi non appena è arrivato in quella corrente, in quanto una tale svolta immediata porta il nostro personaggio a camminare sui bordi di piastrelle. Invece, dovremmo aspettare fino a quando il personaggio è a pochi passi all'interno della tessera prima di cercare la prossima destinazione. È anche meglio posizionare manualmente l'eroe nel mezzo del riquadro corrente appena prima di girare, per far sì che tutto si senta perfetto.
Guarda la demo di lavoro qui sotto:
Quando l'area del livello è molto più grande dell'area dello schermo disponibile, avremo bisogno di farcela scorrere.
L'area dello schermo visibile può essere considerata come un rettangolo più piccolo all'interno del rettangolo più grande dell'area di livello completa. Lo scorrimento è, in sostanza, semplicemente spostando il rettangolo interno all'interno di quello più grande. Di solito, quando si verifica tale scorrimento, la posizione dell'eroe rimane la stessa rispetto al rettangolo dello schermo, comunemente al centro dello schermo. È interessante notare che tutto ciò di cui abbiamo bisogno per implementare lo scorrimento è tracciare il punto d'angolo del rettangolo interno.
Questo punto d'angolo, che rappresentiamo in coordinate cartesiane, rientra in una tessera nei dati di livello. Per lo scorrimento, incrementiamo la posizione x e y del punto d'angolo in coordinate cartesiane. Ora possiamo convertire questo punto in coordinate isometriche e usarlo per disegnare lo schermo.
I valori appena convertiti, nello spazio isometrico, devono essere anche l'angolo del nostro schermo, il che significa che sono i nuovi (0, 0)
. Quindi, mentre analizziamo e disegniamo i dati di livello, sottraiamo questo valore dalla posizione isometrica di ogni piastrella e possiamo determinare se la nuova posizione della piastrella rientra nello schermo.
In alternativa, possiamo decidere di disegnare solo un X x Y griglia di piastrelle isometriche sullo schermo per rendere efficiente il ciclo di disegno per livelli maggiori.
Possiamo esprimere questo nei passaggi in questo modo:
var cornerMapPos = new Phaser.Point (0,0); var cornerMapTile = new Phaser.Point (0,0); var visibleTiles = new Phaser.Point (6,6); // ... function update () // ... if (isWalkable ()) heroMapPos.x + = heroSpeed * dX; heroMapPos.y + = heroSpeed * dY; // sposta l'angolo nella direzione opposta cornerMapPos.x - = heroSpeed * dX; cornerMapPos.y - = heroSpeed * dY; cornerMapTile = getTileCoordinates (cornerMapPos, tileWidth); // ottiene la nuova mappa della mappa dell'eroe heroMapTile = getTileCoordinates (heroMapPos, tileWidth); // depthsort & draw new scene renderScene (); function renderScene () gameScene.clear (); // cancella il fotogramma precedente e disegna di nuovo var tileType = 0; // limitiamo i loop all'interno dell'area visibile 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); // controlla la condizione di confine per (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);
Si noti che il punto d'angolo viene incrementato nel di fronte la direzione dell'aggiornamento della posizione dell'eroe mentre si muove. Questo assicura che l'eroe rimanga dov'è rispetto allo schermo. Guarda questo esempio (usa le frecce per scorrere, tocca per aumentare la griglia visibile).
Un paio di note:
Questa serie è particolarmente indirizzata ai principianti che cercano di esplorare mondi di giochi isometrici. Molti dei concetti spiegati hanno approcci alternativi che sono un po 'più complicati e ho scelto deliberatamente i più facili.
Potrebbero non soddisfare la maggior parte degli scenari che potresti incontrare, ma le conoscenze acquisite possono essere utilizzate per sviluppare questi concetti per creare soluzioni più complicate. Ad esempio, il semplice ordinamento di profondità implementato si interromperà quando avremo livelli multipiattaforma e tessere piattaforma che passano da una storia all'altra.
Ma questo è un tutorial per un'altra volta.