В процессе разработки некоторого количества достаточно сложных текстовых квестов, пришло понимание связанных с этим сложностей. Работа в графическом редакторе увлекательна, но крайне неудобна, по целому ряду причин. В ответ на эти проблемы, родилась идея текстового языка разметки, а уже в процессе его разработки появилась возможность сделать кое что неожиданное. Мне требовался вычислительный блок, для выполнения нетривиальных вычислений и система команд МК-61 показалась неплохим выбором. Ну а чтобы убедиться что всё работает, пришлось воссоздать “Лунолёт”. Внутри текстового квеста…

Напомню, о чём идёт речь. В 2002 году компанией Elemental Games, под руководством Дмитрия Гусарова, была разработана легендарная игра “Космические рейнджеры” (движок игры был разработан Алексеем Дубовым). Текстовые квесты были добавлены в качестве мини-игр, во многом, благодаря инициативе Дениса Фёдорова. Совместно с Дмитрием Гусаровым, а также Константином Савенковым и Алексеем Бондарчуком, им был разработан графический редактор текстовых квестов TGE, впоследствии выпущенный в свободный доступ. Далее, в работу над квестами включилось сообщество. Доработку квестов до рабочего состояния взял на себя Артём Пяткин.

Возможности квестового движка расширялись от версии к версии. Одним из ключевых моментов стала разработка нового формата qmm для игры “Космические Рейнджеры HD: Революция” 2012 года. Впоследствии, Василием Рогиным были разработаны Web-версии редактора и проигрывателя квестов, для работы с qm и qmm файлами. Этот инструментарий, гораздо более удобный по сравнению с TGE, позволил мне разработать несколько квестов:

Эта непростая работа позволила понять, что использование бинарных форматов (qm и qmm) практически исключает какое либо повторное использование кода. Кроме того, вот так, например, выглядит реализация “Охоты на кнашей” в Web-редакторе:

Знаете, каково тянуть каждую эту стрелочку вручную? Ладно-ладно, мне удалось немного схитрить, сгенерировав часть переходов программно, при помощи своего проекта для проигрывания квестов в Telegram-боте, но это всё равно, крайне трудоёмко.

Так родилась идея текстовой разметки. Как легко видеть, квест, в основном, состоит из описания локаций ‘site’ и переходов между ними ‘case’. Почему текстовое представление лучше? Попробую сформулировать.

  • При заливке двоичных файлов в git не видно, что именно изменилось (и даже если было бы видно, редактор может полностью поменять порядок следования локаций и переходов при очередном сохранении)

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

  • Для локаций также удобнее использовать строковые идентификаторы (в противном случае, приходится постоянно держать в голове какая локация за что отвечает)

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

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

Для чего может пригодится синус?

Райский уголок! Вернее, даже лучше - рисунок рельефа дна райского уголка! Море, дюны и отмели, приливы и отливы, охота за сокровищами, рыбалка и немного торговли, хождение под парусом… Есть идея сделать из всего этого квест. Но, разумеется, вполне понятно, что даже для рельефа дна никакого набора параметров не хватит. И тут на помощь приходит процедурная генерация. Вот что на эту тему предложил DeepSeek:

import numpy as np

def sea_floor(x, y):
    # Параметры для различных элементов рельефа
    island_params = [
        (2.0, 0.0, 0.0, 0.8),   # (высота, центр_x, центр y, радиус)
        (1.8, 3.0, 2.0, 0.6),
        (1.5, -2.0, -3.0, 0.7),
        (2.2, -4.0, 1.5, 0.9),
        (1.7, 2.5, -2.5, 0.5)
    ]
    
    shallow_params = [
        (-0.4, 1.0, 1.0, 1.5),
        (-0.3, -2.0, 2.0, 1.2)
    ]
    
    deep_params = [
        (-2.5, 0.0, -2.0, 1.8),
        (-3.0, 3.0, -1.0, 2.0)
    ]
    
    # Базовый рельеф (общая форма дна)
    base = -1.0 * np.exp(-0.1 * (x**2 + y**2))
    
    # Добавляем острова (гауссианы)
    islands = 0
    for height, cx, cy, radius in island_params:
        islands += height * np.exp(-((x - cx)**2 + (y - cy)**2) / (2 * radius**2))
    
    # Добавляем мелководье (более пологие гауссианы)
    shallows = 0
    for depth, cx, cy, radius in shallow_params:
        shallows += depth * np.exp(-((x - cx)**2 + (y - cy)**2) / (2 * (radius*1.5)**2))
    
    # Добавляем глубины (синусоидальные впадины)
    depths = 0
    for depth, cx, cy, radius in deep_params:
        r = np.sqrt((x - cx)**2 + (y - cy)**2)
        depths += depth * np.sin(0.8 * r) * np.exp(-0.3 * r)
    
    # Добавляем периодические структуры (рифы, волны)
    periodic = 0.2 * np.sin(1.5*x) * np.cos(1.2*y) * np.exp(-0.05*(x**2 + y**2))
    
    # Собираем всё вместе
    terrain = base + islands + shallows + depths + periodic
    
    return terrain

