Mise en Place

Projet

Ouvrir un nouveau projet de type EmptyActivity.

Style

On enlève la barre de menu (ActionBar) et on met le mode plein écran dans le fichier style.xml .

Ouvrir le fichier :   res > values > styles.xml :

  1. On peut changer pour un style NoActionBar dans l’éditeur de style :
     
  2. Mais pour le mode plein écran, il faut passer par l’éditeur de texte :
    <resources>
        <!-- Base application theme. -->
        <style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
            <item name="windowNoTitle">true</item>
            <item name="windowActionBar">false</item>
            <item name="android:windowFullscreen">true</item>
            <item name="android:windowContentOverlay">@null</item>
        </style>
    </resources>

Layout

Ouvrir le fichier :   res > layout > activity_main.xml.
Dans la vue TEXT :

  • Supprimer le TextView « Hello World! »
  • Vérifier que :
    android:layout_width="match_parent"
    android:layout_height="match_parent"
  • Dans paddingBottom, paddingLeft, paddingRight, paddingTop, cliquer sur la valeur par défaut 16 px pour qu’elle devienne :
    android:paddingBottom="@dimen/activity_vertical_margin"
    android:paddingLeft="@dimen/activity_horizontal_margin"
    android:paddingRight="@dimen/activity_horizontal_margin"
    android:paddingTop="@dimen/activity_vertical_margin"

Image en fond d’écran

  1. Mettre l’image dans le dossier drawable par Copier/Coller.
  2. Mettre cette ressource en Background dans le fichier xml :  res > layout > activity_main.xml

Ecran d’accueil

Créer un écran d’accueil revient à créer une Activité Android avec un bouton ‘commencer’ qui lance l’activité du jeu GameActivity.

Cette étape intermédiaire est facultative, on peut la supprimer facilement en copiant le contenu de la classe GameActivity directement dans la classe MainActivity.

Pour faire un écran d’accueil (1 TextViex + 1 Button) sur MainActivity :

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/activity_main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:paddingBottom="@dimen/activity_vertical_margin"
    android:paddingLeft="@dimen/activity_horizontal_margin"
    android:paddingRight="@dimen/activity_horizontal_margin"
    android:paddingTop="@dimen/activity_vertical_margin"
    tools:context="fr.isn.nlg.gameloop.MainActivity"
    android:background="@drawable/comete">

    <LinearLayout
        android:orientation="vertical"
        android:layout_width="match_parent"
        android:layout_height="match_parent" >

        <Space
            android:layout_height="wrap_content"
            android:layout_width="wrap_content"
            android:layout_weight="1" />

        <TextView
            android:id="@+id/textView"
            android:text="Mon Jeu"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:textColor="@android:color/background_light"
            android:textSize="@android:dimen/notification_large_icon_height"
            android:layout_weight="1"
            android:gravity="center_vertical|center_horizontal" />

        <Space
            android:layout_height="wrap_content"
            android:layout_width="wrap_content"
            android:layout_weight="1" />

        <Button
            android:id="@+id/buttonCommencer"
            android:text="Commencer"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_weight="1" />

        <Space
            android:layout_height="wrap_content"
            android:layout_width="wrap_content"
            android:layout_weight="1"  />
    </LinearLayout>
</RelativeLayout>

public class MainActivity extends AppCompatActivity {
    Button buttonCommencer;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        buttonCommencer = (Button) findViewById(R.id.buttonCommencer);
        buttonCommencer.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                //lance GameActivity dans le contexte de l'application
                startActivity(new Intent(getApplicationContext(), GameActivity.class));

                //et on ferme cette activité (écran d'accueil)
                finish();
            }
        });
    }
}

Faire Alt+Entrer sur chaque erreur pour importer les classes Button, View et Intent.

Faire aussi Alt+Entrer sur l’erreur  GameActivity.class :

  1. le logiciel va créer cette nouvelle classe dans votre application : ‘Create Class GameActivity’.
  2. laisser le package et l’emplacement par défaut : Valider

 Ajouter cette nouvelle activité ‘GameActivity’ au Manifest : Comme notre application contient désormais 2 activités (l’écran d’accueil MainActivity puis le jeu en lui-même GameActivity) alors elles doivent être déclarées dans le AndroidManifest.xml

Ouvrir :  manifests > AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="fr.isn.nlg.gameloop">

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:supportsRtl="true"
        android:theme="@style/AppTheme">
        <activity android:name=".MainActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

        <activity android:name=".GameActivity"></activity>

    </application>
</manifest>

 

Activité du jeu ‘GameActivity’

