Il
movimento in collisione
Supponiamo di avere una tile-map costituita di celle
le cui dimensioni siano TILE_WIDTH e TILE_HEIGHT. e
assegnamo al giocatore uno sprite che abbia le stesse
dimensioni di una cella della mappa. Abbiamo già
visto come definire le coordinate della cella virtualmente
occupata dal giocatore all'interno della tile-map. Data
la posizione (playerX, playerY), espressa in pixel,
la cella del giocatore ha indici
xi
= playerX / TILE_WIDTH;
yi = playerY / TILE_HEIGHT;
Dalla
posizione nella griglia possiamo ricavare gli indici
delle celle che circondano il giocatore. Nel caso in
cui il giocatore si muova in 4 direzioni, i valori sono
quelli mostrati nella figura1:
Figura 1
La
soluzione al problema collisione ruota intorno alla
domanda: cosa incontrerà il giocatore quando
muoverà il prossimo passo in una qualsiasi delle
direzioni consentite?. Le poche righe già scritte
sono la risposta. Il giocatore che si trova nella cella
(x,y) troverà di fronte a se, muovendosi verso
l'alto, ciò che la tile-map contiene nella cella
(x,y - 1). Resta da stabilire se la cella (x,y - 1)
contenga o meno un elemento che possa "collidere"
con il giocatore. Esistono diversi modi per determinare
se un elemento di una tile-map sia collidibile. Avendo
l'opportunità di usare correttamente i paradigmi
della programmazione orientata agli oggetti, definiremmo
la "collidibilità" come un attributo
di un oggetto "Cella" ed una tile-map come
una matrice di oggetti "Cella". Sarebbe logico
e sufficente a questo punto estrarre dalla mappa la
cella (x, y -1) e verificare il valore del suo attributo
"collidibile" e consentire o meno il movimento
del giocatore verso quella cella secondo tale valore.
E'
bene tuttavia iniziare, ora che il proverbiale gioco
inizia "a farsi serio", a camminare sulla
terra ferma. Benchè la programmazione Java in
ambiente J2ME possa effettivamente essere condotta seguendo
scrupolosamente l'OOP, l'effettivo "deployment"
di un'applicazione stressante, com'è un videogioco,
sui dispositivi attuali richiede che si faccia ben più
di una concessione alla programmazione meno elegante
e più brutale. Approfondiremo la questione sul
finire dell'articolo.
Scartare
l'approccio più OOP non significa tuttavia che
la soluzione diventi totalmente differente. In realtà
cambia solo la tecnica di implementazione di una teoria
inalterata. Poichè la nostra tile-map è
costituita di valori numerici, è infatti sufficiente,
di primo acchito, definire uno di tali valori come "trasparente"
alle collisioni. Definiamo trasparente il valore 0.
Data
la tile-map seguente:
Figura 2
int[][]
tileMap = {
{1, 1, 1, 1, 1, 1, 1, 1},
{1, 0, 0, 0, 0, 0, 0, 1},
{1, 0, 1, 1, 1, 1, 0, 1},
{1, 0, 0, 0, 1, 0, 0, 1},
{1, 0, 0, 0, 1, 0, 0, 1},
{1, 0, 0, 0, 0, 0, 0, 1},
{1, 0, 0, 0, 0, 0, 0, 1},
{1, 1, 1, 1, 1, 1, 1, 1}
};
la
cella di indice (x,y) risulta collidibile se:
tileMap[y][x]
> 0;
Tirando
le fila del discorso, l'agoritmo per determinare i limiti
al movimento del nostro giocatore può essere
espresso nel codice:
//posizione
del giocatore nella griglia: playerX e playerY rappresentano
//la posizione del giocatore sullo schermo, in pixel
int playerCellX = playerX / TILE_WIDTH;
int playerCellY = playerY / TILE_HEIGHT;
//playerMoveUp
è l'ideale valore booleano attraverso cui controlliamo
//la risposta dell'applicazione all'interazione utente
"muovi il giocatore
//verso l'alto"
if(playerMoveUp) {
int upperCellX = playerCellX;
int upperCellY = playerCellY - 1;
if(tileMap[upperCellY][upperCellX] == 0) {
//possiamo effettivamente muovere il giocatore
nella cella superiore.
}
//rendering....
Quello
che risulta naturale chiedersi è fino a quando
il codice qui sopra sia valido. Esso rappresenta la
soluzione al problema del movimento in collisione, nel
caso in cui il movimento avvenga per passi discreti
di lunghezza pari alla dimensione di una cella, con
posizione iniziale del giocatore pari ad un multiplo
intero di tali dimensioni. Tradotto, affinchè
quel codice funzioni il giocatore deve avere una posizione
iniziale tipo (n*TILE_WIDTH, m*TILE_HEIGHT) e il movimento
deve avvenire per passi TILE_WIDTH, TILE_HEIGHT: è
il caso tipico di molti giochi in cui il personaggio
salta da una cella all'altra.
I
limiti dell'algoritmo precedente si afferrano meglio
osservando il le figure seguenti.
Figura 3
Nel
caso qui sopra, esiste una sola cella nella posizione
immediatamente superiore a quella occupata dal giocatore.
Se si prevede che il giocatore possa muoversi per passi
pari ad una cella, siamo "a cavallo". Ma cosa
succede nel caso in cui il movimento del giocatore sia
continuo?
Figura 4
La
cella ottenuta dall'applicazione dell'algoritmo è
solo una di quelle che si trovano sopra al giocatore.
Risulta libera, il movimento è ammesso ed il
nostro sprite attraverserà gli ostacoli.
Dalla
stessa figura4, si può notare come l'algoritmo
produca un risultato corretto nel caso in cui "playerX"
sia un valore compreso tra l'angolo inferiore sinistro
e l'angolo inferiore destro della cella immediatamente
superiore. In effetti, la trasformazione dell'algoritmo
in forma "continua" deve terner conto della
possibilità che lo sprite del giocatore collida
per tutta la lunghezza dei suoi lati.
Si
osservi il seguente schema:
Figura 5
La
posizione "in pixel" del giocatore sulla mappa
(playerX, playerY) corrisponde alla posizione dell'angolo
superiore destro dell'area che lo contiene. Il limite
al movimento del giocatore "verso l'alto"
si ottiene verificando che tutte le celle comprese tra
gli indici (playerX / TILE_WIDTH, (playerY - 1) / TILE_HEIGHT)
e ((playerX + PLAYER_WIDTH) / TILE_WIDTH, (playerY -
1) / TILE_HEIGHT) siano "vuote". Generalizzando
la deduzione per i quattro lati si ottiene il panorama
completo. Prima di passare al codice, si possono spendere
due parole sul concetto di "previsione" del
movimento, che occorre effettuare al fine di generare
un movimento in collisione. In effetti, l'abbiamo accennato
sopra, ciò che interessa ai fini della gestione
del movimento in collisione non è la posizione
attuale del giocatore, ma la posizione in cui il giocatore
si troverà nel frame successivo. L'atto divinatorio
è quanto mai elementare: in effetti, è
sufficiente, all'interno del nostro motore di gioco,
applicare il movimento, verificare se vi siano collisioni
in quella posizione e, in tale evenienza, "annullare"
il movimento in quella direzione.
Ecco
l'implementazione del codice relativa alla verifica
delle collisioni, logicamente seguente l'applicazione
del movimento "previsto":
//applicazione
del movimento ai valori playerX e playerY...
//Vertici
di collisione (a, b, c, d) dell'area definita dal giocatore
int aX = playerX / TILE_HEIGHT;
int aY = playerY / TILE_WIDTH;
int
bX = (playerX + TILE_WIDTH - 1) / TILE_WIDTH;
int bY = aY;
int
cX = bX;
int cY = (playerY + TILE_HEIGHT -1) / TILE_HEIGHT;
int
dX = aX;
int dY = cY;
//Verifica
collisione: lato superiore (AB)
if(tileMap[aY][aX] > 0 || tileMap[bY][bX] > 0)
{
playerY++;
}
//Verifica collisione: lato inferiore (DC)
if(tileMap[dY][dX] > 0 || tileMap[cY][cX] > 0)
{
playerY--;
}
//Verifica
collisione: lato destro (BC)
if(tileMap[bY][bX] > 0 || tileMap[cY][cX] > 0)
{
playerX--;
}
//Verifica
collisione: lato sinistro (AD)
if(tileMap[aY][aX] > 0 || tileMap[dY][dX] > 0)
{
playerX++;
}
I
vertici del rettangolo che definisce l'area del giocatore
sono rappresentati dalle coppie di interi (aX, aY),
(bX, bY), (cX, cY), (dX, dY).
L'arte
della pratica
Poichè l'obietto degli articoli è far
"toccare con mano" ciò di cui si parla,
di seguito è proposta una versione più
compatta, per codice e prestazioni, del motore di gioco.
E' bene fornire qualche indicazione di massima sul contenuto
e sui motivi. Finora abbiamo definito le mappe come
matrici di valori interi. In un'applicazione di tipo
desktop (J2SE), è una necessità logica:
gli indice degli array sono numeri interi di tipo int.
L'uso di un intero "minore" (short, byte)
è controproducente: i pochi byte risparmiati
sulle dimensioni della matrice di "byte" anzichè
"int" sono infatti bilanciati, come "idea"
di ottimizzazione, dal casting a runtime effettuato
per convertire il tipo "byte" in un "int",
valido come indice per "estrarre" un elemento
da un array. Per una macchina in cui la memoria "papabile"
si misuri in kilobyte il discorso cambia radicalmente:
qui i byte contano eccome. La "storia" dei
videogame sugli antenati delle odierne consolle ha prodotto
i risultati più vari. Noi ci limitiamo all'uso
del tipo "byte" (il minore tra i disponibili,
dopo questo occorre passare a dividere la tile-map in
due, un livello per disegnare le celle ed un livello
per computare le collisioni, dove quest'ultimo è
rappresentato da una matrice di dati 1-0 "virtualmente
compressi" nei 32 bit di un tipo int ed estratti
con il bit-shift che, francamente, mi pare eccessivo).
Il byte consente di immagazzinare 255 tipi di celle
(valori da -128 a 127);
Benchè
non sia un grande estimatore del bit-shift, alcuni white-paper
indicano tuttora questo tipo di operatori come "più
rapidi" degli operatori comuni nelle operazioni
compatibili. Un breve riassunto per chi non fosse a
conoscenza del loro uso: tra le altre cose, è
possibile, per alcune proprietà dei numeri binari,
usare gli operatori >> e << per effettuare
moltiplicazioni e divisioni intere di un numero per
un altro, a patto che il moltiplicatore o divisore sia
una potenza di 2. Java consente l'uso del bit shift
sui numeri di tipo "int". Due esempi:
1000
/ 16
corrisponde
a
1000
>> 4
dove
4 è l'esponente della potenza di 2 che esprime
il numero "16". Analogamente,
1000
* 16
corrisponde
a
1000
<< 4
Esula
palesemente dagli scopi (e dalle personali capacità
di chi scrive) qualsiasi ulteriore approfondimento.
Detto
questo, e con l'intento di sfruttare le mirabolanti
capacità del "bit-shift", definiamo
come linea guida per le nostre mappe l'uso di celle
le cui dimensioni siano esprimibili come potenze di
due (16, 32, 64 ecc...). La leggibilità del codice
non ne risente.
Dalle
paludi della matematica binaria passiamo a quelle dell'OOP.
Accennavamo al fatto che un corretta distribuzione dei
compiti dell'applicazione tra oggetti, il più
possibile indipendenti, e la gestione delle transizioni
di stato come metodi degli oggetti (uno dei pilastri
dell'OOP), siano argomenti che non vanno a braccetto
con un videogame, almeno sulla gran parte delle piattaforme
oggi disponibili. In soldoni, questo si traduce nell'evitare,
se possibile, l'uso delle coppie di metodi "setter/getter"
e, a dire il vero, di qualsiasi metodo che non sia assolutamente
necessario, delegando quanto possibile all'interrogazione
di campi. L'ereditarietà può essere gettata
tranquillamente alle ortiche. Per farla breve, quanto
più pretenderete dal vostro motore di gioco,
tanto meno "orientamento agli oggetti" dovrà
essere usato nel suo ciclo.
Considero
il framework presentato negli articoli precedenti valido
ai fini di ogni possibile esperimento. Dovendo però
iniziare a combinare rendering, movimento, collisioni
e quanto altro può seguire in un videogame, e
volendo far girare il tutto non sul simulatore (che,
è bene ricordarlo, non arriva nemmeno vicino
al cellulare "vivo") ma su un telefonino,
possiamo considerarlo come un utile tool per il testing.
Ecco
come si presenta una versione intermedia tra chiarezza
e "prestanza" del "framework" per
il gioco:
//GameApp.java
//L'estensione di MIDlet, necessaria all'esecuzione.
import javax.microedition.midlet.*;
import javax.microedition.lcdui.*;
public
class GameApp extends MIDlet {
Display display;
GameCore gameCore;
public GameApp() {
display = Display.getDisplay(this);
gameCore = new GameCore(display);
}
public void startApp() {
display.setCurrent(gameCore);
gameCore.start();
}
public void pauseApp() {
gameCore.stop();
}
public void destroyApp(boolean unc) {
gameCore.stop();
notifyDestroyed();
}
}
Qui
cambia poco o nulla. In realtà il problema delle
prestazioni è concentrato tutto nel ciclo del
motore di gioco. Potete usare quanta OOP desiderate,
ad esempio nella schermata introduttiva o finale, a
patto di lasciare liberare il tutto "dereferenziando"
ogni oggetto non strettamente necessario (la kvm si
occuperà della "garbage collection")
nel momento in cui lanciate il ciclo del motore di gioco.
Per
il "motore" è necessario disporre di
due immagini: una per la tile-map (si è scelto
di usarne una sola per semplicità, le ragioni
dell'ottimizzazione sono tutte dedicate a lasciar spazio
alla fantasia delle immagini, qui fortunatamente potrete
e dovrete sbizzarrirvi) e una per il "personaggio"
che si muove nel piccolo labirinto:
Figura 6
Figura 7
Di
seguito il codice del motore (commentato all'eccesso).
Si noti che è suscettibile di essere spremuto
ulteriormente, ma non sarebbe stato chiaro come invece
credo (spero) sia ancora.
//GameCore.java
import javax.microedition.lcdui.*;
import javax.microedition.midlet.*;
public
class GameCore extends Canvas implements Runnable {
final
int ANCHOR = Graphics.LEFT | Graphics.TOP;
final int SCREEN_WIDTH = getWidth();
final int SCREEN_HEIGHT = getHeight();
//dimensione delle immagini per le celle
//16x16 pixel
final int TILE_WIDTH = 16;
final int TILE_HEIGHT = 16;
//i "divisori binari". Dove è
scritto "N >> X_SHIFT" va letto come
//"N / TILE_WIDTH" e "N <<
X_SHIFT" è "N * TILE_WIDTH"
//lo stesso per Y_SHIFT e TILE_HEIGTH.
final int X_SHIFT = 4; //radice quadrata
di TILE_WIDTH
final int Y_SHIFT = 4; //radice quadrata
di TILE_HEIGHT
//Il numero massimo di celle visualizzabili
lungo l'asse X
int X_TILES = 1 + SCREEN_WIDTH / TILE_WIDTH;
//Il numero massimo di celle visualizzabili
lungo l'asse Y
int Y_TILES = 1 + SCREEN_HEIGHT / TILE_HEIGHT;
//tile map e tile store.
Image[] tileStore = new Image[2];
byte[][] tileMap = {
{1, 1, 1, 1, 1, 1, 1, 1},
{1, 0, 1, 0, 0, 0, 0, 1},
{1, 0, 1, 0, 1, 1, 0, 1},
{1, 0, 1, 0, 1, 0, 0, 1},
{1, 0, 1, 0, 0, 0, 1, 1},
{1, 0, 1, 0, 1, 0, 0, 1},
{1, 0, 0, 0, 1, 0, 0, 1},
{1, 1, 1, 1, 1, 1, 1, 1}
};
//giocatore: immagine, posizione iniziale
(16, 16)
Image player = null;
int playerX = 16;
int playerY = 16;
//campi per il controllo dell'interazione
tra utente
//e movimento del giocatore
boolean playerLeft = false;
boolean playerRight = false;
boolean playerUp = false;
boolean playerDown = false;
//Campi preinizializzati per il calcolo
delle collisioni. Questo è un tipo di
//ottimizzazione di cui in genere si occupa
direttamente il compilatore.
int aX = 0;
int aY = 0;
int bX = 0;
int bY = 0;
int cX = 0;
int cY = 0;
int dX = 0;
int dY = 0;
//offscreen utils
private Image offImage = Image.createImage(SCREEN_WIDTH,
SCREEN_HEIGHT);
private Graphics offGraphics = offImage.getGraphics();
//Display, usato per il rendering attraverso
la "coda seriale"
//AWT. Abbandonando il questo sistema e
passando ad un loop più
//classico si perde la sincronizzazione
e si guadagna qualche frame.
Display display;
//controllo per il ciclo del motore di gioco
boolean gameLoop = false;
public GameCore(Display display) {
this.display = display;
//carica le immagini, dalla
directory "/res" se l'applicazione
//è lanciata dal toolkit
J2ME, altrimenti dal jar con cui è
//distribuito (senza "/res").
try {
tileStore[1] = Image.createImage("/tile0.png");
player = Image.createImage("/player0.png");
} catch(Exception e) {
e.printStackTrace();
}
//minimo tra N_TILES e la dimensione
della mappa
if(Y_TILES > tileMap.length
) {Y_TILES = tileMap.length; }
if(X_TILES > tileMap[0].length)
{X_TILES = tileMap[0].length; }
}
/*start loop */
public void start() {
gameLoop = true;
new Thread(this).start();
}
/* stop loop */
public void stop() {
gameLoop = false;
}
/* loop */
public void run() {
/* update player */
//la serie "if-else if-else"
produce un movimento
//esclusivo nelle due direzioni
"up-down" e "left-right"
//senza combinazione "up-left"
ecc...
if(playerUp) {
playerY--;
}
else if(playerDown) {
playerY++;
}
else
if(playerLeft) {
playerX--;
}
else if(playerRight) {
playerX++;
}
//Vertici di collisione (a,
b, c, d) dell'area definita dal giocatore
//corrispondono ai vertici della
figura 5
aX = playerX >> X_SHIFT;
// == playerX / TILE_WIDTH
aY = playerY >> Y_SHIFT;
// == playerY / TILE_HEIGHT
bX = (playerX + TILE_WIDTH -
1) >> X_SHIFT;
bY = aY;
cX = bX;
cY = (playerY + TILE_HEIGHT
-1) >> Y_SHIFT;
dX = aX;
dY = cY;
//Lo spostamento in collisione
genera l'effetto
//"gira intorno agli angoli".
//Verifica collisione: lato
superiore (AB)
if(tileMap[aY][aX] != 0 || tileMap[bY][bX]
!= 0) {
playerY++; //sposta
il giocatore verso il basso
}
//Verifica collisione: lato
inferiore (DC)
if(tileMap[dY][dX] != 0 || tileMap[cY][cX]
!= 0) {
playerY--; //sposta
il giocatore verso l'alto
}
//Verifica collisione: lato
destro (BC)
if(tileMap[bY][bX] != 0 || tileMap[cY][cX]
!= 0) {
playerX--; //sposta
il giocatore a sinistra
}
//Verifica collisione: lato
sinistro (AD)
if(tileMap[aY][aX] != 0 || tileMap[dY][dX]
!= 0) {
playerX++; //sposta
il giocatore a destra
}
offGraphics.setColor(0x5fc4cd);
offGraphics.fillRect(0, 0, SCREEN_WIDTH,
SCREEN_HEIGHT);
for(int yi = 0; yi < Y_TILES;
yi++) {
for(int xi = 0;
xi < X_TILES; xi++) {
int
tileID = tileMap[yi][xi];
if(tileID
> 0) {
offGraphics.drawImage(tileStore[tileID],
xi << X_SHIFT,
yi
<< Y_SHIFT, ANCHOR);
}
}
}
offGraphics.drawImage(player,
playerX, playerY, ANCHOR);
repaint();
//ripetizione
if(gameLoop) display.callSerially(this);
}
//rendering passivo
public void paint(Graphics g) {
g.drawImage(offImage, 0, 0,
ANCHOR);
}
//Gestione dell'interazione utente.
/* key press */
int gameAction = 0;
public void keyPressed(int keyCode) {
gameAction = getGameAction(keyCode);
switch(gameAction) {
case UP:
if(!playerUp) {
playerUp
= true;
}
break;
case DOWN:
if(!playerDown)
{
playerDown
= true;
}
break;
case LEFT:
if(!playerLeft)
{
playerLeft
= true;
}
break;
case RIGHT:
if(!playerRight)
{
playerRight
= true;
}
break;
}
}
public void keyReleased(int keyCode) {
gameAction = getGameAction(keyCode);
switch(gameAction) {
case UP:
playerUp
= false;
break;
case DOWN:
playerDown
= false;
break;
case LEFT:
playerLeft
= false;
break;
case RIGHT:
playerRight
= false;
break;
}
}
}
Ed
ecco uno sprite che si muove in una tile-map a schermo
singolo evitando accortamente di attraversare i muri.
Figura 8
Una
piccola nota sulle immagini. Il formato più appetibile
per un'esecuzione "allegra" è il png
a 8 bit. Molti tool consentono di salvare immagini in
tale formato. Attenzione tuttavia che alcuni, nel caso
in cui si aggiunga un livello di trasparenza, salvano
l'immagine come png a 32 bit, rgb + canale alpha, anziché
8 bit con maschera. Verificate sempre il corretto formato
delle immagini.
|