MokaByte 81 - Gennaio 2004 
Introduzione allo sviluppo di
un videogame per la piattaforma J2ME
di
Pierluigi Grassi
Al momento in cui scriviamo esistono due versioni delle librerie MIDP: la versione 1.0 e la 2. L'ultima release contiene maggiori funzionalità espressamente dedicate al gaming (chiaramente indicate dal nome attribuito ad alcune classi, GameCanvas, Sprite ecc...) ed supportata da un numero crescente di dispositivi. La versione 1.0 è più "povera" ma generalmente disponibile e sarà quella che prenderemo come riferimento.

Lo strumento principale per la realizzazione di un videogame contenuto nelle librerie MIDP 1.0 è l'oggetto javax.microedition.lcdui.Canvas (in seguito Canvas).
Un Canvas incorpora l'intercettazione degli eventi da tastiera attraverso i metodi keyPressed(int) e keyReleased(int) ed un rendering passivo nel metodo paint(javax.microedition.lcdui.Graphics).

 

Intercettazione degli eventi da tastiera
Il parametro dei metodi keyPressed e keyReleased corrisponde al codice del tasto premuto e la ricostruzione dell'evento avviene con il "classico" confronto tra codice e set di costanti statiche presenti nella stessa classe Canvas.

Ai fini delle programmazione di videogame esiste un interessante "astrazione" rappresentata dalle costanti DOWN, UP, LEFT, RIGHT, FIRE, GAME_A, GAME_B, GAME_C, GAME_D (in seguito game-code) e dai metodi getGameAction(int keycode) e getKeyCode(int gameaction). Per sommi capi, il funzionamento è il seguente: la piattaforma di esecuzione è libera di definire un'associazione tra tasti e funzioni "in game" in modo tale che ad un valore game-code corrispondano uno o più pulsanti del dispositivo. Le specifiche dell'implementazione del metodo getGameAction(int) garantiscono che il tasto associato non muti durante l'esecuzione del gioco-applicazione. L'assenza di un tasto associato ad un codice game-action è verificabile attraverso il metodo getKeyCode(int).

boolean game_left, game_right, game_up, game_down, game_fire;

public void keyPressed(int keyCode) {
  int gameAction = getGameAction(keyCode);
  if(gameAction == Canvas.UP) {
    game_up = true;
  }
  else if(gameAction == Canvas.DOWN) {
    game_down = true;
  }
  else if(gameAction == Canvas.LEFT) {
  }
  else if(gameAction == Canvas.RIGHT) {
    game_right = true;
  }
  else if(gameAction == Canvas.FIRE) {
  game_fire = true;
  }
}

Si noti che il codice precedente si affida all'esistenza di almeno un codice-tasto per ogni azione di gioco.

La fase di rendering del motore di gioco è realizzata attraverso il metodo paint(Graphics). Come è tipico di Java, le librerie agiscono indipendentemente dall'hardware ma permettono di recuperare alcune informazioni in base a cui scegliere che tipo di funzioni usare nell'applicazione. In particolare, attraverso il metodo Canvas.isDoubleBuffered() è possibile stabilire se l'ambiente di esecuzione disponga o meno di un buffer multiplo per effettuare le operazioni di visualizzazione. Com'è noto, il double-buffering consiste nella creazione di un buffer in memoria sul quale vengono effettuate le operazioni di disegno terminate le quali l'intero buffer viene copiato sul contesto grafico corrente.

public class GameCore extends Canvas {
  private boolean doubleBuffered;
  private Image backBuffer;

  public GameCore() {
    doubleBuffered = isDoubleBuffered();

    if(doubleBuffered == false) {
      backBuffer = Image.createImage(getWidth(), getHeight());
    }
  }

  public void paint(Graphics screen) {
    Graphics g = doubleBuffered ? Screen : backBuffer.getGraphics();

    //operazioni di disegno

    if(doubleBuffered == false) screen.drawImage(backBuffer, 0, 0, Graphics.TOP |       Graphics.LEFT);
    }
}

Nel caso in cui sia disponibile la "doppia bufferizzazione" l'oggetto Graphics in argomento è per definizione il back-buffer il che rende per lo più superfluo l'uso di un terzo buffer come intermediario del rendering.

 

Sincronizzazione motore-rendering
Il motore di gioco di un videogame è costituito da un ciclo all'interno del quale vengono eseguiti una serie di aggiornamenti dello stato del sistema. L'ultima fase del ciclo è costituita dalla presentazione all'utente dello stato del sistema attraverso l'invio all'hardware di una serie di istruzioni per il rendering. Abbiamo visto come il rendering venga effettuato nel metodo paint(Graphics) dell'oggetto Canvas, ciò che manca è il collegamento tra il ciclo di gioco e le operazioni di disegno. La questione principale è relativa alla sincronizzazione tra i cicli del motore di gioco e l'aggiornamento grafico: in generale la vostra applicazione potrebbe non essere l'unica ad effettuare una richiesta di aggiornamento del contesto grafico, e anche se lo fosse dovrebbe tener conto della possibilità che due chiamate successive per l'aggiornamento si sovrappongano.

