Simple8

Contextes

Un jeu enchaîne des parties bien distinctes : l'écran titre, les niveaux, l'écran de fin. Ce chapitre montre comment découper un jeu en contextes, c'est-à-dire en morceaux chargés en mémoire seulement quand on en a besoin. Nous construirons pas à pas un programme qui passe d'un écran titre à un jeu. En chemin, nous rencontrerons deux outils indispensables : initContext() et les interruptions.

Le problème : un jeu trop grand pour la mémoire

Les machines rétro ont très peu de mémoire. La PC Engine, par exemple, ne voit que 64 Ko à la fois, dont 40 Ko pour le programme. Or une cartouche peut contenir jusqu'à 1 Mo : bien plus que ce que le processeur peut voir d'un coup.

Heureusement, un jeu n'a jamais besoin de tout en même temps. Pendant l'écran titre, le code du niveau 3 ne sert à rien. Pendant le niveau 3, c'est l'écran titre qui ne sert à rien. D'où l'idée des contextes :

Un contexte, c'est une partie du jeu (le titre, un niveau, la fin), rangée à part et chargée en mémoire seulement quand on en a besoin. Un seul contexte est chargé à la fois. Le code qui sert partout reste toujours en mémoire.

Les contextes sont rangés sur la cartouche ou la disquette ; dans la mémoire de la machine, le code résident est toujours présent, et un seul contexte est chargé à la fois, par sys.loadContext Cartouche ou disquette contexte Titre contexte Jeu contexte Fin Mémoire de la machine Code résident : Main, Commun toujours là Contexte chargé : Jeu un seul à la fois sys.loadContext(Jeu)
Un seul contexte en mémoire à la fois, à côté du code résident.

Selon la machine

Les contextes ont été conçus pour les machines à mémoire segmentée, comme la PC Engine. Sa cartouche est découpée en banques de 8 Ko, et le programme choisit lesquelles sont visibles. Gérer ces banques à la main serait pénible. Simple8 fait donc comme si la cartouche était un système de fichiers : chaque contexte est un ensemble de banques, rangé par le compilateur.

Sur les micro-ordinateurs, comme le Commodore 64 ou l'Amstrad CPC, un contexte est tout simplement un fichier auxiliaire sur la disquette, que le programme principal charge en mémoire. Le programme s'écrit de la même façon partout. Seule change ce que la machine fait en coulisse.

MachineOù est rangé un contexteLe charger prend…
PC Enginedes banques de 8 Ko de la cartoucheun instant : les banques sont rendues visibles
NESdes banques de 8 Ko de la cartoucheun instant
Game Boyune banque de 16 Ko de la cartoucheun instant
Commodore 64un fichier .dat sur la disquettele temps de lire le fichier
Amstrad CPCun fichier sur la disquettele temps de lire le fichier

Étape 1 : un premier contexte

Commençons par un seul contexte : l'écran titre. Il faut faire deux choses : le déclarer, puis écrire sa unit.

Déclarer le contexte

Dans main.s8p, juste après les addlib, on déclare le contexte. On lui donne un nom et le fichier de sa unit :

main.s8p

// Étape 1 : un seul contexte, l'écran titre (Commodore 64)
architecture commodore.c64;

addlib vic.base;
addlib vic.screentext;

context Titre {
    addunit("Titre.s8u");
}

unit {
    main() {
        sys.loadContext(Titre);     // charge le contexte Titre, et ne revient jamais
    }
}

La unit principale ne fait plus qu'une chose : charger le contexte Titre avec sys.loadContext(Titre). Cet appel ne revient jamais. À partir de là, c'est le contexte qui a la main.

Écrire la unit du contexte

La unit d'un contexte ressemble à une unit ordinaire. Elle doit simplement contenir deux fonctions obligatoires, initContext() et main() :

Titre.s8u

// Titre.s8u : le contexte de l'écran titre
unit Titre {

    // Appelée à chaque chargement du contexte, avant main() : on prépare l'écran
    initContext() {
        vic.initScreen();
        vic.setBorderColor(0);
        vic.setBackgroundColor(6);
        vic.clearScreen();
        vic.setFontColor(1);
        vic.setCursor(16, 6);
        vic.print("TITRE");
    }

    // Le programme du contexte
    main() {
        while (1) {
        }
    }
}

