Juego moderno de NES escrito en un lenguaje similar a Lisp

What Remains es un juego narrativo de aventuras para la consola de videojuegos NES de 8 bits, lanzado en marzo de 2019 como una ROM gratuita que se ejecuta en el emulador. Fue creado por un pequeño equipo de Iodine Dynamics durante dos años de forma intermitente. En este momento, el juego se encuentra en la etapa de implementación en el hardware: estamos creando un conjunto limitado de cartuchos a partir de piezas recicladas.


El juego tiene 6 niveles en los que el jugador camina a lo largo de varias escenas con cartas de desplazamiento en cuatro direcciones, se comunica con el NPC, recoge pistas, conoce su mundo, juega minijuegos y resuelve rompecabezas simples. Yo era el ingeniero jefe del proyecto, así que encontré muchas dificultades para hacer realidad la visión del equipo. Dadas las serias limitaciones del equipo NES, es bastante difícil crear un juego para él, sin mencionar un proyecto con tanto contenido como en What Remains. Solo gracias a los subsistemas útiles creados que nos permiten ocultar esta complejidad y administrarla, pudimos trabajar en equipo y completar el juego.


En este artículo hablaré sobre algunos detalles técnicos de partes individuales del motor del juego. Espero que otros desarrolladores los encuentren útiles o al menos curiosos.

Equipo NES


Antes de comenzar el código, le contaré un poco sobre las especificaciones del equipo con el que trabajamos. NES es una consola de juegos lanzada en 1983 (Japón, 1985 - América). En su interior tiene una CPU 6502 de 8 bits [1] con una frecuencia de 1.79 MHz. Dado que la consola produce 60 cuadros por segundo, hay aproximadamente 30 mil ciclos de CPU por cuadro, y esto es bastante pequeño para calcular todo lo que sucede en el ciclo de juego principal.

Además, la consola tiene un total de 2048 bytes de RAM (que se puede ampliar a 10,240 bytes usando RAM adicional, lo cual no hicimos). También puede abordar 32 KB de ROM a la vez, lo que se puede ampliar cambiando de banco (What Remains usa 512 KB de ROM). Cambiar de banco es un tema complejo [2] que los programadores modernos no abordan. En resumen, el espacio de direcciones disponible para la CPU es menor que los datos contenidos en la ROM, es decir, cuando se cambia manualmente, los bloques de memoria completos permanecen inaccesibles. ¿Querías llamar a alguna función? No es hasta que reemplace el banco llamando al comando de cambio de banco. Si esto no se hace, cuando se llama a la función, el programa se bloqueará.

De hecho, lo más difícil al desarrollar un juego para NES es considerar todo esto al mismo tiempo. La optimización de un aspecto del código, como el uso de memoria, a menudo puede afectar a otra cosa, como el rendimiento de la CPU. El código debe ser efectivo y al mismo tiempo conveniente en soporte. Por lo general, los juegos se programaban en lenguaje ensamblador.

Co2


Pero en nuestro caso, no fue así. En cambio, un tándem con el juego habría desarrollado su propio lenguaje. Co2 es un lenguaje similar a Lisp construido en Racket Scheme y compilado en el ensamblador 6502. Inicialmente, el lenguaje fue creado por Dave Griffiths para construir la demostración What Remains, y decidí usarlo para todo el proyecto.

Co2 le permite escribir código de ensamblador incorporado si es necesario, pero también tiene capacidades de alto nivel que simplifican algunas tareas. Implementa variables locales que son efectivas tanto en términos de consumo de RAM como de velocidad de acceso [2]. Tiene un sistema macro muy simple que le permite escribir código legible y al mismo tiempo eficiente [3]. Lo más importante, debido a la homo-conicidad de Lisp, simplifica enormemente la visualización de datos directamente en la fuente.

Escribir sus propias herramientas está bastante extendido en el desarrollo de juegos, pero crear un lenguaje de programación completo es mucho menos común. Sin embargo, lo hicimos. No está muy claro si la complejidad del desarrollo y el soporte de Co2 se demostró, pero definitivamente tenía ventajas que nos ayudaron. En la publicación no hablaré en detalle sobre el trabajo de Co2 (esto merece un artículo separado), pero lo mencionaré constantemente porque su uso está bastante estrechamente entrelazado con el proceso de desarrollo.

Aquí hay un código de Co2 de muestra que dibuja el fondo de una escena cargada antes de atenuarla:

; Render the nametable for the scene at the camera position (defsub (create-initial-world) (camera-assign-cursor) (set! camera-cursor (+ camera-cursor 60)) (let ((preserve-camera-v)) (set! preserve-camera-v camera-v) (set! camera-v 0) (loop i 0 60 (set! delta-v #xff) (update-world-graphics) (when render-nt-span-has (set! render-nt-span-has #f) (apply-render-nt-span-buffer)) (when render-attr-span-has (set! render-attr-span-has #f) (apply-render-attr-span-buffer))) (set! camera-v preserve-camera-v)) (camera-assign-cursor)) 

Sistema de la entidad



Cualquier juego en tiempo real más complejo que Tetris es inherentemente un "sistema de entidades". Esta es una funcionalidad que permite que varios actores independientes actúen simultáneamente y sean responsables de su propia condición. Aunque What Remains no es en absoluto un juego activo, todavía tiene muchos actores independientes con un comportamiento complejo: se animan y se representan a sí mismos, verifican colisiones y provocan diálogos.

La implementación es bastante típica: una gran matriz contiene una lista de entidades en la escena, cada registro contiene datos relacionados con la entidad junto con una etiqueta de tipo. La función de actualización en el ciclo de juego principal evita todas las entidades e implementa el comportamiento correspondiente según su tipo.

 ; Called once per frame, to update each entity (defsub (update-entities) (when (not entity-npc-num) (return)) (loop k 0 entity-npc-num (let ((type)) (set! type (peek entity-npc-data (+ k entity-field-type))) (when (not (eq? type #xff)) (update-single-entity k type))))) 

La forma de almacenar datos de entidad es más interesante. En general, el juego tiene tantas entidades únicas que el uso de una gran cantidad de ROM puede convertirse en un problema. Aquí Co2 muestra su poder, permitiéndonos presentar cada esencia de la escena en una forma concisa pero legible, como una secuencia de pares clave-valor. Además de datos como la posición inicial, casi todas las claves son opcionales, lo que permite declararlas a las entidades solo cuando es necesario.

 (bytes npc-diner-a 172 108 prop-palette 1 prop-hflip prop-picture picture-smoker-c prop-animation simple-cycle-animation prop-anim-limit 6 prop-head hair-flip-head-tile 2 prop-dont-turn-around prop-dialog-a (2 progress-stage-4 on-my-third my-dietician) prop-dialog-a (2 progress-stage-3 have-you-tried-the-pasta the-real-deal) prop-dialog-a (2 progress-diner-is-clean omg-this-cherry-pie its-like-a-party) prop-dialog-a (2 progress-stage-1 cant-taste-food puff-poof) prop-dialog-b (1 progress-stage-4 tea-party-is-not) prop-dialog-b (1 progress-stage-3 newspaper-owned-by-dnycorp) prop-dialog-b (1 progress-stage-2 they-paid-a-pr-guy) prop-dialog-b (1 progress-stage-1 it-seems-difficult) prop-customize (progress-stage-2 stop-smoking) 0) 

En este código, prop-palette establece la paleta de colores utilizada para la entidad, prop-anim-limit establece el número de cuadros de animación y prop-dont-turn-around evita que el NPC gire si el jugador intenta hablar con él desde el otro lado. También establece un par de banderas de condiciones que cambian el comportamiento de la entidad en el proceso de pasar el juego por el jugador.

Este tipo de presentación es muy efectiva para el almacenamiento en ROM, pero es muy lenta cuando se accede en tiempo de ejecución y será demasiado ineficiente para el juego. Por lo tanto, cuando un jugador ingresa a una nueva escena, todas las entidades en esta escena se cargan en la RAM y procesan todas las condiciones que pueden afectar su estado inicial. Pero no puede descargar ningún detalle para cada entidad, ya que ocuparía más RAM de la que está disponible. El motor carga solo lo más necesario para cada entidad, además de un puntero a su estructura completa en ROM, que se desreferencia en situaciones como el manejo de diálogos. Este conjunto específico de compromisos nos permitió proporcionar un nivel suficiente de rendimiento.

Portales



El juego What Remains tiene muchas ubicaciones diferentes, varias escenas en la calle con mapas de desplazamiento y muchas escenas en habitaciones que permanecen estáticas. Para pasar de uno a otro, debe determinar que el jugador ha llegado a la salida, cargar una nueva escena y luego colocar al jugador en el punto deseado. En las primeras etapas de desarrollo, tales transiciones se describieron de una manera única como dos escenas conectadas, por ejemplo, "primera ciudad" y "café" y datos en la declaración if sobre la ubicación de las puertas en cada escena. Para determinar dónde colocar al jugador después de cambiar la escena, solo tenía que verificar de dónde iba y dónde, y colocarlo al lado de la salida correspondiente.

Sin embargo, cuando comenzamos a llenar la escena de la "segunda ciudad", que se conecta a la primera ciudad en dos lugares diferentes, dicho sistema comenzó a desmoronarse. De repente, el par (_, _) ya no se ajusta. Después de pensar en esto, nos dimos cuenta de que la conexión en sí es realmente importante, lo que dentro del código del juego llama el "portal". Para tener en cuenta estos cambios, el motor ha sido reescrito. lo que nos llevó a una situación similar a la entidad. Los portales podrían almacenar listas de pares clave-valor y cargarlos al comienzo de la escena. Al ingresar al portal, puede usar la misma información de posición que al salir. Además, se simplificó la adición de condiciones, similar a lo que tenían las entidades: en ciertos puntos del juego, podíamos modificar portales, por ejemplo, abrir o cerrar puertas.

 ; City A (bytes city-a-scene #x50 #x68 look-up portal-customize (progress-stage-5 remove-self) ; to Diner diner-scene #xc0 #xa0 look-down portal-width #x20 0) 

También simplificó el proceso de agregar "puntos de teletransportación", que a menudo se usaban en inserciones cinematográficas, donde el jugador tenía que moverse a otro en la escena, dependiendo de lo que sucedía en la trama.

Así es como se ve la teletransportación al comienzo del nivel 3:

 ; Jenny's home (bytes jenny-home-scene #x60 #xc0 look-up portal-teleport-only jenny-back-at-home-teleport 0) 

Preste atención al valor de look-up , que indica la dirección para la "entrada" a este portal. Al salir del portal, el jugador mirará en la otra dirección; en este caso, Jenny (el personaje principal del juego) está en casa, mientras mira hacia abajo.

Bloque de texto


La representación de un bloque de texto resultó ser una de las piezas de código más complejas de todo el proyecto. Las limitaciones gráficas de NES obligaron a engañar. Para empezar, NES solo tiene una capa para datos gráficos, es decir, para liberar espacio para un bloque de texto, debe borrar parte del mapa en el fondo y luego restaurarlo después de cerrar el bloque de texto.


Además, la paleta para cada escena individual debe contener colores blanco y negro para representar el texto, lo que impone restricciones adicionales al artista. Para evitar conflictos de color con el resto del fondo, el bloque de texto debe alinearse con la cuadrícula 16 × 16 [5]. Dibujar un bloque de texto en una escena con una habitación es mucho más simple que en una calle, donde la cámara se puede mover, porque en este caso es necesario considerar los buffers gráficos desplazándose vertical y horizontalmente. Finalmente, el mensaje de la pantalla de pausa es un cuadro de diálogo estándar ligeramente modificado, porque muestra información diferente, pero usa casi el mismo código.

Después de un número infinito de versiones defectuosas del código, finalmente logré encontrar una solución en la que el trabajo se divide en dos etapas. Primero, se realizan todos los cálculos que determinan dónde y cómo dibujar el bloque de texto, incluido el código de procesamiento para todos los casos de borde. Por lo tanto, todas estas dificultades se llevan a un solo lugar.

Luego, un bloque de texto con preservación de estado se dibuja línea por línea y los cálculos de la primera etapa se usan para no complicar el código.

 ; Called once per frame as the text box is being rendered (defsub (text-box-update) (when (or (eq? tb-text-mode 0) (eq? tb-text-mode #xff)) (return #f)) (cond [(in-range tb-text-mode 1 4) (if (not is-paused) ; Draw text box for dialog. (text-box-draw-opening (- tb-text-mode 1)) ; Draw text box for pause. (text-box-draw-pausing (- tb-text-mode 1))) (inc tb-text-mode)] [(eq? tb-text-mode 4) ; Remove sprites in the way. (remove-sprites-in-the-way) (inc tb-text-mode)] [(eq? tb-text-mode 5) (if (not is-paused) ; Display dialog text. (when (not (crawl-text-update)) (inc tb-text-mode) (inc tb-text-mode)) ; Display paused text. (do (create-pause-message) (inc tb-text-mode)))] [(eq? tb-text-mode 6) ; This state is only used when paused. Nothing happens, and the caller ; has to invoke `text-box-try-exiting-pause` to continue. #t] [(and (>= tb-text-mode 7) (< tb-text-mode 10)) ; Erase text box. (if (is-scene-outside scene-id) (text-box-draw-closing (- tb-text-mode 7)) (text-box-draw-restoring (- tb-text-mode 7))) (inc tb-text-mode)] [(eq? tb-text-mode 10) ; Reset state to return to game. (set! text-displaying #f) (set! tb-text-mode 0)]) (return #t)) 

Si te acostumbras al estilo Lisp, entonces el código se lee con bastante comodidad.

Sprite Z-capas


Al final, hablaré sobre un pequeño detalle que no afecta particularmente el juego, pero agrega un toque agradable del que estoy orgulloso. NES tiene solo dos componentes gráficos: una tabla de nombres, que se utiliza para fondos estáticos y alineados a la cuadrícula, y los sprites son objetos de 8x8 píxeles que se pueden colocar en lugares arbitrarios. Los elementos como el personaje del jugador y los NPC generalmente se crean como sprites si deberían estar encima de los gráficos de la tabla de nombres.

Sin embargo, el equipo NES también proporciona la capacidad de especificar una porción de sprites que se pueden colocar completamente debajo de la tabla de nombres. Esto sin esfuerzo le permite realizar un efecto 3D genial.


Funciona de la siguiente manera: la paleta utilizada para la escena actual maneja el color en la posición 0 de una manera especial: es el color de fondo global. Se dibuja una tabla de nombres encima, y ​​los sprites con una capa z se dibujan entre otras dos capas.

Aquí está la paleta de esta escena:


Entonces, el color gris oscuro en la esquina izquierda se usa como color de fondo global.

El efecto de las capas funciona de la siguiente manera:


En la mayoría de los otros juegos, todo esto termina, sin embargo, What Remains ha dado un paso más. El juego no coloca a Jenny completamente delante o debajo de los gráficos de la tabla de nombres: su personaje se divide entre ellos según sea necesario. Como puede ver, los sprites tienen un tamaño de 8x8 unidades, y los gráficos de todo el personaje consisten en varios sprites (de 3 a 6, dependiendo del cuadro de animación). Cada sprite puede establecer su propia capa z, es decir, algunos sprites estarán delante de la tabla de nombres y otros detrás de ella.

Aquí hay un ejemplo de este efecto en acción:


El algoritmo para implementar este efecto es bastante complicado. Primero, se examinan los datos de colisión que rodean al jugador, en particular fichas, que pueden tomar un personaje completo para dibujar. En este diagrama, los mosaicos sólidos se muestran en cuadrados rojos, y los mosaicos amarillos indican la parte con la capa z.


Utilizando varias heurísticas, se combinan para crear un "punto de referencia" y una máscara de bits de cuatro bits. Cuatro cuadrantes relativos al punto de referencia corresponden a cuatro bits: 0 significa que el jugador debe estar delante de la tabla de nombres, 1, que está detrás de él.


Al colocar sprites individuales para renderizar al jugador, su posición se compara con el punto de referencia para determinar la capa z de este sprite en particular. Algunos de ellos están en la capa frontal, otros en la parte posterior.


Conclusión


Brevemente hablé sobre los diferentes aspectos del funcionamiento interno de nuestro nuevo juego retro moderno. Hay mucho más interesante en la base del código, pero he esbozado una parte importante de lo que hace que el juego funcione.

La lección más importante que aprendí de este proyecto son los beneficios que se pueden obtener de los motores basados ​​en datos. Varias veces logré reemplazar una lógica única con una tabla y un mini-intérprete, y gracias a esto, el código se volvió más simple y más legible.

¡Espero que hayas disfrutado el artículo!



Notas


[1] Estrictamente hablando, se instaló una especie de CPU 6502 llamada Ricoh 2A03 en NES.

[2] De hecho, este proyecto me convenció de que cambiar de banco / administrar ROM es la principal limitación para cualquier proyecto de NES que exceda cierto tamaño.

[3] Por esto, uno debería agradecer a la "pila compilada", un concepto utilizado en la programación de sistemas embebidos, aunque apenas logré encontrar literatura al respecto. En resumen, debe crear un gráfico completo de las llamadas al proyecto, clasificarlo desde los nodos hoja hasta la raíz y luego asignar a cada nodo una memoria igual a sus necesidades + número máximo de nodos secundarios.

[4] Las macros se agregaron en etapas de desarrollo bastante tardías y, francamente, no pudimos aprovecharlas especialmente.

[5] Puedes leer más sobre gráficos NES en mi serie de artículos . Los conflictos de color son causados ​​por los atributos descritos en la primera parte.

Source: https://habr.com/ru/post/467125/


All Articles