La classe javax.microedition.lcdui.Display viene incontro alle esigenze di sincronia attraverso il metodo callSerially(Runnable) il cui effetto è quello di accodare all'ordine di esecuzione degli eventi AWT le istruzioni contenute nel metodo run() dell'oggetto che implementi l'interfaccia java.lang.Runnable passato in argomento. E' facile intuire come il comportamento del metodo callSerially(Runnable) possa essere usato per generare un ciclo di istruzioni sincronizzate con l'aggiornamento grafico:


import javax.microedition.midlet.*;
import javax.microedition.lcdui.*;

public class GameCore2 extends Canvas implements Runnable {
  private Display display;
  private Image backBuffer;
  private boolean doubleBuffered;
  private int screenWidth;
  private int screenHeight;
  public static final int ANCHOR = Graphics.TOP | Graphics.LEFT;
  public boolean gameLoop = false;


  /**
  * Il costruttore richiede come parametro l'oggetto Display
  * associato alla MIDlet
  */
  public GameCore2(Display display) {
    this.display = display;
    screenWidth = getWidth();
    screenHeight = getHeight();
    doubleBuffered = isDoubleBuffered();
    
    
if(doubleBuffered == false) backBuffer = Image.createImage(screenWidth,       screenHeight);
    }

    /**
    * Attiva il campo di controllo per il ciclo principale ed
    * effettua la prima chiamata al metodo run()
    */
    public void start() {
      gameLoop = true;
      run();
    }

    /**
    * Termina le chiamate successive nel ciclo del motore di gioco
    */
    public void stop() {
      gameLoop = false;
    }

    /**
    * Ciclo del motore di gioco
    */
    public void run() {
      //operazioni del motore di gioco
      //aggiornamento grafico
      repaint();

      if(gameLoop) display.callSerially(this);
    }

    /**
    * Rendering
    */
    public void paint(Graphics screen) {
      Graphics g = doubleBuffered ? screen : backBuffer.getGraphics();

      //pulizia del buffer
      g.setColor(0x000000);
      g.fillRect(0, 0, screenWidth, screenHeight);

      //operazioni di rendering
      //Copia del back-buffer se necessario
      if(doubleBuffered == false) screen.drawImage(backBuffer, 0, 0, ANCHOR);
    }

    /**
    * Termina il ciclo alla pressione di un tasto
    */
    public void keyPressed(int keyCode) {
      stop();
    }

Il metodo run è paragonabile ad un metodo ricorsivo ma la successione delle chiamate è regolata dalla coda AWT in modo tale da consumare l'esecuzione precedente prima che sia creata la successiva (cosa che impedisce di fatto problemi di sovraccaricamento dello stack).

 

Operazioni periodiche
Nel codice su riportato il motore di gioco esegue il ciclo principale alla massima velocità consentita dalla sincronizzazione con il rendering. Per le operazioni periodiche occorre inserire una serie di istruzioni per il calcolo del tempo trascorso tra diversi passaggi nel ciclo, il che si ottiene facilmente attraverso l'uso del metodo java.lang.System.currentTimeMillis():

private long time0 = 0L;
private long time1 = 0L;
private long DTIME_1 = 50L; //periodo in millisecondi

  /**
  * Attiva il campo di controllo per il ciclo principale ed
  * effettua la prima chiamata   al metodo run()
  */
  public void start() {
    gameLoop = true;
    
time0 = System.currentTimeMillis();
    
run();
  }

  /**
  * Ciclo del motore di gioco
  */
  public void run() {
    //tempo in ms al momento del passaggio nel ciclo
    time1 = System.currentTimeMillis();

    //operazioni di aggiornamento della logica di gioco ecc...

    if(time1 - time0 >= DTIME_1) {
      //operazioni con periodo DTIME_1;
      time0 = time1;
    }

    //aggiornamento grafico
    repaint();

    if(gameLoop) display.callSerially(this);
  }

Potento contare su una granularità fine del timer di sistema, è anche possibile usare direttamente un differenziale tra passaggi consecutivi:

private long time0 = 0L;
private long time1 = 0L;
private long dTime = 0L;

private long elapsedTime = 0L;
private int DTIME_1 = 50; //periodo in millisecondi

  /**
  * Attiva il campo di controllo per il ciclo principale ed
  * effettua la prima chiamata al metodo run()
  */
  public void start() {
    gameLoop = true;
 
    time0 = System.currentTimeMillis();

     run();
  }