Здесь нам и синус и косинус и даже экспоненты (которые ещё, кстати, надо сделать).

Как быть с вещественными числами? Поскольку в QM их нет, придётся обходиться рациональными.

#var:rXn #range:-100000000..100000000 #default:0
#var:rXd #range:1..100000000 #default:1

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

Вот как выглядит алгоритм Евклида на QMS
#site:gcd

#case:gcd_1 #priority:1 {$rXn>0}
$rA=$rXn
$rB=$rXd
#case:gcd_1 #priority:1 {$rXn<0}
$rA=-$rXn
$rB=$rXd

#site:gcd_1
#case:gcd_3 #priority:100 {$rA>10 and $rB>10000 and $rB>$rA}
$rB=$rA
#case:gcd_3 #priority:100 {$rA>10000 and $rB>10 and $rA>\$rB}
$rA=$rB
#case:gcd_2 {$rA<$rB and $rA>0} #priority:1
$rB=$rB mod $rA
#case:gcd_2 {$rA>$rB and $rB>0} #priority:1
$rA=$rA mod $rB
#case:gcd_3 {$rA=$rB and $rA>0} #priority:10
#case:gcd_3 {$rA=0} #priority:10
$rA=$rB
#case:gcd_3 {$rB=0} #priority:10
$rB=$rA

#site:gcd_2
#case:gcd_1

#site:gcd_3
#return

Обратите внимание на #return в самом конце. Поиск НОД - это подпрограмма, которая вызывается следующим образом:

#case:gcd #return:sqrt_4

Здесь #return:sqrt_4 говорит о том, к какой локации необходимо вернуться по завершении вычислений. Такой приём можно использовать и в графическом редакторе, но там он подразумевает большое количество ручной работы. Так QMS делает мою жизнь лучше. Кстати, формулы в фигурных скобках переходов ограничивают возможность перехода указанным условием.

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

Помимо трансцендентных функций, хотелось бы иметь какую-то систему команд, ну и, разумеется, четыре арифметических действия уже над рациональными числами. Система команд ПМК (например, МК-61) выглядит вполне подходящей. Почему? Да просто потому что у меня был такой в детстве (и мне не придётся привыкать к чему-то другому)!

Для тех кто не застал это маленькое инженерное чудо, могу порекомендовать обзор на Хабре. Субкультура ПМК вполне может посоревноваться с сообществом “Космических рейнджеров”. Для этих калькуляторов написаны тысячи вычислительных и сотни игровых программ. В журналах “Техника молодёжи” и “Наука и жизнь” велись рубрики, посвященные их программированию. Один из таких циклов статей привёл меня в программирование.

В августе 1985 года, “Кон-Тики” стартовал к Земле. Историю, в журнале “Техника Молодёжи”, записал российский фантаст Михаил Пухов. Консультантом раздела выступал Герой Советского Союза лётчик-космонавт СССР Ю. Н. Глазков. Статьи проиллюстрированы великолепными рисунками Евгения Катышева.

Разумеется, все эти статьи в “Технике Молодёжи” повлияли на разработку текстовых квестов. Квест “Глубина” может служить хорошим тому примером. Но одно дело манёвры батискафа в глубине океана и совсем другое - полёты вблизи безатмосферного тела на реактивном двигателе. Вычислительные возможности QM не позволяли реализовать столь сложную физику. Попробуем это исправить. Вот здесь моя версия игры “Лунолёт 2” (с текстовой составляющей и иллюстрациями из журнала “Техника молодёжи”).

Не обошлось без макросов

Вот так выглядит код ПМК, взятый из журнала:

