Текстовые квесты… на Telegram

hjaurkrxvjgxhdoj1-nf9mxgyxy.pngДавным-давно, около полугода назад, мне в голову пришла интересная идея:, а не запустить ли текстовые квесты из «Космических рейнджеров» под управлением Telegram-бота? Скажу сразу, что как и всё с упоминанием «Dagaz» в заголовке, проект полностью бесплатный, с открытыми исходными кодами и MIT-лицензией. Если вы неравнодушны к теме, всё ещё помните неповторимую атмосферу легендарной игры или просто любите играть в текстовые квесты, просто кликните по картинке в начале этой статьи и перейдите в уютный Telegram. В том же случае, если вам как и мне гораздо более интересны технические подробности, добро пожаловать в мою статью…
Прежде всего, хочу поблагодарить Василия Рогина за превосходную реинкарнацию квестов «Космических рейнджеров». Исходные тексты его проекта также доступны по MIT-лицензии и сэкономили мне немало сил и времени.

Историческое отступление
Хотя текстовые квесты вносят ощутимый вклад в атмосферу игры, они стоят несколько особняком от основного игрового процесса. Наравне с RTS-ми планетарными боями роботов и аркадными пострелушками внутри чёрных дыр, текстовые квесты представляют из себя своего рода «игру внутри игры». Для их разработки, разработчики Elemental Games создали специальный графический редактор TGE, впоследствии «ушедший в народ». При помощи этого инструмента, создавать, редактировать и запускать квесты может любой желающий. Выглядит этот процесс примерно так:
tpaxk8fusogympd8t0c-9peljkm.png

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

TGE позволял создавать квесты, но был разработан довольно давно, содержит некоторое количество неприятных багов и продолжительное время не поддерживается. Именно здесь в нашей истории появляется Василий Рогин, выпустивший (помимо web-плейера) фактически новую версию графического редактора квестов.

hqrtw7bbmaeiykcyynrlluvqfge.png

Это выглядит гораздо современнее, содержит меньше багов и, что самое главное, значительно удобнее в работе. Кроме того, Василий увеличил некоторые лимиты (например, на максимальное количество параметров) внутри квестов и привязал медиа-элементы к сценам и переходам (раньше это приходилось делать отдельной программой), фактически создав новую версию qm-файлов (которая теперь называются qmm). Вот что пишет сам Василий о проделанной работе.
czzuc9e0uwxh9zxpnxlpdrwlvmu.png

Кстати, вот так выглядит в web-плейере сам игровой процесс.


На Хабре уже былистатьи, посвящённые разработке Telegram-ботов, поэтому на эту тему повторяться не буду. Исходники здесь. Запускается всё в Node.js. В качестве базы данных используется PostgreSQL.

Больше подробностей
zygwcnisl3s3tzuaap-1qzqplsq.png

Понимаю, что это может напугать, но вкратце расскажу про основные таблички. В users будут попадать все пользователи. Наиболее важные поля: user_id — идентификатор пользователя в Telegram, chat_id — туда будем слать сообщения, lang — язык (определяется автоматически, но потом можно поменять).

Сервис (приложение) может поддерживать сразу несколько Telegram-ботов, токены которых будем хранить в service.token (сам токен для бота можно получить у BotFather). Факт подключения пользователя к боту будет сохраняться в таблице user_service, поле is_developer которой будет включать некоторые полезные дополнительные команды.

Таблички command и action хранят сценарии. Это достаточно гибкий механизм для произвольного ввода/вывода, вызова хранимых процедур, REST-сервисов, а также запуска квестов. Сами квесты хранятся в script и привязаны к service. Язык квеста хранится в lang, благодаря чему не русскоязычные пользователи видят квесты на английском языке (которых правда сильно меньше):

mjbyarhlcza3s7dueohnjxoxt5w.png

Важной табличкой является user_context, в которой сохраняются контексты выполнения как квестов, так и обычных команд старого стиля. К ней привязывается param_value, в которой сохраняются значения параметров (благодаря этому квест может быть продолжен даже после перезагрузки сервиса). В свою очередь, global_value сохраняет значения между запусками команд и квестов (там хранится статистика запусков, побед и поражений, а также кредиты пользователей, используемые в некоторых квестах).


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

