Interface graphique
Gameboj – Étape 11

1 Introduction

Le but de cette étape est de terminer le projet Gameboj en écrivant l'interface utilisateur graphique permettant d'interagir avec le simulateur.

1.1 Interface graphique

L'interface graphique de Gameboj est minimaliste et consiste en une simple fenêtre montrant l'image affichée sur l'écran du Game Boy, agrandie d'un facteur 2 pour compenser la faible résolution de ce dernier. La copie d'écran ci-dessous montre cette interface sur macOS, lorsque Gameboj exécute le jeu Bomberman.

gameboj-gui.png

Figure 1 : Gameboj durant la séquence d'introduction de Bomberman

L'interaction avec le simulateur se fait au moyen du clavier uniquement. Les huit touches du Game Boy sont assignées à huit touches du clavier de l'ordinateur hôte — celui exécutant Gameboj — selon la correspondance suivante :

Game Boy Gameboj
haut curseur haut
bas curseur bas
gauche curseur gauche
droite curseur droite
A a
B b
Start s
Select barre d'espacement

1.2 Synchronisation de la simulation

Dans sa version actuelle, le simulateur simule un Game Boy aussi vite que possible sur l'ordinateur hôte, sans tenter de respecter la vitesse d'exécution d'un Game Boy réel. Ce comportement est utile dans certaines situations, p.ex. lorsqu'on désire exécuter des tests rapidement, mais n'est bien entendu pas acceptable lorsqu'on désire jouer à un jeu.

Dit autrement, le simulateur possède une notion de temps qui lui est propre, exprimée en terme de cycles écoulés depuis le début de la simulation, et ce temps simulé n'est pour l'instant pas lié au temps réel. Or lorsqu'un être humain interagit avec le Game Boy simulé, il est important que les deux soient liés afin que les jeux s'exécutent à la bonne vitesse.

Sachant qu'un Game Boy exécute 220 cycles par seconde, il faut trouver un moyen pour faire en sorte que le simulateur simule, en moyenne en tout cas, ce même nombre de cycles par seconde de temps réel.

La technique que nous utiliserons, fréquemment employée dans les jeux1, consiste à faire avancer la simulation par petits pas, de manière à ce que le temps simulé rattrape à chaque pas le temps réel. L'idée est d'effectuer en boucle les actions suivantes :

  1. déterminer le nombre de secondes de temps réel écoulées depuis le début de la simulation,
  2. déterminer le nombre de cycles du Game Boy correspondant, en multipliant cetta valeur par 220,
  3. faire avancer la simulation jusqu'à ce nombre de cycles,
  4. obtenir du simulateur l'image actuellement affichée à l'écran du Game Boy et l'afficher à l'écran de l'ordinateur hôte.

En supposant que le simulateur soit plus rapide qu'un Game Boy réel, cette technique garantit que la simulation ne s'effectue jamais trop rapidement. En effet, le simulateur est toujours en léger retard par rapport au temps réel.

Telle quelle, cette solution a néanmoins le défaut d'afficher potentiellement un nombre bien trop important d'images à l'écran de l'ordinateur hôte. Par exemple, si on admet que les 4 actions décrites ci-dessus prennent un cinq-centième de seconde à s'exécuter, alors l'image affichée à l'écran de l'ordinateur hôte sera changée 500 fois par secondes. Cela est clairement inutile, sachant d'une part que l'image affichée à l'écran du Game Boy ne change au mieux que 60 fois par secondes, et d'autre part que les êtres humains ne peuvent généralement pas percevoir plus d'une soixantaine d'images par secondes.

Dès lors, la technique ci-dessus peut encore être améliorée en introduisant une étape d'attente, calibrée de manière à ce que la boucle décrite s'exécute environ 60 fois par secondes. Comme nous le verrons, JavaFX offre une manière très simple de faire cela.

2 Mise en œuvre Java