#C6A  // 00 ИПА
#C5C  // 01 Fx<0
#A:14 // 02 14
#N:2  // 03 2
#C12  // 04 x
#C53  // 05 ПП
#A:82 // 06 82
#C22  // 07 x^2
#C10  // 08 +
#C21  // 09 sqrt
#C6B  // 10 ИПB
#C11  // 11 -
#C51  // 12 БП
#A:78 // 13 78
…
#C0F  // 91 FBx
#C10  // 92 +
#N:2  // 93 2
#C13  // 94 /
#C62  // 95 ИП2
#C12  // 96 x
#C52  // 97 B/O

Разумеется, в самом QMS таких операторов нет. Всё это вызовы макросов. Вот так, например, выглядит макрос, реализующий команду деления:

#macro:C13 // /
  #site:[$S][$NN]
    #eOff
    #save
    #case:ERROR {$rXn=0} #priority:10
    #case:[$S][$NN]_1 {$rXn<0} #priority:10
    $rXn=-$rXn
    $rYn=-$rYn
    #case:[$S][$NN]_1 {$rXn>0} #priority:1
  #site:[$S][$NN]_1
    #dn
    $rXn=$rXd*$rYn
    $rXd=$rXn*$rYd
    #case:gcd #return:[$S][$NN]_2 {$rXd<>0} #priority:1
  #site:[$S][$NN]_2
    #case:[$S][$NN]_4 {$rA<=1} #priority:1
    #case:[$S][$NN]_3 {$rA>1} #priority:10
  #site:[$S][$NN]_3
    $rXn=$rXn div $rA
    $rXd=$rXd div $rA
    #case:[$S][$NN]_4
  #site:[$S][$NN]_4
    #case:[$S][$NN+1]
#end:NN

Внутри него можно видеть вызовы других макросов. Например, макрос ‘dn’ опускает значения операционных регистров X, Y, Z и T вниз по стеку:

#macro:dn
    $rZn=$rTn
    $rZd=$rTd
    $rYn=$rZn
    $rYd=$rZd
#end

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

После применения компилятора, исходный код превратился в следующее:

Как всё это отлаживать?

Через боль. Прежде всего, команда ‘compatible:debug’ позволяет пометить все пустые узлы (на картинке они окрашены в зелёный), сохранив в них имена локаций, взятых из qms-исходника. Затем, пользуясь возможностями графического редактора, можно разместить узлы локаций более удобным для отладки образом, зафиксировав их, чтобы не пришлось повторять это утомительное действие после каждой компиляции.

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

Стало удобнее. Теперь можно отлаживаться пошагово, временно изменяя тип узлов с “пустого” на “промежуточный” (это создаёт своего рода брекпойнт в плейере QM). Для проверки корректности значений регистров можно использовать эмулятор ПМК в режиме пошагового выполнения.

Осталось решить ещё одну задачу. Все эти рациональные числа, с которыми мы работаем, надо уметь отображать на экране. Здесь на помощь вновь приходят макросы:

#macro:r:NM:DC
{$r[$NM]n div $r[$NM]d}<clr>.<clrEnd>{((($r[$NM]n*[$DC]) div $r[$NM]d) mod [$DC])*(1-2*($r[$NM]n<0))}
#end

Довольно хитрая конструкция, которая в QM превращается в следующее:

{[p5] div [p6]}<clr>.<clrEnd>{((([p5]*100) div [p6]) mod 100)*(1-2*([p5]<0))}

Разумеется, имя регистра ПМК (отображающееся в пару параметров QM) и масштабирующий коэффициент передаются через параметры макроса ‘NM’ и ‘DC’ и вычисляются квадратными скобками в процессе раскрытия макроса. Просто отображаем целую и дробную части рационального числа, не забыв подавить знак в дробной части для отрицательных чисел ‘1-2*([p5]<0)’. Использование макроса выглядит следующим образом:

#macro:aim_1
#page:1
^ Высота: #r:X м^
^ Запас топлива: #r:Y кг^
^ Вертикальная скорость: #r:B м/с^
^ -----------------------------^
^ Расход топлива: $fuel кг^
^ Время: $time с^
^ @angl^
#end

Здесь второй параметр в макрос не передаётся, поскольку по умолчанию берётся из глобального значения:

#global:DC:100

В результате, при выполнении видим следующее:

Всё, теперь можно играть (В Telegram можно играть тоже).

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