Jogo NES moderno escrito em uma linguagem semelhante a Lisp

What Remains é um jogo narrativo de aventura para o console de videogame NES de 8 bits, lançado em março de 2019 como uma ROM gratuita em execução no emulador. Foi criado por uma pequena equipe da Iodine Dynamics por dois anos intermitentemente. No momento, o jogo está na fase de implementação do hardware: estamos criando um conjunto limitado de cartuchos a partir de peças recicladas.


O jogo possui 6 níveis nos quais o jogador caminha por várias cenas com cartões de rolagem de quatro vias, se comunica com o NPC, coleta pistas, conhece seu mundo, joga mini-jogos e resolve quebra-cabeças simples. Eu era o engenheiro chefe do projeto, por isso encontrei muitas dificuldades em realizar a visão da equipe. Dadas as sérias limitações do equipamento NES, é bastante difícil criar qualquer jogo para ele, sem mencionar um projeto com tanto conteúdo quanto em What Remains. Somente graças aos subsistemas úteis criados que nos permitem ocultar essa complexidade e gerenciá-la, fomos capazes de trabalhar em equipe e concluir o jogo.


Neste artigo, falarei sobre alguns detalhes técnicos de partes individuais do mecanismo de jogo. Espero que outros desenvolvedores os considerem úteis ou pelo menos curiosos.

Equipamento NES


Antes de iniciar o código, vou falar um pouco sobre as especificações do equipamento com o qual trabalhamos. NES é um console de jogos lançado em 1983 (Japão, 1985 - América). No interior, possui uma CPU 6502 de 8 bits [1] com uma frequência de 1,79 MHz. Como o console produz 60 quadros por segundo, aproximadamente 30 mil ciclos de CPU são alocados por quadro, e isso é muito pequeno para calcular tudo o que acontece no ciclo de jogo principal.

Além disso, o console possui um total de 2048 bytes de RAM (que pode ser expandido para 10.240 bytes usando RAM adicional, o que não fizemos). Ele também pode endereçar 32 KB de ROM por vez, que pode ser expandido alternando bancos (What Remains usa 512 KB of ROM). Trocar de banco é um tópico complexo [2] com o qual os programadores modernos não lidam. Em resumo, o espaço de endereço disponível para a CPU é menor que os dados contidos na ROM, ou seja, quando alternados manualmente, blocos de memória inteiros permanecem inacessíveis. Deseja chamar alguma função? Não é até você substituir o banco chamando o comando de troca de banco. Se isso não for feito, quando a função for chamada, o programa falhará.

De fato, a coisa mais difícil ao desenvolver um jogo para o NES é considerar tudo isso ao mesmo tempo. A otimização de um aspecto do código, como o uso de memória, pode afetar outra coisa, como o desempenho da CPU. O código deve ser eficaz e ao mesmo tempo conveniente no suporte. Normalmente, os jogos eram programados em linguagem assembly.

Co2


Mas no nosso caso, não foi assim. Em vez disso, um conjunto com o jogo teria desenvolvido sua própria linguagem. Co2 é uma linguagem semelhante ao Lisp, criada no Racket Scheme e compilada no assembler 6502. Inicialmente, a linguagem foi criada por Dave Griffiths para criar a demo What Remains, e eu decidi usá-la em todo o projeto.

O Co2 permite escrever o código do assembler, se necessário, mas também possui recursos de alto nível que simplificam algumas tarefas. Ele implementa variáveis ​​locais eficazes tanto em termos de consumo de RAM quanto de velocidade de acesso [2]. Possui um sistema macro muito simples que permite escrever código legível e ao mesmo tempo eficiente [3]. Mais importante ainda, devido à homoconicidade do Lisp, simplifica bastante a exibição de dados diretamente na fonte.

Escrever suas próprias ferramentas é bastante difundido no desenvolvimento de jogos, mas criar uma linguagem de programação inteira é muito menos comum. No entanto, nós fizemos isso. Não está muito claro se a complexidade do desenvolvimento e do suporte ao CO2 se provou, mas definitivamente teve vantagens que nos ajudaram. No post, não vou falar detalhadamente sobre o trabalho do Co2 (isso merece um artigo separado), mas vou mencioná-lo constantemente porque seu uso está intimamente entrelaçado com o processo de desenvolvimento.

Aqui está um exemplo de código Co2 que desenha o plano de fundo para uma cena carregada antes de escurecer:

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



Qualquer jogo em tempo real mais complexo que o Tetris é inerentemente um "sistema de entidades". Essa é uma funcionalidade que permite que vários atores independentes ajam simultaneamente e sejam responsáveis ​​por sua própria condição. Embora What Remains não seja de forma alguma um jogo ativo, ele ainda possui muitos atores independentes com comportamento complexo: eles se animam e se reproduzem, verificam colisões e causam diálogos.

