Современная игра для NES, написанная на Lisp-подобном языке

What Remains — это повествовательная игра-адвенчура для 8-битной видеоигровой консоли NES, выпущенная в марте 2019 года как бесплатный ROM, запускаемый в эмуляторе. Она создавалась небольшой командой Iodine Dynamics на протяжении двух лет с перерывами. В настоящий момент игра находится на этапе реализации в «железе»: мы создаём из переработанных деталей ограниченный набор картриджей.


В игре есть 6 уровней, на которых игрок ходит по нескольким сценам с картами с прокруткой в четырёх направлениях, общается с NPC, собирает улики, знакомится их миром, играет в мини-игры и решает простые головоломки. Я был главным инженером проекта, поэтому столкнулся со множеством трудностей при реализации видения команды. Учитывая серьёзные ограничения оборудования NES, достаточно сложно создавать для неё любую игру, не говоря уже о проекте с таким количеством контента, как в What Remains. Только благодаря созданным полезным подсистемам, позволяющим скрыть эту сложность и управлять ею, мы смогли работать как одна команда и завершить игру.


В этой статье я расскажу о некоторых технических подробностях отдельных частей движка игры. Надеюсь, другим разработчикам они окажутся полезными или хотя бы любопытными.

Оборудование NES
Прежде чем приступать к коду, расскажу немного о спецификациях оборудования, с которым мы работаем. NES — это игровая консоль, выпущенная в 1983 году (Япония, 1985 год — Америка). Внутри у неё 8-битный ЦП 6502 [1] с частотой 1,79 МГц. Так как консоль выдаёт 60 кадров в секунду, на один кадр приходится примерно 30 тысяч циклов ЦП, а это довольно мало для вычисления всего, что происходит в основном геймплейном цикле.

Кроме того, консоль имеет всего 2048 байт ОЗУ (которые можно расширить до 10240 байт при помощи дополнительной ОЗУ, чего мы не делали). Также она может адресовать за раз 32 КБ ПЗУ, которое можно расширить переключением банков (в What Remains используется 512 КБ ПЗУ). Переключение банков — это сложная тема [2], с которой не имеют дела современные программисты. Если вкратце, то адресное пространство, доступное ЦП, меньше, чем содержащиеся в ПЗУ данные, то есть при переключении вручную целые блоки памяти остаются недоступными. Вы хотели вызвать какую-то функцию? Её нет, пока вы не замените банк, вызвав команду переключения банков. Если этого не сделать, то при вызове функции произойдёт сбой в программе.

На самом деле, самое сложное при разработке игры для NES — учитывать всё это одновременно. Оптимизация одного аспекта кода, например, использования памяти, часто может повлиять на что-то другое, например, на производительность ЦП. Код должен быть эффективным и при этом удобным в поддержке. Обычно игры программировались на языке ассемблера.

Co2
Но в нашем случае всё было не так. Вместо этого в тандеме с игрой бы разработан собственный язык. Co2 — это похожий на Lisp язык, построенный на Racket Scheme и компилируемый в ассемблер 6502. Изначально язык создавался Дэйвом Гриффитсом для сборки демо What Remains, и я решил использовать его для целого проекта.

Co2 позволяет при необходимости писать встроенный ассемблерный код, но также имеет высокоуровневые возможности, упрощающие некоторые задачи. В нём реализованы локальные переменные, которые эффективны как с точки зрения потребления ОЗУ, так и скорости доступа [2]. Он имеет очень простую систему макросов, позволяющую писать читаемый и в то же время эффективный код [3]. Важнее всего то, что благодаря гомоиконичности Lisp он сильно упрощает отображение данных непосредственно в исходниках.

Написание собственных инструментов достаточно широко распространено в разработке игр, но создание целого языка программирования встречается намного реже. Тем не менее, мы это сделали. Не очень понятно, оправдала ли себя сложность разработки и поддержки Co2, но у него определённо были преимущества, помогавшие нам. В посте я не буду подробно рассказывать о работе Co2 (это заслуживает отдельной статьи), но постоянно буду упоминать его, потому что его использование довольно тесно переплетено с процессом разработки.

Вот пример кода на Co2, рисующий фон для только что загруженной сцены перед её затемнением:

