2 pontos por GN⁺ 2024-10-19 | 1 comentários | Compartilhar no WhatsApp
  • 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-projects do Quicklisp, o tamanho da janela em src/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.tmx e o arquivo do tileset podem ser baixados no Resources.zip fornecido pelo tutorial
  • A dependência de cl-tiled é adicionada em ecs-tutorial-2.asd, e um novo src/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 local tiled

Por que mover objetos CLOS para componentes ECS

  • O cl-tiled retorna 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.0016 segundo por frame, ou seja, mais de 1,5 ms
  • Ao mover os dados lidos por cl-tiled para o armazenamento cl-fast-ecs, é possível reduzir o dispatch e melhorar o uso do cache da CPU
  • A dependência de cl-fast-ecs é adicionada, e ecs:make-storage é chamado em init, enquanto ecs:run-systems é chamado em update

Componentes de mapa, tile e prefab

  • map é um componente de tag que indica a entidade do mapa carregado
  • map-tile representa um tile individual e tem um slot Boolean obstacle para indicar se é um obstáculo, como uma parede ou uma porta fechada
  • O componente parent indica de qual entidade de mapa são filhos os tiles e outros objetos relacionados ao mapa
    • O slot entity recebe :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
  • Um hook é adicionado a ecs:*entity-deleting-hook* para que, quando a entidade-pai for removida, as entidades-filhas encontradas pelo índice children também sejam removidas
  • O componente image armazena apenas um ponteiro C para ALLEGRO_BITMAP
    • A imagem do tileset é dividida em pedaços 32×32 com al_create_sub_bitmap, e os ponteiros são armazenados
  • map-tile-prefab é um prefab de tile que contém o ID global gid do 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 image e outros componentes do prefab, mas mantêm a posição em um componente position separado
  • O finalizer de image chama al_destroy_bitmap apenas quando a entidade é map-tile-prefab
    • Isso é para evitar double free, já que vários tiles do mapa compartilham o mesmo ponteiro ALLEGRO_BITMAP
  • position e size armazenam coordenadas e dimensões da tela como single-float
    • Como liballegro trata coordenadas de tela como ponto flutuante de precisão simples por compatibilidade com OpenGL, o tutorial segue a mesma abordagem

Renderização de imagens e carregamento do mapa

  • O sistema render-images renderiza entidades que têm position e image
    • al_hold_bitmap_drawing ativa e desativa sprite batching
    • al_draw_bitmap desenha 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 encapsula al_load_bitmap com al:ensure-loaded
  • tile->spec cria 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-prefab verifica pelo índice map-tile-prefab se o prefab já foi carregado; se não, ele é criado com make-object
  • load-tile cria a entidade do tile real do mapa copiando componentes do prefab e adicionando position
  • load-map percorre tilesets e layers no objeto CLOS lido por tiled: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-entity garante 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.lisp e animation.lisp são adicionados para separar componentes comuns e componentes/sistemas relacionados à animação
  • O componente animation-frame representa um frame da animação
    • sequence é o nome da animação e é armazenado como tipo keyword
    • O índice sequence-frames é usado para encontrar os frames de uma animação específica
    • duration é a duração do frame em segundos
  • animation-state armazena o estado atual do tile animado no mapa
    • sequence atual
    • frame atual
    • duration do frame atual
    • tempo elapsed exibido 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-animations aumenta elapsed em dt e, ao ultrapassar a duração, troca para o próximo frame
    • Como o tempo do frame pode ser menor que um dt grande, floor é usado para calcular quantos frames devem ser pulados
    • truncate faz 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 a duração da animação é armazenada em milissegundos no Tiled, animation->spec a converte para segundos
  • instantiate-animation cria animation-state na entidade real do tile e inicializa elapsed com um valor aleatório entre 0 e duration, 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

Personagem do jogador e controles

  • character.lisp é adicionado, e define o componente character para personagens que podem se mover
    • speed é a velocidade em pixels por segundo
    • target-x e target-y são as coordenadas de destino do movimento
    • Os valores iniciais de destino são definidos como single-float-nan para evitar que um novo personagem se mova para o canto superior esquerdo sem motivo
  • O componente de tag player usa um slot bit e :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
  • Na implementação inicial, a imagem do orc é recortada do tileset, salva como player.png e o jogador é criado por hardcode com load-player
    • Posição em (64.0, 64.0)
    • Tamanho de 32×32
    • Velocidade de 100.0
  • O sistema move-characters move 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 e dt
  • O sistema control-player lê as teclas W, A, S, D e atualiza as coordenadas de destino
    • Usa al:with-current-keyboard-state e al:key-down
    • clamp evita sair dos limites da tela
    • :after (move-characters) faz com que ele execute depois do sistema de movimento, evitando o problema de inicialização com NaN

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 Boolean obstacle é adicionado
    • A propriedade map-tile é adicionada aos tiles de parede, com obstacle marcado
  • A função properties->spec converte 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-prefab inclui o resultado de properties->spec na especificação do prefab
    • Se não houver propriedades, um componente map-tile padrão é adicionado com spec-adjoin, e obstacle fica com o valor padrão nil
  • O componente position recebe um slot tile-hash e o índice tiles
    • tile-hash transforma x e y em inteiros e depois os empacota em um único inteiro de 64 bits
    • O índice tiles encontra todas as entidades na coordenada do canto superior esquerdo de um tile específico
  • tile-start retorna a coordenada do canto superior esquerdo do tile da grade ao qual uma coordenada arbitrária pertence
  • tile-obstacle-p verifica se, entre as entidades da mesma coordenada, existe algum tile map-tile com obstacle verdadeiro
  • obstaclep verifica se o tile correspondente a uma coordenada arbitrária é um obstáculo
  • control-player verifica 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 character e player são adicionadas ao Tiled
    • character tem apenas o membro speed, do tipo float
    • target-x e target-y são omitidos para usar os valores padrão
    • player tem um membro int player, com valor padrão 1
  • O personagem do jogador é colocado como objeto de tile na camada de objetos do Tiled, com as propriedades character e player
  • load-map é expandido para também processar tiled:object-layer
    • As propriedades do objeto são convertidas em componentes ECS com properties->spec
    • tiled:tile-object copia dados do tile e animações com load-tile e define a posição
    • Como as coordenadas de objeto do Tiled usam a base inferior esquerda, a altura do objeto é subtraída de y para ajustar para a base no canto superior esquerdo
  • A chamada hardcoded de load-player e 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-idle e orc-run do orc, definidas no tileset
  • change-animation-sequence muda 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, e animation-state e image-bitmap são atualizados
  • move-characters troca para :orc-idle quando o personagem está parado e para :orc-run quando está em movimento

