Il y a deux ans (wouah, déjà — hé oui, le 20 novembre 2020…), je sortais Tristam Island, jeu d’aventure conçu pour tourner sur vieilles machines rétro. (Le saviez-vous ? Je l’ai aussi traduit en français l’année suivante !) Et en faisant ce jeu, j’ai été poussé dans certains retranchements, ce qui m’a amené à faire des choses que je n’avais jamais faites. Alors autant vous en faire profiter ! Le sujet d’aujourd’hui : comment économiser des objets en Inform 6.

Pourquoi économiser des objets ? Il n’y a vraiment qu’une réponse possible : à cause du format Z3, dont je vous parlais dans ces colonnes — avec même des astuces pour faire un jeu en français dans ce format. Dans un jeu Z3, on ne peut avoir que 255 objets ; dans les autres formats Z-machine et Glulx, vous n’avez jamais besoin de vous en soucier. Mais lisez donc, il y a peut-être des astuces intéressantes pour vous dans ce qui suit…

Quand j’ai eu fini de faire ma carte sur Trizbort, avec la liste des objets nécessaires pour résoudre les énigmes, j’ai appuyé sur le bouton « Exporter en Inform 6 » et… j’avais 254 objets. Autant dire, une faible marge de manœuvre… Heureusement, il y a des trucs et astuces ! Je ne prétends pas que ces trucs et astuces sont applicables à votre jeu, mais ce sont des exercices intéressants qui permettent de se confronter aux possibilités du parser Inform et le dompter pour faire ce qu’on veut. J’insérerai dans l’article les bouts de code correspondants, mais pas de souci si personne ne s’en sert jamais. 🙂

Scenic5sens et cheap_scenery

L’idée de ces extensions Inform 6 est relativement similaire : pouvoir ajouter des réponses aux commandes d’un joueur qui voudrait regarder des objets du décor, mais sans avoir à consacrer un objet entier à un mur. Le degré de sophistication des réponses dépend de l’extension, mais c’est tout de même bien pratique.

En des temps immémoriaux (en 2007), stormi avait écrit une extension nommée scenic5sens, basée sur une idée de Roger Firth. Cette extension permet de définir un objet dans une pièce, et de spécifier des réponses à l’application de verbes des 5 sens (examiner, toucher, écouter, sentir et, bien entendu, goûter) à des mots spécifiques. On peut ainsi, dans une fête foraine, définir « sentir hot-dogs », « écouter musique », « sentir barbe à papa » et « lécher sucette » sans avoir besoin de définir un objet Inform 6 pour chacun de ces noms. Très pratique !

Pour ceux qui ne sont pas au courant (ceux qui ne sont pas sur notre Discord, donc), la blague récurrente est que stormi adore goûter le soleil. C’est tout à fait vrai et l’intéressé ne s’en défend pas le moins du monde ; la blague est tellement répandue qu’elle est devenue un easter egg dans certains jeux. Ma théorie est que scenic5sens a été créée par stormi pour inciter les gens à écrire quelque chose spécifiquement pour « goûter soleil ».

Une autre extension sur le même principe est une extension (pour PunyInform, mais je pense qu’elle doit être compatible avec la bibliothèque standard ?) par Fredrik Ramsberg et Johan Berntsson, cheap_scenery. L’idée est similaire : un seul objet à déclarer, et une propriété qui spécifie des noms auxquels le parser devrait réagir et donner une réponse particulière. L’extension a depuis mai 2022 une nouvelle fonctionnalité : on peut maintenant spécifier des routines contenant des réactions à n’importe quel verbe, tout ça pour n’importe quel nom spécifié dans l’objet — ce qui rend l’extension bien plus puissante et, oui stormi, nous permet ainsi d’écrire une réponse pour les goûteurs de soleil.

À ma sauce