Cette nouvelle classe ‘GameActivity’ vient d’être créée juste à coté de la classe MainActivity.

  1. Ajouter  extends AppCompatActivity  pour que cette classe soit une activité Android (exactement comme la classe MainActivity).
    Faire Alt+Entrer sur l’erreur pour importer la classe AppCompatActivity.
  2. Ajouter les méthodes ci-dessous, liées à la vie d’une application Android (création, interruption et reprise du jeu) :
    public class GameActivity extends AppCompatActivity {
        protected void onCreate(Bundle savedInstanceState) {
            //exécuté lorsque l’utilisateur clique sur l’application pour une 1ère fois
            super.onCreate(savedInstanceState);
        }
    
        protected void onPause() {
            //exécuté chaque fois que l’utilisateur passe à une autre activité
            super.onPause();
        }
    
        protected void onResume() {
            //exécuté à chaque fois que l'activité repasse au premier plan
            super.onResume();
        }
    }

    Faire Alt+Entrer sur l’erreur pour importer la classe Bundle.

SurfaceView

Le jeu sera dessiné sur un composant appelé SurfaceView, et c’est le développeur qui va gérer le rafraîchissement graphique.

Nouvelle classe

Bien entendu, c’est une SurfaceView personnalisée donc on crée une nouvelle classe java héritant de la superClass SurfaceView (donc extends SurfaceView ) et on lui ajoute un Thread en interface (donc implements Runnable).

  

Ainsi, cette nouvelle class se crée … mais elle possède encore 2 erreurs :

  1. Comme cette classe implémente Runnable alors il lui faut une méthode run(), il faut donc « implémenter cette méthode » :
  2. Comme cette classe est une SurfaceView alors il lui faut un constructeur :

Finalement, la forme basique de cette classe est donc :

public class GameView extends SurfaceView implements Runnable {
    public GameView(Context context) {
        super(context);
    }

    @Override
    public void run() {

    }
}

GameLoop (Thread)

Ensuite on ajoute l’architecture de la GameLoop (la boucle sans fin qui anime le jeu) :

public class GameView extends SurfaceView implements Runnable {

  private volatile Thread gameThread = null;

  public GameView(Context context) { //constructeur
    super(context);
  }

  public void demarrer() {  // équivalent onResume()
    if(gameThread == null) {
        gameThread = new Thread(this); // (re)crée un thread
        gameThread.start(); // et lance run() [dans une 2e file d'exécution]
    }
  }

  public void arreter() {  // équivalent onPause()
    //recette pour arrêter le thread 'gameThread'
    if(gameThread != null) {
        Thread dummyThread = gameThread;
        gameThread = null;
        dummyThread.interrupt();
    }
  }

  @Override
  public void run() {
    Thread threadEnCours = Thread.currentThread();

    // BOUCLE 'SANS FIN' QUI ANIME LE PERSONNAGE (le coeur du programme)
    while (gameThread==threadEnCours && !threadEnCours.isInterrupted()) {
        try {Thread.sleep(17);} catch(InterruptedException e){} //pause 17ms

        // Puis ici : on va faire bouger les coordonnées du personnage

        // Et enfin, on redessine le SurfaceView (rafraichissement du visuel)
    }
  }
}

Par horodatage

Un problème majeur est que le jeu n’aura pas la même vitesse sur tous les smartphones car la rapidité de calcul diffère d’un appareil à l’autre.

=> Une solution est d’adapter le temps de pause en fonction du temps de calcul.

Le principe est simple :

  • Prenons 17 millisecondes comme temps de pause « théorique » :  sleepTime = 17;
    Normalement: sleepTime = 1000/fps car c’est 1000 millisecondes divisé par fps [avec fps = vitesse par seconde = frame per second ]
  • On fait un horodatage startTime avant de commencer les calculs.
  • On fait un horodatage après avoir fini les calculs :  System.currentTimeMillis()
  • On calcule dont le temps de pause restant :  sleepTime = 17 - (System.currentTimeMillis() - startTime);
public void run() {
  Thread threadEnCours = Thread.currentThread();

  // déclaration des temps de départ et de pause
  long startTime = System.currentTimeMillis();
  long sleepTime = 17; // 17 millisecondes

  while (gameThread == threadEnCours  && !threadEnCours.isInterrupted()) {
      //calcul du tps de pause en fonction de l'horodatage du départ et actuel
      sleepTime = 17 - (System.currentTimeMillis() - startTime);

      //pause
      if (sleepTime > 0)
          try {Thread.sleep(sleepTime);} catch(InterruptedException e){}

      //horodatage du départ (avant les calculs)
      startTime = System.currentTimeMillis();

      //Puis les calculs : coordonnées et rafraichissement graphique
  }
}

Horodatage et FrameSkip

