Modernes NES-Spiel in einer Lisp-ähnlichen Sprache

What Remains ist ein narratives Abenteuerspiel für die 8-Bit-Videospielkonsole NES, das im März 2019 als kostenloses ROM im Emulator veröffentlicht wurde. Es wurde von einem kleinen Team von Iodine Dynamics für zwei Jahre mit Unterbrechungen erstellt. Im Moment befindet sich das Spiel in der Implementierungsphase der Hardware: Wir erstellen einen begrenzten Satz von Patronen aus recycelten Teilen.


Das Spiel hat 6 Level, auf denen der Spieler mehrere Szenen mit Vier-Wege-Scrolling-Karten durchläuft, mit dem NPC kommuniziert, Hinweise sammelt, seine Welt kennenlernt, Minispiele spielt und einfache Rätsel löst. Ich war der Chefingenieur des Projekts und hatte daher viele Schwierigkeiten, die Vision des Teams zu verwirklichen. Angesichts der schwerwiegenden Einschränkungen der NES-Ausrüstung ist es ziemlich schwierig, ein Spiel dafür zu erstellen, ganz zu schweigen von einem Projekt mit so viel Inhalt wie in What Remains. Nur dank der erstellten nützlichen Subsysteme, mit denen wir diese Komplexität verbergen und verwalten können, konnten wir als Team arbeiten und das Spiel abschließen.


In diesem Artikel werde ich über einige technische Details einzelner Teile der Spiel-Engine sprechen. Ich hoffe, dass andere Entwickler sie nützlich oder zumindest neugierig finden.

NES-Ausrüstung


Bevor Sie mit dem Code beginnen, werde ich Ihnen ein wenig über die Spezifikationen der Geräte erzählen, mit denen wir arbeiten. NES ist eine Spielekonsole, die 1983 veröffentlicht wurde (Japan, 1985 - Amerika). Im Inneren befindet sich eine 8-Bit-CPU 6502 [1] mit einer Frequenz von 1,79 MHz. Da die Konsole 60 Frames pro Sekunde produziert, werden ungefähr 30.000 CPU-Zyklen pro Frame zugewiesen, und dies ist ziemlich klein, um alles zu berechnen, was im Hauptspielzyklus passiert.

Darüber hinaus verfügt die Konsole über insgesamt 2048 Byte RAM (die mit zusätzlichem RAM auf 10.240 Byte erweitert werden können, was wir nicht getan haben). Es kann auch jeweils 32 KB ROM adressieren, was durch Bankwechsel erweitert werden kann (What Remains verwendet 512 KB ROM). Das Wechseln von Banken ist ein komplexes Thema [2], mit dem sich moderne Programmierer nicht befassen. Kurz gesagt, der der CPU zur Verfügung stehende Adressraum ist kleiner als die im ROM enthaltenen Daten, dh wenn manuell geschaltet wird, bleiben ganze Speicherblöcke unzugänglich. Wollten Sie eine Funktion aufrufen? Erst wenn Sie die Bank durch Aufrufen des Bankwechselbefehls ersetzen. Wenn dies nicht getan wird, stürzt das Programm ab, wenn die Funktion aufgerufen wird.

Tatsächlich ist es bei der Entwicklung eines Spiels für NES am schwierigsten, all dies gleichzeitig zu berücksichtigen. Das Optimieren eines Aspekts des Codes, z. B. der Speichernutzung, kann häufig andere Auswirkungen haben, z. B. die CPU-Leistung. Der Code sollte effektiv sein und gleichzeitig eine bequeme Unterstützung bieten. Normalerweise wurden Spiele in Assemblersprache programmiert.

CO2


Aber in unserem Fall war es nicht so. Stattdessen hätte ein Tandem mit dem Spiel eine eigene Sprache entwickelt. Co2 ist eine Lisp-ähnliche Sprache, die auf dem Racket-Schema basiert und in Assembler 6502 kompiliert wurde. Ursprünglich wurde die Sprache von Dave Griffiths erstellt , um die What Remains-Demo zu erstellen, und ich habe beschlossen, sie für das gesamte Projekt zu verwenden.

Mit Co2 können Sie bei Bedarf integrierten Assembler-Code schreiben, es verfügt jedoch auch über allgemeine Funktionen, die einige Aufgaben vereinfachen. Es implementiert lokale Variablen, die sowohl hinsichtlich des RAM-Verbrauchs als auch der Zugriffsgeschwindigkeit wirksam sind [2]. Es verfügt über ein sehr einfaches Makrosystem, mit dem Sie lesbaren und gleichzeitig effizienten Code schreiben können [3]. Aufgrund der Homokonizität von Lisp wird die Anzeige von Daten direkt in der Quelle erheblich vereinfacht.

