2 puntos por GN⁺ 2024-10-19 | 1 comentarios | Compartir por WhatsApp
  • 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-2 con la plantilla cookiecutter-lisp-game, y en el ejemplo se elige liballegro como backend
  • Después de enlazar el directorio del proyecto a local-projects de Quicklisp, se cambia el tamaño de la ventana en src/main.lisp a 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.tmx y los archivos del tileset pueden descargarse en Resources.zip, provisto por el tutorial
  • Se agrega la dependencia cl-tiled a ecs-tutorial-2.asd, y se crea src/map.lisp para separar el código de carga y visualización del mapa
  • En src/package.lisp, cl-tiled se registra con el apodo local tiled

Por qué mover objetos CLOS a componentes ECS

  • Como cl-tiled devuelve 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.0016 segundos por frame, es decir, más de 1.5 ms
  • Al mover los datos leídos por cl-tiled al almacenamiento de cl-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 a ecs:make-storage en init y a ecs:run-systems en update

Componentes de mapa, tile y prefab

  • map es un componente etiqueta que representa la entidad del mapa cargado
  • map-tile representa un tile individual y tiene un slot Boolean obstacle para indicar si es un obstáculo, como un muro o una puerta cerrada
  • El componente parent indica de qué entidad de mapa son hijos los tiles y objetos relacionados con el mapa
    • En el slot entity se especifica :index children para 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
  • 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 índice children
  • El componente image solo almacena un puntero C a ALLEGRO_BITMAP
    • La imagen del tileset se divide en fragmentos de 32×32 con al_create_sub_bitmap y se guarda el puntero
  • map-tile-prefab es un prefab de tile con el ID global gid del tile de Tiled
    • En gid se especifica :index map-tile-prefab :unique t para encontrar una única entidad prefab por ID
    • Los tiles reales del mapa copian image y otros datos del prefab, pero tienen su propia position en un componente aparte
  • El finalizer de image llama a al_destroy_bitmap solo cuando la entidad es map-tile-prefab
    • Esto es para evitar double free, ya que varios tiles del mapa comparten el mismo puntero ALLEGRO_BITMAP
  • position y size almacenan coordenadas y tamaño en pantalla como single-float
    • Como liballegro maneja las coordenadas de pantalla en punto flotante de precisión simple por compatibilidad con OpenGL, aquí se sigue el mismo enfoque

Renderizado de imágenes y carga del mapa

  • El sistema render-images renderiza las entidades que tienen position e image
    • 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
  • load-bitmap es una función de carga de imágenes que envuelve al_load_bitmap con al:ensure-loaded
  • tile->spec crea 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-prefab verifica mediante el índice map-tile-prefab si el prefab ya fue cargado y, si no, lo crea con make-object
  • load-tile copia componentes del prefab y agrega position al crear la entidad del tile real del mapa
  • load-map recorre tilesets y capas del objeto CLOS leído con tiled: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-entity garantiza 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.lisp y animation.lisp para separar los componentes comunes y los componentes/sistemas relacionados con animación
  • El componente animation-frame representa un frame de una animación
    • sequence es el nombre de la animación y se guarda como tipo keyword
    • Se usan índices sequence-frames para encontrar los frames de una animación específica
    • duration es la duración del frame en segundos
  • animation-state guarda el estado actual de un tile animado real en el mapa
    • sequence actual
    • frame actual
    • duration del frame actual
    • Tiempo elapsed mostrado del frame actual
  • Se agrega la dependencia let-plus para escribir de forma más concisa el código de cambio de frames
  • El sistema update-animations incrementa elapsed en dt y, si supera la duración, cambia al siguiente frame
    • Como el tiempo del frame puede ser menor que un dt grande, se usa floor para calcular cuántos frames hay que saltar
    • Se usa truncate para que, si el número de frame supera la longitud de la lista, vuelva al inicio en ciclo
    • El puntero bitmap de image se cambia al bitmap del prefab del siguiente frame
  • Como la duración de la animación se guarda en milisegundos en Tiled, en animation->spec se convierte a segundos
  • instantiate-animation crea animation-state en la entidad real del tile e inicializa elapsed con un valor aleatorio entre 0 y duration para 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

