«Вагон-вагон»: клубок технологий внутри простой текстовой игры

История создания интерактивной новеллы для мобильных телефонов.

Игра проста на вид, однако под капотом у неё нетипичное сочетание технологий, а разработка в режиме «по вечерам в свободное время» заняла почти два года.

«Вагон-вагон»: клубок технологий внутри простой текстовой игры

«Два года? Правда? А кажется, тут дел на пару месяцев», — примерно так реагировали друзья на рассказ о пути к релизу нашего дебютного проекта. И действительно, что может быть сложного в игре с почти статичной графикой и с механикой «читаем текст, выбираем один из вариантов действия, получаем новую порцию текста»? Оказалось, много чего. Причём не только в творческой части, но и в технологической.

На технологии я и хочу сфокусироваться в этой статье. Для затравки приведу список: Ink, PyDoit, Phaser, Transcrypt, Jasmine, SikuliX, Language Tool, Apache Cordova, PhoneGap, ImageMagick. Все эти инструменты мы использовали в ходе разработки, причём большинство из них пришлось изучать с нуля.

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

Описание игры

«Вагон-вагон» — это интерактивная дорожная новелла.

Двое, мужчина и женщина, едут домой на поезде из города будущего. Их тянет друг к другу, но они не могут быть вместе. Игрок — это судьба. Он управляет внешними событиями, которые влияют на ход разговора героев. Задача — выстроить цепочку событий, чтобы привести героев к... Пусть будет к счастью. Для простоты.

Наша игра.
Наша игра.

Первые прототипы. Выбор Ink

Нас было двое, сценарист-программист — это я — и художник-музыкант, оба на полставки геймдизайнеры. Начинали с голой идеи.

Подумали однажды: а не сделать ли нам игру? Написали несколько концептов, долго их обсуждали. Сочинили общую канву — «День сурка» встречает Grow Cube — зацикленный, меняющийся в зависимости от обстоятельств разговор двух героев. Захотели собрать простой текстовый прототип и проверить, насколько эта механика будет интересна людям. Опыты собирались ставить на друзьях.

Для создания протипа я решил использовать готовую платформу/движок для интерактивной литературы. Цель — одновременно писать художественный текст и выстраивать логику взаимодействия с игроком: сцены, переходы между сценами, деревья диалогов, динамические описания с параметрами.

Платформ существует много. Некоторым уже больше 30 лет, и с их помощью по-прежнему делаются игры. Некоторые созданы недавно и только набирают популярность.

Для себя я определил следующие критерии выбора.

  • Простота синтаксиса. Чтобы сделать прототип как можно быстрее.
  • Простота сборки web-версии игры. Чтобы можно было выложить куда-нибудь страничку и показывать через браузер.
  • Потенциал для сборки мобильной версии игры под iOS и Android. Это были основные платформы, на которые мы нацелились.
  • Минимум зависимостей. В идеале я представлял простую библиотеку, которую можно подключить к популярному игровому движку. Например, к Godot или Unity.
  • Лицензия должна позволять делать коммерческие продукты. Это очень важный пункт. К примеру, для меня было большой неожиданностью, что популярный Twine использует лицензию GPL, и при публикации игры на Twine вы обязаны опубликовать и исходный код.

Первым запустил Ink, давно чесались руки попробовать — на нём написаны одни из самых любимых моих игр последних лет: 80 Days и Sorcery. Язык родился как внутренний инструмент студии-разработчика, а потом перекочевал в Open-Source. При этом студия продолжает его поддерживать и развивать.

Начало игры с одним описанным выбором в редакторе Inky. Слева  —  текст с разметкой, справа  — получившаяся web-страничка
Начало игры с одним описанным выбором в редакторе Inky. Слева — текст с разметкой, справа — получившаяся web-страничка

По всем критериям Ink подошёл идеально: синтаксис интуитивно понятен, web-страница без оформления собирается из коробки, есть плагин для популярного движка Unity, а лицензия максимально свободная — MIT.

Другие варианты я перебирать не стал. C точки зрения «взрослой» разработки это непрофессиональное решение. С альтернативами надо познакомиться хотя бы поверхностно, но тут случилась любовь с первой строчки текста, и я решил, что это судьба.

Самое сложное место  —  главный цикл игры. Каждый  «+»  —  это вариант выбора, который появляется у игрока в зависимости от разных условий.  «->»   — переход к сцене с блоком текста
Самое сложное место — главный цикл игры. Каждый «+» — это вариант выбора, который появляется у игрока в зависимости от разных условий. «->» — переход к сцене с блоком текста