miacwdqnjcs4skn9scrgwtevaeo.png


Попробуем разобраться, что здесь произошло:

  1. Администратор написал в чат сообщение «Вопрос»
  2. Поскольку администратор является пользователем с подходящей языковой настройкой, он получает продублированное сообщение…
  3. И может на него ответить средствами Telegram
  4. В результате чего получает адресный ответ на своё самое первое сообщение
Как всё это работает?
Прежде всего, любое сообщение в чат (если это не команда) попадает сюда. Далее, в зависимости от того администратор это или обычный пользователь, используются две различные схемы для пересылки сообщения. Разумеется, это будут уже не те же самые сообщения и для сохранения их идентификаторов требуется отдельная таблица:
fyhjxu5odk8_cude0mftprahuo0.png

Теперь, если установлено поле reply_to_message, мы можем найти исходное сообщение и ответить на него.


Посмотрим как выполняется запуск квеста. Можно выполнить команду »/start» с указанием идентификатора квеста, передав её в чат или просто перейдя по ссылке.

llfev8uv1mdwhpn77-vubayicd4.png


Что происходит?
Прежде всего, создаём или обновляем параметры аккаунта и однократно выводим текст приветствия в чате, после чего переходим к обработке команды. Команда /start прописана в табличке command и содержит всего одно действие (action) — запуск квеста. При вводе команды, мы принимаем параметры командной строки, а затем создаём и запускаем контекст выполнения (в рамках которого создаются и изменяются параметры).

Далее, последовательно выполняются все действия, составляющие команду. После того как все действия выполнены, контекст удаляется в setNextAction. Рассмотрим действие запуска квеста более подробно:

  1. Получаем id квеста из параметра командной строки
  2. Читаем описание скрипта из таблицы script (здесь нам требуется имя файла)
  3. Далее загружаем файл из каталога upload (разбирая его в процессе)
  4. Здесь же инициализируем параметры (и впервые сталкиваемся с формулами)
  5. Закрываем предыдущие контексты квестов если они есть
  6. И создаём новый контекст квеста, добавляя его в хэш (теперь квест может быть продолжен даже после перезагрузки сервиса)
  7. Некоторые квесты используют специальный параметр, помеченный флагом «Деньги игрока». Читаем значение этого параметра из global_value, используя привязки
  8. Выводим текст приветствия квеста, если он есть
  9. И наконец собираем первый диалог квеста для передачи его пользователю

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

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


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

kgzgzmnxbdlflqgps1jght4lyl0.png


Вы обратили внимание что образец и ключ расположены вертикально?
Сценарий квеста пришлось отредактировать, поскольку горизонтальная расстановка не влезала в Telegram по ширине.
xhyfzx2rpixafx66e2y9fwiqnt0.png

Две локации по восемь вариантов в каждой. И это скорее правило чем исключение. Телеграм имеет свою специфику (например, не любит длинные тексты в переходах от одной локации к другой). Практически все квесты приходится редактировать, если только они не создавались под Telegram изначально.


Меню переключения языка сделано чуть сложнее. Прежде всего, эта команда отображается в меню (для этого она прописана в таблице command с установленным флагом is_visible).

rapfqpulclx54sfnecyydpmqeng.png


В результате ввода команды открывается меню:

79mqwvtfo8cmyijcpuwtxt77nce.png


Что внутри?
В отличии от /start, эта команда состоит из нескольких действий (таблица action):
hvlpbtf41elkjew1ocg7qwxqhzy.png

Можно заметить что это дерево. Дочерние узлы скрипта привязаны к родительским через parent_id. Выполняемые узлом действия определяются значением type_id и обрабатываются здесь.

Значение type_id=5 определяет меню (в поле width можно указать ширину, то есть количество пунктов размещаемых на одной строке). Заголовок берётся из таблицы localized_string (обратите внимание, что он привязан к языку пользователя). Аналогичным образом из дочерних узлов собираются наименования пунктов меню. В качестве идентификаторов берутся id дочерних узлов и меню передаётся пользователю.

