Пишем текстовую игру на Python-Ren’Py ч.2: мини-игры и подводные камни

в 2:48, , рубрики: CYOA, Gamedev, python, разработка игр, текстовый квест

Краткое содержание предыдущих двадцати пяти тысяч серий: мы пишем текстовую игру про плавание по морю и ввязывание в истории. Пишем мы её на движке Ren'Py, который изначально предназначен для визуальных новелл, но после минимальной настройки способен делать всё, что нам надо. В прошлой статье я рассказал, как сделать простенькую интерактивную читалку, а в этой мы добавим ещё пару экранов с более сложными функциями и наконец поработаем на питоне.
image


Прошлая статья закончилась на том, что игрок может читать текст, смотреть картинки и влиять на развитие сюжета. Если бы мы собирались портировать книги-игры Браславского или писать CYOA в духе Choice of Games – этого бы хватило. Но в игре про мореходство нужен хотя бы простенький интерфейс собственно мореходства. Собрать полноценный симулятор, как в Sunless Sea, в Ren'Py можно, но трудозатратно и в целом бессмысленно. К тому же мы не хотим сделать своё Sunless Sea с бурятками и буузами, поэтому ограничимся чем-то вроде глобальной карты в “Космических Рейнджерах”: десяток-другой активных точек, из каждой из которых можно отправиться в несколько соседних. Примерно вот так:

image

Displayables, экраны и слои

Для самого интерфейса нам нужно просто вывести на экран кучу кнопок (по одной на каждую точку) и фоновый рисунок; для этого хватит одной Displayable типа Imagemap. Принципиально она не сильно отличается от своего аналога в HTML: это картинка, на которой определены активные зоны, каждая со своим действием. Однако работать с голой Displayable неудобно, да и с архитектурной точки зрения это некрасиво. Поэтому, прежде чем браться за код, стоит разобраться с иерархией элементов, которые Ren'Py выводит на дисплей.

Самый нижний уровень – это Displayables, то есть виджеты, встроенные или созданные разработчиками конкретной игры. Каждый из них выполняет какую-то базовую функцию: показывает картинку, выводит текст, обеспечивает ввод и т.п. Displayables обычно группируются в экраны, которые обеспечивают уже более абстрактные взаимодействия: например, вывод текста и внутриигровые меню выполняются экраном nvl, который включает собственно текст, фон, рамку и кнопки меню. Одновременно можно показывать сколько угодно экранов: на одном, к примеру, может быть текст, на другом кнопки, регулирующие громкость музыки, а на третьем какие-нибудь летающие поверх всего этого снежинки. По мере необходимости можно показывать или убирать отдельные экраны с мини-играми, меню сохранений и всем остальным, что нужно для игры. И, наконец, существуют слои. Работать напрямую со слоями нужно нечасто, но знать об их существовании необходимо. Фоновый рисунок, например, не имеет отношения к системе экранов и выводится ниже них в слое master.

Итак, описание экрана путешествий выглядит следующим образом:

screen map_screen():
    tag map 
    modal True 
    zorder 2 
    imagemap: 
        auto 'images/1129map_%s.png' 
        #  Main cities 
        hotspot monet.hotspot action Travel(monet) 
        hotspot tartari.hotspot action Travel(tartari) 
        #  …
        #  More of the same boilerplate
        #  …
    add 'images/1024ship.png': 
        at shiptransform(old_coords, coords) 
        anchor (0.5, 1.0) 
        id 'ship'
transform shiptransform(old_coords, coords): 
    pos old_coords 
    linear 0.5 pos coords 

Сперва объявляется экран map_screen(). Тэг опциональный; он просто позволяет группировать экраны для команд типа “Убрать все экраны, связанные с перемещением”. Zorder – это “высота” экрана, по которой решается, какие элементы будут друг друга заслонять. Поскольку мы задали zorder=2, а у экрана nvl дефолтный zorder=1, во время путешествия игрок не будет видеть окна с текстом. Но самое главное – этот экран модальный, то есть никакие элементы ниже него не будут получать события ввода. Получается, что экран nvl с появлением карты блокируется, поэтому нам не нужно самим отслеживать game state и следить, чтобы клик по карте заодно не промотал текст.

