- Tutorial que amplía la arquitectura ECS y la programación metalingüística sobre un entorno de desarrollo de juegos en Common Lisp con un ejemplo práctico de dungeon crawler
- Tras leer un mapa XML de Tiled con cl-tiled, traslada los datos a componentes ECS en lugar de usar directamente objetos CLOS, separando renderizado, colisiones y gestión de memoria
- Combina prefabs de tiles, punteros de imagen, índices padre-hijo y finalizers para evitar carga duplicada y double free, y aprovecha las propiedades personalizadas de Tiled como si fueran datos
- El jugador y los enemigos usan sistemas ECS para movimiento, cambio de animaciones y manejo de colisiones, mientras que los enemigos persiguen evitando muros con búsqueda de rutas A* basada en cl-astar
- Completa un pequeño ejemplo de dungeon crawler de unas 500 líneas con UI basada en Nuklear, objetos narrativos, pausa y condición de victoria
Inicio del proyecto y ejecución básica
- Usando la arquitectura Entity-Component-System y las técnicas de programación metalingüística vistas en la Parte 1, se crea un pequeño dungeon crawler con interfaz
- El binario de demostración ejecutable y el código fuente están en el repositorio de GitHub ecs-tutorial-2
- El entorno de desarrollo parte del entorno de desarrollo de juegos en Common Lisp de la Parte 1, y se actualiza la distribución de Quicklisp desde el REPL de SBCL
(ql-util:without-prompting (ql:update-all-dists))
- Se crea un nuevo proyecto
ecs-tutorial-2con la plantilla cookiecutter-lisp-game, y en el ejemplo se eligeliballegrocomo backend - Después de enlazar el directorio del proyecto a
local-projectsde Quicklisp, se cambia el tamaño de la ventana ensrc/main.lispa 1280×800 - Al ejecutar
(ql:quickload :ecs-tutorial-2)y(ecs-tutorial-2:main), aparece una ventana negra con la resolución indicada y un contador de FPS
Mapas de Tiled y almacenamiento ECS
- Para crear el mapa de la mazmorra se usa el editor de mapas de código abierto Tiled
- Tiled es una herramienta multiplataforma y multiplmotor, y guarda los datos del mapa en XML
- En Common Lisp, cl-tiled carga los archivos de Tiled como objetos Lisp
- El tileset del ejemplo usa Dungeon Tileset II - Extended
- Como los tiles originales de 16×16 son pequeños, se amplían al 200% con ImageMagick para usarlos como tiles de 32×32
level1.tmxy los archivos del tileset pueden descargarse enResources.zip, provisto por el tutorial
- Se agrega la dependencia
cl-tiledaecs-tutorial-2.asd, y se creasrc/map.lisppara separar el código de carga y visualización del mapa - En
src/package.lisp,cl-tiledse registra con el apodo localtiled
Por qué mover objetos CLOS a componentes ECS
- Como
cl-tileddevuelve los datos del mapa como objetos CLOS, es cómodo explorarlos desde el REPL - Si esos objetos se usan directamente en el game loop, el costo de dispatch en tiempo de ejecución puede ser alto
- Para llenar una ventana de 1280×800 con tiles de 32×32, se necesitan al menos 40×25 = 1000 tiles
- En una demo aparte, al activar el renderizado del mapa en un Ryzen 5 3600 de 12 núcleos, los FPS caen de 20,000 a 600
- Eso agrega unos
1/600 - 1/20000 = 0.0016segundos por frame, es decir, más de 1.5 ms
- Al mover los datos leídos por
cl-tiledal almacenamiento decl-fast-ecs, se puede reducir el dispatch y mejorar el aprovechamiento de la caché de CPU - Se agrega la dependencia
cl-fast-ecs, y se llama aecs:make-storageeninity aecs:run-systemsenupdate
Componentes de mapa, tile y prefab
mapes un componente etiqueta que representa la entidad del mapa cargadomap-tilerepresenta un tile individual y tiene un slot Booleanobstaclepara indicar si es un obstáculo, como un muro o una puerta cerrada- El componente
parentindica de qué entidad de mapa son hijos los tiles y objetos relacionados con el mapa- En el slot
entityse especifica:index childrenpara encontrar rápidamente las entidades hijas de un padre específico - El índice se basa en una tabla hash de open addressing, así que ofrece búsquedas promedio de O(1), aunque tiene costo de actualización al crear o borrar
- En el slot
- Se agrega un hook a
ecs:*entity-deleting-hook*para que, cuando se elimine una entidad padre, también se eliminen las entidades hijas encontradas por el índicechildren - El componente
imagesolo almacena un puntero C aALLEGRO_BITMAP- La imagen del tileset se divide en fragmentos de 32×32 con
al_create_sub_bitmapy se guarda el puntero
- La imagen del tileset se divide en fragmentos de 32×32 con
map-tile-prefabes un prefab de tile con el ID globalgiddel tile de Tiled- En
gidse especifica:index map-tile-prefab :unique tpara encontrar una única entidad prefab por ID - Los tiles reales del mapa copian
imagey otros datos del prefab, pero tienen su propiapositionen un componente aparte
- En
- El finalizer de
imagellama aal_destroy_bitmapsolo cuando la entidad esmap-tile-prefab- Esto es para evitar double free, ya que varios tiles del mapa comparten el mismo puntero
ALLEGRO_BITMAP
- Esto es para evitar double free, ya que varios tiles del mapa comparten el mismo puntero
positionysizealmacenan coordenadas y tamaño en pantalla comosingle-float- Como
liballegromaneja las coordenadas de pantalla en punto flotante de precisión simple por compatibilidad con OpenGL, aquí se sigue el mismo enfoque
- Como
Renderizado de imágenes y carga del mapa
- El sistema
render-imagesrenderiza las entidades que tienenpositioneimage- Activa y desactiva sprite batching con
al_hold_bitmap_drawing - Dibuja la imagen en la posición indicada con
al_draw_bitmap - Los prefabs no tienen
position, así que no se procesan en este sistema
- Activa y desactiva sprite batching con
load-bitmapes una función de carga de imágenes que envuelveal_load_bitmapconal:ensure-loadedtile->speccrea una especificación de objeto ECS para generar un prefab de tile- Entidad padre del mapa
- Fragmento de imagen del tile
- ID global del tile de Tiled
- Tamaño del tile
load-tile-prefabverifica mediante el índicemap-tile-prefabsi el prefab ya fue cargado y, si no, lo crea conmake-objectload-tilecopia componentes del prefab y agregapositional crear la entidad del tile real del mapaload-maprecorre tilesets y capas del objeto CLOS leído contiled:load-map- Carga la imagen del tileset y crea un prefab para cada tile
- Convierte cada celda de una capa de tiles en una entidad y copia los datos del prefab
- El orden de las capas en Tiled se conserva tal como está en el editor, y
make-entitygarantiza números de entidad crecientes- Como el sistema procesa primero las entidades más antiguas, los tiles de las capas superiores se dibujan después y cubren las capas inferiores
- Guardar todos los tiles como entidades separadas no es la única opción; también se puede prerenderizar un mapa estático en un buffer
Animación de tiles
- Tiled admite tiles animados, por lo que se pueden representar elementos como antorchas o fuentes mágicas
- Se agregan
common.lispyanimation.lisppara separar los componentes comunes y los componentes/sistemas relacionados con animación - El componente
animation-framerepresenta un frame de una animaciónsequencees el nombre de la animación y se guarda como tipokeyword- Se usan índices
sequence-framespara encontrar los frames de una animación específica durationes la duración del frame en segundos
animation-stateguarda el estado actual de un tile animado real en el mapasequenceactualframeactualdurationdel frame actual- Tiempo
elapsedmostrado del frame actual
- Se agrega la dependencia
let-pluspara escribir de forma más concisa el código de cambio de frames - El sistema
update-animationsincrementaelapsedendty, si supera la duración, cambia al siguiente frame- Como el tiempo del frame puede ser menor que un
dtgrande, se usafloorpara calcular cuántos frames hay que saltar - Se usa
truncatepara que, si el número de frame supera la longitud de la lista, vuelva al inicio en ciclo - El puntero bitmap de
imagese cambia al bitmap del prefab del siguiente frame
- Como el tiempo del frame puede ser menor que un
- Como la duración de la animación se guarda en milisegundos en Tiled, en
animation->specse convierte a segundos instantiate-animationcreaanimation-stateen la entidad real del tile e inicializaelapsedcon un valor aleatorio entre 0 ydurationpara que las mismas animaciones no queden totalmente sincronizadas- Los tiles animados deben tener la propiedad de Tiled
"sequence"- Si falta esta propiedad, se cargará con nombre
NIL, no se podrá encontrar con el nombre de animación esperado y puede producirse un error de tipo
- Si falta esta propiedad, se cargará con nombre
Personaje del jugador y controles
- Se agrega
character.lispy se define el componentecharacterpara personajes que pueden moversespeedes la velocidad en píxeles por segundotarget-x,target-yson las coordenadas objetivo del movimiento- Los valores objetivo iniciales se dejan en
single-float-nanpara evitar que un personaje nuevo se mueva sin motivo hacia la esquina superior izquierda
- El componente etiqueta
playerusa un slotbity:index player-entity :unique t- Es una estructura para encontrar la entidad del jugador en O(1) con
(player-entity 1) - No se guarda la entidad del jugador en una variable global
- Es una estructura para encontrar la entidad del jugador en O(1) con
- En la implementación inicial, se recorta una imagen de orco del tileset para crear
player.pngy el jugador se crea de forma hardcodeada conload-player- La posición es
(64.0, 64.0) - El tamaño es 32×32
- La velocidad es 100.0
- La posición es
- El sistema
move-charactersmueve a los personajes hacia el punto objetivo- Si hay coordenadas objetivo
NaN, se inicializan con la posición actual - Se usa
approx-equalen lugar de comparación directa de punto flotante - Se calculan nuevas coordenadas con
atan,cos,sin, velocidad ydt
- Si hay coordenadas objetivo
- El sistema
control-playerlee la entrada de tecladoW,A,S,Dy actualiza las coordenadas objetivo- Usa
al:with-current-keyboard-stateyal:key-down - Usa
clamppara no salir de los límites de la pantalla - Se ejecuta con
:after (move-characters)para evitar el problema de inicialización deNaNejecutándose después del sistema de movimiento
- Usa
Colisiones y carga de objetos con propiedades de Tiled
- Al principio, las paredes eran imágenes normales igual que los tiles de piso, así que el jugador atravesaba las paredes
- Se crea una clase
map-tilecomo tipo personalizado de Tiled y se agrega un miembro Booleanobstacle- Se agrega la propiedad
map-tilea los tiles de pared y se marcaobstacle
- Se agrega la propiedad
- La función
properties->specconvierte la tabla hash de propiedades de Tiled en una especificación de objetos ECS- Las clases personalizadas de Tiled se tratan como componentes
- Los miembros de la clase se tratan como slots del componente
- El ejemplo tiene la forma
((:map-tile :obstacle t))
load-tile-prefabincluye el resultado deproperties->specen la especificación del prefab- Si no hay propiedades, con
spec-adjoinse agrega el componentemap-tilepredeterminado yobstacletoma el valor por defectonil
- Si no hay propiedades, con
- Se agregan el slot
tile-hashy el índicetilesal componentepositiontile-hashconviertexeya enteros y luego los empaqueta en un único entero de 64 bits- Con el índice
tilesse pueden encontrar todas las entidades en la coordenada superior izquierda de un tile específico
tile-startdevuelve la coordenada superior izquierda del tile de la grilla al que pertenece una coordenada arbitrariatile-obstacle-pverifica si, entre las entidades de la misma coordenada, hay algún tilemap-tileconobstacleverdaderoobstaclepcomprueba si el tile correspondiente a una coordenada arbitraria es un obstáculocontrol-playerrevisa, según la dirección de movimiento, los tiles de las esquinas relevantes del rectángulo del personaje, y si hay un obstáculo devuelve la coordenada objetivo a la posición actual- Este método de colisión no es perfecto
- Si se diseñara usando la coordenada central del personaje, las matemáticas y el código podrían simplificarse, pero el ejemplo mantiene el enfoque actual para evitar más complejidad
Carga del jugador y personajes animados desde el mapa
- Se agregan las clases personalizadas
characteryplayeren Tiledcharactersolo tiene el miembro floatspeedtarget-xytarget-yse omiten para usar los valores predeterminadosplayertiene un miembro intplayercon valor predeterminado 1
- En la capa de objetos de Tiled se coloca el personaje del jugador como objeto tile y se le asignan las propiedades
characteryplayer load-mapse amplía para que también procesetiled:object-layer- Convierte las propiedades del objeto en componentes ECS con
properties->spec tiled:tile-objectcopia los datos del tile y la animación conload-tiley establece la posición- Como las coordenadas de los objetos en Tiled usan la esquina inferior izquierda como referencia, se resta la altura del objeto a
ypara ajustarlas a la referencia de esquina superior izquierda
- Convierte las propiedades del objeto en componentes ECS con
- Se eliminan la llamada hardcodeada a
load-playery la función misma - Esta estructura lee directamente los datos del mapa de Tiled como objetos ECS, acercándose a una programación guiada por datos
- La animación del personaje usa las secuencias
orc-idleyorc-rundel orco definidas en el tileset change-animation-sequencecambia la animación actual de una entidad- Si ya está en la misma secuencia, no hace nada
- Busca el primer frame de la nueva secuencia con el índice
sequence-framesy actualizaanimation-stateeimage-bitmap
move-characterscambia a:orc-idlecuando el personaje está detenido y a:orc-runcuando se está moviendo
Enemigos, game over y búsqueda de rutas con A*
- El componente
enemytiene dos slots necesarios para el comportamiento del enemigovision-range: distancia a la que empieza a ver y reaccionar al jugadorattack-range: rango de ataque
- Para terminar el juego, se agrega la variable global
*should-quit*, y el bucle principal finaliza si este valor es verdadero - El sistema
handle-enemiesobtiene las coordenadas del jugador y las compara con las de los enemigos- Si el jugador está dentro del rango de visión, establece las coordenadas objetivo del enemigo en la posición del jugador
- Si el jugador está dentro del rango de ataque, establece
*should-quit*en verdadero y muestra el cuadro de mensaje nativoYou died
- La animación de los enemigos usa las secuencias
demon-idleydemon-runmove-characterselige la animación del orco para el jugador y la del demonio para los enemigos según el resultado dehas-player-p
- Como con el seguimiento directo los enemigos también atraviesan paredes, se agrega búsqueda de rutas con A*
- Se añade cl-astar como dependencia
- Esta biblioteca genera mediante macros funciones de búsqueda de rutas optimizadas para el problema concreto
- La ruta no se guarda como un arreglo dentro de un slot del componente, sino que cada punto de la ruta se representa como una entidad separada
path-pointtienex,yytraveller, ytravellerusa el índicepath-pointspathguarda el destino final endestination-xydestination-y- Las coordenadas objetivo de
characterrepresentan el siguiente punto de la ruta, mientras quepathrepresenta el destino final
- El sistema
follow-pathtoma el primer punto de la ruta y mueve el personaje hacia ese punto- Al llegar al punto, elimina la entidad
path-pointcorrespondiente - Si ya no quedan puntos, elimina el componente
path
- Al llegar al punto, elimina la entidad
find-pathse define cona*:define-path-finder- El tamaño del mundo se calcula dividiendo el tamaño de la ventana entre el tamaño del tile
- Usa un indexador row-major
- Determina que se alcanzó el objetivo cuando las coordenadas del tile coinciden
- Enumera vecinos en 8 direcciones
- Los obstáculos o los movimientos diagonales que atraviesan obstáculos reciben un costo
most-positive-single-float, volviéndolos prácticamente imposibles - La heurística usa octile distance
- Si ya existe una ruta, elimina sus puntos y asigna un nuevo
path - Cada punto de la ruta resultante se crea como una entidad con
path-pointyparent
handle-enemiesllama afind-pathcuando el enemigo ve al jugador y el destino de la ruta existente no coincide con la posición del jugador- Tras el cambio, los enemigos persiguen al jugador evitando los obstáculos
UI del juego basada en Nuklear
- Para los elementos narrativos hace falta una GUI, pero las bibliotecas GUI tradicionales como Qt o GTK no encajan con una UI de juego dibujada sobre el contexto gráfico de
liballegro - Se usa Nuklear como biblioteca de UI
- Existe el binding de Common Lisp cl-liballegro-nuklear para usarlo junto con
liballegro - El binding también ofrece un DSL para interfaces declarativas
- Existe el binding de Common Lisp cl-liballegro-nuklear para usarlo junto con
- Se agrega la dependencia
cl-liballegro-nuklear/declarativey se añadesrc/narrative.lispcomo archivo nuevo - En el paquete se registra el alias local
uipara referirse de forma breve acl-liballegro-nuklear/declarative - Como fuente de la UI se usa Alegreya de Google Fonts, renombrando el archivo a
alegreya-sc.ttf ui:defwindow narrativedefine la función de la ventana narrativa- La posición de la ventana se calcula en el área central de la pantalla
ui:label-wrapmuestra texto con ajuste automático de líneaui:button-label "Ok"devuelve verdadero al hacer clic
- Nuklear es una biblioteca de UI de immediate mode
- En lugar de un retained mode que mantiene objetos widget en memoria, renderiza y procesa cada frame
- El clic de un botón no se maneja con callbacks, sino con valores de retorno y condiciones evaluados en cada frame
main.lispcarga la fuente de la UI e inicializa el contexto de UI connk:allegro-init- En el bucle de eventos llama a
nk:input-begin,nk:allegro-handle-eventynk:input-end - Durante el renderizado llama a
nk:allegro-render - Al salir llama a
nk:allegro-shutdownynk:allegro-font-del
- En el bucle de eventos llama a
Skin de la UI y objetos narrativos
- Como la UI predeterminada es monótona, se estiliza usando los assets de imagen
fantasy-ui-bordersde Kenney - Las variables globales
*window-background*,*button-normal-background*,*button-hover-background*y*button-active-background*almacenan las imágenes de la UI load-uicarga las imágenes connk:allegro-create-image, yunload-uilibera los recursos de imagen del lado de C connk:allegro-del-imageinitllama aload-ui, y al terminar el bucle principal se llama aunload-ui- El argumento
:stylesdeui:defwindowespecifica el fondo, las imágenes para cada estado del botón y el color del texto - El componente
narrativerepresenta objetos de environmental storytellingtext: texto que se mostraráshown: si ya se mostró al menos una vezactive: si la ventana está activa en ese momentoactivetiene el índiceactive-narratives
- El sistema
show-narrativemuestra la ventana si el jugador está cerca de un objeto narrativo- La distancia de interacción se calcula con
+interact-distance-factor+y el tamaño de tile del jugador - Muestra la ventana si ya estaba activa, si todavía no se había mostrado o si se presiona la tecla
E - La ventana se cierra con el botón
Ok,Esc,SpaceoEnter
- La distancia de interacción se calcula con
- En Tiled se crea un tipo personalizado
narrativey se agrega el miembro stringtext - Para la detección de colisiones y la alineación, los objetos no transitables deben ajustarse a las coordenadas de la cuadrícula de tiles
- El problema de que la mazmorra siga moviéndose mientras la ventana narrativa está abierta se evita con condiciones de ejecución de sistemas
- Se añade
:when (null (active-narratives t))amove-charactersycontrol-player - Si hay una narrativa activa, los sistemas de movimiento y control no se ejecutan
- Se añade
- La condición de victoria se agrega con el componente etiqueta
win- Si se cierra la ventana en un objeto que también tiene
narrative, se establece*should-quit*en verdadero y el juego termina
- Si se cierra la ventana en un objeto que también tiene
Cierre y alcance
- El ejemplo final arma un dungeon crawler estilo Souls-like con environmental storytelling, IA enemiga y GUI usando
cl-fast-ecs,cl-tiled,cl-astarycl-liballegro-nuklear - La implementación tiene unas 500 líneas de código
- El código completo está en el repositorio de GitHub e incluye, además del código del tutorial, declaraciones de tipo opcionales con
declaim - No se tratan el diseño de sonido, las cutscenes, el menú principal, las transiciones de nivel ni el “door problem”
- Autumn Lisp Game Jam 2024 se celebrará el 25 de octubre de 2024 en itch.io, y es un evento donde se hacen juegos en 10 días con dialectos de Lisp y luego se evalúan y comentan entre participantes
- Esta parte se basa en Thoughtbound, una obra presentada en Spring Lisp Game Jam 2023
- En la siguiente parte se adelanta el desafío de escalar el proyecto y añadir una IA más avanzada para crear un juego de estrategia en tiempo real
1 comentarios
Opiniones en Hacker News
Ojalá todos los tutoriales técnicos fueran así. El texto está bien estructurado, casi no tiene errores de sintaxis, explica cada tema nuevo en la medida justa y además incluye ejemplos de código completos y material visual que muestra qué hace realmente el código.
Es lo bastante largo como para tratar el material en profundidad, pero también lo bastante independiente como para seguirlo aunque no hayas leído la primera parte y solo hayas usado Common Lisp durante unos meses hace años. Sí he trabajado bastante con Clojure y Emacs Lisp.
Bravo, awkravchuk/Andrew :^)
(Publicado también desde https://mxjn.me/2024/10/17/1)
También tiene la ventaja de que se puede guardar fácilmente para uso offline o archivo, anotar y buscar.
Pocas cosas en tecnología me conmueven tanto como un gran proyecto o artículo sobre Common Lisp. Este texto es un verdadero regalo.
Leí la primera parte cuando salió, y tengo muchas ganas de leer esta también. Mis elogios al autor.
package.shy, en general, la gestión de builds para 3 sistemas operativos ya son una masterclass por sí solos. Aprendí mucho con solo revisar el repositorio de GitHub.Normalmente compilo apps de línea de comandos en Common Lisp con SBCL o LispWorks, pero quizá la próxima lo haga con ECL. Es genial tener builds para macOS y Linux, y también parece divertido probar algo nuevo.
Muy buen artículo. Estoy desarrollando en Lisp, más precisamente en ClojureScript, un shooter multijugador en tercera persona basado en hechizos. Es un juego 3D basado en la web, y pienso escribir en el blog sobre el recorrido, incluyendo las herramientas y abstracciones que creé para el proyecto.
Si les interesa, la demo está aquí: https://wizardmasters.io
El artículo en sí es muy sólido, pero al ver que el proceso de configuración de la primera parte pasa por Common Lisp en sí, Python, C y varias etapas, se entiende por qué CL no es tan popular, especialmente entre programadores jóvenes.
Es una pena, y ojalá alguien se tome el trabajo de hacer que el lenguaje sea más accesible desde el punto de vista de la instalación.
Según entiendo, se enfoca más en el problema de que hay demasiadas opciones y valores predeterminados antiguos que se sienten obsoletos.
El bucle de eventos es un excelente ejemplo de lo mucho que
loopes un lenguaje específico de dominio para iteración en toda regla. Te guste o no ;)loop? No tiene esa sintaxis extraña que no es S-expresión, ni necesitasdopara volver a la sintaxis de Lisp.Usa
if/whennormales sin los feoselse/end, y en general agrega funciones útiles.loopse convirtió en uno de mis elementos favoritos de CL.Este artículo me recuerda a "Caves of Clojure": https://stevelosh.com/blog/2012/07/caves-of-clojure-01/
Justo esta semana empecé a desarrollar un roguelike en Python, pero hacerlo en Lisp también suena genial.
Me siento engañado. Vine a aprender cómo hacer un juego simple y terminé aprendiendo muchísimo sobre computación en general.
Excelente.