Pull to refresh

Разработка игр под NES на C. Главы 7-10. Работа с джойстиком. Коллизии спрайтов

Reading time 7 min
Views 7.9K
Original author: Nesdoug

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


<<< предыдущая следующая >>>


image
Источник


Пользовательский ввод


Работа с джойстиками довольно простая. Нажатия кнопок первого джойстика читаются по адресу $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){
       // код обработчика коллизии
   }

Отступ слева нужен для корректной обработки пустого края


image


Но целочисленное переполнение сделает нам неприятный сюрприз. Если спрайт уедет на правый край экрана, в район 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;
}

image


Дропбокс
Гитхаб


Отрисовка фона целиком


Теперь мы умеем показывать спрайты — в период V-blank или принудительно отключив экран. V-blank хватает только на 2 ряда тайлов, а во втором случае на полную перерисовку надо пропустить пару кадров — экран при этом зальет фоном по умолчанию.


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


Будем менять фон при нажатии Start на джойстике. Также проследим, чтобы при длительном нажатии кнопки отрисовка прошла только один раз — иначе могут наложиться несколько запусков рендера, а это ой.


Логика примерно такая:


  1. Читаем регистры джойстика каждый кадр
  2. Если в этот кадр нажат Start, а за кадр до того он был не нажат...
  3. Ставим флаг гашения экрана
  4. В ближайший V-blank гасим экран
  5. Рендерим все что надо
  6. В следующий 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};
// указатели на каждый фон

image


Код:
Дропбокс
Гитхаб


Коллизии с фоном


Чуть сложнее отследить коллизии спрайтов с фоном. Примем по умолчанию, что мы используем квадратные метатайлы 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% быстрее. Думаю, что пока оно того не стоит. Хотя возможно в большой игре именно этих тактов процессора не хватит, и придется выжимать из приставки абсолютно все возможное.


Логика проверки коллизий с фоном примерно такая:


  1. Двигаем спрайт по горизонтали
  2. Считаем его левый и правый край — по краям есть прозрачные полоски, их надо учесть
  3. Если двигались вправо, то проверяем, не попал ли правый верхний или нижний угол спрайта в запрещенный тайл
  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 — Ассемблер
Гитхаб

Tags:
Hubs:
+20
Comments 0
Comments Leave a comment

Articles