Das Schreiben eigener Tools ist in der Spieleentwicklung weit verbreitet, das Erstellen einer vollständigen Programmiersprache ist jedoch weitaus seltener. Wir haben es jedoch geschafft. Es ist nicht sehr klar, ob sich die Komplexität der Entwicklung und Unterstützung von CO2 bewährt hat, aber es hatte definitiv Vorteile, die uns geholfen haben. In dem Beitrag werde ich nicht im Detail auf die Arbeit von Co2 eingehen (dies verdient einen separaten Artikel), aber ich werde es ständig erwähnen, da seine Verwendung ziemlich eng mit dem Entwicklungsprozess verknüpft ist.

Hier ist ein Beispiel für einen Co2-Code, der den Hintergrund für eine Szene zeichnet, die gerade geladen wurde, bevor sie gedimmt wird:

; 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)) 

Entitätssystem



Jedes Echtzeitspiel, das komplexer als Tetris ist, ist von Natur aus ein "System von Entitäten". Dies ist eine Funktionalität, die es verschiedenen unabhängigen Akteuren ermöglicht, gleichzeitig zu handeln und für ihren eigenen Zustand verantwortlich zu sein. Obwohl What Remains keineswegs ein aktives Spiel ist, gibt es immer noch viele unabhängige Akteure mit komplexem Verhalten: Sie animieren und rendern sich selbst, suchen nach Kollisionen und verursachen Dialoge.

Die Implementierung ist recht typisch: Ein großes Array enthält eine Liste von Entitäten in der Szene, jeder Datensatz enthält entitätsbezogene Daten sowie eine Typbezeichnung. Die Aktualisierungsfunktion im Hauptspielzyklus umgeht alle Entitäten und implementiert das entsprechende Verhalten je nach Typ.

 ; 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))))) 

Interessanter ist die Art und Weise, wie Entitätsdaten gespeichert werden. Im Allgemeinen hat das Spiel so viele einzigartige Einheiten, dass die Verwendung einer großen Anzahl von ROMs zu einem Problem werden kann. Hier zeigt Co2 seine Kraft und ermöglicht es uns, jede Essenz der Szene in einer präzisen, aber lesbaren Form darzustellen - als Strom von Schlüssel-Wert-Paaren. Zusätzlich zu Daten wie der Anfangsposition ist fast jeder Schlüssel optional, sodass sie nur bei Bedarf für Entitäten deklariert werden können.

 (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) 

In diesem Code legt die prop-palette die Farbpalette fest, die für die Entität verwendet wird, die prop-anim-limit legt die Anzahl der Animationsrahmen fest und die prop-dont-turn-around verhindert, dass sich der NPC dreht, wenn der Spieler versucht, von der anderen Seite mit ihm zu sprechen. Außerdem werden einige Bedingungen gesetzt, die das Verhalten der Entität beim Weitergeben des Spiels durch den Spieler ändern.

Diese Art der Präsentation ist für die Speicherung im ROM sehr effektiv, beim Zugriff zur Laufzeit jedoch sehr langsam und für das Gameplay zu ineffizient. Wenn ein Spieler eine neue Szene betritt, werden daher alle Objekte in dieser Szene in den RAM geladen und verarbeiten alle Bedingungen, die sich auf ihren Ausgangszustand auswirken können. Sie können jedoch keine Details für jede Entität herunterladen, da dies mehr RAM beanspruchen würde, als verfügbar ist. Die Engine lädt nur das Notwendigste für jede Entität sowie einen Zeiger auf ihre vollständige Struktur im ROM, der in Situationen wie der Behandlung von Dialogen dereferenziert wird. Diese spezifischen Kompromisse ermöglichten es uns, ein ausreichendes Leistungsniveau bereitzustellen.

Portale



Das Spiel What Remains hat viele verschiedene Orte, mehrere Szenen auf der Straße mit scrollenden Karten und viele Szenen in Räumen, die statisch bleiben. Um von einem zum anderen zu wechseln, müssen Sie feststellen, dass der Spieler den Ausgang erreicht hat, eine neue Szene laden und den Spieler dann an der gewünschten Stelle platzieren. In den frühen Entwicklungsstadien wurden solche Übergänge auf einzigartige Weise als zwei miteinander verbundene Szenen beschrieben, z. B. „Erste Stadt“ und „Café“, und Daten in der if-Anweisung über die Position der Türen in jeder Szene. Um zu bestimmen, wo der Spieler nach dem Ändern der Szene platziert werden soll, mussten Sie nur überprüfen, woher er kam und wohin, und ihn neben dem entsprechenden Ausgang platzieren.