Сам Imagemap состоит из двух основных элементов: тэга auto и списка активных зон. Auto содержит ссылку на набор файлов, которые будут использоваться в качестве фона. Именно набора, а не единой картинки: отдельно лежит файл, в котором все кнопки нарисованы как неактивные, отдельно – в котором все они нажаты и так далее. А Ren'Py уже сам выбирает нужный фрагмент из каждого файла и составляет картинку на экране. И, наконец, активные зоны (hotspot). Они описываются кортежем из четырёх целых чисел (координаты и размер) и объектом действия. Кортеж мы не хардкодим, а используем атрибут объекта; в описании экрана вместо значения всегда можно вставить переменную или атрибут. Объект действия описывает, что произойдёт по нажатию кнопки и контролирует, должна ли быть кнопка активна в данный момент. Ren'Py предоставляет довольно много встроенных действий для рутинных задач типа переходов по сценарию или сохранения игры, но можно сделать и свой. Наконец, последним добавляется рисунок корабля и трансформация, благодаря которой он не просто выводится на экран, а ползёт из точки А в точку Б. Трансформации описываются отдельным языком ATL (Animation & Transformation Language).

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

Играем в карты

В предыдущей статье я обещал показать два способа делать интерфейсы в Ren'Py. Первый мы только что увидели: он довольно прост, но отнюдь не универсален. Я так и не понял, например, как на экранном языке описать перетягивание объектов мышью и обрабатывать коллизии. Или как верстать экраны, если количество элементов на них определяется в ходе игры. К счастью, Displayables – это полноценные виджеты: они могут включать в себя другие Displayables, ловить события ввода и при необходимости меняться. Следовательно, можно описать в одной Displayable всю мини-игру практически так же, как мы бы сделали для отдельного приложения, и вставить в проект.

Мини-игра у нас будет карточная. Вся потенциально полезная собственность и познания главного героя представлены в виде колоды карт номиналом от 1 до 10 в четырёх мастях: Сила, Знания, Интриги и Деньги. Скажем, двойка силы – это бесценное знание о том, где у человека дых, под который надо бить, а восьмёрка интриг – достоверный список агентов Конторы среди контрабандистов. Несложная игра со взятками определяет, справился ли игрок со стоящей перед ним проблемой и какой мастью он выиграл или проиграл. В результате у конфликта может быть максимум восемь исходов: победа и поражение каждой мастью в принципе могут приводить к разным последствиям.

Игра карточная — значит, в ней фигурируют карты. Код Displayable, отображающей маленькую карту, выглядит вот так:

class CardSmallDisplayable(renpy.Displayable): 
        """ 
        Regular card displayable 
        """ 

        suit_bg = {u'Деньги': 'images/MoneySmall{0}Card.jpg', 
           u'Знания': 'images/KnowledgeSmall{0}Card.jpg', 
           u'Интриги': 'images/IntrigueSmall{0}Card.jpg', 
           u'Сила': 'images/ForceSmall{0}Card.jpg'} 

        def __init__(self, card, **kwargs): 
            super(CardSmallDisplayable, self).__init__(xysize=(100, 140), xfill=False, yfill=False, **kwargs) 
            self.bg = Image(self.suit_bg[card.suit].format((card.spendable and 'Spendable' or 'Permanent'))) 
            self.text = Text(u'{0}'.format(card.number), color = '#6A3819', font='Hangyaboly.ttf') 
            self.xpos = 0 
            self.ypos = 0 
            self.xsize = 100 
            self.ysize = 140 
            self.x_offset = 0 
            self.y_offset = 0 
            self.transform = Transform(child=self) 

        def render(self, width, height, st, at): 
            """ 
            Return 100*140 render for a card 
            """ 
            bg_render = renpy.render(self.bg, self.xsize-4, self.ysize-4, st, at) 
            text_render = renpy.render(self.text, width, height, st, at) 
            render = renpy.Render(width, height, st, at) 
            render.blit(bg_render, (2, 2)) 
            render.blit(text_render, (15-int(text_render.width/2), 3)) 
            render.blit(text_render, (88-int(text_render.width/2), 117)) 
            return render 

        def visit(self): 
            return[self.bg, 
                   self.text] 

