ZoG на стероидах

в 11:25, , рубрики: dsl, game development, Zillions of Games 2, ZRF, макросы, ненормальное программирование, препроцессор, функциональное программирование, метки: , , , ,

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

Как можно бороться с подобной избыточностью?

Разумеется, с помощью макросов! Проблема в том, что макросы ZRF недостаточно выразительны для этого. Адриан Кинг, в процессе разработки игр Scirocco и Typhoon, пришел к аналогичному выводу и разработал свой собственный, расширенный язык макросов, работающий как внешний препроцессор. Сегодня, я расскажу о возможностях этого языка и постараюсь, на примере Thud!, показать его использование в процессе разработки ZRF-приложений.


Как я уже сказал выше, речь идет о внешнем препроцессоре, преобразующем исходные файлы (с расширением .prezrf) в обычные zrf-файлы. Сам препроцессор разработан на языке Java и представляет собой jar-файл. Для обработки prezrf-файла, достаточно выполнить следующую команду (при условии, что на вашем компьютере установлена Java):

java -jar prezrf.jar MyFile.prezrf

Если обработка пройдет без ошибок, результирующий zrf-файл будет сформирован в том же каталоге.

Какие возможности предлагает нам новый язык? Во первых, он вводит новый тип макросов. Для определения макроса prezrf используется ключевое слово define! (ко всем новым ключевым словам, в конце, добавлен восклицательный знак). Оригинальные define макросы ZRF игнорируются препроцессором и просто копируются в вывод. Определение макросов нового образца можно отменять командой undefine!. Эта возможность может быть полезна, поскольку новые макросы могут определяться локально, в других макросах (ZRF подобного делать не позволяет).

Ключевое слово expand! приводит к «развертыванию» ранее определенного макроса в указанном им месте кода. Поскольку это действие выполняется очень часто, для него определено сокращение '!'. Таким образом, обработав следующий код:

(define! swap
  ($2 $1)
)

(! swap a b)

… мы получим на выходе:

(b a)

Этот макрос был бы раскрыт и без восклицательного знака, командой (swap a b), но его использование спасает нас от возможных опечаток. Например, если мы, по какой то причине, забыли добавить восклицательный знак к define в определении swap, конструкция (swap a b) была бы просто продублирована в вывод, а вызов макроса констукцией expand!, привел бы к формированию ошибки: expand!: undefined macro «swap».

Все это, пожалуй, было бы не очень интересно, если бы не новые возможности, предоставляемые prezrf. В макросах нового типа мы можем использовать аргументы-списки! Кроме того, для нашего удобства, добавлена возможность ссылки на несколько аргументов, передаваемых в макрос, как на список. Конструкция $2*4 последовательно выведет значения 2-го, 3-го и 4-го аргументов, переданных в макрос (при условии того, что передано не менее четырех аргументов). Также, определены сокращенные конструкции $n* и $*m имеющие очевидную семантику. Используя эту возможность, мы можем, например, подсчитать количество аргументов, переданных в макрос:

(define! count
  (length! ($1*))
)

(! count a b c) ; => 3

Обращаю ваше внимание на то, что скобки вокруг $1*, в этом примере обязательны — мы формируем список, в котором перечисляем значения всех аргументов макроса, начиная с первого. Отсутсвие скобок приведет к ошибке обработки, поскольку length! принимает только один списковый аргумент. Тем не менее, наш макрос недостаточно защищен от ошибок входных данных. Вызов (count) без аргументов закончится ошибкой. Мы можем исправить это следующим образом:

(define! count
  ($?1
    (length! ($1*))
  )
  ($!1 0)
)

Здесь $?1 выполняется в случае, если в макрос передан один или более аргументов, а $!1 — в противном случае. Кроме того, имеется возможность нумеровать элементы с конца списка, используя конструкцию $-n. Все эти возможности будут нам весьма полезны в дальнейшем.