Quand j’ai écrit Tristam Island, je voulais économiser des objets, mais pouvoir prendre en compte beaucoup de commandes sensées (et c’était avant que SceneryReply n’existe). Alors bien sûr j’ai fait mon propre système, c’est à dire du gros code spaghetti qui tache, qui m’économise des objets mais me rajoute plusieurs kilo-octets de code (sachant que je n’ai que 128 ko au total…). L’idée est d’avoir une grosse routine parse_name qui fait son travail (dire au parser combien de mots consécutifs de l’input se rapportent à l’objet) mais en répondant pour plusieurs objets à la fois, et en levant un flag correspondant à l’objet reconnu, pour que le reste du code de l’objet (le nom affiché, la description, etc,) puisse s’en servir. C’est technique, et ça ressemble à ça :

Object  FarAwayElements
  with  found_in [; rtrue; ],
        parse_name [ w n ;
            WE_PARSED_A_WORD = 0;

            n = 0; w = NextWord();
            while(w) {
                if (w == 'sea' or 'ocean' or 'atlantic' or 'waves' or 'seawater' or 'surface' or 'salt' || (location ~= Narrowstrait && location ~= Centralplaza && w == 'water')) {
                        if (PARSED_SEA == false) {
                            if (WE_PARSED_A_WORD) { return n; } else { ClearParsingFlags(); WE_PARSED_A_WORD = true; PARSED_SEA = true; }
                        } jump parsedaword1;
                    }
                if (w == 'sun' or 'sky' or 'blue') {
                        if (PARSED_SKY == false) {
                            if (WE_PARSED_A_WORD) { return n; } else { ClearParsingFlags(); WE_PARSED_A_WORD = true; PARSED_SKY = true; }
                        } jump parsedaword1;
                    }
                if (w == 'ridge' or 'plateau' || (w == 'hill' && location ~= Deepinthewoods) || (w == 'island' or 'this' && location ~= Inpub && ~~TestScope(TristamIsland) )) {
                        if (PARSED_ISLAND == false) {
                            if (WE_PARSED_A_WORD) { return n; } else { ClearParsingFlags(); WE_PARSED_A_WORD = true; PARSED_ISLAND = true; }
                        } jump parsedaword1;
                    }
                ! Si on est là, c’est qu’on n’a pas matché : stop.
                return n;
    .parsedaword1;
                ! Si on est là, c’est qu’on a matché : continuons.
                n++; w = NextWord();
            }
            return n;
],
        short_name [;
            if (PARSED_SEA) { print "the Atlantic Ocean"; rtrue; }
            if (PARSED_SKY) { print "the sun"; rtrue; }
            if (PARSED_ISLAND) { print "this island"; rtrue; }
],

Ce n’est pas la version complète, qui fait 7 objets différents, sans compter les autres objets avec la même structure…

Grosso modo, l’algorithme est : « Si tu vois un mot que tu aimes, regarde si tu étais en train de parser un autre mot (« x plage falaise ») ; si non, tout va bien, continue à compter et parser. » Mais comme vous pouvez le voir, les conditions que je peux spécifier peuvent être complexes, et me donnent beaucoup de contrôle (et de perfectionnisme) pour pouvoir éviter que des petits malins me fassent remarquer qu’on ne voit pas la mer depuis la forêt. L’algorithme m’a pris un certain temps, avec des petites subtilités (genre comment faire pour une phrase qui contient 2 mots, etc.) mais globalement on a l’air de s’en sortir sans bugs ; c’est juste qu’on a une routine qui combine l’élégance naturelle du parse_name (hahahahaha) et le code spaghetti artisanal, le tout pour pouvoir cacher des réponses narquoises à « compter arbres » ou « regarder sous île ».

Une porte, des portes

Autre objet qui a l’air normal mais sous lequel se cache une optimisation pour réduire le nombre total d’objets : la porte verrouillable. Car oui, dans Tristam Island, les 4 portes verrouillables sont en fait un seul objet — et d’ailleurs, les 8 portes non verrouillables en sont un autre. Savoureux !

