Для своих игр в ASCII-арте я написал библиотеку bear_hug с очередью событий, коллекцией виджетов, поддержкой ECS и прочими полезными мелочами. В этой статье мы посмотрим, как с её помощью сделать минимальную работающую игру.

Дисклеймеры
  • Я единственный разработчик библиотеки, поэтому могу быть предвзят.
  • bear_hug – по сути обёртка вокруг bearlibterminal, поэтому относительно низкоуровневых операций с глифами не будет.
  • Похожая функциональность есть в clubsandwich, но я им не пользовался и сравнивать не могу.

Под капотом у bear_hug – bearlibterminal, библиотека на SDL для создания псевдо-консольного окна. То есть в чистом TTY, как какой-нибудь ncurses, она работать не будет. Но зато картинка получается одинаковой что на Linux, что на Windows, и не зависит от настроек пользовательского терминала. Это важно, особенно для игр, потому что при смене шрифта ASCII-арт вполне может превратиться Бог весть во что:


Один и тот же рисунок в оригинальном виде и после копипаста в разные программы

Разумеется, библиотека писалась для относительно масштабных проектов. Но, чтобы не отвлекаться на геймдизайн и архитектуру, в этой статье мы будем создавать что-нибудь простенькое. Проект на один вечер, в котором есть на чём показать основные функции библиотеки. А именно – упрощённый клон тех самых танчиков с Денди (они же Battle City). Будут танк игрока, танки противников, разрушаемые стены, звук и подсчёт очков. А вот главного меню, уровней и подбираемых бонусов не будет. Не потому, что их было бы невозможно добавить, а потому что этот проект – не более чем хэлловорлд.


Геймовер будет, а победы не будет. Потому что жизнь — боль.

Весь использованный в статье материал есть на гитхабе; сама библиотека – там же и на PyPI (под лицензией MIT).

Прежде всего нам понадобятся ассеты. Для рисования ASCII-арта я использую REXpaint от Джоша Ге (он же Kyzrati), разработчика научно-фантастического рогалика Cogmind. Редактор бесплатный, хоть и не опенсорсный; официальная версия есть только для Windows, но всё прекрасно работает под wine. Интерфейс довольно понятный и удобный:



Сохраняем в местный двоичный формат .xp и копируем из /path/to/rexpaint/images в папку с будущей игрой. В принципе поддерживается и загрузка изображений из .txt-файлов, но в текстовом файле очевидно невозможно сохранить цвета отдельных символов. Да и редактировать ASCII-арт в блокноте лично мне неудобно. Чтобы не хардкодить координаты и размер каждого элемента – эти данные хранятся в отдельном JSON-файле:

battlecity.json
[
  {
    "name": "player_r",
    "x": 1,
    "y": 2,
    "xsize": 6,
    "ysize": 6
  },
  {
    "name": "player_l",
    "x": 1,
    "y": 8,
    "xsize": 6,
    "ysize": 6
  },
  ...
]


Звуки под свободными лицензиями качаем из интернета. Пока что поддерживается только .wav. На этом с ассетами всё, можно начинать кодить. Прежде всего надо инициализировать терминал и очередь событий.

game.py
# Терминал
terminal = BearTerminal(font_path='cp437_12x12.png',
                        size='91x60', title='AsciiCity',
                        filter=['keyboard', 'mouse'])
# Очередь событий
dispatcher = BearEventDispatcher()
# Цикл, который считает тики и вообще следит за временем
loop = BearLoop(terminal, dispatcher)


Терминал – это собственно окно игры. На него можно помещать виджеты, и он же по мере необходимости кидает события ввода. В качестве ключей при создании терминала можно использовать все опции терминала bearlibterminal; в данном случае мы задали шрифт, размер окна (в символах), заголовок окна и интересующие нас методы ввода.

