Плавно движемся к написанию игры. В этой части описана работа с джойстиками и коллизиями спрайтов.
Пользовательский ввод
Работа с джойстиками довольно простая. Нажатия кнопок первого джойстика читаются по адресу $4016, а второго — $4017. Достаточно считывать один раз за кадр, сразу после обновления PPU и установки прокрутки.
Я всегда завожу две переменных на каждый джойстик: для кнопок, нажатых сейчас, и для нажатий в прошлый кадр. Чтобы получить нажатые кнопки, надо записать в $4016 сначала 1, а потом 0. Потом прочитать оттуда 8 значений — это будут значения, соответствующие нажатиям кнопок на джойстике. Они выйдут в порядке A, B, Select, Start, Вверх, Вниз, Влево, Вправо. Их удобно сохранять битовым сдвигом и логическими операциями.
Еще удобно определить битовые маски для кнопок. Это позволит получать события быстрыми и наглядными битовыми операциями.
#define RIGHT 0x01
#define LEFT 0x02
#define DOWN 0x04
#define UP 0x08
#define START 0x10
#define SELECT 0x20
#define B_BUTTON 0x40
#define A_BUTTON 0x80
Воспользуемся метаспрайтом из прошлого урока и подвигаем его по экрану. Этот кусок намного удобней было написать на Ассемблере, вникать в него нет необходимости.
Чтобы вызвать ассемблерную функцию из кода на С, нужен ее прототип. Линкер соберет их вместе на своем этапе компиляции.
void Get_Input(void);
Объявлять функцию как void необязательно, это больше для единообразия кода. Настоятельно рекомендую использовать __fastcall__, потому что в этом случае последний (или единственный) аргумент будет передан через регистры A и X — это быстрее, чем через стек. 8-битный аргумент передается через регистр А, 16-битный — в паре А/Х, 32-битный — А/Х/sreg, где sreg — 16-битная переменная в нулевой странице памяти. Подробности описаны в документации к компилятору.
Но вернемся к Get_Input(). Если мы вызовем эту функцию один раз после каждого кадра, то она соберет и приведет в удобный формат все нажатия кнопок.
Теперь можно двигать человечка по экрану с помощью джойстика. Весь ассемблерный код вынесен в файл asm4c.s. Скрипты сборки тоже подправлены. А обработчик событий джойстика вынесен в отдельную функцию:
void move_logic(void) {
if ((joypad1 & RIGHT) != 0){
state = Going_Right;
++X1;
}
if ((joypad1 & LEFT) != 0){
state = Going_Left;
--X1;
}
if ((joypad1 & DOWN) != 0){
state = Going_Down;
++Y1;
}
if ((joypad1 & UP) != 0){
state = Going_Up;
--Y1;
}
}
Коллизии спрайтов
Проще всего обнаружить столкновение двух спрайтов. Здесь будем рассматривать метаспрайты размером 16х16 точек. Это в принципе можно считать стандартом для большинства NES-игр.
Определение столкновений реализовано через сравнение координат краев объектов. Там получается куча достаточно очевидных сравнений. Позиции спрайтов удобно определять через координату левого верхнего угла, а потом рассчитывать границы. Это выглядит примерно так:
A_left_side_X = A_X + 3; // левый край - необходимость отступа очевидна из картинки
A_right_side_X = A_X + 12; // правый край
A_top_Y = A_Y; // верх
A_bottom_Y = A_Y + 15; // низ
// аналогично для В
if (A_left_side_X <= B_right_side_X &&
A_right_side_X >= B_left_side_X &&
A_top_Y <= B_bottom_Y && A_bottom_Y >= B_top_Y){
// код обработчика коллизии
}
Отступ слева нужен для корректной обработки пустого края
Но целочисленное переполнение сделает нам неприятный сюрприз. Если спрайт уедет на правый край экрана, в район A_X = 250, то A_X+12 = 6, а это очевидно неправильно. Нам нужно проверить края и при переполнении присвоить значение 255. Это не идеально, но работает неплохо. Завести 16-битную переменную под координату можно, но неэффективно — код проверки на коллизии выполняется для многих спрайтов каждый кадр, а процессор 6502 не силен в таких больших числах. Или можно принудительно ограничить приближение спрайтов к краям.
A_left_side_X = A_X + 3;
if (A_left_side_X < A_X) A_left_side_X = 255; // при переполнении присвоить максимальное для типа значение
В следующем примере объект В будет двигаться сам по себе кодом из предыдущей главы, а А будет управляться джойстиком. При каждом их касании счетчик будет увеличиваться на 1. Проверка будет проходить один раз за кадр. Счетчик будем хранить как целочисленную переменную на каждую цифру счетчика и делать переносы вручную.
if (score5 > 9){
++score4;
score5 = 0;
}
if (score4 > 9){
++score3;
score4 = 0;
}
if (score3 > 9){
++score2;
score3 = 0;
}
if (score2 > 9){
++score1;
score2 = 0;
}
if (score1 > 9){ // при переполнении обнуляем все
score1 = 0;
score2 = 0;
score3 = 0;
score4 = 0;
score5 = 0;
}
При каждом обновлении счетчика выставляется флаг, по которому счетчик перерисовывается на следующем кадре. Мы можем менять спрайты только в период V-blank. Это событие ловится через обработчик NMI.
void PPU_Update (void) {
PPU_ADDRESS = 0x20;
PPU_ADDRESS = 0x8c;
PPU_DATA = score1+1; // Нулевой тайл пустой, первой - "0", второй - "1", и так далее
PPU_DATA = score2+1; // так что индекс смещен на единицу от отображаемой цифры
PPU_DATA = score3+1;
PPU_DATA = score4+1;
PPU_DATA = score5+1;
}
Отрисовка фона целиком
Теперь мы умеем показывать спрайты — в период V-blank или принудительно отключив экран. V-blank хватает только на 2 ряда тайлов, а во втором случае на полную перерисовку надо пропустить пару кадров — экран при этом зальет фоном по умолчанию.
Второй вариант проще и требует меньше кода. Фоны очень удобно рисовать в NES Screen Tool, и он поддерживает сохранение таблиц имен с RLE-сжатием. Распаковываются они простым декодером на Ассемблере. В подробности вникать не будем, а возьмем готовый код.
Будем менять фон при нажатии Start на джойстике. Также проследим, чтобы при длительном нажатии кнопки отрисовка прошла только один раз — иначе могут наложиться несколько запусков рендера, а это ой.
Логика примерно такая:
- Читаем регистры джойстика каждый кадр
- Если в этот кадр нажат Start, а за кадр до того он был не нажат...
- Ставим флаг гашения экрана
- В ближайший V-blank гасим экран
- Рендерим все что надо
- В следующий V-blank включаем экран
void Draw_Background(void) {
All_Off();
PPU_ADDRESS = 0x20; // адрес $2000 = начало таблицы имен #0
PPU_ADDRESS = 0x00;
UnRLE(All_Backgrounds[which_BGD]); // распаковка фона
Wait_Vblank(); // не включаем экран, пока не придет V-blank
All_On();
++which_BGD;
if (which_BGD == 4) // зацикливаем переключение фонов
which_BGD = 0;
}
const unsigned char * const All_Backgrounds[]={n1,n2,n3,n4};
// указатели на каждый фон
Коллизии с фоном
Чуть сложнее отследить коллизии спрайтов с фоном. Примем по умолчанию, что мы используем квадратные метатайлы 16х16, и такого же размера спрайты. Большинство игр используют такую схему. Спрайт будет двигаться в одну из четырех сторон на 1 пиксель за кадр, и каждый кадр будем проверять коллизии.
Обычно сначала спрайт двигается, проверяется, нет ли контакта, и это касание обрабатывается. Мы же разнесем это по двум координатам — сначала сдвинем и проверим по горизонтали, а потом по вертикали.
Чтение из PPU — это боль. Надо посчитать адрес актуальной таблицы имен, и запросить ее из PPU во время V-blank, чтобы при этом хватило времени на работу логики игры и обновление спрайтов. Не будем так делать.
Нам надо хранить карту фоновых метатайлов в одной странице RAM. Эта же карта может использоваться для быстрого расчета коллизий, если тайлов всего два вида — сделаем нулевой проходимым для персонажа, а первый нет. Если же надо больше видов тайлов, то таблицу проходимости надо хранить отдельно. В принципе, карты можно хранить и в ROM картриджа.
Карту коллизий можно удобно создать в редакторе Tiled. Метатайлы (все два) рисуются в NES Screen Tool и через обрезанный до 256х256 скриншот перебрасываются в Tiled. Он умеет экспортировать в .csv — по одному на фон. Этот файл пришлось чуть подправить — дописать заголовок константы
const unsigned char c2[]={
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,0,
0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,0,
0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,0,
0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,
0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
};
Теперь можно импортировать ее в код на С и сослаться по указателю на массив.
Обработчик нажатия конпки Start отрисует следующий фон и загрузит карту коллизий в RAM по адресу $300-$3FF. Для этого пришлось подправить конфиг — добавить сегмент MAP по адресу $300 и размером $100. В коде просто прописывается пустой массив в этом сегменте.
#pragma bss-name(push, “MAP”)
unsigned char C_MAP[256];
Точный адрес удобен и для отладки в FCEUX — можно зайти дебаггером в работающей игре и посмотреть что и как.
А вот так карта коллизий грузится из ROM в RAM:
p_C_MAP = All_Collision_Maps[which_BGD]; // указатель на карту коллизий в ROM
for (index = 0;index < 240; ++index){
C_MAP[index] = p_C_MAP[index]; // пересылка в RAM
}
Но через какое-то время я переписал это с memcpy — копирование по байтам занимает 42688 тактов процессора, это в 9 раз больше, чем memcpy.
void __fastcall__ memcpy (void* dest, const void* src, int count);
p_C_MAP = All_Collision_Maps[which_BGD]; // указатель на карту коллизий
memcpy (C_MAP, p_C_MAP, 240);
Но и это не все. Третий подход к снаряду был с Ассемблером — получилось на 4% быстрее. Думаю, что пока оно того не стоит. Хотя возможно в большой игре именно этих тактов процессора не хватит, и придется выжимать из приставки абсолютно все возможное.
Логика проверки коллизий с фоном примерно такая:
- Двигаем спрайт по горизонтали
- Считаем его левый и правый край — по краям есть прозрачные полоски, их надо учесть
- Если двигались вправо, то проверяем, не попал ли правый верхний или нижний угол спрайта в запрещенный тайл
- Если влево — то наоборот
if ((joypad1 & RIGHT) != 0){
// правый верхний
corner = ((X1_Right_Side & 0xf0) >> 4) + (Y1_Top & 0xf0); // пересчет координат спрайта в линейный индекс карты
if (C_MAP[corner] > 0)
X1 = (X1 & 0xf0) + 3; // если коллизия - сдвинуть обратно
// правый нижний
corner = ((X1_Right_Side & 0xf0) >> 4) + (Y1_Bottom & 0xf0);
if (C_MAP[corner] > 0)
X1 = (X1 & 0xf0) + 3; // если коллизия - сдвинуть обратно
}
+3 нужно, чтобы скомпенсировать 3-пиксельные прозрачные края спрайта.
Проверка коллизий по вертикали делается аналогично. Судя по всему, код не будет работать, если спрайт движется быстрее, чем 1 точка/кадр.
Надо помнить, что спрайт всегда рисуется на 1 точку ниже, чем ожидается. Поправку можно внести перед обновлением вертикальной координаты в OAM. В платформерах этого обычно достаточно. Игры с видом сверху могут выглядеть странно — персонаж немного провалится в текстуру.
Исходный код. Три реализации копирования карты разнесенвы в разные проекты — так наглядней.
lesson8.zip — цикл
lesson8B.zip — memcpy
lesson8C.zip — Ассемблер
Гитхаб