MokaByte 82 - Febbraio 2004 
Introduzione allo sviluppo di
un videogame per la piattaforma J2ME
II parte
di
Pierluigi Grassi
Nell'articolo precedente abbiamo analizzato la struttura elementare di un motore di gioco per la piattaforma J2ME ed abbiamo osservato come sia possibile fornire un'implementazione per l'animazione di immagini. Il passo successivo è la rappresentazione di un'animazione in movimento, i cosidetti Sprite e l'introduzione al controllo di base di un oggetto Sprite. Vedremo inoltre come sfruttare le classi presentate finora per la costruzione di un semplice framework che abbia quel grado di flessibilità sufficiente a renderlo una base per ulteriori sviluppi.

Sprite
Gli sprite sono immagini in movimento, di solito animate. Il movimento richiede, in termini essenziali, una posizione ed un vettore che indichi velocità e direzione dell'oggetto. La posizione dello sprite sullo schermo non richiede particolari introduzioni, si tratta di una comune coppia di valori interi che rappresentano la posizione sull'asse x e y. Qualche esercizio di fantasia potrebbe essere richiesto, al contrario, per la rappresentazione della velocità. Al di là della questione relativa alla scomposizione del vettore nei componenti x e y, troviamo la particolarità data dal mancato supporto dell'attuale piattaforma J2ME ai valori in virgola mobile. Normalmente la velocità di un oggetto nel codice di un videogame bidimensionale è rappresentata da un valore float che indica i pixel percorsi in un millisecondo, il che comporta la possibilità di determinare lo spazio percorso dall'oggetto tra un ciclo ed il successivo del motore di gioco moltiplicando direttamente la velocità per il tempo trascorso tra i due passaggi. E' ovvio che anche senza float lo spazio percorso resta comunque il prodotto del tempo per la velocità, ma occorre aggirare la mancanza dei decimali. Senza tanti giri di parole, possiamo controllare con un certo grado di precisione la velocità di un oggetto sullo schermo definendo il tempo che deve trascorrere affinchè esso si muova di un pixel.
Per l'animazione è sufficiente dotare lo sprite di uno o più oggetti Animation, attribuendo ad un metodo ad hoc la possibilità di cambiare l'animazione corrente ed immettendo il controllo sull'aggiornamento delle immagini all'interno di un singolo metodo update che si occupi anche di ridefinire la posizione dello sprite. Vediamo una possibile implementazione di quanto detto costruendo una classe Sprite predefinita per il movimento lineare nelle quattro direzioni.

//La classe Sprite
import javax.microedition.lcdui.Image;

public class Sprite {
  Animation[] animations;
  Animation currentAnimation;

  int posX, posY;
  int dx, dy;
  long dTimeX, dTimeY;
  long timeLineX, timeLineY;

  public Sprite(Animation[] animations) {
    this.animations = animations;
    reset();
  }

  //azzera velocità ed indice dell'animazione corrente
  public void reset() {
    setSpeedX(0, 0L);
    setSpeedY(0, 0L);
    currentAnimation = animations[0];
  }

  //imposta direzione e variazione di tempo per la velocità lungo l'asse X
  public void setSpeedX(int dx, long dTimeX) {
    this.dx = dx;
    this.dTimeX = dTimeX;
    timeLineX = 0L;
  }

  //imposta direzione e variazione di tempo per la velocità lungo l'asse X
  public void setSpeedY(int dy, long dTimeY) {
    this.dy = dy;
    this.dTimeY = dTimeY;
    timeLineY = 0L;
  }

  //imposta l'animazione corrente tra quelle fornite all'interno dell'array.
  public void setAnimation(int index) {
    currentAnimation = animations[index];
  }

  //aggiorna lo Sprite (posizione e frame dell'animazione)
  public void update(long dTime) {
    currentAnimation.animate(dTime);

    timeLineX += dTime;
    timeLineY += dTime;

    //aggiorna la posizione lungo l'asse X, tenendo conto
    //della possibilità che la variazione di tempo dTime sia tale
    //da richiedere una serie di spostamenti successivi
    if(dx != 0 && dTimeX > 0L) {
      while(timeLineX >= dTimeX) {
        timeLineX -= dTimeX;
        posX += dx;
      }
    }

    if(dy != 0 && dTimeY > 0L) {
      while(timeLineY >= dTimeY) {
        timeLineY -= dTimeY;
        posY += dy;
      }
    }
  }

  //restituisce la posizione dell'oggetto lungo l'asse X
  public int getX() {
    return posX;
  }

  //restituisce la posizione dell'oggetto lungo l'asse Y
  public int getY() {
    return posY;
  }

  //restituisce l'immagine ottenuta dall'animazione corrente
  public Image getImage() {
    return currentAnimation.getImage();
  }
}


