Simple8

Projet complet pas à pas

Voici le chapitre où tout se rejoint. Nous allons écrire un vrai jeu, un casse-briques, et le faire tourner sur sept machines. Les règles du jeu seront écrites une seule fois. L'affichage, lui, sera écrit pour chaque machine, parce que chacune dessine à sa façon. C'est en faisant ce travail qu'on comprend ce qui fait la personnalité d'une console ou d'un micro-ordinateur, et pourquoi les jeux de l'époque se ressemblaient si peu d'une machine à l'autre.

Un jeu, sept machines

Voici le résultat. Chaque image est une capture du programme de ce chapitre, compilé par Simple8 et lancé en émulation. Les règles sont les mêmes partout, au point près : seule la façon de dessiner change.

Le casse-briques sur Commodore 64 : briques en damier de couleurs, murs gris
COMMODORE 64Des caractères de 8 × 8 points pour les briques, deux sprites pour la balle et la raquette. caractèressprites Voir les sources →
Le casse-briques sur PC Engine : briques aux couleurs vives sur fond bleu nuit
PC ENGINEDes tuiles, une palette par couleur de brique, des sprites de 16 × 16 et 32 × 16. tuilessprites Voir les sources →
Le casse-briques sur NES : briques rouges, orange et jaunes, dernière rangée verte et bleue
NESDes tuiles dessinées pour l'occasion, une palette par bloc de 32 × 32 points. tuilessprites Voir les sources →
Le casse-briques sur Game Boy : briques en quatre gris sur fond blanc
GAME BOYQuatre gris, et un écran de 160 × 144 points : exactement le terrain du jeu. tuilesobjets Voir les sources →
Le casse-briques sur Mega Drive : briques rouges, vertes, bleues et roses
MEGA DRIVELes tuiles de la Game Boy, converties en 16 couleurs, chargées quatre fois. tuilessprites Voir les sources →
Le casse-briques sur Amstrad CPC : briques rouges et orange dans un cadre blanc
AMSTRAD CPCUn écran bitmap et rien d'autre : tout est dessiné point par point. bitmapsprites logiciels Voir les sources →
Le casse-briques sur Amiga : briques en dégradé de couleurs sur fond bleu nuit
AMIGAUn bitmap rempli par le Blitter, des sprites matériels, une police dessinée à la main. bitmapsprites Voir les sources →

Chaque version a sa page de sources, avec tous ses fichiers. Les projets sont aussi dans le dossier Examples/ du dépôt. Ouvrez-en un dans Simple8 IDE, compilez, lancez : sans manette, une démo joue toute seule. Touchez la manette, et la main est à vous.

MachineDossierSources
Les règles, communesExamples/common/CasseBriques/dans chaque page
Commodore 64Examples/commodore/c64/CasseBriques_C64/voir
PC EngineExamples/nec/pce/CasseBriques_PCE/voir
NESExamples/nintendo/nes/CasseBriques_NES/voir
Game BoyExamples/nintendo/dmg/CasseBriques_DMG/voir
Mega DriveExamples/sega/megadrive/CasseBriques_MD/voir
Amstrad CPCExamples/amstrad/cpc/CasseBriques_CPC/voir
AmigaExamples/commodore/amiga/CasseBriques_Amiga/voir

Un exemple pour apprendre, pas une référence d'optimisation. Ces sept programmes ne sont pas la meilleure façon d'écrire un casse-briques sur chaque machine. Un programmeur qui viserait la performance sur une seule d'entre elles ferait d'autres choix : plus d'assembleur, des routines taillées pour son circuit vidéo, des données préparées à l'avance. Le but ici est pédagogique : montrer comment on aborde la conception d'un jeu selon l'architecture, et comment on le développe avec Simple8. Ces exemples ne sont évidemment pas parfaits, mais ils ont l'avantage d'être courts, lisibles et comparables d'une machine à l'autre.

Les règles du jeu

Avant d'écrire une ligne, on fixe ce que le jeu doit faire. C'est le cœur d'un Arkanoid, sans les bonus :

Tout ce qu'on voudra ajouter plus tard (bonus, niveaux, son) se greffera sur ce cœur.

L'architecture : des règles communes, un écran par machine

Le piège serait d'écrire sept jeux. On sépare plutôt le programme en deux units :

L'unit Jeu, commune, appelle les fonctions de l'unit Ecran ; chaque machine a son propre fichier Ecran.s8u Jeu.s8u les règles, communes : 236 lignes Ecran.placer(raquette, x, y)… Commodore 64 caractères + sprites 102 lignes PC Engine tuiles + sprites 125 lignes NES tuiles + sprites 116 lignes Game Boy tuiles + objets 87 lignes Mega Drive tuiles + sprites 115 lignes Amstrad CPC bitmap sprites logiciels 111 lignes Amiga bitmap + sprites 161 lignes
Jeu ne connaît que les fonctions d'Ecran ; chaque machine les écrit à sa façon.

Le contrat de l'unit Ecran

Jeu appelle huit fonctions d'Ecran. Chaque Ecran.s8u doit les définir, avec les mêmes paramètres. C'est un contrat, comme une interface en Java : s'il en manque une, le compilateur refuse le programme (« Function 'message' not found »).

FonctionRôle
init()préparer l'écran : couleurs, motifs, murs, sprites
attendreImage()attendre l'image suivante : c'est elle qui donne le rythme du jeu
manette()rendre les touches : Jeu.GAUCHE, Jeu.DROITE, Jeu.BOUTON
dessinerBriques()dessiner les 50 briques
effacerBrique(c, l)effacer la brique de la colonne c, rangée l
placer(raquette, x, y)montrer la raquette et la balle à leur place
afficherInfos()écrire le score et les vies
message(texte)écrire « BRAVO » ou « PERDU » au milieu, 0 pour l'effacer

Les coordonnées passées à Ecran sont celles du terrain, de 0 à 159 et de 0 à 143. Chaque machine place ce terrain où elle veut sur son écran : elle ajoute simplement un décalage (X0, Y0).

Le programme principal

Le main.s8p de chaque machine est minuscule. Voici celui du Commodore 64 :

// Casse-briques — Commodore 64
//
// Les règles sont dans Examples/common/CasseBriques/Jeu.s8u, les mêmes sur toutes les machines.
// Ici, l'affichage du C64 : les briques en caractères, la balle et la raquette en sprites.
// Joystick en port 2 : gauche, droite, FEU pour lancer la balle. Sans joystick, la démo joue.
architecture commodore.c64;

addlib event.irq;
addlib vic.base;
addlib vic.screentext;
addlib vic.sprite;
addlib io.base;

addunit "../../../common/CasseBriques/Jeu.s8u";   // les règles, communes
addunit "Ecran.s8u";                                // l'affichage du C64

unit {
    main() {
        Jeu.jouer();
    }

    // l'interruption de l'image : elle rythme Ecran.attendreImage()
    interrupt -> irq {
    }
}

