Les bases d'un jeu Android en 2D

Par Jean-François GAZET le 7 avril 2015

Nous allons disséquer un jeu vidéo et créer les bases d’une application Android en 2D.

Principe d’un jeu 2D/3D

Il s’agit d’un jeu “graphique”, c’est-à-dire un jeu “vidéo” dans le vrai sens du terme. Cela signifie que des images se succèdent pour créer un effet cinématique. On doit donc définir la fréquence des images par seconde (ou FPS en anglais, pour Frames Per Second). Le code qui gère l’affichage est une boucle permanente, dans laquelle on va à chaque fois :

  • gérer les actions de l’utilisateur (clavier, souris, tap sur écran mobile…);
  • déplacer les objets, gérer les collisions;
  • afficher l’image complète du jeu à l’écran;
  • on recommence.

Environnement de développement

Je suggère Android Studio qui est gratuit. Il contient tous les outils nécessaires pour développer une application Android : SDK, émulateurs… Si ce n’est déjà fait, créez un émulateur en allant dans le menu TOOLS - ANDROID - AVD MANAGER.

Création d’un jeu

Pour l’exemple, je vous propose de :

  • placer une balle en mouvement à l’écran;
  • qui rebondira sur les bords (détection des collisions);
  • et que l’on pourra repositionner avec le doigt (gestion des actions de l’utilisateur).

Dans Android Studio, créez un nouveau projet, avec une “blank activity”, c’est-à-dire un écran vierge.

Afin de vous faciliter la suite de la lecture, j’ai rédigé les explications directement dans le code, en commentaires.

Sur Android, il faut savoir que la boucle qui gère l’affichage vidéo doit être exécutée dans un processus distinct de l’application principale (on dit un thread). Nous l’appellerons GameLoopThread.

Dans l’onglet “project”, “app”, “java”, faite un clic droit sur le package de votre application et choisissez “new - java class”. Nommez-le fichier “GameLoopThread” et cliquez sur OK. Copiez-collez le code suivant :

import android.graphics.Canvas;

public class GameLoopThread extends Thread
    {
    // on définit arbitrairement le nombre d'images par secondes à 30
    private final static int FRAMES_PER_SECOND = 30;

    // si on veut X images en 1 seconde, soit en 1000 ms,
    // on doit en afficher une toutes les (1000 / X) ms.
    private final static int SKIP_TICKS = 1000 / FRAMES_PER_SECOND;

    private final GameView view; // l'objet SurfaceView que nous verrons plus bas
    private boolean running = false; // état du thread, en cours ou non

    // constructeur de l'objet, on lui associe l'objet view passé en paramètre
    public GameLoopThread(GameView view) {
        this.view = view;
        }

    // défini l'état du thread : true ou false
    public void setRunning(boolean run) {
        running = run;
        }

    // démarrage du thread
    @Override
    public void run()
        {
        // déclaration des temps de départ et de pause
        long startTime;
        long sleepTime;

        // boucle tant que running est vrai
        // il devient faux par setRunning(false), notamment lors de l'arrêt de l'application
        // cf : surfaceDestroyed() dans GameView.java
        while (running)
            {
            // horodatage actuel
            startTime = System.currentTimeMillis();

            // mise à jour du déplacement des ojets dans GameView.update()
            synchronized (view.getHolder()) {view.update();}

            // Rendu de l'image, tout en vérrouillant l'accès car nous
            // y accédons à partir d'un processus distinct
            Canvas c = null;
            try {
                c = view.getHolder().lockCanvas();
                synchronized (view.getHolder()) {view.doDraw(c);}
                }
            finally
                {
                if (c != null) {view.getHolder().unlockCanvasAndPost(c);}
                }

            // Calcul du temps de pause, et pause si nécessaire
            // afin de ne réaliser le travail ci-dessus que X fois par secondes
            sleepTime = SKIP_TICKS-(System.currentTimeMillis() - startTime);
            try {
                if (sleepTime >= 0) {sleep(sleepTime);}
                }
            catch (Exception e) {}
            } // boucle while (running)
        } // public void run()

    } // class GameLoopThread

Si vous souhaitez en savoir plus sur les différentes méthodes d’implémentation de cette boucle de jeu, je vous recommande la lecture de cet article (en anglais).

Ajoutons une baballe, telle que définie dans Balle.java :

import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;

