I. Contexte

Coder des applications « professionnelles » nécessite un background technique assez important tant les éléments à prendre en compte sont nombreux, surtout quand il s'agit d'applications web, multiutilisateurs, transactionnelles et sécurisées.

Cela ne peut pas se faire sans une bonne maîtrise des fondamentaux.

« Science sans conscience n’est que ruine de l’âme », François Rabelais, Pantagruel.

À mon sens, ces fondamentaux sont :

  • la représentation des données, l'adressage ;
  • l'exécution d'un programme ;
  • l'ordonnancement des tâches et des instructions ;
  • le fonctionnement d'un ordinateur ;
  • réutiliser les fonctionnalités existantes (libs, BIOS, jar, etc.) ;
  • l'utilisation des ressources ;
  • la factorisation du code.

Lassé par les TP/TD un peu trop conventionnels, j'ai donc réalisé une infrastructure de jeu vidéo de type « Shoot Them Up » (Shm'up pour les intimes) à scrolling (défilement) vertical, digne des années 1990, nostalgie oblige…

C'est sur cette infrastructure que sont venus s'appuyer mes stagiaires pour développer telle ou telle partie du jeu comme la gestion des collisions ou encore l'attribution de bonus et la gestion de l'état du vaisseau spatial.

Car oui, sous des allures « peu sérieuses », coder un jeu vidéo permet de mettre en œuvre des concepts de Design Patterns (Singleton, Facade, Factory), de polymorphisme, de découplage, de complexité algorithmique. Ces concepts seront toujours valables sur des applications professionnelles.

Ce projet « fil rouge » s'inscrivait dans un cours de « Java Bases » où les notions suivantes n'avaient pas encore été abordées, ce qui représentait un sacré challenge pour faire un jeu vidéo :

  • les collections (toutes) : ils n'avaient à étudier que List et ArrayList ;
  • les fichiers ;
  • les interfaces graphiques ;
  • le multithreading ;
  • les lambdas de Java 8 ;
  • Maven ;
  • JUnit.

De plus, mes stagiaires découvraient Eclipse, ayant fait des cours d'Algo pur en pseudolangage et avec Java's Cool. Parallèlement ils avaient des cours de POO, menés avec Bluej.

II. Hommage

Si vous ne le saviez pas encore, je suis nostalgique des jeux vidéo des années 90 et principalement de deux jeux : Xenon et Xenon 2 des Bitmap Brothers sur Atari-ST et Amiga.

Xenon 1
Xenon 2

Cliquez pour lire la vidéo


ATARI-ST
AMIGA

J'ai donc souhaité rendre hommage, à mon humble niveau, à ces deux « hits » de l'informatique microludique 1980-2000…

III. Le résultat

Voici ce que cela donne pour le moment, le jeu commence vraiment vers 48 secondes de vidéo, avant il s'agit surtout d'un peu de musique et de mouvements aléatoires de caméra sur le fond étoilé :


Cliquez pour lire la vidéo


Si vous souhaitez y jouer, il vous suffit d'avoir un Java 8 (JRE) installé sur votre poste (Windows ou Linux), et de dézipper ce fichier :

XenonReborn_0_1_10.zip.

Puis, lancez le fichier .jar XenonReborn.jar situé à la racine.

Pour vous amuser un peu, voici les commandes :

Touche(s)

Action

-------------------------------

---------------------------------------------------

FLECHES DU CURSEUR

mouvements du vaisseau

CTRL-DROIT

tir conventionnel

SHIFT-DROIT

tir destructeur

ENTREE

activation/désactivation du bouclier

F1

bascule mode plein écran/fenêtré

ESC

retour à l'écran précédent

CURSEUR GAUCHE et DROIT

change la musique seulement sur l'écran de menu

IV. Solutions techniques

IV-A. Diagrammes de classes

Voici différents diagrammes, afin de vous rendre compte d'une partie du système réalisé.

Diagramme UML Game
Diagramme UML Screens

IV-B. Graphismes

Le projet a été réalisé en Java, parce que la formation de mes stagiaires a comme objectif la maîtrise de ce langage et notamment, finalement, de la plateforme Java EE 7. Cela passe donc par l'apprentissage de Java SE 8.

Je me suis appuyé sur LibGDX, décliné exclusivement en mode « Client Lourd Java » (pas de client Android), qui est une bibliothèque avec laquelle j'avais déjà fait quelques essais et qui m'avait semblé robuste. 60 FPS pour un jeu censé être « retro », c'est beaucoup trop…