Les bibliothèques changent d'une machine à l'autre, le reste ne change presque pas. Le chemin ../../../common/ va chercher les règles dans le dossier commun : il n'en existe qu'un exemplaire.

Pourquoi Jeu est ajouté avant Ecran ? Ecran lit des variables de Jeu : Jeu.vies, et l'adresse du texte du score, @Jeu.texteScore. Avec le compilateur actuel, une unit ne peut prendre l'adresse d'une variable que dans une unit ajoutée avant elle. Les appels de fonctions, eux, se résolvent dans n'importe quel ordre : Jeu peut appeler Ecran, ajouté après lui.

La boucle de jeu

Un jeu vidéo est une boucle : attendre l'image, lire la manette, faire avancer le monde d'un pas, montrer le résultat. La nôtre tient en quelques lignes :

// La boucle du jeu : appelée une fois par main(), ne revient jamais
void jouer() {
    Ecran.init();
    nouvellePartie();
    while (1) {
        Ecran.attendreImage();
        image(Ecran.manette());
        Ecran.placer((ubyte).raquetteX, (ubyte).balleX, (ubyte).balleY);
    }
}

Et voici une image de jeu. La balle a deux états : collée à la raquette, ou en jeu.

// Une image de jeu
void image(ubyte touches) {
    bougerRaquette(touches);
    if (.collee == 1) {
        coller();
        if (.attente > 0) {
            .attente--;
        }
        if ((touches & .BOUTON) != 0 || .attente == 0) {
            .collee = 0;
            .dx = 1;
            .dy = -2;
        }
        return;
    }
    deplacerBalle();
    if (.balleY >= .HAUTEUR) {
        ballePerdue();
    }
}

Toutes les vitesses sont en points par image : la raquette avance de 2, la balle de 1 ou 2 en x et de 2 en y. Pas de virgule, pas de calcul de temps : le jeu est réglé sur l'image. Conséquence directe : il va un peu plus vite sur les machines à 60 images par seconde (PC Engine, NES, Game Boy, Mega Drive japonaise ou américaine) que sur les machines européennes à 50 (Commodore 64, Amstrad CPC, Amiga). Beaucoup de jeux de l'époque avaient ce défaut.

Attendre l'image

attendreImage() est la fonction la plus importante d'Ecran : elle bloque jusqu'au retour vertical, le moment où le faisceau remonte en haut de l'écran (chapitre 12). Chaque machine le fait à sa façon :

MachineattendreImage()
Commodore 64irq.waitFrame(), avec un interrupt -> irq vide dans main.s8p
PC Engineattendre que Main.images change ; interrupt -> irq1 l'augmente à chaque image
NESppu.waitFrame() : les tuiles changées pendant l'image sont posées à ce moment-là
Game Boyattendre Main.images (interrupt -> vblank), puis recopier les objets par DMA
Mega Drivevdp.waitVBlank()
Amstrad CPCattendre Main.images (interrupt -> int, appelé une fois par image)
Amigaocs.frame()

Le terrain et sa grille

Pourquoi 160 × 144 ?

C'est l'écran de la Game Boy, le plus petit des sept. Un terrain de cette taille tient donc partout, et la Game Boy l'affiche sans rien couper.

Les briques font 16 × 8 points. Ce n'est pas un hasard : 16 et 8 sont des puissances de deux (chapitre 13). Diviser par 16, c'est décaler de 4 bits vers la droite ; diviser par 8, décaler de 3. Sur un 6502 ou un Z80, qui n'ont pas d'instruction de division, la différence est énorme. Et une brique fait exactement deux tuiles de 8 × 8 sur les machines à tuiles.

Du point à la brique

Les briques sont rangées dans un tableau de 50 octets, briques : 1 si la brique est là, 0 si elle est cassée. Pour savoir quelle brique se trouve sous un point (x, y) du terrain, il suffit d'un petit calcul :

Le terrain de 160 par 144 points et ses 50 briques ; le point (70, 37) tombe dans la colonne 4 et la rangée 2, la brique numéro 24 0 32 64 96 128 160 16 24 32 40 48 56 (70, 37) Quelle brique sous le point (70, 37) ? colonne = 70 >> 4 = 4 rangée = (37 - 16) >> 3 = 2 n = DEBUT_LIGNE[2] + 4 = 24 briques[24] vaut 1 : touchée Aucune lecture de l'écran : deux décalages, une table et une case de tableau. Le même calcul sur les sept machines.
La brique sous un point se calcule, elle ne se cherche pas.
// La brique sous le point (x, y), ou 255 s'il n'y en a pas
ubyte briqueEn(int x, int y) {
    ubyte n;
    if (y < .HAUT_BRIQUES || y >= .BAS_BRIQUES || x < 0 || x >= .LARGEUR) {
        return 255;
    }
    .colonneTouchee = (ubyte)(x >> 4);                     // x / 16
    .ligneTouchee = (ubyte)((y - .HAUT_BRIQUES) >> 3);     // (y - 16) / 8
    n = (ubyte)(.DEBUT_LIGNE[.ligneTouchee] + .colonneTouchee);
    if (.briques[n] == 0) {
        return 255;
    }
    return n;
}

La multiplication rangée × 10 est remplacée par une table, DEBUT_LIGNE[] = { 0, 10, 20, 30, 40 } : 10 n'est pas une puissance de deux, et lire une table est plus rapide que multiplier. C'est une recette du chapitre 13.

La balle

Un axe à la fois

La balle avance de dx en x et de dy en y. Si l'on déplaçait les deux à la fois, une balle qui arrive dans un coin de brique poserait une question difficile : a-t-elle touché par le côté (il faut inverser dx) ou par dessous (il faut inverser dy) ?

On évite la question en avançant d'abord en x, en regardant ce qu'on touche, puis en y, en regardant encore. Chaque test ne concerne qu'une direction : un contact en x inverse dx, un contact en y inverse dy. C'est simple, et dans un casse-briques, ça se voit à peine.

Le bord qui avance

Inutile de tester les 16 points de la balle. Quand elle va vers la droite, seul son bord droit peut entrer dans une brique ; quand elle monte, seul son bord haut. On teste les deux coins de ce bord : une brique fait au moins 8 points, elle ne peut pas passer entre deux coins distants de 4.