Это, по сути, самая простая возможная Displayable. Она состоит из двух других: фоновой картинки, выбираемой в зависимости от масти, и текста с номиналом. Оба метода (не считая конструктора) необходимы для работы Displayable: self.render возвращает текстуру, а self.visit –
список всех Displayables, входящих в данную. И она действительно рисует маленькую карту; вот несколько таких карт на экране колоды:

image

Уже неплохо, но карта сама по себе умеет только находиться на экране, и то только если её кто-нибудь туда поставит. Чтобы в мини-игру можно было, собственно, играть, нужно добавить внешнюю Displayable, способную обрабатывать ввод и обсчитывать игровую логику. Карты и прочие элементы интерфейса будут входить в неё так же, как текстовые поля и фон являются частями карты. Отличаться эта Displayable будет наличием метода self.event(), который получает на вход события PyGame. ССЫЛКА НА PYGAME.EVENT Примерно вот так (полный код доступен на гитхабе в классе Table):

def event(self, ev, x, y, st): 
        if ev.type == pygame.MOUSEBUTTONDOWN and ev.button == 1: 
            #  …
            #  Process click at x, y
            #  …
        elif ev.type == pygame.MOUSEMOTION and self.dragged is not None:
            #  …
            #  Process dragging card to x, y
            #  …
        renpy.restart_interaction()

Об очереди событий можно не беспокоиться: движок сам раздаст все события всем элементам, которые активны в данный момент. Методу остаётся только проверить, интересует ли его произошедшее, ответить на событие и завершить взаимодействие. Взаимодействие в Ren'Py приблизительно эквивалентно тику игровой логики в других движках, но не ограничено по времени. В общем случае это одна команда игрока и ответ игры на неё, хотя иногда (например, при промотке текста) взаимодействия могут завершаться сами собой.
Эту Displayable, как и все остальные, мы завернём в экран:

screen conflict_table_screen():
    modal True
    zorder 9
    add conflict_table

conflict_table в данном случае не имя класса, а глобальная переменная, в которой хранится соответствующая Displayable. Выше упоминалось, что код экрана в принципе может выполняться в любое время, но перед показом он выполнится непременно, иначе игра не будет знать, что ей, собственно, выводить. Поэтому вполне безопасно непосредственно перед мини-игрой выполнить что-то вроде conflict_table.set_decks(player_deck, opponent_deck) и полагаться на то, что перед игроком окажется ровно то, что нужно. Аналогичным образом по завершении мини-игры можно обратиться к результатам, которые хранятся в том же объекте.

Надо сказать, что использование глобальных переменных – это не ограничение Ren'Py, а наше собственное решение. Поддерживаются экраны и Displayables, которые способны принимать аргументы и возвращать значения, но с ними несколько сложнее. Во-первых, поведение таких экранов слабо задокументировано. По крайней мере, разобраться, в какой именно момент экран начинает своё первое взаимодействие и когда именно возвращает контроль сценарию, довольно сложно. А это очень важный вопрос, так как не зная ответа на него, сложно гарантировать, что весь предшествующий конфликту текст будет показан до начала конфликта, а следующий за конфликтом текст показан не будет. Во-вторых, с использованием глобальных переменных большинство нужных для мини-игры объектов инициализируется только один раз, а потом мы меняем их атрибуты при каждом следующем запуске. В противном случае пришлось бы с каждым конфликтом тратить время на подгрузку всех необходимых файлов; игра ощутимо лагает, если к HDD параллельно обращаются ещё, например, торрент и антивирус. Наконец, к картам обращается не только экран конфликта, но и несколько других экранов, поэтому логично использовать одни и те же Displayables везде, где они нужны.

Послесловие

На этом собственно программная часть разработки заканчивается и начинается наполнение игры контентом. Литературой в игре занимаюсь в основном не я, поэтому о структуре повествования и стилистике рассуждать не стану. На эту тему могу посоветовать почитать, например, классическую статью о структуре CYOA. Или неплохое руководство по написанию убедительных независимых NPC от сценариста 80 days.
Ещё больше ссылок (а также обзоров на свежие англоязычные работы и статей на смежные темы) можно найти в блоге Эмили Шорт.

Автор: synedra

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js