Je n'ai pas utilisé les classes offertes par la partie Scene2D (Actor, Stage, etc.) : j'ai préféré recoder mes propres éléments comme AnimatedSprite, mais finalement, cela y ressemble fortement. Si je devais refactoriser ce point, je m'appuierais sur les classes offertes en prenant le temps de les étudier un peu mieux.

Je me suis contraint à coder toute une infrastructure, notamment MVC, pour la gestion des écrans et des événements clavier, afin de ne pas trop complexifier la tâche de mes développeurs : l'objectif étant de leur faire pratiquer « boucle, conditions, variables, méthodes ».

IV-C. Musiques et Sons

Jouer du MP3, rien de plus facile avec LibGDX. Trop facile même.

Pour rendre vraiment hommage aux RetroGames, j'ai donc utilisé une bibliothèque de lecture de « MOD ». Les nostalgiques comme moi se rappelleront alors des différents Sound Tracker utilisés sur ST et AMIGA qui jouaient des musiques digitalisées « de dingues » alors que le poids des fichiers était très faible.

Je m'y suis donc contraint, ne serait-ce que pour retrouver « le son d'autrefois »…

Le reste des effets sonores (tirs, explosions) sont généralement joués à partir de fichier WAV directement par LibGDX.

IV-D. Algorithmes

Ici le terme d'algorithme est un bien grand mot. En effet, il s'agissait surtout de mettre en œuvre les fondamentaux du développement :

  • variables ;
  • boucles ;
  • conditions.

Puis d'enchaîner sur les concepts fondamentaux de la programmation orientée objet :

  • responsabilité ;
  • découplage ;
  • encapsulation ;
  • héritage.

Le tout étant toujours vu sous l'angle de la factorisation du code : dupliquer du source, c'est mal !

Objectifs : 1 classe = 10 méthodes, 10 lignes de code maximum par méthode.

Voici par exemple l'algorithme codé par certains stagiaires concernant la détection de collision, au moyen de la méthode d'intersection de cercles, bien suffisante pour un Shoot Them Up, sachant que tout élément à l'écran implémente une interface Artefact définie à cette occasion :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
43.
44.
45.
46.
47.
48.
49.
50.
51.
52.
53.
54.
55.
56.
57.
58.
59.
60.
61.
62.
63.
64.
65.
66.
67.
68.
69.
70.
71.
72.
73.
74.
75.
76.
77.
78.
79.
80.
81.
82.
83.
84.
85.
86.
87.
88.
89.
90.
91.
92.
93.
94.
95.
96.
97.
98.
99.
100.
101.
102.
103.
104.
105.
106.
107.
108.
109.
110.
111.
112.
113.
114.
115.
116.
117.
118.
119.
120.
121.
122.
123.
124.
125.
126.
127.
128.
129.
130.
131.
package net.entetrs.xenon.artefacts;

import com.badlogic.gdx.graphics.g2d.Sprite;
import com.badlogic.gdx.math.Circle;

import net.entetrs.xenon.commons.displays.Renderable;

/**
 * représente un artefact qui possède des points de vie, une force d'impact, un
 * cercle de collision et un sprite courant. Un artefact est "Renderable" par
 * héritage d'interface.
 * 
 * @author fxrobin et stagiaires.
 * @see Renderable
 */

public interface Artefact extends Renderable
{
    /**
     * retourne le cercle de collision.
     * 
     * @return
     */
    Circle getBoundingCircle();
    
    /**
     * retourne le nombre de points de vie de l'artefact.
     * 
     * @return
     *         le nombre de points de vie.
     */
    int getLifePoints();

    /**
     * décrémente les points de vie en fonction de la force d'impact exercée.
     * 
     * @param force
     *            force d'impact.
     */
    void decreaseLife(int force);

    /**
     * retourne la force d'impact de cet artefact.
     * 
     * @return la force d'impact.
     */
    int getImpactForce();

    /**
     * retourne "true" si l'artefact est toujours en vie, "false" sinon.
     * 
     * @return "true" si l'artefact est toujours en vie, "false" sinon.
     */
    boolean isAlive();

    /**
     * retourne le Sprite courant représenté par cet artefact.
     * 
     * @return le sprite courant.
     */
    Sprite getSprite();

    /**
     * affecte le nombre de points de vie.
     * 
     * @param lifePoints
     */
    void setLifePoints(int lifePoints);

    /**
     * affecte la vitesse sur l'axe Y.
     * 
     * @param vectorY
     */
    void setVectorY(float vectorY);

    /**
     * affecte la vitesse sur l'axe X.
     * 
     * @param vectorX
     */
    void setVectorX(float vectorX);