Что касается очереди событий, то у неё очень простой интерфейс: dispatcher.add_event(event) добавляет событие в очередь, а dispatcher.register_listener(listener, event_types) позволяет на неё подписаться. У подписывающегося (например, виджета или компонента) должен быть коллбэк on_event, который принимает событие в качестве единственного аргумента и либо ничего не возвращает, либо возвращает другое событие или набор событий. Само событие состоит из типа и значения; тип тут не в смысле str или int, а в смысле “разновидность”, например ‘key_down’ или ‘tick’. Очередь принимает только события известных ей типов (встроенных или созданных пользователем) и отдаёт их в on_event всех подписавшихся на данный тип. Значения она никак не проверяет, но в пределах библиотеки есть соглашения по поводу того, что является валидным значением для каждого типа событий.

Сперва мы подписываем на очередь пару Listeners. Это базовый класс для объектов, которые могут подписываться на события, но не являются не виджетами, ни компонентами. В принципе использовать его не обязательно, лишь бы у подписывающегося был метод on_event.

Listeners
# Обрабатывает закрытие игрового окна операционной системой
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')


Полный список встроенных типов событий есть в документации. Несложно заметить, что там есть события для создания и уничтожения сущностей, но нет для урона. Поскольку у нас будут объекты, которые не разваливаются с одного выстрела (стены и танк игрока), мы его создадим:

Регистрация типа событий
# О значениях очередь ничего не знает, поэтому ей достаточно
# названия типа
dispatcher.register_event_type('ac_damage')
# Дебажим наш новый тип и звуки
logger = LoggingListener(sys.stderr)
dispatcher.register_listener(logger, ['ac_damage', 'play_sound'])


Договоримся, что в качестве значения у этого события будет кортеж из ID той сущности, которой нанесён урон, и значение урона. LoggingListener – просто инструмент для дебага, который печатает все полученные события куда скажут, в данном случае в stderr. В данном случае я хотел убедиться, что урон проходит корректно, а звук запрашивается всегда, когда должен.

С Listeners пока всё, можно добавить первый виджет. У нас это игровое поле класса ECSLayout. Это такой Layout, который умеет помещать на себя виджеты сущностей и перемещать их в ответ на события ecs_move, а заодно считает коллизии. Как и у большинства виджетов, у него два обязательных аргумента: вложенный список символов (возможно, пустых – пробел или None) и вложенный список цветов для каждого символа. В качестве цветов принимаются именованные цвета, RGB в формате `0xAARRGGBB` (либо `0xARGB`, `0xRGB`, `0xRRGGBB`) и в формате ‘#fff’. Размеры обоих списков должны совпадать; в противном случае выбрасывается исключение.

Первый виджет
# Создаём поле боя. Оно 84х60, а экран 91х60.
# 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')


Раз у нас теперь есть, на чём размещать внутриигровые объекты, можно начать создавать сущности. Весь код сущностей и компонентов вынесен в отдельный файл. Простейшая из них – разрушаемая кирпичная стена. Она умеет находиться на определённом месте, отображать свой виджет, служить объектом коллизии и получать урон. После достаточного количества урона стена исчезает.

entities.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)))


Прежде всего, создаётся сам объект сущности. Он содержит только имя (которое должно быть уникальным) и набор компонентов. Их можно либо передать все сразу при создании, либо, как здесь, добавлять по одному. Потом создаются все нужные компоненты. В качестве виджета используется SwitchWidget, который содержит несколько рисунков одинакового размера и может их менять по команде. Кстати, рисунки загружаются из атласа при создании виджета. И, наконец, в очередь уходят объявление о создании сущности и приказ её отрисовать на нужных координатах.

Из не-встроенных компонентов тут только здоровье. Я создал базовый класс “Компонент здоровья” и унаследовал от него “Компонент здоровья, меняющий виджет” (чтобы показывать стену целой и на нескольких стадиях разрушения).

class 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})


При создании компонента в super().__init__ передаётся ключ ‘name’. Когда компонент будет добавлен к какой-нибудь сущности, под названием из этого ключа он будет добавлен в __dict__ сущности и к нему можно будет обращаться через entity_object.health. Помимо удобства интерфейса, такой подход хорош тем, что запрещает выдавать сущности несколько однородных компонентов. А то, что он хардкодится внутри компонента, не позволяет по ошибке засунуть, например, WidgetComponent в слот компонента здоровья. Сразу при создании компонент подписывается на интересующие его классы событий, в данном случае ac_damage. Получив такое событие, метод on_event проверит, не о его ли хозяине часом речь. Если да – он вычтет нужное значение из хитпойнтов и дёрнет коллбэк на изменение здоровья, у базового класса абстрактный. Ещё есть метод __repr__, который используется для сериализации в JSON (например, для сохранений). Его добавлять не обязательно, но у всех встроенных компонентов и большинства встроенных виджетов он есть.

