Jeu NES moderne écrit dans un langage de type Lisp

What Remains est un jeu d'aventure narratif pour la console de jeu vidéo NES 8 bits, sorti en mars 2019 sous forme de ROM gratuite exécutée dans l'émulateur. Il a été créé par une petite équipe de Iodine Dynamics pendant deux ans par intermittence. Pour le moment, le jeu est au stade de la mise en œuvre du matériel: nous créons un ensemble limité de cartouches à partir de pièces recyclées.


Le jeu comporte 6 niveaux sur lesquels le joueur parcourt plusieurs scènes avec des cartes de défilement à quatre voies, communique avec le PNJ, recueille des indices, apprend à connaître son monde, joue à des mini-jeux et résout des énigmes simples. J'étais l'ingénieur en chef du projet, j'ai donc rencontré de nombreuses difficultés pour réaliser la vision de l'équipe. Compte tenu des sérieuses limites de l'équipement NES, il est assez difficile de créer un jeu pour celui-ci, sans parler d'un projet avec autant de contenu que dans What Remains. Ce n'est que grâce aux sous-systèmes utiles créés qui nous permettent de masquer cette complexité et de la gérer que nous avons pu travailler en équipe et terminer le jeu.


Dans cet article, je vais parler de certains détails techniques de certaines parties du moteur de jeu. J'espère que d'autres développeurs les trouveront utiles ou du moins curieux.

Équipement NES


Avant de commencer le code, je vais vous parler un peu des spécifications de l'équipement avec lequel nous travaillons. NES est une console de jeu sortie en 1983 (Japon, 1985 - Amérique). À l'intérieur, il possède un processeur 8 bits 6502 [1] avec une fréquence de 1,79 MHz. Étant donné que la console produit 60 images par seconde, il y a environ 30 000 cycles CPU par image, ce qui est assez petit pour calculer tout ce qui se passe dans le cycle de jeu principal.

De plus, la console dispose d'un total de 2048 octets de RAM (qui peut être étendue à 10 240 octets en utilisant de la RAM supplémentaire, ce que nous n'avons pas fait). Il peut également traiter 32 Ko de ROM à la fois, ce qui peut être étendu en changeant de banque (What Remains utilise 512 Ko de ROM). Changer de banque est un sujet complexe [2] que les programmeurs modernes ne traitent pas. En bref, l'espace d'adressage disponible pour le CPU est inférieur aux données contenues dans la ROM, c'est-à-dire qu'en cas de commutation manuelle, des blocs de mémoire entiers restent inaccessibles. Vouliez-vous appeler une fonction? Ce n'est que lorsque vous remplacez la banque en appelant la commande de changement de banque. Si cela n'est pas fait, alors lorsque la fonction est appelée, le programme se bloque.

En fait, la chose la plus difficile lors du développement d'un jeu pour NES est de considérer tout cela en même temps. L'optimisation d'un aspect du code, comme l'utilisation de la mémoire, peut souvent affecter autre chose, comme les performances du processeur. Le code doit être efficace et à la fois pratique à l'appui. Habituellement, les jeux étaient programmés en langage assembleur.

Co2


Mais dans notre cas, ce n'était pas le cas. Au lieu de cela, un tandem avec le jeu aurait développé son propre langage. Co2 est un langage de type Lisp construit sur Racket Scheme et compilé dans l'assembleur 6502. Initialement, le langage a été créé par Dave Griffiths pour construire la démo What Remains, et j'ai décidé de l'utiliser pour l'ensemble du projet.

Co2 vous permet d'écrire du code assembleur intégré si nécessaire, mais il possède également des capacités de haut niveau qui simplifient certaines tâches. Il implémente des variables locales efficaces à la fois en termes de consommation de RAM et de vitesse d'accès [2]. Il dispose d'un système de macros très simple qui vous permet d'écrire du code lisible et en même temps efficace [3]. Plus important encore, en raison de l' homo-conicité de Lisp, il simplifie considérablement l'affichage des données directement dans la source.

L'écriture de vos propres outils est assez répandue dans le développement de jeux, mais la création d'un langage de programmation complet est beaucoup moins courante. Cependant, nous l'avons fait. On ne sait pas très bien si la complexité du développement et du support de Co2 a fait ses preuves, mais elle avait certainement des avantages qui nous ont aidés. Dans le post, je ne parlerai pas en détail du travail de Co2 (cela mérite un article séparé), mais je le mentionnerai constamment car son utilisation est assez étroitement liée au processus de développement.

Voici un exemple de code Co2 qui dessine l'arrière-plan d'une scène juste chargée avant de la réduire:

; Render the nametable for the scene at the camera position (defsub (create-initial-world) (camera-assign-cursor) (set! camera-cursor (+ camera-cursor 60)) (let ((preserve-camera-v)) (set! preserve-camera-v camera-v) (set! camera-v 0) (loop i 0 60 (set! delta-v #xff) (update-world-graphics) (when render-nt-span-has (set! render-nt-span-has #f) (apply-render-nt-span-buffer)) (when render-attr-span-has (set! render-attr-span-has #f) (apply-render-attr-span-buffer))) (set! camera-v preserve-camera-v)) (camera-assign-cursor)) 

Système d'entité



Tout jeu en temps réel plus complexe que Tetris est intrinsèquement un «système d'entités». Il s'agit d'une fonctionnalité qui permet à différents acteurs indépendants d'agir simultanément et d'être responsables de leur propre condition. Bien que What Remains ne soit en aucun cas un jeu actif, il a encore de nombreux acteurs indépendants au comportement complexe: ils s'animent et se rendent, vérifient les collisions et provoquent des dialogues.

L'implémentation est assez typique: un grand tableau contient une liste d'entités dans la scène, chaque enregistrement contient des données relatives aux entités ainsi qu'une étiquette de type. La fonction de mise à jour du cycle de jeu principal contourne toutes les entités et implémente le comportement correspondant en fonction de leur type.

 ; Called once per frame, to update each entity (defsub (update-entities) (when (not entity-npc-num) (return)) (loop k 0 entity-npc-num (let ((type)) (set! type (peek entity-npc-data (+ k entity-field-type))) (when (not (eq? type #xff)) (update-single-entity k type))))) 

La façon de stocker les données d'entité est plus intéressante. En général, le jeu possède tellement d'entités uniques que l'utilisation d'un grand nombre de ROM peut devenir un problème. Ici, Co2 montre sa puissance, nous permettant de présenter chaque essence de la scène sous une forme concise mais lisible - comme un flux de paires clé-valeur. En plus des données telles que la position initiale, presque chaque clé est facultative, ce qui permet de les déclarer aux entités uniquement lorsque cela est nécessaire.

 (bytes npc-diner-a 172 108 prop-palette 1 prop-hflip prop-picture picture-smoker-c prop-animation simple-cycle-animation prop-anim-limit 6 prop-head hair-flip-head-tile 2 prop-dont-turn-around prop-dialog-a (2 progress-stage-4 on-my-third my-dietician) prop-dialog-a (2 progress-stage-3 have-you-tried-the-pasta the-real-deal) prop-dialog-a (2 progress-diner-is-clean omg-this-cherry-pie its-like-a-party) prop-dialog-a (2 progress-stage-1 cant-taste-food puff-poof) prop-dialog-b (1 progress-stage-4 tea-party-is-not) prop-dialog-b (1 progress-stage-3 newspaper-owned-by-dnycorp) prop-dialog-b (1 progress-stage-2 they-paid-a-pr-guy) prop-dialog-b (1 progress-stage-1 it-seems-difficult) prop-customize (progress-stage-2 stop-smoking) 0) 

Dans ce code, prop-palette définit la palette de couleurs utilisée pour l'entité, prop-anim-limit définit le nombre d'images d'animation et prop-dont-turn-around empêche le PNJ de tourner si le joueur essaie de lui parler de l'autre côté. Il définit également quelques indicateurs de conditions qui modifient le comportement de l'entité lors du passage du jeu par le joueur.

Ce type de présentation est très efficace pour le stockage en ROM, mais il est très lent lors de l'accès au moment de l'exécution et sera trop inefficace pour le gameplay. Par conséquent, lorsqu'un joueur entre dans une nouvelle scène, toutes les entités de cette scène sont chargées dans la RAM et traitent toutes les conditions qui peuvent affecter leur état initial. Mais vous ne pouvez pas télécharger de détails pour chaque entité, car cela prendrait plus de RAM que ce qui est disponible. Le moteur charge uniquement les éléments les plus nécessaires pour chaque entité, plus un pointeur sur sa structure complète en ROM, qui est déréférencé dans des situations telles que la gestion des boîtes de dialogue. Cet ensemble spécifique de compromis nous a permis de fournir un niveau de performance suffisant.

Portails



Le jeu What Remains a de nombreux endroits différents, plusieurs scènes dans la rue avec des cartes à défilement et de nombreuses scènes dans des pièces qui restent statiques. Pour passer de l'un à l'autre, vous devez déterminer que le joueur a atteint la sortie, charger une nouvelle scène, puis placer le joueur au point souhaité. Dans les premiers stades de développement, ces transitions étaient décrites de manière unique comme deux scènes connectées, par exemple, «première ville» et «café» et des données dans la déclaration if sur l'emplacement des portes dans chaque scène. Afin de déterminer où placer le joueur après avoir changé de scène, il vous suffit de vérifier où il allait et où, et de le placer à côté de la sortie correspondante.

Cependant, lorsque nous avons commencé à remplir la scène de la «deuxième ville», qui se connecte à la première ville à deux endroits différents, un tel système a commencé à s'effondrer. (_, _) coup, la paire (_, _) ne correspond plus. Après avoir réfléchi à cela, nous avons réalisé que la connexion elle-même est vraiment importante, ce qui dans le code du jeu appelle le «portail». Pour tenir compte de ces changements, le moteur a été réécrit. ce qui nous a conduit à une situation semblable à une entité. Les portails pouvaient stocker des listes de paires clé-valeur et les charger au début de la scène. Lorsque vous entrez dans le portail, vous pouvez utiliser les mêmes informations de position que lorsque vous quittez. De plus, l'ajout de conditions a été simplifié, semblable à ce que les entités avaient: à certains moments du jeu, nous pouvions modifier des portails, par exemple, ouvrir ou fermer des portes.

 ; City A (bytes city-a-scene #x50 #x68 look-up portal-customize (progress-stage-5 remove-self) ; to Diner diner-scene #xc0 #xa0 look-down portal-width #x20 0) 

Cela a également simplifié le processus d'ajout de «points de téléportation», qui étaient souvent utilisés dans les encarts cinématographiques, où le joueur devait passer à un autre dans la scène, selon ce qui se passait dans l'intrigue.

Voici à quoi ressemble la téléportation au début du niveau 3:

 ; Jenny's home (bytes jenny-home-scene #x60 #xc0 look-up portal-teleport-only jenny-back-at-home-teleport 0) 

Faites attention à la valeur de recherche, qui indique la direction de l '"entrée" de ce portail. En quittant le portail, le joueur regardera dans l'autre sens; dans ce cas, Jenny (le personnage principal du jeu) est à la maison, tout en regardant vers le bas.

Bloc de texte


Le rendu d'un bloc de texte s'est avéré être l'un des morceaux de code les plus complexes de l'ensemble du projet. Les limitations graphiques de NES ont forcé à tromper. Pour commencer, NES n'a qu'une seule couche pour les données graphiques, c'est-à-dire que pour libérer de l'espace pour un bloc de texte, vous devez effacer une partie de la carte en arrière-plan, puis la restaurer après avoir fermé le bloc de texte.


De plus, la palette de chaque scène doit contenir des couleurs noir et blanc pour le rendu du texte, ce qui impose des restrictions supplémentaires à l'artiste. Pour éviter les conflits de couleurs avec le reste de l'arrière-plan, le bloc de texte doit être aligné avec la grille 16 × 16 [5]. Dessiner un bloc de texte dans une scène avec une pièce est beaucoup plus simple que dans une rue, où la caméra peut se déplacer, car dans ce cas, il est nécessaire de considérer les tampons graphiques défilant verticalement et horizontalement. Enfin, le message d'écran de pause est une boîte de dialogue standard légèrement modifiée, car elle affiche des informations différentes, mais utilise presque le même code.

Après un nombre infini de versions buggées du code, j'ai finalement réussi à trouver une solution dans laquelle le travail est divisé en deux étapes. Tout d'abord, tous les calculs sont effectués pour déterminer où et comment dessiner le bloc de texte, y compris le code de traitement pour tous les cas de bordure. Ainsi, toutes ces difficultés sont réunies au même endroit.

Ensuite, un bloc de texte avec conservation de l'état est tracé ligne par ligne et les calculs de la première étape sont utilisés pour ne pas compliquer le code.

 ; Called once per frame as the text box is being rendered (defsub (text-box-update) (when (or (eq? tb-text-mode 0) (eq? tb-text-mode #xff)) (return #f)) (cond [(in-range tb-text-mode 1 4) (if (not is-paused) ; Draw text box for dialog. (text-box-draw-opening (- tb-text-mode 1)) ; Draw text box for pause. (text-box-draw-pausing (- tb-text-mode 1))) (inc tb-text-mode)] [(eq? tb-text-mode 4) ; Remove sprites in the way. (remove-sprites-in-the-way) (inc tb-text-mode)] [(eq? tb-text-mode 5) (if (not is-paused) ; Display dialog text. (when (not (crawl-text-update)) (inc tb-text-mode) (inc tb-text-mode)) ; Display paused text. (do (create-pause-message) (inc tb-text-mode)))] [(eq? tb-text-mode 6) ; This state is only used when paused. Nothing happens, and the caller ; has to invoke `text-box-try-exiting-pause` to continue. #t] [(and (>= tb-text-mode 7) (< tb-text-mode 10)) ; Erase text box. (if (is-scene-outside scene-id) (text-box-draw-closing (- tb-text-mode 7)) (text-box-draw-restoring (- tb-text-mode 7))) (inc tb-text-mode)] [(eq? tb-text-mode 10) ; Reset state to return to game. (set! text-displaying #f) (set! tb-text-mode 0)]) (return #t)) 

Si vous vous habituez au style Lisp, le code est lu assez facilement.

Couches z Sprite


Au final, je vais parler d'un petit détail qui n'affecte pas particulièrement le gameplay, mais ajoute une belle touche dont je suis fier. NES n'a que deux composants graphiques: une table de noms (table de noms), qui est utilisée pour les arrière-plans statiques et alignés sur la grille, et les sprites - objets de taille 8x8 pixels, qui peuvent être placés à des endroits arbitraires. Des éléments tels que le personnage du joueur et les PNJ sont généralement créés sous forme de sprites s'ils doivent figurer au-dessus des graphiques de la table des noms.

Cependant, l'équipement NES offre également la possibilité de spécifier une partie des sprites qui peuvent être complètement placés sous la table des noms. Cela vous permet sans effort de réaliser un effet 3D cool.


Cela fonctionne comme suit: la palette utilisée pour la scène actuelle gère la couleur à la position 0 d'une manière spéciale: c'est la couleur de fond globale. Une table de noms est dessinée par-dessus, et les sprites avec un calque z sont dessinés entre deux autres calques.

Voici la palette de cette scène:


Ainsi, la couleur gris foncé dans le coin tout à gauche est utilisée comme couleur de fond globale.

L'effet des calques fonctionne comme suit:


Dans la plupart des autres jeux, tout cela se termine, cependant, What Remains a fait un pas de plus. Le jeu ne place pas Jenny complètement devant ou sous les graphiques de la table des noms - son personnage est divisé entre eux de la bonne manière. Comme vous pouvez le voir, les images-objets ont une taille de 8 x 8 et les graphiques du personnage entier se composent de plusieurs images-objets (de 3 à 6, selon le cadre d'animation). Chaque sprite peut définir sa propre couche z, c'est-à-dire que certains sprites seront devant la table des noms et d'autres derrière.

Voici un exemple de cet effet en action:


L'algorithme pour implémenter cet effet est assez délicat. Tout d'abord, les données de collision entourant le joueur sont examinées, en particulier les tuiles, qui peuvent prendre un personnage entier à dessiner. Dans ce diagramme, les carreaux pleins sont représentés dans des carrés rouges et les carreaux jaunes indiquent la partie avec la couche z.


Utilisant diverses heuristiques, ils sont combinés pour créer un «point de référence» et un masque de bits de quatre bits. Quatre quadrants par rapport au point de référence correspondent à quatre bits: 0 signifie que le joueur doit être devant la table des noms, 1 - qui est derrière.


Lorsque vous placez des sprites individuels pour le rendu du joueur, leur position est comparée au point de référence pour déterminer la couche z de ce sprite particulier. Certains d'entre eux sont dans la couche avant, d'autres à l'arrière.


Conclusion


J'ai brièvement parlé des différents aspects du fonctionnement interne de notre nouveau jeu rétro moderne. Il y a beaucoup plus intéressant dans la base de code, mais j'ai décrit une partie importante de ce qui fait fonctionner le jeu.

La leçon la plus importante que j'ai apprise de ce projet est les avantages qui peuvent être tirés des moteurs basés sur les données. Plusieurs fois, j'ai réussi à remplacer une logique unique par une table et un mini-interprète, et grâce à cela, le code est devenu plus simple et plus lisible.

J'espère que l'article vous a plu!



Remarques


[1] À proprement parler, une sorte de CPU 6502 appelée Ricoh 2A03 a été installée dans NES.

[2] En fait, ce projet m'a convaincu que le changement de banque / la gestion des ROM est la principale limitation pour tout projet NES qui dépasse une certaine taille.

[3] Pour cela, il faut remercier la «pile compilée» - un concept utilisé dans la programmation de systèmes embarqués, bien que j'aie à peine réussi à trouver de la littérature à ce sujet. En bref, vous devez créer un graphique d'appel de projet complet, le trier des nœuds feuilles à la racine, puis affecter à chaque nœud une mémoire égale à ses besoins + nombre maximal de nœuds enfants.

[4] Les macros ont été ajoutées à des stades de développement assez tardifs et, franchement, nous n'avons pas pu en tirer un avantage particulier.

[5] Vous pouvez en savoir plus sur les graphiques NES dans ma série d'articles . Les conflits de couleurs sont causés par les attributs décrits dans la première partie.

Source: https://habr.com/ru/post/fr467125/


All Articles