www.migniot.com

Android Gaming

Le développement d'un jeu basé sur les sprites en Android demande un temps d'adaptation. Le cursus habituel quel que soit le système d'exploitation utilisé consiste à :
  • Initialiser une application en plein écran
  • Afficher un écran de présentation pendant le chargement des graphiques
  • Puis en boucle
    • Redéssiner l'aire de jeu à partir d'une matrice
    • Redéssiner les sprites
    • Interroger l'état du clavier
    • Modifier les coordonnées des sprites en conséquence
  • En fin de jeu, sauvegarder la partie en cours et libérer les ressources
Dans ce cycle, il n'y a pas de place pour la gestion du système, l'interruption en cas d'appel téléphonique, la programmation d'interface graphique standard Fenêtre - Icones - Menu - Souris. Hors la programmation Android est par défaut basé sur ce type d'interface WIMP
RickDangerousAtari

Quick Start

Dans ce tutoriel seront décrits
  • La construction d'une interface android plein écran
  • L'initialisation d'une surface graphique modifiable
  • L'initialisation d'un thread de gestion, le plus rapide possible
  • Le dessin du jeu frame après frame
  • La sortie d'application

Interface Fullscreen

La 1ère étape consiste à forcer l'application complète à s'exécuter en plein écran. Dans le descripteur AndroidManifest.xml, cela se matérialise par le thème donné à l'activité principale de l'application :
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.migniot.android.macgrowl" android:versionName="1.0" 
    android:versionCode="9">
    <application android:icon="@drawable/icon" android:label="@string/app_name">
        <activity android:name=".Game" android:label="@string/app_name"
            android:screenOrientation="landscape"
            android:theme="@android:style/Theme.NoTitleBar.Fullscreen">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>
</manifest> 
Fullscreen

Surface Custom

Maintenant il faut obtenir une surface pour dessiner sur la totalité de l'écran. La classe SurfaceView est une View dédiée à la gestion manuelle. Elle permet de dessiner des Bitmap à la main à n'importe quel moment en s'affranchissant des contraintes de rafraichissement standard.
SurfaceView ne sera pas utilisable directement. Créer une sous-classe permet d'obtenir le controle du redimensionnement et d'initialiser, de démarrer et stopper le Thread du jeu lors de l'affichage, de la restauration et de la suspension de l'application. Ci-dessous le layout déclarant une SurfaceView custom :
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent" android:layout_height="match_parent">

    <com.migniot.android.macgrowl.MacGrowlView
        android:id="@+id/screen" android:focusableInTouchMode="true"
        android:focusable="true" android:layout_width="match_parent"
        android:layout_height="match_parent" />

</FrameLayout>

Il faut maintenant créer cette classe fille de SurfaceView. Les méthodes interessantes de la classe sont :
  • com.migniot.android.macgrowl.MacGrowlView.surfaceCreated(SurfaceHolder)
    notifie de l'allocation de la surface
  • com.migniot.android.macgrowl.MacGrowlView.surfaceChanged(SurfaceHolder, int, int, int)
    notifie du dimensionnement initial de la surface, qui possède des dimensions nulles à sa création
  • com.migniot.android.macgrowl.MacGrowlView.surfaceDestroyed(SurfaceHolder)
    notifie de la destruction de la surface à la fermeture de l'application
SurfaceLog
Ci-dessous un extrait simple de la classe :
package com.migniot.android.macgrowl;

// [...]

/**
 * The bitmap game surface.
 */
public class MacGrowlView extends SurfaceView implements Callback {

    /**
     * The game thread.
     */
    private GameThread thread;

    /**
     * Constructor.
     *
     * @param context
     *            The context
     * @param attrs
     *            The attributes
     */
    public MacGrowlView(Context context, AttributeSet attrs) {
        super(context, attrs);
        // [...]
        SurfaceHolder holder = getHolder();
        holder.addCallback(this);
        setFocusable(true);
    }

    /**
     * Surface ready to serve.
     *
     * @param holder
     *            The surface holder
     */
    public void surfaceCreated(SurfaceHolder holder) {
        Log.d("mg", "Surface created");
    }

    /**
     * Injects surface size.
     */
    public void surfaceChanged(SurfaceHolder holder, int format, int width,
            int height) {
        Log.d("mg", "Surface changed, width = [" + width + "], height = ["
                + height + "]");
    }

    /**
     * Inform of surface deletion
     */
    public void surfaceDestroyed(SurfaceHolder holder) {
        Log.d("mg", "Surface destroyed");
    }

}
SurfaceLifecycle

Game Threading

Avant de continuer, on peut noter que le log capturé montre un FPS de 2. Cette vitesse d'animation est ridiculement basse et ne permet pas de réaliser un jeu fluide. Rassurez-vous, cette vitesse n'est obtenue que dans l'émulateur et l'exécution sur un téléphone est satisfaisante - FPS = [52] sur un HTC Desire.
Il s'agit maintenant de lancer un Thread. Ce Thread est le coeur du jeu, il se charge de :
  • Redéssiner l'aire de jeu à partir d'une matrice
  • Redéssiner les sprites
  • Interroger l'état du clavier
  • Modifier les coordonnées des sprites en conséquence
Ci-dessous la classe SurfaceView modifiée pour créer le Thread :
package com.migniot.android.macgrowl;

import android.content.Context;
import android.util.AttributeSet;
import android.util.Log;
import android.view.MotionEvent;
import android.view.SurfaceHolder;
import android.view.SurfaceView;
import android.view.SurfaceHolder.Callback;

/**
 * The bitmap game surface.
 */
public class MacGrowlView extends SurfaceView implements Callback {