На тот момент мне казалось, что лучший вариант разработки под мобильные платформы — это интеграция с Unity, а легкая HTML5-версия — это сиюминутное удобство. Однако с течением времени именно это решение стало основным.

На практике убедился в верности слов Роберт Мартина из книги «Чистая архитектура»: «Встретившись с фреймворком, не торопитесь вступать с ним в союз . Посмотрите, есть ли возможность отложить решение . Если это возможно, удерживайте фреймворк за архитектурными границами. Может быть, вам удастся найти способ получить молоко, не покупая корову».

Более сложные прототипы. Система автоматической сборки PyDoit

Первые прототипы друзьям понравились. Но и проблем было много. Начиная c опечаток, заканчивая тем, что многие не понимали, какая у игры цель, и что вообще происходит. Мы наматывали на ус, постоянно делали правки в тексте и логике, выкатывали новые версии, тестировали на новых людях.

«Выкатывание» состояло из двух операций: сборка web-страницы и заливка на бесплатный хостинг через FTP. Повторять это вручную нам быстро надоело. И ошибиться было легко: залить старую страницу вместо новой.

Чтобы избавиться от рутины сделал скрипт, запускающий этот процесс из командной строки в одно действие. В основе скрипта — система автоматизации PyDoit.

Далее пример простого скрипта из трёх шагов, с которого я начинал:

from doit.action import CmdAction import os.path, deploy from pathlib import Path PROJECT_DIR = Path(os.path.dirname(os.path.abspath(__file__))) HTML_DIR = PROJECT_DIR / "html" STORY_JS = HTML_DIR / "story.js" def task_build_ink(): # Сброс исходного ink-файла в json ink_file = PROJECT_DIR / "ink_scripts" / "main.ink" tool_exe = PROJECT_DIR / "tools" / "inklecate" / "inklecate.exe" ink_cmd = "{} -o {} {}".format(tool_exe, STORY_JS, ink_file) return {'actions': [CmdAction(ink_cmd)]} def task_ink2js(): # Помещаем json в переменную storyContent. Она будет доступна напрямую из JavaScript. def convert(): with open(STORY_JS, mode="r", encoding="utf-8") as file: story_content = file.read() with open(STORY_JS, mode="w", encoding="utf-8") as file: file.write("var storyContent = {}".format(story_content)) return {'actions': [(convert,)]} def task_deploy_debug(): # Загружаем story.js и другие файлы из каталога html (шаблон странички, плагин для загрузки ink) на сервер def run_deploy(): deploy.ftp_deploy_to_server(HTML_DIR, "public_html/debug") return {'actions': [(run_deploy, )]}

При выборе PyDoit сомнений не было. Использую эту систему уже лет восемь на самых разных проектах. Она умеет всё, что должна уметь хорошая система автоматизации: настраивать зависимость между шагами сборки, учитывать изменение или неизменность тех или иных исходных ресурсов (в моём случае это исходные файлы Ink), гибко модифицировать сборку по входным параметрам.

Но больше всего мне нравится то, что под капотом обычный код на Python. Таким образом? для заливки через FTP можно использовать стандартную питоновcкую библиотеку ftplib.

Список всех шагов сборки к концу проекта. Скалирование иконок под разные размеры экрана, конвертация музыки в разные форматы, обфускация кода, тесты и так далее
Список всех шагов сборки к концу проекта. Скалирование иконок под разные размеры экрана, конвертация музыки в разные форматы, обфускация кода, тесты и так далее

Полагаю, что любая другая система сборки тоже бы подошла. Главное, что она у нас просто была. И была с самого начала. Новые шаги сборки, вроде запуска юнит-тестов или проверки орфографии, оказалось добавлять в неё легко и естественно. Это сэкономило нам много времени и нервов.

Визуальный движок. Выбор Phaser

После многих экспериментов и отзывов от друзей мы убедились, что игра получается интересной. Тогда мы решили сделать для механики достойную обёртку, добавить графику и поучаствовать в Конкурсе русскоязычной интерактивной литературы (КРИЛ). Чтобы игру оценили независимые игроки, которые понимают и любят текстовые игры.

Во многих платформах для создания интерактивной литературы поддержка графики встроена, но возможности её ограничены: делай либо по готовым шаблонам, либо, если хочешь что-то необычное, залезай в исходники платформы.