Dernier problème : Et si le smartphone est si lent, que le temps d’affichage du graphisme est supérieur à 17ms (c’est à dire : sleepTime < 0) ?!!!
Dans ce cas, on ne peut rien faire … ce smartphone ne peut pas afficher le mouvement tel quel !

=> Une solution consiste à ne pas afficher toutes les frames, mais faire les calculs de déplacement tout de même, pour maintenir la vitesse du jeu !
Dans ce cas, on n’affichera peut-être qu’une frame sur 2 … C’est un moindre mal !

public void run() {
  Thread threadEnCours = Thread.currentThread();

  // déclaration des temps de départ et de pause
  long startTime = System.currentTimeMillis();
  long sleepTime = 17; // 17 millisecondes
  int framesSkipped=0;  // nb de frames non affichées


  // BOUCLE 'SANS FIN' QUI ANIME LE PERSONNAGE (le coeur du programme)
  while (gameThread == threadEnCours  && !threadEnCours.isInterrupted()) {
    //calcul du tps de pause en fonction de l'horodatage du départ et actuel
    sleepTime = 17 - (System.currentTimeMillis() - startTime);

    //pause
    if (sleepTime > 0)
        try {Thread.sleep(sleepTime);} catch(InterruptedException e){}

    while (sleepTime < 0 && framesSkipped < 5) { //prenons MAX_FRAME_SKIP=5
        //on calcule la frame sans l'afficher
        // (on fait bouger les coordonnées du personnage)

        // on ajoute une période et on verra si la prochaine frame
        // sera affichée ou seulmt calculée
        sleepTime = sleepTime + 17;
        framesSkipped++;
    }
    
    //horodatage du départ (avant les calculs et rafraichissement graphique)
    startTime = System.currentTimeMillis();

    // Puis ici : on va faire bouger les coordonnées du personnage

    // Et enfin, on redessine le SurfaceView (rafraichissement du visuel)
    
    //la frame est déssinée donc remise à zéro de framesSkipped
    framesSkipped = 0;
  }
}

Ajouter la SurfaceView à l’Activité

Maintenant il faut inclure cette GameView (une SurfaceView personnalisée) dans l’application du jeu (la classe GameActivity).

Dans la classe GameActivity :

  • On déclare une variable générale gameView de type GameView (la SurfaceView personnalisée).
  • On affiche cette variable à l’écran (comme contentView de l’activité).
  • Lorsque l’utilisateur met l’application en pause : on arrête le Thread (la boucle du jeu).
  • Lorsque l’utilisateur active l’application : on démarre le Thread (la boucle du jeu).
public class GameActivity extends AppCompatActivity {
  GameView gameView;

  protected void onCreate(Bundle savedInstanceState) {
    // exécuté lorsque l’utilisateur clique sur l’application pour une 1ère fois
    super.onCreate(savedInstanceState);

    //construction et affichage de la SurfaceView
    gameView = new GameView(this);
    setContentView(gameView);
  }

  @Override
  protected void onPause() {
    //exécuté chaque fois que l’utilisateur passe à une autre activité
    super.onPause();
    gameView.arreter();
  }

  @Override
  protected void onResume() {
    //exécuté à chaque fois que l'activité repasse au premier plan
    super.onResume();
    gameView.demarrer();
  }
}

Dessiner

Pour dessiner sur une SurfaceView, on utilise un Canvas (interface permettant de dessiner sur une zone de dessin appelée Bitmap).

Pour mieux comprendre :

  • Le Canvas est le peintre.
  • La Bitmap est la toile de pixels (la zone de dessin).
  • Le Paint est le pinceau (taille du trait, couleur ..)

Pour dessiner sur une SurfaceView, on procède ainsi :

  1. On pose l’objet :   SurfaceHolder surfaceHolder = getHolder();
  2. On vérifie que la SurfaceView peut accueillir un dessin :   if (surfaceHolder.getSurface().isValid()) { ... }
  3. Si oui, on récupère le canvas de la SurfaceView : Canvas canvas = surfaceHolder.lockCanvas();
  4. On dessine avec ce canvas.
  5. On affiche le résultat du canvas à l’écran : surfaceHolder.unlockCanvasAndPost(canvas);

Voici le code pour notre SurfaceView et son animation « balle rebondissante » :

public class GameView extends SurfaceView implements Runnable {

  private volatile Thread gameThread=null;
  int fps = 60; //nb de frames par seconde (vitesse du jeu)

  //variables pour le mouvement
  boolean right, down;
  int x, y;

  //Objets nécessaires pour dessiner
  Paint paint;
  SurfaceHolder surfaceHolder;
  Canvas canvas;