    /**
     * retourne la vitesse sur l'axe Y.
     * 
     * @return
     */
    float getVectorY();

    /**
     * retourne la vitesse sur l'axe X.
     * 
     * @return
     */
    float getVectorX();

    /**
     * déplace l'artefact en fonction de sa vitesse sur les 2 axes
     * et en fonction du temps delta écoulé.
     * 
     * @param delta
     */
    void act(float delta);

    /**
     * affecte la taille du cercle de collision.
     * 
     * @param radius
     */
    void setRadius(float radius);
    
    /**
     * retourne vrai si cet artefact entre en collision
     * avec celui passé en paramètre.
     * 
     * @param otherArtefact
     * @return
     *          "true" si la collision est avérée, "false" sinon.
     */
    boolean isCollision(Artefact otherArtefact);

    /**
     * augmente la vie de l'artefact (dans la limite de son maximum).
     * 
     * @param points
     *         points de vie à ajouter.
     */
    void increaseLife(final int points);
    
}

Système de gestion de collisions :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
43.
44.
45.
46.
47.
48.
49.
50.
51.
52.
53.
54.
55.
56.
57.
58.
59.
60.
61.
62.
63.
64.
65.
66.
67.
68.
69.
70.
71.
72.
73.
74.
75.
76.
77.
78.
79.
80.
81.
82.
83.
84.
85.
86.
87.
88.
89.
90.
91.
92.
93.
94.
95.
96.
97.
98.
99.
100.
101.
102.
103.
104.
105.
106.
107.
108.
109.
110.
111.
112.
113.
114.
package net.entetrs.xenon.artefacts.managers;

import java.util.List;
import java.util.Random;

import net.entetrs.xenon.artefacts.Artefact;
import net.entetrs.xenon.artefacts.enemies.Bullet;
import net.entetrs.xenon.artefacts.extra.BonusType;

/**
 * gestion de collisions entre des "targets" (cibles) et des "projectiles".
 * 
 * @author fxrobin & stagiaires.
 *
 */
public final class CollisionManager
{
    private static CollisionManager instance = new CollisionManager();
    private static Random randomGenerator = new Random();

    public static CollisionManager getInstance()
    {
        return instance;
    }

    private CollisionManager()
    {
        /* protection */
    }

    /**
     * vérifie les collisions entre une liste de cibles (targets), et une liste de projectiles (projectiles). 
     * 
     * @param targets
     * @param projectiles
     */
    public void checkCollision(List<? extends Artefact> targets, List<? extends Artefact> projectiles)
    {
        // on vérifie la collision de tous les "targets" avec chacun des "projectiles".
        for (Artefact t : targets)
        {
            for (Artefact p : projectiles)
            {
                checkCollision(t, p);
            }
        }
    }

    /**
     * vérifie la collision entre deux artefacts, l'un "cible", l'autre "projectile".
     * Si c'est le cas, le calcul des impacts est lancé, puis la vérification de l'état
     * "alive" du target.
     * 
     * @param target
     * @param projectile
     */
    public void checkCollision(Artefact target, Artefact projectile)
    {
        if (target.isCollision(projectile))
        {
            /* collision !!! */
            processCollision(target, projectile);
            checkDestruction(target);
        }
    }
    
    /**
     * décrémente la vie de deux artefacts en fonction des forces d'impact
     * de leur opposant respectif.
     * 
     * @param target
     * @param projectile
     */
    public void processCollision(Artefact target, Artefact projectile)
    {
        projectile.decreaseLife(target.getImpactForce());
        target.decreaseLife(projectile.getImpactForce());
    }

    /**
     * vérifie si l'artefact est détruit en lui demandant s'il est "alive".
     * S'il est détruit, le score est incrémenté de 10 points et un bonus
     * sera éventuellement généré.
     * 
     * @param target
     */
    public void checkDestruction(Artefact target)
    {    
        if (!target.isAlive())
        {
            /* MAJ du score */
            ScoreManager.getInstance().add(10);
            /* Génération des bonus éventuels en fonction de la cible abattue */
            processBonus(target);
        }
    }

    /**
     * génère éventuellement un bonus (une chance sur 2 de la générer).
     * Le bonus apparaîtra là où l'artefact a été détruit.
     * 
     * @param target
     */
    public void processBonus(Artefact target)
    {
        /* une destruction sur deux génère un bonus et les bullets sont ignorées*/
        if (randomGenerator.nextBoolean() && !(target instanceof Bullet))
        {
            /* puis on choisit au hasard, encore l'un ou l'autre des bonus potentiels.*/
            BonusType bonusType = randomGenerator.nextBoolean() ? BonusType.NORMAL_BONUS : BonusType.POWER_UP_BONUS;
            BonusManager.getInstance().addBonus(bonusType, target.getBoundingCircle().x, target.getBoundingCircle().y);
        }
    }
}

