Ora che abbiamo visto come muovere le immagini sul display, ritorniamo a parlare di ciclo di gioco. Nella terza lezione ne abbiamo implementato uno molto rudimentale, un semplice ciclo while che esegue le stesse istruzioni finché la variabile running è settata su true. Tuttavia, non abbiamo considerato quanto veloce deve essere l’aggiornamento del nostro stato di gioco, né quanti frame al secondo devono essere visualizzati. Per questo motivo, oggi vedremo come implementare un ciclo di gioco con FPS e UPS. Facciamo un esempio:
boolean running = true; while (!running) { updateGameState(); displayGameState(); }
Un codice simile a quello sopra, esegue le istruzioni senza prendersi cura del tempo o delle risorse, ed è questo che noi dovremo considerare nel nostro nuovo ciclo di gioco. Com’è facile intuire, il metodo updateGameState() si occupa dell’aggiornamento dello stato di gioco e displayGameState() del rendering sul display.
Ad ogni modo, prima di procedere con l’implementazione del ciclo di gioco con FPS e UPS, è necessario fare un po’ di chiarezza sull’argomento.
[divider]
FPS e UPS
Partiamo, come di consueto, dalle definizioni:
- FPS (Frames per Second): il numero di volte che displayGameState() viene chiamato al secondo.
- UPS (Updates per Second): il numero di volte che updateGameState() viene chiamato al secondo.
L’ideale sarebbe chiamare i due metodi lo stesso numero di volte al secondo e, noi, considereremo un minimo di FPS per fare in modo che l’animazione non risulti troppo lenta.
Ad esempio, se abbiamo 25 FPS significa che dovremo chiamare displayGameState() ogni 40 ms, infatti 1 secondo equivale a 1000 ms e 1000 / 25 = 40. Tuttavia, bisogna ricordare che updateGameState viene richiamato anche prima come metodo e, di conseguenza, dobbiamo essere sicuri che l’aggiornamento dello stato di gioco segua lo stesso passo del rendering sul display, altrimenti avremo un gioco con un’esecuzione più lenta. Vediamo alcuni esempi per capire meglio il concetto.
L’immagine seguente mostra esattamente un FPS: comprendendo il tempo necessario all’aggiornamento e al rendering. Ciò significa che guarderete l’immagine cambiare ogni secondo.
La figura di seguito, invece, mostra 10 FPS: l’immagine di seguito cambia ogni decimo di secondo, dunque ogni 100 ms.
Tuttavia, lo scenario appena descritto presuppone che ogni aggiornamento avvenga in un decimo di secondo. Ma se, ad esempio, i nemici che ci stanno sparando in quel momento sono 200? Come facciamo in un lasso di tempo così breve a far muovere 200 droidi nemici e controllare tutte le possibili collisioni? Naturalmente, il tempo richiesto è maggiore, ma ciò dipende strettamente dal dispositivo in nostro possesso.
Infatti, gli scenari in questo caso sono diversi. Potremmo avere, infatti, cicli di aggiornamento-rendering più brevi su alcuni dispositivi piuttosto che su altri. In alcuni casi, il ciclo di gioco potrebbe terminare anche prima del tempo che gli abbiamo dedicato. Il diagramma di seguito mostra, infatti, un piccolo lasso di tempo restante dopo il rendering sul display.
Com’è logico che sia, può accadere anche il contrario e, come naturale conseguenza, il gioco andrà più piano del previsto in quanto il tempo richiesto dai due metodi è maggiore di 10 ms (sempre immaginando uno scenario con 10 FPS).
La domanda sorge spontanea: qual è la situazione desiderata tra le ultime due? Naturalmente la prima, in quanto nel periodo di sleep time, avremo un po’ di tempo prima di passare al ciclo successivo e, a dirla tutta, facendo dormire il thread per un determinato lasso di tempo ci consentirà di raggiungere un obiettivo molto ambito: il frame rate costante.
La seconda situazione richiede un approccio completamente diverso (possiamo saltare la situazione ideale in quanto non accade mai). Per raggiungere una velocità di gioco costante (la velocità di gioco non coincide con il numero di FPS!) possiamo ricorrere a due alternative: la prima prevede di aggiornare lo stato dei nostri oggetti un certo numero di volte in un determinato lasso di tempo, la seconda prevede la conoscenza di alcune caratteristiche degli oggetti per prevedere il loro stato immediatamente futuro.
Un droide, ad esempio, che ha percorso metà del display in un secondo, si troverà in collisione dopo un altro secondo esatto. La tattica preferita è, naturalmente, la prima. In determinati casi, per ottenere una velocità di gioco costante sarà necessario anche saltare alcuni frames. Esaminate lo schema di seguito dove, a causa dell’over time, nel secondo aggiornamento salteremo il rendering sul display per riprenderlo nell’aggiornamento successivo.
Lo scenario che vedete sopra ha, in ogni caso, moltissime variazioni. A lungo andare, il gioco risulterà lento e per mantenere costante la velocità di gioco sarà necessario eseguire il rendering più frequentemente affinché non vengano saltati troppi fotogrammi rendendo l’applicazione ingiocabile. Per questo, è buona norma impostare un numero minimo e massimo di FPS. A questo punto, ne sapete abbastanza sull’argomento e possiamo passare alla modifica del nostro codice.
[divider]
Ciclo di gioco con FPS e UPS: il codice
Di seguito, il codice della classe MainThread.java. Esaminatelo con molta attenzione poiché implementa la logica dei diagrammi visti sopra:
package it.androidblog.droidz; import android.graphics.Canvas; import android.util.Log; import android.view.SurfaceHolder; public class MainThread extends Thread{ private static final String TAG = MainThread.class.getSimpleName(); // Numero desiderato di FPS private final static int MAX_FPS = 50; // Numero massimo di FPS da saltare private final static int MAX_FRAME_SKIPS = 5; // Periodo frame private final static int FRAME_PERIOD = 1000 / MAX_FPS; //SurfaceHolder per accedere alla superficie fisica private SurfaceHolder surfaceHolder; //L'attuale view che cattura gli input e disegna sulla superficie private MainGamePanel gamePanel; //flag booleano per lo stato di gioco private boolean running; public MainThread(SurfaceHolder surfaceHolder, MainGamePanel gamePanel) { super(); this.surfaceHolder = surfaceHolder; this.gamePanel = gamePanel; } public void setRunning (boolean running) { this.running = running; } @Override public void run() { Canvas canvas; Log.d(TAG, "Starting game loop"); long beginTime; // il tempo quando inizia il ciclo long timeDiff; // tempo impiegato per il ciclo int sleepTime; // ms per lo stato di sleep (può essere <0) int framesSkipped; // numero di frame saltati sleepTime = 0; while (running) { canvas=null; //Proviamo a bloccare la "tela" per la modifica dei pixel sulla superficie try { canvas = this.surfaceHolder.lockCanvas(); synchronized (surfaceHolder) { beginTime = System.currentTimeMillis(); framesSkipped = 0; // resettiamo i frame saltati // Aggiornamento dello stato di gioco this.gamePanel.update(); // disegna la "tela" (canvas) nel pannello this.gamePanel.onDraw(canvas); // Calcoliamo quanto tempo prende il ciclo timeDiff = System.currentTimeMillis() - beginTime; // Calcoliamo sleepTime sleepTime = (int)(FRAME_PERIOD - timeDiff); if (sleepTime > 0) { // Caso ottimale se > 0 try { // mandiamo il thread a dormire per un breve periodo // molto utile per risparmiare batteria Thread.sleep(sleepTime); } catch (InterruptedException e) {} } while (sleepTime < 0 && framesSkipped < MAX_FRAME_SKIPS) { // Abbiamo bisogno di recuperare this.gamePanel.update(); // aggiornamento senza rendering sleepTime += FRAME_PERIOD; // aggiungere frame period per il controllo del frame successivo framesSkipped++; } } } finally { // Se scatta l'eccezione la superficie non viene lasciata // in uno stato incoerente if (canvas != null) { surfaceHolder.unlockCanvasAndPost(canvas); } } //fine finally } } }
Il codice completo potete scaricarlo qui (NB. i sorgenti includono anche gli argomenti introdotti nella lezione 7). Si notino tutte le nuove modifiche apportate, relative prettamente alla classe MainThread.java che vedete sopra e, ad ogni modo, il codice è completamente commentato per migliorarne la comprensione. Nella classe Speed.java, ad esempio, la velocità è impostata in unità al secondo e poiché abbiamo impostato il numero di FPS desiderato a 50, la velocità aumenterà proporzionalmente al numero di FPS. Ad esempio, per far avanzare il droide di 40 pixel al secondo, dobbiamo basarci sul numero di aggiornamenti al secondo che abbiamo.
Di primo acchito, la logica sembra un tantino complicata da capire, ma mettendo mano al codice voi stessi e facendo i dovuti esperimenti imparerete in fretta. Una volta scaricato il codice provatelo ed esaminate voi stessi se riuscite ad avere una velocità di gioco costante con un numero di FPS variabile. Per qualunque dubbio, commentate l’articolo.