MokaByte 84 - Aprile 2004 
Sviluppare un videogame per la piattaforma J2ME
IV parte: movimento in collisione e ottimizzazioni del motore di gioco
di
Pierluigi
Grassi
Punto chiave nella programmazione di un videogame, l'implementazione di un sistema di gestione delle collisioni all'interno di una tile-map può lasciare perplessi, specialmente nel caso in cui le spiegazioni fornite siano criptiche. Cerchiamo di esaminare la questione nel modo più esplicito e semplice possibile

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.

MokaByte® è un marchio registrato da MokaByte s.r.l. 
Java®, Jini® e tutti i nomi derivati sono marchi registrati da Sun Microsystems.
Tutti i diritti riservati. E' vietata la riproduzione anche parziale.
Per comunicazioni inviare una mail a info@mokabyte.it