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

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

Let’s block ads! (Why?)

Read More

Recent Posts

Apple возобновила переговоры с OpenAI и Google для интеграции ИИ в iPhone

Apple возобновила переговоры с OpenAI о возможности внедрения ИИ-технологий в iOS 18, на основе данной операционной системы будут работать новые…

23 часа ago

Российская «дочка» Google подготовила 23 иска к крупнейшим игрокам рекламного рынка

Конкурсный управляющий российской «дочки» Google подготовил 23 иска к участникам рекламного рынка. Общая сумма исков составляет 16 млрд рублей –…

1 день ago

Google завершил обновление основного алгоритма March 2024 Core Update

Google завершил обновление основного алгоритма March 2024 Core Update. Раскатка обновлений была завершена 19 апреля, но сообщил об этом поисковик…

1 день ago

Нейросети будут писать тексты объявления за продавцов на Авито

У частных продавцов на Авито появилась возможность составлять текст объявлений с помощью нейросети. Новый функционал доступен в категории «Обувь, одежда,…

1 день ago

Объявлены победители международной премии Workspace Digital Awards-2024

24 апреля 2024 года в Москве состоялась церемония вручения наград международного конкурса Workspace Digital Awards. В этом году участниками стали…

2 дня ago

Яндекс проведет гик-фестиваль Young Con

27 июня Яндекс проведет гик-фестиваль Young Con для студентов и молодых специалистов, которые интересуются технологиями и хотят работать в IT.…

2 дня ago