Als wir jedoch begannen, die Szene der „zweiten Stadt“ zu füllen, die an zwei verschiedenen Orten mit der ersten Stadt verbunden ist, begann ein solches System auseinanderzufallen. Plötzlich passt das Paar (_, _) nicht mehr. Nachdem wir darüber nachgedacht hatten, stellten wir fest, dass die Verbindung selbst wirklich wichtig ist, was im Spielcode das „Portal“ nennt. Um diesen Änderungen Rechnung zu tragen, wurde die Engine neu geschrieben. was uns zu einer entitätsähnlichen Situation führte. Portale könnten Listen von Schlüssel-Wert-Paaren speichern und am Anfang der Szene laden. Beim Betreten des Portals können Sie dieselben Positionsinformationen wie beim Verlassen des Portals verwenden. Darüber hinaus wurde das Hinzufügen von Bedingungen vereinfacht, ähnlich wie bei den Entitäten: An bestimmten Punkten im Spiel konnten wir Portale ändern, z. B. Türen öffnen oder schließen.

 ; 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) 

Es vereinfachte auch das Hinzufügen von „Teleportationspunkten“, die häufig in Filmbeilagen verwendet wurden, bei denen der Spieler je nach Geschehen in der Handlung zu einem anderen in der Szene wechseln musste.

So sieht Teleportation zu Beginn von Level 3 aus:

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

Achten Sie auf den look-up , der die Richtung für den "Eingang" zu diesem Portal angibt. Beim Verlassen des Portals schaut der Spieler in die andere Richtung. In diesem Fall ist Jenny (die Hauptfigur des Spiels) zu Hause, während sie nach unten schaut.

Textblock


Das Rendern eines Textblocks erwies sich als einer der komplexesten Codeteile im gesamten Projekt. Die grafischen Einschränkungen von NES sind zum Trick gezwungen. Zunächst verfügt NES nur über eine Ebene für Grafikdaten. Um also Speicherplatz für einen Textblock freizugeben, müssen Sie einen Teil der Karte vor dem Hintergrund löschen und nach dem Schließen des Textblocks wiederherstellen.


Darüber hinaus muss die Palette für jede einzelne Szene Schwarzweißfarben zum Rendern des Texts enthalten, was dem Künstler zusätzliche Einschränkungen auferlegt. Um Farbkonflikte mit dem Rest des Hintergrunds zu vermeiden, sollte der Textblock am 16 × 16-Raster ausgerichtet sein [5]. Das Zeichnen eines Textblocks in einer Szene mit einem Raum ist viel einfacher als in einer Straße, in der sich die Kamera bewegen kann, da in diesem Fall Grafikpuffer berücksichtigt werden müssen, die vertikal und horizontal scrollen. Schließlich ist die Pausenbildschirmmeldung ein leicht modifiziertes Standarddialogfeld, da sie unterschiedliche Informationen anzeigt, aber fast denselben Code verwendet.

Nach einer unendlichen Anzahl fehlerhafter Versionen des Codes gelang es mir schließlich, eine Lösung zu finden, bei der die Arbeit in zwei Phasen unterteilt ist. Zunächst werden alle Berechnungen durchgeführt, die bestimmen, wo und wie der Textblock gezeichnet werden soll, einschließlich des Verarbeitungscodes für alle Grenzfälle. Somit werden all diese Schwierigkeiten an einen Ort gebracht.

Dann wird ein Textblock mit Zustandserhaltung zeilenweise gezeichnet und die Berechnungen aus der ersten Stufe werden verwendet, um den Code nicht zu komplizieren.

 ; 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)) 

Wenn Sie sich an den Lisp-Stil gewöhnt haben, wird der Code ganz bequem gelesen.

Sprite Z-Schichten


Am Ende werde ich über ein kleines Detail sprechen, das das Gameplay nicht besonders beeinflusst, aber eine nette Geste hinzufügt, auf die ich stolz bin. NES besteht nur aus zwei grafischen Komponenten: einer Namenstabelle (Namenstabelle), die für statische und gitterausgerichtete Hintergründe verwendet wird, und Sprites - Objekte mit einer Größe von 8 x 8 Pixel, die an beliebigen Stellen platziert werden können. Elemente wie der Charakter des Spielers und NPCs werden normalerweise als Sprites erstellt, wenn sie über den Grafiken der Namenstabelle stehen sollen.

