июля 18, 2011

Пара слов о том, как делался Maze Journey

Недавно участвовал в конкурсе IGDC, писал игру на DEmbro. Это был достаточно интересный опыт использования на практике своего языка.

Сразу оговорюсь, что в DEmbro полно недоделок, он глючный, и я плохо на нём программирую :)

Средства разработки

DEmbro и текстовый редактор Vim. Выглядит это как-то так:


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

Коротко о языке

Я использовал (за исключением нескольких незначительных мест) только три типа: целое число, указатель и строка. Хотя типов как таковых в DEmbro нет. Все операции производятся на стеке, для чисел и указателей один стек, для строк — отдельный. Круглые скобки используются для многострочных комментариев, а для однострочных — общепринятое «//».

Несколько примеров:


1 // положить на стек число 1
2 // положить на стек число 2
3 4 5 // положить на стек числа 3, 4 и 5. Теперь стек выглядит так: 1 2 3 4 5
+ // сложить два верхних числа. Теперь стек выглядит так: 1 2 3 9
* // умножить два верхних числа. Теперь стек выглядит так: 1 2 27
swap // поменять два верхних числа местами, 1 27 2
div // нацело поделить два верхних числа, 1 13
max // из двух верхних элементов оставить максимальный, 13
1+ // увеличить верхнее число на 1, получится 14
drop // скинуть верхний элемент со стека, теперь стек вернулся в начальное состояние


Можно писать свои команды при помощи конструкции

: name ..... ;


где name — имя команды.

(Именем может быть любая последовательность символов без пробелов. Никаких ограничений на имя нет (можно переопределять стандартные слова).)

Например, в программе всё измеряется в миллесекундах. Но иногда приходится задавать время в минутах. Чтобы задать пять минут, нужно 5 умножить на 60*1000, и получится соответствующее число миллисекунд. Чтобы делать это вычисление автоматически, можно написать команду

: mins 60000 * ;


Теперь можно просто записать

5 mins


и получить время в миллесекундах для пяти минут.

Команды можно выполнять косвенно. При помощи символа апостроф, за которым идёт название команды, можно положить указатель на эту команду. А при помощи команды execute выполнить указатель с вершины стека. Т.е.

5 ' mins execute


эквивалентно коду «5 mins».

Можно создавать анонимные команды при помощи команды «:noname»:

:noname ." Hello world" cr ;


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

:noname ." Hello world" cr ; execute



Пара примеров на работу с указателями и переменными:

variable x // создаёт область памяти под целое число или указатель
// теперь вызов команды x кладёт на стек указатель на эту область памяти
5 x ! // записать число 5 в переменную x
x @ // положить в стек значение переменной x
. // распечатать на консоль полученное число
x off // записать в переменную x ноль


Ещё есть условный оператор и циклы.

: printbool if ." TRUE" else ." FALSE" then cr ; // создаём команду printbool
// она снимает со стека верхний элемент, и если он ноль, то печатает FALSE
// иначе TRUE
false printbool // напечатает FALSE
true printbool // напечатает TRUE
100 10 > printbool // напечатает TRUE
100 10 < printbool // напечатает FALSE
100 0<> printbool // напечатает TRUE

// печатает числа от 0 до 10
:noname 0 begin dup 10 <= while dup . 1+ repeat drop ; execute


Условные операторы и циклы являются компилирующими командами, и потому их можно использовать только внутри описания команд.

Сказанного должно быть достаточно для понимания того, что будет ниже. Чтобы не затягивать вступление до бесконечности, дам напоследок несколько ссылок
  1. Пишущееся вики
  2. Файл с реализацией условного оператора и простых циклов
  3. Файл с реализацией оператора switch
  4. Файл с реализацией работы с пространствами имён
Практически никакого контроля нет ни на стадии компиляции, ни на стадии интерпретации. Что лежит на стеке или в памяти узнать нельзя. Можно написать на свой страх и риск почти любую фигню с непредсказуемым исходом.

Кратко об игре
Лучше один раз увидеть, чем сто раз услышать, так что отсылаю читателя самому сыграть в игру (в конкурсном архиве запускать надо Doj\mazejourney\release\run.bat). Я написал на DEmbro создание окна и инициализацию OpenGL достаточно давно. Т.к. числа с плавающей точкой в DEmbro немного сыроваты (в частности, нет нормального способа передать double в виде параметра в dll функцию), я решил для упрощения жизни их не использовать вообще. Кроме того, я не писал ни поддержки текстур, ни каких-то хитрых примитивов. Единственное, что я использовал при рендере — цветные квадраты. Игру я писал с девизом «каждый уровень имеет свой уникальный геймплей». Поэтому нужно было иметь удобные конструкции для описания уровней.