Controllo sul movimento di uno sprite, il giocatore
Da uno Sprite all'alter ego del giocatore il passo è breve; una delle differenze risiede nel controllo, affidato all'interazione con l'utente anziché a routine più o meno "intelligenti". Abbiamo visto precedentemente come gestire la risposta alla pressione di tasti sul dispositivo. A questo punto occorre precisare che non tutti i dispositivi supportano la pressione simultanea di più tasti e la ripetizione dell'evento pressione. Per quanto riguarda il primo caso si tratta di definire una serie di altri 4 tasti oltre ai 4 che definiscono il controllo sul movimento, affinchè il giocatore possa spostarsi in 8 direzioni: dato il numero limitato di tasti a disposizione, in questo caso occorre passare dall'intercettazione degli eventi tradotta dal metodo getGameAction(int) alla gestione diretta del tastierino numerico. La seconda evenienza deve essere affrontata a contrario: se la funzione di ripetizione è presente, occorre prestare attenzione a come l'applicazione ragisce alla pressione ripetuta del tasto. Il problema si aggira facilmente con la supposizione che tutti i dispositivi siano dotati di ripetizione inserendo un boolean per ciascun tasto, il cui valore cambierà a seconda che il tasto sia stato premuto o rilasciato. Controllando il valore del boolean insieme al codice dell'evento otterremo una reazione limitata al caso in cui il tasto non nell'ipotetico stato "premuto".

//Controllo del giocatore
boolean up = false;
boolean down = false;
boolean left = false;
boolean right = false;

Sprite sprite = ...

...

public void keyPressed(int keyCode) {
  //ricaviamo il codice azione corrispondente al tasto premuto
  int gameAction = getGameAction(keyCode);

  if(gameAction == Canvas.RIGHT && !right) {
    System.out.println("RIGHT_PRESSED");
    sprite.setSpeedX(1, 10L);
    right = true;
  }
  else if(gameAction == Canvas.LEFT && !left) {
    sprite.setSpeedX(-1, 10L);
    left = true;
  }
  else if(gameAction == Canvas.UP && !up) {
    sprite.setSpeedY(-1, 10L);
    up = true;
  }
  else if(gameAction == Canvas.DOWN && !down) {
    sprite.setSpeedY(1, 10L);
    down = true;
  }
}

public void keyReleased(int keyCode) {
  //ricaviamo il codice azione corrispondente al tasto premuto
  int gameAction = getGameAction(keyCode);

if(gameAction == Canvas.RIGHT && right) {
System.out.println("RIGHT_RELEASED");
sprite.setSpeedX(0, 10L);
right = false;
}
else if(gameAction == Canvas.LEFT && left) {
sprite.setSpeedX(0, 10L);
left = false;
}
else if(gameAction == Canvas.UP && up) {
sprite.setSpeedY(0, 10L);
up = false;
}
else if(gameAction == Canvas.DOWN && down) {
sprite.setSpeedY(0, 10L);
down = false;
}
}

Un framework minimo
Riprendiamo quanto detto finora e cerchiamo di predisporre l'insieme minimo di strumenti che possano servire per cominciare a fare qualche esperimento in proprio. Quello che manca è un collegamento tra il motore di gioco ed un oggetto MIDlet, e soprattutto una generalizzazione del motore di gioco. A tale ultimo scopo, è sufficiente una semplice manipolazione del sorgente di GameCore che renda più facile l'uso di sue sottoclassi.

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

public abstract class GameCore extends Canvas implements Runnable {
  private Display display;
  private Image backBuffer;
  private boolean doubleBuffered;

  int screenWidth;
  int screenHeight;

  public static final int ANCHOR = Graphics.TOP | Graphics.LEFT;
  boolean gameLoop = false;

  private long time0, time1, dTime;