Pourquoi initContext() ?

Un contexte peut être chargé plusieurs fois : on revient à l'écran titre après chaque partie. À chaque chargement, il faut remettre l'écran en place : effacer, choisir les couleurs, charger les graphismes. C'est le rôle d'initContext(). Les deux fonctions ont chacune un métier bien précis :

On pourrait tout mettre dans main(). Mais séparer les préparatifs rend le contexte plus lisible. Et nous verrons plus bas qu'initContext() a un avantage de plus : rien ne peut l'interrompre.

Écran du Commodore 64 : TITRE sur fond bleu
Étape 1 : le contexte Titre est chargé et s'affiche.

Étape 2 : passer à un deuxième contexte

Ajoutons le jeu. On déclare un deuxième contexte, et l'écran titre le charge à son tour :

// main.s8p : un contexte de plus
context Jeu {
    addunit("Jeu.s8u");
}

// Titre.s8u : main() passe au jeu
main() {
    sys.loadContext(Jeu);
}

Le programme compile, et le jeu s'affiche bien. Mais l'écran titre, lui, n'apparaît jamais : il passe au jeu à l'instant où il commence. Il faudrait qu'il attende un peu, deux secondes par exemple.

Comment attendre deux secondes ? Une boucle qui compte jusqu'à un grand nombre attendrait plus ou moins longtemps selon la machine. Il nous faut une vraie horloge.

Étape 3 : mesurer le temps avec une interruption

Qu'est-ce qu'une interruption ?

Imaginez que vous lisez un livre. Une sonnerie retentit à intervalles réguliers. À chaque sonnerie, vous notez un trait sur une feuille, puis vous reprenez votre lecture. Pour savoir combien de temps a passé, il suffit de compter les traits.

Une interruption fonctionne ainsi. Le circuit vidéo envoie un signal à chaque image qu'il affiche : 50 fois par seconde sur un C64 européen, 60 fois sur la PC Engine. À chaque signal, le processeur met son programme en pause, exécute un petit morceau de code (le gestionnaire d'interruption), puis reprend exactement où il en était.

Le programme principal tourne en continu ; à chaque image, 50 fois par seconde, l'interruption le coupe un court instant pour compter, puis il reprend main() : la boucle attend le temps irq .images++ irq .images++ irq .images++ irq .images++ irq .images++ 1/50 s 1/50 s 1/50 s 1/50 s
main() tourne en continu ; à chaque image, l'interruption le coupe un court instant pour compter.

Écrire un gestionnaire

Un gestionnaire s'écrit dans la unit avec interrupt ->, suivi du nom de l'interruption. Celle de l'image s'appelle irq sur C64. Pour qu'elle soit active, main.s8p charge sa bibliothèque : addlib event.irq;.

Titre.s8u

// Titre.s8u : le contexte de l'écran titre
unit Titre {
    ubyte images;                   // le nombre d'images depuis le chargement

    // Appelée à chaque chargement du contexte, avant main() : on prépare l'écran
    initContext() {
        vic.initScreen();
        vic.setBorderColor(0);
        vic.setBackgroundColor(6);
        vic.clearScreen();
        vic.setFontColor(1);
        vic.setCursor(16, 6);
        vic.print("TITRE");
    }

    // Le programme du contexte : attendre 100 images (2 secondes), puis passer au jeu
    main() {
        while (.images < 100) {
        }
        sys.loadContext(Jeu);
    }

    // À chaque image, 50 fois par seconde, le programme est interrompu le temps de compter
    interrupt -> irq {
        .images++;
    }
}

Le travail est partagé en deux :

100 images à 50 par seconde, cela fait 2 secondes. Cette fois, l'écran titre reste affiché deux secondes, puis le jeu apparaît.

MachineInterruption de l'imageBibliothèque à charger
Commodore 64interrupt -> irq, 50 fois par secondeaddlib event.irq;
PC Engineinterrupt -> irq1, 60 fois par secondeaddlib event.irq1;

Il existe d'autres interruptions (un minuteur, timer, par exemple), et les autres machines ont leurs propres noms. Leur documentation les décrit.

Et initContext() dans tout ça ?

