logo

 

Cet article est terminé.
Si vous voulez le beta tester: lisez-le, perdez-vous, et donnez moi votre avis.

Objectif

Il y a déjà un bon moment, j'avais fait une vidéo traitant de la programmation sur Gameboy où je montrais vaguement comment faire un lecteur de son court sans vraiment trop entrer dans les détails. Cette vidéo était censée être le pilote d'une série, mais comme bien sûr je n'ai pas du tout eu le courage de faire les scripts, les exemples, les prises, et le montage pour la suite, j'en viens aujourd'hui à faire un seul et unique "tuto" de base dans lequel je vais montrer comment réaliser un pong infini de A à Z.

Ce que j'appelle pong infini, c'est une variante simplifiée et pas amusante du fameux jeu Pong dans laquelle on joue seul contre... personne. Même pas contre la console.
On peut aussi voir ça comme un casse-brique, sans les briques.
C'est donc tout à fait dénué d'intêret à part celui de faire rebondir une balle sur une raquette et les bords de l'écran.

J'ai fait ce choix qui peut paraître douteux car programmer sur ce genre de console devient vite compliqué, non seulement à comprendre mais aussi à expliquer.
Si vous êtes interessé (ou du moins courageux) et que vous suivez tout ce tuto, vous vous direz surement à la fin que j'ai bien fait de ne pas vous montrer comment faire un clone de Pokemon jaune.

Dans un souci de clarté et d'honnêteté, je propose tout d'abord de télécharger le programme final, qu'on peut lancer avec un émulateur (logiciel simulant une Gameboy sur PC, j'en parle plus tard dans la partie "Outils").

ROM final (Zip)

Source complète (Zip)

Passionnant comme jeu, n'est-ce pas ?
Le pire, c'est que même avec un peu d'experience, il m'a falu plus de deux heures pour l'écrire. Ca devrait vous donner une idée du rapport entre l'effort fourni et le résultat...

Dernière chose avant de commencer: ce tuto a pris beaucoup plus que deux heures à réaliser. Par simple respect donc, je demanderai à ceux qui veulent le copier de préciser son origine en donnant un lien vers cette page. Merci !

 


Le langage et les nombres

Choisir un langage pour coder sur Gameboy est simple, car il n'y a vraiment qu'une solution fiable: l'assembleur. Il existe des compilateurs C qui peuvent fonctionner, mais avoir un tel niveau d'abstraction sur une machine si simple a tendance à me déranger un peu... Surtout quand il est question de tirer un maximum de celle-ci.

