GameLisp-Test: Eine neue Sprache zum Schreiben von Spielen in Rust



Ein Programmierer, der sich mit dem Pseudonym Fleabit anmeldet, entwickelt seit sechs Monaten seine Programmiersprache . Es stellt sich sofort die Frage: eine andere Sprache? Wozu?



Hier sind seine Argumente:



  • – , , , . , garbage collection .
  • Rust : , , – enum- ; pattern matching ; , ; .. , Rust : « , »; ; /, , .
  • JavaScript, Lua, Python Ruby; Rust – , - , . , garbage collector, , – , GC , . GameLisp – , .
  • GameLisp, – , , . enum- Rust, , . "" , .


Zunächst werden die Einfachheit der Syntax und die Einfachheit des Interpreters von Lisp in GameLisp übernommen: Die Implementierung von GameLisp zusammen mit der "Standardbibliothek" erfordert jetzt 36 KLOC, verglichen mit beispielsweise 455 KLOC in Python. Auf der anderen Seite hat GameLisp im Vergleich zu normalem Lisp keine Listen und konzentriert sich viel weniger auf funktionale Programmierung und unveränderliche Daten. Stattdessen konzentriert sich GameLisp wie die meisten Skriptsprachen auf die imperative, objektorientierte Programmierung.

Die Lisp-basierte Syntax kann überwältigend sein, aber Sie gewöhnen sich schnell an das Schreiben (.print console (+ 2 2)) usw. anstelle von console.print (2 + 2). Diese Syntax ist viel einfacher und flexibler als in bekannten Skriptsprachen: Das Komma wird als Leerzeichen betrachtet und kann verwendet werden, um die Lesbarkeit an einer beliebigen Stelle im Code zu verbessern. Anstelle von zwei Arten von Klammern {} () werden nur runde Klammern verwendet. Die meisten ASCII-Zeichen können in Zeichen verwendet werden, daher ist I ~ <3 ~ Lisp! ~ ^ _ ^ ein gültiger Name für eine Funktion oder Variable. Nicht benötigt; Operationen usw. zu trennen Ich kann sagen , dass ohne den Erfahrungen der Vergangenheit mit Lisp, in nur ein paar Abende ich es geschafft , die klassische zu umschreiben NIBBLES.BAS auf GameLisp: http://atari.ruvds.com/nibbles.html



Alles, was in der GameLisp "Standardbibliothek" für E / A enthalten ist, ist eine PRN-Funktion zum Drucken auf Standardausgabe. Keine Tastatur- / Mausarbeit, keine Dateien, keine Grafiken, kein Ton. Es wird davon ausgegangen, dass der GameLisp-Benutzer selbst alle Schnittstellen-Tools in Rust implementiert, die speziell für sein Projekt relevant sind. Als Beispiel für eine solche Bindung wird auf https://gamelisp.rs/playground/ mit wasm-bindgen eine minimalistische Engine für Browsergames veröffentlicht Dadurch wird der GameLisp-Code mit play: down?, play: gedrückt ?, play: release ?, play: mouse-x, play: mouse-y, play: fill und play: draw. Mein Port von Nibbles verwendet dieselbe Engine - ich habe gerade eine Funktion hinzugefügt, um Sound abzuspielen. Es ist interessant, die Größen zu vergleichen: Die ursprüngliche NIBBLES.BAS war 24 KB; Mein Port auf GameLisp ist 9 KB groß. Die WebAssembly-Datei mit der kompilierten Rust-Laufzeit, dem GameLisp-Interpreter und dem Spielcode ist 2,5 MB groß und enthält eine 11-KB-JavaScript-Bindung, die von wasm-bindgen generiert wird.



Zusammen mit einer minimalistischen Engine unter https://gamelisp.rs/playground/GameLisp-Implementierungen von drei klassischen Spielen hinzugefügt: Pong, Tetris und Sapper. Tetris und Minesweeper sind größer und komplexer als mein Hafen von Nibbles, und es gibt viel zu lernen aus ihrem Code.



Um die Funktionen von GameLisp zu demonstrieren, habe ich zwei Beispiele ausgewählt. Das erste betrifft Makros. In NIBBLES.BAS werden die Ebenen durch den Zeilenblock SELECT CASE mit verschachtelten Schleifen angegeben:



SELECT CASE curLevel
CASE 1
    sammy(1).row = 25: sammy(2).row = 25
    sammy(1).col = 50: sammy(2).col = 30
    sammy(1).direction = 4: sammy(2).direction = 3

CASE 2
    FOR i = 20 TO 60
        Set 25, i, colorTable(3)
    NEXT i
    sammy(1).row = 7: sammy(2).row = 43
    sammy(1).col = 60: sammy(2).col = 20
    sammy(1).direction = 3: sammy(2).direction = 4

CASE 3
    FOR i = 10 TO 40
        Set i, 20, colorTable(3)
        Set i, 60, colorTable(3)
    NEXT i
    sammy(1).row = 25: sammy(2).row = 25
    sammy(1).col = 50: sammy(2).col = 30
    sammy(1).direction = 1: sammy(2).direction = 2

...
      
      





Alle diese Schleifen haben eine ähnliche Struktur, die in ein Makro aufgenommen werden kann:



(let-macro set-walls (range ..walls)
  `(do ~..(map (fn1
    `(forni (i ~..range) (set-wall ~.._))) walls)))

      
      





Mit diesem Makro wird die Beschreibung aller Ebenen um vier reduziert und kommt einer deklarativen JSON-ähnlichen Beschreibung so nahe wie möglich:



(match @level
  (1 (set-locations '(25 50 right) '(25 30 left)))
  (2 (set-walls (20 60) (25 i))
     (set-locations '(7 60 left) '(43 20 right)))
  (3 (set-walls (10 40) (i 20) (i 60))
     (set-locations '(25 50 up) '(25 30 down)))
  ...
      
      





In einer Sprache ohne Makros - zum Beispiel in JavaScript - würde eine ähnliche Implementierung die gesamte Beschreibung von Ebenen mit Lambdas verdecken:



switch (level) {
case 1: setLocations([25, 50, "right"], [25, 30, "left"]); break;
case 2: setWalls([20, 60], i => [25, i]);
        setLocations([7, 60, "left"], [43, 20, "right"]); break;
case 3: setWalls([10, 40], i => [i, 20], i => [i, 60]);
        setLocations([25, 50, "up"], [25, 30, "down"]); break;
...
      
      





Dieses Beispiel zeigt deutlich, wie viel JavaScript-Code mit verschiedenen Interpunktions- und Funktionswörtern überladen ist, auf die Sie verzichten können.

Mein zweites Beispiel handelt von Zustandsautomaten. Meine Implementierung des Spiels hat folgende Struktur:



(defclass Game

  ...

  (fsm
    (state Playing
      (field blink-rate (Rate 0.2))
      (field blink-on)
      (field move-rate (Rate 0.3))
      (field target)
      (field prize 1)

      (state Paused
        (init-state ()
          (@center "*** PAUSED ***" 0))
        (wrap Playing:update (dt)
          (when (play:released? 'p)
            (@center "    LEVEL {@level}    " 0)
            (@disab! 'Paused))))

      (met update (dt)
        ...

        (when (play:released? 'p)
          (@enab! 'Paused) (return))

        ...

        ; Move the snakes
        (.at @move-rate dt (fn0 
          (for snake in @snakes (when (> [snake 'lives] 0)
            (let position (clone [[snake 'body] 0]))

            ...

            ; If player runs into any point, he dies
            (when (@occupied? position)
              (play:sound 'die)
              (dec! [snake 'lives])
              (dec! [snake 'score] 10)
              (if (all? (fn1 (== 0 [_ 'lives])) @snakes)
                (@enab! 'Game-Over)
                (@enab! 'Erase-Snake snake))
              (return))

        ...

    (state Game-Over
      (init-state ()
        (play:fill ..(@screen-coords 10 (-> @grid-width (/ 2) (- 16))) ..(@screen-coords 7 32) 255 255 255)
        (play:fill ..(@screen-coords 11 (-> @grid-width (/ 2) (- 15))) ..(@screen-coords 5 30) ..@background)
        (@center "G A M E   O V E R" 13))
      (met update (dt)))))
      
      





In jedem Frame (beim Aufruf von window.requestAnimationFrame) ruft die Game Engine die Game.update-Methode auf. Innerhalb der Game-Klasse wird ein Automat aus den Zuständen Init-Level, Playing, Erase-Snake und Game-Over definiert, von denen jeder die Aktualisierungsmethode auf seine eigene Weise definiert. Im Status "Spielen" sind fünf private Felder definiert, auf die von anderen Status aus nicht zugegriffen werden kann. Zusätzlich hat der Wiedergabezustand einen verschachtelten angehaltenen Zustand, d.h. Das Spiel kann sich entweder im Status "Spielen" oder im Status "Spielen: Angehalten" befinden. Der Konstruktor für angehaltene Zustände druckt bei jedem Übergang in diesen Zustand die entsprechende Zeile auf dem Bildschirm. In diesem Zustand prüft die Aktualisierungsmethode, ob die P-Taste erneut gedrückt wurde. Wenn sie gedrückt und losgelassen wird, verlässt sie den angehaltenen Zustand und kehrt in den "normalen" Wiedergabestatus zurück. Die Methode zur Aktualisierung des Wiedergabestatus verarbeitet Tastenanschläge.berechnet die neue Position der Spieler und wenn einer von ihnen gegen die Wand stößt, geht es entweder in den Game-Over-Status oder in den Erase-Snake-Status. Der Konstruktor des Erase-Snake-Status ist insofern interessant, als er als Parameter eine Verknüpfung zu einer Schlange verwendet, die vor dem Neustart des Levels wunderschön gelöscht werden muss. Schließlich zeigt der Konstruktor für den Game-Over-Status eine entsprechende Meldung auf dem Bildschirm an, und die Aktualisierungsmethode ist leer. Dies bedeutet, dass unabhängig davon, welche Tasten gedrückt werden, nichts Neues auf dem Bildschirm gezeichnet wird und es unmöglich ist, diesen Status zu verlassen.Schließlich zeigt der Konstruktor für den Game-Over-Status eine entsprechende Meldung auf dem Bildschirm an, und die Aktualisierungsmethode ist leer. Dies bedeutet, dass unabhängig von den gedrückten Tasten nichts Neues auf dem Bildschirm gezeichnet wird und es unmöglich ist, diesen Status zu verlassen.Schließlich zeigt der Konstruktor für den Game-Over-Status eine entsprechende Meldung auf dem Bildschirm an, und die Aktualisierungsmethode ist leer. Dies bedeutet, dass unabhängig von den gedrückten Tasten nichts Neues auf dem Bildschirm gezeichnet wird und es unmöglich ist, diesen Status zu verlassen.



Das Spiel könnte auf ähnliche Weise in einer klassischen Skriptsprache implementiert werden: Die Game-Klasse hätte InitLevel-, Playing-, EraseSnake- und GameOver-Klassen verschachtelt, es gäbe ein currentState-Feld und die Game.update-Methode würde den Aufruf an currentState.update delegieren. Innerhalb der Playing-Klasse befindet sich eine verschachtelte Paused-Klasse, und die Playing.update-Methode delegiert den Aufruf wiederum an das Unterobjekt. Standardbibliotheksmakros verbergen die automatische Generierung von currentState-Feldern und Delegierungsmethoden, sodass der Spieleentwickler eher aussagekräftige Statusimplementierungen als deren Boilerplate-Framing sieht.



Anstelle einer Zustandsmaschine wäre es möglich, Nibbles als Schleife zu implementieren:



while (lives>0) {
  InitLevel;
  while (prize<10) {
    Playing;
    if (dies) {
      EraseSnake;
      break;
    }
  }
}
GameOver;

      
      





So wurde das ursprüngliche QBasic-Spiel implementiert. Für eine Browser-Engine würde eine solche Schleife nach dem Rendern jedes Frames in einen Generator mit Ausbeute eingeschlossen, und Game.update würde aus einem Aufruf von iter-next bestehen! Ich habe die Implementierung als Automat aus zwei Gründen bevorzugt: Erstens funktioniert die Implementierung von Tetris so was der Autor von GameLisp als Beispiel anführt; und zweitens sind die Generatoren in GameLisp im Vergleich zu anderen Skriptsprachen nicht ungewöhnlich. Der Hauptzweck von Automaten besteht darin, die Zustände von Spielcharakteren (Warten, Angreifen, Weglaufen usw.) zu implementieren, was mittels einer Schleife im Generator unmöglich ist. Ein zusätzliches Argument für Automaten ist die Isolierung von Daten, die sich auf jeden der Zustände beziehen.






All Articles