En fait, ça n’est pas bien compliqué : on fait en sorte qu’il n’y a qu’une seule porte verrouillable à la fois dans un lieu donné, et on stocke ses propriétés (ouverte/fermée, déverrouillée/verrouillée) dans des variables globales. Au moment où le jeu regarde si la porte est là, on se racle la gorge, on met à jour les propriétés à partir des variables globales, et on dit « oui oui je suis là » ; puis si le joueur fait quelque chose à la porte, on met à jour les variables globales, pour qu’elles suivent. Contemplez donc :

[ FlagToHas ob flagunlocked flagopen ;
    if (flagunlocked) { give ob ~locked; } else { give ob locked;}
    if (flagopen) { give ob open; } else { give ob ~open; }
];

Object OneBigLockableDoor "door"
  with  found_in [; switch(real_location) { ! On met aussi à jour l’état.
                        Fencedarea: FlagToHas(self, F_GUARDDOOR_UNLOCKED, F_GUARDDOOR_OPEN); rtrue;
                        Field: FlagToHas(self, F_GUARDDOOR_UNLOCKED, F_GUARDDOOR_OPEN); rtrue;
                        
                        Inpub: FlagToHas(self, F_PUBDOOR_UNLOCKED, F_PUBDOOR_OPEN); rtrue;
                        Outsidethepub: FlagToHas(self, F_PUBDOOR_UNLOCKED, F_PUBDOOR_OPEN); rtrue;
            } rfalse;
        ],
        door_to [; switch(real_location) {
                    Field: return Fencedarea;
                    Fencedarea: return Field;
                    Inpub: return Outsidethepub;
                    Outsidethepub: return Inpub;
                    }
        ],
        door_dir [; if (real_location == Field) return w_to;
                    if (real_location == Fencedarea) return e_to;
                    if (real_location == Inpub) return out_to;
                    else return in_to;
        ],
        with_key [; switch(real_location) {
                        InPub, Outsidethepub: return Sparekeyforthepub;
                        Field, Fencedarea: return Guardkey;
                    } rfalse;
        ],
        after [; ! Mettons à jour les flags.
            Open, Close, Lock, Unlock: switch(real_location) {
                        Field, Fencedarea:      if (OneBigLockableDoor has open) { F_GUARDDOOR_OPEN =1; } else { F_GUARDDOOR_OPEN=0; }
                                                if (OneBigLockableDoor hasnt locked) { F_GUARDDOOR_UNLOCKED = 1; } else { F_GUARDDOOR_UNLOCKED=0; }
                        Inpub, Outsidethepub:   if (OneBigLockableDoor has open) { F_PUBDOOR_OPEN =1; } else { F_PUBDOOR_OPEN=0; }
                                                if (OneBigLockableDoor hasnt locked) { F_PUBDOOR_UNLOCKED = 1; } else { F_PUBDOOR_UNLOCKED=0; }
                        }
        ],

L’océan !

Dans Tristam Island, on peut s’aventurer dans l’océan, patauger un petit peu, pour aller pêcher ou observer l’île de plus loin et remarquer des détails intéressants. Ce qui veut dire en gros que pour chaque lieu au bord de l’eau dans l’acte 1, il y a un lieu correspondant dans la mer… Aïe, ça fait 9 objets quasiment identiques (« vous êtes dans la mer »). Comment est-ce que je peux m’en sortir ?

Un dessin aidera sans doute à clarifier les choses, si les explications qui suivent ne sont pas claires ! Mais gardez à l’esprit que je suis un ancien prof de maths, et donc penser en termes de vecteur normal et d’arithmétique modulaire (car oui, il y en a) est fun pour moi. Pas de souci si ça n’est pas fun pour vous…

Ma solution a été d’utiliser la rotondité de l’île, en gros : l’île est essentiellement un octogone avec un côté plus long au sud. Ou, alternativement, une rose des vents avec 2 flèches au sud. La seule chose à faire, donc, est de créer un seul lieu où on est dans la mer, et garder en mémoire notre position sur la rose des vents. J’ai appelé cela le « vecteur normal » dans le code — c’est à dire le vecteur (la flèche) qui coupe la plage (le côté de l’octogone) perpendiculairement. (C’est en gros l’axe sur lequel on regarde si on se plante sur la plage et qu’on regarde au loin, pour le dire autrement.) On garde également en mémoire en face de quel endroit on se trouve, et on met tout cela à jour à chaque fois qu’on bouge.