  /**
  * Ciclo del motore di gioco
  */
  public void run() {
    //calcola il tempo trascorso tra due passaggi successivi nel ciclo.
    time1 = System.currentTimeMillis();
    dTime = time1 - time0;
    time0 = time1;

    elapsedTime += time0;

    // operazioni di aggiornamento della logica di gioco ecc...

    if(elapsedTime >= DTIME_1) {
      int maxLoops = 1 + (int) elapsedTime / DTIME_1;

    // esecuzione con recupero
    for(int i = 0; i < maxLoops; i++) {
      elapsedTime -= DTIME_1;
      //operazioni con periodo DTIME_1;
    }

}

  //aggiornamento grafico
  repaint();

  if(gameLoop) display.callSerially(this);
}

 

Animazione
L'implementazione di un'animazione, un'immagine che cambia contenuto al variare del tempo, è realizzabile in molti modi. Di seguito se ne propone uno generico dotato di una certa flessibilità. Gli strumenti per il caricamento e la gestione delle immagini sono contenuti nella classe javax.microedition.lcdui.Image (in seguito Image); le immagini possono essere "mutable o immutable" a seconda che si possa o meno interagire con i dati che la rappresentano. Le immagini caricate da un file sono "immutable", per trattarne il contenuto è necessario copiarle su un'immagine "mutable" attraverso l'oggetto Graphics associato al buffer di destinazione. Abbiamo già visto un esempio di immagine "mutable" (il back-buffer usato per la doppia bufferizzazione). Per caricare un'immagine è sufficiente usare il metodo statico "createImage(String)" della classe Image:

Image immagine;
try {
  immagine = Image.createImage("/frame.png");
} catch (java.io.IOException e) {
  //gestione eccezione di IO
}

Nell'esempio su riportato è creata un'immagine i cui dati sono contenuti nel file "frame.png" all'interno dell'archivio jar dell'applicazione. Per la nostra animazione creremo prima di tutto un oggetto "AniFrame" che rappresenti un'immagine dotata di un tempo di persistenza, quindi passeremo a definire l'animazione come una sequenza di oggetti AniFrame dotata di un metodo che consenta di cambiare il frame visualizzato tenendo conto dell'ordine e del periodo di persistenza di ciascun frame. Il modello degli oggetti AniFrame è piuttosto breve.

import javax.microedition.lcdui.Image;

/**
*Un oggetto AniFrame è un semplice contenitore per un Image e un int.
*/
public class AniFrame {
  public int tick;
  public Image image;

  public AniFrame(Image image, int tick) {
    this.image = image;
    this.tick = tick;
  }
}

Ed ecco uno dei modelli possibili per gli oggetti Animation.

import javax.microedition.lcdui.Image;

public class Animation {
  private long animationTime = 0L;
  private AniFrame[] frames;
  private AniFrame currentFrame;
  private int currentIndex;

  public Animation(AniFrame[] frames) {
    this.frames = frames;
    reset();
  }

  /**
  * E' possibile che l'applicazione che userà gli oggetti
  * Animation debba azzerare gli stati dell'oggetto.
  */
  public void reset() {
    currentIndex = 0;
    currentFrame = frames[0];
    animationTime = 0L;
  }

  /**
  * Il parametro del metodo rappresenta un intervallo di tempo.
  */
  public void animate(long dTime) {
    // aggiorniamo il tempo trascorso dall'ultima animazione.
    animationTime += dTime;

    // se il tempo trascorso dall'ultimo aggiornamento dell'immagine
    // è maggiore del periodo di persistenza
    //del frame corrente...
    if(animationTime >= currentFrame.tick) {
      animationTime -= currentFrame.tick;
      currentIndex++;

    // controlliamo se il frame precedente fosse l'ultimo della lista:
    // in questo caso il frame corrente
    // torna ad essere il primo della lista (in breve, l'animazione è ciclica).
    if(currentIndex >= frames.length)
      currentIndex = 0;

      //re-indirizziamo il riferimento "currentFrame"
      currentFrame = frames[currentIndex];
    }
  }

  /**
  * Il metodo consente l'accesso "diretto" all'immagine del frame corrente.
  */
  public Image getImage() {
    return currentFrame.image;
  }

}

La classe Animation ha tre metodi pubblici, reset(), animate(long) e getImage(). Il suo uso ruota per lo più attorno agli ultimi due: nel ciclo del motore di gioco una fase potrebbe essere dedicata ad eseguire l'aggiornamento delle animazioni invocando per ciascuna di esse il metodo animate(long) passando come parametro il valore della differenza di tempo tra due passaggi successivi nel ciclo. Nella fase di rendering si copierà poi l'immagine ottenuta con getImage() sul buffer.

 

Riferimenti
Daniel Sánchez - Crespo Dalmau, "Core Techniques and Algorithms in Game Programming", New Riders Publishing, Indianapolis, 2004.
David Brackeen, "Developing Games in Java", New Riders Publishing, Indianapolis, 2004
Michael Kroll - Stefan Haustein, "Java 2 Micro Edition Application Developement", Sams, Indianapolis, 2002.

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