Программист, подписывающийся псевдонимом Fleabit, уже полгода разрабатывает свой язык программирования. Сразу же возникает вопрос: ещё один язык? Зачем? Вот его аргументы:
- Разработка движка игры и разработка игры на этом движке – две очень разные задачи, и для них удобно использовать разные языки, при условии, что код на них хорошо стыкуется друг с другом. Например, код на языке с garbage collection и на языке с явным управлением памятью было бы сложно объединить в одном проекте.
- Rust идеально подходит для разработки движка игры: из языков, ориентированных на производительность скомпилированного кода, в нём максимум выразительных средств – enum-ы с полями; pattern matching с деструктуризацией; макросы, генерирующие произвольный код во время компиляции; и т.п. С другой стороны, для описания игровой механики Rust подходит плохо: задержки на перекомпиляцию усложняет подход «подправить и тут же проверить, что получилось»; строгое управление памятью усложняет использование одних данных одновременно несколькими объектами; а генераторы/сопрограммы, позволяющие удобно реализовать кооперативную многозадачность между внутриигровыми сущностями, ещё не реализованы.
- Для игровой механики идеально подходил бы скриптовый язык наподобие JavaScript, Lua, Python или Ruby; но интеграция кода на них в проект на Rust – нетривиальная задача, отчасти из-за того, что эти полновесные языки программирования устроены запредельно сложно. Вдобавок, внутри игры напрашивается очень простой garbage collector, отрабатывающий после генерации каждого кадра, чтобы частота кадров оставалась постоянной – без внезапных подвисаний раз в десять минут, когда GC решил пройтись по всем объектам, созданным за эти десять минут. Другое важное преимущество GameLisp перед популярными скриптовыми языками – гомоиконичность, упрощающая обработку и генерацию кода макросами.
- Фишка GameLisp, которой не было бы места в универсальном скриптовом языке – встроенная поддержка конечных автоматов, позволяющая сгруппировать члены класса в функциональные блоки, включаемые и отключаемые как одно целое. Это в некотором роде расширение идеи enum-ов с полями из Rust, к которой добавлен внутриклассовый полиморфизм, когда одно и то же имя метода связывается с разной реализацией в зависимости от состояния объекта. Моделируемые автоматы "недетерминированные" в том смысле, что одновременно может быть активно произвольное число состояний.
От Lisp в GameLisp взяты прежде всего простота синтаксиса и простота интерпретатора: реализация GameLisp вместе со «стандартной библиотекой» сейчас занимает 36 KLOC, по сравнению, например, с 455 KLOC в СPython. С другой стороны, по сравнению с обычным Lisp, в GameLisp нет списков и намного меньше ориентации на функциональное программирование и immutable-данные; вместо этого, как и большинство скриптовых языков, GameLisp ориентирован на императивное, объектно-ориентированное программирование.
Синтаксис на основе Lisp с непривычки может вызвать оторопь, но быстро привыкаешь вместо console.print(2 + 2) писать (.print console (+ 2 2)) и т.д. Этот синтаксис намного проще и гибче, чем в привычных скриптовых языках: запятая считается пробельным символом, и может использоваться для улучшения читаемости в любых местах кода; вместо двух видов скобок {}() используются только круглые; большинство знаков ASCII можно использовать в составе символов, так что I~<3~Lisp!~^_^ – допустимое имя для функции или переменной; не нужны; для разделения операций, и т.д. Могу сказать, что безо всякого прошлого опыта с Lisp я всего за пару вечеров сумел переписать классический NIBBLES.BAS на GameLisp: http://atari.ruvds.com/nibbles.html Всё, что есть в «стандартной библиотеке» GameLisp из средств ввода-вывода – это функция prn для печати на stdout; нет работы ни с клавиатурой/мышью, ни с файлами, ни с графикой, ни со звуком. Предполагается, что пользователь GameLisp сам реализует на Rust все те интерфейсные средства, которые актуальны конкретно в его проекте. В качестве примера такой обвязки на https://gamelisp.rs/playground/ выложен минималистичный движок для браузерных игр, при помощи wasm-bindgen предоставляющий коду на GameLisp функции play:down?, play:pressed?, play:released?, play:mouse-x, play:mouse-y, play:fill и play:draw. В моём порте Nibbles используется тот же самый движок – я лишь добавил к нему функцию для воспроизведения звука. Интересно сравнить размеры: оригинальный NIBBLES.BAS занимал 24 КБ; мой порт на GameLisp занимает 9 КБ; файл на WebAssembly со скомпилированными воедино рантаймом Rust, интерпретатором GameLisp, и кодом игры – занимает 2.5 МБ, и к нему ещё прилагается обвязка на JavaScript в 11 КБ, сгенерированная wasm-bindgen. Вместе с минималистичным движком на https://gamelisp.rs/playground/ выложены реализации на GameLisp трёх классических игр: pong, тетрис и сапёр. Тетрис и сапёр больше и сложнее, чем мой порт Nibbles, и в их коде есть чему поучиться. Для демонстрации возможностей GameLisp я выбрал два примера; первый касается макросов. В NIBBLES.BAS уровни заданы стастрочным блоком SELECT CASE со вложенными циклами:
SELECT CASE curLevel CASE 1 sammy(1).row = 25: sammy(2).row = 25 sammy(1).col = 50: sammy(2).col = 30 sammy(1).direction = 4: sammy(2).direction = 3 CASE 2 FOR i = 20 TO 60 Set 25, i, colorTable(3) NEXT i sammy(1).row = 7: sammy(2).row = 43 sammy(1).col = 60: sammy(2).col = 20 sammy(1).direction = 3: sammy(2).direction = 4 CASE 3 FOR i = 10 TO 40 Set i, 20, colorTable(3) Set i, 60, colorTable(3) NEXT i sammy(1).row = 25: sammy(2).row = 25 sammy(1).col = 50: sammy(2).col = 30 sammy(1).direction = 1: sammy(2).direction = 2 ...
Все эти циклы имеют похожую структуру, которую можно вынести в макрос:
(let-macro set-walls (range ..walls) `(do ~..(map (fn1 `(forni (i ~..range) (set-wall ~.._))) walls)))
С этим макросом описание всех уровней сокращается вчетверо, и становится максимально близким к декларативному JSON-подобному описанию:
(match @level (1 (set-locations '(25 50 right) '(25 30 left))) (2 (set-walls (20 60) (25 i)) (set-locations '(7 60 left) '(43 20 right))) (3 (set-walls (10 40) (i 20) (i 60)) (set-locations '(25 50 up) '(25 30 down))) ...
В языке без макросов – например, в JavaScript – аналогичная реализация затуманила бы всё описание уровней лямбдами:
switch (level) { case 1: setLocations([25, 50, "right"], [25, 30, "left"]); break; case 2: setWalls([20, 60], i => [25, i]); setLocations([7, 60, "left"], [43, 20, "right"]); break; case 3: setWalls([10, 40], i => [i, 20], i => [i, 60]); setLocations([25, 50, "up"], [25, 30, "down"]); break; ...
На этом примере хорошо видно, насколько код на JavaScript перегружен разнообразной пунктуацией и служебными словами, без которых можно обойтись.
Второй мой пример касается конечных автоматов. Реализация игры у меня имеет следующую структуру:
(defclass Game ... (fsm (state Playing (field blink-rate (Rate 0.2)) (field blink-on) (field move-rate (Rate 0.3)) (field target) (field prize 1) (state Paused (init-state () (@center "*** PAUSED ***" 0)) (wrap Playing:update (dt) (when (play:released? 'p) (@center " LEVEL {@level} " 0) (@disab! 'Paused)))) (met update (dt) ... (when (play:released? 'p) (@enab! 'Paused) (return)) ... ; Move the snakes (.at @move-rate dt (fn0 (for snake in @snakes (when (> [snake 'lives] 0) (let position (clone [[snake 'body] 0])) ... ; If player runs into any point, he dies (when (@occupied? position) (play:sound 'die) (dec! [snake 'lives]) (dec! [snake 'score] 10) (if (all? (fn1 (== 0 [_ 'lives])) @snakes) (@enab! 'Game-Over) (@enab! 'Erase-Snake snake)) (return)) ... (state Game-Over (init-state () (play:fill ..(@screen-coords 10 (-> @grid-width (/ 2) (- 16))) ..(@screen-coords 7 32) 255 255 255) (play:fill ..(@screen-coords 11 (-> @grid-width (/ 2) (- 15))) ..(@screen-coords 5 30) ..@background) (@center "G A M E O V E R" 13)) (met update (dt)))))
На каждом кадре (по вызову из window.requestAnimationFrame) игровой движок вызывает метод Game.update. Внутри класса Game определён автомат из состояний Init-Level, Playing, Erase-Snake, Game-Over, каждое из которых определяет метод update по-своему. В состоянии Playing определены пять приватных полей, к которым невозможно обратиться из других состояний. Кроме того, в состоянии Playing есть вложенное состояние Paused, т.е. игра может находиться как в состоянии Playing, так и в состоянии Playing:Paused. Конструктор состояния Paused печатает на экране соответствующую строку каждый раз при переходе к этому состоянию; метод update в этом состоянии проверяет, нажата ли клавиша P повторно, и если нажата и отпущена, то выходит из состояния Paused, возвращаясь к «простому» состоянию Playing. Метод update состояния Playing обрабатывает нажатия клавиш, рассчитывает новое положение игроков, и если кто-то из них врезался в стену, то переходит либо в состояние Game-Over, либо в состояние Erase-Snake. Конструктор состояния Erase-Snake интересен тем, что он принимает параметром ссылку на змейку, которую нужно красиво стереть перед перезапуском уровня. Наконец, у состояния Game-Over конструктор выводит на экран соответствующее сообщение, а метод update пустой – это значит, что независимо от нажимаемых клавиш, ничего нового на экране рисоваться не будет, и выйти из этого состояния невозможно.
Аналогично можно было бы реализовать игру и на классическом скриптовом языке: у класса Game были бы вложенные классы InitLevel, Playing, EraseSnake, GameOver, было бы поле currentState, и метод Game.update делегировал бы вызов currentState.update. Внутри класса Playing был бы вложенный класс Paused, и метод Playing.update в свою очередь делегировал бы вызов подобъекту. Макросы стандартной библиотеки позволяют спрятать автоматическую генерацию полей currentState и делегирующих методов, чтобы разработчик игры видел содержательную реализацию состояний, а не их шаблонное обрамление.
Вместо конечного автомата можно было бы реализовать Nibbles и в виде цикла:
while (lives>0) { InitLevel; while (prize<10) { Playing; if (dies) { EraseSnake; break; } } } GameOver;
Так и была реализована оригинальная игра на QBasic. Для браузерного движка такой цикл был бы заключён в генератор с yield после отрисовки каждого кадра, а Game.update состоял бы из вызова iter-next!.. Я предпочёл реализацию в виде автомата по двум причинам: во-первых, именно так устроена реализация тетриса, которую автор GameLisp приводит как пример; и во-вторых, в генераторах в GameLisp нет ничего необычного по сравнению с другими скриптовыми языками. Основное предназначение для автоматов – реализация состояний игровых персонажей (ждёт, атакует, убегает и т.д.), невозможная посредством цикла внутри генератора. Дополнительный аргумент в пользу автоматов – изоляция данных, относящихся к каждому из состояний, друг от друга.