[Перевод] Разработка игр под NES на C. Главы 4-6. Рисуем персонажа
В этой части рассмотрим работу с графикой: фон и спрайты персонажей.
<<< предыдущая следующая >>>
Что такое V-blank?
PPU — графический процессор — может или отправлять сигнал в телевизор, или получать информацию от процессора, но не одновременно. Так что единственное время для пересылки это V-blank, период кадрового гасящего импульса.
90% времени PPU отправляет пиксели в видеовыход, строка за строкой слева направо и сверху вниз. Внизу экрана делается пауза, и все повторяется снова. Это происходит 60 раз в секунду. Пауза после отрисовки кадра и есть V-blank. Это весьма короткий промежуток времени. В него реально вложить обновление 2–4 столбцов фоновых тайлов и обновление спрайтов. Обновление фона особенно критично для игр с прокруткой.
Есть два способа узнать о наступлении V-blank. Во-первых, PPU выставляет флаг в старшем бите регистра $2002. Во-вторых, вызывается немаскируемое прерывание (NMI), и происходит переход по заданному адресу — вектору прерывания. Его можно использовать для работы с PPU, контроля таймингов музыки, перемещения спрайтов. Интервал между вызовами NMI составляет 1/60 секунды, и его можно использовать для отсчета времени в игре.
Сейчас мы напишем программу, которая будет выводить «HELLO WORLD» по 1 букве каждые 30 кадров. После вывода всей фразы очистим экран, и повторим все по кругу.
Обратите внимание на фрагмент
nmi:
inc _NMI_flag
inc _Frame_Count
в файле reset.s. Это код обработчика немаскируемого прерывания, который фактически реализует счетчик кадров в глобальной переменной. Каждый кадр выставляет флаг NMI_flag, и в случае если прошло 30 кадров (0.5 секунды), выводится спрайт очередной буквы. Вся логика реализована в main ().
void Load_Text(void) {
if (Text_Position < sizeof(TEXT)){
//строка еще не выведена
PPU_ADDRESS = 0x21; // Y-координата строки
PPU_ADDRESS = 0xca + Text_Position; //X-координата с учетом смещения
PPU_DATA = TEXT[Text_Position];
++Text_Position;
}
else {
//все вывели, заливаем экран пустым нулевым тайлом
Text_Position = 0;
PPU_ADDRESS = 0x21;
PPU_ADDRESS = 0xca;
for ( index = 0; index < sizeof(TEXT); ++index ){
PPU_DATA = 0; // при выводе в течении одного кадра позиция тайлов автоинкрементируется
}
}
}
void main (void) {
All_Off(); // гасим экран
Load_Palette();
Reset_Scroll();
All_On(); // включаем экран
while (1){ // бесконечный цикл
while (NMI_flag == 0); // ждем NMI
NMI_flag = 0;
if (Frame_Count == 30){ // прошло 30 кадров
Load_Text();
Reset_Scroll();
Frame_Count = 0;
}
}
}
// Обработчик NMI делает ++NMIflag и ++FrameCount при каждом V-blank
Исходный код:
http://dl.dropboxusercontent.com/s/c3fbfranz5gcafk/lesson2.zip
https://github.com/BubaVV/nesdoug
В идеале, надо ждать V-blank перед вызовом All_On (), иначе один кадр после него будет будет искажен, и экран будет мерцать. В этом примере этот эффект незаметен, потому что All_On () вызывается единственный раз при старте приставки, экран в это время черный, и искажение незаметно.
Немного цвета
Теперь можно раскрасить то что получилось.
Пару слов о палитре в NES. В памяти PPU под нее выделено 16 байт по адресам $3F00-$3F0F для 4 палитр фона и 16 байт по адресам $3F10-$3F1F для 4 палитр спрайтов. Первый цвет спрайта всегда отрисовывается как прозрачный, а первый цвет фона — как цвет фона по умолчанию. Байты в памяти PPU, соответствующие первым цветам, зазеркалированы для всех 8 записей, и соответствуют цвету фона по умолчанию. Так что мы можем использовать 3 уникальных цвета на спрайт и 3 цвета плюс общий фоновый для тайлов.
Таким образом можно одновременно отрисовать 25 цветов из 50 возможных.
Можно затемнить эти цвета через регистры color emphasis, но эта функция совместима не со всеми клонами приставки. Кроме того, некоторым телевизорам срывает крышу от цветов $0D и $1D. Они получаются чернее черного, а это нештатная ситуация. Используйте цвета $xF.
Для фона палитра определяется для блока из 2×2 тайлов, размером 16×16 точек. Для этой разбивки удобно использовать Nes Screen Tool. Большинство игр строит фон именно из таких счетверенных метатайлов.
В каждой таблице имен (фактически, заготовке для рендеринга экрана) выделено 64 байта под атрибуты. Например, для таблицы 0 используются адреса PPU $2000-$23FF, а атрибуты хранятся в $23C0-$23FF. Каждый байт атрибутов описывает 4 метатайла, то есть область 32×32 пикселя. Соответственно, 2 бита выбирают номер палитры для метатайла. Соответствие бита метатайлу такое:
Здесь одна буква соответствует одному тайлу, малый квадрат — метатайл 2×2.
То есть чтобы поменять палитру правого нижнего квадранта на первую из 4 возможных, надо поменять два старших бита атрибута на 01: 01хххххх.
Я добавил к прошлому примеру палитры, так что буквы тепеь разноцветные. Палитры вынесены в отдельный файл и импортируются через #include, а переменные определены в нулевой странице памяти через #pragma — просто для демонстрации этой фичи. Нулевая страница работает быстрее, но 10–20 переменных там используется для внутренних нужд компилятора. Так что надо рассчитывать где-то на 235 доступных байт.
#pragma bss-name(push, "ZEROPAGE")
//здесь определяются переменные
#include "PALETTE.c"
#include "CODE.c"
const unsigned char PALETTE[]={
0x11, 0x00, 0x00, 0x31, // синий
0x00, 0x00, 0x00, 0x15, // красный
0x00, 0x00, 0x00, 0x27, // желтый
0x00, 0x00, 0x00, 0x1a, // зеленый
};
// 0х11 - это стандартный фон, синий
// используются только палитры для фона
const unsigned char Attrib_Table[]={
0x44, // 0100 0100,
0xbb, // 1011 1011,
0x44, // 0100 0100,
0xbb}; // 1011 1011 };
// таблица атрибутов - 2 бита указывают номер палитры для метатайла 16х16 точек
PPU_ADDRESS = 0x23;
PPU_ADDRESS = 0xda;
for( index = 0; index < sizeof(Attrib_Table); ++index ){
PPU_DATA = Attrib_Table[index];
} // пересылка таблицы атрибутов в PPU
Исходный код:
http://dl.dropboxusercontent.com/s/tqp6s3odgurieep/lesson3.zip
https://github.com/BubaVV/nesdoug
Вот полезная шпаргалка по адресам таблицы атрибутов и ее привязке к экранным координатам:
Экран шириной 256 точек, каждая клетка таблицы соответствует квадрату 32×32
Спрайты
Спрайт — это картинка 8×8, которая может перемещаться по экрану. Есть хитрый способ использовать спрайты 8×16, но мы так делать не будем. Почти все персонажи игр состоят из спрайтов. Хотя в некоторых случаях нужно отрисовывать их фоновыми тайлами из-за ограничений на количество спрайтов, такое используется для финальных боссов в некоторых играх.
8×8 точек это очень мало, поэтому придется собирать персонажа из нескольких спрайтов: маленького Марио из 2×2, а большого из 2×4.
Надо помнить об ограничениях. Поддерживается не больше 64 спрайтов, и не более 8 на одной строке экрана. При превышении лимита будут отрисованы только спрайты с большим приоритетом. Если менять приоритет спрайта между кадрами, он будет мигать. Этот способ часто используется в играх.
PPU хранит информацию о спрайтах в таблице OAM. Ее размер 256 байт: по 4 байта на каждый из 64 возможных спрайтов. Если накладываются два спрайта с одинаковым приоритетом, то спрайт с меньшим номером отрисовывается поверх. Если на одной строке экрана будет больше 8 спрайтов, то отрисуются только 8 с меньшими номерами.
Таблица OAM хранит такие атрибуты:
- Y-координату
- Индекс спрайта, который надо отобразить
- Атрибуты: палитра, приоритет, зеркальное отражение
- X-координату
Координаты считаются по левому верхнему углу. X может быть от $00 до $F8, Y от $00 до $EE. Можно частично спрятать спрайт вниз или вправо от экрана, но левая и верхняя границы неприкосновенны. В нашем примере код инициализации прячет спрайты внизу, Y=$F8.
Подробности по таблице OAM:
http://wiki.nesdev.com/w/index.php/PPU_OAM
Как все это хранится в памяти:
Правда здесь таблица спрайтов хранится в RAM по адресу $700, а в примере используется $200.
Запись в таблицу OAM реализована через регистры памяти: надо записать адрес спрайта в $2003, а потом данные для него в $2004. Этим редко пользуются, потому что есть более удобный и быстрый способ заливки через DMA. Он активируется через регистр $4014: пишем $xx по адресу $4014, 256 байт из диапазона $xx00-$xxFF сами заливаются в OAM. Это надо делать в период V-Blank.
В нашем примере мы сделаем метаспрайт из 4 спрайтов и добавим анимацию.
Надо помнить, что спрайты отрисовываются на 1 точку ниже от ожидаемой координаты. На картинке с Марио вверху поста видно, что он на 1 пиксель ушел в пол.
#pragma bss-name(push, "ZEROPAGE")
unsigned char NMI_flag;
unsigned char Frame_Count;
unsigned char index;
unsigned char index4;
unsigned char X1;
unsigned char Y1;
unsigned char move;
unsigned char move_count;
#pragma bss-name(push, "OAM")
unsigned char SPRITES[256];
// OAM лежит по адресам $200-$2FF, это определено в файле cfg
const unsigned char PALETTE[]={
0x19, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0x19, 0x37, 0x24, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0};
const unsigned char MetaSprite_Y[] = {0, 0, 8, 8}; // относительные Y-координаты
const unsigned char MetaSprite_Tile[] = {0, 1, 0x10, 0x11}; // номера тайлов
const unsigned char MetaSprite_Attrib[] = {0, 0, 0, 0}; // атрибуты: отражение и номер палитры
const unsigned char MetaSprite_X[] = {0, 8, 0, 8}; // относительные X-координаты
// Используем 4 спрайта, каждый смещен от левого-верхнего нулевого спрайта
void every_frame(void) {
OAM_ADDRESS = 0;
OAM_DMA = 2; // Запись данных спрайтов в OAM через DMA
PPU_CTRL = 0x90; // экран и NMI включены
PPU_MASK = 0x1e;
SCROLL = 0;
SCROLL = 0; // перепроверим, что прокрутка выключена
}
// код для автоизменения положения спрайтов
void update_Sprites (void) {
index4 = 0;
for (index = 0; index < 4; ++index ){
SPRITES[index4] = MetaSprite_Y[index] + Y1; // сдвиг по вертикали
++index4;
SPRITES[index4] = MetaSprite_Tile[index]; // номера тайлов
++index4;
SPRITES[index4] = MetaSprite_Attrib[index]; // атрибуты не меняются
++index4;
SPRITES[index4] = MetaSprite_X[index] + X1; // сдвиг по горизонтали
++index4;
}
} // код неэффективный, но наглядный
void main (void) {
All_Off(); // гасим экран
X1 = 0x7f; // начальная координата спрайтов
Y1 = 0x77; // в районе центра экрана
Load_Palette();
Reset_Scroll();
All_On(); // включаем экран
while (1){ // бесконечный цикл
while (NMI_flag == 0); // пока не сработает NMI
NMI_flag = 0;
every_frame(); // экран надо обновить при каждом v-blank
if (move == 0) ++X1;
if (move == 1) ++Y1;
if (move == 2) --X1;
if (move == 3) --Y1;
++move_count;
if (move_count == 20){ // движемся 20 кадров
move_count = 0;
++move;
}
if (move == 4) move=0;
update_Sprites();
}
}
Есть немного улучшенная версия, где персонаж поворачивается при движении по квадрату.
const unsigned char MetaSprite_Tile[] = { //дополнительные тайлы
2, 3, 0x12, 0x13, // right
0, 1, 0x10, 0x11, // down
6, 7, 0x16, 0x17, // left
4, 5, 0x14, 0x15}; // up
void update_Sprites (void) {
move4 = move << 2; // Быстрое умножение на 4
index4 = 0;
for (index = 0; index < 4; ++index ){
SPRITES[index4] = MetaSprite_Y[index] + Y1; // сдвиг по вертикали
++index4;
SPRITES[index4] = MetaSprite_Tile[index + move4]; // номера тайлов
++index4;
SPRITES[index4] = MetaSprite_Attrib[index]; // атрибуты не меняются
++index4;
SPRITES[index4] = MetaSprite_X[index] + X1; // сдвиг по горизонтали
++index4;
}
}
Исходный код:
http://dl.dropboxusercontent.com/s/v2wl2aa5gbrjmad/lesson4.zip
https://github.com/BubaVV/nesdoug