Les classes à écrire pour cette étape font toutes partie du paquetage ch.epfl.gameboj.gui, réservé à l'interface graphique (GUI est le sigle de Graphical User Interface).

2.1 Classe ImageConverter

La classe ImageConverter est un convertisseur d'image Game Boy en image JavaFX. Elle n'offre qu'une seule méthode, publique et statique, nommée p.ex. convert, prenant en argument une image de type LcdImage et retournant une image de type javafx.scene.image.Image.

Notez que cette image doit avoir la même taille que l'écran du Game Boy, c-à-d 160 par 144 pixels. Le redimensionnement de l'image avant l'affichage est fait par JavaFX, comme expliqué plus bas.

Les couleurs des pixels des images JavaFX possèdent quatre composantes comprises entre 0 et 255 (inclus) : la composante rouge, la composante verte, la composante bleue, et la composante dite alpha qui n'est rien d'autre que l'opacité de la couleur. La correspondance entre les couleurs du Game Boy et les couleurs JavaFX à utiliser pour ce projet sont données par la table ci-dessous, dans laquelle les composantes JavaFX sont données en hexadécimal :

Couleur Alpha Rouge Vert Bleu
:-) 0 FF FF FF FF
:-) 1 FF D3 D3 D3
:-) 2 FF A9 A9 A9
:-) 3 FF 00 00 00

2.1.1 Conseils de programmation

Pour créer une image JavaFX, il faut tout d'abord créer une instance de WritableImage de la bonne dimension, puis obtenir un « écrivain de pixels » de type PixelWriter permettant de modifier la couleur des pixels de cette image. Au moyen de la méthode setArgb de ce dernier, il est facile de construire l'image pixel par pixel.

N'hésitez pas à vous inspirer du code de la méthode main de la classe DebugMain2 de l'étape 9, en notant toutefois qu'il construit une image Swing, de type BufferedImage, et pas une image JavaFX.

2.2 Classe Main

La classe Main du paquetage ch.epfl.gameboj.gui est la classe contenant le programme principal du simulateur. Ce programme accepte un seul argument, qui est un nom de fichier ROM, et simule un Game Boy dans lequel on aurait inséré une cartouche contenant la ROM correspondante.

Attention : la classe Main est la classe principale de votre programme, et il est donc capital qu'elle porte effectivement le nom Main et qu'elle se trouve dans le paquetage ch.epfl.gameboj.gui. Notre système de rendu refusera votre projet s'il ne contient pas une classe portant ce nom-là.

Comme toute classe principale d'une application JavaFX, la classe Main hérite de la classe Application. Elle définit une méthode main qui ne fait rien d'autre qu'appeler la méthode launch de Application, en lui passant les arguments reçus, ainsi :

public static void main(String[] args) {
  Application.launch(args);
}

Le reste du code du simulateur se trouve dans la redéfinition de la méthode start héritée de Application. Ce code se charge de :

  1. vérifier qu'un seul argument a été passé au programme — le nom du fichier ROM — et terminer l'exécution dans le cas contraire,
  2. créer un Game Boy dont la cartouche est obtenue à partir du fichier ROM passé en argument,
  3. créer l'interface graphique puis l'afficher à l'écran,
  4. simuler le Game Boy en mettant périodiquement à jour l'image affichée à l'écran et en réagissant à la pression des touches correspondant à celles du Game Boy.

