Simple8

Units et bibliothèques

Un jeu complet compte des milliers de lignes. Les écrire dans un seul fichier deviendrait vite ingérable. Ce chapitre montre comment répartir un programme en plusieurs fichiers, les units, et comment écrire ses propres bibliothèques, réutilisables d'un projet à l'autre.

Pourquoi découper en fichiers ?

Jusqu'ici, tout le programme tenait dans main.s8p. Découper en fichiers permet de :

Simple8 propose deux sortes de fichiers pour cela :

FichierContientS'ajoute avecS'appelle par
.s8uune unit : une partie du programmeaddunit "Joueur.s8u";Joueur.deplacer()
.s8lune bibliothèque : des fonctions réutilisablesaddlib outils.outils;outils.carre()

Les units

La unit principale

Depuis le premier chapitre, main.s8p contient une unit : unit { … }. Sans nom, elle s'appelle Main. C'est la unit principale, la seule à avoir une fonction main().

Une unit dans son fichier

Une autre unit s'écrit dans son propre fichier, d'extension .s8u. Elle a un nom, ses variables et ses fonctions, mais pas de main() :

Joueur.s8u

unit Joueur {
    ubyte x;

    void init() {
        .x = 10;
    }

    void deplacer(ubyte dx) {
        .x = .x + dx;
    }
}

Ajouter une unit : addunit

Le programme principal ajoute ses units avec addunit, après les addlib. Le chemin est relatif au fichier qui contient l'addunit, et peut désigner un sous-dossier :

main.s8p

architecture commodore.c64;

addunit "Joueur.s8u";
addunit "jeu/Ennemis.s8u";          // une unit rangée dans un sous-dossier

unit {
    main() {
        Joueur.init();
        Joueur.deplacer(3);         // Joueur.x vaut 13
    }
}

Les deux écritures addunit "Joueur.s8u"; et addunit("Joueur.s8u"); sont acceptées.

La règle de nommage

Comme en Java, le nom de la unit est celui de son fichier, sans l'extension :