Inimigos, game over e busca de caminho com A*

  • O componente enemy tem dois slots necessários para o comportamento do inimigo
    • vision-range: distância em que ele passa a ver o jogador e reagir
    • attack-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-enemies obté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 nativa You died
  • A animação dos inimigos usa as sequências demon-idle e demon-run
    • move-characters escolhe a animação de orc para o jogador e a animação de demônio para os inimigos, de acordo com o resultado de has-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-point tem x, y e traveller, e traveller usa o índice path-points
    • path armazena o destino final em destination-x e destination-y
    • As coordenadas-alvo de character representam o próximo ponto do caminho, e path representa o destino final
  • O sistema follow-path pega o primeiro ponto do caminho e move o personagem até ele
    • Ao alcançar o ponto, a entidade path-point correspondente é removida
    • Se não houver mais pontos, o componente path é removido
  • find-path é definido com a*: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-point e parent
  • handle-enemies chama find-path quando 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
  • A dependência cl-liballegro-nuklear/declarative é adicionada, e src/narrative.lisp é incluído como novo arquivo
  • No pacote, é registrado o apelido local ui para referenciar cl-liballegro-nuklear/declarative de forma abreviada
  • A fonte de UI usada é a Alegreya, do Google Fonts, e o nome do arquivo é alterado para alegreya-sc.ttf
  • ui:defwindow narrative define a função da janela narrativa
    • A posição da janela é calculada para a área central da tela
    • ui:label-wrap exibe texto com quebra automática de linha
    • ui: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.lisp carrega a fonte da UI e inicializa o contexto de UI com nk:allegro-init
    • No loop de eventos, chama nk:input-begin, nk:allegro-handle-event e nk:input-end
    • Na renderização, chama nk:allegro-render
    • Ao encerrar, chama nk:allegro-shutdown e nk:allegro-font-del

Skin da UI e objetos narrativos

  • Como a UI padrão é sem graça, são usados os assets de imagem fantasy-ui-borders do 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-ui carrega as imagens com nk:allegro-create-image, e unload-ui libera os recursos de imagem no lado C com nk:allegro-del-image
  • init chama load-ui, e unload-ui é chamado ao final do loop principal
  • O argumento :styles de ui:defwindow especifica o plano de fundo, as imagens de cada estado do botão e a cor do texto
  • O componente narrative representa objetos de storytelling ambiental
    • text: texto a ser exibido
    • shown: se já foi exibido ao menos uma vez
    • active: se a janela está ativa no momento
    • active usa o índice active-narratives
  • O sistema show-narrative exibe 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 E for pressionada
    • A janela é fechada com o botão Ok, Esc, Space ou Enter
  • No Tiled, é criado o tipo customizado narrative e adicionado o membro string text
  • 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-characters e control-player recebem :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

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

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

    • Acima de tudo, o grande diferencial é que é texto, não vídeo. Dá para copiar e colar, ler com clareza, acompanhar no próprio ritmo e consumir em silêncio
      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.sh e 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 GitHub
    Normalmente 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

    • Dizem que vêm empilhando essa configuração de CI sobre a infraestrutura de CL há anos, e ela continua quebrando :D
  • 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

    • Jon Blow também tentou fazer um jogo assim há muito tempo. Pode valer a pena estudar como e por que ele fracassou
  • 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

    • Não acho que mire exatamente o mesmo problema, mas https://ciel-lang.org/ é pelo menos uma tentativa de resolver parte do problema de haver etapas demais
      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 ;)

    • Não daria para usar https://iterate.common-lisp.dev/ no lugar de loop? Não tem aquela sintaxe estranha fora de S-expressões, nem precisa de do para voltar à sintaxe Lisp
      Dá para usar if/when normais, sem aquele else/end feio, e no geral ele ainda acrescenta recursos úteis
    • No começo eu tirava sarro, mas depois de alguns anos programando em Common Lisp, loop virou um dos meus componentes favoritos de CL
  • Este 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