Site logo

Triceraprog
La programmation depuis le Crétacé

  • La palette de couleur de l'Agon Light ()

    Ces derniers temps, je m'amuse avec un AgonLight (ou plus exactement un AgonLight2, qui est la version Olimex).

    Cette machine est assez récente et possède une petite communauté. Sa documentation est par contre très éparse pour le moment. Du plus, la partie graphique de la machine se reposant sur FabGL, une partie des informations intéressantes sont en fait à déduire de cette bibliothèque. Mais d'autres se déduisent de l'implémentation pour la machine du BBC Basic.

    Je vais me servir de ce blog pour prendre quelques notes. Cette semaine, j'ai tourné en rond autour de la gestion de la palette et des modes graphiques disponibles.

    Modes graphiques

    Les modes graphiques, à cette date (MOS 1.03), sont :

    Mode Résolution (Pixels) Fréquence (Hz) Nb. de couleurs Palette?
    0 1024x768 60 2 Oui
    1 512x384 60 16 Oui
    2 320x200 75 64 Non
    3 640x480 60 16 Oui

    Palette de couleurs

    Dans les modes en palette, les couleurs se choisissent parmi l'espace de couleur complet RGB222. Les 4 niveaux pour chaque composante sont 0x00, 0x55, 0xAA et 0xFF. Ce qui donne :

    Palette RGB222 de l'AgonLight

    Attention, la palette est réinitialisée lors d'un changement de mode

    R,G,B ?

    Une chose étonnante, c'est qu'il est possible de spécifier les palettes, et il est obligatoire de spécifier les pixels des surfaces, en RGB888, alors que l'espace de couleur est RGB222. Le VDP (ou plutôt FabGL), va chercher à trouver les couleurs en fonction. Ce n'est pas hyper pratique au premier abord, et c'est un bon gâchis d'espace.

    Mieux vaut ne spécifier que des couleurs faisant partie de la palette.


  • La Maison dans la colline, partie 7 ()

    Dans ce septième article de la série sur le jeu « La maison dans la colline », il va être question de tests anti-regression.

    Regressions

    Mais qu'est-ce qu'une regression ? C'est un fonctionnement qui donnait toute satisfaction et qui, suite à un changement dans le système, se met à ne plus fonctionner comme attendu. Autrement dit, une apparition de bug !

    Les bugs n'arrivent jamais de nulle part, il y a toujours une raison. Mais plus un programme est grand, plus il se complexifie et plus le risque de programmer des morceaux qui entrent en conflit apparaît. C'est à peu près inéluctable et le développement d'un logiciel est, normalement, accompagné d'un certain nombre de règles pour éviter au mieux et surtout repérer au plus vite les défauts qui apparaissent.

    La vitesse de détection est importante, car il une regression peut ne pas être immédiatement flagrante. Il est possible que quelque chose casse sur une partie « éloignée » de ce sur quoi on travaille sur le moment. Il est aussi possible que le défaut soit subtile ; présent, mais pas évident. Tout à l'air de bien fonctionner en apparence, mais pas dans les détails. Et si on continue à développer avec ce défaut présent, il se peut très bien que l'on amplifie le problème, ajoutant du bug à du bug.

    La vitesse est aussi importante pour une question de contexte. Lorsque l'on a en tête une partie en train d'être travaillée, il est plus simple de corriger ce qui vient d'être modifié que lorsque l'on s'en rend compte plus tard, lorsqu'on est passé à autre chose.

    Dans le contexte de « La maison dans la colline », je suis tout seul et, comme je l'ai déjà dit auparavant, j'essaie de maximiser mon temps libre passé sur le projet. Ainsi, une session à essayer de trouver et corriger la raison d'un bug introduit deux sessions avant ne m'enchante pas du tout.

    J'ai donc mis en place deux systèmes pour m'aider à détecter rapidement les bugs

    Tests unitaires

    Le premier système est un système simpliste de tests unitaires. Le principe est de mettre le système dans un certain état, de faire une opération, puis de vérifier que le système est dans l'état attendu.

    J'ai mis en place ce système après avoir débuté le projet et lorsque celui-ci commençait à devenir un peu complexe. Malheureusement ou heureusement, j'avais pris quelques raccourcis qui rendaient certains tests un compliqués. Cela m'a pris un peu de temps pour nettoyer ça ; au passage, j'en suis sorti avec quelques nettoyages bienvenue dans le code.

    Je n'ai pas non plus beaucoup de tests. Majoritairement, je fais des tests sur la gestion de l'inventaire, qui m'a causé quelques soucis et qui a été ce qui m'a décidé à mettre des tests unitaires. Je fais aussi quelques tests sur mon micro-allocateur de mémoire dynamique (celui de z88dk ne me convenant pas).

    Lorsque je lance le programme avec une option de compilation, les tests sont exécutés dans une page spéciale, au démarrage du jeu. Je peux ainsi m'assurer que les tests passent et que donc le fonctionnement de mon système d'inventaire et d'allocation sont toujours d'aplomb.

    Voilà à quoi ressemble le test des objets, une fois que j'ai mis en place un environnement de tests avec des objets dans des pièces :

        CHECK(object_count_in_room(ROOM(1)) == 2);
        CHECK(object_count_in_room(ROOM(254)) == 1);
        CHECK(object_count_in_room(ROOM(255)) == 0);
    

    Et voilà une capture d'écran lors d'une regression :

    Liste terminée des fonctions du jeu.

    Mode automatique

    L'autre système que j'ai mis en place est un mode de jeu automatique. Au bout d'un moment, et alors que le nombre d'actions et de pièce commençait à grandir, il me fallait régulièrement jouer toute une première partie du jeu. Je vérifiais les mouvements, les portes et changements de pièce, la prise d'objet dans l'inventaire, l'utilisation d'un objet.

    Ça a rapidement été long, pas très amusant et source d'erreurs : est-ce que je fais bien les mêmes étapes ? Est-ce que j'ai encore envie de le faire parce que ça m'ennuie ? Est-ce que quelque chose ne va pas casse pile la fois où j'aurai la flemme de conduire les tests ?

    Solution : automatiser le test.

    Lorsque je compile le jeu avec le mode automatique inclue, je déroule un petit script qui va exécuter les actions à ma place. C'est assez simpliste, je n'ai pas de retour d'erreur automatisé. Mais au moins, si je vois quelque chose d'étrange, ou bien si le personnage se retrouve bloqué sur une étape, je peux le voir rapidement. Et je peux alors conduire un test manuel pour comprendre dans les détails ce qu'il se passe.

    Il n'y a pas beaucoup d'étapes dans ce test automatique, car c'est un peu fastidieux à maintenir. J'ai hésité un moment à rendre le système plus malin, avec une création de script depuis les données du jeu. Par exemple en trouvant le chemin pour aller d'un point A à un point B automatiquement. Mais je ne suis pas allé jusque là, j'ai trouvé un compromis avec un système qui « tente » d'aller vers une position, mais de manière simpliste, que je dois aider manuellement parfois.

    Mais que de temps gagné au final !

    Voici à quoi ressemble le début du script, qui est stocké dans un tableau de caractères dans le code source :

    unsigned char script[] =
            {
                    'W', 'P', 4, // Wait page 4 (test page)
                    'W', 'T', 20,// Wait frames
                    'K', ' ',    // Press ' '
                    'W', 'P', 0, //
                    'K', ' ',    // Press ' '
                    'W', 'P', 1, //
                    'K', 'A',    // Press 'A'
                    'W', 'T', 5,
                    // Go to Kitchen
                    'K', KEY_UP,
                    'K', KEY_UP,
                    'K', KEY_UP,
                    'K', KEY_UP,
                    'G', 'R', 2,// Go to Room 2
    

    Conclusion

    J'ai déjà évoqué mon goût pour les outils et les automatisation dans les articles précédents. L'automatisation des tests est un outil de plus pour se simplifier la vie. Cela demande un peu d'effort en amont, mais force parfois à mieux réfléchir son code pour le rendre flexible, ce qui est bénéfique lors de la mise au point du jeu, et permet un gros gain de temps sur la durée du projet, pour peu que l'on trouve le bon équilibre entre le temps passé à créer les outils et le gain de temps espéré grâce à eux.


  • La Maison dans la colline, partie 6 ()

    Dans ce sixième article de la série sur le jeu « La maison dans la colline », il va être question des structures du jeu, de portage et de « binarisation ».

    Les structures

    « La maison dans la colline » est un jeu programmé en grande partie en C. L'idée derrière est de pouvoir porter assez facilement sur une autre machine qui n'aurait potentiellement pas le même processeur, c'est aussi une manière de faciliter les itérations. Le jeu manipulant des objets, des pièces pour circuler, un personnage, il est intéressant de pouvoir se reposer sur des structures de données et de les manipuler, de les faire évoluer, sans avoir à adapter un code assembleur en parallèle (même s'il existe des assembleurs qui peuvent faciliter ces opérations).

    Les pièces

    La première structure que je présente est celle des pièces de la maison et des portes qui les relient. Ces données sont fixes et pourraient se situer en ROM si je jeu était sur ROM.

    typedef struct Door {
        unsigned char position[2];
        unsigned char destination_room;
        unsigned char destination_position[2];
    } Door;
    

    Une porte a une position dans la pièce et amène vers une pièce de destination (identifiée par un octet) à une position donnée dans cette pièce de destination.

    typedef struct Room {
        unsigned char id;
        unsigned short shift_to_next;
        unsigned short shift_to_doors;
        unsigned char position[2];
        unsigned char size[2];
        unsigned char enter_text;
        unsigned char door_count;
        Door* doors;
        unsigned char data[];
    } Room;
    

    Une pièce est un peu plus complexe. Elle est identifiée par un octet (id) qui est suivi par deux nombres de 16 bits qui sont en fait des déplacements en mémoire. shift_to_next est un offset de chaînage vers la pièce suivante. Toutes les données des pièces sont contiguës en mémoire formant une liste chaînée unidirectionnelle. Ainsi, avec un pointeur vers une structure Room, si on avance de shift_to_next octets, on arrivera sur la pièce suivante dans les données.

    shift_to_doors est un peu plus complexe. C'est une indication qui permet de construire le pointeur doors un peu plus loin dans la structure.

    Comme on peut le voir, la structure Room se termine par un tableau de taille non spécifiée d'octets. Dans ce tableau se trouvent les données graphiques de la pièce suivies par les données des portes présentes dans la pièce. Ces deux données sont de taille variable et s'il est facile de connaître l'emplacement des données graphiques (c'est data), il est plus compliqué de connaître le début des portes qui suivent. Surtout que les données graphiques sont compressées.

    Il y aurait plusieurs autres manières de faire. J'aurais pu mettre les portes (dont les données ne sont pas compressées) en premier et calculer le déplacement à partir du nombre de portes qui est une donnée connue. Mais les portes ont connu différentes implémentations et se sont finalement retrouvées là. Puis la fin du projet est arrivée et elles y sont restées.

    La position et la taille (size) de la pièce indiquent la façon dont elle doit-être affichée à l'écran. enter_text est un identifiant vers le texte qui apparaît à l'écran en entrant. Et door_count comme son nom l'indique, précise le nombre de portes présentes dans la pièce.

    Les données graphiques d'une pièce sont compressées selon un schéma RLE. Lorsqu'on entre dans un pièce ces données sont décompressées dans une zone temporaire et envoyées à l'affichage.

    Les objets

    La structure qui décrit les objets est la suivante. Là encore, ce sont des données fixes.

    typedef struct Object {
        unsigned char name_id;       // the resource id for the text in the inventory. Used also to designate the object.
        unsigned char char_mode;     // what mode for the display
        unsigned char character;     // what char to display
        unsigned char properties;    // object properties
        unsigned char room_id;       // initial room
        unsigned char position[2];   // initial position in the room
        unsigned char action_text_id;// text id when the action is done on the object
    } Object;
    

    Magnifique, le code est commenté.

    Un objet est donc identifié par un identifiant name_id qui est aussi l'identifiant du texte qui y est associé. C'est un choix que j'ai regretté, il aurait été bien plus pratique d'avoir un identifiant pour l'objet lui-même séparé du texte qui le décrit. Plus loin, on voit un autre identifiant du texte écrit lorsque l'on effectue une action. Là encore, c'est assez peu flexible et cela m'a obligé à ne considérer qu'une seule action par objet. Je m'en suis sorti et on dit que les contraintes amènent de la créativité.

    char_mode et character donnent les informations d'affichage. Il n'y a pas de couleur car les objets ont une couleur fixe dans ce jeu, pour indiquer que des actions peuvent être faites dessus.

    properties indique ce que l'on peut faire de l'objet : est-ce qu'on peut le prendre, est-ce qu'on peut le lire, est-ce que c'est un déclencheur d'évènement, est-ce que l'on peut marcher dessus, est-ce qu'il est transformable en un autre objet et enfin, est-ce que c'est un téléporteur.

    Les téléporteurs sont en fait les portes. Initialement, j'avais un système spécifique de traitement des portes. Je l'ai plus tard unifié avec le traitement des objets de manière générale.

    L'objet a aussi un pièce (room_id) et un emplacement (position) qui désignent l'endroit où se trouve l'objet en début de jeu. Cette information est immuable et servira lorsque l'on relance le jeu à tout remettre en place. Au début du jeu, un tableau des localisations réelles des objets est créé en mémoire et ce tableau qui sera modifié en fonction des actions.

    Il existe deux pièces spéciales dans le jeu. Un pièce « nulle part » dans laquelle sont déplacés les objets qui ne sont plus valides (par exemple, une clé après avoir été utilisée). La seconde pièce est « l'inventaire ». Cela permet de s'assurer qu'un objet est toujours dans une pièce. Prendre un objet, c'est changer sa pièce courante pour celle de l'inventaire. En échangeant ses informations avec l'objet qu'il remplace dans l'inventaire, ce dernier est naturellement posé dans la pièce.

    La binarisation

    Partie logique

    Si au début du développement il est possible d'indiquer directement dans le code les pièces (non compressées) et les objets, ça se révèle rapidement impraticable. Pour mettre au point le jeu, un éditeur est plus pratique. Cependant je n'avais non beaucoup de temps à consacrer au développement d'un éditeur de jeu.

    Dans ces cas là, une manière classique de faire est de travailler sur des fichiers texte que l'on transpose dans le format binaire attendu par le jeu. D'où le terme « binarisation ». Un autre terme existe : « cooking »... et probablement d'autres.

    Voici à quoi ressemble la première pièce du jeu :

    Room ; Entrée
    Id: 1
    Position: 4,8
    Size: 9,15
    EnterText: 42
    
    Description
    #########
    ####D####
    #       #
    #       #
    ##      #
    #       #
    #       #
    E       #
    E       #
    ##     ##
    ##     ##
    #       #
    #   i   #
    ####F####
    #########
    
    Doors
    D:3|E^
    E:2|E<
    F:255|A>
    
    Objects
    i:G'10,68,None,9 ; Apparition
    
    Locks
    F:18,56
    
    EndRoom
    

    Tous les # sont des emplacements bloquants : des murs ou des objets de décors. Les autres caractères (souvent des lettres) sont des emplacements spéciaux décrits à la suite de la partie graphique. Ainsi, on voit trois portes, un objet et un verrou.

    Les portes sont suivies d'un petit code qui indique la pièce d'arrivée et un emplacement sous la forme d'une lettre (que l'on trouvera dans cette pièce) ainsi qu'une direction naturelle pour le sprite du personnage.

    Les objets sont suivis d'informations graphiques (G'10,68 signifie : caractère numéro 68 dans la palette G'10), des propriété et d'un identifiant de texte. Ce qui suit le point virgule est un commentaire, il n'est pas lu.

    Toutes ces données sont traitées et envoyées dans un fichier de données qui sera inclus au jeu.

    Partie graphique

    La parte graphique est elle aussi binarisée. Pour cela, j'utilise Pixelorama avec une palette d'objets graphiques et je dessine la pièce. La binarisation s'occupe de découper cela en morceaux de 10 pixels par 8 afin de construire la liste des caractères à redéfinir.

    C'est à moi de m'assurer que les données logiques et graphiques sont cohérentes. Entre autre que les tailles de pièces soient identiques. Il y aurait de la marge pour aller plus loin avec un éditeur mais encore une fois, c'était dans un délai trop court pour cela. Peut-être plus tard ?

    La suite ?

    En effet, l'idée que j'avais en essayant de construire des structures réutilisables et flexibles étaient de pouvoir les... réutiliser. Et pourquoi pas étendre le jeu ou bien en faire un autre sur le même principe ? Avec cette fois un peu plus de temps à passer sur les outils.

    Pourquoi pas. C'est une idée que je garde dans un coin de la tête.


  • La Maison dans la colline, partie 5 ()

    Dans ce cinquième article, je vais aborder la méthodologie que j'ai appliquée pour le développement de « La maison dans la colline ».

    La planification

    Dans un premier temps, j'avais jeté sur papier (électronique) la liste des fonctionnalités que je voulais implémenter, en partant de l'idée générale du jeu et en descendant successivement sur ce dont je pensais avoir besoin. Puis j'ai segmenté cette liste en thèmes, comme par exemple « mouvements du personnage » ou bien « gestion de l'inventaire ».

    Ces fonctionnalités ont besoin les unes des autres, je suis descendu jusqu'aux briques de bases, comme « afficher quelque chose à l'écran » ou bien « lire une touche du clavier ». Entre toutes ces fonctionnalités, j'ai créé des dépendances : afficher un personnage nécessaire de savoir afficher quelque chose à l'écran. L'animer nécessite de savoir l'afficher. Et ainsi de suite.

    Mes dépendances ne sont pas complètes. J'ai celles qui concernent les premières tâches à effectuer, mais c'est tout. Inutile de travailler sur celles qui viendront bien plus tard, et ce pour une raison très simple : il est très probable qu'elles changent au fur et à mesure que j'avance dans le développement. Voire pour certaines, qu'elles disparaissent.

    Liste terminée des fonctions du jeu.

    La qualité progressive

    Dernière étape, pour chaque fonctionnalités, séparer des niveaux de qualité. Par exemple, je sais que je vais un personnage avec un affichage fin, et animé. Mais je ne sais pas encore trop ce que ça donne d'un point de vue jeu, est-ce qu'il me faut un sprite de 8 pixels de large ou de 16 pixels de large ? J'ai même joué avec l'idée à un moment qu'il fasse 8 de large de côté, mais 16 de large de face.

    Je sais que dessiner un sprite va me prendre du temps, surtout que ça n'est pas mon domaine. Je ne veux pas avoir à le refaire trop de fois. Donc dans les premiers niveaux de qualité, je note que je vais afficher des caractères prédéfinis de l'ordinateur.

    Version de développement de MDLC

    Je note ensuite que je pourrais tenter avec un personnage fixe. Puis enfin un personnage animé. Cela me donne trois étapes de qualité pour cette fonctionnalité. Et celles-ci ne seront pas forcément exécutées consécutivement. C'est important car le temps de la jam est limité et je fais ça sur mon temps libre, qui est très variable. Si la fin arrive avant qu'un niveau de qualité soit atteint, ce n'est pas grave, j'aurais tout de même quelque chose de moins joli que prévu, mais quelque chose quand même.

    Je fais de même avec les fonctionnalités : je les classe par importance, mêlée de difficulté. Un personnage qui se déplace, c'est essentiel. Parmi les interactions avec les objets, j'en avais initialement prévus beaucoup plus que ce qui est dans le jeu à la fin. Quant à l'audio... je n'ai pas eu le temps et j'ai laissé de côté.

    De même j'avais prévu quelques scènes graphiques d'illustration en « haute résolution ». C'est passé à la trappe.

    Sur le long terme

    À chaque fois que je termine une fonctionnalité, je reviens sur le document et j'ajuste. En voyant le jeu progresser, je comprends qu'il y a des choses que je voulais faire qui ne vont pas avec le reste. Ou parfois, je vois qu'il me manque un morceau, que j'avais oublié quelque chose.

    Ce document va souvent être modifié. Je garde l'idée générale du jeu, les grandes lignes. Mais je me laisse aussi porter par ce qu'il devient. Ça m'évite de me bloquer sur quelque chose trop longtemps alors que ça ne fonctionne pas, que ce soit techniquement ou en game design.

    Il ne faut pas hésiter à couper par manque de temps, ou parce qu'on a vu trop gros pour la machine. À modifier parce que ça ne convient plus. Ou parce qu'une nouvelle idée arrive. Ce dernier cas est à évaluer avec prudence ceci dit : il faut l'intégrer correctement à l'existant, et à l'état actuel du projet. Les idées arrivent toujours plus nombreuses et plus rapidement que leur temps de développement nécessaire.

    La programmation

    Niveau programmation, j'applique là aussi une démarche itérative « par petits pas ». En reprenant l'affiche, par exemple, je commence par m'adresser directement à l'EF9345, à travers une liste d'affichage, avec une position et un caractère fixes. Puis j'extrais la position pour qu'elle devienne variable, mais toujours localement. Puis je la passe par paramètre de la liste d'affichage. Et enfin je l'injecte depuis le programme principal, avant de répéter la même démarche pour le caractère affiché.

    L'idée ici est de vérifier que le code fonctionne sur un cas simple, s'assurer qu'on a bien compris le problème. Dans le cas de l'EF9345, être certain qu'on a bien compris son fonctionnement par exemple. Puis petit à petit, on généralise, si nécessaire, avant d'extraire les données variables.

    Cela permet de valider chaque étape individuellement et d'éviter les bugs dus à un développement avec beaucoup de changements différents. Cela permet aussi de s'arrêter en chemin. Soit pour passer à autre chose que l'on développe en parallèle car en lien. Ou tout simplement parce que c'est l'heure de manger, et qu'il est toujours préférable de laisser le programme dans un état fonctionnel sur lequel on peut reprendre plus tard.

    Et enfin, parce que parfois, on s'aperçoit qu'il n'est pas nécessaire de généraliser plus avant. Ou alors pas tout de suite. Et peut-être jamais. La fonctionnalité arrive à un point satisfaisant.

    Conclusion

    S'il faut retenir une chose de cette méthodologie, c'est le concept d'avancée graduelle. Faire des « petits pas », monter petit à petit en qualité et savoir s'arrêter.


  • La Maison dans la colline, partie 4 ()

    Dans ce quatrième article concernant le développement de « La maison dans la colline », je vais aborder quelques points de programmation. Deux points en particulier : la structure générale du programme, puis les listes d'affichage.

    Structure générale

    Un jeu vidéo, c'est un programme qui ne s'arrête pas. Enfin si... quand on a fini de jouer. Mais il s'oppose aux programmes en « batch » qui doivent résoudre une fonction à partir de données en entrée. Un jeu vidéo se situe donc dans la classe des applications qui font évoluer un état en fonction des entrées de l'utilisateur.

    Ainsi, un tel programme peut se résumer à cette structure :

    int main()
    {
        while(running())
        {
            read_input();
            update_state();
            display_state();
        }
    }
    

    Autrement dit : tant que le logiciel tourne, on lit les entrées, on met à jour les états du jeu, on affiche l'état du jeu (on peut aussi diffuser le son, mais ce jeu n'en n'a pas) et on recommence.

    Voilà la base.

    Au deuxième étage, le jeu est constitué de « pages ». Il y a la page d'accueil, avec le titre, la page d'introduction, qui donne le texte de début, le jeu en lui-même, et le texte de fin. Comme le jeu est à tout moment dans une seule de ces pages, j'utilise pour les représenter une paire de fonctions. L'une est appelée lorsque l'on entre dans la page, et l'autre à chaque mise à jour, en boucle, tant que cette page est active.

    Fonction d'entrée

    La fonction d'entrée permet de changer le contexte du jeu, de mettre l'écran dans les bonnes conditions. Par exemple, voici la fonction d'entrée de la page de titre :

    void page_title_enter(PageContext* context) __z88dk_fastcall
    {
        initialize_40_long();       // Initialisation du mode 40 colonne format long de l'EF9345
        clear_40_long();            // Effacement de l'écran dans ce mode
    
        // Affichage du titre
        set_print_attributes(M40LONG_COLOR_FG_WHITE, M40LONG_DOUBLE_HEIGHT);
        const char* title = extract_string_from_id(TEXT(0));
        print_at(2, 12, title);
        print_at(2, 13, title);
    
        // Affichage de l'auteur
        set_print_attributes(M40LONG_COLOR_FG_WHITE, 0);
        const char* author = extract_string_from_id(TEXT(71));
        print_at(20, 17, author);
    
        // Affichage du PRESS START
        set_print_attributes(M40LONG_COLOR_FG_WHITE | M40LONG_FLASH, 0);
    
        const char* press = extract_string_from_id(TEXT(1));
        print_at(2, 22 + 8, press);
    }
    

    Quelques commentaires :

    • le paramètre context n'est pas utilisé ici. Nous verrons dans la fonction suivante son utilité. À vrai dire, je ne l'ai jamais utilisé dans les fonctions d'entrée et il devrait probablement être enlevé.
    • __z88dk_fastcall est une annotation pour la suite z88dk qui indique au compilateur que le pointeur passé en argument devra être passé dans HL, plutôt que par la pile. Nous verrons plus tard que c'est bien pratique lorsque l'on mélange C et assembleur.
    • les chaînes de caractères sont appelées via un système d'accès à des ressources. J'en parlerai probablement dans un autre article plus tard.

    Fonction de mise à jour

    L'autre fonction qui décrit une page est celle de la mise à jour. Elle sera appelée en boucle tant que la page est active. Contrairement à ce que j'indiquais au tout début de l'article, il n'y a pas de séparation entre la mise à jour et l'affichage. Dans ce programme, la fonction de mise à jour s'occupe des deux étapes.

    Voici par exemple la fonction de mise à jour pour l'écran de titre :

    void page_title_update(PageContext* context) __z88dk_fastcall
    {
        if (context->just_pressed_key != 0)
        {
            context->command = CHANGE;
            context->command_param = INTRODUCTION_PAGE;
        }
    }
    

    Commentaires :

    • ici, le context est utilisé. On peut voir qu'il sert de communication avec l'état extérieur à la page.
    • en lecture, le context fourni une information sur une touche du clavier éventuellement appuyée.
    • en écriture, le context permet de donner une information à transmettre à l'extérieur. Ici, l'information est celle d'un changement de page.

    En effet, au-dessus des pages, il y a un petit superviseur, qui n'est en fait rien d'autre que la boucle principale du jeu. Celle-ci va s'occuper de peupler le context avec les informations systèmes, dont les touches du clavier, puis va vérifier si la page en cours a donné une commande. Il y a trois commandes possibles :

    • CHANGE : qui indique une page de destination, et qui provoquera donc un changement de page. Ici, lorsqu'une touche est appuyée, on passe à la page d'introduction du jeu.
    • RUN : qui indique que la page actuelle doit continuer à être appelée, c'est la page active.
    • STOP : qui demande l'arrêt du programme. C'est une commande que j'ai utilisée en début de développement pour certains tests, mais que j'ai arrêté d'utiliser. Le programme ne s'arrête pas.

    Voilà pour la structure générale du jeu. Passons maintenant aux listes d'affichage.

    Les listes d'affichage

    Comme je l'avais mentionné dans un article précédent, je voulais dans ce jeu avoir un affichage rapide avec lequel le scintillement de mise à jour était, au moins dans la majorité des cas, invisible. Et cela signifie une chose : il faut afficher vite, et au bon moment.

    Si la mise à jour de l'écran se fait au fil de la mise à jour de l'état du jeu, il y a de bonnes chances qu'une mise à jour de l'affichage arrive au mauvais moment. De plus, l'EF9345 est pleinement efficace en début de traçage de l'écran, dans la zone de bord haute. Par la suite, il commence à être occupé avec la génération de l'image et a moins de temps à consacrer aux données qui arrivent depuis le Z80.

    Il y a deux outils classiques pour cela :

    • la synchronisation verticale, qui permet de savoir quand le processeur vidéo commence une nouvelle image,
    • les listes d'affichage (Display Lists en anglais), qui sont des listes de commandes préparées pour être exécutées le plus vite possible. Ou en tout cas, « assez vite »

    Mise en place

    Par bonheur, le VG5000µ est conçu de manière à être mis au courant d'une synchronisation verticale. En effet, le signal de synchronisation, envoyé par l'EF9345, est branché sur l'interruption (IRQ) du Z80. Comme je l'avais abordé dans l'article sur les hooks de la ROM du VG5000µ, le système appel une emplacement en RAM avant d'effectuer son affichage. Il est possible remplacer l'instruction RET initialement positionné à cette adresse par un saut (JMP xxxx) vers l'adresse que l'on veut.

    Et c'est une des premières opérations que fait le programme : une mise en place d'un routage vers l'exécution des listes d'affichage en cours. La routine dépile aussi l'adresse de retour sur la pile, afin de désactiver complètement l'affichage géré par la ROM.

    On est donc en contrôle complet de l'affichage du VG5000µ. Une lourde responsabilité !

    Gestion de listes

    Il y a de nombreuses manières différentes d'implémenter des listes d'affichage, en fonction des besoins en vitesse balancés avec la place prise et probablement d'autres paramètres encore.

    Dans ce programme, les listes d'affichage sont formées par une suite d'appels, au sens machine (CALL) vers de fonctions spécialisées. Ce sont des listes chaînées qui ont le format suivant :

    • l'adresse du lien vers l'élément suivant, ou zéro pour la fin de liste,
    • l'adresse d'appel,
    • les paramètres de l'appel.

    Ajouter un élément à la liste d'affichage est donc ajouter à la liste une nouvelle adresse d'appel ainsi que des arguments, puis ajuste le lien de la chaîne.

    Rejouer une liste consiste à parcourir la liste et appeler les fonctions spécialisées en fournissant les paramètres.

    Tout cela est stocké dans l'espace de la RAM que la ROM utilise normalement pour traiter l'affichage. Il y a là plein de place inutilisée.

    J'ai considéré un temps utiliser aussi l'espace des variables internes du BASIC, mais z88dk en utilise une partie, par exemple pour la lecture des touches, que je n'ai pas réécrite. Il est cependant possible d'utiliser cette partie avec un peu plus d'efforts.

    Un exemple

    Dans la fonction d'affichage de l'écran titre plus haut, il est cette ligne :

        set_print_attributes(M40LONG_COLOR_FG_WHITE, M40LONG_DOUBLE_HEIGHT);
    

    Cette fonction set_print_attributes n'agit pas directement sur l'EF9345. Si on regarde son implémentation, on peut voir qu'elle se contente d'ajouter à la liste d'affichage un appel différé vers la routine spécialisée :

    void set_print_attributes(unsigned char a, unsigned char b)
    {
        params[0] = a;
        params[1] = b;
        add_to_display_list(dl_set_attributes, 2, (void*) params);
    }
    

    Les trois paramètres indiquent :

    • l'adresse de la routine spécialisée,
    • la taille des paramètres, en octets,
    • les paramètres, sous forme d'un buffer qui sera copié dans la liste d'affichage.

    Au prochain traçage de l'écran, la routine sera alors appelée. Dans ce cas précis, c'est une routine en assembleur.

        ; Sets the A and B attributes to used for KRF
        ; Parameters are A then B
    _dl_set_attributes:
        ef9345_addr EF9345_SEL|REG_R3_CMDST ; Loads A
        ld          a,(hl)
        ef9345_data a
        call        wait_impl
        ef9345_addr EF9345_SEL|REG_R2_CMDST ; Loads B
        inc         hl
        ld          a,(hl)
        ef9345_data a
        call        wait_impl
        ret
    

    Mais il est possible d'appeler une fonction C annotée avec l'attribut __z88dk_fastcall vu plus haut. Ainsi, la fonction C recevra le buffer de paramètre naturellement. Il n'existe qu'une seule fonction dans le programme qui utilise cette méthode, car j'ai progressivement réécrit les autres en assembleur. C'était cependant bien pratique pendant le développement.

    Cette fonction s'occupe de l'affichage des pièces, et commence comme ceci :

    void dl_display_room(const RoomNew** p_room_param) __z88dk_fastcall
    {
    

    Conclusion

    Le système de page est simple et efficace dans le cadre de ce jeu. Et le système de liste d'affichage a bien joué son rôle : l'affichage du jeu est rapide et agréable. Il a fallu quelques ajustements sur matériel réel sur quelques synchronisation, car j'envoyais parfois les commandes trop vite, ce qui est accepté par les émulateurs, mais pas par un vrai VG5000µ.

    Dans le prochain article, je compte parler de la méthodologie de développement que j'ai utilisé sur le jeu. À la prochaine !


« (précédent) Page 2 / 20 (suivant) »