Как и любой уважающий себя язык программирования, prezrf предоставляет нам конструкции условного выполнения (if-less!, if-less-or-equal!) и цикла (for!). If-конструкции (а их в языке несколько больше перечисленных выше), в отличии от аналогичной конструкции ZRF, не определяют ветвь else, а for! может использоваться только для обхода элементов списка. Например, мы можем повторить выполнение некоторого действия для всех определенных в игре направлений (это требуется очень часто):

(define! -all-directions (n ne e se s sw w nw))

(define! shift-all
  (for! $d ($ -all-directions)
     (shift $d)
  )
)

(! shift-all)

В этом коде, используется управляющая конструкция '$', о которой я еще не успел рассказать. Что она делает? Фактически, это сокращение для очень часто использующейся конструкции:

(!! (! macro))

expand! здесь нам уже знаком, но что означает '!!'? Эта команда (expand-first!) сообщает препроцессору о том, что нужно использовать значение элемента, а не сам элемент в вышестоящей конструкции (for! в нашем примере). Этот момент может быть сложен для понимания, но он весьма важен для понимания языка. Вот как будет выглядеть вывод, если использовать просто (! -all-directions):

(shift !)

(shift -all-directions)

Это явно не то, чего мы хотели. К сожалению, for! работает только со списками и не сможет нам помочь в оптимизации следующего безобразия:

Так ходят Тролли

( define troll-1 
  ( $1 
    (verify empty?) 
    (if (enemy? n) (capture n)) (if (enemy? nw) (capture nw))
    (if (enemy? s) (capture s)) (if (enemy? ne) (capture ne))
    (if (enemy? w) (capture w)) (if (enemy? sw) (capture sw))
    (if (enemy? e) (capture e)) (if (enemy? se) (capture se))
    add
  )
)

( define troll-2 
  ( mark
    (opposite $1) (verify friend?) 
    back 
    $1 (verify empty?)
    $1 (verify empty?)
    (verify (or (enemy? n) (enemy? nw) (enemy? s) (enemy? ne)
                (enemy? w) (enemy? sw) (enemy? e) (enemy? se))) 
    (if (enemy? n) (capture n)) (if (enemy? nw) (capture nw))
    (if (enemy? s) (capture s)) (if (enemy? ne) (capture ne))
    (if (enemy? w) (capture w)) (if (enemy? sw) (capture sw))
    (if (enemy? e) (capture e)) (if (enemy? se) (capture se))
    add
  )
)

( define troll-3 
  ( mark
    (opposite $1) (verify friend?) 
    (opposite $1) (verify friend?) 
    back 
    $1 (verify empty?)
    $1 (verify empty?)
    $1 (verify empty?)
    (verify (or (enemy? n) (enemy? nw) (enemy? s) (enemy? ne)
                (enemy? w) (enemy? sw) (enemy? e) (enemy? se))) 
    (if (enemy? n) (capture n)) (if (enemy? nw) (capture nw))
    (if (enemy? s) (capture s)) (if (enemy? ne) (capture ne))
    (if (enemy? w) (capture w)) (if (enemy? sw) (capture sw))
    (if (enemy? e) (capture e)) (if (enemy? se) (capture se))
    add
  )
)

( define troll-4 
  ( mark
    (opposite $1) (verify friend?) 
    (opposite $1) (verify friend?) 
    (opposite $1) (verify friend?) 
    back 
    $1 (verify empty?)
    $1 (verify empty?)
    $1 (verify empty?)
    $1 (verify empty?)
    (verify (or (enemy? n) (enemy? nw) (enemy? s) (enemy? ne)
                (enemy? w) (enemy? sw) (enemy? e) (enemy? se))) 
    (if (enemy? n) (capture n)) (if (enemy? nw) (capture nw))
    (if (enemy? s) (capture s)) (if (enemy? ne) (capture ne))
    (if (enemy? w) (capture w)) (if (enemy? sw) (capture sw))
    (if (enemy? e) (capture e)) (if (enemy? se) (capture se))
    add
  )
)