FichierDéclaration attendue
main.s8punit { ou unit Main {
Joueur.s8uunit Joueur {
jeu/Ennemis.s8uunit Ennemis { (le dossier ne compte pas)

Un autre nom est refusé :

Unit name mismatch: file 'Joueur.s8u' must declare 'unit Joueur {' but found 'unit Heros {'

Ainsi, en lisant Joueur.deplacer() n'importe où dans le projet, on sait tout de suite dans quel fichier chercher.

Même nom, même casse. La vérification ignore les majuscules : unit joueur est accepté dans Joueur.s8u. Mais la unit s'appelle alors joueur, et un appel Joueur.init() ne la trouve pas. Écrivez le nom exactement comme celui du fichier ; par convention, avec une majuscule.

Les autres règles vérifiées

RègleMessage
l'extension est .s8ua unit file must have the .s8u extension
le fichier existefile not found (Absent.s8u)
un fichier n'est ajouté qu'une foisthis unit file is already part of the program
pas de main() dans un .s8umain() is not allowed in a unit file (.s8u) outside a context

Utiliser une unit depuis une autre

Depuis une autre unit, on écrit le nom de la unit, un point, puis la variable ou la fonction :

.position = Joueur.x;           // lire une variable de Joueur
Joueur.deplacer(3);             // appeler une fonction de Joueur
Main.ajouter(5);                // depuis une unit : une fonction de la unit principale
Ce qu'on désigneDans sa propre unitDepuis une autre unit
une variable.xJoueur.x
une fonctiondeplacer(3)Joueur.deplacer(3)
une constante.MAXJoueur.MAX

Deux units peuvent avoir des fonctions du même nom : Joueur.init() et Ennemis.init() ne se confondent pas.

Modifier une autre unit : par ses fonctions

Lire une variable d'une autre unit et appeler ses fonctions marchent dans tous les sens. Pour modifier ses variables, passez par une de ses fonctions : c'est la unit elle-même qui change ses données.

// dans Joueur.s8u
Ennemis.changer(9);         // plutôt que Ennemis.nombre = 9;

// dans Ennemis.s8u
void changer(ubyte n) {
    .nombre = n;
}

Une limite actuelle. Une affectation directe (Ennemis.nombre = 9;) n'est acceptée que vers une unit ajoutée avant celle qui écrit. La unit principale peut donc écrire dans toutes ses units, mais une unit ne peut pas écrire dans Main, ni dans une unit ajoutée après elle : Variable 'Ennemis.nombre' not found !. Passer par une fonction évite le problème, et rend le programme plus clair.

Les bibliothèques

Une bibliothèque, fichier .s8l, regroupe des fonctions réutilisables. Les bibliothèques de libs8/ (vic.screentext, vce.base…) sont écrites ainsi. Une bibliothèque se déclare avec library("nom") :

userlibs/outils/outils.s8l

library("outils")
{
    const ubyte MAX = 200;
    ubyte compteur;

    uint carre(uint n) {
        return n * n;
    }

    void incrementer() {
        .compteur++;
    }
}

Le programme la charge avec addlib, puis l'utilise avec le nom donné dans library("…") :

addlib outils.outils;          // le fichier outils/outils.s8l

unit {
    main() {
        .resultat = outils.carre(9);    // 81
        outils.incrementer();
        .valeur = outils.MAX;           // 200
    }
}

Le préfixe est le nom de library("…"), pas celui du fichier. Le compilateur ne vérifie pas qu'ils sont identiques : avec library("util") dans outils.s8l, les appels outils.carre() ne trouvent rien. Donnez toujours le même nom au fichier et à la bibliothèque.

Où le compilateur cherche

addlib dossier.fichier; désigne le fichier dossier/fichier.s8l. Le compilateur le cherche à quatre endroits, dans cet ordre, et prend le premier trouvé :

  1. libs8/constructeur/machine/ : les bibliothèques de la machine ;
  2. libs8/ : les bibliothèques communes à toutes les machines ;
  3. le dossier de la variable S8_USER_LIBS : vos bibliothèques, pour tous vos projets ;
  4. userlibs/, dans le dossier du projet : les bibliothèques de ce projet.

Introuvable, la bibliothèque donne Library not found: outils/absente.s8l.

Générique ou propre à une machine

Une bibliothèque qui en utilise une autre

Une bibliothèque charge celles dont elle a besoin, avec addlib en tête de fichier, ou avec require dans son bloc, comme le font celles de libs8/ :

architecture commodore.c64;

library("texte")
{
    require vic.screentext;     // ou : addlib vic.screentext; avant library

    ubyte lignes;

    void ligne(uint t) {
        vic.setCursor(2, .lignes);
        vic.print(t);
        .lignes++;
    }
}

Une bibliothèque sur la mauvaise machine

Que se passe-t-il quand le code d'une bibliothèque ne convient pas à la machine du programme ? Tout dépend de la situation, et toutes ne sont pas signalées clairement :

SituationConséquence
Charger une bibliothèque d'une autre machine : addlib vic.screentext; dans un programme PC Engine Refusée. Elle est rangée dans le dossier du C64, le compilateur ne la cherche pas ailleurs : Library not found: vic/screentext.s8l.
Une bibliothèque générique (sans architecture) contient un bloc _asm 6502, et le programme est pour un Z80 (Amstrad CPC) Refusée. Les instructions sont vérifiées pour le processeur du programme : Unknown mnemonic 'LDA' for architecture Zilog Z80A (Amstrad CPC).
La même bibliothèque, dans un programme pour une autre machine 6502 Acceptée sans message. Le processeur comprend les instructions, mais le matériel n'est pas le même : une adresse comme $D020 (la bordure du C64) n'a pas ce rôle sur une autre machine. Le programme compile, et se trompe.
Une bibliothèque déclare une architecture différente de celle du programme Un simple avertissement, Architecture declaration is different from project, puis, selon les cas : des erreurs d'assemblage obscures (Symbol '_context_ram_start' already defined, Unknown instruction: .IMPORTZP), ou une compilation sans erreur d'un code écrit pour une autre machine.

Ne pas ignorer cet avertissement. « Architecture declaration is different from project » veut dire qu'une bibliothèque a été écrite pour une autre machine. Les erreurs qui suivent, quand il y en a, ne désignent pas la vraie cause : c'est la bibliothèque qu'il faut changer.

D'où trois règles simples :

  1. Une bibliothèque qui touche au matériel (un bloc _asm, une adresse de la machine, une bibliothèque comme vic ou vdc) déclare son architecture, et ne sert qu'aux programmes de cette machine.
  2. Une bibliothèque pour toutes les machines n'a pas de ligne architecture, et ne contient que du Simple8 : ni _asm, ni bibliothèque de machine. C'est le cas de nombres dans l'exemple ci-dessous.
  3. Pour une même bibliothèque sur plusieurs machines, on écrit une version par machine, rangée sous le même chemin dans le dossier de chacune : addlib trouve d'abord celle de la machine du programme. Ainsi, libs8/math/bcd.s8l est la version générique de math.bcd, et libs8/nintendo/dmg/math/bcd.s8l la version de la Game Boy, plus rapide.

addlib plutôt que require, pour l'instant. Si une bibliothèque appelée par require est introuvable, le compilateur affiche une exception Java, mais annonce 0 error(s), sans produire de programme. Avec addlib, l'erreur est donnée normalement : Library not found.

Un exemple : le tableau de score en fichiers

Le tableau de score du chapitre 5, réparti en quatre fichiers selon leur rôle :

Score/
├── main.s8p                    le programme principal : il enchaîne les étapes
├── Partie.s8u                  les règles du jeu : score, vies, gemmes
├── Ecran.s8u                   l'affichage, propre à la machine
└── userlibs/
    └── nombres/
        └── nombres.s8l         une bibliothèque générique : un nombre en chiffres

main.s8p

// Units et bibliothèques : le tableau de score, réparti en fichiers (Commodore 64)
architecture commodore.c64;

addunit "Partie.s8u";
addunit "Ecran.s8u";

unit {
    main() {
        Partie.nouvelle();
        Partie.ramasser(7);
        Partie.perdreVie();

        Ecran.preparer();
        Ecran.ligne(10, "SCORE  ", Partie.score, 5);
        Ecran.ligne(12, "VIES   ", Partie.vies, 1);
        Ecran.ligne(14, "GEMMES ", Partie.gemmes, 2);
    }
}

Partie.s8u

// Partie.s8u : les règles du jeu (sans rien de propre à une machine)
unit Partie {
    const uint POINTS_PAR_GEMME = 250;
    uint score;
    ubyte vies;
    ubyte gemmes;

    // Une nouvelle partie
    void nouvelle() {
        .score = 0;
        .vies = 3;
        .gemmes = 0;
    }

    // Le joueur ramasse des gemmes
    void ramasser(ubyte n) {
        .gemmes = .gemmes + n;
        .score = .score + .POINTS_PAR_GEMME * n;
    }

    // Le joueur perd une vie
    void perdreVie() {
        if (.vies > 0) {
            .vies--;
        }
    }
}

Ecran.s8u

// Ecran.s8u : l'affichage, propre au Commodore 64
addlib vic.base;
addlib vic.screentext;
addlib nombres.nombres;

unit Ecran {
    ubyte tampon[6];                    // les chiffres d'un nombre, et le 0 de fin

    // Fond bleu, texte blanc
    void preparer() {
        vic.setBorderColor(0);
        vic.setBackgroundColor(6);
        vic.initScreen();
        vic.clearScreen();
        vic.setFontColor(1);
    }

    // Un texte puis un nombre, à la ligne y
    void ligne(ubyte y, uint texte, uint valeur, ubyte chiffres) {
        vic.setCursor(13, y);
        vic.print(texte);
        nombres.enChiffres(valeur, @.tampon, chiffres);
        vic.print(@.tampon);
    }
}

userlibs/nombres/nombres.s8l

// nombres.s8l : une bibliothèque générique (sans ligne architecture), pour toutes les machines
library("nombres")
{
    // Écrit les chiffres de valeur dans dest, avec des zéros devant, puis le 0 de fin
    void enChiffres(uint valeur, ubyte *dest, ubyte chiffres) {
        uint diviseur = 1;
        ubyte k = 0;
        for (ubyte i = 1; i < chiffres; i++) {
            diviseur = diviseur * 10;
        }
        while (diviseur > 0) {
            dest[k] = (ubyte)('0' + (valeur / diviseur) % 10);
            k++;
            diviseur = diviseur / 10;
        }
        dest[k] = 0;
    }
}
Écran du Commodore 64 : SCORE 01750, VIES 2, GEMMES 07
Le résultat sur Commodore 64.
La version PC Engine

C'est là que le découpage paie : Partie.s8u et nombres.s8l sont exactement les mêmes fichiers. Seuls changent la ligne architecture de main.s8p (et la position des lignes), et Ecran.s8u :

Ecran.s8u

// Ecran.s8u : l'affichage, propre à la PC Engine
addlib sys.resource;
addlib vce.base;
addlib vdc.base;
addlib vdc.screentext;
addlib nombres.nombres;

unit Ecran {
    const int font = addResource("font.bin");
    ubyte tampon[6];                    // les chiffres d'un nombre, et le 0 de fin

    // La police, fond bleu, texte blanc
    void preparer() {
        int source;
        int length;
        vdc.setBATSize(vdc.BAT_32x32);
        vdc.initScreen();
        resource.mapToMPR2(.font);
        source = 0x4000 + resource.getOffset(.font);
        length = resource.getLength(.font);
        vdc.writeToVRAM(source, 0x1000, length);
        vce.setPalette(0, 0, RGB(0, 0, 7));
        vce.setPalette(0, 1, RGB(7, 7, 7));
        vdc.clearScreen();
        vdc.setFontColor(0);
    }

    // Un texte puis un nombre, à la ligne y
    void ligne(ubyte y, uint texte, uint valeur, ubyte chiffres) {
        vdc.setCursor(9, y);
        vdc.print(texte);
        nombres.enChiffres(valeur, @.tampon, chiffres);
        vdc.print(@.tampon);
    }
}
Écran de la PC Engine : SCORE 01750, VIES 2, GEMMES 07
Le résultat sur PC Engine.

À retenir

À vous de jouer

  1. Ajoutez un niveau à Partie (une variable niveau et une fonction niveauSuivant()), et affichez-le.
    Voir la solutionMasquer la solution

    Le niveau fait partie des règles du jeu : sa variable et sa fonction vont dans Partie, à côté du score et des vies. nouvelle() le met à 1 au début de la partie, et niveauSuivant() le fait avancer. Comme le reste de Partie, ce code ne dépend d'aucune machine : le même fichier sert au C64 et à la PC Engine.

    Partie.s8u

    // Partie.s8u : les règles du jeu (sans rien de propre à une machine)
    unit Partie {
        const uint POINTS_PAR_GEMME = 250;
        uint score;
        ubyte vies;
        ubyte gemmes;
        ubyte niveau;
    
        // Une nouvelle partie
        void nouvelle() {
            .score = 0;
            .vies = 3;
            .gemmes = 0;
            .niveau = 1;
        }
    
        // Le joueur ramasse des gemmes
        void ramasser(ubyte n) {
            .gemmes = .gemmes + n;
            .score = .score + .POINTS_PAR_GEMME * n;
        }
    
        // Le joueur perd une vie
        void perdreVie() {
            if (.vies > 0) {
                .vies--;
            }
        }
    
        // Le joueur passe au niveau suivant
        void niveauSuivant() {
            .niveau++;
        }
    }

    Dans main.s8p, on fait passer un niveau, puis on affiche une ligne de plus. Le programme principal lit Partie.niveau directement : lire la variable d'une autre unit est permis. Pour la changer, il passe par Partie.niveauSuivant(), comme le conseille le chapitre.

    main.s8p

    main() {
        Partie.nouvelle();
        Partie.ramasser(7);
        Partie.perdreVie();
        Partie.niveauSuivant();
    
        Ecran.preparer();
        Ecran.ligne(10, "SCORE  ", Partie.score, 5);
        Ecran.ligne(12, "VIES   ", Partie.vies, 1);
        Ecran.ligne(14, "GEMMES ", Partie.gemmes, 2);
        Ecran.ligne(16, "NIVEAU ", Partie.niveau, 2);
    }
    Écran du Commodore 64 : SCORE 01750, VIES 2, GEMMES 07, NIVEAU 02
    Le niveau 2, après un appel à niveauSuivant().

    Sur PC Engine, seule la ligne change : Ecran.ligne(17, "NIVEAU ", Partie.niveau, 2);, sous les gemmes (ligne 15). Ecran.s8u n'est touché sur aucune des deux machines : sa fonction ligne() sait déjà afficher un texte et un nombre.

  2. Créez une unit Bonus.s8u avec une fonction qui donne 1 000 points par vie restante, en appelant Partie.ajouter() que vous écrirez.
    Voir la solutionMasquer la solution

    La nouvelle unit ne contient que des règles, sans rien de propre à une machine : le même fichier Bonus.s8u sert au C64 et à la PC Engine. Elle lit le nombre de vies dans Partie, mais ne touche pas elle-même au score : elle demande à Partie de l'augmenter, par la fonction ajouter(). C'est Partie qui change ses propres données.

    Bonus.s8u

    // Bonus.s8u : le bonus de fin de partie (sans rien de propre à une machine)
    unit Bonus {
        const uint POINTS_PAR_VIE = 1000;
    
        // 1 000 points par vie restante, ajoutés au score de la partie
        void compter() {
            Partie.ajouter(.POINTS_PAR_VIE * Partie.vies);
        }
    }

    Dans Partie.s8u, on ajoute la fonction ajouter(). Elle pourra servir à d'autres occasions : un trésor, un ennemi vaincu…

    Partie.s8u

    // Des points en plus
    void ajouter(uint points) {
        .score = .score + points;
    }

    Enfin, main.s8p ajoute la unit et compte le bonus en fin de partie (ici celui du C64 ; sur PC Engine, seuls les numéros de ligne de l'affichage diffèrent) :

    main.s8p

    addunit "Partie.s8u";
    addunit "Bonus.s8u";
    addunit "Ecran.s8u";
    
    unit {
        main() {
            Partie.nouvelle();
            Partie.ramasser(7);
            Partie.perdreVie();
            Bonus.compter();                // 2 vies : 2 000 points de plus
    
            Ecran.preparer();
            Ecran.ligne(10, "SCORE  ", Partie.score, 5);
            Ecran.ligne(12, "VIES   ", Partie.vies, 1);
            Ecran.ligne(14, "GEMMES ", Partie.gemmes, 2);
        }
    }

    Le score passe de 1 750 (7 gemmes à 250 points) à 3 750 : 2 vies restantes, 2 000 points de plus.

    Écran de la PC Engine : SCORE 03750, VIES 2, GEMMES 07
    Le bonus compté, sur PC Engine.

    Pourquoi passer par ajouter() plutôt que d'écrire Partie.score = Partie.score + … dans Bonus ? Nous avons essayé les deux. L'affectation directe compile tant que Bonus est ajoutée après Partie ; si l'on inverse les deux addunit, le compilateur refuse : Variable 'Partie.score' not found !. Avec ajouter(), l'ordre des addunit n'a plus d'importance : le programme donne 3 750 dans les deux cas.

  3. Ajoutez à la bibliothèque nombres une fonction qui n'écrit pas les zéros de tête.
    Voir la solutionMasquer la solution

    La nouvelle fonction suit la même idée que l'exercice 3 du chapitre 5 : on parcourt les chiffres de gauche à droite, et on n'écrit un chiffre que s'il n'est pas nul, ou si un chiffre a déjà été écrit (les zéros du milieu, comme dans 1050, doivent rester), ou si c'est le dernier (pour que 0 s'écrive 0). Ici, pas besoin d'une variable de plus pour savoir si l'écriture a commencé : k, le nombre de chiffres déjà rangés, suffit. Tant qu'il vaut 0, rien n'a été écrit.

    Comme enChiffres, la fonction n'affiche rien : elle range les chiffres dans un tableau, et la bibliothèque reste générique. On part directement de 10 000, car un uint a au plus 5 chiffres (65 535) ; le paramètre chiffres devient inutile.

    userlibs/nombres/nombres.s8l

    // Écrit les chiffres de valeur dans dest, sans zéros devant, puis le 0 de fin
    void enChiffresCourt(uint valeur, ubyte *dest) {
        uint diviseur = 10000;          // un uint a au plus 5 chiffres (65 535)
        ubyte k = 0;                    // le nombre de chiffres déjà écrits
        while (diviseur > 0) {
            ubyte chiffre = (ubyte)((valeur / diviseur) % 10);
            // on écrit dès le premier chiffre non nul, et toujours le dernier (pour 0)
            if (chiffre != 0 || k > 0 || diviseur == 1) {
                dest[k] = (ubyte)('0' + chiffre);
                k++;
            }
            diviseur = diviseur / 10;
        }
        dest[k] = 0;
    }

    Pour s'en servir, Ecran.s8u reçoit une fonction d'affichage à trois paramètres. Le tableau tampon, de 6 cases, a la place pour 5 chiffres et le 0 de fin.

    Ecran.s8u

    // Un texte puis un nombre sans zéros devant, à la ligne y
    void ligneCourte(ubyte y, uint texte, uint valeur) {
        vic.setCursor(13, y);
        vic.print(texte);
        nombres.enChiffresCourt(valeur, @.tampon);
        vic.print(@.tampon);
    }

    main.s8p

    Ecran.preparer();
    Ecran.ligneCourte(10, "SCORE  ", Partie.score);
    Ecran.ligneCourte(12, "VIES   ", Partie.vies);
    Ecran.ligneCourte(14, "GEMMES ", Partie.gemmes);
    Écran du Commodore 64 : SCORE 1750, VIES 2, GEMMES 7
    Les nombres sans leurs zéros de tête.

    Nous avons aussi vérifié les cas limites, en affichant quelques valeurs de plus : 0 s'écrit 0, 40 s'écrit 40, 10005 garde ses zéros du milieu, et 65535 s'écrit en entier. Sur PC Engine, nombres.s8l est le même fichier ; dans Ecran.s8u, la fonction est la même avec vdc et la colonne 9.

  4. Renommez Ecran.s8u en Affichage.s8u : que faut-il changer d'autre ?
    Voir la solutionMasquer la solution

    Le nom d'une unit est celui de son fichier : en renommant le fichier, on renomme la unit. Il faut donc changer, en plus du nom du fichier :

    1. la ligne addunit "Ecran.s8u"; de main.s8p, qui devient addunit "Affichage.s8u"; ;
    2. la déclaration dans le fichier : unit Ecran { devient unit Affichage { ;
    3. chaque appel Ecran.preparer() et Ecran.ligne(…), qui devient Affichage.preparer() et Affichage.ligne(…), partout dans le projet (ici, seulement dans main.s8p).

    Le compilateur guide pas à pas. Nous avons renommé le fichier seul, puis corrigé une chose à la fois :

    Ce qui a été changéMessage du compilateur
    le nom du fichier seuladdunit "Ecran.s8u": file not found (…)
    et la ligne addunitUnit name mismatch: file 'Affichage.s8u' must declare 'unit Affichage {' but found 'unit Ecran {'
    et la déclaration unitFunction 'preparer' not found !, puis trois fois Function 'ligne' not found !
    et les appelsaucun : 0 error(s)

    Le programme principal devient :

    main.s8p

    addunit "Partie.s8u";
    addunit "Affichage.s8u";
    
    unit {
        main() {
            Partie.nouvelle();
            Partie.ramasser(7);
            Partie.perdreVie();
    
            Affichage.preparer();
            Affichage.ligne(10, "SCORE  ", Partie.score, 5);
            Affichage.ligne(12, "VIES   ", Partie.vies, 1);
            Affichage.ligne(14, "GEMMES ", Partie.gemmes, 2);
        }
    }

    Rien d'autre ne change : Partie.s8u et nombres.s8l ne connaissent pas Ecran, et font.bin, sur PC Engine, reste à côté du fichier. L'écran obtenu est exactement le même qu'avant, sur les deux machines. Pensez aussi au commentaire en tête du fichier, qui citait l'ancien nom : le compilateur, lui, ne le vérifie pas.