Тёмная сторона ZRF

    У тех, кто читал цикл моих статей, посвященных Zillions of Games, могло сложиться впечатление, что я полностью удовлетворен этим продуктом. Разумеется, это не так. ZoG уникален тем, что позволяет быстро и практически «на коленке» разработать прототип почти любой логической игры, но это вовсе не означает, что он идеален. Сегодня, я хочу рассказать о том, что мне не нравится в этом проекте.
    Конечно, эта критика нужна не сама по себе. Я вполне отдаю себе отчет в том, что при полностью остановившейся разработке продукта (кстати, это один из тех моментов, которые мне не нравятся), подобная критика, как средство обратной связи с разработчиком, совершенно бесполезна. Поэтому, я не собираюсь писать каких либо писем создателям продукта — паровоз уже давно ушёл.

    ZoG показал возможное направление действий, саму возможность создания подобного универсального игрового движка, но, если хочешь, чтобы что-то было сделано, делать это придется самому. Работа эта не простая и я совсем не уверен, что мне удастся с ней справиться (по крайней мере в одиночку). В любом случае, в качестве самого первого шага, будет полезно разобраться, чем плохо то, что уже имеется. Для чего затевать разработку нового продукта? Я постараюсь рассказать об этом…

    По сравнению с тем многообразием всевозможных правил, о котором я рассказывал в предыдущей статье, концепция Шахмат выглядит очень простой. Отчасти, эта иллюзия поддерживается тем, что, большинство из нас, знакомо с Шахматами с раннего детства. Конечно, эта простота обманчива! И дело здесь даже не в экзотических правилах "взятия на проходе" и рокировки (из-за которой в ZRF пришлось добавить команды cascade и set-attribute). Я предлагаю разобраться с более глубоким вопросом, лежащим в основе самой игры. Что должен делать Король, оказавшись под шахом?

    Во первых, оставаться под шахом Король не может! Следующим ходом мы обязаны устранить угрозу (если это невозможно, ситуация имеет другое название — мат). Мы можем уйти Королем на поле не находящееся под боем, закрыться от шаха другой фигурой (это невозможно если шах поставлен Конем) или съесть атакующую фигуру. С последним вариантом все тоже не просто. Съесть атакующую фигуру Королем возможно только при условии, что она не защищена другой фигурой, а в случае двойного шаха, есть одну из атакующих фигур, не уводя Короля из под боя другой, также невозможно. Все это может показаться очевидным, но я хочу, чтобы было понятно, что реализация такого поведения может оказаться очень сложной. Как это реализуется в ZRF?

    очень просто:

    (loss-condition (White Black) (checkmated King))
    

    Если в условии поражения игрока фигурирует волшебное слово checkmated, ядро программы знает как фиксировать мат, а, заодно, и то, как нужно уводить Короля из под шаха. Но подобное «зашивание» столь сложной логики в ядро продукта может оказаться не самой удачной мыслью, при реализации универсального движка. Само понятие мата имеется далеко не во всех играх семейства Шахмат, а уводить Короля из под «шаха» в них, все равно, приходится. Я уже рассказывал ранее, что логика обработки checkmated, может привести к не вполне адекватному поведению Короля, а отказ от использования этого условия, практически полностью ломает в ZoG всю игру в эндшпиле.

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

    С Шашками все тоже совсем не гладко. Главным отличием от Шахмат является обязательность взятия и возможность серии взятий. Для реализации этих правил, в ZRF введены частичные ходы (add-partial) и приоритеты ходов (move-priorities). Механизм приоритетов не очень удобен в использовании, но работает. Игрок не сможет выполнить не приоритетный ход, если имеет возможность приоритетного хода. Но на этом дело не ограничивается! В некоторых разновидностях Шашек (например в международных 100-клеточных Шашках), из всех возможных ходов со взятием, игрок должен выбрать вариант берущий максимальное количество фигур (при этом, особым образом могут учитываться или не учитываться дамки).

    Эта логика вновь зашита в ядро:

    (option "maximal captures" true)
    

    Кроме этой опции, имеется ряд других опций, используемых AI ZoG:

    • «include off-pieces» — включающая или отключающая учет фигур находящихся в резерве при определении условий победы и поражения
    • «pass partial» — управляющая возможностью прерывания цепочки взятий, формируемой командой add-partial
    • «pass turn» — управляющая возможностью пропуска хода
    • ...

    В качестве управляющего параметра, опции передаётся булевское значение, включающее или отключающее опцию. Для некоторых опций, разрешено передавать значение 2 или forced. Например, следующая команда:

    (option "maximal captures" 2)
    

    … включает режим «максимального взятия» с учетом дамок.

    Какой либо системы в списке предоставляемых опций нет. Более того, в том же списке присутствуют опции, не имеющие к AI никакого отношения, например "animate captures", "highlight goals", "show moves list",… Этот механизм вряд ли можно назвать универсальным.

    Подобное отсутствие системного подхода проходит через весь ZRF «красной нитью». Например, для определения позиций типа «3 в ряд» имеется удобная команда relative-config. Вот как она используется в "Крестиках-ноликах":

    (win-condition (X O)
    	  	(or (relative-config man n man n man)
                        (relative-config man e man e man)
                        (relative-config man ne man ne man)
                        (relative-config man nw man nw man)
    		)
    

    Просто и удобно. Проблема только в том, что эту команду разрешено использовать только внутри проверок завершения игры win-condition, loss-condition или draw-condition. Кроме того, она реагируют на любое возникновение указанной конфигурации (не важно на чьём ходу оно происходит). В результате, в реализации "Мельницы" приходится городить:

    трехэтажные проверки
    (define Check-for-3
         (set-flag first? false)
         (set-flag second? false)
         (set-flag third? false)
         (set-flag fourth? false)
    
         a1 
         (while (and (on-board? next)(not-flag? fourth?)) 
            (if friend? 
               (if (not-flag? first?)  
                 (set-flag first? true)
               else
                 (if (not-flag? second?) 
                   (set-flag second? true)
                 else
                   (if (not-flag? third?) 
                     (set-flag third? true)
                   else
                     (set-flag fourth? true)
                   ) 
                 )
               )
            )
            next
         )
         (verify (not-flag? fourth?)) 
         
         (if (am-Black?)
           (change-type Jumping z0)   ;set permanent flag
         else
           (change-type Jumping z1)   ;set permanent flag
         )
    )
    


    Чем-то подобным пришлось заниматься и мне, при реализации одного из вариантов Thud. А ведь возможность использования аналога relative-config, при выполнении хода, была бы очень полезна во многих играх, например в Чатуранга с её «триумфом слонов». В Hasami Shogi подобная возможность позволила бы корректно реализовать сложные правила запирания фигур у края и в углу доски.

    Еще одной крайне полезной возможностью могла бы стать реализация команд mark/back без ограничения уровня вложенности. Команда mark запоминает текущую позицию в процессе рассчета хода, а back позволяет в нее вернуться. Казалось бы, что может быть естественней чем использование стека для сохранения позиций? Но нет, вложенные вызовы mark не поддерживаются! Жаль, было бы удобно…

    Но, вернёмся к фундаментальному. Что происходит в Шахматах если мы ставим фигуру на поле, занятое фигурой противника? Это всем известно — фигура противника будет удалена с доски. Именно так все и реализовано в ZoG (и изменить такое поведение нельзя). Если мы даём команду add на занятом поле, то фигура, которая размещалась там ранее, будет удалена с доски. Выполнять команду capture при этом не нужно (более того, если мы выполним capture, будет удалена та фигура, которой мы ходим). Ячейка доски не может содержать более одной фигуры.

    Но ведь это верно далеко не для всех игр! Например, в "Русских шахматах" было бы гораздо удобнее хранить в ячейке доски упорядоченный список фигур. То же касается "Столбовых шашек". Конечно, я не могу сказать, что это послужило непреодолимым препятствием при реализации последних. Столбовые шашки были реализованы, но сложность решения не сказалась положительно ни на его производительности ни на качестве игры.

    И это не единственная (и даже не самая главная) беда команды add! В ZRF, команды семейства add совмещают в себе две функции:

    1. Указание поля на которое помещается фигура, выполнившая ход
    2. Завершение формирования варианта (или части) хода

    Вы уже понимаете в чем проблема? Из этого совершенно неуместного объединения двух принципиально различных действий в одной команде, немедленно следует, что ход (перемещение или сброс) должен завершаться постановкой своей фигуры на какое-то поле доски (при этом, могут быть съедены другие фигуры). Других вариантов нет! Попробуйте реализовать на ZRF Андернах. Я пробовал трижды, ничего не получилось! Дело в том, что, в этом варианте игры, при взятии, фигура меняет цвет, на цвет взятой фигуры. Это означает, что ход мы завершаем уже не своей фигурой…

    Стоило разработчикам разделить команду add на собственно команду постановки фигуры на доску и команду явного завершения хода (end-move например) и кучи проблем удалось бы избежать! Например, были бы возможны взятия фигур противника без перемещения своих фигур. Кстати, в ZSG (нотации ходов ZoG) такая возможность имеется, но используется она только для «первоначальной настройки» доски. В общем, этот момент, действительно, серьезно осложняет процесс разработки с использованием ZRF.

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

    Еще одним недостатком ZRF является полное отсутсвие в нём каких либо арифметических операций. Это не страшно, пока мы ограничиваемся Шахматами и Шашками, но существуют игры, в которых выполнение арифметических действий необходимо, при рассчете хода! Конечно, я говорю о Ритмомахии. Эта средневековая игра не особенно популярна в наше время, но она определенно бросает вызов универсальности игрового «движка». Вот при каких условиях может производиться бой фигур в этой игре:

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

    Арифметика может пригодиться не только при рассчете хода. В этой игре, например, для определения победителя, требуется рассчитать площадь всех квадратов, построенных на доске. Также, может быть полезна возможность связывания с фигурами и полями доски числовых значений (например hitpoint-ов). ZRF позволяет привязывать к фигурам только булевские значения (атрибуты), что касается полей, булевские флаги, привязываемые к ним, могут быть использованы только локально, при рассчете хода. Глобальные числовые значения (не привязанные к полям или фигурам), могли бы быть использованы для реализации разнообразных счетчиков (например игрового времени).

    В стандартном ZoG нет таких возможностей, но они реализованы в Axiom Development Kit. Об этом стоит рассказать подробнее. Дело в том, что ZoG позволяет использовать расширения, которые можно разрабатывать, например, с использованием С++. Платой за использование таких расширений является полный отказ от использования встроенного AI ZoG. Фактически, расширение использует ZoG исключительно как средство визуализации ходов. При разработке такого расширения, об AI приходится заботиться самостоятельно.

    Разумеется, эта возможность мало подходит для рядовых пользователей ZoG. Разработчики Axiom позаботились о них, предоставив вместе с расширением, AI собственной разработки. Помимо всего прочего, этот вариант AI гораздо лучше штатного справляется с играми на «захват территории» и «соединение», такими как Hex. Кроме того, в качестве языка программирования, Axiom использует Forth Script, предоставляющий поддержку арифметических операций.

    К сожалению, являясь расширением ZoG, Axiom вынужден использовать интерфейс, разработанный для взаимодействия с расширениями. Я позволю себе привести ёго здесь:

    Engine.h
    // Engine.h
    //
    // Copyright 1998-2000 Zillions Development
    //
    // Header file for plug-in DLL engine to Zillions
    
    #include "EngineDLL.h"
    
    DLL_Result FAR PASCAL DLL_Search(long lSearchTime, long lDepthLimit, long lVariety,
        Search_Status *pSearchStatus, LPSTR bestMove, LPSTR currentMove,
        long *plNodes, long *plScore, long *plDepth);
    DLL_Result FAR PASCAL DLL_MakeAMove(LPCSTR move);
    DLL_Result FAR PASCAL DLL_StartNewGame(LPCSTR variant);
    DLL_Result FAR PASCAL DLL_CleanUp();
    DLL_Result FAR PASCAL DLL_IsGameOver(long *lResult, LPSTR zcomment);
    DLL_Result FAR PASCAL DLL_GenerateMoves(LPCSTR moveBuffer);
    


    EngineDLL.h
    // EngineDLL.h
    //
    // Copyright 1998-2000 Zillions Development
    //
    // Shared DLL plug-in for DLL engine and Zillions
    
    #include "windows.h"
    
    typedef enum {
        kKEEPSEARCHING = 0,
        kSTOPSOON = 1,
        kSTOPNOW = 2
    } Search_Status;
    
    typedef enum {
        DLL_OK = 0,
        DLL_OK_DONT_SEND_SETUP = 1, // only supported in 1.0.2 and higher!
    
        DLL_GENERIC_ERROR = -1,
        DLL_OUT_OF_MEMORY_ERROR = -2,
        DLL_UNKNOWN_VARIANT_ERROR = -3,
        DLL_UNKNOWN_PLAYER_ERROR = -4,
        DLL_UNKNOWN_PIECE_ERROR = -5,
        DLL_WRONG_SIDE_TO_MOVE_ERROR = -6,
        DLL_INVALID_POSITION_ERROR = -7,
        DLL_NO_MOVES = -8
    } DLL_Result;
    
    enum {
        UNKNOWN_SCORE = -2140000000L,
        LOSS_SCORE = -2130000000L,
        DRAW_SCORE = 0,
        WIN_SCORE = 2130000000L
    };
    
    // ***** REQUIRED ROUTINES
    
    // DLL_Search
    //
    // The DLL should search from the current position. If it returns DLL_OK it should
    // also return the best move found in str; however, it should not make the move
    // internally. A separate call to MakeAMove() will follow to make the move the
    // engine returns.
    //
    // -> lSearchTime: Target search time in milliseconds
    // -> lDepthLimit: Maximum moves deep the engine should search
    // -> lVariety: Variety setting for engine. 0 = no variety, 10 = most variety
    // -> pSearchStatus: Pointer to variable where Zillions will report search status
    // -> bestMove: Pointer to a string where engine can report the best move found so far
    // -> currentMove: Pointer to a string where engine can report the move being searched
    // -> plNodes: Pointer to a long where engine can report # of positions searched so far
    // -> plScore: Pointer to a long where engine can report current best score in search
    // -> plDepth: Pointer to a long where engine can report current search depth
    //
    // Returns DLL_OK or a negative error code 
    
    typedef DLL_Result (FAR PASCAL *SEARCH)(long lSearchTime, long lDepthLimit, long lVariety,
        const Search_Status *pSearchStatus, LPSTR bestMove, LPSTR currentMove,
        long *plNodes, long *plScore, long *plDepth);
    
    
    // DLL_MakeAMove
    //
    // The DLL should try to make the given move internally.
    //
    // -> move: notation for the move that the engine should make
    //
    // Returns DLL_OK or a negative error code 
    
    typedef DLL_Result (FAR PASCAL *MAKEAMOVE)(LPCSTR move);
    
    
    // DLL_StartNewGame
    //
    // The DLL should reset the board for a new game.
    //
    // -> variant: The variant to be played as it appears in the variant menu
    //
    // Returns DLL_OK, DLL_OK_DONT_SEND_SETUP, DLL_OUT_OF_MEMORY_ERROR, or
    //   DLL_GENERIC_ERROR
    
    typedef DLL_Result (FAR PASCAL *STARTNEWGAME)(LPCSTR variant);
    
    // DLL_CleanUp
    //
    // The DLL should free memory and prepare to be unloaded.
    //
    // Returns DLL_OK, DLL_OUT_OF_MEMORY_ERROR, or DLL_GENERIC_ERROR
    
    typedef DLL_Result (FAR PASCAL *CLEANUP)(void);
    
    
    // ***** OPTIONAL ROUTINES
    
    // DLL_IsGameOver
    //
    // This optional function is called by Zillions to see if a game is over.  If 
    // not present, Zillions uses the goal in the ZRF to decide the winner.
    //
    // -> lResult: Pointer to the game result which the DLL should fill in when
    //              called.  If the game is over the routine should fill in WIN_SCORE,
    //              DRAW_SCORE, or LOSS_SCORE.  Otherwise the routine should fill in
    //              UNKNOWN_SCORE.
    // -> zcomment: Pointer to a 500-char string in Zillions which the DLL can optionally 
    //              fill in, to make an announcement about why the game is over, such
    //              as "Draw by third repetition".  The DLL should not modify this 
    //              string if there is nothing to report.
    //
    // Returns DLL_OK or a negative error code 
    
    typedef DLL_Result (FAR PASCAL *ISGAMEOVER)(long *lResult, LPSTR zcomment);
    
    
    // DLL_GenerateMoves
    //
    // You can use GenerateMoves in your DLL to tell Zillions the legal moves for 
    // any position in the game.
    //  
    // -> moveBuffer: Pointer to a 1024-char sting which the DLL should fill in when
    //              called.  Initial call should be with moveBuffer set to "".  Each call 
    //              to GenerateMoves should fill in the next available move from the 
    //              current position, with a final "" when no more moves are available.
    //              All moves must be in valid Zillions move string format.
    //
    // Returns DLL_OK or a negative error code 
    
    typedef DLL_Result (FAR PASCAL *GENERATEMOVES)(LPCSTR moveBuffer);
    


    Как можно видеть, этот интерфейс предназначен исключительно для передачи ядру ZoG, ходов, сгенерированных AI. Ход должен формироваться (в недокументированной) ZSG-нотации. Дополнительно, расширение может определять условие завершение игры, но оно не занимается контролем корректности ходов — эта часть остаётся в ZRF! Из этого простого факта следует, что все недостатки ZRF, о которых я говорил выше (за исключением отсутствия поддержки арифметики) остаются в силе.

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

    Продолжая свой рассказ о недостатках ZRF, я просто не могу пройти мимо игр с неполной информацией. На ZRF имеются реализации таких игр, но являются ли они честными? Они всего лишь скрывают часть информации от человека. AI прекрасно видит все фигуры! Согласитесь, подобная игра «в одни ворота» имеет мало общего с тем, когда неполную информацию имеют все игроки. Видимо, это одна из причин, по которой для ZoG реализовано так мало карточных игр. Не очень интересно играть с тем, кто знает все твои карты. Имеется и другая сторона этого вопроса. Я уже не раз упоминал о Battle vs Chess. Большая часть миссий в кампании этой игры построены на том, что человек и компьютер играют по разным правилам. Например в миссии «Точка невозврата» требуется заманивать фигуры противника на мины, расположенные на поле. Но если AI будет знать расположение мин, он просто не будет ходить на эти поля! Какой смысл делать ход, в результате которого просто теряешь фигуру? Чтобы игра протекала так, как она задумана разработчиками, компьютер должен «думать», что он играет по обычным правилам. Это тоже вариант игры с неполной информацией.

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

    Мы много говорили об AI, теперь можно немножко поговорить о том, что может показаться не очень важным — об оформлении. В качестве иллюстрации, я предлагаю рассмотреть игру Суракарта. Да, она реализована на ZRF, но попробуйте понаблюдать за тем, как она играет. Всё понятно? Мне не очень. Взятие, в этой игре, осуществляется по совершенно уникальным правилам. Фигура должна «прокатиться» по одной или нескольким боковым петлям и ударить фигуру противника сзади. Но ZoG не умеет анимировать такое сложное движение! В результате, партия превращается в ребус. Было бы неплохо, на уровне игры, иметь возможность подключать свой визуализатор. Но, даже в ZRF-файле, правила визуализации перемешаны с правилами AI. Понятно, что разработчикам так было легче, но теперь, 3D-визуализацию, так просто, уже не подключить (хотя бы потому, что для неё нужны совсем другие ресурсы).

    Резюмируя эту длинную статью, можно заметить, что большая часть проблем упирается в закрытые исходные коды продукта. Если бы исходные коды были открыты, можно было бы «допилить» функциональность и «прикрутить» свои визуализаторы. Можно было бы добиться лучшей игры, для отдельных игр, вроде Шахмат, добавив свои библиотеки дебютов и эвристики. Можно было бы портировать продукт на различные платформы, включая iOS и Android. Но исходников нет. И если нам придётся писать свои, стоит не забывать о том, что не следует повторять чужие ошибки. Стоит сделать исходники открытыми!
    Поделиться публикацией

    Похожие публикации

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

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

    Самое читаемое