Pendant qu'initContext() s'exécute, les interruptions sont masquées : le gestionnaire n'est pas encore branché. Les préparatifs se font donc d'un seul tenant, sans risque d'être coupés au milieu. C'est important, par exemple, quand on copie une police en mémoire vidéo. Les interruptions ne démarrent qu'après, juste avant main().

Sur C64, changez de contexte depuis main(). On pourrait appeler sys.loadContext directement dans le gestionnaire. Sur PC Engine, cela fonctionne. Mais sur C64, pour l'instant, le contexte suivant ne reçoit alors plus d'interruptions, et le programme reste bloqué. Le partage ci-dessus — le gestionnaire compte, main() décide — fonctionne partout.

Récapitulatif : le cycle de vie

Nous pouvons maintenant compléter le schéma du chapitre 2. Au démarrage, comme à chaque chargement d'un contexte, les étapes sont les mêmes :

Au démarrage : boot.asm initialise la machine, appelle initContext() avec les interruptions masquées, branche les interruptions, puis appelle main() Mise sous tension RESET boot.asm initialise la machine initContext() préparatifs, interruptions masquées Interruptions branchées irq1, irq, timer… main() le programme
Du démarrage, ou du chargement d'un contexte, à main().
  1. la machine est préparée : par boot.asm au démarrage, par sys.loadContext pour un contexte, qui met aussi le contexte en mémoire ;
  2. initContext() fait les préparatifs, sans être interrompue ;
  3. les gestionnaires d'interruption sont branchés : ceux du contexte, à défaut ceux de la unit principale ;
  4. main() commence.

La unit principale de main.s8p suit le même chemin : elle aussi peut avoir son initContext() et ses gestionnaires. sys.loadContext(Main) y revient. Rien n'est chargé, puisqu'elle est toujours en mémoire, mais son initContext() et son main() sont rappelés.

Ce qu'un contexte peut voir

Comme un seul contexte est chargé à la fois, un contexte ne peut utiliser que ce qui se trouve en mémoire avec lui :

Depuis…On peut utiliserOn ne peut pas utiliser
une unit d'un contexteles units de son contexte, et tout le code résidentles units d'un autre contexte
le code résidentle code résidentles units des contextes

Le code résident, c'est la unit principale et les units ajoutées par addunit en dehors de tout contexte. Ce qui doit servir à plusieurs contextes — par exemple un texte à afficher — va donc dans une unit résidente.

addunit "Commun.s8u";           // hors contexte : résidente, visible de tous

context Jeu {
    addunit("Jeu.s8u");         // la unit principale du contexte : initContext() et main()
    addunit("Ennemis.s8u");     // une autre unit du même contexte, sans initContext() ni main()
}

Utiliser une unit d'un autre contexte est refusé. Le compilateur affiche alors un message qui rappelle la règle :

Jeu.essais: unit Jeu belongs to context Jeu, not reachable from context Titre.
Contexts are isolated: switch with sys.loadContext(Jeu)

Les variables d'un contexte

À chaque chargement, les variables d'un contexte reprennent leur valeur de départ : un niveau rechargé recommence au début. Certaines valeurs doivent pourtant être conservées, comme le nombre de parties jouées. Selon l'endroit où l'on déclare une variable, elle est remise à zéro ou conservée :

VariableOù la déclarerÀ chaque chargement du contexte
du contextedans une unit du contexteremise à sa valeur de départ
shareddans un bloc shared { } d'une unit du contextegardée, et réservée à ce contexte
résidentedans la unit principale, ou une unit hors contextegardée, et visible de tous les contextes
unit Jeu {
    ubyte essais;               // remis à zéro à chaque chargement du jeu

    shared {
        ubyte parties;          // gardé d'un chargement à l'autre
    }
}

Le programme complet

Voici le programme des trois étapes, terminé. Le titre et le jeu se passent la main toutes les deux secondes. Une unit résidente, Commun, affiche les écrans pour les deux contextes. Trois compteurs montrent le sort des trois sortes de variables.

main.s8p

// Contextes : un écran titre et un jeu, chargés tour à tour (Commodore 64)
architecture commodore.c64;

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

addunit "Commun.s8u";           // résidente : toujours en mémoire

context Titre {
    addunit("Titre.s8u");       // unit principale du contexte Titre
}