Тип дочерних узлов type_id=1 — это просто папка содержащая другие действия. Ищем в глубину первую не папку и добираемся до type_id=6 — вызова хранимки setLang. Здесь всё просто: меняем значение поля lang в табличке users. Параметры вызова описываются табличкой request_param.

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


Но главная команда этого бота конечно другая: /quest выводит список подготовленных для загрузки квестов и обеспечивает их запуск. При этом анализируется язык пользователя. Фокус здесь в том, что эту команду можно выполнять как без параметров, так и указав имя квеста:

mb_2j0ukzyhdb7rrvum_8yisqmy.png


Подробности
Как и в случае с командой /start, параметр команды описан в таблице command_param, но если в /start передавался числовой идентификатор (это удобно при вызове квеста через URL), то в /quest передаётся строковое значение, которое может содержать имя файла либо локализованное или не локализованное имя квеста. Введённое значение передаётся в хранимку getQuests. Результат поиска передаётся в параметр menu, описанный в таблице response_param.
r34glfpeini66czmzk3uhstrt2c.png

Если найдено более одного квеста (например при вызове команды без параметров), в result_code помещается значение 1 и обработчик вызова хранимой процедуры переключает управление на дочерний узел, выводящий меню выбора квеста.
dba3uxwi3d94yiqgd6vbel-hpeo.png

В противном случае (если дочерний узел не найден), setNextAction идёт дальше и мы попадаем на узел запуска текстового квеста (такой же, как в обработчике команды /start). Параметр 5, на этот момент, уже содержит найденный ранее id квеста.


Как уже упоминалось выше, отображение квестов в Telegram имеет свою специфику. Например, вряд ли имеет смысл выводить картинки в каждой локации и переходе (хотя благодаря Василию Рогину такая возможность и имеется). В некоторых случаях обойтись без картинок никак нельзя (как в квесте «Дальнобойщик»), но это скорее исключение чем правило.

wjzx8z1tnuwnzkcn03j1s1q-ohm.png


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

В некоторых случаях, текст этих диалогов важен и его полезно иметь перед глазами в дальнейшем. По этой причине, после удаления, текст диалога обычной локации выводится снова, уже без меню. Но это ещё одно сообщение, которое может спровоцировать срабатывание антиспам-фильтра! Чтобы таких сообщений было меньше, тип промежуточных узлов квеста следует заменять на «пустой» (диалоги всё равно будут выводиться, но не будут дублироваться после удаления).

Если антиспам всё таки сработал, может получиться так, что очередной диалог квеста, который сервис бота будет считать уже отосланным, на самом деле не попадёт в ленту чата (и пользователь не сможет продолжить квест). Чтобы такого не происходило, все подобные отсылки оборачиваются в функцию send, фиксирующую факт этой отправки, а также выполняемые после неё некоторые действия. Если идентификатор переданного сообщения не был получен, сообщение будет продублировано по расписанию фоновым процессом.


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

n4taisgg7uiqg-yesvpy8k8cfx0.png


Делается это для того чтобы избежать конфликта имён файлов, загруженных разными пользователями. Исходные наименования загружаемых изображений сохраняются в привязке к пользователям и могут быть использованы в загружаемых ими квестах. Что касается самих загружаемых квестов, в общем доступе (до согласования с администратором) они не появляются, но могут быть загружены их владельцами в режиме отладки.

Опции разработчика
В тот момент когда пользователь загружает в чат свой первый сценарий квеста, он становится разработчиком (устанавливается флаг is_developer в таблице user_service). В результате, ему становится доступно несколько дополнительных полезных команд:
  • /load file_N.qmm — Загрузка сценария квеста аналогична команде /start, но вместо id квеста указывается имя загруженного файла сценария (после переименования). В отличии от команд /start и /quest, команда /load не создаёт контекст выполнения квеста в базе данных и, как следствие, не изменяет значений глобальных счётчиков пользователя (статистики) в результате выполнения квеста (подкрутить таким образом кредиты тоже не получится).
  • /show id — Отображает в чате идентификатор текущей локации квеста.
  • /set pN V — Присваивает заданное значение параметру квеста. Например, если кредиты внутри квеста сохраняются в параметре p1, улучшить благосостояние поможет команда »/set p1 10000».
  • /calc formula — Очень полезная команда, вычисляющая значение формул квеста. Текущие значения параметров учитываются, поэтому для просмотра значения параметра p1, например, достаточно выполнить команду »/calc [p1]», а формулы из редактора квестов можно просто копипастить в чат.