( define troll-5 
  ( mark
    (opposite $1) (verify friend?) 
    (opposite $1) (verify friend?) 
    (opposite $1) (verify friend?) 
    (opposite $1) (verify friend?) 
    back 
    $1 (verify empty?)
    $1 (verify empty?)
    $1 (verify empty?)
    $1 (verify empty?)
    $1 (verify empty?)
    (verify (or (enemy? n) (enemy? nw) (enemy? s) (enemy? ne)
                (enemy? w) (enemy? sw) (enemy? e) (enemy? se))) 
    (if (enemy? n) (capture n)) (if (enemy? nw) (capture nw))
    (if (enemy? s) (capture s)) (if (enemy? ne) (capture ne))
    (if (enemy? w) (capture w)) (if (enemy? sw) (capture sw))
    (if (enemy? e) (capture e)) (if (enemy? se) (capture se))
    add
  )
)

( define troll-6
  ( mark
    (opposite $1) (verify friend?) 
    (opposite $1) (verify friend?) 
    (opposite $1) (verify friend?) 
    (opposite $1) (verify friend?) 
    (opposite $1) (verify friend?) 
    back 
    $1 (verify empty?)
    $1 (verify empty?)
    $1 (verify empty?)
    $1 (verify empty?)
    $1 (verify empty?)
    $1 (verify empty?)
    (verify (or (enemy? n) (enemy? nw) (enemy? s) (enemy? ne)
                (enemy? w) (enemy? sw) (enemy? e) (enemy? se))) 
    (if (enemy? n) (capture n)) (if (enemy? nw) (capture nw))
    (if (enemy? s) (capture s)) (if (enemy? ne) (capture ne))
    (if (enemy? w) (capture w)) (if (enemy? sw) (capture sw))
    (if (enemy? e) (capture e)) (if (enemy? se) (capture se))
    add
  )
)

( define troll-7
  ( mark
    (opposite $1) (verify friend?) 
    (opposite $1) (verify friend?) 
    (opposite $1) (verify friend?) 
    (opposite $1) (verify friend?) 
    (opposite $1) (verify friend?) 
    (opposite $1) (verify friend?) 
    back 
    $1 (verify empty?)
    $1 (verify empty?)
    $1 (verify empty?)
    $1 (verify empty?)
    $1 (verify empty?)
    $1 (verify empty?)
    $1 (verify empty?)
    (verify (or (enemy? n) (enemy? nw) (enemy? s) (enemy? ne)
                (enemy? w) (enemy? sw) (enemy? e) (enemy? se))) 
    (if (enemy? n) (capture n)) (if (enemy? nw) (capture nw))
    (if (enemy? s) (capture s)) (if (enemy? ne) (capture ne))
    (if (enemy? w) (capture w)) (if (enemy? sw) (capture sw))
    (if (enemy? e) (capture e)) (if (enemy? se) (capture se))
    add
  )
)

Но там где пасуют циклы, нам придет на помощь рекурсия:

Рекурсия, как и было сказано

(define! repeat
    (if-less! 0 $1
        $2*
        (! repeat (!! (sum! $1 -1)) $2*)
    )
)

(define! troll-n
  (if-less! 0 $1
     (for! $d ($ -all-directions)
        ( (if-less! 1 $1
             mark
             (repeat $1
                 (opposite $d)
                 (verify friend?)
             )
             back
          )
          (repeat $1
             $d
             (verify empty?)
          )
          (if-less! 1 $1
             (verify (or
                 (for! $dd ($ -all-directions)
                     (enemy? $dd)
                 )
             ) )
          )
          (for! $dd ($ -all-directions)
             (if (enemy? $dd)
                 (capture $dd)
             )
          )
          add
        )
     )
     (! troll-n (!! (sum! $1 -1)))
  )
)
...
(! troll-n 7)

Это несколько сложнее для понимания, чем исходная версия, но зато никакого копипаста. Прошу особо обратить внимание на реализацию repeat. Этот макрос очень полезен и будет неоднократно использоваться нами в дальнейшем.