Мне такой вариант не нравился. Пусть в нашем первом проекте мы не задумывали сложных графических эффектов, но в будущем, во втором, третьем, собирались сделать что-нибудь этакое. Поэтому я изначально рассматривал Ink как библиотеку для работы с текстом. Не более того.

Теперь настало время подключить её к удобному визуальному движку.

Я стал экспериментировать с Unity, собрал тестовую болванку с подключенным плагином Ink. Однако web-версия даже для такой болванки много весила (~15 mb) и долго загружалась (~20 cекунд без учета загрузки ресурсов с сервера). А в конкурсе мы хотели участвовать именно с web-версией.

Так выглядел наш прототип на Unity (c графикой из тестового проекта для Ink)
Так выглядел наш прототип на Unity (c графикой из тестового проекта для Ink)

При этом та же болванка с простейшим визуальным движком на JavaScript работала существенно быстрее и меньше весила. Но как быть с мобильной версией? Переписывать графику позже на Unity мне не хотелось. После изучения вопроса узнал, что HTML5-страничку можно превратить в мобильное или десктопное приложение с помощью фреймворка Apache Cordova.

Ещё как вариант движка под мобильные платформу рассматривал PyKivy на питоне. Рассчитывал, что портироваться с JavaScript на питон будет проще, чем на Unity со своей сложной инфраструктурой. Ну и писать игры на питоне — это давняя мечта, которую мне хотелось осуществить в собственном проекте.

В итоге для конкурсной версии решил остановиться на JavaScript-движке. Из большого списка возможных вариантов чуть ли не методом тыка выбрал Phaser. Методом тыка, потому что мне казалось это решение временным. Однако, как известно, нет ничего более постоянного, чем временное…

Далее простейший пример использования Phaser. Создаём картинку и выводим на экран, сколько раз игрок нажал на эту картинку:

// Настраиваем глобальные парамерты для Phaser (размер экрана, функции, которые срабатывают при загрузке) var game = new Phaser.Game(800, 600, Phaser.AUTO, 'phaser-example', { preload: preload, create: create }); var text; var counter = 0; function preload() { // Загрузка картинки game.load.image('demo', 'assets/demo.png'); } function create() { // Добавляем спрайт по центру экрана с загруженной картинкой var image = game.add.sprite(game.world.centerX, game.world.centerY, 'demo'); // Корректируем точки привязки для нашего спрайта (не правый верхний угол, как по умолчанию, а центр) image.anchor.set(0.5); // Теперь спрайт будет реагировать на нажатия мыши image.inputEnabled = true; // Подготавливаем контейнер для отображения текста text = game.add.text(150, 10, '', { fill: '#ffffff' }); // При нажатия на спрайт вызываем функцию on_click image.events.onInputDown.add(on_click, this); } function on_click() { // Увеличиваем счётчик нажатий и выводим этот счётчик на экране в виде текстовой строки counter++; text.text = "Вы нажали " + counter + " раз"; }

JavaScript мне пришлось изучать с нуля, но это оказалось не так сложно. Для моих целей хватило официальной документации Phaser и книги «JavaScript для детей».

Использовать Phaser просто, возможностей для небольшой игры достаточно. Из недостатков: нет инструментов для создания сложных элементов интерфейса, таких как группы кнопок, панели с прокруткой и так далее. Кроме того, пару раз натыкался на неприятные внутренние баги движка. В итоге выбором скорее доволен, но для следующих проектов буду изучать альтернативы.

Наш первый макет главного экрана. Отсюда брал первые болванки для экспериментов с Phaser
Наш первый макет главного экрана. Отсюда брал первые болванки для экспериментов с Phaser

В качестве итога этой части скажу: учиться на ходу — это нормально. Даже если ты ошибаешься, это расширяет кругозор. И учиться новому приятно.

Связь визуального и текстового движка. Transcrypt

Итак, у меня был текстовый прототип и визуальный движок. Теперь я хотел передать текст из Ink в Phaser и научиться настраивать внешний вид сцены, исходя из текста. Например, если я в исходном ink-файле пишу «[curtain_up]», то этот текст не должен показываться игроку, а вместо этого должен открываться занавес.

Пример исходного ink-файла с особыми командами и маркерами
Пример исходного ink-файла с особыми командами и маркерами