Помимо этого, есть ещё одна возможность, доступная всем пользователям. Команда /save может быть полезна для прохождения сложных квестов.

gvglwj7fguzwx8oee_e5zt12twa.png


Сохраняем qms-файл себе на диск, а потом, если квест пошёл как-то не так, кидаем его обратно в чат:

pwvtl8dpi3fssjc8ot_dflqbuyi.png


Важный нюанс заключается в том, что загруженный таким образом квест не влияет на статистику пользователя (очки и кредиты таким образом накрутить невозможно).

Мой личный рейтинг квестов
Не все квесты одинаково удачны и интересны. Хочу порекомендовать вашему вниманию несколько, понравившихся мне особенно:

vbag4cjlpc0lhzpj8snm--k1evs.pngПроПролог — Невероятно атмосферный квест воссоздающий атмосферу «Космических рейнджеров». Здесь есть и борьба с ботами и сражения с пиратами и даже планетарные квесты. Проходится не просто и далеко не с первого раза. Обещаю массу положительных эмоций.

lhvwfnzuewrzx4smkxsxcphup58.pngГлавный редактор — Также очень атмосферный квест, посвящённый непростой жизни главного редактора компьютерного журнала в глубоко тоталитарном обществе. Квест очень объёмный, но при этом обильно приправленный юмором. Верстайте журнал, распределяйте тираж и, самое главное, постарайтесь не упасть в глазах совета цензоров!

5s6lkrf6ixdcf7klnotspxjuuaa.pngЗлой гений — Если вы чувствуете себя достаточно гадким, примерьте роль планетарного диктатора. Личная армия миньонов, регулярные рейды правительственных войск, а также визиты наёмных убийц обильно украсят ваше существование.

2qg4ze6gcgqhpi_ke93lar6pbrq.pngМастер Иике-Бааны — Пожалуй, самый сложный квест в «Космических рейнджерах». Это практически полноценная ролевая игра с оригинальной магической системой. Убивайте монстров и сразитесь с верховным магом. Только таким образом вы сможете вызволить господина Дуу-Рака из пучин виртуального мира.

5wf1b425duxjwtnbekcyociisv0.pngЛыжный курорт — отличный экономический симулятор. Имеется англоязычная версия. Развивайте инфраструктуру и постарайтесь не прогореть. Космолинии — альтернатива на случай если космос нравится вам больше чем лыжи. Тоже с английской версией.

rib3voyhwhmpzf40ebvimqpailm.pngДальнобойщик — Выкупаем задолжавшего бандитам Ролана, зарабатывая на жизнь извозом на грузовичке. Не забываем исследовать местность, грузовичок можно модернизировать! Подвозим попутчиков. Периодически сталкиваемся с бандитами и следим за показателями здоровья! Английская версия в наличии.

5hz80f7fndbsqovajhcq7j6blnu.pngКолонизация — Для поклонников игры «Цивилизация». Я не шучу, это полноценная стратегическая игра! Стройте здания, завоёвывайте варваров и готовьтесь к прилёту комиссии (космопорт вам придётся построить самостоятельно).

bc0y7-ojjc5mfmf9df7jqv19rcs.pngЦитадели — Ещё одна стратежка (но эта больше похожа на «Magic: The Gathering»). Собираем колоду, строим базу, собираем ресурсы (энергия, металл, электроника), ломаем базу противника. Если хочется посоревноваться в чём-нибудь попроще, то к вашим услугам «Роботы» (для которых имеется английская версия).

9mknzurlc1usptgx8gqwwtppmui.pngГайднет — Для тех кто хочет почувствовать себя хакером. Вообще, это такой динамический лабиринт, но сделан очень атмосферно. Как раз тот случай, когда легче запустить и посмотреть самому чем пытаться всё это объяснить.