À partir de là, on définit les directions dans lesquelles on peut aller : par exemple, si on suit la flèche perpendiculaire, c’est qu’on veut retourner à la plage. On peut aussi vouloir aller dans une direction parallèle à la côte — mais comment détecter « parallèle à la côte » ? J’ai triché. L’algorithme est : « Regarde le lieu correspondant sur la plage ; si on peut aller dans cette direction sur la plage, alors on dit qu’on peut aller dans cette direction dans la mer. » Ajoutez également une fleur faite au joueur (« si tu suis une direction biaisée mais qui te rapprocherait de la plage, on fait comme si tu suivais la côte »), et ça donne un système un peu compliqué mais qui donne bien le change en pratique.

Extrait du code, pour les gens intéressés :

Object OnlyRoomWaistDeep "In the ocean"
    n_to MoveInSea,
    nw_to MoveInSea,
    w_to MoveInSea,
(etc.)

[ EnterOcean ;
    vecteur_normal = location.vec_norm; ! Chaque lieu sur la plage contient cette information, i.e. la direction vers laquelle on regarde si on regarde vers la mer.
    room_shore = location;
    return OnlyRoomWaistDeep;
];

! Notez qu’il y a deux "vers le nord" ; c’est normal, c’est pour les deux endroits au sud de l’île, au lieu d’un pour les autres directions
Array clockwise_dir_array static ->  n_to ne_to e_to se_to s_to sw_to w_to nw_to n_to ;

[ MoveInSea  v; 
    v = (vecteur_normal + 6) % 8;
    ! Va-t-on vers la plage ?
    if (selected_direction == clockwise_dir_array->vecteur_normal) { return room_shore; }
    ! Va-t-on presque vers la plage ? Si oui, faire comme si on suivait la côte
    if (selected_direction == clockwise_dir_array->((vecteur_normal + 7) % 8)) { selected_direction = clockwise_dir_array->v; }
    !  Est-ce que la direction existe sur la terre (pour détecter les parallèles) ; mais attention, la direction "vers la mer" ne compte pas.
    if (room_shore provides selected_direction && selected_direction ~= clockwise_dir_array->((vecteur_normal + 4) % 8) ) {
        ! Changeons le vecteur normal, sauf pour en haut et en bas de l’octogone (côtés plus longs)
        if (selected_direction == clockwise_dir_array->v) { ! Sens des aiguilles d’une montre.
            if (room_shore ~= Immaculatebeach) { vecteur_normal = (vecteur_normal + 1) % 8; }
        } else { 
            if (room_shore ~= Beachnearforest) { vecteur_normal = (vecteur_normal + 7) % 8; }
        }
        ! On n’est plus devant le même bout de côte.
        room_shore = room_shore.selected_direction;
        print "You walk a little in this direction, following the coast.^";
        MoveFloatingObjects(); ! Pour mettre à jour les objets, genre faire apparaître les poissons si besoin.
        <Look>; rtrue;
    } else { print_ret "You walk a little in this direction, but you don't want to go too far from the shore."; }
];

Et voilà !

C’est terminé, on peut souffler ! Tout cela représente du code plutôt très technique, mais je me devais d’en parler quelque part avant de ne plus me souvenir de ces trucs, et en espérant pouvoir inspirer d’autres programmeurs. (Si jamais le code complet vous intéresse, n’hésitez pas à venir me voir !)

Ce qui est sûr, c’est que toutes ces astuces alourdissent le code de quelques kilo-octets (peut-être 8-10 au total ?), mais m’ont permis d’économiser au total 35 objets ! Donc même si la limite est de 255 objets dans le format z3 (et je l’ai atteinte), il y a en fait 280 objets dans le jeu – ce qui est vraiment très bon pour augmenter l’immersion, ajouter des réponses intéressantes, et faire un jeu encore plus vivant. Je garde ces trucs dans ma poche pour mon prochain jeu rétro, en tout cas !