A implementação é bastante típica: uma grande matriz contém uma lista de entidades na cena, cada registro contém dados relacionados à entidade junto com uma etiqueta de tipo. A função de atualização no ciclo principal de jogo ignora todas as entidades e implementa o comportamento correspondente, dependendo do 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))))) 

A maneira de armazenar dados da entidade é mais interessante. Em geral, o jogo possui tantas entidades únicas que o uso de um grande número de ROMs pode se tornar um problema. Aqui o CO2 mostra seu poder, permitindo-nos apresentar cada essência da cena de forma concisa, mas legível - como um fluxo de pares de valores-chave. Além de dados como a posição inicial, quase todas as chaves são opcionais, o que permite que elas sejam declaradas às entidades somente quando necessário.

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

Nesse código, prop-palette define a paleta de cores usada para a entidade, prop-anim-limit define o número de quadros de animação e prop-dont-turn-around impede que o NPC gire se o jogador estiver tentando falar com ele do outro lado. Também define algumas bandeiras de condições que alteram o comportamento da entidade no processo de aprovação do jogo pelo jogador.

Esse tipo de apresentação é muito eficaz para armazenamento em ROM, mas é muito lento quando acessado em tempo de execução e será muito ineficiente para o jogo. Portanto, quando um jogador entra em uma nova cena, todas as entidades nessa cena são carregadas na RAM e processam todas as condições que podem afetar seu estado inicial. Mas você não pode baixar nenhum detalhe para cada entidade, pois isso consumiria mais RAM do que o disponível. O mecanismo carrega apenas o mais necessário para cada entidade, além de um ponteiro para sua estrutura completa na ROM, que é desreferenciada em situações como manipulação de diálogos. Esse conjunto específico de compromissos nos permitiu fornecer um nível suficiente de desempenho.

Portais



O jogo What Remains tem muitos locais diferentes, várias cenas na rua com mapas de rolagem e muitas cenas em salas que permanecem estáticas. Para passar de um para outro, você precisa determinar que o jogador chegou à saída, carregar uma nova cena e, em seguida, colocar o jogador no ponto desejado. Nos estágios iniciais do desenvolvimento, essas transições foram descritas de maneira única como duas cenas conectadas, por exemplo, “primeira cidade” e “café” e dados na declaração if sobre a localização das portas em cada cena. Para determinar onde posicionar o jogador após alterar a cena, basta verificar de onde ele estava indo e para onde, e colocá-lo próximo à saída correspondente.

No entanto, quando começamos a preencher a cena da “segunda cidade”, que se conecta à primeira cidade em dois lugares diferentes, esse sistema começou a desmoronar. De repente, o par (_, _) não se encaixa mais. Depois de pensar sobre isso, percebemos que a conexão em si é realmente importante, que dentro do código do jogo chama de “portal”. Para explicar essas alterações, o mecanismo foi reescrito. o que nos levou a uma situação semelhante à entidade. Os portais podem armazenar listas de pares de valores-chave e carregar no início da cena. Ao entrar no portal, você pode usar as mesmas informações de posição de quando sair. Além disso, a adição de condições foi simplificada, semelhante ao que as entidades tinham: em determinados pontos do jogo, podíamos modificar portais, por exemplo, abrir ou fechar portas.

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

Também simplificou o processo de adição de "pontos de teletransporte", que costumavam ser usados ​​em inserções cinematográficas, onde o jogador tinha que se mudar para outro na cena, dependendo do que estava acontecendo na trama.

Aqui está a aparência do teletransporte no início do nível 3:

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

Preste atenção ao valor de pesquisa, que indica a direção da "entrada" para este portal. Ao sair do portal, o jogador olha na outra direção; neste caso, Jenny (a personagem principal do jogo) está em casa, enquanto olha para baixo.

Bloco de texto


A renderização de um bloco de texto acabou sendo uma das partes mais complexas do código em todo o projeto. As limitações gráficas do NES forçadas a enganar. Para começar, o NES possui apenas uma camada para dados gráficos, ou seja, para liberar espaço para um bloco de texto, você precisa apagar parte do mapa no fundo e restaurá-lo depois de fechar o bloco de texto.


Além disso, a paleta de cada cena individual deve conter cores preto e branco para renderizar o texto, o que impõe restrições adicionais ao artista. Para evitar conflitos de cores com o restante do plano de fundo, o bloco de texto deve estar alinhado com a grade 16 × 16 [5]. Desenhar um bloco de texto em uma cena com uma sala é muito mais simples do que em uma rua, onde a câmera pode se mover, pois nesse caso é necessário considerar buffers gráficos rolando na vertical e na horizontal. Finalmente, a mensagem da tela de pausa é uma caixa de diálogo padrão levemente modificada, porque exibe informações diferentes, mas usa quase o mesmo código.