Как описывается уровень
Чтобы не томить любителей ковыряться самостоятельно, рекомендую посмотреть файл «game\levels\big.de» — он является очень простым примером того, как описывается уровень. Кстати, по-поводу этого уровня, в нём можно усмотреть букву O — это чит, позволяющий пройти в определённом месте сквозь стену и оказаться у выхода. Итак, первым делом я написал команду lab, которая позволяет в наглядном текстовом виде описывать уровни. Например, так:

lab
##########
#S       #
#        #
#        #
#        #
#        #
#       F#
##########
\lab

(Из-за бага в ядре дембро, после слова «lab» нужно ставить пробел.) Команда lab использует функцию ядра «source-next-line», которая читает следующую строку исходника. По первой строке определяется ширина уровня, а дальше создаёт двумерный массив, содержащий ascii коды символов. После выполнения этой конструкции можно при помощи команды last-lab-x получить ширину лабиринта, при помощи last-lab-y высоту лабиринта, а при помощи last-lab-maze указатель на двумерный массив. Далее я написал команду «level», которая создаёт уровень. Напрямую она практически не используется, но в файле «game\levels\list.de» определяется надстройка над ней — newlevel, которая внутри себя вызывает level, выполняет файл, имя которого следует после «newlevel», и связывает уровни в последовательность. В том же файле list.de можно обнаружить сам список уровней. Как только мы создали уровень (командой level) можно приступать к его наполнению. Команда «maze!» установит на текущем уровне последний лабиринт, созданный командой «lab». При помощи команд «cell-x!» и «cell-y!» можно установить размер в пикселях одной ячейки лабирнта текущего уровня, например так: 16 cell-x! 16 cell-y! При помощи команд «offset-x!» и «offset-y!» задаётся положение лабиринта на экране. Т.к. всегда мне нужно было центрировать лабирит, я написал команду «centrize», которая автоматически вызывает «offset-x!» и «offset-y!», вычисляя смещение при помощи размера лабиринта и размера ячейки. При помощи команд «player-x!» и «player-y!» можно установить координаты ячейки, в которой будет появляться игрок в начале уровня. Перейдём к более нетривиальным элементам уровня — событиям. Можно описать команды, которые будут выполняться при старте уровня — при помощи этого механизма запускается звук гонга перед началом уровня:

:noname pchar" data\sounds\newlevel.wav" sound ;init

Тут просто вызывается команда sound, которая принимает один параметр — указатель на pchar-строку, содержащую имя звукового файла. Менее тривиальный примеры — события, когда игрок входит на какую-то ячейку. Можно установить на каждый тип (т.е. ascii-символ) ячейки свой обработчик. Обработчику на стеке передаются x,y координаты ячейки, на которую пытается войти игрок. Если их скинуть со стека, то игрок не сможет зайти в ячейку, т.е. можно описать обработчик для стены:

:noname drop drop ;enter_ #

Есть команда passed, которая, наоборот, считывает x,y и смещает в них игрока. Можно написать

:noname passed ;enter_ O

Тогда игрок сможет заходить в букву O. Чтобы буква O выглядела как обычная стена, для неё нужно определить такой же обработчик рисования (который тоже принимает координаты x,y), как и для стены:

: draw_# COLOR 2dup PURPLES GEN[]2 ^ set-color draw-cell-rect ;
:noname  draw_# ;draw_ #
:noname  draw_# ;draw_ O

Есть ещё один тип обработчиков для каждой ячейки — обработчик инициализации. Обработчики инициализации нужно установить перед вызовом команды «lab». Тогда при считывании каждой ячейки внутри конструкции «lab .... \lab» будет вызван соответствующий заданному ascii-символу обработчик инициализации. Пример использования: всегда неудобно задавать положение игрока координатами при помощи «player-x!» и «player-y!». Гораздо удобнее пометить нужную ячейку буквой S. Для этого перед выполнением «lab» достаточно добавить обработчик инициализации ячейки S:

:noname  player-y! player-x! ;oninit_ S

