- Tutoriel qui prolonge l’architecture ECS et la programmation métalinguistique sur un environnement de développement de jeux en Common Lisp, à travers un exemple concret de dungeon crawler
- Après lecture d’une carte Tiled XML avec cl-tiled, les objets CLOS ne sont pas utilisés tels quels : ils sont transférés vers des composants ECS afin de séparer rendu, collisions et gestion mémoire
- En combinant des préfabs de tuiles, des pointeurs d’images, des index parent-enfant et des finalizers, on évite le chargement en double et les double free, tout en exploitant les propriétés personnalisées de Tiled comme des données
- Le joueur et les ennemis utilisent des systèmes ECS pour gérer déplacement, transitions d’animation et collisions ; les ennemis poursuivent le joueur en évitant les murs grâce à une recherche de chemin A* basée sur cl-astar
- Une UI basée sur Nuklear, des objets narratifs, la pause et une condition de victoire sont ajoutés pour aboutir à un petit exemple de dungeon crawler d’environ 500 lignes
Démarrage du projet et exécution de base
- On crée un petit dungeon crawler avec UI en utilisant l’architecture Entity-Component-System et les techniques de programmation métalinguistique abordées dans la partie 1
- Le binaire de démonstration exécutable et le code source se trouvent dans le dépôt GitHub ecs-tutorial-2
- L’environnement de développement suppose celui de développement de jeux en Common Lisp de la partie 1, et la distribution Quicklisp est mise à jour depuis le REPL SBCL
(ql-util:without-prompting (ql:update-all-dists))
- On crée un nouveau projet
ecs-tutorial-2avec le template cookiecutter-lisp-game ; dans l’exemple,liballegroest choisi comme backend - Après avoir lié le répertoire du projet à
local-projectsde Quicklisp, on modifie la taille de la fenêtre danssrc/main.lispà 1280×800 - En exécutant
(ql:quickload :ecs-tutorial-2)puis(ecs-tutorial-2:main), une fenêtre noire à la résolution indiquée et un compteur de FPS s’affichent
Carte Tiled et stockage ECS
- La création de la carte du donjon utilise l’éditeur de cartes open source Tiled
- Tiled est un outil multiplateforme et multi-moteur, qui stocke les données de carte en XML
- En Common Lisp, cl-tiled charge les fichiers Tiled sous forme d’objets Lisp
- L’exemple utilise le tileset Dungeon Tileset II - Extended
- Les tuiles originales en 16×16 étant petites, elles sont agrandies à 200 % avec ImageMagick pour être utilisées comme tuiles 32×32
level1.tmxet le fichier de tileset sont disponibles dansResources.zipfourni par le tutoriel
- On ajoute la dépendance
cl-tiledàecs-tutorial-2.asd, puis on créesrc/map.lisppour séparer le code de chargement et d’affichage de la carte - Dans
src/package.lisp,cl-tiledest enregistré sous le surnom localtiled
Pourquoi transférer les objets CLOS vers des composants ECS
cl-tiledrenvoie les données de carte sous forme d’objets CLOS, ce qui les rend pratiques à explorer dans le REPL- Les utiliser directement dans la boucle de jeu peut entraîner un coût de dispatch à l’exécution important
- Remplir une fenêtre 1280×800 avec des tuiles 32×32 nécessite au minimum 40×25 = 1000 tuiles
- Dans une démonstration distincte, sur un Ryzen 5 3600 à 12 cœurs, l’activation du rendu de la carte fait chuter les FPS de 20 000 à 600
- Cela ajoute environ
1/600 - 1/20000 = 0.0016seconde par frame, soit plus de 1,5 ms
- Transférer les données lues par
cl-tiledvers le stockagecl-fast-ecspermet de réduire le dispatch et d’améliorer l’utilisation du cache CPU - On ajoute la dépendance
cl-fast-ecs, puis on appelleecs:make-storagedansinitetecs:run-systemsdansupdate
Composants de carte, tuiles et préfabs
mapest un composant tag qui indique l’entité de carte chargéemap-tilereprésente une tuile individuelle et possède un slot booléenobstaclepour indiquer s’il s’agit d’un obstacle, comme un mur ou une porte fermée- Le composant
parentindique de quelle entité de carte une tuile ou un objet lié à la carte est l’enfant- Le slot
entityreçoit:index childrenafin de retrouver rapidement les entités enfants d’un parent donné - L’index repose sur une table de hachage à adressage ouvert et offre donc des recherches en O(1) en moyenne, mais implique un coût de mise à jour lors des créations et suppressions
- Le slot
- Un hook est ajouté à
ecs:*entity-deleting-hook*afin que, lorsqu’une entité parent est supprimée, les entités enfants trouvées via l’indexchildrensoient également supprimées - Le composant
imagene stocke que le pointeur C versALLEGRO_BITMAP- L’image du tileset est découpée en fragments 32×32 avec
al_create_sub_bitmap, puis les pointeurs sont stockés
- L’image du tileset est découpée en fragments 32×32 avec
map-tile-prefabest un préfab de tuile portant legid, l’ID global de la tuile Tiledgidreçoit:index map-tile-prefab :unique tafin de retrouver une unique entité préfab par ID- Les tuiles réelles sur la carte copient notamment l’
imagedu préfab, mais possèdent leur propre composantposition
- Le finalizer de
imagen’appelleal_destroy_bitmapque lorsque l’entité est unmap-tile-prefab- Cela permet d’éviter un double free, car plusieurs tuiles de la carte partagent le même pointeur
ALLEGRO_BITMAP
- Cela permet d’éviter un double free, car plusieurs tuiles de la carte partagent le même pointeur
positionetsizestockent les coordonnées à l’écran et la taille ensingle-floatliballegromanipulant les coordonnées d’écran en flottants simple précision pour des raisons de compatibilité OpenGL, le même choix est conservé
Rendu des images et chargement de la carte
- Le système
render-imagesrend les entités qui possèdentpositionetimageal_hold_bitmap_drawingactive et désactive le sprite batchingal_draw_bitmapdessine l’image aux coordonnées indiquées- Les préfabs n’ayant pas de
position, ils ne sont pas traités par ce système
load-bitmapest une fonction de chargement d’image qui enveloppeal_load_bitmapavecal:ensure-loadedtile->specconstruit la spécification d’objet ECS pour créer un préfab de tuile- Entité de carte parente
- Fragment d’image de la tuile
- ID global de tuile Tiled
- Taille de la tuile
load-tile-prefabvérifie via l’indexmap-tile-prefabsi le préfab est déjà chargé et, sinon, le crée avecmake-objectload-tilecopie les composants du préfab lors de la création d’une entité de tuile réelle de la carte, puis ajoutepositionload-mapparcourt les tilesets et les couches à partir de l’objet CLOS lu partiled:load-map- Il charge l’image du tileset et crée chaque tuile comme préfab
- Il crée une entité pour chaque cellule des couches de tuiles et copie les données du préfab
- L’ordre des couches de Tiled est conservé tel qu’il apparaît dans l’éditeur, et
make-entitygarantit des numéros d’entités croissants- Comme les systèmes traitent les entités les plus anciennes en premier, les tuiles des couches supérieures sont dessinées plus tard et recouvrent les couches inférieures
- Stocker toutes les tuiles comme entités distinctes n’est pas la seule solution ; il est aussi possible de pré-rendre une carte statique dans un buffer
Animation des tuiles
- Tiled prend en charge les tuiles animées, ce qui permet de représenter des éléments comme des torches ou des fontaines magiques
- Ajout de
common.lispetanimation.lispafin de séparer les composants communs des composants et systèmes liés à l’animation - Le composant
animation-framereprésente une image d’une animationsequenceest le nom de l’animation, stocké avec le typekeyword- L’index
sequence-framespermet de retrouver les images d’une animation donnée durationest la durée d’affichage de l’image, en secondes
animation-statestocke l’état courant d’une tuile animée sur la carte réelle- La
sequencecourante - La
framecourante - La
durationde l’image courante - Le temps
elapsedécoulé depuis l’affichage de l’image courante
- La
- Ajout de la dépendance
let-pluspour écrire plus simplement le code de changement d’image - Le système
update-animationsincrémenteelapseddedtet passe à l’image suivante lorsque la durée est dépassée- Comme la durée d’une image peut être inférieure à un grand
dt,floorsert à calculer combien d’images doivent être sautées truncatepermet de revenir au début lorsque le numéro d’image dépasse la longueur de la liste- Le pointeur bitmap de
imageest remplacé par le bitmap du prefab de l’image suivante
- Comme la durée d’une image peut être inférieure à un grand
- Les durées d’animation étant stockées en millisecondes dans Tiled,
animation->specles convertit en secondes instantiate-animationcrée unanimation-statesur l’entité de tuile réelle et initialiseelapsedavec une valeur aléatoire entre 0 etduration, afin que les animations identiques ne soient pas parfaitement synchronisées- Les tuiles animées doivent avoir la propriété Tiled
"sequence"- Sans cette propriété, elles sont chargées avec le nom
NIL, ne peuvent pas être retrouvées sous le nom d’animation attendu et peuvent provoquer une erreur de type
- Sans cette propriété, elles sont chargées avec le nom
Personnage joueur et contrôles
- Ajout de
character.lispet définition du composantcharacterpour les personnages pouvant se déplacerspeedest la vitesse en pixels par secondetarget-xettarget-ysont les coordonnées de destination du déplacement- Les valeurs cibles initiales sont définies à
single-float-nanafin qu’un nouveau personnage ne se déplace pas sans raison vers le coin supérieur gauche
- Le composant tag
playerutilise un slotbitet:index player-entity :unique t- Cette structure permet de trouver l’entité joueur en O(1) avec
(player-entity 1) - L’entité joueur n’est pas stockée dans une variable globale
- Cette structure permet de trouver l’entité joueur en O(1) avec
- Dans l’implémentation initiale, une image d’orc est découpée depuis le tileset pour créer
player.png, puis le joueur est créé en dur avecload-player- Position :
(64.0, 64.0) - Taille : 32×32
- Vitesse : 100.0
- Position :
- Le système
move-charactersdéplace le personnage jusqu’à son point cible- Si les coordonnées cibles sont
NaN, elles sont initialisées à la position courante approx-equalest utilisé au lieu d’une comparaison directe de nombres à virgule flottante- Les nouvelles coordonnées sont calculées avec
atan,cos,sin, la vitesse etdt
- Si les coordonnées cibles sont
- Le système
control-playerlit les touchesW,A,S,Det met à jour les coordonnées cibles- Il utilise
al:with-current-keyboard-stateetal:key-down clampempêche de sortir des limites de l’écran- Il s’exécute après le système de déplacement avec
:after (move-characters), afin d’éviter le problème d’initialisation àNaN
- Il utilise
Chargement des collisions et des objets via les propriétés Tiled
- Au départ, les murs sont de simples images comme les tuiles de sol, si bien que le joueur les traverse
- Création d’une classe
map-tilecomme type personnalisé Tiled, avec ajout d’un membre Booleanobstacle- La propriété
map-tileest ajoutée aux tuiles de mur etobstacleest coché
- La propriété
- La fonction
properties->specconvertit la table de hachage des propriétés Tiled en spécification d’objet ECS- Les classes personnalisées Tiled sont traitées comme des composants
- Les membres de classe sont traités comme des slots de composant
- Exemple sous la forme
((:map-tile :obstacle t))
load-tile-prefabinclut le résultat deproperties->specdans la spécification du prefab- S’il n’y a pas de propriété,
spec-adjoinajoute un composantmap-tilepar défaut etobstacleprend la valeur par défautnil
- S’il n’y a pas de propriété,
- Ajout d’un slot
tile-hashet d’un indextilesau composantpositiontile-hashconvertitxetyen entiers, puis les compacte dans un seul entier 64 bits- Toutes les entités situées aux coordonnées du coin supérieur gauche d’une tuile donnée peuvent être retrouvées avec l’index
tiles
tile-startrenvoie les coordonnées du coin supérieur gauche de la tuile de grille contenant des coordonnées arbitrairestile-obstacle-pvérifie si, parmi les entités aux mêmes coordonnées, il existe une tuilemap-tiledontobstacleest vraiobstaclepvérifie si la tuile correspondant à des coordonnées arbitraires est un obstaclecontrol-playerexamine, selon la direction du déplacement, les tuiles des coins pertinents du rectangle du personnage et, s’il y a un obstacle, remet les coordonnées cibles à la position courante- Cette méthode de collision n’est pas parfaite
- Une conception fondée sur les coordonnées du centre du personnage pourrait simplifier les maths et le code, mais l’exemple conserve l’approche actuelle pour éviter davantage de complexité
Chargement du joueur et des personnages animés depuis la carte
- Ajout des classes personnalisées
characteretplayerdans Tiledcharacterne possède qu’un membre floatspeedtarget-xettarget-ysont omis afin d’utiliser les valeurs par défautplayerpossède un membre intplayer, avec 1 comme valeur par défaut
- Le personnage joueur est placé comme objet tuile sur une couche d’objets Tiled, avec les propriétés
characteretplayer load-mapest étendu pour traiter aussitiled:object-layer- Les propriétés des objets sont converties en composants ECS avec
properties->spec tiled:tile-objectcopie les données de tuile et l’animation avecload-tile, puis définit la position- Les coordonnées d’objet Tiled étant basées sur le coin inférieur gauche, on soustrait la hauteur de l’objet à
ypour les aligner sur le coin supérieur gauche
- Les propriétés des objets sont converties en composants ECS avec
- L’appel à
load-playercodé en dur et la fonction correspondante sont supprimés - Cette structure lit directement les données de carte Tiled sous forme d’objets ECS, ce qui se rapproche d’une programmation pilotée par les données
- L’animation du personnage utilise les séquences
orc-idleetorc-runde l’orc, définies dans le tileset change-animation-sequencechange l’animation courante d’une entité- Si la séquence est déjà la même, elle ne fait rien
- Elle trouve la première image de la nouvelle séquence via l’index
sequence-frames, puis met à jouranimation-stateetimage-bitmap
move-charactersbascule vers:orc-idlelorsque le personnage est immobile, et vers:orc-runlorsqu’il se déplace
Ennemis, game over et recherche de chemin A*
- Le composant
enemypossède deux slots nécessaires au comportement des ennemisvision-range: distance à partir de laquelle l’ennemi voit le joueur et commence à réagirattack-range: portée d’attaque
- Pour mettre fin à la partie, une variable globale
*should-quit*est ajoutée, et la boucle principale se termine si cette valeur est vraie - Le système
handle-enemiesrécupère les coordonnées du joueur et les compare à celles des ennemis- Si le joueur est dans le champ de vision, les coordonnées cibles de l’ennemi sont définies sur la position du joueur
- Si le joueur est à portée d’attaque,
*should-quit*est défini sur vrai et une boîte de message nativeYou dieds’affiche
- Les animations des ennemis utilisent les séquences
demon-idleetdemon-runmove-characterschoisit l’animation d’orc pour le joueur et l’animation de démon pour les ennemis selon le résultat dehas-player-p
- Avec le suivi direct, les ennemis traversent eux aussi les murs ; une recherche de chemin A* est donc ajoutée
- cl-astar est ajouté aux dépendances
- Cette bibliothèque génère, au moyen d’une macro, une fonction de recherche de chemin optimisée pour le problème donné
- Le chemin n’est pas stocké sous forme de tableau dans un slot de composant : chaque point du chemin est représenté par une entité distincte
path-pointpossèdex,yettraveller, ettravellerdispose d’un indexpath-pointspathstocke la destination finaledestination-x,destination-y- Les coordonnées cibles de
characterreprésentent le prochain point du chemin, tandis quepathreprésente la destination finale
- Le système
follow-pathrécupère le premier point du chemin et déplace le personnage vers ce point- Lorsque le point est atteint, l’entité
path-pointcorrespondante est supprimée - S’il n’y a plus de point, le composant
pathest supprimé
- Lorsque le point est atteint, l’entité
find-pathest défini aveca*:define-path-finder- La taille du monde est calculée en divisant la taille de la fenêtre par la taille des tuiles
- Un indexeur en row-major est utilisé
- L’arrivée à l’objectif est déterminée par l’égalité des coordonnées de tuile
- Les voisins sont énumérés dans 8 directions
- Les obstacles, ou les déplacements diagonaux qui traversent un obstacle, reçoivent un coût
most-positive-single-float, ce qui les rend pratiquement impossibles - L’heuristique utilise l’octile distance
- S’il existe déjà un chemin, ses points sont supprimés et un nouveau
pathest attribué - Chaque point du chemin résultant est créé comme une entité possédant
path-pointetparent
- Quand un ennemi voit le joueur,
handle-enemiesappellefind-pathsi la destination du chemin existant diffère de la position du joueur - Après ces changements, les ennemis poursuivent le joueur tout en contournant les obstacles
UI de jeu basée sur Nuklear
- Une GUI est nécessaire pour les éléments narratifs, mais les bibliothèques GUI traditionnelles comme Qt ou GTK ne conviennent pas à une UI de jeu dessinée dans le contexte graphique de
liballegro - Nuklear est utilisé comme bibliothèque d’UI
- Il existe un binding Common Lisp, cl-liballegro-nuklear, pour l’utiliser avec
liballegro - Le binding fournit aussi un DSL pour une interface déclarative
- Il existe un binding Common Lisp, cl-liballegro-nuklear, pour l’utiliser avec
- La dépendance
cl-liballegro-nuklear/declarativeest ajoutée, ainsi qu’un nouveau fichiersrc/narrative.lisp - Un surnom local
uiest enregistré dans le paquet afin de référencer plus brièvementcl-liballegro-nuklear/declarative - La police UI utilisée est Alegreya de Google Fonts, et le fichier est renommé
alegreya-sc.ttf ui:defwindow narrativedéfinit la fonction de fenêtre narrative- La position de la fenêtre est calculée pour la placer dans la zone centrale de l’écran
ui:label-wrapaffiche du texte avec retour à la ligne automatiqueui:button-label "Ok"renvoie vrai lorsqu’il est cliqué
- Nuklear est une bibliothèque d’UI en immediate mode
- Elle ne conserve pas des objets widgets en mémoire comme en retained mode, mais effectue le rendu et le traitement à chaque frame
- Un clic sur un bouton n’est pas géré par callback, mais par une valeur de retour et des conditions à chaque frame
main.lispcharge la police UI et initialise le contexte UI avecnk:allegro-init- Dans la boucle d’événements,
nk:input-begin,nk:allegro-handle-eventetnk:input-endsont appelés - Lors du rendu,
nk:allegro-renderest appelé - À la fermeture,
nk:allegro-shutdownetnk:allegro-font-delsont appelés
- Dans la boucle d’événements,
Skin UI et objets narratifs
- Comme l’UI par défaut est monotone, elle est stylisée avec les assets d’image
fantasy-ui-bordersde Kenney - Les images UI sont stockées dans les variables globales
*window-background*,*button-normal-background*,*button-hover-background*et*button-active-background* load-uicharge les images avecnk:allegro-create-image, etunload-uilibère les ressources d’image côté C avecnk:allegro-del-imageload-uiest appelé dansinit, etunload-uiest appelé à la fin de la boucle principale- L’argument
:stylesdeui:defwindowprécise l’arrière-plan, les images selon l’état des boutons et la couleur du texte - Le composant
narrativereprésente un objet destiné à la narration environnementaletext: texte à affichershown: indique si le texte a déjà été affiché une foisactive: indique si la fenêtre est actuellement activeactivedispose d’un indexactive-narratives
- Le système
show-narrativeaffiche une fenêtre lorsque le joueur se trouve près d’un objet narratif- La distance d’interaction est calculée avec
+interact-distance-factor+et la taille de tuile du joueur - La fenêtre s’affiche si elle est déjà active, si elle n’a encore jamais été affichée, ou si la touche
Eest enfoncée - La fenêtre se ferme avec le bouton
Ok, ou avecEsc,SpaceouEnter
- La distance d’interaction est calculée avec
- Un type personnalisé
narrativeest créé dans Tiled, avec un membre stringtext - Pour correspondre à la détection des collisions, les objets infranchissables doivent être alignés sur la grille de tuiles
- Le problème du donjon qui continue à bouger quand la fenêtre narrative est affichée est évité via des conditions d’exécution des systèmes
:when (null (active-narratives t))est ajouté àmove-charactersetcontrol-player- S’il existe une narration active, les systèmes de déplacement et de contrôle ne s’exécutent pas
- La condition de victoire est ajoutée sous forme de composant tag
win- Lorsque la fenêtre d’un objet auquel
narrativeest aussi attaché est fermée,*should-quit*est défini sur vrai et la partie se termine
- Lorsque la fenêtre d’un objet auquel
Conclusion et périmètre
- L’exemple final utilise
cl-fast-ecs,cl-tiled,cl-astaretcl-liballegro-nuklearpour construire un dungeon crawler de type Souls-like avec narration environnementale, IA ennemie et GUI - La taille de l’implémentation est d’environ 500 lignes de code
- Le code complet se trouve dans le dépôt GitHub et inclut, en plus du code du tutoriel, des déclarations de type optionnelles utilisant
declaim - Le sound design, les cinématiques, le menu principal, les transitions de niveau et le « door problem » ne sont pas abordés
- L’Autumn Lisp Game Jam 2024 aura lieu le 25 octobre 2024 sur itch.io ; c’est un événement où l’on crée un jeu en 10 jours dans un dialecte Lisp, puis où l’on s’évalue mutuellement et échange des retours
- Cette partie s’appuie sur Thoughtbound, une soumission au Spring Lisp Game Jam 2023
- La partie suivante annonce le défi de passer à une plus grande échelle et d’ajouter une IA plus avancée pour créer un jeu de stratégie en temps réel
1 commentaires
Commentaires sur Hacker News
J’aimerais que tous les tutoriels techniques ressemblent à ça. La structure est excellente, il n’y a presque pas d’erreurs de grammaire, et chaque nouveau sujet est expliqué avec la juste dose de détail, avec en plus des exemples de code complets et des supports visuels montrant ce que fait réellement le code
C’est assez long pour traiter le sujet en profondeur, tout en restant suffisamment autonome pour qu’on puisse suivre même sans avoir lu la partie 1, et avec seulement quelques mois de pratique de Common Lisp il y a plusieurs années. J’ai davantage pratiqué Clojure et Emacs Lisp
Bravo, awkravchuk/Andrew :^)
(Crossposted depuis https://mxjn.me/2024/10/17/1)
C’est aussi facile à sauvegarder pour un usage hors ligne ou l’archivage, à annoter, et à parcourir par recherche
Il y a peu de choses dans la tech qui me touchent autant qu’un excellent projet ou article sur Common Lisp. C’est vraiment un texte qui ressemble à un cadeau
J’avais lu la partie 1 à sa sortie, et je suis vraiment impatient de lire celle-ci aussi. Bravo à l’auteur
Rien que
package.shet, plus largement, toute la gestion des builds sur 3 systèmes d’exploitation relèvent de la masterclass. J’ai déjà beaucoup appris rien qu’en parcourant le dépôt GitHubD’habitude, on construit les applications Common Lisp en ligne de commande avec SBCL ou LispWorks, mais la prochaine fois, je pourrais essayer ECL. C’est chouette d’avoir des builds macOS et Linux, et ce serait amusant d’essayer quelque chose de nouveau
Très bon article. Je développe un shooter à sorts multijoueur à la troisième personne en Lisp, plus précisément en ClojureScript. C’est un jeu 3D web, et je compte bloguer sur le parcours, y compris les outils et abstractions que j’ai créés pour le projet
Si ça vous intéresse, il y a une démo ici : https://wizardmasters.io
L’article est vraiment solide en lui-même, mais quand on voit que la configuration de la partie 1 enchaîne Common Lisp lui-même, Python, C et plusieurs étapes, on comprend pourquoi CL n’est pas particulièrement populaire, surtout chez les jeunes programmeurs
C’est dommage, et j’aimerais que quelqu’un fasse l’effort de rendre le langage plus accessible côté installation
Si j’ai bien compris, le projet se concentre davantage sur le fait qu’il y a trop d’options et que les choix par défaut historiques paraissent datés
La boucle d’événements est un excellent exemple de à quel point
loopest un véritable langage spécifique au domaine pour l’itération. Qu’on aime ou non ;)loop? Pas de syntaxe étrange qui ne soit pas une S-expression, et pas besoin non plus dedopour revenir à une syntaxe LispOn peut utiliser les
if/whenhabituels sans les affreuxelse/end, et ça ajoute globalement des fonctionnalités utilesloopest devenu l’un de mes composants CL préférésCet article me fait penser à "Caves of Clojure" : https://stevelosh.com/blog/2012/07/caves-of-clojure-01/
Justement cette semaine, j’ai commencé à développer un roguelike en Python, mais le faire en Lisp pourrait être sympa
Je me sens floué. Je suis venu pour apprendre à faire un petit jeu simple, et j’ai fini par en apprendre énormément sur l’informatique en général
C’est vraiment excellent