2.2.1 Conseils de programmation

  1. Validation des arguments

    Dans une application JavaFX, les arguments passés au programme peuvent être obtenus au moyen de la méthode getParameters, et la méthode getRaw permet de les obtenir sous forme de liste de chaîne de caractères.

  2. Terminaison de l'exécution

    Pour terminer immédiatement l'exécution d'un programme, p.ex. après avoir constaté que les arguments reçus sont invalides, il suffit d'appeler la méthode exit de System en lui passant 1 comme argument pour signaler qu'un problème s'est produit.

  3. Construction de l'interface graphique

    L'image de l'écran du Game Boy peut être affichée à l'écran de l'ordinateur hôte au moyen d'un composant JavaFX de type ImageView. Les méthodes setFitWidth et setFitHeight permettent de redimensionner l'image pour qu'elle soit deux fois plus grande qu'en réalité, comme demandé. La méthode setImage permet de changer l'image affichée.

    Ce composant ImageView peut être placé à l'intérieur d'un composant BorderPane, placé lui-même à la racine de la scène.

    Les gestionnaires d'événements permettant de gérer la pression et le relâchement des touches doivent être attachés au composant ImageView au moyen des méthodes setOnKeyPressed et setOnKeyReleased. Ces gestionnaires, de type EventHandler<KeyEvent>, reçoivent en argument un événement de type KeyEvent. Les méthodes getCode et getText permettent respectivement d'obtenir le code et le texte associé à la touche pressée ou relâchée. Les deux sont utiles car :

    • les touches textuelles (a, b, s et espace) ont à la fois un code et un texte qui leur est attaché, mais le code correspond toujours à un clavier américain et est donc incorrect pour certaines touches sur les autres claviers (suisse-romands ou français, p.ex.),
    • les touches de curseur n'ont pas de texte associé, il faut donc utiliser leur code (KeyCode.LEFT, KeyCode.RIGHT, KeyCode.UP et KeyCode.DOWN).

    Notez qu'une table associative convient très bien pour associer les touches de l'ordinateur hôte à celles du Game Boy.

    Finalement, sachez que pour que les pressions des touches provoquent l'envoi des événements associés au composant ImageView, il faut impérativement appeler sa méthode requestFocus juste après avoir appelé la méthode show de la scène principale (primary stage).

  4. Avancement de la simulation

    Pour faire avancer la simulation, nous utiliserons un minuteur d'animation JavaFX, de type AnimationTimer. Un tel minuteur possède une méthode nommée handle, que JavaFX appelle environ 60 fois par secondes dès que le minuteur a été démarré au moyen de sa méthode start. Cette méthode reçoit en argument le temps actuel, donné en nombre de nanosecondes écoulées depuis un instant d'origine arbitraire.

    Ce temps actuel en nanosecondes peut également être obtenu au moyen de la méthode nanoTime de la classe System. Dès lors, il est facile de mémoriser le temps du début de la simulation avant de démarrer le minuteur, puis ensuite de déterminer à chaque appel de la méthode handle le temps écoulé depuis le début de la simulation.

    Le programme d'exemple ci-dessous illustre cela en utilisant un minuteur d'animation JavaFX pour afficher sur la console le temps écoulé depuis le début du programme.

    public final class Example extends Application {
      public static void main(String[] args) {
        Application.launch(args);
      }
    
      @Override
      public void start(Stage primaryStage) {
        long start = System.nanoTime();
        AnimationTimer timer = new AnimationTimer() {
    	@Override
    	public void handle(long now) {
    	  long elapsed = now - start;
    	  System.out.printf("Temps écoulé : %.2f s%n",
    			    elapsed / 1e9);
    	}
          };
        timer.start();
      }
    }
    

    En l'exécutant, vous devriez obtenir un affichage ressemblant initialement à ceci, mais avec des valeurs légèrement différentes :

    Temps écoulé : 0,02 s
    Temps écoulé : 0,19 s
    Temps écoulé : 0,20 s
    Temps écoulé : 0,21 s
    Temps écoulé : 0,23 s
    Temps écoulé : 0,25 s
    Temps écoulé : 0,26 s
    Temps écoulé : 0,28 s
    Temps écoulé : 0,30 s
    Temps écoulé : 0,31 s
    …
    

2.3 Classes MBC1 et Cartridge

Pour vous permettre de tester votre simulateur avec des jeux utilisant un autre type de contrôleur de banque mémoire que celui de type 0 écrit à l'étape 6, nous mettons à votre disposition une archive Zip contenant la classe MBC1, modélisant un contrôleur de type 1.