Personaje del jugador y controles

  • Se agrega character.lisp y se define el componente character para personajes que pueden moverse
    • speed es la velocidad en píxeles por segundo
    • target-x, target-y son las coordenadas objetivo del movimiento
    • Los valores objetivo iniciales se dejan en single-float-nan para evitar que un personaje nuevo se mueva sin motivo hacia la esquina superior izquierda
  • El componente etiqueta player usa un slot bit y :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
  • En la implementación inicial, se recorta una imagen de orco del tileset para crear player.png y el jugador se crea de forma hardcodeada con load-player
    • La posición es (64.0, 64.0)
    • El tamaño es 32×32
    • La velocidad es 100.0
  • El sistema move-characters mueve a los personajes hacia el punto objetivo
    • Si hay coordenadas objetivo NaN, se inicializan con la posición actual
    • Se usa approx-equal en lugar de comparación directa de punto flotante
    • Se calculan nuevas coordenadas con atan, cos, sin, velocidad y dt
  • El sistema control-player lee la entrada de teclado W, A, S, D y actualiza las coordenadas objetivo
    • Usa al:with-current-keyboard-state y al:key-down
    • Usa clamp para no salir de los límites de la pantalla
    • Se ejecuta con :after (move-characters) para evitar el problema de inicialización de NaN ejecutándose después del sistema de movimiento

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-tile como tipo personalizado de Tiled y se agrega un miembro Boolean obstacle
    • Se agrega la propiedad map-tile a los tiles de pared y se marca obstacle
  • La función properties->spec convierte 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-prefab incluye el resultado de properties->spec en la especificación del prefab
    • Si no hay propiedades, con spec-adjoin se agrega el componente map-tile predeterminado y obstacle toma el valor por defecto nil
  • Se agregan el slot tile-hash y el índice tiles al componente position
    • tile-hash convierte x e y a enteros y luego los empaqueta en un único entero de 64 bits
    • Con el índice tiles se pueden encontrar todas las entidades en la coordenada superior izquierda de un tile específico
  • tile-start devuelve la coordenada superior izquierda del tile de la grilla al que pertenece una coordenada arbitraria
  • tile-obstacle-p verifica si, entre las entidades de la misma coordenada, hay algún tile map-tile con obstacle verdadero
  • obstaclep comprueba si el tile correspondiente a una coordenada arbitraria es un obstáculo
  • control-player revisa, 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 character y player en Tiled
    • character solo tiene el miembro float speed
    • target-x y target-y se omiten para usar los valores predeterminados
    • player tiene un miembro int player con 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 character y player
  • load-map se amplía para que también procese tiled:object-layer
    • Convierte las propiedades del objeto en componentes ECS con properties->spec
    • tiled:tile-object copia los datos del tile y la animación con load-tile y 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 y para ajustarlas a la referencia de esquina superior izquierda
  • Se eliminan la llamada hardcodeada a load-player y 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-idle y orc-run del orco definidas en el tileset
  • change-animation-sequence cambia 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-frames y actualiza animation-state e image-bitmap
  • move-characters cambia a :orc-idle cuando el personaje está detenido y a :orc-run cuando se está moviendo

Enemigos, game over y búsqueda de rutas con A*

  • El componente enemy tiene dos slots necesarios para el comportamiento del enemigo
    • vision-range: distancia a la que empieza a ver y reaccionar al jugador
    • attack-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-enemies obtiene 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 nativo You died
  • La animación de los enemigos usa las secuencias demon-idle y demon-run
    • move-characters elige la animación del orco para el jugador y la del demonio para los enemigos según el resultado de has-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-point tiene x, y y traveller, y traveller usa el índice path-points
    • path guarda el destino final en destination-x y destination-y
    • Las coordenadas objetivo de character representan el siguiente punto de la ruta, mientras que path representa el destino final
  • El sistema follow-path toma el primer punto de la ruta y mueve el personaje hacia ese punto
    • Al llegar al punto, elimina la entidad path-point correspondiente
    • Si ya no quedan puntos, elimina el componente path
  • find-path se define con a*: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-point y parent
  • handle-enemies llama a find-path cuando 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
  • Se agrega la dependencia cl-liballegro-nuklear/declarative y se añade src/narrative.lisp como archivo nuevo
  • En el paquete se registra el alias local ui para referirse de forma breve a cl-liballegro-nuklear/declarative
  • Como fuente de la UI se usa Alegreya de Google Fonts, renombrando el archivo a alegreya-sc.ttf
  • ui:defwindow narrative define la función de la ventana narrativa
    • La posición de la ventana se calcula en el área central de la pantalla
    • ui:label-wrap muestra texto con ajuste automático de línea
    • ui: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.lisp carga la fuente de la UI e inicializa el contexto de UI con nk:allegro-init
    • En el bucle de eventos llama a nk:input-begin, nk:allegro-handle-event y nk:input-end
    • Durante el renderizado llama a nk:allegro-render
    • Al salir llama a nk:allegro-shutdown y nk:allegro-font-del

Skin de la UI y objetos narrativos

  • Como la UI predeterminada es monótona, se estiliza usando los assets de imagen fantasy-ui-borders de Kenney
  • Las variables globales *window-background*, *button-normal-background*, *button-hover-background* y *button-active-background* almacenan las imágenes de la UI
  • load-ui carga las imágenes con nk:allegro-create-image, y unload-ui libera los recursos de imagen del lado de C con nk:allegro-del-image
  • init llama a load-ui, y al terminar el bucle principal se llama a unload-ui
  • El argumento :styles de ui:defwindow especifica el fondo, las imágenes para cada estado del botón y el color del texto
  • El componente narrative representa objetos de environmental storytelling
    • text: texto que se mostrará
    • shown: si ya se mostró al menos una vez
    • active: si la ventana está activa en ese momento
    • active tiene el índice active-narratives
  • El sistema show-narrative muestra 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, Space o Enter
  • En Tiled se crea un tipo personalizado narrative y se agrega el miembro string text
  • 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)) a move-characters y control-player
    • Si hay una narrativa activa, los sistemas de movimiento y control no se ejecutan
  • 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

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-astar y cl-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

 
GN⁺ 2024-10-19
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)

    • Sobre todo, pesa mucho que sea texto y no video. Se puede copiar y pegar, leer con claridad, seguir al propio ritmo y consumir en silencio.
      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.sh y, 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.

    • Lleva años montando esa configuración de CI sobre la infraestructura de CL, y dice que se rompe constantemente :D
  • 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

    • Jon Blow también intentó hacer un juego así hace mucho tiempo. Puede valer la pena ver cómo y por qué fracasó.
  • 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.

    • No diría que apunta exactamente al mismo problema, pero https://ciel-lang.org/ al menos intenta resolver parte del problema de que hay demasiados pasos.
      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 loop es un lenguaje específico de dominio para iteración en toda regla. Te guste o no ;)

    • ¿No bastaría con usar https://iterate.common-lisp.dev/ en lugar de loop? No tiene esa sintaxis extraña que no es S-expresión, ni necesitas do para volver a la sintaxis de Lisp.
      Usa if/when normales sin los feos else/end, y en general agrega funciones útiles.
    • Al principio me burlaba, pero después de programar en Common Lisp durante algunos años, loop se 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.