[Перевод] Создание игр для NES на ассемблере 6502: скроллинг фона

lyrwvsetfh0p1zyrktuvcappjse.png


Оглавление

Оглавление


Часть I: подготовка


Часть II: графика



Содержание:

  • Использование PPUSCROLL
  • Системы камер
  • Подготовка фонов к скроллингу
  • Реализация автоскроллинга
  • Логические фильтры
  • Подводим итог
  • Домашняя работа


Мы уже рассмотрели отрисовку и перемещение спрайтов, но фоны мы пока только отрисовывали. NES имеет возможность плавного скроллинга фонов с точностью до одного пикселя в кадр. В отличие от этой системы, скроллинг фона на Atari 2600 был гораздо менее плавным, что можно увидеть в игре Vanguard:
[Vanguard (SNK, 1982 год) — это шутер со скроллингом вбок, изначально выпущенный для аркадных автоматов, а позже портированный на Atari 2600.]

Своими возможностями скроллинга NES обязана нескольким специальным регистрам в PPU, а также структуре хранения фонов в памяти. Напомню, что отображение памяти PPU консоли NES содержит место для четырёх таблиц имён, но физической ОЗУ хватает для хранения только двух. Эти две таблицы имён могут иметь или вертикальное расположение, при котором $2000 и $2800 являются «реальными», или горизонтальное расположение, при котором «реальными» являются $2000 и $2400. Выбор схемы зависит от того, как был изготовлен картридж. В самых старых картриджах есть площадки для припоя «V» и «H». Схема выбирается в зависимости от того, какие из площадок соединены.

5355a67166e9143a355a1d9a83c8e922.jpg


Площадки для припоя на плате картриджа Donkey Kong (Nintendo, 1983 год). Здесь соединены площадки «V», то есть в этом картридже используется вертикальное расположение таблиц имён (также называемое горизонтальным зеркалированием).

Использование PPUSCROLL


В большинстве случаев управление скроллингом выполняется при помощи записи в отображённый в память адрес ввода-вывода PPUSCROLL ($2005) во время Vblank (т. е. в обработчике NMI).

[Что же это за «другие случаи» в которых скроллинг обрабатывается иначе? При помощи аккуратных манипуляций с внутренним устройством PPU можно создать эффекты разделения экрана, при которых верхняя и нижняя части экрана находятся в разных позициях скроллинга. Для этого требуются записи в PPUSCROLL и PPUCTRL за очень короткий промежуток времени, когда PPU почти завершил отрисовку растровой строки перед разделением.]

Обработчик NMI должен выполнять запись в PPUSCROLL дважды за кадр. Первая операция записи определяет позицию скроллинга по X в пикселях, а вторая операция записи — позицию скроллинга по Y, тоже в пикселях. Записи в PPUSCROLL должны всегда происходить в конце обработчика NMI (или, по крайней мере, после любых записей в другие регистры PPU наподобие PPUADDR), потому что PPU использует одинаковые внутренние регистры и для доступа к памяти, и для информации о скроллинге, то есть операции записи в другие регистры PPU могут изменять позицию скроллинга.

Что подразумевается под «позициями скроллинга» по X и Y, зависит от расположения таблиц имён картриджа и того, что было записано в PPUCTRL. PPU отслеживает «текущую», или «базовую» таблицу имён, которую можно задать при помощи записи в PPUCTRL. Самые младшие два бита байта, записанного в PPUCTRL, задают базовую таблицу имён. 00 обозначает таблицу имён по адресу $2000, 01 — по адресу $2400, 10 — по адресу $2800, а 11 — по адресу $2c00. После задания базовой таблицы имён позиции скроллинга по X и Y являются смещениями от этой базовой таблицы имён.

Возьмём для примера стандартную игру с горизонтальным расположением. В такой схеме есть две таблицы имён: $2000 и $2400. Если задать в качестве базовой таблицы имён $2000 и присвоить обеим позициям скроллинга нулевое значение, то в результате на экране в качестве фонового изображения будет полная таблица имён по адресу $2000. Вот как это выглядит:

ua2tqldthxhlsjs_jcxpaxlx70k.png