В примере весь текст в двойных квадратных скобках — это команды, которые нужно особым образом обработать. Выражение [[repeat_count_for_player]] должно замениться на число попыток изменить судьбу в игре; [[player_status]] — это особый статус, описывающий прогресс игрока; [[skippable]] означает, что для этого текста доступен режим быстрой перемотки; [[curtain_up]], [[play_music]], [[blink_light]] — различные визуальные и аудиоэффекты.

Вот, как всё это выглядит в динамике, в игре

Для связи текстового и визуального движков я решил сделать некий управляющий слой, который назвал GameLogic. На этом слое исходный текст обрабатывается, превращается в типизированные команды и отправляется в Phaser.

На этом же слое живёт вся логика высокого уровня, связанная с основным циклом игры. Например, системы сохранения и загрузки, системы статистики и так далее.

Естественным выбором языка программирования для слоя игровой логики был JavaScript — Phaser написан на JavaScript, Ink экспортируется в JavaScript и легко подключается через библиотеку ink.js. Однако я внезапно решил писать на Python.

Почему?

  • Хотелось максимально отделить слой игровой логики от остальных частей игры. Это правильно с точки зрения архитектуры приложения. Так, в теории, у меня появляется возможность переключиться с Phaser на PyKivy, заменив только функции для отрисовки и не переписывая логику игры.
  • Выделенная игровая логика означает, что на неё проще писать тесты. В свою очередь тесты помогают сохранять игровую логику независимой и могут отслеживать лишние обращения к визуальному движку.
  • На Python я пишу намного быстрее чем на JavaScript. Есть какой-никакой профессиональный опыт.
  • Просто я очень люблю Python и, повторюсь, всегда мечтал делать на нём игры.

Вопрос: возможно ли это технически? Ведь в итоге в браузере всё равно должна оказаться HTML-страница с JavaScript-кодом. Python браузеры не поддерживают.

Ответ: да. Есть несколько вариантов для автоматического преобразования Python-кода в JavaScript. Самым надежным решением показался Transcrypt, так как не берёт на себя слишком много. Фактически конвертируется только синтаксис без лишних хитрых преобразований и библиотек.

Далее код из примера использования Phaser, переписанный на Transcrypt:

class TranscryptGame: def __init__(self): # Пришлось занести всё в класс, т.к. в питоне в отличие от JavaScript нельзя обратиться к функциям preload и create до их описания self.game = __new__ (Phaser.Game(800, 600, Phaser.AUTO, 'phaser-example', { "preload": self.preload, "create": self.create })) self.text = 0 self.counter = 0 def preload(): self.game.load.image('demo', 'assets/demo.png') def create() : image = self.game.add.sprite(self.game.world.centerX, self.game.world.centerY, 'demo') image.anchor.set(0.5) image.inputEnabled = True self.text = self.game.add.text(150, 10, '', { "fill": '#ffffff' }) image.events.onInputDown.add(self.on_click, this) def on_click(): self.counter+=1 self.text.text = "Вы нажали " + self.counter + " раз" transcrypt_game = TranscryptGame()

Признаю, что решение это выглядит несколько странным, и в коммерческом проекте я бы в авантюру с «питоном» не ввязывался. Но мы в первую очередь делали игру для души. А душа — это штука такая, иррациональная…

И оно всё заработало. Пусть и со скрипом.

Например, я на практике узнал, почему в JavaScript не рекомендуют использовать цикл вида for...in для итерирования по списку объектов. Потому что на самом деле итерирование проходит не по элементам, а по определённым свойствам объекта. И так случайно совпадает, что у стандартного списка нет других свойств, кроме самих элементов. А вот при трансляции питоновского списка через Transcrypt на выходе получается объект с большим количеством самых разнообразных свойств (__len__, __repr__) и так далее.

При попытке пройтись по такому списку через for...in эффект получается очень неожиданным. Осложнялась ситуация тем, что цикл такого вида был описан во внешней библиотеке, которую я пытался подключить к игре. Потратил на эту загадку много времени.

В общем, друзья, не пишите такие циклы в своих проектах, если не знаете точно, что делаете. Можно испытать много боли на ровном месте. Используйте старые добрые индексы.

Итоговая архитектура игры в самом общем виде. Стрелочки обозначают зависимости между компонентами
Итоговая архитектура игры в самом общем виде. Стрелочки обозначают зависимости между компонентами

Тесты игровой логики

Как я уже писал, выделение игровой логики в отдельный независимый слой позволяет писать на эту логику юнит-тесты.