(! repeat 3 a b c) ; => a b c a b c a b c

В исходной реализации Thud! есть еще одно место, которое хотелось бы оптимизировать:

( define check-rock
  ( check-rock-direction n  ne e  se s  sw w  nw)
  ( check-rock-direction ne e  se s  sw w  nw n)
  ( check-rock-direction e  se s  sw w  nw n  ne)
  ( check-rock-direction se s  sw w  nw n  ne e)
  ( check-rock-direction s  sw w  nw n  ne e  se)
  ( check-rock-direction sw w  nw n  ne e  se s)
  ( check-rock-direction w  nw n  ne e  se s  sw)
  ( check-rock-direction nw n  ne e  se s  sw w)
)

Перечисляя вручную все циклические перестановки набора из восьми элементов легко наделать ошибок. Первым делом, определим макрос, позволяющий нам «вырезать» кусок списка:

(define! range
  (if-less-or-equal! $1 $2
    (nth! $1 $3)
    (! range (!! (sum! $1 1)) $2*)
  )
)

(! range 3 4 (a b c d e)) ; => c d

Функция (nth! n list), здесь, позволяет получить n-ый элемент списка. Попробуем «повертеть» список:

(define! rotate
  (if-less! 0 $1
     $2
     (! rotate (!! (sum! $1 -1)) ((splice! ((! range 2 8 $2)) ((nth! 1 $2)))))
  )
)

(! rotate 2 (a b c d e f g h)) ; => (a b c d e f g h) (b c d e f g h a)

Вроде бы нормально, но уже (! rotate 3 (a b c d e f g h)) дает ошибку:

Сообщение об ошибке

Expanding [list "(nth! 2 ((splice! ((! range 2 8 (a b c "… at argument substitution in [list "(nth! $1 $3)" at t.prezrf, line 3] for [list "(range 2 8 ((splice! ((! range 2 8 (a b"… at expand! of [list "(! range 2 8 ((splice! ((! range 2 8 (a"… at argument substitution in [list "(! range 2 8 $2)" at t.prezrf, line 11] for [list "(rotate 2 ((splice! ((! range 2 8 (a b "… at expand! of [list "(! rotate 2 ((splice! ((! range 2 8 (a "… at argument substitution in [list "(! rotate (!!! (sum! $1 -1)) ((splice! ("… at t.prezrf, line 11] for [list "(rotate 3 (a b c d e f g h))" at expand! of [list "(! rotate 3 (a b c d e f g h))" at t.prezrf, line 15]]]]]]]:
In [list "(nth! 2 ((splice! ((! range 2 8 (a b c "… at argument substitution in [list "(nth! $1 $3)" at t.prezrf, line 3] for [list "(range 2 8 ((splice! ((! range 2 8 (a b"… at expand! of [list "(! range 2 8 ((splice! ((! range 2 8 (a"… at argument substitution in [list "(! range 2 8 $2)" at t.prezrf, line 11] for [list "(rotate 2 ((splice! ((! range 2 8 (a b "… at expand! of [list "(! rotate 2 ((splice! ((! range 2 8 (a "… at argument substitution in [list "(! rotate (!!! (sum! $1 -1)) ((splice! ("… at t.prezrf, line 11] for [list "(rotate 3 (a b c d e f g h))" at expand! of [list "(! rotate 3 (a b c d e f g h))" at t.prezrf, line 15]]]]]]]:
Trying to get item #2 of list with 1 items

Как можно заметить, оно не такое пространное как сообщения об ошибках при компиляции шаблонов C++, но не на много их понятнее. Я довольно долго возился с prezrf и вывел для себя два эмпирических правила в части относительно безболезненной работы с ним:

  1. Не передавать изменяемые списки в качестве аргументов рекурсивных макросов
  2. Ограничивать использование for! наиболее простыми случаями

