Обзор GameLisp: нового языка для написания игр на Rust

МЕНЮ


Главная страница
Поиск
Регистрация на сайте
Помощь проекту
Архив новостей

ТЕМЫ


Новости ИИРазработка ИИВнедрение ИИРабота разума и сознаниеМодель мозгаРобототехника, БПЛАТрансгуманизмОбработка текстаТеория эволюцииДополненная реальностьЖелезоКиберугрозыНаучный мирИТ индустрияРазработка ПОТеория информацииМатематикаЦифровая экономика

Авторизация



RSS


RSS новости


Программист, подписывающийся псевдонимом 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 нет ничего необычного по сравнению с другими скриптовыми языками. Основное предназначение для автоматов – реализация состояний игровых персонажей (ждёт, атакует, убегает и т.д.), невозможная посредством цикла внутри генератора. Дополнительный аргумент в пользу автоматов – изоляция данных, относящихся к каждому из состояний, друг от друга.

Источник: habr.com

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