Пишем текстовую игру на Python/Ren'Py ч.2: мини-игры и подводные камни
Прошлая статья закончилась на том, что игрок может читать текст, смотреть картинки и влиять на развитие сюжета. Если бы мы собирались портировать книги-игры Браславского или писать CYOA в духе Choice of Games — этого бы хватило. Но в игре про мореходство нужен хотя бы простенький интерфейс собственно мореходства. Собрать полноценный симулятор, как в Sunless Sea, в Ren’Py можно, но трудозатратно и в целом бессмысленно. К тому же мы не хотим сделать своё Sunless Sea с бурятками и буузами, поэтому ограничимся чем-то вроде глобальной карты в «Космических Рейнджерах»: десяток-другой активных точек, из каждой из которых можно отправиться в несколько соседних. Примерно вот так:
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, входящих в данную. И она действительно рисует маленькую карту; вот несколько таких карт на экране колоды:
Уже неплохо, но карта сама по себе умеет только находиться на экране, и то только если её кто-нибудь туда поставит. Чтобы в мини-игру можно было, собственно, играть, нужно добавить внешнюю 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.
Ещё больше ссылок (а также обзоров на свежие англоязычные работы и статей на смежные темы) можно найти в блоге Эмили Шорт.