V. Temps passé et technos abordées

Pour donner un ordre de grandeur, j'ai dû passer 20 heures sur l'ensemble du coding sans compter le temps passé à trouver (pomper oooh c'est pas bien) des ressources graphiques et sonores. Cela reste assez peu finalement, au regard du résultat.

Je me suis forcé aussi à ne pas utiliser Lombok, qui aurait pourtant bien accéléré les choses, mais il fallait être en accord avec les compétences détenues par mes stagiaires à ce moment-là.

À ce jour voici les statistiques d'analyse de code (CODACY et CODEFACTOR) :

  • LineOfCode : 2897 ;
  • Java Files : 61 ;
  • Issues : 0%, Complex Files : 0%, Duplicated Code : 0% ;
  • CODEFACTOR : rated A ;
  • CODACY : rated A.

Je sais que certains aiment bien les statistiques de « production de code ». Grosso modo, cela représente 140 LoC par heure. Comme une classe, c'est en moyenne 50 LoC, cela fait presque trois classes par heure. Ces statistiques ne veulent pas dire grand-chose, mais je les laisse quand même.

Voici les points du jeu sur lesquels j'ai passé le plus de temps, souvent sur des détails d'ailleurs :

  • enchaînement par fade-in, fade-out des écrans (rien dans LibGDX pour cette chose pourtant nécessaire !) ;
  • scrolling parallax perso versus TileMap ;
  • affichage en channel Alpha du niveau de bouclier à gauche, parce qu’il faut prendre en compte les particularités du SpriteBatch ;
  • gestion des ressources au moyen d'enum (Jean-Jacques, spéciale dédicace si tu as lu jusque là…) ;
  • découplage de l'affichage du vaisseau en fonction de son état et des traitements des inputs.

Pour les stagiaires, se rendre compte :

  • que développer un jeu vidéo ce n'est pas simple, et finalement revoir à la baisse leurs prétentions de recoder Call Of Duty… ;
  • qu'avec quelques lignes de code, quelques boucles, on fait des choses sympathiques ;
  • que reprendre du code que l'on n’a pas écrit, ce n'est pas simple.

Cela m'a permis surtout, en fonction du niveau individuel de chacun de mes développeurs, de leur faire faire des choses plus ou moins complexes, et même d'en initier certains aux lambdas Java 8 et à la Stream API.

VI. Conclusions

J'aurais aimé structurer un peu mieux les différentes étapes à faire coder par mes stagiaires. La raison est que je (re)découvrais LibGDX en même temps qu'eux : pas facile pour estimer des jalons sur une technologie non maîtrisée (à méditer…).

Souvent, j'ai codé la solution, puis j'ai retiré les portions que j'estimais être à leur portée. Certains ont été vraiment déroutés par l'usage de l'anglais et surtout, par la reprise du code source qui ne leur appartient pas : et pourtant avant de savoir écrire du code, il faut aussi et surtout savoir le lire !

Je leur avais demandé de regarder LibGDX en avance de phase, mais je pense que cette action n'était pas encore dans leur compétence : lire de la JavaDoc !

On a pu aborder, sans les étudier en profondeur, la gestion des fichiers en ressource (images, sons), la gestion des logs. Seules les exceptions ne sont pas mises en œuvre alors qu'elles font partie des fondamentaux du développement Java.

Il reste encore pas mal de choses à coder, par exemple la scénarisation des niveaux, décrite dans des fichiers (XML, par exemple), car pour l'instant les ennemis sont générés aléatoirement, indéfiniment, par vagues successives.

Si c'était à refaire, je le referais, mais en y consacrant plus que 20 heures et en jalonnant un peu plus les étapes de réalisation pour ne pas en « perdre » certaines. Je pense finalement m'être plus amusé qu'eux…

S'ils lisent cet article, je les invite d'ailleurs à faire part de leurs commentaires sur cette partie « ludique » de l'apprentissage du développement.

Le code source du projet est disponible sur mon repository GitHub : https://github.com/fxrobin/Xenon_Reborn.

VII. Remerciements

Cet article a été publié avec l'aimable autorisation de François-Xavier Robin.

Nous tenons à remercier jacques_jean pour sa relecture orthographique attentive de cet article et Mickael Baron pour la mise au gabarit.