La balle de 4 points sur 4 : en x, vers la droite, on teste les deux points du bord droit ; en y, vers le haut, les deux points du bord haut En x : la balle va vers la droite brique ? dx 2 points : en haut et en bas du bord droit En y : la balle monte brique ? dy 2 points : à gauche et à droite du bord haut
Deux points par axe, quatre par image, quelle que soit la machine.
// La balle : on avance en x, puis en y, et on regarde à chaque fois ce que touche le bord
// de la balle qui avance
void deplacerBalle() {
    int x = .balleX + .dx;
    int y;
    int bord;

    // les murs de gauche et de droite
    if (x < 0) {
        x = 0;
        .dx = -.dx;
    } else if (x > .LARGEUR - .BALLE) {
        x = .LARGEUR - .BALLE;
        .dx = -.dx;
    }
    // les briques, en x : le bord gauche ou droit de la balle, en haut et en bas
    bord = x;
    if (.dx > 0) {
        bord = x + .BALLE - 1;
    }
    if (toucher(bord, .balleY) != 0 || toucher(bord, .balleY + .BALLE - 1) != 0) {
        .dx = -.dx;
        x = .balleX;
    }
    .balleX = x;

    // le plafond
    y = .balleY + .dy;
    if (y < 0) {
        y = 0;
        .dy = -.dy;
    }
    // les briques, en y : le bord haut ou bas de la balle, à gauche et à droite
    bord = y;
    if (.dy > 0) {
        bord = y + .BALLE - 1;
    }
    if (toucher(.balleX, bord) != 0 || toucher(.balleX + .BALLE - 1, bord) != 0) {
        .dy = -.dy;
        y = .balleY;
    }
    .balleY = y;

    rebondRaquette();
}

Remarquez l'ordre : on calcule la position future, on teste, et si la balle touche, on la laisse à sa place en inversant sa vitesse. La balle n'entre jamais dans une brique, et ne se coince jamais dans un mur.

toucher() fait tout le travail d'une brique cassée : la retirer du tableau, compter les points, demander à Ecran de l'effacer, et vérifier s'il en reste.

// Casse la brique sous le point (x, y) s'il y en a une ; rend 1 si la balle a touché
ubyte toucher(int x, int y) {
    ubyte n = briqueEn(x, y);
    if (n == 255) {
        return 0;
    }
    .briques[n] = 0;
    .restantes--;
    .score = .score + .POINTS[.ligneTouchee];
    Ecran.effacerBrique(.colonneTouchee, .ligneTouchee);
    majInfos();
    if (.restantes == 0) {
        gagne();
    }
    return 1;
}

Le rebond sur la raquette

Si la balle repartait toujours avec le même angle, le joueur ne pourrait pas viser. On coupe la raquette en quatre zones de 6 points : plus la balle touche près du bord, plus elle repart en biais.

La raquette de 24 points est coupée en 4 zones de 6 points ; selon la zone touchée, la balle repart avec dx = -2, -1, +1 ou +2 dx = −2 0 à 5 dx = −1 6 à 11 dx = +1 12 à 17 dx = +2 18 à 23 raquette milieu de la balle dy = −2 dans tous les cas
L'endroit touché donne l'angle.
// La raquette : la balle descend et chevauche la raquette
void rebondRaquette() {
    int zone;
    if (.dy < 0) {
        return;
    }
    if (.balleY + .BALLE < .RAQUETTE_Y || .balleY > .RAQUETTE_Y + .RAQUETTE_H) {
        return;
    }
    if (.balleX + .BALLE <= .raquetteX || .balleX >= .raquetteX + .RAQUETTE_L) {
        return;
    }
    // l'endroit touché donne l'angle : le milieu de la balle sur l'une des 4 zones de
    // 6 points de la raquette. Trois comparaisons, pas de division par 6
    zone = .balleX + 2 - .raquetteX;
    if (zone < 6) {
        .dx = -2;
    } else if (zone < 12) {
        .dx = -1;
    } else if (zone < 18) {
        .dx = 1;
    } else {
        .dx = 2;
    }
    .dy = -2;
    .balleY = .RAQUETTE_Y - .BALLE;
    .visee++;
    if (.visee == 6) {
        .visee = 0;
    }
}

Trois comparaisons suffisent : pas besoin de diviser par 6. Le test du début (.dy < 0) évite qu'une balle qui remonte rebondisse une seconde fois sur la raquette.

La démo

Une démo qui place toujours la balle au même endroit de la raquette la renvoie toujours avec le même angle. Pendant la mise au point de ce chapitre, avant le rebond actuel, cela a suffi pour que la balle tourne en rond dans une zone déjà vide : le score ne bougeait plus. La nôtre vise donc un endroit différent de la raquette à chaque rebond, pris dans une table :

// La raquette : les touches, ou la démo quand personne ne joue
void bougerRaquette(ubyte touches) {
    if (touches != 0) {
        .inactif = 0;
    } else if (.inactif < 255) {
        .inactif++;
    }
    if (.inactif > 120) {
        // la démo : la raquette suit la balle
        int cible = .balleX + 2 - .VISEES[.visee];
        if (.raquetteX + 12 < cible - 1) {
            touches = .DROITE;
        } else if (.raquetteX + 12 > cible + 1) {
            touches = .GAUCHE;
        }
    }
    if ((touches & .GAUCHE) != 0 && .raquetteX > 0) {
        .raquetteX = .raquetteX - 2;
    }
    if ((touches & .DROITE) != 0 && .raquetteX < .LARGEUR - .RAQUETTE_L) {
        .raquetteX = .raquetteX + 2;
    }
}

Avec VISEES[] = { -9, 6, -3, 9, -6, 3 }, la raquette se présente tantôt à gauche, tantôt à droite de la balle : les rebonds successifs partent dans des directions différentes. Une démo n'a pas besoin d'être intelligente, juste variée.

Trois façons de détecter une collision

Détecter qu'une balle touche une brique est la question centrale d'un casse-briques. Il y a trois grandes méthodes, et toutes ont servi à l'époque.

1. Calculer dans une grille

C'est la méthode de notre jeu. Le programme connaît la position de chaque brique dans un tableau, et calcule ce qu'il y a sous un point. Elle ne lit jamais l'écran :

Sa limite : elle suppose un décor rangé dans une grille. Pour un décor de formes libres, il faut autre chose.

2. Lire l'écran

Sur un écran bitmap, on peut regarder la couleur du point vers lequel la balle avance. Sur Amstrad CPC, gfx.getPixel rend le pen d'un point :

// le pen du point juste au-dessus de la balle : 0, rien ; sinon, une brique ou un mur
ubyte devant = gfx.getPixel(.X0 + x + 1, .Y0 + y - 1);

C'est tentant, mais la couleur ne dit pas quelle brique est touchée : pour l'effacer, il faut de toute façon calculer sa colonne et sa rangée. Il faut aussi que chaque chose ait sa couleur (une brique orange et le texte orange se confondent), et lire un point coûte plus cher que lire une case de tableau.

Sur les machines à tuiles, l'équivalent serait de lire la carte de tuiles. Mais elle est rangée dans la mémoire vidéo, que le processeur ne lit qu'à travers les ports du circuit vidéo (PC Engine, NES, Mega Drive), parfois seulement pendant le retour vertical (NES, Game Boy). La solution de l'époque était de garder une copie de la carte en mémoire vive… c'est exactement notre tableau briques.

3. Les collisions matérielles