Разумеется, это далеко не всё. Есть куча мелких квестов, в которых можно поиграть в сложную малокскую игру (есть на английском), очень маленькую манкалу, попытаться вскрыть сейф из «Братьев Пилотов», поперекладывать «Ханойские башни» и даже поиграть в домино. И разумеется, квесты будут ещё добавляться.

Кстати, запустить Ханойские башни у Василия Рогина не удастся
dqxvyjvj6xpicgjq9wv3xtcqagm.png

Тег nobr в его реализации qmm-файлов не поддерживается. Он был добавлен для упрощения кодирования отображения параметров и убирает следующий перевод строки, склеивая строки. В результате получается следующее:
ns7ysa6eytgbbctuinmeszgtg2u.png



Что дальше? Текстовые квесты «Космических рейнджеров» в Telegram — это уже прекрасно, но что если дать возможность играть в Цитадели или в тех же Роботов не против квестов, а друг с другом? Организовать, своего рода, квестовую арену? Разумеется, сценарии придётся полностью переделывать. Что касается самого движка…

На мой взгляд, достаточно всего двух доработок
Вот здесь, реализован прототип простейшей соревновательной игры для двух человек. Это расширенная версия игры «Камень, Ножницы, Бумага» из «Теории большого взрыва». Можно заметить обработку двух переходов специального вида.
watzf3xi0cwqwqwhycnvkfhypcw.png

Переход будет выполняться автоматически, как и при отсутствии заголовка. Попутно (при переходе через ! session) будет производиться подключение пользователя к сессии.
wzvb5hm3fd3eerkhwwx-xsdzb_s.png

Первый пользователь, прошедший через ! session, создаст сессию (в таблице session), а следующий к ней подключится (user_session). В сессии могут участвовать и более двух игроков (как например, в игре Перудо). Обратите внимание, что к одной записи session_type могут быть подключены несколько скриптов. Это позволит соревноваться игрокам с различными языковыми настройками.

Минимальное и максимальное количество игроков задаётся в session_type. Там же определяются параметры, через которые будет осуществляться взаимодействие. Прежде всего, это start_param, определяющий номер параметра, с которого будут начинаться блоки синхронизации. Поле param_count будет задавать размер блока (количество параметров передаваемых каждым пользователем). Нулевой блок предлагается использовать для задания исходящих значений параметров.

7wmnupa2vij0rw-rfqgwfel6f90.png

Здесь начинается магия. Прежде всего, это единственное место, в котором игроки ждут друг друга (в ! session также может указываться таймаут на подключение в миллисекундах, но там ожидание производиться не будет). В первый раз выполнив ! wait игрок будет ожидать наполнения сессии (в соответствии с настройками) в течении суммарного таймаута обеих команд.

Если наполнения сессии дождаться не удастся — ничего страшного, ! wait сбросит параметр индекса игрока (заданный в session_type.index_param). В нашем случае, это параметр p1. После чего, каждый игрок продолжит игру независимо, против бота:

ltke2kbxcpepp1036jw3qiyrnny.png

Что произойдёт если ожидание всех игроков завершится удачно? Перед выполнением ! wait, каждый из игроков заполнит исходящие параметры в нулевом блоке. Задача ! wait — переложить параметры каждого игрока в соответствующий по счёту блок синхронизации, после чего «пробудить» всех игроков, продолжив квест. Для дополнительного удобства, можно определить строковые подстановки , и т.д., заменяя их именами соответствующих игроков.
a-mxpxekzjp8up3gaqiotq-t5as.png


В общем, как концепт, это вроде работает, но работы по отладке предстоит ещё очень много (и отлаживаться с двух сессий в Телеграмме до чёртиков неудобно). Таймауты пока не реализованы и с партнёром надо предварительно договариваться, чтобы он присоединился между «Конечно, я приму участие в игре» и самым первым ходом (Камень, Ножницы, Бумага, Ящерица или Спок). В противном случае, противником будет назначен бот.

qmy_2h0xvwr8rvcgk9fwmr0-aks.png

Такой вот задел на будущее…

© Habrahabr.ru