context Jeu {
    addunit("Jeu.s8u");         // unit principale du contexte Jeu
}

unit {
    // Au démarrage, avant main() : l'écran en mode texte
    initContext() {
        vic.initScreen();
        vic.setBorderColor(0);
    }

    main() {
        sys.loadContext(Titre); // ne revient jamais
    }
}

Commun.s8u

// Commun.s8u : résidente, utilisée par tous les contextes
unit Commun {
    ubyte chargements;          // contextes chargés depuis le démarrage : jamais remis à zéro

    // Un écran : sa couleur de fond et son titre
    void ecran(ubyte fond, uint titre) {
        vic.setBackgroundColor(fond);
        vic.clearScreen();
        vic.setFontColor(1);
        vic.setCursor(16, 6);
        vic.print(titre);
        .chargements++;
    }

    // Un texte puis un chiffre, à la ligne y
    void ligne(ubyte y, uint texte, ubyte valeur) {
        vic.setCursor(10, y);
        vic.print(texte);
        vic.putChar((ubyte)('0' + valeur));
    }
}

Titre.s8u

// Titre.s8u : le contexte de l'écran titre
unit Titre {
    ubyte images;               // remis à zéro à chaque chargement du contexte

    initContext() {
        Commun.ecran(6, "TITRE");
        Commun.ligne(10, "CHARGEMENTS ", Commun.chargements);
    }

    // attendre 100 images (2 secondes), puis passer au jeu
    main() {
        while (.images < 100) {
        }
        sys.loadContext(Jeu);
    }

    // 50 fois par seconde
    interrupt -> irq {
        .images++;
    }
}

Jeu.s8u

// Jeu.s8u : le contexte du jeu
unit Jeu {
    ubyte images;               // remis à zéro à chaque chargement
    ubyte essais;               // remis à zéro à chaque chargement : vaut toujours 1 ici

    shared {
        ubyte parties;          // gardé d'un chargement à l'autre
    }

    initContext() {
        .essais++;
        .parties++;
        Commun.ecran(2, "JEU");
        Commun.ligne(10, "CHARGEMENTS ", Commun.chargements);
        Commun.ligne(12, "PARTIES     ", .parties);
        Commun.ligne(14, "ESSAIS      ", .essais);
    }

    // attendre 100 images, puis revenir à l'écran titre
    main() {
        while (.images < 100) {
        }
        sys.loadContext(Titre);
    }

    interrupt -> irq {
        .images++;
    }
}
CompteurRangé dansValeurs à chaque passage dans le jeu
CHARGEMENTSune unit résidente2, 4, 6, 8… : il compte aussi les passages par le titre
PARTIESun bloc shared1, 2, 3, 4… : gardé d'un chargement du jeu à l'autre
ESSAISune variable du contexte1, 1, 1, 1… : remis à zéro à chaque chargement
Écran du Commodore 64 : TITRE, CHARGEMENTS 5
Le contexte Titre.
Écran du Commodore 64 : JEU, CHARGEMENTS 8, PARTIES 4, ESSAIS 1
Le contexte Jeu, au quatrième passage.

Ce que produit le compilateur

Sur C64, la compilation produit un fichier par contexte, et les réunit sur une disquette :

context Titre: titre.dat, 175 bytes at $6000
context Jeu: jeu.dat, 229 bytes at $6000
FichierContenu
main.prgle code résident : la unit principale, Commun, les bibliothèques
titre.dat, jeu.datun fichier par contexte, chargé à l'adresse $6000
main.d64l'image de la disquette, qui contient tous ces fichiers : c'est elle qu'il faut lancer

Sur PC Engine, tout est dans une seule ROM, main.pce : les contextes y occupent chacun leurs banques.

Le programme vu par Simple8 IDE

Simple8 IDE montre l'organisation d'un programme dans son diagramme du projet (Projet → Diagramme du projet, ⌥⌘D). Après la compilation de l'exemple, il se présente ainsi :

