Dagaz: История с персистентностью

4isck4toxbws20hsk3nrqghdkoy.pngЛюбая достаточно развитая технология неотличима от магии.
Артур Кларк
 
— Я больше не хочу быть сравнением… Сделайте меня метафорой.
Чайна Мьевиль

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

? setup=0c3;0a2;0c2;0d2;1a1;4b1;5c1;1d1;-1a4;4b4;5c4;1d4;0a3;0d3;0b2;;&turn=0


Затем, я могу добавить эти параметры в url, чтобы воспроизвести позицию. Этим и занимается common-setup. Модуль сохраняет описание всех промежуточных позиций в лог и даёт возможность воспроизвести их. Это не очень совершенный механизм. Например, такое описание ничего нам не даёт, если мы не знаем к какой игре оно относится. Имеются и другие недостатки, о которых я скажу позже. Сейчас, пришло время познакомиться со следующим участником нашего рассказа.

7_i1dzv7lp6sajcawqmldwx4hma.png


Опция progressive-levels пришла из Zillions of Games и я долгое время не понимал, зачем она нужна. Попробуйте погасить все лампочки (это просто) и вам станет понятно о чём я говорю. После победы игрока, progressive-levels просматривает текущий url, и если находит в нём число, увеличивает его на единичку. На самом деле, это замечательное средство вовлечения игрока, но этим его возможности не ограничиваются. Есть игры, состоящие из нескольких раундов. Закончив один, игроки, по определённым правилам, перераспределяют фигуры и начинают играть снова.

iybiff0rupuwcijge_z8b0fimiw.png


Такой подход очень часто практикуется в африканских манкалах. В Овалху, например, игра не просто начинается снова. Игроки используют захваченные в предыдущем раунде зёрна для первоначального наполнения своих лунок. Лунки, заполнить которые не удалось, считаются выбывшими из игры, использовать их нельзя. Фактически, в каждом раунде играется новая игра, на новой доске. Только когда проигравший в очередном раунде игрок не может заполнить хотя бы одну лунку, признаётся его окончательное поражение.

Эту возможность можно понимать гораздо шире
o_ajlpvgnzcglfojk3vd-rhwozy.png

Для решения этой головоломки, квадратную плашку требуется последовательно провести через все четыре угла игрового поля. Это хороший пример игры разделённой на раунды и для common-setup здесь также найдётся работа. Дело в том, что при переходе от одного раунда к другому, необходимо сохранять взаимное расположение частей, чтобы создать иллюзию непрерывного решения. Можно пойти ещё дальше, сделав сами переходы между раундами менее заметными.
gkhfyel6ybgkumfd5x_foxluhny.png

Просто перенесите отмеченные тайлы и посмотрите, что произойдёт. Подобные «бездосковые» игры большая редкость, но сама концепция может создать определённые сложности. Строго говоря, в «Magyar Dama» такая возможность не особенно необходима (надо постараться, чтобы доске действительно потребовалось «ползать»), но в Hive, например, она была бы очень востребована. При переходе от одного раунда к другому, можно не просто запоминать расположение фигур, но и изменять его, перемещая все фигуры, например, ближе к центру рабочей области.
ss0suora_d8v2vavrw7kz8wehlw.png

Концепция многораундной игры может быть полезна при разработке разнообразных рогаликов (иллюстрация выше показывает возможность одновременного перемещения фигур, но это лишь один элемент из множества важных составных частей roguelike-игр). Переход от одного раунда к другому может выполнять генерацию «комнат», на основании seed, используемого для генерации мира. Конечно, здесь также будет полезна возможность завершения раунда игры с различным результатом (чтобы различать выход из комнаты с различных сторон). Сейчас возможных итогов всего три (победа, проигрыш и ничья), но существуют игры в которых их должно быть больше.
ltea11ctmcw6ymezanbr_ygakfi.png

Я пока не сделал этого, но по правилам, Ритмомахию можно завершать различным способом. И эта игра не исключение. В бурятском «Шатар», например, итог игры существенно различается, в зависимости от того, каким из способов был заматован король.


Следующий шаг был сделан практически случайно. В моих играх есть звук. Он мне очень нравится, но наверняка на свете есть люди, которых он раздражает. Именно для них была предусмотрена возможность его отключения. Казалось бы, это работало неплохо, но настройки никуда не сохранялись и после каждого обновления страницы звук приходилось отключать снова (и это раздражало ещё больше). Меня попросили как-то исправить эту проблему и первым пришедшим мне в голову решением были cookie (первое решение не всегда бывает удачным).