Certains circuits vidéo détectent eux-mêmes les contacts entre sprites, ou entre un sprite et le décor. Sur Commodore 64, le VIC-II met à 1, pour chaque sprite, un bit dans $D01E (contact avec un autre sprite) et dans $D01F (contact avec le décor) :

ubyte contact = readMem(0xD01F);        // un bit par sprite ; la lecture remet à 0
if ((contact & (1 << .spriteBalle)) != 0) {
    // la balle a touché un caractère du décor
}
MachineCollisions matérielles
Commodore 64sprite contre sprite, sprite contre décor, un bit par sprite
Amigasprites et plans de l'écran, par paires (registre CLXDAT) ; sprite.hit() compare deux bobs au point près avec le Blitter
PC Enginele sprite 0 contre les autres sprites, un seul bit
Mega Drivedeux sprites qui se chevauchent, un seul bit, sans dire lesquels
NES, Game Boy, Amstrad CPCrien d'utilisable pour le jeu

Elles sont précises au point près, et gratuites pour le processeur. Mais elles arrivent après coup : le circuit signale un chevauchement une fois l'image dessinée, donc quand la balle est déjà dans la brique. Elles ne disent pas non plus quelle brique est touchée, et n'existent pas sur toutes les machines.

Laquelle choisir ?

MéthodePourContre
La grillerapide, prévoit le contact, dit quelle brique, la même partoutun décor rangé en grille
Lire l'écranmarche pour n'importe quelle formene dit pas quel objet ; mémoire vidéo lente ou inaccessible
Le matérielprécis au point, gratuit pour le processeuraprès coup, rarement le détail, pas partout

Dans un vrai jeu, on les combine souvent : la grille pour le décor, des rectangles qui se chevauchent pour les objets entre eux, et le test au point près (matériel ou Blitter) seulement quand les rectangles se touchent.

Tuiles, bitmap et sprites

Les règles sont écrites. Reste à dessiner, et c'est là que les machines se séparent.

Les tuiles

Sur une machine à tuiles (Commodore 64 en mode texte, PC Engine, NES, Game Boy, Mega Drive), l'écran n'est pas une grille de points mais une grille de cases de 8 × 8. Chaque case contient le numéro d'un motif, dessiné une fois pour toutes en mémoire vidéo. Pour afficher une brique de 16 × 8, on écrit deux numéros. Pour l'effacer, deux autres. Le circuit vidéo fait le reste à chaque image.

C'est économique, et parfaitement adapté à un décor en grille comme un mur de briques. La contrainte : tout ce qui est dans le décor doit tomber sur la grille des cases.

Le bitmap

Sur une machine bitmap (Amstrad CPC, Amiga), chaque point de l'écran a sa place en mémoire. On peut dessiner n'importe quoi, n'importe où : des courbes, des polygones, des dégradés. Mais chaque point se paie : une brique de 16 × 8, ce sont 128 points à écrire.

Octets écrits pour effacer une brique : 2 à 4 avec des tuiles, 32 sur Amstrad CPC et 64 sur Amiga en bitmap Commodore 64 4 octets 2 caractères + leurs 2 couleurs PC Engine 4 octets 2 cases de la BAT (2 mots) NES 2 octets 2 cases de la nametable Game Boy 2 octets 2 cases du plan Mega Drive 4 octets 2 cases du plan A (2 mots) Amstrad CPC 32 octets 16 × 8 points, 4 points par octet Amiga 64 octets 16 × 8 points × 4 plans, par le Blitter
Ce que le programme écrit en mémoire vidéo pour effacer une brique.

L'Amiga compense par son Blitter, un circuit qui remplit et copie des rectangles pendant que le processeur fait autre chose. L'Amstrad CPC n'a que son Z80 : c'est le processeur qui écrit chaque octet.

Les sprites matériels

Un sprite est un petit dessin que le circuit vidéo superpose à l'écran, à la position qu'on lui donne. Pour le déplacer, on change deux nombres. Rien à effacer, rien à redessiner, et le décor dessous reste intact. C'est idéal pour ce qui bouge : la balle et la raquette.

Chaque machine a ses limites : 8 sprites de 24 × 21 sur Commodore 64, 64 sprites de 8 × 8 sur NES, 40 objets sur Game Boy, 64 sprites jusqu'à 32 × 64 sur PC Engine, 80 sur Mega Drive, 8 sprites de 16 points de large sur Amiga. Une raquette de 24 points demande donc un sprite sur Commodore 64, trois sur NES et Game Boy, deux sur Amiga.

Les sprites logiciels

L'Amstrad CPC n'a pas de sprites. Il faut les imiter : à chaque image, effacer l'ancienne place, puis dessiner la nouvelle. C'est ce qu'on appelle un sprite logiciel.

Un sprite matériel se déplace en changeant sa position ; un sprite logiciel doit être effacé à son ancienne place puis redessiné à la nouvelle Sprite matériel x = 130, y = 40 on change deux nombres : le circuit vidéo dessine Sprite logiciel (Amstrad CPC) 1. effacer 2. dessiner le programme repeint l'ancienne place, puis dessine la nouvelle
Le même déplacement, fait par le circuit vidéo ou par le programme.

Sur un fond noir, effacer revient à repeindre en noir. Sur un décor dessiné, il faudrait d'abord sauver ce qui est sous le sprite, pour le remettre ensuite : c'est ce que font les « bobs » de l'Amiga (video.sprite), avec l'aide du Blitter.

Machine par machine

Chaque Ecran.s8u fait entre 87 et 161 lignes. Voici, pour chaque machine, ce qui a guidé ses choix.

Commodore 64

Le casse-briques sur Commodore 64
Briques
deux espaces en inversion vidéo : deux caractères pleins
Couleurs
une par case (la mémoire couleur) : un damier de deux teintes par rangée
Balle, raquette
deux sprites matériels de 24 × 21
Texte
la police du Commodore, directement
Sources
tous les fichiers du projet

Le mode texte du C64 est déjà une machine à tuiles : 40 × 25 cases, chacune avec sa couleur. Un espace en inversion vidéo est un carré plein de la couleur du texte. Une brique, ce sont donc deux espaces inversés, sans dessiner un seul motif :

// Une brique = deux espaces en inversion vidéo, de la couleur de sa rangée
void dessinerBriques() {
    for (ubyte l = 0; l < 5; l++) {
        for (ubyte c = 0; c < 10; c++) {
            if (((c + l) & 1) == 0) {
                vic.setFontColor(.COULEUR_A[l]);
            } else {
                vic.setFontColor(.COULEUR_B[l]);
            }
            vic.setCursor((ubyte)(.COL0 + c * 2), (ubyte)(.LIG0 + 2 + l));
            vic.setInvertOn();
            vic.print("  ");
            vic.setInvertOff();
        }
    }
}

La balle et la raquette sont des sprites : deux appels à moveSprite par image, et c'est tout.