  public GameView(Context context) {
    super(context);

    //initialise le mouvement
    x = y = 0;
    right = down = true;

    //initialise les objects de dessin
    surfaceHolder = getHolder();
    paint = new Paint();
  }

  public void demarrer() {  // équivalent onResume()
    if(gameThread == null) {
        gameThread = new Thread(this); // (re)crée un thread
        gameThread.start(); // et lance run() [dans une 2e file d'exécution]
    }
  }

  public void arreter() {  // équivalent onPause()    
    if(gameThread != null) { //arrête le thread 'gameThread'
        Thread dummyThread = gameThread;
        gameThread = null;
        dummyThread.interrupt();
    }
  }

  @Override
  public void run() {
    Thread threadEnCours = Thread.currentThread();

    // déclaration des temps de départ et de pause
    long startTime = System.currentTimeMillis();
    long sleepTime = 1000/fps; // 1000 millisecondes/(fps=vitesse par seconde)
    int framesSkipped=0;  // nb of frames non affichées

    // BOUCLE 'SANS FIN' QUI ANIME LE PERSONNAGE (le coeur du programme)
    while (gameThread == threadEnCours  && !threadEnCours.isInterrupted()) {
        //calcul du tps de pause en fonction de l'horodatage du départ et actuel
        sleepTime = 1000/fps - (System.currentTimeMillis() - startTime);

        //pause
        if (sleepTime > 0)
            try {Thread.sleep(sleepTime);} catch(InterruptedException e){}

        while (sleepTime < 0 && framesSkipped < 5) { //prenons MAX_FRAME_SKIP=5
            //on calcule la frame sans l'afficher
            // (on fait bouger les coordonnées du personnage)
            moveIt();

            // on ajoute une période et on verra si la prochaine frame
            // sera affichée ou seulmt calculée
            sleepTime = sleepTime + 1000/fps;
            framesSkipped++;
        }

        //horodatage du départ (avant les calculs et rafraichissement)
        startTime = System.currentTimeMillis();

        // On fait bouger les coordonnées
        moveIt();

        // On redessine le SurfaceView (rafraichissement du visuel)
        if (surfaceHolder.getSurface().isValid()) {
            canvas = surfaceHolder.lockCanvas();
            dessine();
            surfaceHolder.unlockCanvasAndPost(canvas);
        }
    }
  }

  void moveIt() {  //calcul du déplacement
    if (x > this.getWidth()-200){right = false; }
    if (x < 200)  {right = true;  }
    if (y > this.getHeight()-200){down = false; }
    if (y < 200) { down = true;  }
    if (down) { y = y+10; } else {y = y-10;}
    if (right){ x = x+10; } else {x = x-10;}
  }

  void dessine() {
    canvas.drawRGB(255, 150, 150); //-- Remplissage du fond en rose

    // Dessiner l'intérieur de figure
    paint.setStyle(Paint.Style.FILL);
    paint.setColor(Color.parseColor("#FFFF00")); //rect jaune
    canvas.drawRect(50, 50, 1000, 750, paint );
    paint.setColor(Color.CYAN); //rect bleu
    canvas.drawRect(50, 750, 1000, 1500, paint );
    paint.setColor(Color.GREEN); //balle verte
    canvas.drawCircle(x, y, 200, paint);

    // Dessiner le contour d'une figure
    paint.setStyle(Paint.Style.STROKE);
    paint.setColor(Color.BLACK); //contour noir
    paint.setStrokeWidth(20);
    canvas.drawCircle(x, y, 200, paint);
  }
}

CallBack

Implémenter l’interface SurfaceHolder.CallBack peut s’avérer très utile car elle nous fournit 3 méthodes supplémentaires. Et ces méthodes nous permettent de connaitre les dimensions de la SurfaceView, d’être averti si le smartphone change d’orientation et de réagir si l’application est mise au 2nd plan (ou remise au 1er plan) …

CallBack permet de recevoir les changements provenant de la SurfaceView : dimension, orientation, mise en pause …
Grâce à  implements SurfaceHolder.Callback on dispose de 3 méthodes supplémentaires :

  • public void surfaceCreated (SurfaceHolder holder)
    Cette méthode sera appelée en cas de

  • public void surfaceChanged (SurfaceHolder holder, int format, int width, int height)
    Cette méthode sera appelée en cas de modification ou rotation de l’aperçu. Elle permet de récupérer les nouvelles dimensions width et height.
  • public void surfaceDestroyed (SurfaceHolder holder)
    Cette méthode sera appelée si la surface vient à disparaître, il faut stopper la preview de la caméra avant et lui indiquer en passant que la surface n’est plus accessible ! La méthode se nomme tout simplement camera.stopPreview()

Il est plus que probable que l’activité passe dans la méthode onPause avant que sa surface ne soit détruite.