Вот как это выглядело
Dagaz.Controller.sound = function() {
    if (Dagaz.Controller.soundOff) {
        sound.innerHTML = "no Sound";
        Dagaz.Controller.soundOff = false;
        document.cookie = "dagaz.sound=on";
    } else {
        sound.innerHTML = "Sound";
        Dagaz.Controller.soundOff = true;
        document.cookie = "dagaz.sound=off";
    }
}


Мне не хотелось мусорить, поэтому cookie были короткоживущими, до закрытия окна браузера. А потом я подумал: «а почему бы не научить common-setup сохраняться в cookie тоже»? Пришлось устанавливать параметр max-age, но это того стоило. Во первых, состояние игры теперь сохранялось при случайном закрытии окна. В качестве бонуса, переключение режима игры с ботом «AI/no AI» также не сбрасывало игру. Это было здорово!

Конечно, пришлось предусмотреть ещё кое что
Во первых, любое завершение игры должно было сбрасывать сохранение, в противном случае, последний ход повторялся бы снова и снова, до момента устаревания cookie (и это вряд ли кого нибудь бы порадовало). Для досрочного завершения игры я добавил ссылку «New game», очищающую сохранение и обновляющую страницу. Но это была меньшая из проблем. Помните я говорил, что результат работы common-setup мало полезен в отрыве от информации о том, к какой игре он относится? А cookie для сохранения был общим для всех игр (опять же, чтобы не мусорить лишнего).
Пришлось делать что-то в таком вот роде
var getName = function() {
  var str = window.location.pathname.toString();
  var result = str.match(/\/([^.\/]+)\./);
  if (result) {
      return result[1].replace("-board", "").replace("-ai", "");
  } else {
      return str;
  }
}

var badName = function(str) {
  var result = str.match(/[?&]game=([^&*]*)/);
  if (result) {
      return result[1] != getName();
  } else {
      return true;
  }
}

var getCookie = function() {
  var str = document.cookie;
  var result = str.match(/dagaz\.(setup=[^*]*)/);
  if (result) {
      var r = decodeURIComponent(result[1]);
      if (badName(r)) return "";
      return "?" + r;
  } else {
      return "";
  }
}

var getSetup = function() {
  var str = window.location.search.toString();
  var result = str.match(/[?&]setup=([^&]*)/);
  if (result) {
      return result[1];
  } else {
      str = getCookie();
      result = str.match(/[?&]setup=([^&]*)/);
      if (result) {
          return result[1];
      } else {
          return "";
      }
  }
}

Dagaz.Model.getSetup = function(design, board) {
  var str = "";
  ...
  str = str + ";&turn=" + board.turn;
  if (Dagaz.Controller.persistense == "setup") {
      var s = str + "&game=" + getName() + "*";
      var maxage = getMaxage();
      if (maxage) {
        document.cookie = "dagaz.setup=" + encodeURIComponent(s) + "; max-age=" + maxage;
      } else {
        document.cookie = "dagaz.setup=" + encodeURIComponent(s);
      }
  }
  return "?setup=" + str;
}

Кроме того, далеко не все игры можно описать исключительно расстановкой фигур на доске. В тех играх, где счёт идёт на очки (таких как Pasang или Chaturanji) доска, сама по себе, хранит глобальные значения, а в играх с выставлением фигур из резерва (Morris, Bolotoudou) пришлось научить common-setup сохранять счётчики фигур, ещё не выставленных на доску. Да что там говорить, самая первая его версия не умела сохранять даже очерёдность хода!
nwxpp1i7qo0aevfux5rr_kqaaim.png

В некоторых играх имеется несколько вариантов начальной расстановки фигур. В Шатрандже фигуры расставляются согласно табиям (в противном случае, дебюты в этой игре были бы чрезмерно утомительными). В других играх, таких как Stratego и Luzhanqi, подобным образом расставляется лишь часть фигур (остальные фигуры бота размещаются случайным образом). Само по себе это не проблема (common-setup, в любом случае, запоминает расстановку всех фигур), но некоторые игры идут дальше…
0yhpyduqu2mgx4glqonulbwmrlw.png