Наследующий от базового компонента здоровья VisualDamageHealthComponent переопределяет коллбэк на изменение здоровья:

class 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])


Пока здоровье выше 0, он просит компонент, ответственный за виджет, отрисовывать стену в нужном состоянии. Тут как раз используется описанное выше обращение через атрибут объекта сущности. Как только хитпойнты кончатся, таким же образом будет вызван компонент, ответственный за корректное уничтожение сущности и всех компонентов.

Для остальных сущностей всё аналогично, только набор компонентов другой. Танкам добавляются контроллеры (ввод для игрока, ИИ для противников) и поворачивающиеся виджеты, снарядам – компонент коллизии, наносящий урон тем, в кого они попали. Разбирать каждый из них я не буду, потому что это громоздко и довольно тривиально; посмотрим только на коллайдер снаряда. У него есть метод collided_into, вызываемый, когда сущность-хозяин во что-то врезалась:

коллайдер-компонент пули
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()


Чтобы убедиться, что в жертву действительно можно попасть (что может быть не так для, например, элементов фона), снаряд использует EntityTracker(). Это синглетон, который отслеживает все создаваемые и уничтожаемые сущности; через него можно получить объект сущности по имени и что-нибудь сделать с его компонентами. В данном случае проверяется, что entity.collision (обработчик коллизий жертвы) вообще существует.

Теперь в основном файле игры мы просто вызываем все необходимые функции создания сущностей:

Возвращаемся в 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)


Счётчики очков и хитпойнтов не являются сущностями и не находятся на поле боя. Поэтому они добавляются не на ECSLayout, а прямо на терминал справа от карты. Соответствующие виджеты наследуют от Label (виджет текстового вывода) и имеют метод on_event, чтобы узнавать о том, что их интересует. В отличие от Layout, терминал не обновляет виджеты автоматически каждый тик, поэтому после изменения текста виджеты приказывают ему это сделать:

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)


Генератор врагов и объект, ответственный за вывод “GAME OVER”, на экране не представлены вовсе, поэтому они наследуют от Listener. Принцип тот же: объекты слушают очередь, дожидаются нужного момента, а потом создают сущность или виджет.

геймовер
# Спаунер не приведён из-за громоздкости. См. репозиторий.
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)



Теперь мы создали всё, что нужно, и можем запускать игру.

Запускаем
# Виджеты создаются и подписываются так же, как и Listeners в начале статьи
terminal.start()
terminal.add_widget(layout)
terminal.add_widget(score, pos=(85, 10))
terminal.add_widget(hp, pos=(85, 15))


Виджеты добавляются на экран только после его запуска. Сущности можно было добавлять на карту и раньше – события создания (в которых хранится вся сущность, включая виджет) просто копятся в очереди и разрешаются на первом тике. А вот терминал умеет добавлять виджеты только после того, как для него будет успешно создано окно.

К этому моменту у нас есть работающий прототип, можно выпускаться в Early Access за двадцать баксов добавлять фичи и полировать геймплей. Но это уже выходит за рамки хэллоуворлда, а значит – и статьи. Добавлю только, что не зависящий от системного питона билд можно собрать с помощью pyinstaller.

Комментарии (3)


  1. assembled
    10.10.2019 18:21

    Все таки это не ASCII-арт, а просто псевдографика. В ASCII нет никаких рамочек и т.п.

    Но получилось красиво.


    1. synedra Автор
      10.10.2019 18:24
      +2

      Строго говоря да, чарсет CP437. А под капотом внутри питона вообще ЕМНИП UTF-8. Но обычно ASCII-артом называют любую символьную псевдографику, независимо от кодировки.


  1. hardtop
    10.10.2019 21:29

    О, сыну покажу!