При входе на букву F по умолчанию задан переход к следующему уровню командой «next». При желании аналогичной функциональностью можно наделить другие буквы, и сделать переходы на другие уровни. Таковы общие принципы создания уровней. Теперь я разберу особенности разработки некоторых уровней. Часто, из-за копипасты в описнии уровней можно увидеть много ненужного кода, — я не успел его удалить до дедлайна.  

Уровень в темноте (dark.de)
На этом уровне игрок видит только соседние с собой клетки. Нужно просто при рисовании стены посчитать расстояние игрока до стены, и если оно больше одного, то не рисовать стену:

:
  2dup player-y @ - abs swap player-x @ - abs max 1 > if drop drop exit then
  COLOR 2dup PURPLES GEN[]2 ^ set-color draw-cell-rect
;draw_ #


Уровень-цикл (loop.de)
Тоже уровень в темноте, в котором если ходить по часовой стрелке, то будешь бегать по кругу, а если пойти против часовой стрелки, то прийдёшь к выходу. Реализация простая: описываются при помощи команды «lab» три лабиринта — тот, который изначально, тот, который с циклом, и тот, который с выходом. Изначальный нужен, чтобы закрутить игрока идти по часовой стрелки, его мы устанавливаем в уровне командой «maze!». Остальные два сохраняем в переменные. Вот, например, цикличный лабиринт:

lab
     #######
     #1    #
###### ### #
#      # # #
# ###### # #
# #      # #
# #      # #
# ######## #
#     2    #
############
\lab
  last-lab-x value 1maze-x
  last-lab-y value 1maze-y
  last-lab-maze value 1maze-ptr

На карте есть ячейки 1 и 2. При наступании на 1 включается лабиринт с циклом, при наступании на 2 — лабиринт с выходом. Вот их обработчики:

:noname passed 1maze-x maze-x ! 1maze-y maze-y ! 1maze-ptr maze-ptr ! ;enter_ 1
:noname passed 2maze-x maze-x ! 2maze-y maze-y ! 2maze-ptr maze-ptr ! ;enter_ 2

Проверка видимости сделана так же, как и в dark.de, только расстояние берётся не 1, а 3 ячейки.  

Уровень с убегающим выходом (runningexit.de)
Алгоритм убегания выхода простой. Если игрок приблизился на близкое расстояние по обеим координатам, то выход смещается. Выбирается координата, по которой расстояние до игрока больше, после чего выход смещается от игрока по этому направлению. Если это не удаётся (стена), пытаемся сместить выход в перпендикулярном направлении. Поэтому, например, если игрок идёт на выход по узкому коридору, то выход будет убегать по этому коридору от игрока, и свернёт в бок, как только упрётся в стену. Там используется событие after-update для всех этих расчётов и рендера выхода (рендер сделан отдельный, потому что выход при смещении с ячейки на ячейку должен смещаться плавно). Когда выход смещается, в лабиринте перезаписывается его расположение, поэтому событие попадания в выход обрабатывается корректно обычным образом.

Уровень с двигающимися стенами (movingwalls.de)
Идея аналогична уровню-циклу. Я создал два лабиринта: главный прямоугольник с выходом, и лабиринт со штуковиной. И сохранил его в переменные (moving-x, moving-y, moving-ptr). Далее, я переопределил события рендера и входа для пробела:

: draw#brown COLOR 2dup BROWNS GEN[]2 ^ set-color draw-cell-rect ;
:noname  2dup moving-x * + player-y @ + cells moving-ptr + @
         35 = if draw#brown else 2drop then ;draw_
:noname  2dup moving-x * + player-y @ + cells moving-ptr + @
         35 = if 2drop else passed then ;enter_  

(Там в конце, каждого определения должно быть по два пробела.) Число 35 — это ascii код для символа решётки (который я получил при помощи команды «ga» в виме). Тут всё просто: чтобы определить нужно ли рисовать на пустом месте что-то, берётся значение из вспомогательного лабиринта со штуковиной, с прибавлением y-координаты игрока.  

Заключение
 В целом, разрабатывать игру мне очень понравилось.

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


1 комментарий:

Darthman комментирует...

Ты маньяк! Но я это уважаю :)

Отправить комментарий

Постоянные читатели

Обо мне

Моя фотография
Мой e-mail: vitek_03(at)mail(dot)ru