Здесь имеется несколько, выбираемых случайным образом, вариантов самого игрового поля и если не предпринимать никаких мер, common-setup может попытаться восстановить расстановку фигур, сохранённую для другой доски. Самих вариантов может быть немного (в Mana, например, их всего два), но в зависимости от выбора какого либо из них, игра может изменяться очень существенно.

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


К сожалению, есть игры, для которых метод сохранения состояния модулем common-setup не подходит. В упомянутом выше Kamisado, возможность выполнения хода (для всех ходов кроме самого первого) зависит от того, на каком поле был завершён предыдущий ход. Существуют игры и с ещё более сложной механикой:

pcahyosk3fotjxjnlmmafvjw5jk.png


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

Модуль session-manager сохраняет историю игры, позволяя игроку возвращаться на один или несколько ходов назад, возможно выполняя другой ход. Также, с этим механизмом связана опция «advisor», подсказывающая игроку возможный ход, при игре с ботом. Ретроспективно, идея сохранения истории игры в cookie кажется очевидной, но пришёл я к ней не сразу.

Сначала появилась игра по переписке
Проект Dagaz имеет богатую историю. В каком-то смысле, он является идейным продолжением «Zillions of Games», разработанной в далёком 1998 году. С 2003 года Zillions не развивается, но новые игры продолжают появляться, во многом благодаря деятельности одного человека. Ed van Zon взял на себя общение с фанатами и публикацию новых игр на сайте. Помимо этого он развивает и собственный проект под названием «MindSports», посвящённый популяризации традиционных настольных игр.
euinmrgfa_ur3e13hx3tjnhokdc.png

Эта страничка на MindSports посвящена Dagaz и появилась она не случайно. Ed уже довольно давно разрабатывает настольные игры, но первоначально их интерактивность обеспечивалась Java-апплетами, пользоваться которыми, в последнее время, стало несколько неудобно. Некоторое время назад, Ed принял решение о переводе своего сайта на Dagaz, попросив меня выполнить некоторые доработки.

Прежде всего, требовалось обеспечить сохранение истории игры на сервере, для того, чтобы игроки могли играть друг с другом, по переписке. Здесь следует отметить, что Dagaz, сам по себе, не содержит какого-то backend-а, ограничиваясь выполнением JavaScript-кода в браузере. Ed предложил доработать session-manager, чтобы обеспечить возможность сериализации истории игры. Первоначально, я отнёсся к идее скептически, поскольку это означало, что перед выполнением каждого хода в браузере игрока, каждый раз, должна была проигрываться вся партия, до сохранённой позиции (без отображения на экране, разумеется), но практика показала, что подход вполне работоспособен.

В качестве формата хранения я выбрал SGF поскольку, как уже говорил, session-manager хранит дерево состояний, а SGF это поддерживает. В результате, в каждую игру с сессионной персистентностью пришлось тащить парсер, но это не очень большая проблема, учитывая то, что SGF используется в проекте и в других целях (для хранения дебютных библиотек, например). В результате всего этого, на MindSports, в настоящий момент, имеется уже более 80 игр и головоломок на движке Dagaz и в большую из них часть можно играть по переписке.


Следующий шаг был довольно очевиден — я стал сохранять SGF-описание партий в cookie, а потом мне подсказали, что это не очень современно и я перенёс всё в localStorage (это упростило код и решило некоторые проблемы с отладкой в Google Chrome). В этом месте у читателя может возникнуть вопрос. У меня имеются два механизма одинакового назначения, и один из них более совершенен чем другой. Почему бы не перевести все игры на сессионную персистентность? К сожалению, всё не так просто.

Есть целые семейства игр, для которых сохранение сессий неприменимо:

  1. Все игры со случайной расстановкой фигур или случайным выбором игрового поля (Shatranj, Sittuyin, Janggi, Stratego, Luzhanqi, Banqi и много других)
  2. Игры с костями, для которых использование session-manager имеет мало смысла (Ur, Puluc, Shen, Backgammon, Chaturaji и другие)
  3. Многораундовые игры (Kamisado, Washington, Ohvalhu)
  4. Наконец, есть игры, в которых персистентность session-manager-а не может использоваться, по соображениям производительности (как ни странно, в Fanorona, при всей её кажущейся простоте, в самом начале игры, имеется огромное количество возможных дебютов, связанное со спецификой выполнения взятия в этой игре)


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

© Habrahabr.ru