Любое отступление от этих правил, временами, грозит взорвать мне мозг, в тщетных попытках сути произошедшего. Попробуем перефразировать наш rotate в духе первого правила:

(define! rotate
  (if-less! 0 $1
     ((splice! ((! range (!! (sum! $1 1)) (!! (length! $2)) $2))
               ((! range 1 $1 $2))
     ))
     (! rotate (!! (sum! $1 -1)) $2)
  )
  (if-equal! 0 $1
     $2
  )
)

Он стал выглядеть страшнее, но он работает! Что же нам теперь с ним делать? Перестановки нам нужны не просто так, мы должны передать эти аргументы в check-rock-direction. Конечно, можно было бы внести в rotate соответвующие изменения, вызывая макрос из него, но это сделало бы rotate не универсальным. Видимо, пришло время расчехлять секретное оружие функционального программирования — функции высшего порядка:

(define! map
  ($!3
    (! map $1 $2 (!! (length! $2)))
  )
  ($?3
     (if-less! 0 $3
        ($1 (!! (nth! $3 $2)))
        (! map $1 $2 (!! (sum! $3 -1)))
     )
  )
)

(define check-rock
  (! map check-rock-direction
      (!! ((! rotate
              (!! (sum! (!! (length! ($ -all-directions)))))
              ($ -all-directions)
      )))       
  )
)

Над этим пришлось повозиться, но оно того стоило. Главное место во всем этом коде здесь: ($1...). Это тоже работает, что не может не радовать. Собственно, на этом варианте можно было бы остановиться, если бы не размер результирующего ZRF-файла. Развертывая все макросы, из 14-килобайтного исходника, препроцессор получает на выходе более чем мегабайтный файл описания! Стоит ли говорить о том, что ZoG загружает эту ZRF-ку довольно неторопливо.

Как победить эту беду? Да с помощью макросов же (у нас же больше нет ничего):

Макросы создают макросы

(define! troll-n
  (if-less! 0 $1
     (define (concat! troll - $1)
        ( (if-less! 1 $1
             mark
             (repeat (!! (sum! $1 -1))
                 (opposite (concat! $ 1)) 
                 (verify friend?) 
             )
             back 
          )
          (repeat $1
             (concat! $ 1)
             (verify empty?) 
          )
          (if-less! 1 $1
             (verify (or
                 (for! $dd ($ -all-directions)
                     (enemy? $dd)
                 )
             ) )
          )
          (for! $dd ($ -all-directions)
             (if (enemy?  $dd)
                 (capture $dd)
             )
          )
          add
        )
     )
     (! troll-n (!! (sum! $1 -1)))
  )
)

(! troll-n 7)

(define! troll-all
  (if-less! 0 $1
     (for! $d ($ -all-directions)
        ( (concat! troll - $1) $d
        )
     )
     (! troll-all (!! (sum! $1 -1)))
  )
)

Конструкция (! troll-n 7) последовательно создает определения ZRF-макросов troll-1, troll-2,… troll-7 (порядок их следования для нас не важен), а (! troll-all), будучи вызвана в нужном месте, перечислит их вызовы. Здесь стоит обратить внимание на конструкцию (concat! $ 1), таким замысловатым образом, мы формируем $1 в теле ZRF-макроса. Если мы просто скажем $1, получится не очень хорошо.

Результат не замедливает сказаться. Размер итогового ZRF-файла снижается до 28-килобайт. Можно было бы сделать его еще меньше, но я не видел в этом большого смысла. Он работает так же как и оригинал, а это значит, что на нашем длинном пути мы не наделали ошибок.

Хочу отметить, что описываемый мной препроцессор совершенно бесплатен в использовании и кроссплатформенен. Каждый счастливый обладатель установленной Java может с ним поэксперементировать. Разумеется результаты его трудов запустить в демо-версии ZoG не удастся, но мы ведь тут ненормальным программированием занимаемся?

Примечание

В качестве иллюстрации к статье, использована работа знаменитого художника Мориса Эшера.

Автор: GlukKazan

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js