Допустим, что нам нужно переместить «камеру» на двадцать пикселей вправо. При горизонтальном расположении мы увидим всё, кроме двадцати самых левых пикселей таблицы имён по адресу $2000, выровненное по левому краю экрана, а в правой части экрана будет двадцать самых левых пикселей таблицы имён по адресу $2400.

ee99a2ce12f953905ea9b4e66abc504e.png


Расположенные бок о бок таблицы имён в горизонтальной схеме, синим показана сетка атрибутов. При движении игрока вправо значение позиции скроллинга по X увеличивается и окно просмотра перемещается по двум таблицам имён. Здесь показаны первые два экрана уровня Metal Man в игре Mega Man 2 (Capcom, 1988 год).

В коде это выглядит так:

-ryelwivkrr0acda3wxoqgofjbe.png


Что будет делать этот код в игре с вертикальным расположением? Когда позиция скроллинга настроена так, чтобы окно просмотра вьюпорт может перемещаться за пределы двух «реальных» таблиц имён, вьюпорт вместо этого оборачивается вокруг. Однако в большинстве игр эта функция не используется; обычно в игре с вертикальным расположением предотвращается скроллинг по горизонтали, и наоборот.

Системы камер


Теперь, когда мы увидели, как выполнять скроллинг фона, настало время разобраться, когда скроллить фон. Хотя кажется естественным, что двигающийся вправо персонаж должен скроллить фон вместе с собой вправо, NES позволяет разработчику самостоятельно выбирать способ скроллинга. В разных играх действия, вызывающие скроллинг, могут очень сильно отличаться. Эти варианты представляют собой различные системы камер, каждая из которых имеет свои преимущества и недостатки.

[Показанные ниже примеры взяты из подробной статьи Итея Керена о системах камер «Scroll Back: The Theory and Practice of Cameras in Side-Scrollers», в которой эта тема рассмотрена гораздо подробнее, чем в моей книге. Используемые ниже термины (например, «фиксация позиции») взяты из той же статьи.]

Давайте рассмотрим некоторые из основных методик, часто использовавшихся на NES.

Фиксация позиции


В простейшей системе камер под названием «фиксация позиции» (position locking) игрок всегда находится на экране в одном месте, а фон скроллится каждый раз, когда игрок «движется».
Камера с фиксацией позиции из Micro Machines (CodeMasters, 1991 год). Здесь лодка игрока постоянно находится в центре экрана.

Фиксация позиции полезна, когда игроку необходима постоянная дистанция видимости вокруг его персонажа на экране. В игре Micro Machines, где скоростные гонки сочетаются с внезапными поворотами, фиксированная по центру камера позволяет игроку постоянно видеть то, что его окружает. Аналогично, в играх с боковым скроллингом фиксация позиции по оси X гарантирует, что игрок может видеть на фиксированное расстояние влево и вправо, чтобы его не застали врасплох враги с какой-то из сторон.


Камера с фиксацией позиции по горизонтали в игре Ducktales (Capcom, 1989 год). Скрудж постоянно находится в одной позиции по оси X, но может перемещаться вертикально, и камера при этом за ним не следует.

Окна камеры


Камеры с фиксацией позиции очень популярны на NES, но иногда разработчики хотят дать игроку больше свободы в перемещении без постоянного смещения окна просмотра. Окно камеры задаёт область экрана, в которой игрок может двигаться, не вызывая скроллинг экрана; попытка выхода из окна приводит к скроллингу экрана.
Окно камеры в Crystalis (SNK, 1990 год). Окно камеры здесь особенно заметно, когда игрок движется влево или вправо.

Автоскроллинг


Третья система камер, которую мы рассмотрим — это автоскроллинг. В системе камер с автоскроллингом игрок никак не может управлять движением камеры — она постоянно движется самостоятельно, а игрок или остаётся на одном месте, пока фон под ним скроллится, или спрайты игрока движутся в соответствии с движением фона.
Автоскроллинг в Star Soldier (Hudson Soft / Taxan, 1988 год). Корабль игрока совершенно не связан с звёздным фоном. Если игрок не нажимает кнопки, то остаётся в одном и том же месте экрана.
Автоскроллинг на уровне с воздушным судном из Super Mario Bros. 3 (Nintendo, 1990 год). Здесь когда игрок не нажимает кнопки на контроллере, Марио движется вместе с фоном. Когда игрок достигает левого края экрана, он остаётся там, но фон продолжает под ним двигаться. В других играх с автоскроллингом контакт с левым краем экрана может расцениваться как мгновенная смерть.

