Вас от недуга излечу,
Вы мне доверьтесь, как врачу,
Поможет вам моя микстура.
Советы Тристана — "Собака на сене"
В своей предыдущей статье я много рассказывал о том, как устроен генератор ходов Dagaz. Пожалуй, я поставил телегу впереди лошади. Моё наиподробнейшее описание, совершенно не помогает понять главного — того, каким образом всё это можно использовать. На самом деле, это просто.
Проще некуда
Для того, чтобы пользоваться генератором ходов, совершенно необязательно досконально разбираться в том, как он работает. Важно понимать назначение всего лишь трёх классов. И первый из них — это ZrfBoard. Как должно быть ясно из наименования, этот класс описывает доску.
ZrfBoard содержит описание состояния игры, на момент начала определённого хода. По большей части, это информация о размещении на доске фигур. Для её изменения достаточно всего двух методов:
- getPiece(position) — возвращает объект класса ZrfPiece, содержащий информацию о фигуре размещённой на указанной позиции (или null, если эта позиция пуста)
- setPiece(position, piece) — помещает на указанную позицию фигуру (также как и в предыдущем случае, вместо фигуры может передаваться null)
Все позиции — это просто целочисленные значения (индекс большого линейного массива). Для преобразования в более привычное строковое представление (и обратно), используются глобальные функции Model.Game.posToString и Model.Game.stringToPos. Описание фигуры (ZrfPiece) немногим сложнее:
function ZrfPiece(type, player) {
this.type = type;
this.player = player;
}
Тип фигуры и её владелец (и то и другое — целочисленные значения). Важно понимать, что это неизменяемые значения. Один и тот же экземпляр ZrfPiece может использоваться на нескольких позициях доски одновременно и даже на позициях различных экземпляров ZrfBoard, описывающих состояние игры в разные моменты времени.
ZrfPiece.prototype.promote = function(type) {
return new ZrfPiece(type, this.player);
}
Может показаться, что я уделяю этому слишком много внимания, но на самом деле, это важно. Дело в том, что помимо информации о типе и владельце, класс ZrfPiece может содержать дополнительные числовые значения — атрибуты фигур. Простейший пример использования атрибутов — рокировка в Шахматах). Признак того, что фигура перемещалась ранее и не может быть задействована в рокировке, может быть сохранён в одном из её атрибутов. Изменение значения любого атрибута также порождает новый экземпляр ZrfPiece, не изменяя существующий.
Есть ещё два метода, о которых необходимо знать, чтобы использовать ZrfBoard:
- generate() — возвращает массив всех ходов, доступных из текущей позиции
- apply(move) — применяет к текущей позиции выбранный ход
Хотя мы и можем изменять состояние ZrfBoard методом setPiece, мы не должны этого делать напрямую. Вместо этого, мы должны выбрать ход (один из списка всех возможных) и применить его к состоянию доски методом apply, который, как и в случае с изменением ZrfPiece, вернёт новый экземпляр объекта.
Вот и всё, что необходимо знать для применения ZrfBoard. Дизайн игры и связанные с ним алгоритмы генерации ходов могут быть очень сложными, но это ничего не меняет. Формируем все доступные ходы методом generate и применяем выбранный ход методом apply (получая новое состояние). Начальное состояние доски может быть получено при помощи глобальной функции Model.Game.getInitBoard.
Что бы мы без них делали
Ходы — это то что переводит одно игровое состояние в другое. Проблема в том, что ходы далеко не всегда такие простые какими могут показаться. В Шатрандже (непосредственном предшественнике Шахмат), для полного описания любого хода вполне достаточно задать начальную и конечную позицию. Даже взятия и превращения описывать не обязательно. Пешка всегда превращается в ферзя, а взятие всегда «шахматное». Но уже в самих Шахматах всё не так просто!
Появляется пешка, способная бить фигуру не на том поле, на которое она ходит. Появляется "рокировка" — ход, при выполнении которого перемещаются сразу две фигуры! Становится понятно, что ход должен состоять из нескольких действий. Какие это действия? Я могу перечислить три типа:
- movePiece(from, to, piece) — перемещение фигуры
- capturePiece(position) — взятие фигуры (удаление её с доски)
- dropPiece(position, piece) — сброс фигуры (добавление на доску)
Хочу заметить, что все эти сложности не только для того, чтобы реализовать «рокировку» и «взятие на проходе». Эти правила — пробный камень, позволивший вовремя расширить функциональность универсального решения. Те же самые «каскадные» ходы, которые команда Ziilions of Games ввела в свой продукт для того, чтобы выполнять в шахматах рокировку, можно с большим успехом использовать во множестве других, совершенно непохожих игр. Например, в этой:
Каскадный ход — это не обязательно рокировка! Фактически — это любой ход, при выполнении которого перемещается сразу несколько фигур. Подобные «нестандартные» правила сильно обогащают продукт! И если Шахматы подарили нам ходы «каскадные», то и у Шашек тоже нашлось чему поучиться.
Составной ход выполняется «по частям» и это важно, потому что, часто, игрок может выбрать несколько различных продолжений выполняемого им составного хода. Ход не завершён, пока не выполнены все его частичные ходы, но удобный пользовательский интерфейс должен предоставлять возможность выполнения каждого частичного хода по отдельности! С другой стороны, AI-ботам удобнее рассматривать составной ход целиком, как единую сущность изменяющую игровое состояние. Это действительно сложная проблема, на которой я остановлюсь ниже.
Что ещё нужно знать о классе ZrfMove? Всего два метода:
- toString(part) — получение текстовой нотации хода
- changeView(part, view) — изменение визуального представления игры
Из предыдущего раздела, мы помним, что можем применить ход к экземпляру класса ZrfBoard, чтобы изменить позицию на доске. Метод changeView делает то же самое, но по отношению к внешнему, для модели игры, визуальному представлению. Представление получает от модели простые команды:
- move(from, to, player, piece) — перемещение фигуры
- delete(position) — удаление фигуры с доски
- create(position, player, piece) — добавление фигуры
Это почти то же самое, что и действия добавляемые в ход, за тем исключением, что вместо числовых значений, используемых моделью, передаются строки, описывающие позиции и фигуры в известной представлению текстовой нотации. Что касается метода toString, то это просто получение нотации хода в понятной человеку форме. Ненулевое значение part позволяет получить описание соответствующего частичного хода. Передав в аргумент 0, можно получить полное описание составного хода.
Оправданная сложность
Итак, на каждом этапе игры у нас имеется игровое состояние и список ходов (разрешённых правилами игры) на выбор. Этого вполне достаточно для корректной работы приложения, но с точки зрения реализации пользовательского интерфейса, список ходов — не самая удобная вещь. Давайте ещё раз посмотрим, как работает пользовательский интерфейс Zillions of Games:
Прежде всего, пользователь выбирает одну из своих фигур (указывая поле, на котором она находится). Далее, если имеется несколько вариантов выполнения частичного хода, целевые позиции помечаются и пользователь может перетащить на одну из них фигуру. Если же возможный ход всего один, он выполняется немедленно (работает опция «smart moves»). Это удобно. Это гораздо удобнее чем предложение выбора из следующего списка ходов:
- d8-g8-g3-d3-d7-h7-h5-a5-a7-e7-e1-c1-c6-a6-a1
- d8-g8-g3-d3-d7-h7-h5-a5-a7-e7-e1-a1-a6-c6-c1
- d8-h8-h3-d3-d7-g7-g5-a5-a7-e7-e1-c1-c6-a6-a1
- d8-h8-h3-d3-d7-g7-g5-a5-a7-e7-e1-a1-a6-c6-c2
- ...
Вообще говоря, это работа для контроллера, но здесь слишком много специфичной логики уже реализованной в модели. Контроллеру совершенно не обязательно знать о том, что ходы делятся на перемещающие фигуры (возможно несколько фигур сразу) и добавляющие их на доску. Контроллер не должна волновать правильность порядка выполнения действий, при выполнении каждого частичного хода. Опция «smart moves» его также волновать не должна. Всё это реализовано в модели!
- getPositions()
- setPosition(position, view)
Вот два самых главных метода нового класса, призванного обеспечить взаимопонимание модели и контроллера. Вместо «плоского» массива ходов, мы получаем от ZrfBoard экземпляр класса ZrfMoveList, содержащий этот список. Контроллер вызывает метод getPositions, для получения массива позиций, доступных для выполнения очередного шага. Одна из этих позиций выбирается при помощи пользовательского интерфейса и передаётся методу setPosition.
Этот метод возвращает контроллеру текстовую нотацию частичного хода (для отображения в списке ходов), вносит необходимые изменения в визуальное представление доски и готовит ZrfMoveList к выполнению следующего шага. Цикл повторяется до тех пор, пока очередной вызов getPositions не вернёт пустой список. Это означает, что мы дошли до конца составного хода. Чаще всего, выбранной последовательности шагов соответствуют всего один возможный ход из всего списка. Чаще всего, но не всегда!
Здесь возможно четыре различных хода с совпадающими начальными и конечным позициями перемещения. Для нас это означает, что после того как метод getPositions вернёт пустой список, ZrfMoveList будет содержать более одного допустимого хода. Контроллер должен предоставить пользователю возможность выбора из этого списка. Список допустимых ходов может быть получен вызовом метода getMoves (по мере передачи в setPosition новых значений, этот список будет уменьшаться). Есть ещё несколько методов, о которых стоит сказать:
- back(view) — откат к предыдущему частичному ходу
- getCapturing() — получение списка позиций, находящихся «под боем»
- canPass() — проверка возможности завершения составного хода
- pass() — завершение составного хода
Если с первыми двумя методами всё более менее понятно, то следующие два требуют пояснения. Существуют игры (шашки к ним не относятся), в которых игрок имеет право прервать выполнение составного хода. Например, в "Фанороне", хотя первое взятие является обязательным (как и в шашках), игрок может прервать цепочку взятий в любой момент:
Хотя игрок может продолжить взятие ходом "D4-C5", он имеет право отказаться от этой возможности, нажав кнопку "Pass". Частным случаем этой ситуации является отказ игрока от выполнения всего составного хода (в некоторых играх, например в "Го", это допускается). Перед получением списка позиций, контроллер должен вызывать метод canPass, для определения допустимости досрочного завершения составного хода. Вызов метода pass завершит выполнение хода (если это допускается правилами). После этого, контроллеру останется получить список допустимых ходов, выбрать один из них и применить его к ZrfBoard, для получения нового состояния.
Я рассказал далеко не о всех возможностях класса ZrfMoveList. В некоторых играх (например в "Мельнице", показанной выше) логика выполнения хода может быть гораздо более сложной. Она может включать в себя множественные перемещения фигур, недетерминированные захваты и сбросы фигур и даже недетерминированные перемещения! Для нас важно, что все эти сложности надёжно скрыты за простым и понятным интерфейсом класса ZrfMoveList. Да, сам этот класс очень сложен, но эта сложность оправдана! Ведь не будь её, нам пришлось бы в ещё большей степени усложнить контроллер.
Комментарии (8)
WinPooh73
28.02.2017 00:14+2Кстати говоря, метод toString у хода может быть нетривиален, и требовать рассмотрения легальности других ходов на доске. Пример из шахмат: пусть есть две белых ладьи, a4 и h4. Если одна из них идет на e4, то по правилам краткой нотации мы должны указать ее начальную вертикаль. Например, Rae4. Но! Если ладья h4 связана (например, на h8 стоит черная ладья, а на h1 — белый король), то неоднозначность исчезает! И мы должны тот же самый ход записывать просто как Re4.
GlukKazan
28.02.2017 10:00Есть такой момент. Метод toString для ходов можно перегружать JS-плагинами. Правда я не рассматривал возможность взаимозависимости ходов (об этом стоит подумать). Также, в отличии от Zillions с его ZSG, я не использую текстовую нотацию для сериализации хода. Текстовое представление используется только для того, чтобы показать его человеку, ни для чего больше. По этой причине, нотация не обязательно должна включать в себя полное описание хода. Например в Го, нотация может включать только сброс камня на доску. Подразумевается, что взятие камней (если оно есть) будет выполнено в соответствии с правилами игры. Сам ход (JS-объект) будет включать в себя эти действия, но нотация хода их описывать не обязана.
WinPooh73
28.02.2017 16:32+1Если только для демонстрации, то вопрос легко снимается полной нотацией, конечно. Мне-то краткая понадобилась, когда для машинного обучения начал читать базы чужих партий в формате PGN.
GlukKazan
28.02.2017 22:24Вообще тема нотации достаточно больная. PGN еще более менее (хотя короткая нотация хода безусловно засада), но для Го, например, хочется сделать поддержку SGF. Он очень широко применяется, поскольку позволяет хранить разбор партий (дерево, а не просто последовательность ходов). У шашек своя нотация, у Сёги своя. Кто во что горазд. А поскольку хочется универсальности, надо и для манкал что-то вменяемое придумать. И это я ещё не рассматриваю карточные игры и домино! Сложная в общем тема.
koutsenko
28.02.2017 01:46+1Добрый день, а как вы в целом позиционируете проект? Java+JS фреймворк для создания досочных логических игр, с MVC архитектурой и с поддержкой DSL-описаний от Zillion of Games?
GlukKazan
28.02.2017 09:50Пока скорее прототип, но в целом да, именно так. Причём без Java. На Java написан только конвертер ZRF файлов в JS, без него вполне можно обходиться. И пока это только маленькая часть фреймворка, фактически, только модель.
leshabirukov
Неудачный пример. Отказаться от хода действительно можно, но только от просто хода, составных ходов в Го нет.
По описанию игр: вы не пробовали функциональные языки? Там хорошо описываются зависимости в моделях, для описания каскадного хода возможно подойдёт такая вещь как продолжения (continuations).
GlukKazan
Ну тут пример скорее на тему, что можно отказаться не только от продолжения составного хода (если разрешён более короткий префикс), но и от всего хода (то есть выполнить 0 частичных ходов, если разрешён ход не содержащий действий). Ход в Го можно рассматривать как частный случай составного хода, содержащего от 0 до 1 частичных. Про continuations много думал конечно. Вначале вообще всё хотел на Схеме делать. Решил что слишком сложно (для меня во всяком случае).