// Les sprites suivent le jeu : deux appels, rien à effacer
void placer(ubyte raquette, ubyte x, ubyte y) {
    sprite.moveSprite(.spriteRaquette, .X0 + raquette, .Y0 + Jeu.RAQUETTE_Y);
    sprite.moveSprite(.spriteBalle, .X0 + x, .Y0 + y);
}

PC Engine

Le casse-briques sur PC Engine
Briques
des tuiles pleines de la BAT
Couleurs
une palette de tuiles par couleur de brique (1 à 10), une pour les murs
Balle, raquette
deux sprites, de 16 × 16 et 32 × 16
Texte
une police chargée en mémoire vidéo, depuis une ressource
Sources
tous les fichiers du projet

La PC Engine a 16 palettes de 16 couleurs pour les tuiles : on peut s'offrir une palette par couleur de brique, et choisir la couleur d'une brique en choisissant sa palette. Elle n'a pas de police en ROM : police.bin est ajoutée par addResource et copiée en mémoire vidéo au démarrage.

Ses sprites lisent leurs motifs en mémoire vidéo : on les y copie, puis on crée les sprites en donnant l'adresse du motif et leur taille.

// les motifs des sprites, copiés en mémoire vidéo
vdc.writeToVRAM(@.MOTIF_BALLE, .VRAM_BALLE, 128);
vdc.writeToVRAM(@.MOTIF_RAQUETTE, .VRAM_RAQUETTE, 256);
sprite.initSprite();
sprite.setSpritePriority(sprite.SPRITE_PRIORITY_FG);
sprite.setSpriteSize(sprite.SPRITE_16x16);
sprite.setSpritePalette(0);
.spriteBalle = sprite.createSprite(0, -64, .VRAM_BALLE);
sprite.setSpriteSize(sprite.SPRITE_32x16);
sprite.setSpritePalette(1);
.spriteRaquette = sprite.createSprite(0, -64, .VRAM_RAQUETTE);

NES

Le casse-briques sur NES
Briques
six tuiles dessinées : deux moitiés de brique en trois couleurs
Couleurs
une palette de 3 couleurs par bloc de 32 × 32 points
Balle, raquette
4 sprites de 8 × 8 : 1 pour la balle, 3 pour la raquette
Texte
la police de ppu.screentext
Sources
tous les fichiers du projet

La NES impose la contrainte la plus forte : la palette ne se choisit pas par case, mais par bloc de 32 × 32 points (4 × 4 cases). Quatre rangées de briques partagent donc la même palette de 3 couleurs. L'astuce : mettre la couleur dans la tuile elle-même (une tuile de couleur 1, une de couleur 2, une de couleur 3), et aligner le terrain sur les blocs pour que les murs aient les leurs.

// une palette par bloc de 32 x 32 points : les blocs des briques
for (byte bx = 2; bx < 7; bx++) {
    ppu.setAreaPalette(bx, 2, 1);
    ppu.setAreaPalette(bx, 3, 2);
}

Autre contrainte : on n'écrit dans la carte de tuiles que pendant le retour vertical. Pour poser les 100 tuiles du mur d'un coup, dessinerBriques() éteint l'écran (ppu.off()) ; en cours de jeu, ppu.putTile met les tuiles en attente, et ppu.waitFrame() les pose au retour vertical.

Game Boy

Le casse-briques sur Game Boy
Briques
six tuiles dessinées, d'un gris par rangée
Couleurs
4 gris en tout : le blanc du fond, le noir du texte, deux gris
Balle, raquette
4 objets de 8 × 8
Texte
une police générée par tools/police.py (Police.s8u)
Sources
tous les fichiers du projet

L'écran fait exactement 160 × 144 points : le terrain occupe tout l'écran, le score s'écrit sur la première ligne. Avec 4 gris, les rangées se distinguent par leur teinte, sans plus.

Ici, les objets (les sprites de la Game Boy) ne s'écrivent pas directement : le programme remplit une copie de leur table en mémoire vive, et le DMA la recopie d'un coup pendant le retour vertical.

// Un objet dans la copie de la table : y + 16, x + 8, tuile, attributs
void objet(ubyte n, ubyte x, ubyte y, ubyte tuile) {
    ubyte i = (ubyte)(n * 4);
    oam.shadow[i] = (ubyte)(y + 16);
    oam.shadow[i + 1] = (ubyte)(x + 8);
    oam.shadow[i + 2] = tuile;
    oam.shadow[i + 3] = 0;
}

// Attendre le retour vertical, puis y recopier les objets par le DMA
void attendreImage() {
    ubyte avant = Main.images;
    while (Main.images == avant) {
    }
    oam.flush();
}

Mega Drive

Le casse-briques sur Mega Drive
Briques
les tuiles de la Game Boy, converties en 16 couleurs
Couleurs
4 jeux de 3 couleurs dans la palette 0
Balle, raquette
deux sprites : 8 × 8, et 24 × 8 (trois tuiles)
Texte
la police de vdp.loadFont
Sources
tous les fichiers du projet

La Mega Drive a des tuiles de 16 couleurs (4 bits par point). Plutôt que de redessiner les briques, on reprend les tuiles de la Game Boy (2 bits par point) : vdp.loadTiles2bpp les convertit en décalant leurs couleurs. Les mêmes 6 tuiles, chargées 4 fois avec un décalage de 0, 4, 8 et 12, donnent 4 jeux de couleurs :

for (ubyte jeu = 0; jeu < 4; jeu++) {
    vdp.loadTiles2bpp(@.TUILES_BRIQUES, (uint)(.BRIQUES + jeu * 6), 6, (ubyte)(jeu * 4), 0);
}

Ses sprites forment une liste chaînée : chacun indique le suivant. La balle (0) mène à la raquette (1), qui termine la liste. Un sprite de la Mega Drive peut faire jusqu'à 32 × 32 points : la raquette tient en un seul.

// Deux sprites chaînés : la balle (0), puis la raquette (1), la fin de la liste
void placer(ubyte raquette, ubyte x, ubyte y) {
    vdp.setSprite(0, .X0 + x, .Y0 + y, 0, 1, vdp.cell(.BALLE, 2, 0));
    vdp.setSprite(1, .X0 + raquette, .Y0 + Jeu.RAQUETTE_Y, 8, 0, vdp.cell(.RAQUETTE, 2, 0));
}

Amstrad CPC

Le casse-briques sur Amstrad CPC
Briques
des rectangles pleins, gfx.setFillRect
Couleurs
4 pens en mode 1 : noir, rouge, orange, blanc
Balle, raquette
des sprites logiciels : effacer, puis redessiner
Texte
gfx.setText, sur une zone d'abord repeinte en noir
Sources
tous les fichiers du projet

Le CPC est la machine la plus dépouillée : un écran bitmap, et c'est tout. En mode 1 (320 × 200, 4 couleurs), il reste deux couleurs pour les briques, une fois le noir et le blanc pris. La balle et la raquette sont des sprites logiciels, et on ne les redessine que si elles ont bougé :

