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 della programmazione di videogame esiste un'interessante astrazione rappresentata dalle costanti DOWN, UP, LEFT, RIGHT, FIRE, GAMEA, GAMEB, GAMEC, GAMED (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. È 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 sopra 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);
}
}
Potendo 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 realizzarlo sono due classi, una per rappresentare il singolo frame con il relativo tempo di permanenza, e una per gestire la sequenza ciclica dei frame.
Un possibile modello per il singolo frame è il seguente:
import javax.microedition.lcdui.Image;
public class AniFrame {
public Image image;
public long tick;
public AniFrame(Image image, long 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.
