
Para meus jogos na arte ASCII, escrevi
a biblioteca bear_hug com uma fila de eventos, uma coleção de widgets, suporte ao ECS e outras pequenas coisas úteis. Neste artigo, veremos como usá-lo para criar um jogo de trabalho mínimo.
Isenções de responsabilidade- Sou o único desenvolvedor da biblioteca, portanto, posso ser tendencioso.
- bear_hug é essencialmente um invólucro em torno de bearlibterminal , portanto não haverá operações de nível relativamente baixo com glifos.
- Existe uma funcionalidade semelhante no clubsandwich , mas não a usei e não posso compará-la.
Sob o capô de bear_hug está o
bearlibterminal , uma biblioteca SDL para criar uma janela de pseudo-console. Ou seja, em TTY puro, como algumas maldições, não funcionará. Mas a imagem é a mesma no Linux, no Windows e não depende das configurações do terminal do usuário. Isso é importante, especialmente para jogos, porque quando você altera a fonte ASCII-art, Deus pode se transformar no que:
O mesmo desenho em sua forma original e após copiar e colar em diferentes programasObviamente, a biblioteca foi escrita para projetos de larga escala. Mas para não se distrair com o design e a arquitetura dos jogos, neste artigo, criaremos algo simples. Um projeto de uma noite, no qual há algo para mostrar as funções básicas da biblioteca. Ou seja - um clone simplificado desses mesmos tanques com Dandy (eles também são a Cidade da Batalha). Haverá tanque de um jogador, tanques inimigos, paredes destrutíveis, som e pontuação. Mas o menu principal, os níveis e os bônus selecionados não serão. Não porque seria impossível adicioná-los, mas porque esse projeto nada mais é do que um mundo da Hallow.
Haverá um gameover, mas não haverá vitória. Porque a vida é dor.Todo o material usado no artigo está
no github ; a própria biblioteca
também está
em PyPI (sob a licença MIT).
Primeiro de tudo, precisamos de ativos. Para desenhar arte ASCII, eu uso o
REXpaint de Josh Ge (também conhecido como Kyzrati), o desenvolvedor do bagel de ficção científica
Cogmind . O editor é gratuito, embora não seja de código aberto; a versão oficial é apenas para Windows, mas tudo funciona bem no vinho. A interface é bastante clara e conveniente:

Nós salvamos no formato binário local .xp e copiamos de
/path/to/rexpaint/images
para a pasta com o jogo futuro. Em princípio, o carregamento de imagens de arquivos .txt também é suportado, mas é obviamente impossível salvar as cores de caracteres individuais em um arquivo de texto. Sim, e editar arte ASCII em um notebook não é conveniente para mim pessoalmente. Para não codificar as coordenadas e o tamanho de cada elemento, esses dados são armazenados em um arquivo JSON separado:
battlecity.json [ { "name": "player_r", "x": 1, "y": 2, "xsize": 6, "ysize": 6 }, { "name": "player_l", "x": 1, "y": 8, "xsize": 6, "ysize": 6 }, ... ]
Soa sob licenças gratuitas baixadas da Internet. Até agora, apenas o .wav é suportado. Isso é tudo com ativos, você pode começar a codificar. Primeiro de tudo, você precisa inicializar o terminal e a fila de eventos.
O terminal é a janela real do jogo. Você pode colocar widgets nele e lançar
eventos de entrada conforme necessário. Como teclas ao criar um terminal, você pode usar todas as
opções de terminal bearlibterminal ; nesse caso, definimos a fonte, o tamanho da janela (em caracteres), o título da janela e os métodos de entrada que nos interessam.
Quanto à fila de eventos, ela possui uma interface muito simples: dispatcher.add_event (event) adiciona o evento à fila, e dispatcher.register_listener (listener, event_types) permite que você se inscreva. Um assinante (por exemplo, um widget ou componente) deve ter um retorno de chamada on_event, que recebe um evento como um único argumento e não retorna nada ou retorna outro evento ou conjunto de eventos. O evento em si consiste em tipo e valor; o tipo aqui não está no sentido de str ou int, mas no sentido de "variedade", por exemplo, 'key_down' ou 'tick'. A fila aceita apenas eventos dos tipos conhecidos (internos ou criados pelo usuário) e os envia para on_event a todos aqueles que se inscreverem nesse tipo. Ele não verifica os valores de forma alguma, mas existem convenções na biblioteca sobre qual é um valor válido para cada tipo de evento.
Primeiro, colocamos alguns ouvintes na fila. Esta é a classe base para objetos que podem se inscrever em eventos, mas não são widgets ou componentes. Em princípio, não é necessário usá-lo, desde que o signatário possua o método on_event.
Uma lista completa de tipos de eventos internos está
na documentação . É fácil ver que existem eventos para a criação e destruição de entidades, mas não para danos. Como teremos objetos que não se separam de um tiro (as paredes e o tanque do jogador), vamos criá-lo:
Registro de tipo de evento Concordamos que, como valor, esse evento terá uma tupla do ID da entidade que sofreu o dano e do valor do dano. LoggingListener é apenas uma ferramenta de depuração que imprime todos os eventos recebidos onde quer que eles digam, neste caso no stderr. Nesse caso, eu queria ter certeza de que o dano passasse corretamente e que o som fosse solicitado sempre que deveria.
Com os Listeners, por enquanto, você pode adicionar o primeiro widget. Temos este campo de jogo da classe ECSLayout. Esse é um layout, que pode colocar widgets nas entidades e movê-los em resposta a eventos ecs_move e, ao mesmo tempo, considerar colisões. Como a maioria dos widgets, ele possui dois argumentos obrigatórios: uma lista aninhada de caracteres (possivelmente espaço vazio ou Nenhum) e uma lista aninhada de cores para cada caractere. As cores nomeadas são aceitas como cores, RGB no formato `0xAARRGGBB` (ou` 0xARGB`, `0xRGB`,` 0xRRGGBB`) e no formato '#fff'. Os tamanhos das duas listas devem corresponder; caso contrário, uma exceção será lançada.
Como agora temos o que colocar objetos no jogo, podemos começar a criar entidades. Todo o código de entidades e componentes é movido para um arquivo separado. O mais simples deles é uma parede de tijolos destrutível. Ela sabe como estar em um determinado lugar, exibir seu widget, servir como um objeto de colisão e receber dano. Após danos suficientes, a parede desaparece.
entity.py def create_wall(dispatcher, atlas, entity_id, x, y):
Primeiro de tudo, o próprio objeto de entidade é criado. Ele contém apenas um nome (que deve ser exclusivo) e um conjunto de componentes. Eles podem ser transferidos todos de uma vez após a criação ou, como aqui, adicionados um de cada vez. Todos os componentes necessários são criados. Como widget, é utilizado o SwitchWidget, que contém vários desenhos do mesmo tamanho e pode alterá-los por comando. A propósito, os desenhos são carregados do atlas ao criar um widget. E, finalmente, o anúncio da criação da entidade e a ordem para desenhá-la nas coordenadas necessárias entram na fila.
Dos componentes não integrados, há apenas integridade. Criei a classe base “Componente de saúde” e herdei dela o “Widget de alteração do componente de saúde” (para mostrar a parede intacta e em vários estágios de destruição).
classe HealthComponent class HealthComponent(Component): def __init__(self, *args, hitpoints=3, **kwargs): super().__init__(*args, name='health', **kwargs) self.dispatcher.register_listener(self, 'ac_damage') self._hitpoints = hitpoints def on_event(self, event): if event.event_type == 'ac_damage' and event.event_value[0] == self.owner.id: self.hitpoints -= event.event_value[1] @property def hitpoints(self): return self._hitpoints @hitpoints.setter def hitpoints(self, value): if not isinstance(value, int): raise BearECSException( f'Attempting to set hitpoints of {self.owner.id} to non-integer {value}') self._hitpoints = value if self._hitpoints < 0: self._hitpoints = 0 self.process_hitpoint_update() def process_hitpoint_update(self): """ Should be overridden by child classes. """ raise NotImplementedError('HP update processing should be overridden') def __repr__(self):
Ao criar um componente, a chave 'name' é passada para super () .__ init__. Quando um componente é adicionado a uma entidade, sob o nome dessa chave, ele é adicionado ao __dict__ da entidade e pode ser acessado através de entity_object.health. Além da conveniência da interface, essa abordagem é boa porque proíbe a emissão de entidades de vários componentes homogêneos. E o fato de ser codificado dentro do componente não permite inserir por engano, por exemplo, o WidgetComponent no slot do componente de integridade. Imediatamente após a criação, o componente assina as classes de eventos de seu interesse, neste caso ac_damage. Após receber esse evento, o método on_event verificará se é sobre o proprietário por uma hora. Nesse caso, ele subtrairá o valor desejado dos pontos de vida e puxará o retorno de chamada para alterar a saúde, a classe base é abstrata. Há também o método __repr__, usado para
serialização no JSON (por exemplo, para salvar). Não é necessário adicioná-lo, mas todos os componentes internos e a maioria dos widgets internos possuem.
A herança do componente de integridade subjacente de VisualDamageHealthComponent substitui o retorno de chamada na alteração de integridade:
classe VisualDamageHealthComponent class VisualDamageHealthComponent(HealthComponent): """ . HP=0 """ def __init__(self, *args, widgets_dict={}, **kwargs): super().__init__(*args, **kwargs) self.widgets_dict = OrderedDict() for x in sorted(widgets_dict.keys()): self.widgets_dict[int(x)] = widgets_dict[x] def process_hitpoint_update(self): if self.hitpoints == 0 and hasattr(self.owner, 'destructor'): self.owner.destructor.destroy() for x in self.widgets_dict: if self.hitpoints >= x: self.owner.widget.switch_to_image(self.widgets_dict[x])
Enquanto a saúde estiver acima de 0, ele pede ao componente responsável pelo widget que desenhe a parede no estado desejado. Aqui, a chamada descrita acima é usada através do atributo do objeto de entidade. Depois que os hitpoints terminarem, o componente responsável pela destruição correta da entidade e todos os componentes serão chamados da mesma maneira.
Para outras entidades, tudo é semelhante, apenas o conjunto de componentes é diferente. Os tanques são adicionados com controladores (entrada para o jogador, IA para oponentes) e widgets rotativos, para projéteis - um componente de colisão que causa danos àqueles que atingem. Não analisarei cada um deles, porque é volumoso e bastante trivial; olhe apenas para o colisor de projéteis. Ele tem um método collided_into, chamado quando a entidade host colidiu com algo:
colisor de componentes de bala def collided_into(self, entity): if not entity: self.owner.destructor.destroy() elif hasattr(EntityTracker().entities[entity], 'collision'): self.dispatcher.add_event(BearEvent(event_type='ac_damage', event_value=( entity, self.damage))) self.owner.destructor.destroy()
Para garantir que seja realmente possível obter uma vítima (o que pode estar errado para, por exemplo, elementos de segundo plano), o projétil usa EntityTracker (). Este é um singleton que rastreia todas as entidades criadas e destruídas; através dele, você pode obter um objeto de entidade pelo nome e fazer algo com seus componentes. Nesse caso, é verificado que entity.collision (o manipulador de colisão de vítima) existe.
Agora, no arquivo principal do jogo, simplesmente chamamos todas as funções necessárias para criar entidades:
Os contadores de pontos e de pontos de vida não são entidades e não estão no campo de batalha. Portanto, eles não são adicionados ao ECSLayout, mas diretamente ao terminal à direita do mapa. Os widgets relevantes herdam do Label (widget de saída de texto) e têm um método on_event para descobrir o que lhes interessa. Ao contrário do Layout, o terminal não atualiza automaticamente os widgets a cada tick, portanto, após alterar o texto, os widgets dizem para ele fazer isso:
listeners.py class ScoreLabel(Label): """ """ def __init__(self, *args, **kwargs): super().__init__(text='Score:\n0') self.score = 0 def on_event(self, event): if event.event_type == 'ecs_destroy' and 'enemy' in event.event_value and 'bullet' not in event.event_value:
O gerador inimigo e o objeto responsável pela saída de "GAME OVER" não são exibidos, portanto eles herdam do Listener. O princípio é o mesmo: os objetos ouvem a fila, esperam o momento certo e, em seguida, criam uma entidade ou widget.
Agora criamos tudo o que precisamos e podemos começar o jogo.
Os widgets são adicionados à tela somente após o início. As entidades poderiam ser adicionadas ao mapa antes - os eventos de criação (nos quais toda a entidade está armazenada, incluindo o widget) são simplesmente acumulados na fila e resolvidos no primeiro tique. Mas o terminal pode adicionar widgets somente após a criação de uma janela para ele.
Neste ponto, temos um protótipo funcional, que você pode
emitir no Early Access por vinte dólares, adicionar recursos e aprimorar a jogabilidade. Mas isso já está além do escopo do mundo santo e, portanto, do artigo. Acrescentarei apenas que a compilação independente do python do sistema pode ser criada usando o
pyinstaller .