    /**
     * The game thread.
     */
    private GameThread thread;

    /**
     * Constructor.
     *
     * @param context
     *            The context
     * @param attrs
     *            The attributes
     */
    public MacGrowlView(Context context, AttributeSet attrs) {
        super(context, attrs);
        SurfaceHolder holder = getHolder();
        this.thread = new GameThread(holder, context);
        holder.addCallback(this);
        setFocusable(true);
    }

    /**
     * Surface ready to serve.
     *
     * @param holder
     *            The surface holder
     */
    public void surfaceCreated(SurfaceHolder holder) {
        Log.d("mg", "Surface created");
        this.thread.setRunning(true);
        this.thread.start();
    }

    /**
     * Injects surface size.
     */
    public void surfaceChanged(SurfaceHolder holder, int format, int width,
            int height) {
        Log.d("mg", "Surface changed, width = [" + width + "], height = ["
                + height + "]");
        this.thread.setSurfaceSize(width, height);
    }

    /**
     * Inform of surface deletion
     */
    public void surfaceDestroyed(SurfaceHolder holder) {
        Log.d("mg", "Surface destroyed");
        thread.setRunning(false);
        boolean alive = true;
        while (alive) {
            try {
                thread.join();
                alive = false;
            } catch (InterruptedException e) {
            }
        }
    }

}
ThreadLifecycle

Frame Looping

La classe de Thread modifiée pour redessiner le tableau à chaque itération :
Example
package com.migniot.android.macgrowl;

import java.util.Random;

import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Rect;
import android.util.Log;
import android.view.SurfaceHolder;

/**
 * The game main loop.
 */
public class GameThread extends Thread {

    /** The surface width. */
    private int width;
    /** The surface height. */
    private int height;
    /** The running state. */
    private boolean running;
    /** The surface holder. */
    private SurfaceHolder holder;
    // [...]
    /** The context. */
    private Context context;
    /** The tiles. */
    private Bitmap tiles;

    /** The level. */
    private static final int[][] level = {
            {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0},
            {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0},
            {0,0,4,5,5,5,6,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0},
            {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0},
            {0,0,0,0,0,0,0,4,6,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0},
            {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0},
            {0,0,0,0,0,0,0,0,0,4,6,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0},
            {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0},
            {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0},
            {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4,5,6,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0},
            {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0},
            {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0},
            {0,0,0,0,0,0,0,0,0,0,0,0,4,5,5,6,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0},
            {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0},
            {0,0,0,0,0,0,16,17,19,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0},
            {0,0,0,0,0,0,7,7,7,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0},
            {0,0,0,0,0,0,7,7,7,7,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0},
            {0,0,0,0,0,7,7,7,7,7,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0},
            {0,0,0,0,7,7,7,7,7,7,7,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0},
            {1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,3},};

    /**
     * Constructor.
     *
     * @param holder
     *            The surface holder
     * @param context
     *            The context
     */
    public GameThread(SurfaceHolder holder, Context context) {
        this.holder = holder;
        this.context = context;
        this.tiles = BitmapFactory.decodeResource(this.context.getResources(),
                R.drawable.tiles);
        this.src = new Rect(0, 0, 16, 16);
        this.dst = new Rect(0, 0, 16, 16);
        this.fps = 0;
    }

    /**
     * Injects the surface size.
     *
     * @param width
     *            The width
     * @param height
     *            The height
     */
    public void setSurfaceSize(int width, int height) {
        synchronized (this.holder) {
            this.width = width;
            this.height = height;
        }
    }

    /**
     * Set the running state.
     *
     * @param running
     *            The state
     */
    public void setRunning(boolean running) {
        this.running = running;
    }

    /**
     * Game main loop.
     */
    @Override
    public void run() {
        long start = System.currentTimeMillis();
        long count = 0;
        Log.d("mg", "Game thread started");
        while (running) {
            updateState();
            Canvas canvas = null;
            try {
                canvas = holder.lockCanvas(null);
                synchronized (this.holder) {
                    draw(canvas);
                }
                count++;
            } finally {
                if (canvas != null) {
                    holder.unlockCanvasAndPost(canvas);
                }
            }
        }
        long end = System.currentTimeMillis();
        this.fps = (1000 * count) / (end - start);
        Log.d("mg", "FPS = [" + this.fps + "], count = [" + count
                + "], duration = [" + (end - start) + "]");
        Log.d("mg", "Game thread ended");
    }

    /**
     * Update the game state.
     */
    private void updateState() {
    }

    /**
     * Draw bitmaps.
     *
     * @param canvas
     *            The canvas
     */
    private void draw(Canvas canvas) {
        int[][] current = level;
        src.top = src.left = dst.top = dst.left = 0;
        src.bottom = src.right = dst.bottom = dst.right = 16;
        for (int y = 0; y < current.length; y++) {
            int[] line = current[y];
            dst.top = y * 16;
            dst.bottom = dst.top + 16;
            dst.left = 0;
            dst.right = 16;
            for (int x = 0; x < line.length; x++) {
                int tile = line[x];
                src.left = tile * 17;
                src.right = src.left + 16;
                canvas.drawBitmap(this.tiles, src, dst, this.blackPaint);
                dst.left += 16;
                dst.right += 16;
            }
        }
    }

    /**
     * Return the FPS.
     *
     * @return The FPS
     */
    public long getFps() {
        return fps;
    }

}

Dans ce contexte on obtient une vitesse de 50 FPS sur un HTC Desire. Ce taux de rafraichissement est suffisant pour la plupart des jeux. Pour des jeux plus rapides se reporter à l'excellent article A Quick Primer du développeur de Replica Island ou à une librairie, par exemple andengine