Revue GameLisp: un nouveau langage pour écrire des jeux dans Rust



Un programmeur qui se signe sous le pseudonyme Fleabit développe son langage de programmation depuis six mois . La question se pose immédiatement: une autre langue? Pourquoi?



Voici ses arguments:



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


Tout d'abord, la simplicité de la syntaxe et la simplicité de l'interpréteur sont tirées de Lisp dans GameLisp: l'implémentation de GameLisp avec la "bibliothèque standard" prend maintenant 36 KLOC, comparé, par exemple, à 455 KLOC en Python. D'un autre côté, comparé au Lisp classique, GameLisp n'a pas de listes et se concentre beaucoup moins sur la programmation fonctionnelle et les données immuables; à la place, comme la plupart des langages de script, GameLisp se concentre sur la programmation impérative orientée objet.

La syntaxe basée sur Lisp peut être écrasante, mais on s'habitue rapidement à l'écriture (.print console (+ 2 2)), etc. au lieu de console.print (2 + 2). Cette syntaxe est beaucoup plus simple et plus flexible que dans les langages de script familiers: la virgule est considérée comme un caractère d'espacement et peut être utilisée pour améliorer la lisibilité n'importe où dans le code; au lieu de deux types de crochets {} (), seuls les crochets sont utilisés; la plupart des caractères ASCII peuvent être utilisés dans les caractères, donc I ~ <3 ~ Lisp! ~ ^ _ ^ est un nom valide pour une fonction ou une variable; Pas besoin; pour séparer les opérations, etc. Je peux dire que sans aucune expérience passée avec Lisp, en quelques soirées seulement, j'ai pu réécrire le classique NIBBLES.BAS sur GameLisp: http://atari.ruvds.com/nibbles.html



Tout ce qu'il y a dans la "bibliothèque standard" de GameLisp pour les E / S est une fonction prn pour imprimer sur stdout; pas de travail clavier / souris, pas de fichiers, pas de graphiques, pas de son. On suppose que l'utilisateur de GameLisp implémente lui-même dans Rust tous les outils d'interface qui sont spécifiquement pertinents dans son projet. À titre d'exemple d'une telle liaison, un moteur minimaliste pour les jeux par navigateur est publié sur https://gamelisp.rs/playground/ en utilisant wasm-bindgenqui fournit le code GameLisp avec play: down?, play: pressé?, play: relâché?, play: mouse-x, play: mouse-y, play: fill et play: draw. Mon port de Nibbles utilise le même moteur - je viens de lui ajouter une fonction pour jouer du son. Il est intéressant de comparer les tailles: le NIBBLES.BAS original était de 24 Ko; mon port sur GameLisp est de 9 Ko; Le fichier WebAssembly avec le runtime Rust compilé, l'interpréteur GameLisp et le code du jeu fait 2,5 Mo, et il est également livré avec une liaison JavaScript de 11 Ko générée par wasm-bindgen.



Avec un moteur minimaliste sur https://gamelisp.rs/playground/Ajout des implémentations GameLisp de trois jeux classiques: pong, tetris et sapeur. Tetris et Minesweeper sont plus gros et plus complexes que mon port de Nibbles, et il y a beaucoup à apprendre de leur code.



Pour démontrer les capacités de GameLisp, j'ai choisi deux exemples; le premier concerne les macros. Dans NIBBLES.BAS, les niveaux sont spécifiés par le bloc de ligne SELECT CASE avec des boucles imbriquées:



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

...
      
      





Toutes ces boucles ont une structure similaire, qui peut être incluse dans une macro:



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

      
      





Avec cette macro, la description de tous les niveaux est réduite de quatre et devient aussi proche que possible d'une description déclarative de type JSON:



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





Dans un langage sans macros - par exemple, en JavaScript - une implémentation similaire obscurcirait toute la description des niveaux avec lambdas:



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