; Render the nametable for the scene at the camera position
(defsub (create-initial-world)
(camera-assign-cursor)
(set! camera-cursor (+ camera-cursor 60))
(let ((preserve-camera-v))
(set! preserve-camera-v camera-v)
(set! camera-v 0)
(loop i 0 60
(set! delta-v #xff)
(update-world-graphics)
(when render-nt-span-has
(set! render-nt-span-has #f)
(apply-render-nt-span-buffer))
(when render-attr-span-has
(set! render-attr-span-has #f)
(apply-render-attr-span-buffer)))
(set! camera-v preserve-camera-v))
(camera-assign-cursor))
Система сущностей

Любая игра реального времени сложнее «Тетриса» по своей сути является «системой сущностей». Это функциональность, позволяющая различным независимым акторам действовать одновременно и отвечать за своё собственное состояние. Хотя What Remains ни в коем случае не является активной игрой, в ней всё равно есть множество независимых акторов со сложным поведением: они анимируют и отрисовывают себя, проверяют коллизии и вызывают диалоги.

Реализация довольно типична: большой массив содержит список сущностей в сцене, каждая запись содержит относящиеся к сущности данные вместе с меткой типа. Функция обновления в основном геймплейном цикле обходит все сущности и реализует в зависимости от их типа соответствующее поведение.

; Called once per frame, to update each entity
(defsub (update-entities)
(when (not entity-npc-num)
(return))
(loop k 0 entity-npc-num
(let ((type))
(set! type (peek entity-npc-data (+ k entity-field-type)))
(when (not (eq? type #xff))
(update-single-entity k type)))))
Способ хранения данных сущностей более интересен. В целом игра имеет так много уникальных сущностей, что проблемой может стать использование большого количества ПЗУ. Здесь свою мощь проявляет Co2, позволяя нам представить каждую сущность сцены в лаконичном, но читаемом виде — как поток пар «ключ-значение». Кроме таких данных, как начальная позиция, почти каждый ключ необязателен, что позволяет объявлять их сущностям только при необходимости.

(bytes npc-diner-a 172 108
prop-palette 1
prop-hflip
prop-picture picture-smoker-c
prop-animation simple-cycle-animation
prop-anim-limit 6
prop-head hair-flip-head-tile 2
prop-dont-turn-around
prop-dialog-a (2 progress-stage-4 on-my-third my-dietician)
prop-dialog-a (2 progress-stage-3 have-you-tried-the-pasta the-real-deal)
prop-dialog-a (2 progress-diner-is-clean omg-this-cherry-pie
its-like-a-party)
prop-dialog-a (2 progress-stage-1 cant-taste-food puff-poof)
prop-dialog-b (1 progress-stage-4 tea-party-is-not)
prop-dialog-b (1 progress-stage-3 newspaper-owned-by-dnycorp)
prop-dialog-b (1 progress-stage-2 they-paid-a-pr-guy)
prop-dialog-b (1 progress-stage-1 it-seems-difficult)
prop-customize (progress-stage-2 stop-smoking)
0)
В этом коде prop-palette задаёт используемую для сущности цветовую палитру, prop-anim-limit задаёт количество кадров анимации, а prop-dont-turn-around не позволяет NPC поворачиваться, если игрок пытается поговорить с ним с другой стороны. Здесь также задаётся пара флагов условий, изменяющих поведение сущности в процессе прохождения игроком игры.

Такой вид представления очень эффективен для хранения в ПЗУ, но очень медленен при доступе во время выполнения, и будет слишком неэффективен для геймплея. Поэтому когда игрок входит в новую сцену, все сущности в этой сцене загружаются в ОЗУ и обрабатывают все условия, которые могут повлиять на их исходное состояние. Но загрузить можно не любую деталь для каждой сущности, потому что это заняло бы больше ОЗУ, чем доступно. Движок загружает только самое необходимое для каждой сущности, плюс указатель на её полную структуру в ПЗУ, который разыменуется в таких ситуациях, как обрабтка диалогов. Такой специфический набор компромиссов позволил нам обеспечить достаточный уровень производительности.

Порталы

В игре What Remains есть множество различных локаций, несколько сцен на улице со скроллингом карт и множество сцен в помещениях, остающихся статичными. Для перехода из одной в другую нужно определить, что игрок достиг выхода, загрузить новую сцену, а затем поместить игрока в нужную точку. На ранних этапах разработки такие переходы описывались уникальным образом как две соединённые сцены, например «первый город» и «кафе» и данными в операторе if о расположении дверей в каждой сцене. Для того, чтобы определить, куда поместить игрока после смены сцены, нужно было просто проверить, откуда и куда он шёл, и поместить его рядом с соответствующим выходом.

Однако когда мы начали заполнять сцену «второй город», который соединяется с первым городом в двух разных местах, такая система начала разваливаться. Внезапно оказалось, что пара (начальная_точка, конечная_точка) больше не подходит. Поразмыслив над этим, мы поняли, что на самом деле важно само соединение, которое внутри кода игры вызывает «портал». Чтобы учесть эти изменения, движок был переписан. что привело нас к ситуации, похожей на сущности. Порталы могли хранить списки пар «ключ-значение» и загружаться в начале сцены. При входе в портал можно было использовать ту же информацию о позиции, что и при выходе. Кроме того, упростилось добавление условий, аналогично тому, что было у сущностей: в определённые моменты игры мы могли модифицировать порталы, например, открывать или закрывать двери.

; City A
(bytes city-a-scene #x50 #x68 look-up
portal-customize (progress-stage-5 remove-self)
; to Diner
diner-scene #xc0 #xa0 look-down
portal-width #x20
0)
Также это упростило процесс добавления «точек телепортации», которые часто применялись в кинематографических вставках, где игрок должен был перемещаться на другую в сцену в зависимости от того, что происходит в сюжете.

Вот как выглядит телепортация в начале уровня 3:

; Jenny’s home
(bytes jenny-home-scene #x60 #xc0 look-up
portal-teleport-only jenny-back-at-home-teleport
0)
Обратите внимание на значение look-up, обозначающее направление для «входа» в этот портал. При выходе из портала игрок будет смотреть в другом направлении; в данном случае, Дженни (главный герой игры) оказывается у себя дома, смотря при этом вниз.

Текстовый блок
Отрисовка текстового блока оказалась одним из самых сложных фрагментов кода во всём проекте. Графические ограничения NES заставили идти на хитрости. Начнём с того, что у NES только один слой для графических данных, то есть чтобы освободить пространство для текстового блока, необходимо стереть часть карты на фоне, а затем восстановить её после закрытия текстового блока.


К тому же палитра для каждой отдельной сцены должна содержать для отрисовки текста чёрный и белый цвета, что накладывает на художника дополнительные ограничения. Чтобы избежать конфликтов цветов с остальной частью фона, текстовый блок должен быть выровнен относительно сетки 16×16 [5]. Отрисовка текстового блока в сцене с помещением гораздо проще, чем в уличной, где камера может двигаться, потому что в таком случае приходится учитывать прокручивающиеся по вертикали и горизонтали графические буферы. Наконец, сообщение экрана паузы — это немного модифицированное стандартное поле диалога, потому что отображает другую информацию, но использует практически тот же код.

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

Затем построчно отрисовывается текстовый блок с сохранением состояния и при этом используются вычисления из первого этапа, чтобы не усложнять код.

; Called once per frame as the text box is being rendered
(defsub (text-box-update)
(when (or (eq? tb-text-mode 0) (eq? tb-text-mode #xff))
(return #f))
(cond
[(in-range tb-text-mode 1 4)
(if (not is-paused)
; Draw text box for dialog.
(text-box-draw-opening (- tb-text-mode 1))
; Draw text box for pause.
(text-box-draw-pausing (- tb-text-mode 1)))
(inc tb-text-mode)]
[(eq? tb-text-mode 4)
; Remove sprites in the way.
(remove-sprites-in-the-way)
(inc tb-text-mode)]
[(eq? tb-text-mode 5)
(if (not is-paused)
; Display dialog text.
(when (not (crawl-text-update))
(inc tb-text-mode)
(inc tb-text-mode))
; Display paused text.
(do (create-pause-message)
(inc tb-text-mode)))]
[(eq? tb-text-mode 6)
; This state is only used when paused. Nothing happens, and the caller
; has to invoke `text-box-try-exiting-pause` to continue.
#t]
[(and (>= tb-text-mode 7) (< tb-text-mode 10))
; Erase text box.
(if (is-scene-outside scene-id)
(text-box-draw-closing (- tb-text-mode 7))
(text-box-draw-restoring (- tb-text-mode 7)))
(inc tb-text-mode)]
[(eq? tb-text-mode 10)
; Reset state to return to game.
(set! text-displaying #f)
(set! tb-text-mode 0)])
(return #t))
Если привыкнуть к стилю Lisp, то код читается вполне удобно.

Z-слои спрайтов
В конце я расскажу о небольшой детали, которая не особо влияет на геймплей, но добавляет хороший штрих, которым я горжусь. NES имеет всего два графических компонента: таблицу имён (nametable), которая используется для статичных и выровненных по сетке фонов, и спрайты — объекты размером 8×8 пикселей, которые можно размещать в произвольных местах. Такие элементы, как персонаж игрока и NPC, обычно создаются как спрайты, если они должны находиться поверх графики таблицы имён.

Однако оборудование NES также предоставляет возможность задать спрайтам часть, которую можно полностью разместить под таблицей имён. Это без особых усилий позволяет реализовывать крутой 3D-эффект.


Работает это следующим образом: палитра, используемая для текущей сцены, обрабатывает цвет в позиции 0 особым образом: он является глобальным цветом фона. Таблица имён отрисовывается поверх него, и спрайты с z-слоем отрисовываются между двумя другими слоями.

Вот палитра этой сцены:


Итак, тёмно-серый цвет в самом левом углу используется как глобальный цвет фона.

Эффект слоёв работает следующим образом:


В большинстве других игр на этом всё заканчивается, однако What Remains сделала ещё один шаг вперёд. Игра размещает Дженни не полностью перед или под графикой таблицы имён — её персонаж разделяется между ними нужным образом. Как видите, спрайты имеют размер 8×8 единиц, и графика всего персонажа состоит из нескольких спрайтов (от 3 до 6, в зависимости от кадра анимации). Каждый спрайт может сам задавать свой z-слой, то есть некоторые спрайты будут находиться перед таблицей имён, а другие — за ней.

Вот пример этого эффекта в действии:


Алгоритм реализации этого эффекта довольно хитёр. Сначала исследуются окружающие игрока данные коллизий, в частности, тайлы, которые может занять отрисовка целого персонажа. На этой схеме красными квадратами показаны твёрдые тайлы, а жёлтые тайлы задают часть с z-слоем.


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


При размещении отдельных спрайтов для отрисовки игрока их положение сравнивается с опорной точкой для определения z-слоя этого конкретного спрайта. Некоторые из них оказываются на переднем слое, другие — на заднем.


Заключение
Я вкратце рассказал о разных аспектах внутренней работы нашей новой современной ретро-игры. В кодовой базе есть гораздо больше интересного, но я изложил значительную часть того, благодаря чему игра работает.

Самый важный урок, который я извлёк из этого проекта, заключается в преимуществах, которые можно получить от движков, управляемых данными. Мне несколько раз удавалось заменять какую-нибудь уникальную логику таблицей и мини-интерпретатором, и благодаря этому код становился проще и читабельнее.

Надеюсь, вам понравилась статья!

Примечания
[1] Строго говоря, в NES была установлена разновидность ЦП 6502 под названием Ricoh 2A03.

[2] На самом деле, этот проект убедил меня, что переключение банков/управление ПЗУ — это основное ограничение для любого проекта NES, превышающего определённый размер.

[3] За это стоит поблагодарить «скомпилированный стек» — концепцию, используемую в программировании встроенных систем, хотя мне и с трудом удалось найти литературу о ней. Если вкратце, то нужно построить полный граф вызовов проекта, отсортировать его от узлов-листьев до корня, а затем назначить каждому узлу память, равную его потребностям + максимальному количеству дочерних узлов.

[4] Макросы были добавлены на довольно поздних этапах разработки, и, честно говоря, нам не удалось особо воспользоваться их преимуществами.

[5] Подробнее о графике NES можно прочитать в моей серии статей. Конфликты цветов вызываются атрибутами, описанными в первой части.

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