- Um tutorial que expande a arquitetura ECS e a programação metalinguística sobre um ambiente de desenvolvimento de jogos em Common Lisp para um exemplo prático de dungeon crawler
- Depois de ler um mapa XML do Tiled com cl-tiled, os dados são movidos para componentes ECS em vez de usar diretamente objetos CLOS, separando renderização, colisão e gerenciamento de memória
- Combinando prefabs de tiles, ponteiros de imagem, índices de pai-filho e finalizers, evita carregamento duplicado e double free, além de usar propriedades personalizadas do Tiled como dados
- Jogador e inimigos realizam movimentação, troca de animação e tratamento de colisão por meio de sistemas ECS, enquanto os inimigos perseguem desviando de paredes com busca de caminho A* baseada em cl-astar
- Com UI baseada em Nuklear, objetos narrativos, pausa e condição de vitória, o tutorial conclui um pequeno exemplo de dungeon crawler com cerca de 500 linhas
Início do projeto e execução básica
- Usando a arquitetura Entity-Component-System e técnicas de programação metalinguística abordadas na Parte 1, o tutorial cria um pequeno dungeon crawler com interface
- O binário de demonstração executável e o código-fonte estão no repositório GitHub ecs-tutorial-2
- O ambiente de desenvolvimento parte do ambiente de desenvolvimento de jogos em Common Lisp da Parte 1, e a distribuição do Quicklisp é atualizada no REPL do SBCL
(ql-util:without-prompting (ql:update-all-dists))
- Um novo projeto
ecs-tutorial-2é criado com o template cookiecutter-lisp-game, e no exemplo o backend escolhido éliballegro - Depois de vincular o diretório do projeto a
local-projectsdo Quicklisp, o tamanho da janela emsrc/main.lispé alterado para 1280×800 - Ao executar
(ql:quickload :ecs-tutorial-2)e(ecs-tutorial-2:main), aparece uma janela preta na resolução especificada com um contador de FPS
Mapa do Tiled e armazenamento ECS
- Para criar o mapa da masmorra, o tutorial usa o editor de mapas open source Tiled
- O Tiled é uma ferramenta multiplataforma e multi-engine, e salva os dados do mapa em XML
- Em Common Lisp, o cl-tiled carrega arquivos do Tiled como objetos Lisp
- O tileset de exemplo usado é Dungeon Tileset II - Extended
- Como os tiles originais de 16×16 são pequenos, eles são ampliados em 200% com ImageMagick e usados como tiles de 32×32
- O arquivo
level1.tmxe o arquivo do tileset podem ser baixados noResources.zipfornecido pelo tutorial
- A dependência de
cl-tiledé adicionada emecs-tutorial-2.asd, e um novosrc/map.lispé criado para separar o código de carregamento e exibição do mapa - Em
src/package.lisp,cl-tiledé registrado com o apelido localtiled
Por que mover objetos CLOS para componentes ECS
- O
cl-tiledretorna os dados do mapa como objetos CLOS, o que facilita explorá-los no REPL - Usar esses objetos diretamente no loop do jogo pode aumentar o custo de dispatch em tempo de execução
- Para preencher uma janela de 1280×800 com tiles de 32×32, são necessários no mínimo 40×25 = 1000 tiles
- Em uma demo separada, ao ativar a renderização do mapa em um Ryzen 5 3600 de 12 núcleos, o FPS cai de 20.000 para 600
- Isso adiciona cerca de
1/600 - 1/20000 = 0.0016segundo por frame, ou seja, mais de 1,5 ms
- Ao mover os dados lidos por
cl-tiledpara o armazenamentocl-fast-ecs, é possível reduzir o dispatch e melhorar o uso do cache da CPU - A dependência de
cl-fast-ecsé adicionada, eecs:make-storageé chamado eminit, enquantoecs:run-systemsé chamado emupdate
Componentes de mapa, tile e prefab
mapé um componente de tag que indica a entidade do mapa carregadomap-tilerepresenta um tile individual e tem um slot Booleanobstaclepara indicar se é um obstáculo, como uma parede ou uma porta fechada- O componente
parentindica de qual entidade de mapa são filhos os tiles e outros objetos relacionados ao mapa- O slot
entityrecebe:index children, permitindo localizar rapidamente as entidades-filhas de um pai específico - O índice é baseado em tabela hash com open addressing, oferecendo busca média em O(1), mas com custo de atualização em criações e remoções
- O slot
- Um hook é adicionado a
ecs:*entity-deleting-hook*para que, quando a entidade-pai for removida, as entidades-filhas encontradas pelo índicechildrentambém sejam removidas - O componente
imagearmazena apenas um ponteiro C paraALLEGRO_BITMAP- A imagem do tileset é dividida em pedaços 32×32 com
al_create_sub_bitmap, e os ponteiros são armazenados
- A imagem do tileset é dividida em pedaços 32×32 com
map-tile-prefabé um prefab de tile que contém o ID globalgiddo tile no Tiled- Em
gid, é definido:index map-tile-prefab :unique t, permitindo localizar uma única entidade de prefab pelo ID - Os tiles reais no mapa copiam
imagee outros componentes do prefab, mas mantêm a posição em um componentepositionseparado
- Em
- O finalizer de
imagechamaal_destroy_bitmapapenas quando a entidade émap-tile-prefab- Isso é para evitar double free, já que vários tiles do mapa compartilham o mesmo ponteiro
ALLEGRO_BITMAP
- Isso é para evitar double free, já que vários tiles do mapa compartilham o mesmo ponteiro
positionesizearmazenam coordenadas e dimensões da tela comosingle-float- Como
liballegrotrata coordenadas de tela como ponto flutuante de precisão simples por compatibilidade com OpenGL, o tutorial segue a mesma abordagem
- Como
Renderização de imagens e carregamento do mapa
- O sistema
render-imagesrenderiza entidades que têmpositioneimageal_hold_bitmap_drawingativa e desativa sprite batchingal_draw_bitmapdesenha a imagem na coordenada especificada- Como prefabs não têm
position, eles não são processados por esse sistema
load-bitmapé uma função de carregamento de imagem que encapsulaal_load_bitmapcomal:ensure-loadedtile->speccria a especificação de objeto ECS para gerar um prefab de tile- Entidade-pai do mapa
- Fragmento de imagem do tile
- ID global do tile no Tiled
- Tamanho do tile
load-tile-prefabverifica pelo índicemap-tile-prefabse o prefab já foi carregado; se não, ele é criado commake-objectload-tilecria a entidade do tile real do mapa copiando componentes do prefab e adicionandopositionload-mappercorre tilesets e layers no objeto CLOS lido portiled:load-map- Carrega a imagem do tileset e cria um prefab para cada tile
- Cria uma entidade para cada célula nas camadas de tile e copia os dados do prefab
- A ordem das layers no Tiled é preservada conforme a ordem no editor, e
make-entitygarante números de entidade crescentes- Como os sistemas processam primeiro as entidades mais antigas, os tiles das layers superiores são desenhados depois, cobrindo as layers inferiores
- Armazenar todos os tiles como entidades separadas não é a única solução; também é possível pré-renderizar um mapa estático em um buffer
Animação de tiles
- O Tiled suporta tiles animados, o que permite representar elementos como tochas ou fontes mágicas
common.lispeanimation.lispsão adicionados para separar componentes comuns e componentes/sistemas relacionados à animação- O componente
animation-framerepresenta um frame da animaçãosequenceé o nome da animação e é armazenado como tipokeyword- O índice
sequence-framesé usado para encontrar os frames de uma animação específica durationé a duração do frame em segundos
animation-statearmazena o estado atual do tile animado no mapasequenceatualframeatualdurationdo frame atual- tempo
elapsedexibido no frame atual
- A dependência
let-plusé adicionada para escrever o código de troca de frames de forma mais concisa - O sistema
update-animationsaumentaelapsedemdte, ao ultrapassar a duração, troca para o próximo frame- Como o tempo do frame pode ser menor que um
dtgrande,flooré usado para calcular quantos frames devem ser pulados truncatefaz o número do frame voltar ao início quando ultrapassa o tamanho da lista- O ponteiro de bitmap de
imageé trocado pelo bitmap do prefab do próximo frame
- Como o tempo do frame pode ser menor que um
- Como a duração da animação é armazenada em milissegundos no Tiled,
animation->speca converte para segundos instantiate-animationcriaanimation-statena entidade real do tile e inicializaelapsedcom um valor aleatório entre 0 eduration, para que a mesma animação não fique totalmente sincronizada- Tiles animados devem ter a propriedade do Tiled
"sequence"- Se essa propriedade não existir, eles serão carregados com o nome
NIL, não serão encontrados pelo nome de animação esperado e podem causar um erro de tipo
- Se essa propriedade não existir, eles serão carregados com o nome
Personagem do jogador e controles
character.lispé adicionado, e define o componentecharacterpara personagens que podem se moverspeedé a velocidade em pixels por segundotarget-xetarget-ysão as coordenadas de destino do movimento- Os valores iniciais de destino são definidos como
single-float-nanpara evitar que um novo personagem se mova para o canto superior esquerdo sem motivo
- O componente de tag
playerusa um slotbite:index player-entity :unique t- Essa é uma estrutura para encontrar a entidade do jogador em O(1) com
(player-entity 1) - A entidade do jogador não é armazenada em uma variável global
- Essa é uma estrutura para encontrar a entidade do jogador em O(1) com
- Na implementação inicial, a imagem do orc é recortada do tileset, salva como
player.pnge o jogador é criado por hardcode comload-player- Posição em
(64.0, 64.0) - Tamanho de 32×32
- Velocidade de 100.0
- Posição em
- O sistema
move-charactersmove os personagens até o ponto de destino- Se houver coordenadas de destino
NaN, elas são inicializadas com a posição atual approx-equalé usado em vez de comparação direta de ponto flutuante- As novas coordenadas são calculadas com
atan,cos,sin, velocidade edt
- Se houver coordenadas de destino
- O sistema
control-playerlê as teclasW,A,S,De atualiza as coordenadas de destino- Usa
al:with-current-keyboard-stateeal:key-down clampevita sair dos limites da tela:after (move-characters)faz com que ele execute depois do sistema de movimento, evitando o problema de inicialização comNaN
- Usa
Colisão e carregamento de objetos com propriedades do Tiled
- No início, as paredes eram imagens comuns, como os tiles de chão, então o jogador atravessava as paredes
- Uma classe
map-tileé criada como tipo customizado do Tiled, e um membro Booleanobstacleé adicionado- A propriedade
map-tileé adicionada aos tiles de parede, comobstaclemarcado
- A propriedade
- A função
properties->specconverte a tabela hash de propriedades do Tiled em uma especificação de objeto ECS- Classes customizadas do Tiled são tratadas como componentes
- Membros da classe são tratados como slots do componente
- O exemplo tem a forma
((:map-tile :obstacle t))
load-tile-prefabinclui o resultado deproperties->specna especificação do prefab- Se não houver propriedades, um componente
map-tilepadrão é adicionado comspec-adjoin, eobstaclefica com o valor padrãonil
- Se não houver propriedades, um componente
- O componente
positionrecebe um slottile-hashe o índicetilestile-hashtransformaxeyem inteiros e depois os empacota em um único inteiro de 64 bits- O índice
tilesencontra todas as entidades na coordenada do canto superior esquerdo de um tile específico
tile-startretorna a coordenada do canto superior esquerdo do tile da grade ao qual uma coordenada arbitrária pertencetile-obstacle-pverifica se, entre as entidades da mesma coordenada, existe algum tilemap-tilecomobstacleverdadeiroobstaclepverifica se o tile correspondente a uma coordenada arbitrária é um obstáculocontrol-playerverifica os tiles dos cantos relevantes do retângulo do personagem conforme a direção do movimento e, se houver obstáculo, retorna a coordenada de destino para a posição atual- Esse método de colisão não é perfeito
- Projetar com base na coordenada central do personagem poderia simplificar a matemática e o código, mas o exemplo mantém a abordagem atual para evitar complexidade
Carregando o jogador e personagens animados a partir do mapa
- As classes customizadas
charactereplayersão adicionadas ao Tiledcharactertem apenas o membrospeed, do tipo floattarget-xetarget-ysão omitidos para usar os valores padrãoplayertem um membro intplayer, com valor padrão 1
- O personagem do jogador é colocado como objeto de tile na camada de objetos do Tiled, com as propriedades
charactereplayer load-mapé expandido para também processartiled:object-layer- As propriedades do objeto são convertidas em componentes ECS com
properties->spec tiled:tile-objectcopia dados do tile e animações comload-tilee define a posição- Como as coordenadas de objeto do Tiled usam a base inferior esquerda, a altura do objeto é subtraída de
ypara ajustar para a base no canto superior esquerdo
- As propriedades do objeto são convertidas em componentes ECS com
- A chamada hardcoded de
load-playere a própria função são removidas - Essa estrutura lê diretamente os dados do mapa do Tiled como objetos ECS, aproximando-se de programação orientada a dados
- A animação dos personagens usa as sequências
orc-idleeorc-rundo orc, definidas no tileset change-animation-sequencemuda a animação atual da entidade- Se já for a mesma sequência, não faz nada
- O primeiro frame da nova sequência é encontrado pelo índice
sequence-frames, eanimation-stateeimage-bitmapsão atualizados
move-characterstroca para:orc-idlequando o personagem está parado e para:orc-runquando está em movimento
Inimigos, game over e busca de caminho com A*
- O componente
enemytem dois slots necessários para o comportamento do inimigovision-range: distância em que ele passa a ver o jogador e reagirattack-range: alcance de ataque
- Para encerrar o jogo, é adicionada a variável global
*should-quit*, e o loop principal termina quando esse valor é verdadeiro - O sistema
handle-enemiesobtém as coordenadas do jogador e as compara com as dos inimigos- Se o jogador estiver no campo de visão, define as coordenadas-alvo do inimigo como a posição do jogador
- Se o jogador estiver no alcance de ataque, define
*should-quit*como verdadeiro e exibe a caixa de mensagem nativaYou died
- A animação dos inimigos usa as sequências
demon-idleedemon-runmove-charactersescolhe a animação de orc para o jogador e a animação de demônio para os inimigos, de acordo com o resultado dehas-player-p
- Como, no rastreamento direto, os inimigos também atravessam paredes, é adicionada a busca de caminho A*
- cl-astar é adicionado como dependência
- Essa biblioteca gera por macro funções de busca de caminho otimizadas para o problema em questão
- Em vez de armazenar o caminho em um array dentro do slot de um componente, cada ponto do caminho é representado como uma entidade separada
path-pointtemx,yetraveller, etravellerusa o índicepath-pointspatharmazena o destino final emdestination-xedestination-y- As coordenadas-alvo de
characterrepresentam o próximo ponto do caminho, epathrepresenta o destino final
- O sistema
follow-pathpega o primeiro ponto do caminho e move o personagem até ele- Ao alcançar o ponto, a entidade
path-pointcorrespondente é removida - Se não houver mais pontos, o componente
pathé removido
- Ao alcançar o ponto, a entidade
find-pathé definido coma*:define-path-finder- O tamanho do mundo é calculado dividindo o tamanho da janela pelo tamanho dos tiles
- É usado um indexador row-major
- A chegada ao objetivo é determinada pela igualdade das coordenadas dos tiles
- Os vizinhos são enumerados em 8 direções
- Obstáculos ou movimentos diagonais que cruzem obstáculos recebem custo
most-positive-single-float, tornando-os praticamente impossíveis - A heurística usa octile distance
- Se já houver um caminho existente, os pontos do caminho são removidos e um novo
pathé atribuído - Cada ponto do caminho resultante é criado como uma entidade com
path-pointeparent
handle-enemieschamafind-pathquando o inimigo vê o jogador e o destino do caminho existente é diferente da posição do jogador- Depois da mudança, os inimigos perseguem o jogador evitando obstáculos
UI de jogo com Nuklear
- É necessária uma GUI para os elementos narrativos, mas bibliotecas GUI tradicionais como Qt ou GTK não se adequam a interfaces de jogo desenhadas no contexto gráfico do
liballegro - Nuklear é usado como biblioteca de UI
- Existe o binding de Common Lisp cl-liballegro-nuklear para uso com
liballegro - O binding também oferece uma DSL para interface declarativa
- Existe o binding de Common Lisp cl-liballegro-nuklear para uso com
- A dependência
cl-liballegro-nuklear/declarativeé adicionada, esrc/narrative.lispé incluído como novo arquivo - No pacote, é registrado o apelido local
uipara referenciarcl-liballegro-nuklear/declarativede forma abreviada - A fonte de UI usada é a Alegreya, do Google Fonts, e o nome do arquivo é alterado para
alegreya-sc.ttf ui:defwindow narrativedefine a função da janela narrativa- A posição da janela é calculada para a área central da tela
ui:label-wrapexibe texto com quebra automática de linhaui:button-label "Ok"retorna verdadeiro quando clicado
- Nuklear é uma biblioteca de UI de modo imediato
- Em vez de manter objetos de widget na memória como no retained mode, ela renderiza e processa a cada frame
- O clique em botão é tratado por valor de retorno e condição em cada frame, e não por callback
main.lispcarrega a fonte da UI e inicializa o contexto de UI comnk:allegro-init- No loop de eventos, chama
nk:input-begin,nk:allegro-handle-eventenk:input-end - Na renderização, chama
nk:allegro-render - Ao encerrar, chama
nk:allegro-shutdownenk:allegro-font-del
- No loop de eventos, chama
Skin da UI e objetos narrativos
- Como a UI padrão é sem graça, são usados os assets de imagem
fantasy-ui-bordersdo Kenney para aplicar estilo - As imagens da UI são armazenadas nas variáveis globais
*window-background*,*button-normal-background*,*button-hover-background*e*button-active-background* load-uicarrega as imagens comnk:allegro-create-image, eunload-uilibera os recursos de imagem no lado C comnk:allegro-del-imageinitchamaload-ui, eunload-uié chamado ao final do loop principal- O argumento
:stylesdeui:defwindowespecifica o plano de fundo, as imagens de cada estado do botão e a cor do texto - O componente
narrativerepresenta objetos de storytelling ambientaltext: texto a ser exibidoshown: se já foi exibido ao menos uma vezactive: se a janela está ativa no momentoactiveusa o índiceactive-narratives
- O sistema
show-narrativeexibe a janela quando o jogador está perto de um objeto narrativo- A distância de interação é calculada com
+interact-distance-factor+e o tamanho do tile do jogador - A janela é exibida se já estiver ativa, se ainda não tiver sido exibida ou se a tecla
Efor pressionada - A janela é fechada com o botão
Ok,Esc,SpaceouEnter
- A distância de interação é calculada com
- No Tiled, é criado o tipo customizado
narrativee adicionado o membro stringtext - Para detecção de colisão e alinhamento corretos, objetos intransponíveis precisam ter coordenadas alinhadas à grade de tiles
- O problema de a masmorra continuar se movendo enquanto a janela narrativa está aberta é evitado com uma condição de execução dos sistemas
move-charactersecontrol-playerrecebem:when (null (active-narratives t))- Se houver uma narrativa ativa, os sistemas de movimento e controle não são executados
- A condição de vitória é adicionada com o componente de tag
win- Ao fechar a janela de um objeto que também tenha
narrative,*should-quit*é definido como verdadeiro e o jogo termina
- Ao fechar a janela de um objeto que também tenha
Encerramento e escopo
- O exemplo final monta um dungeon crawler no estilo Souls-like com storytelling ambiental, IA de inimigos e GUI usando
cl-fast-ecs,cl-tiled,cl-astarecl-liballegro-nuklear - A implementação tem cerca de 500 linhas de código
- O código completo está no repositório no GitHub e, além do código do tutorial, inclui declarações de tipo opcionais com
declaim - Sound design, cutscenes, menu principal, transição de fases e o “door problem” não são abordados
- O Autumn Lisp Game Jam 2024 será realizado no itch.io em 25 de outubro de 2024, um evento em que se fazem jogos por 10 dias em dialetos de Lisp e depois eles são avaliados e recebem feedback mutuamente
- Esta parte é baseada em Thoughtbound, inscrito no Spring Lisp Game Jam 2023
- Na próxima parte, o autor antecipa o desafio de aumentar a escala e adicionar uma IA mais avançada para criar um jogo de estratégia em tempo real
1 comentários
Comentários do Hacker News
Queria que todo tutorial técnico fosse assim. A estrutura do texto é boa, quase não há erros gramaticais, e cada novo tópico é explicado na medida certa, além de trazer exemplos de código completos e até material visual mostrando o que o código realmente faz
É longo o bastante para tratar o assunto com profundidade, mas independente o suficiente para dar para acompanhar mesmo sem ter lido a parte 1 e tendo só brincado com Common Lisp por alguns meses anos atrás. Já usei bastante Clojure e Emacs Lisp
Bravo, awkravchuk/Andrew :^)
(Crossposted from https://mxjn.me/2024/10/17/1)
Também tem a vantagem de ser fácil de salvar para uso offline ou arquivo, fazer anotações e pesquisar
Poucas coisas na área de tecnologia me emocionam tanto quanto um ótimo projeto ou texto sobre Common Lisp. É um texto que parece um grande presente
Li a parte 1 quando saiu, e estou realmente ansioso para ler esta também. Meus parabéns ao autor
Só o
package.she toda a gestão de build para 3 sistemas operacionais já são uma aula por si só. Aprendi muito só de dar uma olhada no repositório no GitHubNormalmente eu construo apps de linha de comando em Common Lisp com SBCL ou LispWorks, mas talvez da próxima vez eu tente com ECL. É legal ter builds para macOS e Linux, e também parece divertido experimentar algo novo
Texto muito bom. Estou desenvolvendo um shooter multiplayer em terceira pessoa baseado em feitiços, em Lisp — mais especificamente ClojureScript. É um jogo 3D para a web, e pretendo escrever no blog sobre essa jornada, incluindo as ferramentas e abstrações que criei para o projeto
Se houver interesse, o demo está aqui: https://wizardmasters.io
O texto em si é muito sólido, mas ao olhar o processo de configuração da parte 1 — passando por Common Lisp em si, Python, C e várias etapas — fica meio claro por que CL não é tão popular, especialmente entre programadores mais jovens
É uma pena, e seria ótimo se alguém fizesse o esforço de tornar a linguagem mais acessível do ponto de vista da instalação
Pelo que entendo, o foco é mais na quantidade excessiva de escolhas e no fato de que os padrões antigos parecem datados
O loop de eventos é um ótimo exemplo de como
loopé uma verdadeira linguagem específica de domínio para iteração. Gostando ou não ;)loop? Não tem aquela sintaxe estranha fora de S-expressões, nem precisa dedopara voltar à sintaxe LispDá para usar
if/whennormais, sem aqueleelse/endfeio, e no geral ele ainda acrescenta recursos úteisloopvirou um dos meus componentes favoritos de CLEste texto me lembrou "Caves of Clojure": https://stevelosh.com/blog/2012/07/caves-of-clojure-01/
Por coincidência, comecei esta semana a desenvolver um roguelike em Python, mas fazer isso em Lisp também parece bem legal
Me senti enganado. Vim aprender a fazer um jogo simples e acabei aprendendo um monte sobre computação de forma geral
Muito bom