Подготовка фонов к скроллингу


Для нашего проекта космического шутера мы используем вертикальное расположение и камеру с автоскроллингом. Чтобы фоны были интереснее, я создал дополнительные тайлы графики и поместил их в новый файл .chr (scrolling.chr).

1d40d5071adba8567373eee775d73b4f.png


Новые тайлы фона в scrolling.chr.

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

Вот одна из таблиц имён:

ad24edadd337ca61d53d63adbba51ad7.png


Незначительные дополнения в таблицу имён из предыдущей главы, показанные в NES Lightbox.

А вот вторая:

a3352b037b8789282c08694286ddca4f.png


Ещё один набор изменений в NES Lightbox.

В обеих таблицах имён используется один и тот же узор из звёзд на фоне, но с наложенными поверх новыми объектами. Чтобы упростить работу, давайте переместим код отрисовки звёздного фона в подпрограмму, которую можно будет вызывать для отрисовки звёзд любой из четырёх таблиц имён, выбираемых в зависимости от того, что хранится в регистре. Я создал новый файл backgrounds.asm и добавил следующую подпрограмму:

k_lr2oxvadv6hzq_vp7rwmhfcks.png


Чтобы использовать эту подпрограмму, нужно сначала сохранить в регистр X старший байт таблицы имён, из которой мы хотим отрисовывать звёзды. В нашем случае, так как мы используем горизонтальное зеркалирование, две «реальные» таблицы имён расположены по адресам $2000 и $2800, поэтому в регистр X мы будем записывать значение #$20 или #$28. Обратите внимание, что мы используем регистр Y для загрузки и сохранения номеров тайлов, а накопитель для записи в PPUADDR. Нам нужно хранить этот байт базовой таблицы имён в регистре X, так как мы должны часто загружать его в накопитель (с помощью TXA), а затем прибавлять что-нибудь для создания правильного адреса тайла таблицы имён. Например, см. выше строки 19–23. Этот конкретный байт данных должен записываться или в $2157, или в $2957, в зависимости от того, в какой таблице имён он находится. Использовав TXA, за которым следует ADC #$01, мы получим $21 (если в регистре X хранилось $20), или $29 (если в регистре X хранилось $28), что даёт нам правильное значение для обеих таблиц имён.

В нашем основном файле можно удалить код отрисовки звёздного фона и заменить его двумя вызовами новой подпрограммы:

foxuuxyevqbg4qc-il_u3ghdtdm.png


В результате этого, если вы посмотрите в функцию отладки «Nametables» в Nintaco, то увидите, что обе таблицы имён будут иметь фон из звёзд. Далее нам нужно добавить новые объекты поверх звёзд. Я решил делать это в другой подпрограмме внутри backgrounds.asm, назвав её draw_objects:

w0v6kcx1ptzdkxfa2tw_lmqjdae.png


Эта подпрограмма не собирает никакой информации из регистра X (и всех других); адреса жёстко прописаны в коде. Вызов этой подпрограммы (с помощью JSR draw_objects, за которым следует два вызова draw_starfield) заполнит две наши таблицы имён.

e6378e758c6f3b2d37a401d35937ccab.jpg


Заполненные таблицы имён в режиме просмотра «Nametables» эмулятора Nintaco. Обратите внимание, что левая и правая половины одинаковы, потому что мы используем горизонтальное зеркалирование. Если бы мы скроллили влево или вправо, то переместились бы в эту отзеркаленную область и казалось бы, что экран «оборачивается вокруг».

Реализация автоскроллинга