Depois de um número infinito de versões com erros do código, finalmente consegui encontrar uma solução na qual o trabalho é dividido em dois estágios. Primeiro, são realizados todos os cálculos que determinam onde e como desenhar o bloco de texto, incluindo o código de processamento para todos os casos de borda. Assim, todas essas dificuldades são trazidas para um só lugar.

Em seguida, um bloco de texto com preservação de estado é desenhado linha por linha e os cálculos do primeiro estágio são usados ​​para não complicar o 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)) 

Se você se acostumar com o estilo Lisp, o código será lido de maneira conveniente.

Camadas z de Sprite


No final, falarei sobre um pequeno detalhe que não afeta particularmente a jogabilidade, mas acrescenta um toque agradável do qual me orgulho. O NES possui apenas dois componentes gráficos: uma tabela de nomes (nametable), usada para planos de fundo estáticos e alinhados à grade, e sprites - objetos de tamanho 8x8 pixels, que podem ser colocados em locais arbitrários. Elementos como o personagem do jogador e os NPCs geralmente são criados como sprites se eles estiverem no topo dos gráficos da tabela de nomes.

No entanto, o equipamento NES também fornece a capacidade de especificar uma parte dos sprites que podem ser completamente colocados sob a tabela de nomes. Isso sem esforço permite que você realize um efeito 3D legal.


Funciona da seguinte maneira: a paleta usada para a cena atual lida com a cor na posição 0 de uma maneira especial: é a cor de fundo global. Uma tabela de nomes é desenhada em cima dela, e sprites com uma camada z são desenhados entre duas outras camadas.

Aqui está a paleta desta cena:


Portanto, a cor cinza escuro no canto esquerdo é usada como cor de fundo global.

O efeito das camadas funciona da seguinte maneira:


Na maioria dos outros jogos, tudo isso termina, no entanto, o que resta permanece mais um passo adiante. O jogo não coloca Jenny completamente na frente ou embaixo dos gráficos da tabela de nomes - seu personagem é dividido entre eles da maneira certa. Como você pode ver, os sprites têm tamanho 8x8 e os gráficos do personagem inteiro consistem em vários sprites (de 3 a 6, dependendo do quadro da animação). Cada sprite pode definir sua própria camada z, ou seja, alguns sprites estarão na frente da tabela de nomes e outros atrás dela.

Aqui está um exemplo desse efeito em ação:


O algoritmo para implementar esse efeito é bastante complicado. Primeiro, os dados de colisão em torno do jogador são examinados, em especial os ladrilhos, o que pode levar um personagem inteiro para desenhar. Neste diagrama, os ladrilhos sólidos são mostrados em quadrados vermelhos e os ladrilhos amarelos indicam a peça com a camada z.


Usando várias heurísticas, eles são combinados para criar um "ponto de referência" e uma máscara de bits de quatro bits. Quatro quadrantes em relação ao ponto de referência correspondem a quatro bits: 0 significa que o jogador deve estar na frente da tabela de nomes, 1 - que está atrás dela.


Ao colocar sprites individuais para renderizar o jogador, sua posição é comparada com o ponto de referência para determinar a camada z desse sprite em particular. Alguns deles estão na camada frontal, outros na parte de trás.


Conclusão


Eu falei brevemente sobre os diferentes aspectos do funcionamento interno do nosso novo jogo retrô moderno. Há muito mais interessante na base de código, mas descrevi uma parte significativa do que faz o jogo funcionar.

A lição mais importante que aprendi deste projeto são os benefícios que podem ser obtidos com os mecanismos controlados por dados. Várias vezes consegui substituir uma lógica única por uma tabela e um mini-intérprete e, graças a isso, o código se tornou mais simples e legível.

Espero que tenham gostado do artigo!



Anotações


[1] A rigor, um tipo de CPU 6502 chamado Ricoh 2A03 foi instalado no NES.

[2] De fato, este projeto me convenceu de que trocar bancos / gerenciar ROMs é a principal limitação para qualquer projeto NES que exceda um determinado tamanho.

[3] Por isso, vale a pena agradecer à “pilha compilada” - um conceito usado na programação de sistemas embarcados, embora eu mal tenha conseguido encontrar literatura sobre isso. Em resumo, você precisa criar um gráfico de chamada do projeto completo, classificá-lo dos nós folhas para a raiz e atribuir a cada nó uma memória igual às suas necessidades + número máximo de nós filhos.

[4] As macros foram adicionadas em estágios bastante avançados de desenvolvimento e, francamente, não fomos capazes de tirar vantagem especial delas.

[5] Você pode ler mais sobre gráficos do NES na minha série de artigos . Conflitos de cores são causados ​​pelos atributos descritos na primeira parte.

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


All Articles