Приведу пример теста, который проверяет корректность работы менеджера локализации:

class LocalizationManagerTest(unittest.TestCase): def test_set_language(self): # Менеджер локализации принимает на вход табличку с локализованными строками manager = LocalizationManager({"Картофель": "Potato"}) # Переключаем язык на русский manager.set_language("RU") # В этом случае в игре у нас все тексты должны отображаться на русском assert_that(manager.get_text_for_view("Картофель"), equal_to("Картофель")) # Переключаем язык на английский manager.set_language("EN") # В этом случае в игре у нас все тексты должны отображаться на английском assert_that(manager.get_text_for_view("Картофель"), equal_to("Potato"))

Возникает вопрос: а зачем нужен такой тест? Понятно, что внутри LocalizationManager «живёт» простой словарь, и мы в зависимости от текущего языка либо не изменяем текст, пришедший из Ink, либо заменяем его на перевод. Казалось бы, где тут можно ошибиться?

Отвечаю.

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

Вот тест посложнее. Он проверяет взаимодействие между игровой логикой, историей и отображением выбора для пользователя. Написан по мотивам реальной ошибки:

class GameLogicTest(unittest.TestCase): def test_user_chooses_story_option(self): # Тестовые выборы, которые нужно совершить игроку в данный момент времени choice0 = mock_choice(index=0, text="Мигает свет") choice1 = mock_choice(index=1, text="Абель признаётся") # Создаём простую тестовую историю, состояющую из двух вариантов выбора story = mock_story() story.current_choices = Mock(return_value=[choice0, choice1]) # Симулируем визуальную часть игры # Графика нам неинтересна, нам нужно просто симулировать тот или иной выбор игрока view = mock_view() # Запуск игры game = GameLogic(view, story) game.start() # Проверка, что отрисовка запустилась view.show_choices.assert_called_once() choices = view.show_choices.call_args[0][0] # Проверка, что игроку был показан выбор из двух вариантов assert_that(choices, has_length(2)) assert_that(choices[0], has_property("text", "Мигает свет")) assert_that(choices[1], has_property("text", "Абель признаётся")) # Симулируем ситуацию, когда игрок выбрал первый из двух вариантов: "Мигает свет" choices[0].on_click() # Проверяем, что информация о выборе игрока дошла до текстового движка и история продолжилась story.choose_choice_index.assert_called_once_with(0) # Проверяем, что экран был очищен от вариантов выбора view.clear_choices.assert_called_once()

Я считаю юнит-тесты первыми и самыми надёжными союзниками программиста. Поначалу оценить их прелесть сложно, и кажется, что тратишь время зря, но чем ближе к концу разработки, тем больше от них выгоды. Они позволяют править код двухлетней давности без боязни, что вся игра необратимо сломается. По опыту «Вагонов» могу сказать, что юнит-тесты действительно свели на нет мелкие ошибки от невнимательности, c которыми я постоянно сталкивался в прошлых проектах.

Вот ссылка на замечательную статью инди-разработчика Ноэля Ллописа о применении юнит-тестов в геймдеве. Ого, сколько ей лет уже... После этой статьи я в своё время заболел TDD.

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

О них я расскажу в следующей части статьи. И ещё расскажу, как мы переводили игру на английский; как обфусцировали код; как превращали веб-страничку в полноценное мобильное приложение; как заливали игру в AppStore без собственного «Мака» (спойлер: это было сложно). Так что, надеюсь, мы с вами не прощаемся.

Опробовать «Вагон-вагон» можно через AppStore и Google Play. Следить за нами можно в VK, Facebook, Twitter, Instagram.

Спасибо за внимание!

6767
10 комментариев

Комментарий недоступен

11
Ответить

Комментарий недоступен

8
Ответить

Купил игру после статьи про отзыв, целую авторов в обе щечки за такую маленькую и прекрасную штуку

5
Ответить

Спасибо, очень приятно! Такие слова - лучшая мотивация двигаться дальше.

5
Ответить

Комментарий недоступен

4
Ответить

Не рассматривали RenPy? Я с ним не знаком, но там вроде и питон, и мультиплатформа.

3
Ответить

На тот момент, когда интересовался, там не было простого пути собрать веб-версию игры. Поэтому этот вариант отпал сразу.

Сейчас есть порт RenPyWeb, но насколько он рабочий, не знаю. Если бы разработку начинал сейчас, то обязательно поэкспериментировал бы)

3
Ответить