Как говорилось выше, позиции скроллинга задаются в обработчике NMI двумя операциями записи в PPUSCROLL. Первая запись задаёт величину скроллинга по X (на какое количество пикселей должно переместиться окно камеры вправо), а вторая запись — величину скроллинга по Y (на сколько пикселей должно переместиться окно камеры вверх).
Мы хотим, чтобы игра постоянно скроллила фон вниз, поэтому величина скроллинга по X будет постоянной (нулевой), а величина скроллинга по Y будет начинаться с максимального значения и уменьшаться с каждым кадром. Помните, что хотя экран NES имеет ширину 256 пикселей, в высоту он только 240 пикселей. Это означает, что величина скроллинга по Y изначально должна иметь значение 239 (максимальное значение), а когда нам нужно будет скроллить «ниже нуля», то нужно будет возвращать его к 239, а не использовать обычный оборот до 255.

Чтобы реализовать это, нам понадобятся ещё две дополнительные переменные нулевой страницы. Первая, scroll, будет хранить текущую величину скроллинга по Y. Вторая, ppuctrl_settings, будет хранить текущие параметры, которые мы будем отправлять в PPUCTRL, чтобы мы могли менять базовую таблицу имён каждый раз, когда доберёмся до позиции скроллинга 0.

w-vjutdccbyjrcfmvc4v3ngxaze.png


Также, пока не забыли, давайте добавим в constants.inc новую константу:

m6pvksub-y_gcomvir-x2fj_k1q.png


Для подготовки нам нужно будет внести два изменения в код main. Во-первых, нужно будет задать исходное значение scroll:

9gklil7vlkkgw97kdbupwilrqca.png


Во-вторых, мы задаём PPUCTRL после отрисовки всех таблиц имён. Нам нужно будет хранить значение. отправляемое PPUCTRL, чтобы позже можно было изменять и повторно использовать его:

qyjkfzfdczi6nflydsi40c5qqdu.png


Самые младшие (правые) два бита указывают, какая из таблиц имён является «базовой», то есть к какой применяются смещения скроллинга. В данном случае эти два бита равны 00, что указывает на таблицу по адресу $2000. Чтобы обеспечить плавный скроллинг, нам нужно будет по очереди делать базовой таблицу $2000 (00) или $2800 (10). Если мы не изменим базовую таблицу имён, то увидим плавный скроллинг с одной таблицы данных на другую, но затем скроллинг мгновенно переключится на базовую таблицу имён, а не продолжит плавное движение.

Подготовив всё, давайте взглянем на обработчик NMI, где задаются позиции скроллинга. Сначала я покажу код, а потом объясню его.

tjna5huot8_ih8uudkrtsd_myxs.png


Верхняя часть кода осталась такой же, как и в предыдущем примере. Новый код скроллинга начинается со строки 28. Обратите внимание, что нужно всегда задавать позиции скроллинга в конце обработчика NMI, непосредственно перед RTI. Если задать позиции скроллинга раньше, то другие операции записи в память PPU могут повлиять на то, как PPU вычисляет позиции скроллинга, что приведёт к неожиданному поведению.

[Полное объяснение того, как PPU вычисляет позиции скроллинга, в том числе и обсуждение внутренних регистров PPU, см. на странице NESDev Wiki, посвящённой скроллингу PPU.]

Первым делом новый код сравнивает текущую позицию скроллинга (scroll) с нулём. Если она равна нулю, то это значит, что мы готовы перейти на новую таблицу имён и нам нужно сменить базовую таблицу. BNE set_scroll_positions пропускает код задания PPUCTRL, если scroll не равно нулю. Предполагая, что нам нужно сменить базовую таблицу, мы загружаем сохранённые параметры PPUCTRL и используем логический фильтр (EOR) для переключения состояния бита на противоположное.

Логические фильтры


Прежде чем двигаться дальше, давайте уделим время изучению трёх опкодов логических фильтров, которые есть у 6502: AND, ORA и EOR. Каждый опкод получает в качестве операнда один байт, сравнивает каждый бит операнда с соответствующим битом накопителя и меняет биты хранящегося в накопителе значения. Так как логические фильтры выполняют побитные сравнения, операнды логического фильтра обычно выражены в двоичном виде.

AND сравнивает каждый бит своего операнда с соответствующим битом накопителя и устанавливает этот бит накопителя на 1, если оба бита были равны 1. В противном случае, он присваивает этому биту накопителя значение 0. Вот пример:

zdcjn6sre6dkiekylfqzyrxalq8.png