Une fois cette classe importée dans votre projet, vous devez l'utiliser dans votre classe Cartridge pour créer une instance de MBC1 au lieu d'une instance de MBC0 lorsque l'octet identifiant le type de MBC (d'index 14716) contient la valeur 1, 2 ou 3. La raison pour laquelle il existe trois octets identifiant le même type de MBC est qu'il en existe trois variantes qui sont :

  1. une première variante, identifiée par la valeur 1, donnant uniquement accès à une mémoire morte,
  2. une seconde variante, identifiée par la valeur 2, donnant accès à une mémoire morte et à une mémoire vive volatile,
  3. une troisième variante, identifiée par la valeur 3, donnant accès à une mémoire morte et à une mémoire vive non volatile.

Les cartouches contenant la troisième variante incluent une petite pile, qui permet au contenu de la mémoire vive de ne pas disparaître même lorsque la cartouche est enlevée du Game Boy. Les jeux l'utilisent pour sauvegarder l'état de la partie en cours, ou les meilleurs scores obtenus. La classe MBC1 fournie considère toutefois que toutes les mémoires sont volatiles.

Etant donné que les cartouches dotées d'un contrôleur de banque mémoire de type 1 incluent parfois de la mémoire vive, le constructeur de la classe MBC1 prend un second argument qui est la taille de cette mémoire, en octets. Pour la déterminer, il suffit de consulter la valeur de l'octet d'index 14916 de la cartouche, nommé RAM_SIZE, qui peut prendre une valeur allant de 0 à 3 (inclus). La taille de la mémoire volatile correspondant à chacune de ces quatre valeurs est donnée par la table ci-dessous :

RAM_SIZE Taille RAM
0 0
1 2 048
2 8 192
3 32 768

2.4 Tests

La manière la plus simple — et la plus agréable — de tester votre simulateur consiste à essayer de jouer à différents jeux. En plus de Flappy Boy, nous mettons à votre disposition les fichiers ROM de deux autres jeux simples (et libres) qui sont :

Titre MBC Note Liens
2048 0 N'utilise ni sprite, ni fenêtre ROM, site Web
Snake 0 N'utilise ni sprite, ni fenêtre ROM, site Web

De plus, le site LoveROMs contient un très grand nombre de jeux. Toutefois, il faut bien noter que certains d'entre eux ne fonctionnent pas correctement sur notre simulateur en raison d'approximations que nous avons faites. Ceux de la table ci-dessous fonctionnent néanmoins, et sont de plus d'excellents jeux.

Titre MBC Note
Super Mario Land 1 Utilise la fenêtre en pause (touche Start)
Super Mario Land 2 1 Très bon jeu
Donkey Kong 1 Utilise des sprites de 8×16 pixels
Bomberman 1 Bon test pour la fenêtre
The Legend of Zelda 1 Très belle séquence d'introduction

Comme précédemment, les personnes ne possédant pas les cartouches de ces jeux doivent être conscientes du fait qu'il n'est pas forcément légal de télécharger les fichiers ROM correspondants.

3 Résumé

Pour cette étape, vous devez :

  • écrire les classes ImageConverter (ou équivalent) et Main, selon les indications données plus haut,
  • intégrer la classe MBC1 fournie à votre projet, et modifier la classe Cartridge pour permettre le chargement de fichiers ROM utilisant ce type de contrôleur de banque mémoire,
  • tester votre programme,
  • rendre votre projet dans le cadre du rendu final anticipé, si vous désirez tenter d'obtenir un bonus, ou dans le cadre du rendu final normal sinon ; les instructions concernant ces rendus seront publiées ultérieurement.

Notes de bas de page

1

Cette idée est p.ex. décrite par le patron Game Loop dans le livre Game Programming Patterns de Robert Nystrom.