class Balle
    {
    private BitmapDrawable img=null; // image de la balle
    private int x,y; // coordonnées x,y de la balle en pixel
    private int balleW, balleH; // largeur et hauteur de la balle en pixels
    private int wEcran,hEcran; // largeur et hauteur de l'écran en pixels
    private boolean move = true; // 'true' si la balle doit se déplacer automatiquement, 'false' sinon

    // pour déplacer la balle on ajoutera INCREMENT à ses coordonnées x et y
    private static final int INCREMENT = 5;
    private int speedX=INCREMENT, speedY=INCREMENT;

    // contexte de l'application Android
    // il servira à accéder aux ressources, dont l'image de la balle
    private final Context mContext;

    // Constructeur de l'objet "Balle"
    public Balle(final Context c)
        {
        x=0; y=0; // position de départ
        mContext=c; // sauvegarde du contexte
        }

    // on attribue à l'objet "Balle" l'image passée en paramètre
    // w et h sont sa largeur et hauteur définis en pixels
    public BitmapDrawable setImage(final Context c, final int ressource, final int w, final int h)
        {
        Drawable dr = c.getResources().getDrawable(ressource);
        Bitmap bitmap = ((BitmapDrawable) dr).getBitmap();
        return new BitmapDrawable(c.getResources(), Bitmap.createScaledBitmap(bitmap, w, h, true));
        }

    // retourne 'true' si la balle se déplace automatiquement
    // 'false' sinon
    // car on la bloque sous le doigt du joueur lorsqu'il la déplace
    public boolean isMoving() {
        return move;
        }

    // définit si oui ou non la balle doit se déplacer automatiquement
    // car on la bloque sous le doigt du joueur lorsqu'il la déplace
    public void setMove(boolean move) {
        this.move = move;
        }

    // redimensionnement de l'image selon la largeur/hauteur de l'écran passés en paramètre
    public void resize(int wScreen, int hScreen) {
        // wScreen et hScreen sont la largeur et la hauteur de l'écran en pixel
        // on sauve ces informations en variable globale, car elles serviront
        // à détecter les collisions sur les bords de l'écran
        wEcran=wScreen;
        hEcran=hScreen;

        // on définit (au choix) la taille de la balle à 1/5ème de la largeur de l'écran
        balleW=wScreen/5;
        balleH=wScreen/5;
        img = setImage(mContext,R.mipmap.ball,balleW,balleH);
        }

    // définit la coordonnée X de la balle
    public void setX(int x) {
        this.x = x;
        }

    // définit la coordonnée Y de la balle
    public void setY(int y) {
        this.y = y;
        }

    // retourne la coordonnée X de la balle
    public int getX() {
        return x;
        }

    // retourne la coordonnée Y de la balle
    public int getY() {
        return y;
        }

    // retourne la largeur de la balle en pixel
    public int getBalleW() {
        return balleW;
        }

    // retourne la hauteur de la balle en pixel
    public int getBalleH() {
        return balleH;
        }

    // déplace la balle en détectant les collisions avec les bords de l'écran
    public void moveWithCollisionDetection()
        {
        // si on ne doit pas déplacer la balle (lorsqu'elle est sous le doigt du joueur)
        // on quitte
        if(!move) {return;}

        // on incrémente les coordonnées X et Y
        x+=speedX;
        y+=speedY;

        // si x dépasse la largeur de l'écran, on inverse le déplacement
        if(x+balleW > wEcran) {speedX=-INCREMENT;}

        // si y dépasse la hauteur l'écran, on inverse le déplacement
        if(y+balleH > hEcran) {speedY=-INCREMENT;}

        // si x passe à gauche de l'écran, on inverse le déplacement
        if(x<0) {speedX=INCREMENT;}

        // si y passe à dessus de l'écran, on inverse le déplacement
        if(y<0) {speedY=INCREMENT;}
        }

    // on dessine la balle, en x et y
    public void draw(Canvas canvas)
        {
        if(img==null) {return;}
        canvas.drawBitmap(img.getBitmap(), x, y, null);
        }

    } // public class Balle

Ajoutez le fichier GameView.java à votre projet. Il s’agit de l’affichage principal du jeu :

import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.view.MotionEvent;
import android.view.SurfaceHolder;
import android.view.SurfaceView;

// SurfaceView est une surface de dessin.
// référence : http://developer.android.com/reference/android/view/SurfaceView.html
public class GameView extends SurfaceView implements SurfaceHolder.Callback {