★ main.s8p
unit Main
ROM 18 oRAM 0 oZP 0 o
Socle résident · $0801-$5FFF
ROM2 381 / 22 52711 %
RAM143 / 4 0963.5 %
ZP34 / 25613 %
❖ Bibliothèques
vic94 o
base · screentext94 o
sans code : event.irq
▣ Commun
Commun.s8u
ROM 127 oRAM 8 oZP 0 o
◇ context Titre
fichier titre.dat · $6000
ROM175 / 16 3841.1 %
▣ Titre
Titre.s8u
ROM 175 oRAM 1 oZP 0 o
◇ context Jeu
fichier jeu.dat · $6000
ROM229 / 16 3841.4 %
▣ Jeu
Jeu.s8u
ROM 229 oRAM 3 oZP 0 o

Ressources 0 o · Système 2 142 o ROM, 135 o RAM (démarrage, runtime, chaînes)

Les tailles sont en octets (o). Elles viennent du fichier asm/main_units.json, que le compilateur écrit à chaque compilation réussie.

La version PC Engine

La structure est la même. La unit principale charge la police dans son initContext(), le fond change par la palette, et l'interruption de l'image s'appelle irq1. Elle se déclenche 60 fois par seconde : il faut donc 120 images pour deux secondes.

main.s8p

// Contextes : un écran titre et un jeu, chargés tour à tour (PC Engine)
architecture nec.pce;

addlib event.irq1;
addlib sys.resource;
addlib vce.base;
addlib vdc.base;
addlib vdc.screentext;

addunit "Commun.s8u";           // résidente : toujours en mémoire

context Titre {
    addunit("Titre.s8u");       // unit principale du contexte Titre
}

context Jeu {
    addunit("Jeu.s8u");         // unit principale du contexte Jeu
}

unit {
    const int font = addResource("font.bin");

    // Au démarrage, avant main() : l'écran, la police, le texte en blanc
    initContext() {
        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, 1, RGB(7, 7, 7));
    }

    main() {
        sys.loadContext(Titre); // ne revient jamais
    }
}

Commun.s8u

// Commun.s8u : résidente, utilisée par tous les contextes
unit Commun {
    ubyte chargements;          // contextes chargés depuis le démarrage : jamais remis à zéro

    // Un écran : sa couleur de fond et son titre
    void ecran(uint fond, uint titre) {
        vce.setPalette(0, 0, fond);
        vdc.clearScreen();
        vdc.setFontColor(0);
        vdc.setCursor(13, 7);
        vdc.print(titre);
        .chargements++;
    }

    // Un texte puis un chiffre, à la ligne y
    void ligne(ubyte y, uint texte, ubyte valeur) {
        vdc.setCursor(8, y);
        vdc.print(texte);
        vdc.putChar((ubyte)('0' + valeur));
    }
}

Titre.s8u

// Titre.s8u : le contexte de l'écran titre
unit Titre {
    ubyte images;               // remis à zéro à chaque chargement du contexte

    initContext() {
        Commun.ecran(RGB(0, 0, 7), "TITRE");
        Commun.ligne(11, "CHARGEMENTS ", Commun.chargements);
    }

    // attendre 120 images (2 secondes), puis passer au jeu
    main() {
        while (.images < 120) {
        }
        sys.loadContext(Jeu);
    }

    // 60 fois par seconde
    interrupt -> irq1 {
        .images++;
    }
}

Jeu.s8u

// Jeu.s8u : le contexte du jeu
unit Jeu {
    ubyte images;               // remis à zéro à chaque chargement
    ubyte essais;               // remis à zéro à chaque chargement : vaut toujours 1 ici

    shared {
        ubyte parties;          // gardé d'un chargement à l'autre
    }

    initContext() {
        .essais++;
        .parties++;
        Commun.ecran(RGB(4, 0, 1), "JEU");
        Commun.ligne(11, "CHARGEMENTS ", Commun.chargements);
        Commun.ligne(13, "PARTIES     ", .parties);
        Commun.ligne(15, "ESSAIS      ", .essais);
    }

    // attendre 120 images, puis revenir à l'écran titre
    main() {
        while (.images < 120) {
        }
        sys.loadContext(Titre);
    }

    interrupt -> irq1 {
        .images++;
    }
}
Écran de la PC Engine : TITRE, CHARGEMENTS 3
Le contexte Titre.
Écran de la PC Engine : JEU, CHARGEMENTS 4, PARTIES 2, ESSAIS 1
Le contexte Jeu.

À retenir