// Les sprites logiciels : effacer l'ancienne place, dessiner la nouvelle
void placer(ubyte raquette, ubyte x, ubyte y) {
    int ry = .Y0 + Jeu.RAQUETTE_Y;
    if (raquette != .ancienneRaquette) {
        if (.ancienneRaquette >= 0) {
            gfx.setPen(.NOIR);
            gfx.setFillRect(.X0 + .ancienneRaquette, ry, .X0 + .ancienneRaquette + 23, ry + 3);
        }
        gfx.setPen(.BLANC);
        gfx.setFillRect(.X0 + raquette, ry, .X0 + raquette + 23, ry + 3);
        .ancienneRaquette = raquette;
    }
    if (x != .ancienneBalleX || y != .ancienneBalleY) {
        if (.ancienneBalleX >= 0) {
            gfx.setPen(.NOIR);
            gfx.setFillRect(.X0 + .ancienneBalleX, .Y0 + .ancienneBalleY,
                            .X0 + .ancienneBalleX + 3, .Y0 + .ancienneBalleY + 3);
        }
        if (y < Jeu.HAUTEUR) {
            gfx.setPen(.BLANC);
            gfx.setFillRect(.X0 + x, .Y0 + y, .X0 + x + 3, .Y0 + y + 3);
        }
        .ancienneBalleX = x;
        .ancienneBalleY = y;
    }
}

Même le texte demande de l'attention : en bitmap, écrire un chiffre par-dessus un autre superpose les deux. On repeint donc la zone du score en noir avant de l'écrire.

Amiga

Le casse-briques sur Amiga
Briques
des rectangles posés par le Blitter, avec une ligne de reflet
Couleurs
16 couleurs (4 plans), chacune choisie parmi 4 096
Balle, raquette
trois sprites matériels : 1 pour la balle, 2 pour la raquette
Texte
une police de 3 × 5 points, dessinée en petits carrés
Sources
tous les fichiers du projet

L'Amiga réunit le meilleur des deux mondes : un écran bitmap, que le Blitter remplit vite, et de vrais sprites. Avec 16 couleurs, chaque brique peut avoir une ligne plus claire en haut, un reflet. Les sprites sont décrits par une liste en mémoire : leurs lignes sont écrites une fois, au démarrage, puis seuls leurs deux mots de contrôle (leur position) changent à chaque image.

// Les sprites : seulement leurs mots de contrôle
void placer(ubyte raquette, ubyte x, ubyte y) {
    int rx = .X0 + raquette;
    ocs.spritePosition(ocs.sprite(0, 0), .X0 + x, (uint)(.Y0 + y), 4, 0);
    ocs.spritePosition(ocs.sprite(0, 2), rx, (uint)(.Y0 + Jeu.RAQUETTE_Y), 4, 0);
    ocs.spritePosition(ocs.sprite(0, 3), rx + 16, (uint)(.Y0 + Jeu.RAQUETTE_Y), 4, 0);
}

Sans le système d'exploitation, l'Amiga n'a pas de police à portée de main. Qu'à cela ne tienne : 5 rangées de 3 points par caractère, chaque point dessiné comme un carré de 2 × 2 :

// Un caractère de la police, en carrés de 2 x 2 ; l'espace ne dessine rien
void lettre(int x, int y, ubyte c) {
    if (c < '0' || c > 'Z') {
        return;
    }
    uint g = (uint)(c - '0') * 5;
    for (ubyte r = 0; r < 5; r++) {
        ubyte bits = .POLICE[g + r];
        for (ubyte k = 0; k < 3; k++) {
            if ((bits & (4 >> k)) != 0) {
                bitmap.rect(x + (int)k * 2, y + (int)r * 2, 2, 2, .TEXTE);
            }
        }
    }
}

Le bilan

MachineBriquesBalle et raquetteEcran.s8uProgramme
Commodore 64caractères2 sprites102 lignes5,4 Ko (.prg)
PC Enginetuiles2 sprites125 lignesHuCard de 16 Ko
NEStuiles4 sprites116 lignescartouche de 32 Ko
Game Boytuiles4 objets87 lignescartouche de 32 Ko
Mega Drivetuiles2 sprites115 lignescartouche de 128 Ko
Amstrad CPCbitmapsprites logiciels111 lignes7,9 Ko (sur disquette)
Amigabitmap (Blitter)3 sprites161 lignes67 Ko (disquette ADF)

Les règles, elles, font 236 lignes, écrites une seule fois. Une cartouche a une taille standard : le jeu lui-même en occupe bien moins.

Ce que montre ce projet. Simple8 ne cache pas les machines : sur chacune, on a utilisé ce qu'elle fait de mieux, les tuiles là où il y en a, les sprites là où il y en a, le Blitter sur Amiga. Ce que Simple8 apporte, c'est un langage commun pour la partie qui ne dépend pas du matériel. Et dans un jeu, c'est souvent la plus grosse.

Pour aller plus loin

Le jeu est volontairement minimal. Voici des pistes, de la plus simple à la plus ambitieuse :

À retenir