После выполнения этого кода значение в накопителе будет равно #%00001010. Битам один и три присвоено значение 1, потому что у этих битов изначальное значение и в накопителе, и в операнде AND было равно 1. Всем остальным битам накопителя присвоено значение 0.

ORA («OR with Accumulator»), как и AND, выполняет побитовое сравнение, но присваивает битам в накопителе значение 1, если хотя бы один из бита накопителя или бита операнда равен 1. Биты накопителя получают значение 0, только если биты и накопитель, и операнда были равны нулю. Вот пример:

w4thlgad5qqkqlzrlvf7fqg7-ue.png


После выполнения этого кода значение в накопителе будет равно #%10101111.

Последний логический фильтр — это EOR («Exclusive OR», более известный как «XOR»). В логике XOR возвращает «истина», если один из входящих элементов, но не оба, истинны, и «ложь» в обратном случае. Когда EOR сравнивает биты, если бит из операнда EOR равен 0, то соответствующий бит накопителя не меняется. Если бит операнда равен 1, то соответствующий бит накопителя меняет значение на противоположное. Вот пример:

xsmizekldy33cpxyxp64zmb5kqi.png


После выполнения этого кода значение в накопителе будет равно #%10100101. Самые старшие четыре бита (1010) остались неизменными, потому что там у операнда EOR находятся нули. Младшие четыре бита изменили значения на 0101, потому что там у операнда EOR находятся единицы.

Три логических фильтра позволяют вносить точные изменения в биты любого байта. AND позволяет отфильтровывать только конкретные биты, которые вам важны, ведь биты 0 его операнда присваивают битам накопителя нулевые значения, а биты 1 позволяют битам накопителя проходить через фильтр неизменными. ORA позволяет устанавливать биты при помощи 1 в его операнде. EOR позволяет переключать конкретные биты в накопителе на обратные значения при помощи бита 1 в операнде. Чтобы их освоить, нужно время, но они невероятно полезны, и в дальнейшем мы будем их часто встречать.

Подводим итог


Вернёмся к нашему обсуждению: когда нам нужно обновить базовую таблицу имён, мы загружаем в накопитель ppuctrl_settings, а затем следует EOR #%00000010. При этом основная часть значения в накопителе остаётся неизменной, а бит 1 (второй справа) меняет своё значение на противоположное. Биты 1 и 0 значения, которое мы отправили в PPUCTRL, управляют базовой таблицей имён, где 00 обозначает таблицу имён по адресу $2000, 01 — по адресу $2400, 10 — по адресу $2800, а 11 — по адресу $2c00. Меняя один бит, мы можем по очереди делать $2000 и $2800 базовой таблицей имён. Затем мы записываем новое значение в ppuctrl_settings, а также записываем его в PPUCTRL. После изменения базовой таблицы имён мы сбрасываем scroll на 240 (не на 239, потому что будем выполнять декремент этого значения в последующем коде).

Задав базовую таблицу имён, мы можем задать и позиции скроллинга. Сначала мы записываем ноль в PPUSCROLL, чтобы задать величину скроллинга по X. Затем DEC scroll вычитает единицу из scroll и сохраняет результат в scroll. Мы загружаем scroll в накопитель, а затем записываем его в PPUSCROLL, чтобы задать величину скроллинга по Y.

Задав величины скроллинга по X и Y, мы завершили с обработчиком NMI и можем вызвать RTI, чтобы вернуться к основному коду.

Давайте соберём и запустим наш новый проект:

ca65 src/backgrounds.asm
ca65 src/scrolling.asm
ld65 src/backgrounds.o src/scrolling.o -C nes.cfg -o scrolling.nes


Вот как наш проект выглядит в эмуляторе:

Домашняя работа


При помощи кода этой главы попробуйте выполнить следующие упражнения:

  • Изменить фоны, чтобы отображалось что-то другое. В файле CHR этого проекта есть множество дополнительных тайлов, поэкспериментируйте с разными сочетаниями и палитрами.
  • Измените скорость автоматического скроллинга фона. Как увеличить скорость скроллинга вдвое? А как в полтора раза?
  • Измените направление автоматического скроллинга.

© Habrahabr.ru