À vous de jouer

  1. Faites durer l'écran titre cinq secondes au lieu de deux.
    Voir la solutionMasquer la solution

    Cinq secondes, c'est 5 × 50 = 250 images sur C64, et 5 × 60 = 300 images sur PC Engine. Sur C64, il suffit de changer le nombre dans Titre.s8u : 250 tient dans images, un ubyte (255 au plus).

    Sur Commodore 64

    // attendre 250 images (5 secondes), puis passer au jeu
    main() {
        while (.images < 250) {
        }
        sys.loadContext(Jeu);
    }

    Dans l'émulateur de test, le titre apparaît à 0,21 s et le jeu à 5,16 s : cinq secondes, comme prévu.

    Sur PC Engine

    300 ne tient pas dans un ubyte. Le compilateur le remarque, et refuse while (.images < 300) :

    Comparison of Titre.images (ubyte, 0..255) with 300 is always true or always false

    L'idée qui vient ensuite est de déclarer images en uint. Le programme compile, mais nous avons mesuré que le premier écran titre ne dure que 256 images (4,3 s) au lieu de 300. La cause est un piège classique des interruptions : un uint occupe deux octets, et le processeur 8 bits les lit l'un après l'autre. Quand le compteur passe de 255 à 256, si l'interruption arrive entre les deux lectures, main() lit l'octet bas d'avant (255) et l'octet haut d'après (1) : il croit voir 511, et sort de la boucle trop tôt. Cela ne se produit pas à chaque fois, ce qui rend l'erreur difficile à repérer.

    Une solution simple : ne partager entre l'interruption et main() que des ubyte, qui se lisent d'un seul coup. Le gestionnaire compte les images, et toutes les 60 images, ajoute une seconde ; main() attend 5 secondes.

    Titre.s8u

    // Titre.s8u : le contexte de l'écran titre
    unit Titre {
        ubyte images;               // les images de la seconde en cours, de 0 à 59
        ubyte secondes;             // les secondes écoulées depuis le chargement
    
        initContext() {
            Commun.ecran(RGB(0, 0, 7), "TITRE");
            Commun.ligne(11, "CHARGEMENTS ", Commun.chargements);
        }
    
        // attendre 5 secondes, puis passer au jeu
        main() {
            while (.secondes < 5) {
            }
            sys.loadContext(Jeu);
        }
    
        // 60 fois par seconde : toutes les 60 images, une seconde de plus
        interrupt -> irq1 {
            .images++;
            if (.images == 60) {
                .images = 0;
                .secondes++;
            }
        }
    }

    Mesuré dans l'émulateur de test : l'écran titre dure 298 images, soit environ 5 secondes, à chaque passage. Le gestionnaire compte toujours, et main() décide toujours : le partage du chapitre est respecté.

  2. Déplacez essais dans le bloc shared : comment évolue-t-il ?
    Voir la solutionMasquer la solution

    Il suffit de déplacer la déclaration dans le bloc shared, à côté de parties :

    unit Jeu {
        ubyte images;               // remis à zéro à chaque chargement
    
        shared {
            ubyte parties;          // gardé d'un chargement à l'autre
            ubyte essais;           // gardé lui aussi, désormais
        }

    essais n'est plus remis à zéro à chaque chargement du contexte Jeu : il évolue maintenant exactement comme parties. Nous l'avons vérifié sur les deux machines, en relevant l'écran à chaque passage dans le jeu :

    Passage dans le jeu1er2e3e4e
    ESSAIS avant1111
    ESSAIS dans shared1234
    Écran de la PC Engine, contexte Jeu : CHARGEMENTS 8, PARTIES 4, ESSAIS 4
    Au quatrième passage, ESSAIS vaut 4, comme PARTIES.

    La différence avec Commun.chargements reste la même : une variable shared est gardée, mais elle appartient au contexte Jeu ; le titre ne peut pas la lire. Remarquez aussi qu'au dixième passage, le chiffre devient : : Commun.ligne n'écrit qu'un chiffre, avec '0' + valeur.

  3. Ajoutez un contexte Fin, chargé par Jeu au bout de trois parties, qui affiche « FIN » et ne change plus.
    Voir la solutionMasquer la solution

    Trois choses à faire : déclarer le contexte dans main.s8p, écrire sa unit, et décider dans Jeu quand le charger. Le nombre de parties est déjà compté par .parties, qui est dans le bloc shared et survit donc aux chargements.

    main.s8p

    context Fin {
        addunit("Fin.s8u");         // unit principale du contexte Fin
    }

    Le contexte Fin affiche son écran dans initContext(), avec la unit résidente Commun, puis son main() tourne sans fin, sans rien faire. Il n'a pas besoin d'interruption : il n'attend rien.

    Fin.s8u

    // Fin.s8u : le contexte de fin, qui ne change plus
    unit Fin {
        initContext() {
            Commun.ecran(0, "FIN");
        }
    
        // plus rien à faire : on reste sur cet écran
        main() {
            while (1) {
            }
        }
    }

    Dans Jeu.s8u, la décision se prend dans main(), après l'attente, et non dans le gestionnaire d'interruption (on se souvient de l'avertissement du chapitre pour le C64). Comme sys.loadContext ne revient jamais, le else est inutile : si la fin est chargée, la ligne suivante n'est jamais exécutée.

    Jeu.s8u

    main() {
        while (.images < 100) {
        }
        if (.parties == 3) {
            sys.loadContext(Fin);       // trois parties jouées : c'est la fin
        }
        sys.loadContext(Titre);
    }

    Sur PC Engine, c'est le même code, avec while (.images < 120) dans Jeu et Commun.ecran(RGB(0, 0, 0), "FIN"); dans Fin, puisque le fond s'y donne par une couleur.

    Ce que nous avons observé : le titre et le jeu alternent trois fois, puis l'écran « FIN » apparaît vers 12 s et ne change plus. Sur C64, le compilateur crée un fichier de plus, fin.dat (25 octets), et les fichiers lus sur la disquette sont, dans l'ordre : TITRE.DAT, JEU.DAT, TITRE.DAT, JEU.DAT, TITRE.DAT, JEU.DAT, FIN.DAT. Nous avons aussi lancé la disquette dans VICE : l'écran « FIN » y est bien affiché après les trois parties.

  4. Remplacez l'attente de l'écran titre par l'appui sur un bouton de la manette (io.readJoysticks(), io.getButtons(0)).
    Voir la solutionMasquer la solution

    La bibliothèque des manettes s'ajoute dans main.s8p, avec les autres : addlib io.base;. io.readJoysticks() lit l'état des manettes, puis io.getButtons(0) donne les boutons de la première : 0 si aucun n'est appuyé. Sur C64, c'est le bouton de tir du joystick du port 2 ; sur PC Engine, n'importe quel bouton (I, II, SELECT ou RUN).

    L'écran titre n'a plus besoin de compter les images : la variable images et le gestionnaire d'interruption disparaissent. main() attend en deux temps :

    Titre.s8u

    // Titre.s8u : le contexte de l'écran titre
    unit Titre {
        initContext() {
            Commun.ecran(6, "TITRE");
            Commun.ligne(10, "CHARGEMENTS ", Commun.chargements);
        }
    
        // attendre un appui sur un bouton de la manette, puis passer au jeu
        main() {
            // d'abord, que le bouton soit relâché (il est peut-être encore enfoncé)
            io.readJoysticks();
            while (io.getButtons(0) != 0) {
                io.readJoysticks();
            }
            // puis, qu'il soit appuyé
            while (io.getButtons(0) == 0) {
                io.readJoysticks();
            }
            sys.loadContext(Jeu);
        }
    }

    Pourquoi attendre d'abord que le bouton soit relâché ? Parce qu'on arrive sur le titre en venant du jeu, et le joueur a peut-être encore le doigt sur le bouton. Nous l'avons vérifié en simulant les appuis dans l'émulateur de test. Sans la première boucle, un bouton tenu enfoncé fait sauter l'écran titre : il est remplacé par le jeu dans la même image. Avec elle, le titre reste affiché tant que le bouton n'a pas été relâché puis appuyé de nouveau. Sans aucun appui, il reste affiché indéfiniment, comme on le souhaite.

    Le code est le même sur les deux machines (seuls les arguments de Commun.ecran et Commun.ligne, dans initContext(), diffèrent, comme dans l'exemple du chapitre). Le contexte Jeu ne change pas : il garde son interruption pour revenir au titre au bout de deux secondes.