À vous de jouer

  1. Changez les valeurs de POINTS[] et de VISEES[], et regardez la démo jouer autrement sur deux machines.
    Voir la solutionMasquer la solution

    Les deux tables sont dans Jeu.s8u : une seule modification vaut pour les sept machines. On a essayé trois réglages, compilés pour Commodore 64 et PC Engine, et laissé la démo jouer seule trois minutes dans les émulateurs du kit de test.

    POINTS[] : les rangées du bas valent plus

    const uint POINTS[] = { 10, 20, 30, 40, 50 };        // les rangées du bas valent plus

    La démo joue exactement la même partie : le premier mur tombe à 98,1 s sur C64 et 81,6 s sur PC Engine, comme avec les valeurs d'origine. POINTS[] ne sert qu'au score. Seul le score en cours change (3 200 au lieu de 3 040 sur C64 après 3 minutes) ; un mur complet, lui, rapporte toujours 1 500 points, puisque la somme des cinq valeurs est la même.

    VISEES[] : viser toujours le milieu

    const byte VISEES[] = { 0, 0, 0, 0, 0, 0 };

    La raquette se place sous la balle, qui repart toujours avec le même petit angle. Le début est plus lent (830 points à 40 s sur C64, contre 1 170), mais la fin bien plus rapide : le premier mur tombe à 69,1 s au lieu de 98,1 s sur C64, à 57,5 s au lieu de 81,6 s sur PC Engine. Avec les valeurs d'origine, les dernières briques sont longues à trouver ; ici, la balle les atteint mieux. La démo est donc moins variée, mais pas forcément moins efficace : il faut le mesurer pour le savoir.

    VISEES[] : viser les extrémités de la raquette

    const byte VISEES[] = { -11, 11, -11, 11, -11, 11 };

    Cette fois, la démo perd sans arrêt : une vie toutes les 3 à 7 secondes sur les deux machines, et presque aucun point. La balle ne touche plus que les derniers points de la raquette, et la cible saute de 22 points d'un rebond à l'autre ; or la raquette avance de 2 points par image, pas plus vite qu'une balle lancée en biais : elle arrive souvent trop tard.

    Deux règles à retenir. Une visée doit rester dans la raquette : entre -11 et 11, puisque la raquette fait 24 points. Et la table doit garder 6 valeurs : rebondRaquette() remet visee à 0 quand il atteint 6. Pour une autre taille, il faut changer ce 6 aussi.

    Trois parties de la démo sur C64 au même instant
    La démo sur C64 (VICE), environ 40 secondes après le démarrage : les visées d'origine, puis toutes à 0, puis aux extrémités de la raquette.
  2. Ajoutez des briques solides, qui demandent deux coups.
    Voir la solutionMasquer la solution

    Le tableau briques ne dit plus seulement si une brique est là : il compte les coups qu'il faut encore pour la casser. 0, elle est cassée ; 1, une brique normale ; 2, une brique solide. Le reste de Jeu continue de marcher sans changement : briqueEn() teste toujours briques[n] == 0, et restantes compte toujours des briques, pas des coups. Ici, la rangée du haut est solide ; une petite table donne la solidité de chaque rangée.

    Dans Jeu.s8u

    const ubyte SOLIDITE[] = { 2, 1, 1, 1, 1 };          // les coups pour casser une brique

    Les deux boucles qui remplissaient le mur (dans nouvellePartie() et dans gagne()) mettaient 1 partout. Elles sont remplacées par un appel à une nouvelle fonction, remplirMur() :

    // Un mur neuf : chaque brique reçoit la solidité de sa rangée
    void remplirMur() {
        ubyte n = 0;
        for (ubyte l = 0; l < .LIGNES; l++) {
            for (ubyte c = 0; c < .COLONNES; c++) {
                .briques[n] = .SOLIDITE[l];
                n++;
            }
        }
        .restantes = 50;
    }

    Dans toucher(), on enlève un coup. S'il en reste, la brique reste debout : pas de points, la balle rebondit (on rend 1), et Ecran change sa couleur.

    ubyte toucher(int x, int y) {
        ubyte n = briqueEn(x, y);
        if (n == 255) {
            return 0;
        }
        .briques[n]--;                  // un coup de moins
        if (.briques[n] != 0) {
            // une brique solide, encore debout : elle change de couleur, la balle rebondit
            Ecran.fissurerBrique(.colonneTouchee, .ligneTouchee);
            return 1;
        }
        .restantes--;
        .score = .score + .POINTS[.ligneTouchee];
        Ecran.effacerBrique(.colonneTouchee, .ligneTouchee);
        majInfos();
        if (.restantes == 0) {
            gagne();
        }
        return 1;
    }

    Jeu appelle maintenant une neuvième fonction d'Ecran, fissurerBrique(c, l). Le contrat s'allonge : chaque Ecran.s8u doit la définir, sinon le programme de cette machine ne compile plus (Function 'fissurerBrique' not found !, vérifié sur la version Amstrad CPC). Il faut aussi que dessinerBriques() dessine les briques solides d'une autre couleur : elle lit Jeu.briques, ce qui est permis, puisque Jeu est ajouté avant Ecran.

    Sur Commodore 64 : une brique solide est blanche ; touchée une fois, elle reprend la couleur de sa rangée.

    // Une brique = deux espaces en inversion vidéo ; une brique solide est blanche
    void dessinerBriques() {
        ubyte n = 0;
        for (ubyte l = 0; l < 5; l++) {
            for (ubyte c = 0; c < 10; c++) {
                if (Jeu.briques[n] == 2) {
                    brique(c, l, vic.WHITE);
                } else {
                    fissurerBrique(c, l);
                }
                n++;
            }
        }
    }
    
    // Une brique de la couleur de sa rangée, en damier
    void fissurerBrique(ubyte c, ubyte l) {
        if (((c + l) & 1) == 0) {
            brique(c, l, .COULEUR_A[l]);
        } else {
            brique(c, l, .COULEUR_B[l]);
        }
    }
    
    void brique(ubyte c, ubyte l, ubyte couleur) {
        vic.setFontColor(couleur);
        vic.setCursor((ubyte)(.COL0 + c * 2), (ubyte)(.LIG0 + 2 + l));
        vic.setInvertOn();
        vic.print("  ");
        vic.setInvertOff();
    }

    Sur PC Engine : la même chose avec une palette de plus, la 12 (les briques utilisent les palettes 1 à 10, les murs la 11), réglée dans init() :

    vce.setPalette(12, 1, RGB(7, 7, 7));    // les briques solides : blanches
    // Une brique = deux tuiles pleines (des espaces en inversion), dans la palette de sa couleur ;
    // une brique solide prend la palette 12
    void dessinerBriques() {
        ubyte n = 0;
        for (ubyte l = 0; l < 5; l++) {
            for (ubyte c = 0; c < 10; c++) {
                if (Jeu.briques[n] == 2) {
                    brique(c, l, 12);
                } else {
                    fissurerBrique(c, l);
                }
                n++;
            }
        }
    }
    
    // Une brique dans la palette de sa rangée, en damier
    void fissurerBrique(ubyte c, ubyte l) {
        brique(c, l, (ubyte)(1 + l * 2 + ((c + l) & 1)));
    }
    
    void brique(ubyte c, ubyte l, ubyte numero) {
        vdc.setFontColor(numero);
        vdc.setCursor((ubyte)(.COL0 + c * 2), (ubyte)(.LIG0 + 2 + l));
        vdc.setInvertOn();
        vdc.print("  ");
        vdc.setInvertOff();
    }

    Attention à un piège : le paramètre de brique() ne peut pas s'appeler palette. C'est un mot réservé de Simple8 (il sert aux ressources), et le compilateur s'arrête sur une erreur de syntaxe : Encountered " "palette" "palette "". Il s'appelle donc numero.

    Les deux versions compilent et jouent. Dans la démo, au bout de 40 secondes, on voit des briques du haut redevenues rouges ou roses (touchées une fois) et d'autres disparues (touchées deux fois). Le jeu est plus long : après 4 minutes, la démo n'a pas encore fini le premier mur (1 450 points sur les deux machines ; il reste une brique, déjà touchée une fois), alors qu'elle le finit en 98 s sur C64 et 82 s sur PC Engine sans briques solides.

    Trois moments de la démo sur PC Engine, avec une rangée de briques blanches en haut
    Sur PC Engine, après 5 s, 40 s et 4 minutes : la rangée blanche est solide ; une brique touchée une fois reprend la couleur de sa rangée.
    La démo sur C64 avec une rangée de briques blanches
    Sur Commodore 64 (VICE), environ 40 secondes après le démarrage.
  3. Sur Amstrad CPC, remplacez le test de la grille par gfx.getPixel. Que faut-il quand même calculer pour effacer la bonne brique ?
    Voir la solutionMasquer la solution

    Jeu ne doit rien savoir de la machine : il ne peut pas appeler gfx.getPixel lui-même. La lecture de l'écran va donc dans l'Ecran du CPC, sous la forme d'une petite fonction qui dit si un point du terrain a la couleur d'une brique. On ne regarde que les deux pens des briques : le blanc des murs, de la raquette, de la balle et du texte n'est pas une brique.

    // 1 si le point (x, y) du terrain a la couleur d'une brique : on lit l'écran
    ubyte briqueVisible(int x, int y) {
        ubyte pen = gfx.getPixel(.X0 + x, .Y0 + y);
        if (pen == .BRIQUE_A || pen == .BRIQUE_B) {
            return 1;
        }
        return 0;
    }

    Dans briqueEn(), cette lecture remplace le test de la case briques[n]. Le test de la zone des briques, au début, est gardé : il ne coûte que quatre comparaisons, et évite de lire l'écran quand la balle est loin des briques, c'est-à-dire presque toujours.

    // La brique sous le point (x, y), ou 255 s'il n'y en a pas
    ubyte briqueEn(int x, int y) {
        ubyte n;
        if (y < .HAUT_BRIQUES || y >= .BAS_BRIQUES || x < 0 || x >= .LARGEUR) {
            return 255;
        }
        // l'écran dit s'il y a une brique...
        if (Ecran.briqueVisible(x, y) == 0) {
            return 255;
        }
        // ... mais pas laquelle : sa colonne et sa rangée se calculent toujours
        .colonneTouchee = (ubyte)(x >> 4);                     // x / 16
        .ligneTouchee = (ubyte)((y - .HAUT_BRIQUES) >> 3);     // (y - 16) / 8
        n = (ubyte)(.DEBUT_LIGNE[.ligneTouchee] + .colonneTouchee);
        return n;
    }

    Voici la réponse à la question : la couleur dit qu'il y a une brique, pas laquelle. Il faut quand même calculer sa colonne (x >> 4) et sa rangée ((y - 16) >> 3). La colonne et la rangée servent à Ecran.effacerBrique(c, l), qui repeint la bonne brique en noir, et la rangée donne les points (POINTS[ligneTouchee]). Le numéro n sert encore à toucher(), qui met la case à 0 : le tableau n'est plus lu pour les collisions, mais il reste juste.

    Cette version modifie Jeu.s8u et appelle une fonction que seul l'Ecran du CPC possède : elle ne compilerait plus pour les autres machines. C'est le prix de cette méthode : elle lie les règles à l'écran d'une machine.

    Vérifié dans l'émulateur du kit de test : la démo joue normalement, finit son premier mur en 108,5 s (104,3 s avec la grille : la trajectoire change un peu, sans doute parce que l'écart d'un point entre deux briques est noir, alors que la grille le compte dans la brique). Et la lecture coûte plus cher : un appel de briqueEn() qui passe le test de zone prend en moyenne 2 136 états au lieu de 1 192 avec la grille, mesuré sur les 30 premières secondes.

    La démo du casse-briques sur Amstrad CPC, avec getPixel
    La version avec gfx.getPixel, après 30 secondes de démo.
  4. Choisissez une machine et ajoutez un son au rebond, sans toucher à Jeu.s8u plus que l'appel lui-même.
    Voir la solutionMasquer la solution

    Le Commodore 64 est la machine la plus simple pour un « bip » : son circuit sonore, le SID, joue une note tout seul une fois réglé, et la bibliothèque sid.base fournit tout le nécessaire. Il suffit de lui donner une forme d'onde, une enveloppe (comment le son monte, tient et s'éteint), une note, puis d'ouvrir et de fermer la « porte » (le gate) de la voix.

    Dans Jeu.s8u, une seule ligne s'ajoute, dans rebondRaquette(), quand la balle repart :

    .dy = -2;
    .balleY = .RAQUETTE_Y - .BALLE;
    Ecran.bip();                    // le son du rebond : chaque machine le joue à sa façon
    .visee++;

    Tout le reste est dans l'Ecran du C64. Dans main.s8p, la bibliothèque du SID :

    addlib sid.base;                  // le son : le SID

    Dans Ecran.s8u, une variable, la fin de init(), et deux fonctions :

    ubyte dureeBip;             // images avant la fin du bip, 0 : silence
    // …
    void init() {
        // …
        // le son : la voix 0 du SID, une onde carrée qui part tout de suite
        sid.init();
        sid.setWaveform(0, sid.PULSE);
        sid.setPulseWidth(0, 0x800);
        sid.setEnvelope(0, 0x00, 0xF6);     // attaque 0, déclin 0, tenue 15, relâche 6
    }
    // …
    void attendreImage() {
        irq.waitFrame();
        if (.dureeBip > 0) {
            .dureeBip--;
            if (.dureeBip == 0) {
                sid.noteOff(0);             // fin du bip : le son s'éteint (la relâche)
            }
        }
    }
    
    // Un bip court : la note part, attendreImage() l'arrête trois images plus tard
    void bip() {
        sid.setNote(0, 60);                 // C-5 (do 5)
        sid.noteOn(0);
        .dureeBip = 3;
    }
    

    Pourquoi ne pas fermer la porte tout de suite dans bip() ? Le SID ne relance une note que si la porte passe de fermée à ouverte ; il faut aussi que le son dure assez pour s'entendre. bip() ouvre donc la porte, et c'est attendreImage(), déjà appelée à chaque image, qui la referme trois images plus tard. Le son s'éteint alors en douceur (la relâche), et la porte est fermée pour le rebond suivant.

    Ajouter une fonction appelée par Jeu, c'est allonger le contrat : chaque Ecran.s8u doit maintenant avoir un bip(), sinon sa machine ne compile plus (Function 'bip' not found !, obtenu sur la version Amstrad CPC). Une fonction vide suffit sur les machines qui restent muettes :

    // Pas encore de son sur CPC : la fonction existe, elle ne fait rien
    void bip() {
    }

    Vérifié de deux façons. Dans l'émulateur du kit de test, qui note les écritures dans le SID : en 60 secondes de démo, 28 rebonds sur la raquette et 28 ouvertures de la porte de la voix 0 ($D404 = $41), chacune refermée 3 images plus tard ($D404 = $40, 0,058 s après). Et dans VICE, en enregistrant le son dans un fichier WAV : un son d'environ un dixième de seconde à chaque rebond (à 2,96 s, 4,42 s, 5,88 s, 7,32 s…), de fréquence proche de 520 Hz, celle du do 5 (523 Hz).

Vous avez maintenant tout ce qu'il faut pour écrire vos propres jeux. Choisissez une machine, ouvrez Simple8 IDE, et lancez-vous !