bear_hug: jogos em arte ASCII em Python3.6 +



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 programas

Obviamente, 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.

game.py
 #  terminal = BearTerminal(font_path='cp437_12x12.png', size='91x60', title='AsciiCity', filter=['keyboard', 'mouse']) #   dispatcher = BearEventDispatcher() # ,         loop = BearLoop(terminal, dispatcher) 


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.

Ouvintes
 #       dispatcher.register_listener(ClosingListener(), ['misc_input', 'tick']) #       dispatcher.register_listener(EntityTracker(), ['ecs_create', 'ecs_destroy']) #   jukebox = SoundListener({'shot': 'shot.wav', 'explosion': 'explosion.wav'}) # https://freesound.org/people/EMSIarma/sounds/108852/ # https://freesound.org/people/FlashTrauma/sounds/398283/ dispatcher.register_listener(jukebox, 'play_sound') 


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
 #      ,    #   dispatcher.register_event_type('ac_damage') #       logger = LoggingListener(sys.stderr) dispatcher.register_listener(logger, ['ac_damage', 'play_sound']) 


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.

Primeiro widget
 #   .  8460,   9160. # 7         chars = [[' ' for x in range(84)] for y in range(60)] colors = copy_shape(chars, 'gray') layout = ECSLayout(chars, colors) # 'all' -  ,      dispatcher.register_listener(layout, 'all') 


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): #   wall = Entity(entity_id) #  wall.add_component(PositionComponent(dispatcher, x, y)) wall.add_component(CollisionComponent(dispatcher)) wall.add_component(PassingComponent(dispatcher)) wall.add_component(DestructorComponent(dispatcher)) #     ,      #    -  /   images_dict = {'wall_3': atlas.get_element('wall_3'), 'wall_2': atlas.get_element('wall_2'), 'wall_1': atlas.get_element('wall_1')} wall.add_component(SwitchWidgetComponent(dispatcher, SwitchingWidget(images_dict=images_dict, initial_image='wall_3'))) wall.add_component(VisualDamageHealthComponent(dispatcher, hitpoints=3, widgets_dict={3: 'wall_3', 2: 'wall_2', 1: 'wall_1'})) #     dispatcher.add_event(BearEvent('ecs_create', wall)) dispatcher.add_event(BearEvent('ecs_add', (wall.id, wall.position.x, wall.position.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): #           return dumps({'class': self.__class__.__name__, 'hitpoints': self.hitpoints}) 


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:

Voltar ao game.py
 #  , -     atlas = Atlas(XpLoader('battlecity.xp'), 'battlecity.json') #   create_player_tank(dispatcher, atlas, 30, 50) #    wall_array = [[0 for _ in range(14)], [0 for _ in range(14)], [1, 0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0], [1, 0, 1, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0], [1, 1, 1, 0, 1, 0, 1, 0, 0, 1, 1, 0, 1, 1], [1, 0, 1, 0, 1, 1, 1, 0, 0, 1, 1, 0, 1, 0], [1, 0, 1, 0, 1, 0, 1, 0, 0, 1, 1, 0, 1, 0], [0 for _ in range(14)], [0 for _ in range(14)], [0 for _ in range(14)] ] for y in range(10): for x in range(14): if wall_array[y][x] == 1: create_wall(dispatcher, atlas, f'wall{x}{y}', x*6, y*6) #   -  .     . create_spawner_house(dispatcher, atlas, 35, 0) 


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: #    self.score += 10 self.text = f'Score:\n{self.score}' self.terminal.update_widget(self) class HPLabel(Label): """   """ def __init__(self, *args, **kwargs): super().__init__(text='HP:\n5') self.hp = 5 def on_event(self, event): if event.event_type == 'ac_damage' and event.event_value[0] == 'player': self.hp -= event.event_value[1] self.text = f'HP:\n{self.hp}' self.terminal.update_widget(self) 


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.

gameover
 #    - . . . class GameOverListener(Listener): """       """ def __init__(self, *args, widget=None, **kwargs): print (args) super().__init__(*args, *kwargs) self.widget = widget def on_event(self, event): if event.event_type == 'ecs_destroy' and event.event_value == 'player': self.terminal.add_widget(self.widget, pos=(20, 20), layer=5) 


Agora criamos tudo o que precisamos e podemos começar o jogo.

Lançamos
 #      ,   Listeners    terminal.start() terminal.add_widget(layout) terminal.add_widget(score, pos=(85, 10)) terminal.add_widget(hp, pos=(85, 15)) 


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 .

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


All Articles