C'est pour ça que personnellement, j'aime coder en assembleur: on se fait très chier pour pas grand chose, mais on a le contrôle sur TOUT.
Ce langage est très proche de l'électronique: on va devoir faire des opérations au niveau binaire (les fameux 0 et 1), qui representent en fait des signaux electriques.
Le logiciel qui va traduire le code qu'on va écrire en une suite de nombres s'appelle un assembleur (et non pas un compilateur, c'est différent).

Je sais, le logiciel porte le même nom que le langage, ça peut porter à confusion mais j'y peux rien.

Ces nombres seront ensuite inscrits dans une mémoire (un circuit intégré), qu'on appelle ROM. Une mémoire en électronique est simplement un tableau avec des cases numérotés (adresse) dans lesquelles on inscrit des nombres (données).

ROM signifie Read Only Memory, c'est un type de mémoire qu'on ne peut que lire (on la programme une seule fois). C'est aussi le nom qu'on donne aux fichiers informatiques qui contiennent le contenu de ces circuits intégrés. C'est un abus de langage et le vrai nom devrait être en fait "ROM dump" (vidange de ROM).

Avec les langages haut niveau, on a plutôt l'habitude d'utiliser la notation décimale pour les nombres, en assembleur nous allons plus souvent avoir à faire à l'héxadécimal et au binaire.
Avec la Gameboy, on utilisera souvent des nombres sous forme d'octets (8 bits).

L'héxadécimal est en base 16, c'est à dire qu'on compte de 0 à F: 0,1,2,3,4,5,6,7,8,9,A,B,C,D,E,F.
Pour indiquer que le nombre est en héxa, on lui ajoute le préfixe $ (qui est 0x en langage C).
Faire la conversion décimal/héxa de tête n'est pas simple mais les puissances de deux, auxquelles on va souvent avoir à faire, sont facilement mémorisables:
...$8 pour 8, $10 pour 16, $20 pour 32, $40 pour 64, $80 pour 128, et $100 pour 256...

Le binaire est en base 2, on ne compte qu'avec des 0 et des 1 (les bits). Si on ajoute deux 1 ensemble, ça fait une retenue sur le bit de gauche.
Compter de 0 à 7 donne donc: 000,001,010,011,100,101,110,111.
Pour indiquer que le nombre est en binaire, on ajoute le préfixe %. La convertion est plus simple en binaire.
Pour convertir du binaire en décimal, il suffit de multiplier chaque bit par la puissance de deux qui lui correpond.
Le bit de droite (le premier, appellé bit de poids faible ou LSB), correspond à 2^0 (soit 1). Le bit suivant, à gauche, correspond à 2^1 (soit 2), et ainsi de suite.
Par exemple, pour convertir %1100111 en décimal, on va faire 1+2+4+32+64 = 103.

Pour convertir entre le binaire et l'héxadécimal, on peut séparer les nombres en deux, puisque 4 bits correpondent à un chiffre en héxadécimal.
Par exemple, pour convertir %10011100 en héxa, on prend les 4 premiers bits (a droite): 1100, soit 12 en décimal et $C en héxa, et les 4 derniers: 1001, soit $9.
Donc %10011100 = $9C.

Comme en binaire toutes les informations doivent être codés avec des 0 et des 1, on ne peut pas avoir de signe. Si on veut utiliser des nombres signés, on va devoir utiliser un bit supplémentaire qui indiquera si les suivants représentent un nombre positif ou négatif. On utilise pour ça le bit de poids fort (ou MSB). 1 indiquera que le nombre est : négatif, et 0 qu'il est positif.

Avec 8 bits, on peut donc soit compter de 0 à 255, soit de -128 à 127 (toujours 256 possibilités dans les deux cas).

Nombre en binaire %00000000 %00000001 %10000000 %10000001 %11111111
Sans signe 0 1 128 129 255
Avec signe 0 1 -128 -127 -1

Vous remarquerez que sur un octet, si on fait la soustraction $00-1, on boucle sur $FF, qui veut bien dire -1.

Tel quel, si on regarde un nombre binaire, rien n'indique qu'il est signé ou non.
Comme indiqué dans le tableau, %10000001 peut tres bien vouloir dire 129, ou -127 selon la facon de considerer le MSB.

C'est au programmeur de savoir ce qu'il fait, ou au reverse-engineer de deviner !

 


Les entrailles de la console

Le CPU, GPU, APU et le Bootstrap ROM de la Gameboy Pocket dans le même circuit intégré.

On ne peut pas programmer sur une machine à un niveau si bas si on ne sait pas de quoi elle est faite et comment elle fonctionne.

L'architecture de la Gameboy reste globalement simple, on retrouve les élements de base d'un ordinateur complet: un processeur (CPU), de la mémoire de travail (RAM), de la mémoire vidéo (VRAM), un processeur vidéo (GPU), un générateur de son (APU), des entrées/sorties (I/O) et un ou plusieurs ROMs (cartouche).

Passons ces élements en revue:

-Le CPU: Dans la Gameboy, il est très similaire au Z80. Il était très en vogue dans les années 80 et toujours au début des années 90. Comme beaucoup de processeurs, il possède un bus de données et un bus d'addresse. Un bus est un groupe de connections electriques qui permettent d'accèder à d'autres composants en spécifiant une adresse et en écrivant ou lisant une valeur.
C'est un processeur 8 bits, c'est à dire que son bus de données n'est capable que de transporter des nombres 8 bits. La majorite des operations qu'il sait faire seront egalement sur 8 bits.
Le bus d'adresse fait 16 bits, ce qui permet au CPU d'accèder à une plage de 64ko ($0000 à $FFFF, soit 0 à 65535 en décimal).
Il fonctionne à exactement 4.194304MHz, c'est à dire qu'il "avance" dans ses opérations 4194304 fois par seconde, soit presque 1000 fois moins rapidement que les processeurs des ordinateurs actuels. Attention, cela ne veut pas dire qu'il execute autant d'instructions par seconde (une instruction prennant plusieurs cycles).

-Le ROM: Une mémoire morte (qui ne s'efface théoriquement jamais), qui contient le programme que le CPU va lire et executer afin de commander tous les autres composants. C'est le composant principal d'une cartouche, le jeu est stocké à l'intérieur.
La GameBoy possède un petit ROM intégré appellé "bootstrap", qui est le premier programme éxecuté quand la console est allumée. Il s'occupe d'afficher le logo "Nintendo" à l'écran et de vérifier qu'une cartouche valide est insérée. Il est désactivé une fois que le ROM de la cartouche est lancé.

-La RAM: Une mémoire volatile (qui s'efface quand on éteint la console), qui permet au programme de stocker et relire des nombres pour réaliser des calculs et differents traitements. On peut y mettre ce qu'on veut à n'importe quel moment.
Sa taille est de 8ko (32ko sur Gameboy Color), soit 262144 fois moins qu'une barrette de 2Go actuelle.

-La VRAM (Video RAM): Une mémoire similaire à la RAM, mais qui est utilisée pour l'affichage vidéo. On ne peut y accéder (lire et écrire dedans), que quand le GPU n'y touche pas. Les graphismes et les positions des objets à l'écran y sont stockés. Sa taille est de 8ko également (16ko sur Gameboy Color).

-Le GPU (Graphics Processing Unit): Physiquement intégré avec le CPU dans le même circuit intégré, il a été conçu spécifiquement par Nintendo. C'est lui qui se charge de créer une image d'après les données contenues dans la VRAM et de l'envoyer à l'écran LCD. Il possède des registres (petites cases mémoire) dans lesquels on écrit pour le configurer.

-L'APU (Audio Processing Unit): Comme le GPU, il est aussi intégré dans le même circuit intégré et a été conçu spécialement pour la GameBoy. Il ne possède pas sa propre RAM, mais uniquement des registres qui vont lui indiquer quel son jouer à quel moment. Il possède 4 canaux, chacun ayant ses caractèristiques et chacun pouvant jouer un son à la fois.

-Les entrées/sorties: Ou I/O pour "Input/Output", sont les moyens pour la Gameboy de savoir ce qui se passe dans le monde exterieur. En l'occurence, ce sont les boutons, le port link et le port infrarouge. Parfois, les cartouches apportent des I/O supplémentaires, comme par exemple celles qui ont un vibreur ou un lecteur de code barres, mais ce sont des cas spéciaux.

On a vu que le processeur était capable d'adresser sur 64ko maximum. Dans ces 64ko sont réparties des plages attribuées aux différents composants. L'organisation de ces plages est appellé Memory Map ("carte de la mémoire"). Sur GameBoy elle est constituée ainsi:

Début
Fin
Description
$0000
$7FFF
Premiers 32ko du ROM de la cartouche
$8000
$9FFF
8ko de VRAM
$A000
$BFFF
8ko de RAM interne à la cartouche (pour les sauvegardes, si il y en a)
$C000
$DFFF
8ko de RAM de travail
$E000
$FDFF
Echo des 8ko de RAM précedents (mirroir)
$FE00
$FE9F
OAM (sorte de VRAM supplémentaire)
$FEA0
$FEFF
Rien :)
$FF00
$FF7F
Registres de configuration pour le GPU et l'APU
$FF80
$FFFE
Petit morceau de RAM supplémentaire (HRAM)
$FFFF
Registre spécial concernant les interruptions

Les 8ko de RAM sont répétés (mirrored), c'est à dire que la valeur qui est à $C000, se retrouvera automatiquement à $E000, de même pour celle à $C001 et $E001, et ainsi de suite... C'est une situation qui se produit lorsqu'une zone de la plage mémoire adressable n'est pas utilisée. Il vaut mieux ne pas y prêter attention et n'utiliser que la zone $C000 à $DFFF.

L'OAM (Object Attribute Memory) peut être vue comme une petite extension de la VRAM. Elle ne concerne que les sprites (abordés plus tard).

Les 128 octets de HRAM sont utilisables comme de la RAM, mais sont souvent réservés au stack (voir plus bas) ou à la routine DMA (pas abordé ici).

 


Les outils

Ce qui est bien avec les machines qui datent, c'est qu'on a généralement pas besoin d'un IDE de 500Mo avec des licences hors de prix pour coder dessus, car il y a déjà des amateurs qui ont écrit des outils gratuits et très fiables qui nous permettent aujourd'hui d'écrire nos programmes librement sans avoir à passer par Nintendo.

A l'époque, si on voulait publier le jeu et faire produire des cartouches, il fallait une licence très couteuse et leur payer des royalties.

Des documents très détaillés concernant tous les aspects techniques de la GameBoy ont aussi vu le jour il y a quelques années sur le net, ils sont communément appellé les "pandocs" (http://nocash.emubase.de/pandocs.htm), ils sont absolument indispensables pour programmer sur GameBoy. Ils décrivent tous les registres, du GPU à l'APU en passant par le port link et les boutons.

Pour écrire notre code, on va avoir besoin d'un editeur de texte, le bloc-notes de Windows suffit largement. Si vous voulez de la coloration syntaxique avec des onglets, des raccourcis, de la gestion de projet et tout ça, je conseillerai un editeur gratuit qui s'appelle Context.

On va aussi avoir besoin d'un petit editeur graphique de bonne renommée qui s'appelle Tile Layer Pro, qui est capable de gérer le format un peu spécial des graphismes. Il est bien sur possible de dessiner dans n'importe quel logiciel, mais il faut ensuite les convertir au format que le GPU de la GameBoy demande.

Même si le programme pourra fonctionner sur une vraie GameBoy, on va faire nos tests sur PC avec l'émulateur BGB, qui malgre que son auteur soit une petite raclure, est incontestablement le meilleur émulateur GameBoy après NO$GMB.

Et finalement, l'assembleur que nous allons utiliser: WLA-DX. Il est gratuit et tourne sous Windows et Linux, il est rapide, super fiable, et surtout, il est fait spécialement pour le processeur de la GameBoy. Vous pouvez le télécharger ici (en local, zip contenant seulement le nécessaire).

 


Le processeur

Pour faire son travail, le CPU possède ce qu'on appelle un jeu d'instructions et des registres.

Le jeu d'instructions est une liste d'opérations de base que le CPU sait faire, et qui serviront à constituer notre programme. La plupart des instructions nécessitent une ou deux operandes, qui précisent quelles données utiliser pour réaliser l'opération.
Dans les instructions à deux operandes, celle de gauche est la destination et celui de droite la source.

Un jeu complet contient typiquement des dizaines de milliers d'instructions qui ont pour la plupart été tapées une par une à la main. Le CPU lit ces instructions, réalise l'opération demandee puis passe à la suivante. L'integralite du jeu ne repose que sur ce simple principe, repete tres rapidement.

Quand on écrit du code en assembleur, on tape la mnemonique de l'instruction, c'est à dire son "nom". Il est en général composé de 2 à 4 lettres majuscules (par exemple: ADD, LD, SUB...). Chacune de ces mnemoniques correspond à une opcode, qui est leur représentation numérique . La plupart des opcodes font un octet, mais certaines en font deux.
Par exemple, l'instruction CCF sera traduite par WLA-DX avec le nombre $3F.
Sachant celà, on pourrait directement programmer avec des nombres. Mais il faut avouer que c'est nettement plus facile d'y aller avec des mnemoniques qui elles, peuvent dire quelque chose (pour nous, humains).

Les registres de travail sont des cases mémoires 8 bits dans le CPU, qui servent en quelque sorte de variables "fixes", ils peuvent prendre n'importe quelle valeur mais ils ne peuvent pas être renommés. Ils sont au nombre de 7 et s'appellent A, B, C, D, E, H, et L. Ils peuvent parfois être combinés ensembles pour former des registres 16 bits: AF, BC, DE et HL (notez que F est un registre spécial). On ne peut pas les combiner autrement, et seules certaines instructions acceptent ces paires.

Trois registres spéciaux sont également présents:

  • PC (Program Counter) est un registre 16 bits qui contient l'adresse mémoire de la prochaine instruction à executer (c'est un pointeur). Il peut être lu et écrit par le programme lui-même. C'est un peu comme le doigt des vieux quand ils arrivent pas à lire un texte...
  • SP (Stack Pointer) est aussi un pointeur 16 bits, qui contient l'adresse de la prochaine entrée de la pile (le Stack). Ceci sera expliqué un peu plus tard.
  • F est le registres des Flags (drapeaux). Chacun de ses bits sont modifiés en fonction du résultat de l'instruction qui vient d'être executée. Il est indispensable pour réaliser des sauts conditionnels (if/then). De nombreuses instructions utilisent ces flags.

Organisation des bits du registre F:

Bit 7 6 5 4 3 2 1 0
Nom Z N H C On s'en fout
Description Le résultat etait égal à zéro. Signe du dernier résultat (simple copie du bit de poids fort). Demi-retenue. Ce bit est mis à 1 quand il y a eu une retenue du bit 3 au bit 4. Rarement utilisé. Retenue. Mis à 1 si le résultat n'a pas tenu dans le registre (dépassement de $FF vers $00). Toujours a zero, pas utilise.

Les instructions ne peuvent pas toutes fonctionner avec les mêmes operandes. Certaines ne peuvent utiliser que le registre A par exemple, d'autres ne peuvent pas utiliser les paires de registres... Il y a donc plusieurs façons de fournir des arguments aux instructions, ces façons s'appellent "mode d'adressage". Voici les 4 utilisés par le CPU de la GameBoy (le Z80 original en avait bien plus):

-Direct: on donne une valeur fixe (8 bits).
Exemple: "ADD $10" va ajouter $10 au registre A. Notez qu'on ne precise pas "ADD A,$10".

-Indirect: on donne une adresse, la valeur située à cette addresse sera utilisée. Comme l'operande est un pointeur, on met des parenthèses autour.
Exemple: "LD A,($C1B9)" va mettre la valeur située dans la case mémoire $C1B9 (RAM) dans le registre A.

-Registre: on donne le nom d'un registre, la valeur qu'il contient sera utilisée.
Exemple: "ADD B" va ajouter le contenu du registre B au registre A.

-Registre indirect: on donne le nom d'un registre qui contient une adresse, la valeur située à cette addresse sera utilisée. Comme l'argument est un pointeur, on met des parenthèses autour. Presque toutes les instructions n'acceptent que le registre HL pour ce mode.
Exemple: "ADD (HL)" va ajouter la valeur située dans la case mémoire indiquée par le registre HL, au registre A.


Le CPU est aussi capable de gérer ce qui s'appelle une "pile" ou "stack" (dans le sens "tas de données"). Elle porte bien son nom: c'est une zone en RAM (donc hors du CPU, dans les 64ko adressables), qui est utilisée pour "empiler" et "dépiler" des données. Le stack ici est de type Last In First Out (LIFO), ce qui veut dire "Dernier entré, premier sorti".

Le pointeur de stack (registre SP) est typiquement initialisé à l'adresse de fin de la HRAM (à l'adresse $FFF4, comme Nintendo l'exigeait).
Lorsqu'on empile une valeur, elle est inscrite à cette adresse et SP est décrémenté.
Lorsqu'on dépile une valeur, l'adresse est incrémentée et la valeur est relue.
Dépiler n'efface pas la valeur dans la pile.

Exemple:

Initialiser SP    SP:$DFFF
Empiler 5         SP:$DFFE ($DFFF)=5
Empiler 12        SP:$DFFD ($DFFE)=12
Dépiler, donne 12 SP:$DFFE
Empiler 3         SP:$DFFD ($DFFE)=3
Dépiler, donne 3  SP:$DFFE
Dépiler, donne 5  SP:$DFFF

Comme le registre SP "descend" et n'est pas limité (il peut couvrir les 64ko adressables en entier), il est possible de remplir accidentellement la RAM, d'écraser des données qui y étaient déjà placées, ou meme d'aller ecrire au GPU sans le vouloir !
On parle alors de stack collision, une source de violents bugs qui heureusement n'est pratiquement jamais atteinte vu la quantité de RAM assez conséquente (8ko).

Finalement, un dernier système du CPU à voir: les interruptions.

Les interruptions sont des sauts automatiques dans le programme (PC est modifié) lorsqu'un évenement externe se produit. Sur la GameBoy, il a plusieurs interruptions différentes mais nous n'allons en utiliser qu'une seule: l'interruption vblank.
Chaque interruption possède une adresse à laquelle PC sera fixé si elle est déclenchée. Lorsqu'une interruption vblank se produit, PC est empilé, et instantanément fixé à l'adresse $40 (en ROM donc), quel que soit la tâche qui était en train d'être accomplie.
Pour éviter d'effacer la valeur des registres qui servaient à la tâche en cours d'execution, il faut les sauvegarder temporairement: c'est là que le stack devient utile.
Au tout début de la routine de l'interruption, c'est à dire à l'adresse $40, on va empiler tous les registres. Après traitement de l'interruption, on les dépilera tous pour les restorer, ainsi que PC pour retourner là où on en était.

L'interruption vblank se produit a chaque fois que le GPU termine de rafraichir l'ecran (c'est a dire environ 60 fois par seconde).

 

Le GPU

Pour afficher des choses sur l'écran de 160*144 pixels, on dispose de plusieurs moyens.
Plutôt que d'avoir un bête accès à tous les pixels de l'écran (ce qui serait infernal pour animer et déplacer des objets), on utilise ce qui s'appelle des tiles (ou tuiles, si on veut françiser).

Les tiles sont des blocs de 8*8 pixels, qui constituent tout ce qu'on veut afficher.

Ces tiles sont donc constitués de 64 pixels, qui chacun peuvent prendre l'une de 4 couleurs (4 niveaux de gris sur la GameBoy DMG).

Comme 4 choix ne demandent que 2 bits (00, 01, 10 et 11), on peut mettre 4 pixels dans un même octet. Un tile pèse donc 8*8/4 = 16 octets.
Plutôt que d'être organisées horizontalement dans le même octet, les paires de bits qui définissent la couleur d'un pixel sont définies verticalement et à l'envers.

Par exemple, si on a dans la VRAM les valeurs $0B puis $23, ceci donne en binaire:

00001011
00100011

Si on lit verticalement de bas en haut, on a les paires 00,00,10,00,01,00,11,11, soit les couleurs 0,0,2,0,1,0,3,3.

Le GPU fournit trois moyens d'afficher ces tiles: sur le background, sur la window, ou dans des sprites.

  • Le background: C'est une surface virtuelle plane de 256*256 pixels (soit 32*32 tiles) qui possède la plus basse priorité, c'est à dire qu'elle constitura le fond de l'écran, tout le reste sera affiché par dessus. Puisqu'il est plus grand que la taille de l'écran, il peut être scrollé pour faire des défilements. Le background est infini: les bords sont bouclés. Si on arrive à l'un de ses bords, le bord opposé est affiché. Il n'y a pas de pixels transparents sur le background.
  • La window: Comme le background, c'est également une surface de 256*256 pixels qui peut être scrollée, mais avec une priorité plus haute et pas de bouclage des bords. Comme elle est indépendante du background, elle sert souvent de barre de menu ou de score fixe dans les jeux à défilement. Il n'y a pas non plus de pixels transparents.
  • Les sprites: Ce sont des objets de 8*8 ou 8*16 pixels (1 ou 2 tiles), qui peuvent être déplacés n'importe où dans l'espace virtuel de 256*256 pixels (donc hors de l'affichage visible). Ils ont la plus haute priorité. Il peut y en avoir maximum 40, et 10 sur la même ligne horizontale (limitation technique). Les pixels ayant la couleur 0 sont transparents.

 

 

Le code

Ca y est, on peut s'y mettre ! On va ouvrir l'éditeur de texte et enregistrer le fichier avec l'extension .s, c'est pas nécessaire mais c'est la convention.

Ce qu'il faut faire avant tout, c'est renseigner WLA-DX sur comment on veut que le programme soit assemblé. Ca concerne surtout les détails propres aux cartouches GameBoy et la taille du programme final.
Une fois que ça sera fait, on pourra commencer à écrire le code qui sera assemblé a proprement dit.

Pour renseigner l'assembleur sur ces choses, on utilise des directives. C'est des mots clés qui commencent par un point, et qui ne seront pas retranscrits pas dans le programme final. C'est un peu comme des arguments en ligne de commande sauf qu'ils sont dans le fichier source. Toutes ces directives sont expliquées dans la doc de WLA-DX.

On va d'abord spécifier les directives qui vont dire à WLA-DX comment générer le header du ROM.
Le header est une courte zone mémoire vers le début du ROM qui fournit des renseignements à son propos. Seules deux informations (expliquées plus bas) sont necessaires pour que le ROM se lance sur la console, les autres peuvent être omises.
Pour faire ça bien, on va tout renseigner correctement.

.ROMDMG
.NAME "PONGDEMO"
.CARTRIDGETYPE 0
.RAMSIZE 0
.COMPUTEGBCHECKSUM
.COMPUTEGBCOMPLEMENTCHECK
.LICENSEECODENEW "00"
.EMPTYFILL $00

.ROMDMG indique que notre programme n'est pas spécialement fait pour la Gameboy Color. "DMG" veut dire "Dot Matrix Game" et etait le nom de code pour la premiere GameBoy, la "brique".
.NAME indique le nom du programme sur 11 caractères maximum.
.CARTRIDGETYPE 0 indique que notre programme va pouvoir fonctionner sur une cartouche qui comporte seulement un ROM de 32ko, rien d'autre. Tous les types cartouches sont aussi décrits dans la doc de WLA-DX.
.RAMSIZE 0 indique qu'on a pas besoin de RAM de sauvegarde (0ko). C'est implicite avec la directive précedente, mais on le reprécise.
.COMPUTEGBCHECKSUM est nécessaire pour que le jeu se lance. Cela indique à WLA-DX de calculer la somme de vérification (checksum) du ROM. Si elle n'est pas bonne, une vraie GameBoy ne lancera pas le programme.
.COMPUTEGBCOMPLEMENTCHECK est nécessaire aussi, c'est la même chose, mais pour le checksum du bloc d'infos qu'on est en train de définir. Si c'est pas bon, rien ne fonctionnera.
.LICENSEECODENEW "00" définit le numéro de licence (chaque developpeur en avait un attribué par Nintendo). On en a pas, donc on le laisse a zero.
.EMPTYFILL $00 indique que tout l'espace non utilisé dans le ROM final sera rempli par des octets à 0.

Finalement, on décrit comment les zones du ROM vont être organisées.

.MEMORYMAP
SLOTSIZE $4000
DEFAULTSLOT 0
SLOT 0 $0000
SLOT 1 $4000
.ENDME

.ROMBANKSIZE $4000
.ROMBANKS 2

.MEMORYMAP et .ENDME délimitent le bloc d'info qui décrit l'organisation du ROM. Ici on va avoir qu'une zone de $8000 octets de long, c'est à dire 32ko.
On définit plus loin que ces 32ko sont en fait 2 fois 16ko ($4000), mais c'est simplement pour que WLA-DX soit content, la GameBoy vera ces 32ko en entier. Si on devait en utiliser plus (comme la plupart des vrais jeux), il faudrait faie du bankswitching. C'est pas compliqué mais un peu chiant, et on ne va pas s'en servir ici.

.BANK 0 SLOT 0

On se place au tout dans les premiers 16ko du ROM.

.ENUM $C000
RaquetteY DB
BalleX DB
BalleY DB
VitX DB
VitY DB
.ENDE

Comme 7 registres du CPU ne vont pas suffir pour stocker les données de notre jeu, on va avoir besoin de variables. On indique donc où elles vont être placées en RAM.
Au lieu d'attribuer des adresses fixes et pas pratiques à nos variables, on va laisser WLA-DX trouver des adresses pour nous grâce au bloc ENUM.
.ENUM indique qu'on va attribuer des noms à des adresses qui commencent à $C000, qui est l'adresse de départ de la RAM. On définit ensuite les noms des variables et quelles tailles elles font. DB signifie "Declare Byte", pour les variables d'un octet. On peut aussi déclarer des variables 16bits avec DW, "Declare Word".
En interne, WLA-DX va donc maintenant savoir que quand il est écrit "RaquetteY" par exemple, il faudra le remplacer par l'adresse $C000. Ca sera $C001 pour BalleX, $C002 pour BalleY et ainsi de suite...

Maintenant que les directives sont inscrites, on peut passer au code en lui-même.

La première chose qu'on va définir, c'est quel morceau de code ("routine") appeller lorsque l'interruption vblank est déclenchée. Pour celà, on dit à WLA-DX de placer un saut vers un label (pointeur vers du code qu'on écrira plus tard) à l'adresse de l'interruption, soit $40 en utilisant la directive .ORG. L'instruction CALL place PC dans le stack et saute vers la routine nommée "VBlank". Une fois cette routine executée, PC revient ici et passe à l'instruction RETI, qui indique que le traitement de l'interruption est terminé et que le programme peut reprendre son cours normal (PC est depile).

A noter que pour retourner d'une interruption, il faut utiliser l'instruction RETI et non RET. Quand une interruption est declenchee, celles-ci sont automatiquement desactivees (pour empecher qu'une autre se produise trop rapidement et provoque du "nesting"). RETI permet donc de les reactiver en combinant les instruction RET et EI (Enable Interrupts).

.ORG $0040
call VBlank
reti

Ensuite, on va se placer à l'adresse $100. C'est là que PC est initialisé quand le jeu démarre (et non pas $0000). Nintendo conseillent de mettre une instruction NOP (No OPeration, ne rien faire), avant tout. Puis un saut vers notre code: à un label qu'on appelle "start". JP est different de CALL: il ne fait qu'un saut, sans empiler PC.

.ORG $0100
nop
jp start

A l'adresse $104, on va devoir placer une suite de valeurs fixes qui correpondent en fait aux données graphiques du logo Nintendo affiché lorsque la GameBoy est allumée. Si ces données ne sont pas correctes, le jeu ne se lancera pas. Pour définir des valeurs brutes qui seront simplement insérées par WLA-DX, on utilise à nouveau la directive .DB.

Notez que le format de ces donnees graphiques ne sont pas les memes que celles des tiles. Elles sont uniquement destinees au bootstrap.

.ORG $0104
.DB $CE,$ED,$66,$66,$CC,$0D,$00,$0B,$03,$73,$00,$83,$00,$0C
.DB $00,$0D,$00,$08,$11,$1F,$88,$89,$00,$0E,$DC,$CC,$6E,$E6
.DB $DD,$DD,$D9,$99,$BB,$BB,$67,$63,$6E,$0E,$EC,$CC,$DD,$DC
.DB $99,$9F,$BB,$B9,$33,$3E

 

 

L'initialisation

On peut désormais passer au code d'initialisation du jeu. On se place à l'adresse $150 (après le header qui, rappellez-vous, sera inséré automatiquement dans les parages par WLA-DX grâce aux directives qu'on lui a spécifié precedemment). A l'adresse $150 on est tranquille, on ne va pas écrire par dessus.

Notez que tout ce qui suit un point-virgule est un commentaire et sera ignore par WLA-DX. Ils sont presents pour detailler ce que chaque instruction fait dans ce tuto.

.org $0150
start:
di
ld sp,$FFF4 ;SP=$FFF4

On définit le label "start", auquel on va sauter depuis l'adresse $100 (rappellez vous du "JP start").
L'instruction DI (Disable Interrupts) désactive toutes les interruptions, pour ne pas qu'on soit dérangé pendant qu'on initialise tout.
Le pointeur de pile, SP, est fixé à $FFF4 (dans la HRAM) comme Nintendo le conseille.

xor a ;A=0
ldh ($26),a ;($FF26)=A

XOR A est une petite astuce qui consiste à faire l'opération binaire XOR de A sur A lui-même, ce qui a pour conséquence de mettre A à 0. On l'utilise souvent car c'est plus rapide que de faire LD A,$00.
L'instruction LDH est spécifique au CPU de la Gameboy, elle n'existe pas sur le vrai Z80. Elle permet d'acceder aux registres qui sont situés entre $FF00 et $FF7F sans avoir à préciser le $FF du début. C'est simplement un LD avec les 8 derniers bits de la destination coincés à $FF.
Les Pandocs indiquent que si on met le bit 7 du registre $FF26 à 0, on coupe le son. C'est juste une histoire d'économiser du courant. Ici c'est pas vraiment utile puisque la coupure ne va pas être longue, mais ça ne peut pas faire de mal.

waitvbl:
ldh a,($44) ;A=($FF44)
cp 144 ;Comparer A avec 144
jr c, waitvbl ;Sauter à waitvbl si A<144

Pour initialiser la VRAM sans afficher de la merde à l'écran (le GPU tourne deja !), on va l'éteindre. C'est à dire indiquer au GPU de ne rien afficher du tout. Avant d'éteindre l'écran, il faut attendre d'être dans le vblank (une courte période pendant laquelle l'écran n'est pas rafraichi). Pour celà, on va attendre que le GPU finisse de rafraîchir la ligne 144 de l'écran, c'est à dire qu'il passe à la ligne "virtuelle" 145, qui n'est pas physiquement sur l'écran physique.
On fait donc une boucle avec le label "waitvbl", une lecture du registre $FF44 avec un LDH, une comparaison avec le nombre 144 avec l'instruction CP, et un saut conditionnel vers le label si la comparaison indique que le registre $FF44 était inferieur à 144 (JR C, veut dire "sauter si inferieur").

Nintendo précisent bien qu'il ne faut pas éteindre l'écran n'importe quand car on risque de le déteriorer physiquement. Oui, on peut theoriquement faire un ROM qui nique l'ecran, doucement mais surement !

xor a ;A=0
ldh ($40),a ;($FF40)=A

Ensuite, on met le registre $FF40 à 0, le bit 7 étant à 0, l'écran est éteint (oui il faut encore aller voir les Pandocs !). Le reste des bits, on s'en fout pour l'instant, on laisse tout à 0.

ld b,3*8*2 ;B=48
ld de,Tiles ;DE=Adresse du label "Tiles", pointeur ROM
ld hl,$8000 ;HL=$8000, pointeur VRAM
ldt:
ld a,(de)
;A=(DE)
ldi (hl),a ;(HL)=A et incrémenter HL
inc de ;Incrémenter DE
dec b ;Décrémenter B (compteur d'itérations)
jr nz,ldt ;Sauter à ldt si B différent de 0

On veut ensuite charger nos graphismes en VRAM (le GPU ne peut pas aller les chercher lui meme en ROM). Nous aurons besoin que de 3 tiles en tout dans notre jeu. Un pour le fond qui sera répété, un pour la balle, et un pour la raquette (qui sera répété aussi).
Pour celà, on va devoir copier les données graphiques de ces tiles depuis notre ROM, vers la VRAM. On veut copier 3 tiles, donc 3 blocs de 8 lignes de pixels. Comme chaque ligne de 8 pixels demande deux octets, on va devoir copier 3*8*2 = 48 octets.

Pour réaliser une boucle, on charge le registre B avec le nombre d'itérations nécessaires. On utilise DE comme pointeur vers les données dans notre ROM, et HL comme pointeur vers la VRAM. $8000 est la toute première adresse de la VRAM, elle correspond au tile numéro zéro, c'est comme ça, ça bouge pas.
Les octets sont donc copiés un par un en passant par le registre A, les pointeurs HL et DE sont incrémentés et B est décrémenté à chaque fois.
L'intruction LDI est en fait un LD puis un INC. Elle ne marche qu'avec le registre HL, pour DE on est donc obligés de faire "LD A,(DE)" puis "INC DE".
L'instruction "JR NZ" indique qu'il faut sauter au label indiqué tant que la dernière instruction est différente de 0. C'est à dire: boucler jusqu'à ce que B = 0.

ld de,32*32 ;DE=1024
ld hl,$9800 ;HL=$9800
clmap:
xor a ;A=0
ldi (hl),a ;(HL)=A et incrémenter HL
dec de ;Décrémenter DE
ld a,e ;A=E
or d ;A=A OR D (soit A=E OR D)
jr nz,clmap ;Sauter à clmap si DE différent de 0

On veut ensuite nettoyer le background. Comme le bootstrap a dessine le logo Nintendo dessus, il faut le virer.
Pour nettoyer le background, on va le remplir avec le tile 0, qui vient d'être chargé. HL est encore un pointeur vers la VRAM, qui démarre à $9800 cette fois. C'est l'adresse de la map du background: un tableau de 32*32 cases qui indique quel numéro de tile mettre à quel endroit. L'adresse $9800 correspond au premier tile en haut à gauche de l'écran.

De même qu'avant, une boucle est utilisée pour remplir ces 32*32 cases. Bien sûr, il n'est pas nécessaire de remplir toute la map puisque certaines de ces zones ne seront pas affichées à l'écran, mais c'est plus simple de faire ainsi et de la remplir complètement.
Comme cette fois on a besoin de 32*32 = 1024 itérations, on ne peut pas utiliser un simple registre 8 bits tel que B. On utilise donc la paire DE en tant que compteur d'itérations.

Une limitation du Z80 fait que le flag Z (zéro) n'est pas mis à 1 si le résultat d'un DEC sur un registre 16 bits est égal à zéro (c'est con, il faut le savoir). On doit donc ruser et utiliser l'opération logique OR pour mélanger les registres D et E dans le registre A, pour voir si tous le bits sont à zéro. On peut ensuite faire un JR NZ comme avant.

ld hl,$FE00 ;HL=$FE00
ld b,40*4 ;B=160
clspr:
ld (hl),$00 ;(HL)=0
inc l ;Incrémenter L
dec b ;Décrementer B
jr nz,clspr ;Sauter à clspr si B différent de 0

Encore une boucle de nettoyage, cette fois ci dans l'OAM (addresse $FE00). La zone de définition des positions et des attributs des sprites est mise à zéro.
Encore le même genre de boucle avec à nouveau le registre B comme compteur 8 bits initialisé à 40*4 (40 sprites qui ont chacun 4 octets de définitions).

Attention, remarquez qu'ici, "LD (HL)" puis "INC L" sont utilisés au lieu d'un "LDI (HL)" comme précedemment.
C'est à cause d'un bug hardware (oui oui, Nintendo se sont plantés !), qui empêche d'utiliser les incrémentations 16 bits lorsqu'on est dans la plage mémoire de l'OAM ($FE00 à $FE9F). Il faut donc incrémenter L uniquement.
C'est l'un des rares bugs qui se sont retrouvés dans le CPU de la GameBoy.

xor a ;A=0
ldh ($42),a ;($FF42)=A
ldh ($43),a ;($FF43)=A

Retour aux Pandocs pour lire que les registres $FF42 et $FF43 correspondent aux valeurs de scroll X et Y du background (défilement en nombre de pixels depuis la gauche et le haut de l'écran respectivement). Pour caler le background en haut à gauche, on met ces deux registres à zéro.

On prépare ensuite les sprites. On va en utiliser 5 en tout: 4 pour la raquette (disposés de haut en bas), et un pour la balle. La raquette va donc faire 8*32 pixels, et la balle 8*8.
Une boucle est utilisée pour préparer les 4 sprites de la raquette. Ces 4 sprites utilisent le même tile (numéro 1), seule leur position Y est incrémentée de 8 à chaque fois, pour les placer verticalement de haut en bas. Comme précedement, on utilise "INC L" et non pas "INC HL" ou "LDI" car on est dans la plage d'adresse de l'OAM.

ld hl,$FE00 ;HL=$FE00
ld b,4 ;B=4
xor a ;A=0
ldspr:
ld (hl),a ;(HL)=A OAM sprite Y
add 8 ;A=A+8 Prochain Y ++8
inc l ;Incrémenter L
ld (hl),$10 ;(HL)=$10 OAM sprite X
inc l ;Incrémenter L
ld (hl),$01 ;(HL)=1 OAM sprite tile
inc l ;Incrémenter L
ld (hl),$00 ;(HL)=0 OAM sprite attribut
inc l ;Incrémenter L
dec b ;Décrementer B
jr nz,ldspr ;Sauter à ldspr si B différent de 0

Le 5ème sprite (celui de la balle) est préparé à part, en dehors de la boucle. Il utilisera le tile numéro 2 et sera placé vers le centre de l'écran.

ld (hl),$80 ;(HL)=$80 OAM sprite Y
inc l ;Incrémenter L
ld (hl),$80 ;(HL)=$80 OAM sprite X
inc l ;Incrémenter L
ld (hl),$02 ;(HL)=2 OAM sprite tile
inc l ;Incrémenter L
ld (hl),$00 ;(HL)=0 OAM sprite attribut

On initialise nos variables. La position de la raquette est fixée à $20 (32) pixels à partir du haut de l'écran. La balle est mise à X = $80, Y = $80. Et les vitesses sont toutes les deux positives (déplacement en diagonale vers le bas et la droite).

ld a,$20
ld (RaquetteY),a
ld a,$80
ld (BalleX),a
ld (BalleY),a
ld a,2
ld (VitX),a
ld (VitY),a

On arrive maintenant au stade où l'écran pour être réactivé.

Juste avant, on initialise la palette avec les "couleurs" par défaut. Cette palette sert à définir quel chiffre correspond à quelle couleur. L'utilité de cette palette est de pouvoir faire des effets vidéos en changeant toutes les "couleurs" de l'écran sans avoir à modifier les données graphiques (clignotements, flashs...).
Il y a une palette de ce genre pour le background, et deux autres pour les sprites (on en utilisera qu'une mais on initialise les deux quand même).

Finalement, on active l'écran en écrivant de nouveau dans le registre $FF40. Les bits indiquent que le background, les sprites et l'affichage sont activés, et que l'adresse des graphismes du tile numéro 0 seront placés à partir de $8000 (voir les Pandocs encore).

ld a,%11100100 ;11=Noir 10=Gris foncé 01=Gris clair 00=Blanc/transparent
ldh ($47),a ; Palette BG
ldh ($48),a ; Palette sprite 0
ldh ($49),a ; Palette sprite 1 (ne sert pas)
ld a,%10010011 ; Ecran on, Background on, tiles à $8000
ldh ($40),a

ld a,%00010000 ; Interruptions VBlank activées
ldh ($41),a
ld a,%00000001 ; Interruptions VBlank activées (double activation à la con)
ldh ($FF),a

ei ;Activer la prise en charge des interruptions

loop:
jr loop ;Boucle infinie

 

 

La boucle principale

VBlank:
push af ;Empiler AF
push hl ;Empiler HL

La première chose qu'on veut savoir pour mettre à jour l'affichage, c'est quelles touches sont appuyés. Ici on ne va s'intéresser qu'aux touches haut et bas de la croix directionelle, pour deplacer la raquette.
Nintendo ont fait le choix de les séparer en deux groupes: les touches A/B/Start/Select, et puis les 4 directions. On doit donc écrire dans un registre pour choisir l'un de ces deux groupes, puis lire ce même registre pour connaître l'état des touches.

A noter que certaines version des Pandocs comportent une erreur: pour choisir le groupe des touches de direction, il faut mettre le bit 5 du registre $FF00 à 1, et non pas le bit 4.

ld a,%00100000 ; Selection touches de direction
ldh ($00),a ;($FF00)=$20

ldh a,($00) ;B=($FF00) lire état touches
ld b,a

L'état des touches se trouve maintenant dans les 4 premiers bits du registre B dans l'ordre suivant (bits 3 a 0): Bas, Haut, Gauche, Droite. Lorsque la touche est appuyée, le bit correspondant est mis à zéro, sinon il est à 1 (logique inverse). On va donc tester les bits 3 et 2 pour savoir si les touches Bas et Haut sont appuyées ou non. Si l'une ou l'autre le sont, la position de la raquette sera décrémentée ou incrémentée en fonction.

bit $3,b ;Tester bit 3 de B (croix directionnelle bas)
jr nz,nod
ld a,(RaquetteY) ;Faire descendre la raquette de 2 pixels
inc a
inc a
ld (RaquetteY),a
cp 144+16-32 ;Limite bordure écran bas + décalage HW 16 - taille sprite
jr c,nod
ld a,144+16-32
ld (RaquetteY),a
nod:

L'instruction BIT permet de tester l'état d'un bit d'un registre. On utilise ensuite un saut conditionnel pour sauter ou non le bloc de code qui déplace la raquette (label "nod" pour "no down").
La raquette est déplacée de deux pixels à la fois, c'est juste une question de vitesse (les deux "INC A"). On pourrait aussi faire "ADD 2", mais les INC sont plus courts et plus rapides pour de petites additions.
La nouvelle position de la raquette est comparée avec sa valeur maximum (le bas de l'écran), pour être limitée si besoin.

bit $2,b ;Tester bit 2 de B (croix directionnelle haut)
jr nz,nou
ld a,(RaquetteY) ;Faire monter la raquette de 2 pixels
dec a
dec a
ld (RaquetteY),a
cp 16 ;Limite bordure écran haut + décalage HW 16
jr nc,nou
ld a,16
ld (RaquetteY),a
nou:

La même chose doit se dérouler pour la touche Haut. Cette fois la position est décrémentée et la limite est le haut de l'écran.
Maintenant que la position de la raquette est définie en RAM dans la variable RaquetteY, on peut l'inscrire en OAM, pour effectivement déplacer les sprites la constituant à l'écran.
On va devoir écrire la position des 4 sprites un par un, avec un décalage de 8 pixels à chaque fois:

ld hl,$FE00 ;Mise à jour de la position de la raquette en OAM
ld a,(RaquetteY)
ld (hl),a ;OAM sprite 0 Y
ld hl,$FE04
add $8 ;8 pixels plus bas
ld (hl),a ;OAM sprite 1 Y
ld hl,$FE08
add $8 ;8 pixels plus bas
ld (hl),a ;OAM sprite 2 Y
ld hl,$FE0C
add $8 ;8 pixels plus bas
ld (hl),a ;OAM sprite 3 Y

 

 

Les collisions


Pour faire nos detections de collisions, on a besoin de connaître les limites de positions auxquelles on va devoir inverser des vitesses (rebonds).

Pour les 4 bordures de l'écran, pas de problème: ça sera 0 et 160 à l'horizontale, 0 et 144 à la verticale.

Pour la balle, on a besoin de ses 4 bords: sa position X et Y pour ses bords gauche et haut, et sa position X+8 et Y+8 pour les bords bas et droit.

Pour la raquette, on va utiliser de même sa position Y et Y+32 (4*8 = 32 pixels de hauteur) pour ses bords haut et bas, et sa position X+8 pour son bord droit (le bord gauche ne servira pas).

 

Limites collisionables:

  • Bord gauche de l'écran, avec le bord gauche de la balle: lorsque X = 0.
  • Bord droit de l'écran, avec le bord droit de la balle: lorsque X = 160-la largeur de la balle, soit 160-8 = 152.
  • Haut de l'écran, avec le haut de la balle: lorsque Y = 0.
  • Bas de l'écran, avec le bas de balle: lorsque Y = 144-la hauteur de la balle, soit 144-8 = 136.
  • Bord droit de la raquette avec le bord gauche de la balle: lorsque X = 16.

Il faut aussi savoir que les coordonnées des sprites dans l'OAM sont décalés de 8 pixels sur X et 16 pixels sur Y.
Si on veut placer un sprite tout en haut à gauche de l'écran il faut donc mettre X à 8 et Y à 16. C'est chiant, mais c'est comme ça.

On ajustera la position des collisions selon ces décalages...

 

Afin de simplifier les choses concernant les collisions avec la raquette, on ne définit qu'une seule zone collisionable.
Pour celà, on va utiliser une condition: si la balle vient depuis la droite et qu'elle tape cette ligne, elle rebondit (flèche rouge).
Si elle vient de la gauche (elle a rebondi derrière la raquette), on la laisse simplement passer à travers (flèche verte).

 

On va d'abord s'occuper du plus simple: les rebonds sur les bords de l'écran. Ca sera deux blocs de code assez similaires: l'un pour les collisions horizontales, l'autre pour les collisions verticales.
La position de la balle est mise à jour selon sa vitesse à chaque vblank (donc chaque nouvelle image, soit presque 60 fois par seconde). On fait simplement Position = position + vitesse. Si la vitesse est positive, la balle va vers la droite, si elle est négative, elle va vers la gauche. Rien de plus simple.

ld hl,BalleX ;Mise à jour position X de la balle selon la vitesse horizontale
ld a,(VitX) ;A=Vitesse horizontale
add (hl) ;A=(BalleX)+Vitesse

Notez bien que la position n'est ici pas encore mise à jour, elle demeure dans le registre A. La balle à l'écran n'est pas encore déplacée. C'est donc le moment de detecter les collisions. D'abord le bord droit:

cp 160
jr c,nocxr ;BalleX < 160: pas de collision bord droit
call lowbeep ;Collision: bip grave
ld a,$FE ;La vitesse horizontale est mise à -2
ld (VitX),a
ld a,160 ;La position sera remise à la limite, au cas où: 160 (160+8-largeur balle)
nocxr:

La nouvelle position est comparée à 160 (la position du bord droit de l'écran + la largeur de la balle - le décalage de 8 pixels). Un "JR C" saute au label "nocxr" si la position est inférieure: le code de rebond est esquivé. Si la position est égale ou supérieure, le code suivant est executé. Un CALL appelle la routine "lowbeep", qui sera écrite plus tard et qui sert à jouer un bip grave.
Pour faire rebondir la balle, on inverse simplement la vitessehorizontale en la fixant à -2: la balle partira donc à gauche. Par précaution on limite aussi la position au maximum, c'est à dire à160 (au cas où elle était devenue supérieure).

cp 2
jr nc,nocxl ;BalleX > 2: pas de collision bord gauche
call lowbeep ;Collision: bip grave
ld a,2 ;La vitesse horizontal est mise à 2
ld (VitX),a
ld a,8 ;La position sera remise à la limite, au cas où: 8 (0+8)
nocxl:

La position est ensuite comparée à 2 (la position du bord gauche de l'écran + la vitesse max). De la même façon, un saut conditionnel permet de ne pas faire de rebond si la position est supérieure à 2. Si un rebond se produit, le même bip est joué, la vitesse est fixée à 2, et la position fixée à 8 (soit 0 en réalité, à cause du décalage de 8 pixels).

ld (hl),a ;Fixage position X

Enfin, la position mise à jour est inscrite dans la variable BalleX, toujours pointée par le registre HL.
Le second bloc de code est très similaire à ce dernier, c'est la même chose mais pour les bords de l'écran haut et bas. La position est mise à jour selon la vitesse:

ld hl,BalleY ;Mise à jour position Y de la balle selon la vitesse verticale
ld a,(VitY)
add (hl)

Le bord bas est testé (144 - la hauteur de la balle + le décalage de 16 pixels).

cp 144+8
jr c,nocyr ;BalleX < 152: pas de collision bord bas
call lowbeep ;Collision: bip grave
ld a,$FE ;La vitesse verticale est mise à -2
ld (VitY),a
ld a,144+8 ;Limite
nocyr:

Puis le bord haut (le décalage de 16 pixels)

cp 16
jr nc,nocyl ;BalleX > 16: pas de collision bord haut
call lowbeep ;Collision: bip grave
ld a,2 ;La vitesse verticale est mise à 2
ld (VitY),a
ld a,8+8 ;Limite
nocyl:


ld (hl),a ;Fixage position Y

Maintenant, passons aux collisions avec la raquette. Pour que la balle rebondisse dessus, il faut qu'elle satisfasse trois conditions:

  • Qu'elle viennent de la droite (vitesse négative).
  • Qu'elle "touche" le bord droit de la raquette.
  • Qu'elle soit en face de la raquette.

On va d'abord voir si la position X de la balle est entre 10 et 16. On laisse donc 6 pixels de marge sur le bord droit de la raquette pour "attraper" la balle.
Si la position ne tombe pas dans cette zone, il ne peut pas y avoir de rebond et la balle est forcément dans cette zone:

ld a,(BalleX)
cp 8+16
jr nc,nopaddle ;BalleX > 8+16: pas de collision raquette possible
cp 8+10
jr c,nopaddle ;BalleX < 8+10: pas de collision raquette possible

Ensuite, on regarde si la vitesse est négative pour savoir si la balle vient de devant ou derrière la raquette. Si elle vient de derrière, il n'y a pas de rebond: comme dit precedemment, la balle passera simplement au travers de la raquette.

ld a,(VitX)
cp 2
jr z,nopaddle ;Vitesse positive: pas de collision raquette possible

Le troisième et dernier test consiste à savoir si la balle est alignée verticalement avec la raquette. Pour celà on va voir si la position du bord bas de la balle est supérieure à celle du bord haut de la raquette, et si celle du bord haut de la balle est inférieure à celle du bord bas de la raquette.

On teste d'abord la limite haute (bas de la balle, haut de la raquette):

ld a,(BalleY)
add 8 ;Trouve la position du bord bas de la balle en ajoutant 8 à celle de son bord haut
ld b,a
ld a,(RaquetteY)
cp b ;La compare avec celle du bord haut de la raquette
jr nc,nopaddle ;PaddleY > BalleY+8: pas de collision raquette possible

Si ce test ne passe pas, c'est que la balle se situe forcément dans cette zone

Pour prendre notre décision, on finit pas le test de la limite basse (haut de la balle, bas de la raquette):

 

 

ld hl,BalleY
ld a,(RaquetteY)
add 32 ;Trouve la position du bord bas de la raquette en ajoutant 32 à celle de son bord haut
cp (hl)
jr c,nopaddle ;PaddleY+32 < BalleY: pas de collision raquette possible

 

Si tout ces tests sont passés sans que le programme ne saute au label "nopaddle", il y a un rebond sur la raquette. On joue donc un son plus aigu avec un CALL vers une autre routine, et on inverse la vitesse horizontale.

 

 

call hibeep ;Rebond paddle, bip aigu
ld a,2 ;Vitesse horizontale à 2
ld (VitX),a

Quoi qu'il arrive (rebonds ou pas), la position de la balle peut maintenant être mise à jour au niveau de l'OAM pour que le sprite soit déplacé à l'écran.
On inscrit donc les positions X et Y en OAM.

nopaddle:

ld hl,$FE10 ; $FE00 + 4*4 (Sprite numéro 3)
ld a,(BalleY)
ld (hl),a ; OAM balle Y
inc l
ld a,(BalleX)
ld (hl),a ; OAM balle X

pop hl ;Dépiler HL
pop af ;Dépiler AF
ret ;Retour de CALL

 

 

Les sons (a faire, voir les Pandocs, comme toujours !)

lowbeep:
call setsnd
ld a,%00000000
ldh ($13),a
ld a,%11000111
ldh ($14),a
ret

Test

hibeep:
call setsnd
ld a,%11000000
ldh ($13),a
ld a,%11000111
ldh ($14),a
ret

Test

setsnd:
ld a,%10000000
ldh ($26),a
ld a,%01110111
ldh ($24),a
ld a,%00010001
ldh ($25),a
ld a,%10111000
ldh ($11),a
ld a,%11110000
ldh ($12),a
ret

Test

.ORG $800
Tiles:

 

Assembler la source en lancant "make-gb bpong" pour avoir le ROM bpong.gb.

 

Les graphismes

Pour finir, il faut juste qu'on intègre les données graphiques des 3 tiles dont on a besoin. Pour celà, on va ouvrir notre ROM assemblé dans Tile Layer et nous mettre en mode GameBoy.

On va pouvoir observer le code qu'on vient d'écrire sous forme graphique, c'est cet amas de pixels qui semble aléaoire au tout début du fichier. Comme Tile Layer n'a aucun moyen de savoir si les nombres qu'il lit depuis le fichier sont du programme ou des graphismes, il se content d'afficher tout.

 

 

On va se rendre à l'adresse à laquelle on a fait pointer le label "Tiles", c'est à dire $800, à l'aide de la barre de défilement et de la touche +. L'adresse est indiqué dans la barre d'état, en bas à gauche.

Une fois qu'on est à $800, on peut dessiner nos 3 tiles dans l'ordre.
Ne pas oublier que pour les sprites, la couleur 0 (blanc) est transparente.

 

Il ne reste plus qu'à sauvegarder le fichier et à le lancer dans BGB. Voilà, le jeu est terminé !

footer
symbol symbol symbol symbol symbol