    // déclaration de l'objet définissant la boucle principale de déplacement et de rendu
    private GameLoopThread gameLoopThread;
    private Balle balle;

    // création de la surface de dessin
    public GameView(Context context) {
        super(context);
        getHolder().addCallback(this);
        gameLoopThread = new GameLoopThread(this);

        // création d'un objet "balle", dont on définira la largeur/hauteur
        // selon la largeur ou la hauteur de l'écran
        balle = new Balle(this.getContext());
        }

    // Fonction qui "dessine" un écran de jeu
    public void doDraw(Canvas canvas) {
        if(canvas==null) {return;}

        // on efface l'écran, en blanc
        canvas.drawColor(Color.WHITE);

        // on dessine la balle
        balle.draw(canvas);
        }

    // Fonction appelée par la boucle principale (gameLoopThread)
    // On gère ici le déplacement des objets
    public void update() {
        balle.moveWithCollisionDetection();
        }

    // Fonction obligatoire de l'objet SurfaceView
    // Fonction appelée immédiatement après la création de l'objet SurfaceView
    @Override
    public void surfaceCreated(SurfaceHolder surfaceHolder) {
        // création du processus GameLoopThread si cela n'est pas fait
        if(gameLoopThread.getState()==Thread.State.TERMINATED) {
            gameLoopThread=new GameLoopThread(this);
            }
        gameLoopThread.setRunning(true);
        gameLoopThread.start();
    }

    // Fonction obligatoire de l'objet SurfaceView
    // Fonction appelée juste avant que l'objet ne soit détruit.
    // on tente ici de stopper le processus de gameLoopThread
    @Override
    public void surfaceDestroyed(SurfaceHolder surfaceHolder) {
        boolean retry = true;
        gameLoopThread.setRunning(false);
        while (retry) {
            try {
                gameLoopThread.join();
                retry = false;
                }
            catch (InterruptedException e) {}
            }
        }

    // Gère les touchés sur l'écran
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        int currentX = (int)event.getX();
        int currentY = (int)event.getY();

        switch (event.getAction()) {

            // code exécuté lorsque le doigt touche l'écran.
            case MotionEvent.ACTION_DOWN:
                // si le doigt touche la balle :
                if(currentX >= balle.getX() &&
                    currentX <= balle.getX()+balle.getBalleW() &&
                    currentY >= balle.getY() && currentY <= balle.getY()+balle.getBalleH() ) {
                    // on arrête de déplacer la balle
                    balle.setMove(false);
                    }
                break;

            // code exécuté lorsque le doight glisse sur l'écran.
            case MotionEvent.ACTION_MOVE:
                // on déplace la balle sous le doigt du joueur
                // si elle est déjà sous son doigt (oui si on a setMove à false)
                if(!balle.isMoving()) {
                    balle.setX(currentX);
                    balle.setY(currentY);
                    }
                break;

            // lorsque le doigt quitte l'écran
            case MotionEvent.ACTION_UP:
                // on reprend le déplacement de la balle
                balle.setMove(true);
                }

        return true;  // On retourne "true" pour indiquer qu'on a géré l'évènement
        }

    // Fonction obligatoire de l'objet SurfaceView
    // Fonction appelée à la CREATION et MODIFICATION et ONRESUME de l'écran
    // nous obtenons ici la largeur/hauteur de l'écran en pixels
    @Override
    public void surfaceChanged(SurfaceHolder surfaceHolder, int i, int w, int h) {
        balle.resize(w,h); // on définit la taille de la balle selon la taille de l'écran
        }
    } // class GameView

Enfin, modifiez le code de MainActivity.java comme ceci :

import android.app.Activity;
import android.os.Bundle;

public class MainActivity extends Activity {

    private GameView gameView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        // On cré un objet "GameView" qui est le code principal du jeu
        gameView=new GameView(this);

        // et on l'affiche.
        setContentView(gameView);
        }

} // class MainActivity

Dans l’un des dossiers mipmap, ajoutez l’image de la balle, par exemple dans app/src/main/res/mipmap-hdpi/.
Lancez l’application avec le bouton “lecture” (ou MAJ+F10).
Vous verrez une balle de tennis se déplacer à l’écran et rebondir sur les bords.
A tout moment vous pouvez la déplacer avec le doigt.

Vous avez maintenant toutes les bases pour développer votre jeu Android 2D !


Partagez cet article


A lire également Tous les articles