NES-Geräte bieten jedoch auch die Möglichkeit, einen Teil der Sprites anzugeben, die vollständig unter der Namenstabelle platziert werden können. Auf diese Weise können Sie mühelos einen coolen 3D-Effekt erzielen.


Es funktioniert wie folgt: Die für die aktuelle Szene verwendete Palette behandelt die Farbe an Position 0 auf besondere Weise: Es handelt sich um die globale Hintergrundfarbe. Darüber wird eine Namenstabelle gezeichnet, und Sprites mit einer Z-Ebene werden zwischen zwei anderen Ebenen gezeichnet.

Hier ist die Palette dieser Szene:


Daher wird die dunkelgraue Farbe in der linken Ecke als globale Hintergrundfarbe verwendet.

Die Wirkung von Ebenen funktioniert wie folgt:


In den meisten anderen Spielen endet dies alles, jedoch hat What Remains einen weiteren Schritt nach vorne gemacht. Das Spiel platziert Jenny nicht vollständig vor oder unter den Grafiken der Namenstabelle - ihr Charakter wird auf die richtige Weise zwischen ihnen aufgeteilt. Wie Sie sehen können, sind Sprites 8 x 8 Einheiten groß und die Grafiken des gesamten Charakters bestehen aus mehreren Sprites (von 3 bis 6, abhängig vom Animationsrahmen). Jedes Sprite kann eine eigene Z-Ebene festlegen. Das heißt, einige Sprites befinden sich vor der Namenstabelle, andere dahinter.

Hier ist ein Beispiel für diesen Effekt in Aktion:


Der Algorithmus zur Implementierung dieses Effekts ist ziemlich schwierig. Zunächst werden Kollisionsdaten untersucht, die den Spieler umgeben, insbesondere Kacheln, für deren Zeichnen möglicherweise ein ganzer Charakter erforderlich ist. In diesem Diagramm werden durchgezogene Kacheln in roten Quadraten angezeigt, und gelbe Kacheln geben den Teil mit der Z-Schicht an.


Mithilfe verschiedener Heuristiken werden sie kombiniert, um einen „Referenzpunkt“ und eine Bitmaske aus vier Bits zu erstellen. Vier Quadranten relativ zum Referenzpunkt entsprechen vier Bits: 0 bedeutet, dass sich der Spieler vor der Namenstabelle 1 befinden muss, die sich dahinter befindet.


Wenn Sie einzelne Sprites zum Rendern des Players platzieren, wird deren Position mit dem Referenzpunkt verglichen, um die Z-Ebene dieses bestimmten Sprites zu bestimmen. Einige von ihnen befinden sich in der vorderen Schicht, andere in der hinteren.


Fazit


Ich habe kurz über die verschiedenen Aspekte des Innenlebens unseres neuen modernen Retro-Spiels gesprochen. Die Codebasis ist viel interessanter, aber ich habe einen wesentlichen Teil dessen beschrieben, was das Spiel zum Funktionieren bringt.

Die wichtigste Lektion, die ich aus diesem Projekt gelernt habe, sind die Vorteile, die datengesteuerte Engines erzielen können. Mehrmals gelang es mir, eine eindeutige Logik durch eine Tabelle und einen Mini-Interpreter zu ersetzen, und dank dessen wurde der Code einfacher und lesbarer.

Ich hoffe dir hat der Artikel gefallen!



Anmerkungen


[1] Genau genommen wurde in NES eine Art CPU 6502 namens Ricoh 2A03 installiert.

[2] Tatsächlich hat mich dieses Projekt überzeugt, dass das Wechseln von Banken / Verwalten von ROMs die Hauptbeschränkung für jedes NES-Projekt darstellt, das eine bestimmte Größe überschreitet.

[3] Dafür sollte man sich beim „Compiled Stack“ bedanken - einem Konzept, das bei der Programmierung eingebetteter Systeme verwendet wird, obwohl ich kaum Literatur darüber gefunden habe. Kurz gesagt, Sie müssen ein vollständiges Diagramm der Projektaufrufe erstellen, es von den Blattknoten bis zum Stamm sortieren und dann jedem Knoten einen Speicher zuweisen, der seinen Anforderungen + der maximalen Anzahl von untergeordneten Knoten entspricht.

[4] Makros wurden in relativ späten Entwicklungsstadien hinzugefügt, und ehrlich gesagt konnten wir sie nicht besonders nutzen.

[5] Weitere Informationen zu NES-Grafiken finden Sie in meiner Artikelserie . Farbkonflikte werden durch die im ersten Teil beschriebenen Attribute verursacht.

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


All Articles