  /**
  * Il costruttore richiede come parametro l'oggetto Display
  * associato alla MIDlet
  */
  public GameCore(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;
    time0 = System.currentTimeMillis();
    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
    time1 = System.currentTimeMillis();
    dTime = time1 - time0;
    time0 = time1;

    dTimeEngineOperations(dTime);

    //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);

    renderingOperations(g);

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

  //Le sottoclassi di GameCore definiranno semplicemente
  //i due metodi che seguono, il primo per le operazioni da svolgersi
  //all'interno del ciclo prima della fase di rendering
  public abstract void dTimeEngineOperations(long dTime);

  //...il secondo per le operazioni di disegno.
  public abstract void renderingOperations(Graphics g);

}

Dal punto di vista del codice i cambiamenti sono minimi, ma è notevole il vantaggio nella semplicità d'uso, Essendo infatti sufficiente "riempire" i metodi astratti in un'estensione della classe GameCore per ottenere un ciclo di gioco che effettui operazioni "personalizzate". E' possibile averne dimostrazione usando le classi definite sinora in un'applicazione grafica il cui scopo sia quello di muovere uno sprite sullo schermo del cellulare. L'applicazione fa uso di due immagini che definiscono l'animazione dello Sprite.


Figura 1


Figura 2

 


Le immagini sono in formato png indicizzato. La maggioranza dei dipositivi a colori supporta un bit di trasparenza (da cui la necessità dell'indicizzazione dell'immagine da esportare in formato png), alcuni arrivano ad 8 bit. Le immagini sono state realizzate con l'ottimo "The Gimp", v. 1.3. L'indicizzazione si ottiene attraverso il menu "immagine" > "modalità" > "indicizzata". Il cuore dell'applicazione è rappresentato da un'estensione della classe GameCore, chiamata GameCoreImpl. Il costruttore della classe si preoccupa di creare un oggetto Sprite, controllato dal gestore di eventi "incorporato" in ciascun Canvas, aggiornato e disegnato sullo schermo secondo lo schema definito dalla superclasse GameCore.

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

public class GameCoreImpl extends GameCore {
  private Sprite sprite;

  private boolean right = false;
  private boolean left = false;
  private boolean up = false;
  private boolean down = false;

  public GameCoreImpl(Display display) {
    //richiamo all'inizializzazione della superclasse.
    super(display);

    //Inizializzazione dell'oggetto Sprite. Si suppongno irrealizzabili condizioni
    //di errore dovute alla lettura dei file immagine.
    try {
      AniFrame[] razzoFrames = {new AniFrame(Image.createImage("/flare0.png"), 250),
                                new AniFrame(Image.createImage("/flare1.png"), 250)};

      Animation razzo = new Animation(razzoFrames);

      //sfruttiamo una parte delle capacità dell'oggetto Sprite
      //dotandolo di un'unica animazione. Tipicamente uno sprite
      //possiede più di un'animazione.
      sprite = new Sprite(new Animation[] {razzo});
      sprite.setSpeedX(0, 10L);
    }
    catch(java.io.IOException e) {
      System.out.println(e);
    }
  }

  // Definizione delle operazioni da compiersi all'interno del motore
  //
di gioco con precedenza rispetto al rendering, eventualmente
  // basate sul differenziale di tempo
  // tra passaggi successivi nel ciclo.
  public void dTimeEngineOperations(long dTime) {
    sprite.update(dTime);
  }

  //Definizione delle operazioni di rendering.
  public void renderingOperations(Graphics g) {
    g.drawImage(sprite.getImage(), sprite.getX(), sprite.getY(), ANCHOR);
  }

  //Gestione degli eventi tasto premuto
  public void keyPressed(int keyCode) {
    int gameAction = getGameAction(keyCode);

    if(gameAction == Canvas.RIGHT && !right) {
      sprite.setSpeedX(1, 50L);
      right = true;
    }
    else if(gameAction == Canvas.LEFT && !left) {
      sprite.setSpeedX(-1, 50L);
      left = true;
    }
    else if(gameAction == Canvas.UP && !up) {
      sprite.setSpeedY(-1, 50L);
      up = true;
    }
    else if(gameAction == Canvas.DOWN && !down) {
      sprite.setSpeedY(1, 50L);
      down = true;
    }
  }

  //Gestione degli eventi tasto rilasciato.
  public void keyReleased(int keyCode) {
    int gameAction = getGameAction(keyCode);

    if(gameAction == Canvas.RIGHT && right) {
      sprite.setSpeedX(0, 50L);
      right = false;
    }
    else if(gameAction == Canvas.LEFT && left) {
      sprite.setSpeedX(0, 50L);
      left = false;
    }
    else if(gameAction == Canvas.UP && up) {
      sprite.setSpeedY(0, 50L);
      up = false;
    }
    else if(gameAction == Canvas.DOWN && down) {
      sprite.setSpeedY(0, 50L);
      down = false;
    }
  }
}

La realizzazione dell'oggetto MIDlet il cui scopo sia visualizzare le operazioni del motore di gioco può anche essere minimale:

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

public class GameApp extends MIDlet {

  private Display display;
  private GameCore gameCore;

  public GameApp() {
    display = Display.getDisplay(this);
    gameCore = new GameCoreImpl(display);
  }

  public void startApp() {
    display.setCurrent(gameCore);
    gameCore.start();
  }

  public void pauseApp() {
    gameCore.stop();
  } 

  public void destroyApp(boolean unc) {
    gameCore.stop();
    notifyDestroyed();
  }
}

Il risulato, parzialmente riscontrabile in un'immagine statica, è indicato nell'immagine.

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