Cet exemple montre clairement comment le code JavaScript est surchargé avec divers mots de ponctuation et de fonction, dont vous pouvez vous passer.

Mon deuxième exemple concerne les machines à états. Ma mise en œuvre du jeu a la structure suivante:



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





Sur chaque image (comme appelé depuis window.requestAnimationFrame), le moteur de jeu appelle la méthode Game.update. Dans la classe Game, un automate est défini à partir des états Init-Level, Playing, Erase-Snake, Game-Over, chacun définissant la méthode de mise à jour à sa manière. Dans l'état Lecture, cinq champs privés sont définis et ne sont pas accessibles à partir d'autres états. De plus, l'état de lecture a un état de pause imbriqué, c'est-à-dire le jeu peut être à l'état de jeu ou à l'état de jeu: en pause. Le constructeur d'état Paused imprime la ligne appropriée à l'écran chaque fois qu'il passe à cet état; la méthode de mise à jour, dans cet état, vérifie si la touche P a été pressée à nouveau, et si elle est enfoncée et relâchée, quitte l'état Paused, revenant à l'état de lecture "clair". La méthode de mise à jour de l'état de lecture gère les frappes,calcule la nouvelle position des joueurs, et si l'un d'eux s'est écrasé contre le mur, il passe soit à l'état Game-Over soit à l'état Erase-Snake. Le constructeur de l'état Erase-Snake est intéressant en ce qu'il prend comme paramètre un lien vers un serpent, qui doit être magnifiquement effacé avant de redémarrer le niveau. Enfin, pour l'état Game-Over, le constructeur affiche un message correspondant à l'écran, et la méthode de mise à jour est vide, ce qui signifie que quelles que soient les touches enfoncées, rien de nouveau ne sera dessiné à l'écran, et il est impossible de quitter cet état.Enfin, pour l'état Game-Over, le constructeur affiche un message correspondant à l'écran, et la méthode de mise à jour est vide, ce qui signifie que quelles que soient les touches enfoncées, rien de nouveau ne sera dessiné à l'écran, et il est impossible de quitter cet état.Enfin, pour l'état Game-Over, le constructeur affiche un message correspondant à l'écran, et la méthode de mise à jour est vide, ce qui signifie que quelles que soient les touches enfoncées, rien de nouveau ne sera dessiné à l'écran, et il est impossible de quitter cet état.



De même, le jeu pourrait être implémenté dans un langage de script classique: la classe Game aurait imbriqué les classes InitLevel, Playing, EraseSnake, GameOver, il y aurait un champ currentState et la méthode Game.update déléguerait l'appel à currentState.update. À l'intérieur de la classe Playing se trouverait une classe Paused imbriquée et la méthode Playing.update déléguerait à son tour l'appel au sous-objet. Les macros de bibliothèque standard masquent la génération automatique des champs currentState et des méthodes de délégation afin que le développeur du jeu voit une implémentation significative des états, plutôt que leur passe-partout.



Au lieu d'une machine à états, il serait possible d'implémenter Nibbles sous forme de boucle:



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

      
      





C'est ainsi que le jeu QBasic original a été implémenté. Pour un moteur de navigateur, une telle boucle serait enveloppée dans un générateur avec rendement après le rendu de chaque image, et Game.update consisterait en un appel à iter-next! .. J'ai préféré l'implémentation comme un automate pour deux raisons: d'abord, c'est ainsi que fonctionne l'implémentation de Tetris. que l'auteur de GameLisp cite à titre d'exemple; et deuxièmement, il n'y a rien d'inhabituel dans les générateurs de GameLisp par rapport à d'autres langages de script. Le but principal des automates est d'implémenter les états des personnages du jeu (attente, attaque, fuite, etc.), ce qui est impossible grâce à une boucle à l'intérieur du générateur. Un argument supplémentaire en faveur des automates est l'isolement des données liées à chacun des états les unes des autres.






All Articles