Разработка игр для Game Boy Advance

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

Архитектура GBA и настройка окружения разработки DevkitPro

Архитектура GBA и настройка окружения разработки DevkitPro

Добро пожаловать на курс по разработке игр для Game Boy Advance (GBA). Выбор этой платформы для изучения геймдева — отличное решение. GBA — это идеальный баланс между простотой, позволяющей понять каждый бит системы, и мощностью, достаточной для создания красивых 2D-игр. Здесь нет операционной системы, драйверов или тяжелых игровых движков. Есть только вы, ваш код и «железо».

В этой вводной статье мы разберем, как устроен GBA изнутри, почему математика здесь работает иначе и как подготовить ваш компьютер к созданию первой игры.

Сердце консоли: Процессор и архитектура

Game Boy Advance, выпущенный в 2001 году, был настоящим технологическим прорывом. Чтобы писать эффективный код, нужно понимать, на чем он исполняется.

Процессор ARM7TDMI

Центральный процессор GBA — это 32-битный RISC-процессор ARM7TDMI, работающий на частоте 16.78 МГц. Давайте расшифруем, что это значит для нас как разработчиков:

  • RISC (Reduced Instruction Set Computer): Упрощенный набор команд. Каждая инструкция выполняется очень быстро, обычно за один такт.
  • 32-битный: Процессор оперирует 32-битными числами. Это «родной» размер данных.

Особенность этого процессора в том, что он поддерживает два набора инструкций:

  1. ARM (32-бит): Мощные инструкции, дают максимальную производительность. Но код занимает больше места в памяти.
  2. Thumb (16-бит): Сжатый набор инструкций. Код занимает меньше места, но может работать чуть медленнее в определенных ситуациях.

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

Память и её карта

У GBA нет жесткого диска. Вся память доступна процессору напрямую через единое адресное пространство. Однако память неоднородна и разделена на секции:

Тип памяти Название Объем Назначение
EWRAM External Work RAM 256 КБ Медленная память для больших данных и переменных.
IWRAM Internal Work RAM 32 КБ Быстрая память внутри чипа. Здесь хранят критичный код и стек.
VRAM Video RAM 96 КБ Видеопамять. Сюда мы пишем то, что хотим увидеть на экране.
OAM Object Attribute Memory 1 КБ Память атрибутов объектов (спрайтов).
ROM Game Pak до 32 МБ Память картриджа (только для чтения). Здесь живет ваша игра.

Дисплей

Экран GBA имеет разрешение 240x160 пикселей. Это может показаться малым по современным меркам, но именно это ограничение заставляет художников и программистов творить чудеса пиксель-арта. Частота обновления экрана составляет чуть меньше 60 Гц (точнее, 59.73 Гц).

Математика на GBA: Числа с фиксированной точкой

Одной из главных особенностей программирования под старые консоли является отсутствие FPU (Floating Point Unit) — специального блока процессора для работы с дробными числами (float, double).

Если вы попытаетесь использовать тип float в своем коде (например, float x = 1.5;), процессор будет эмулировать вычисления программно. Это катастрофически медленно. Обычное сложение двух float может занять в 50-100 раз больше времени, чем сложение целых чисел.

Решение? Арифметика с фиксированной точкой (Fixed Point Arithmetic).

Суть метода в том, что мы используем обычные целые числа (int), но договариваемся, что часть бит отводится под целую часть, а часть — под дробную. Например, в формате «8.8» (всего 16 бит) 8 бит — это целое, и 8 бит — дробь.

Формула перевода из реального числа в число с фиксированной точкой выглядит так:

Vfixed=Vreal⋅2QV_{fixed} = V_{real} \cdot 2^Q

Где:

  • VfixedV_{fixed} — полученное целочисленное значение, которое мы будем хранить в памяти GBA.
  • VrealV_{real} — исходное дробное число (например, 1.5).
  • QQ — количество бит, отведенных под дробную часть (точность).
  • 2Q2^Q — масштабный коэффициент (сдвиг влево).

Пример: Если мы используем 8 бит для дробной части (Q=8Q=8), то 28=2562^8 = 256. Чтобы представить число 1.51.5, мы умножаем его на 256:

1.5⋅256=3841.5 \cdot 256 = 384

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

Настройка окружения: DevkitPro

Чтобы писать код на C или C++ и превращать его в файл .gba, нам нужен кросс-компилятор. Стандартом индустрии хомбрю (homebrew — любительская разработка) является DevkitPro.

DevkitPro — это набор инструментов, включающий в себя специальную версию GCC (компилятора) для архитектуры ARM и необходимые библиотеки, главной из которых является libtonc.

Шаг 1: Установка

Процесс установки зависит от вашей ОС, но он максимально автоматизирован.

  1. Перейдите на официальный сайт DevkitPro.
  2. Скачайте установщик (графический для Windows или скрипт pacman для Mac/Linux).
  3. При выборе компонентов убедитесь, что отмечена группа GBA (Game Boy Advance).

Шаг 2: Выбор редактора кода

Я настоятельно рекомендую использовать Visual Studio Code. Это легкий и мощный редактор. Для удобства установите расширения:

  • C/C++ (от Microsoft) — для подсветки синтаксиса и навигации.
  • Makefile Tools — так как сборка проектов GBA обычно идет через Makefile.

Шаг 3: Эмулятор

Вам не обязательно каждый раз записывать игру на реальный картридж. Для разработки мы используем эмуляторы. Лучшим выбором для разработчика сейчас является mGBA. Он точен, быстр и имеет встроенные инструменты отладки (просмотр памяти, палитр, тайлов).

Ваш первый проект: Hello World

В мире GBA «Hello World» — это не вывод текста в консоль (консоли-то нет!), а закрашивание экрана в какой-либо цвет. Это доказывает, что вы получили контроль над видеопамятью.

Создайте папку проекта и файл main.c. Вот минимальный код:

// Определяем адрес видеопамяти
#define MEM_VRAM 0x06000000

// Определяем регистр управления дисплеем
#define REG_DISPCNT *(volatile unsigned int*)0x04000000

// Режим видео 3 (попиксельный) и включение фона 2
#define DCNT_MODE3 0x0003
#define DCNT_BG2   0x0400

// Определяем тип для пикселя (16 бит)
typedef unsigned short u16;

int main() {
    // Настраиваем дисплей: Режим 3 + Включаем слой BG2
    REG_DISPCNT = DCNT_MODE3 | DCNT_BG2;

    // Получаем указатель на видеопамять
    // volatile нужен, чтобы компилятор не оптимизировал запись
    volatile u16* vram = (volatile u16*)MEM_VRAM;

    // Рисуем точку в центре экрана
    // Координаты: x=120, y=80. Ширина экрана 240.
    // Формула индекса: y * ширина + x
    vram[80 * 240 + 120] = 0x001F; // Красный цвет (5 бит на канал)

    // Бесконечный цикл, чтобы программа не завершилась
    while(1);

    return 0;
}

Разбор магии

  1. Указатели: Мы обращаемся к памяти напрямую через адреса. 0x06000000 — это начало VRAM. Записывая туда число, мы меняем цвет пикселя на экране.
  2. Регистры ввода-вывода: REG_DISPCNT по адресу 0x04000000 — это «пульт управления» видеочипом. Записывая туда битовые флаги, мы говорим GBA: «Включи видеорежим 3».
  3. Volatile: Ключевое слово, говорящее компилятору: «Не хитри, эта память может измениться или важна для железа, пиши туда всегда, даже если тебе кажется это лишним».

Для компиляции этого кода вам понадобится Makefile, который идет в комплекте с примерами DevkitPro. После команды make вы получите файл .gba, который можно открыть в mGBA и увидеть красную точку на черном фоне.

Заключение

Мы познакомились с архитектурой GBA, узнали про разделение памяти, важность целочисленной арифметики и написали первую программу, работающую с железом напрямую. Это ощущение полного контроля над устройством — то, ради чего стоит изучать низкоуровневую разработку игр.

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

Основы графики: видеорежимы, тайлы и создание фонов

Основы графики: видеорежимы, тайлы и создание фонов

В предыдущей статье мы научились настраивать окружение и вывели на экран одну красную точку. Мы использовали Режим 3 (Mode 3), который позволяет обращаться к каждому пикселю экрана напрямую. Это интуитивно понятно, но крайне неэффективно для создания полноценных игр.

Представьте, что вы хотите сдвинуть фон игры (например, в платформере). В растровом режиме (как Mode 3) процессору пришлось бы переписывать цвет каждого из 38 400 пикселей (240x160) в каждом кадре. Процессор GBA, хоть и шустрый для своего времени, захлебнется от такой нагрузки, и игра будет тормозить.

Сегодня мы разберем, как работают настоящие игры на GBA, используя тайловую графику и аппаратные видеорежимы.

Видеоконтроллер GBA

В отличие от современных ПК, где видеокарта — это отдельное мощное устройство, в GBA видеосистема (PPU — Picture Processing Unit) тесно интегрирована в чип. Она берет на себя всю тяжелую работу по отрисовке, оставляя процессору время на игровую логику.

У GBA есть 6 видеорежимов, которые делятся на две группы:

  1. Тайловые режимы (0, 1, 2): Экран собирается из маленьких кирпичиков (тайлов). Это основной способ создания 2D-игр (Mario, Pokémon, Metroid).
  2. Растровые режимы (3, 4, 5): Позволяют рисовать произвольные пиксели. Используются для 3D-игр (как Doom) или показа статических картинок, но требуют много памяти и ресурсов CPU.

Мы сосредоточимся на Режиме 0, так как он самый универсальный и предоставляет доступ ко всем четырем слоям фона одновременно.

Что такое тайл?

Тайл (Tile) — это квадратное изображение размером 8x8 пикселей. Это базовый строительный блок графики GBA.

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

Хранение тайлов в памяти

Тайлы хранятся в специальной области видеопамяти (VRAM), которая называется Character Block (блок символов). GBA поддерживает разную глубину цвета, но стандартом для тайлов является 4 бита на пиксель (4bpp). Это означает, что каждый пиксель может иметь один из 16 цветов.

Давайте посчитаем размер одного тайла в байтах:

Size=W⋅H⋅B8Size = W \cdot H \cdot \frac{B}{8}

Где:

  • SizeSize — размер тайла в байтах.
  • WW — ширина тайла (8 пикселей).
  • HH — высота тайла (8 пикселей).
  • BB — глубина цвета (4 бита).
  • Деление на 8 переводит биты в байты.

Подставим значения:

Size=8⋅8⋅48=32Size = 8 \cdot 8 \cdot \frac{4}{8} = 32

Итак, один тайл занимает ровно 32 байта. Это очень удобно для процессора.

Анатомия фона: Тайлы, Карты и Палитры

Чтобы вывести изображение на экран в тайловом режиме, нам нужны три компонента:

  1. Tileset (Набор тайлов): Сами картинки 8x8. Хранятся в Character Blocks.
  2. Tilemap (Карта тайлов): Сетка, которая говорит консоли: «В координатах (0,0) нарисуй тайл №5, а в (1,0) — тайл №2». Хранится в Screen Blocks.
  3. Palette (Палитра): Набор цветов. Так как тайлы хранят не сам цвет, а индекс цвета (от 0 до 15), палитра определяет, какой реальный цвет соответствует каждому индексу.

Карта тайлов (Screen Entry)

Карта тайлов — это массив 16-битных чисел. Каждое число описывает один блок 8x8 на экране. Структура этого 16-битного числа (Screen Entry) выглядит так:

Биты Назначение
0-9 ID тайла (номер картинки в наборе, от 0 до 1023)
10 Горизонтальный разворот (Flip H) — зеркалит тайл
11 Вертикальный разворот (Flip V) — переворачивает тайл
12-15 ID палитры (какую из 16 палитр использовать)

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

Практика: Рисуем паттерн в Режиме 0

Давайте напишем код, который заполнит экран простым узором. Для этого нам нужно настроить регистры и загрузить данные в память.

Шаг 1: Подготовка данных

В реальной разработке мы используем конвертеры (например, Grit или Usenti), чтобы превращать PNG-картинки в массивы C-кода. Но для понимания сути создадим один тайл вручную. Представим тайл как массив из 8 полосок по 4 байта (так как одна строка 8 пикселей по 4 бита = 32 бита = 4 байта).

// Простой тайл (крестик), каждый hex-символ — это цвет пикселя (индекс)
// 0 - фон, 1 - цвет линии
const unsigned int tile_cross[] = {
    0x10000001, // Строка 0: 1.......1
    0x01000010, // Строка 1: .1.....1.
    0x00100100, // Строка 2: ..1...1..
    0x00011000, // Строка 3: ...1.1...
    0x00011000, // Строка 4: ...1.1...
    0x00100100, // Строка 5: ..1...1..
    0x01000010, // Строка 6: .1.....1.
    0x10000001  // Строка 7: 1.......1
};

// Палитра: Цвет 0 (черный), Цвет 1 (белый)
const unsigned short palette[] = {
    0x0000, // RGB(0,0,0)
    0x7FFF  // RGB(31,31,31) - белый на GBA
};

Шаг 2: Настройка регистров

Нам понадобятся новые адреса памяти и регистры.

  • REG_DISPCNT (0x04000000): Управление дисплеем.
  • REG_BG0CNT (0x04000008): Управление фоном 0.
  • MEM_PALETTE (0x05000000): Память палитры фона.
  • MEM_VRAM (0x06000000): Видеопамять.

В VRAM память разделена на блоки по 16 КБ (для тайлов) и по 2 КБ (для карт). Мы должны сказать контроллеру, где что лежит.

Шаг 3: Код программы

#include <string.h> // Для memcpy

typedef unsigned short u16;
typedef unsigned int   u32;

#define REG_DISPCNT  *(volatile u32*)0x04000000
#define REG_BG0CNT   *(volatile u16*)0x04000008

#define MEM_PALETTE  ((u16*)0x05000000)
#define MEM_VRAM     ((volatile u16*)0x06000000)

// Настройки дисплея
#define MODE_0 0x0
#define BG0_ON 0x100

// Настройки фона
// CBB - Character Base Block (где лежат тайлы, шаг 16 КБ)
// SBB - Screen Base Block (где лежит карта, шаг 2 КБ)
#define BG_CBB(n)  ((n) << 2)
#define BG_SBB(n)  ((n) << 8)
#define BG_8BPP    0x80 // Если нужно 256 цветов (мы не используем)
#define BG_SIZE0   0    // Размер карты 32x32 тайла

int main() {
    // 1. Загружаем палитру
    // Копируем наши 2 цвета в начало палитры фона
    MEM_PALETTE[0] = 0x0000;
    MEM_PALETTE[1] = 0x7FFF;

    // 2. Загружаем тайл в VRAM
    // Кладем в CBB блок 0. Поскольку VRAM - это u16*, приводим типы
    // Один тайл = 32 байта = 8 int'ов
    u32* tile_mem = (u32*)MEM_VRAM;
    for(int i=0; i<8; i++) {
        tile_mem[i] = tile_cross[i];
    }

    // 3. Заполняем карту (Screen Block)
    // Мы решили использовать SBB блок 30 (обычно карты кладут в конец VRAM)
    // Адрес SBB 30 = Начало VRAM + 30 * 2048 байт
    // 30 * 2048 / 2 (так как указатель u16) = смещение 30720
    volatile u16* map_mem = MEM_VRAM + (30 * 1024);

    // Заполним экран (30x20 тайлов видимой области)
    // Но карта в памяти всегда минимум 32x32
    for(int i=0; i < 32 * 32; i++) {
        map_mem[i] = 0x0001; // Используем тайл с индексом 1 (наш крестик)
                             // Тайл 0 обычно пустой/прозрачный
    }
    // ВАЖНО: Мы загрузили тайл в начало памяти, это индекс 0 или 1?
    // В примере выше мы записали в самое начало, это индекс 0.
    // Давайте исправим map_mem[i] = 0x0000;

    // 4. Настраиваем видеоконтроллер
    // Говорим фону 0: бери тайлы из блока 0, а карту из блока 30
    REG_BG0CNT = BG_CBB(0) | BG_SBB(30) | BG_SIZE0;

    // Включаем Режим 0 и Фон 0
    REG_DISPCNT = MODE_0 | BG0_ON;

    while(1);
    return 0;
}

Примечание к коду: В реальном коде мы бы использовали memcpy для копирования массивов, но здесь использован цикл для наглядности.

Слои и приоритеты

В Режиме 0 мы можем включить до 4-х фонов одновременно (BG0, BG1, BG2, BG3). Зачем так много?

Это позволяет создавать эффект глубины, называемый параллаксом. Вы наверняка видели это в играх: дальние горы движутся медленно, деревья ближе — быстрее, а земля под ногами — еще быстрее.

Каждый фон имеет свой приоритет (от 0 до 3). Фон с приоритетом 0 рисуется поверх всех остальных. Если пиксель верхнего фона имеет индекс цвета 0 (прозрачный), сквозь него будет виден пиксель нижнего фона.

Заключение

Мы перешли от рисования точек к управлению аппаратными блоками. Использование тайлов и карт — это ключ к производительности GBA. Вместо того чтобы менять каждый пиксель, мы просто меняем одно число в карте, и весь блок 8x8 мгновенно меняется на экране.

В этой статье мы научились:

  1. Различать тайловые и растровые режимы.
  2. Понимать структуру тайла (8x8 пикселей, 4bpp).
  3. Связывать тайлы, карты и палитры через регистры управления.

В следующем уроке мы добавим в наш мир жизнь: разберем спрайты (объекты) — подвижные элементы игры, которые могут свободно перемещаться поверх фонов, не будучи привязанными к сетке.

Работа со спрайтами: память OAM, анимация и движение

Работа со спрайтами: память OAM, анимация и движение

В предыдущей статье мы научились создавать красивые статические миры, используя тайловые фоны. Но мир игры мертв без движения. Герой должен бежать, враги — атаковать, а монетки — вращаться. Для всего этого в Game Boy Advance используется специальная аппаратная сущность — спрайты.

Спрайты (или аппаратные объекты, OBJ) — это графические элементы, которые могут свободно перемещаться по экрану поверх фона, не будучи привязанными к сетке тайловой карты. В этой статье мы разберем, как управлять ими через память OAM, как заставить их двигаться и как реализовать покадровую анимацию.

Что такое OAM?

В отличие от современных движков, где вы создаете объект командой new Sprite(), в GBA количество спрайтов строго ограничено железом. У нас есть фиксированная таблица в памяти, называемая OAM (Object Attribute Memory).

Эта память находится по адресу 0x07000000 и имеет размер 1 КБ. Она содержит ровно 128 слотов для описания спрайтов. Даже если спрайт не виден на экране, слот под него существует. Чтобы «удалить» спрайт, мы просто перемещаем его за пределы видимой области или отключаем его отрисовку.

Структура атрибутов спрайта

Каждый спрайт описывается тремя 16-битными числами (атрибутами). Давайте назовем их attr0, attr1 и attr2. Именно манипулируя битами в этих числах, мы говорим видеопроцессору, где и как рисовать персонажа.

Attribute 0: Y-координата и форма

Первый атрибут отвечает за вертикальную позицию и общие настройки режима.

Биты Назначение
0-7 Y-координата. Позиция по вертикали (0-255).
8-9 Режим отрисовки. (0 = Обычный, 1 = Полупрозрачный, 2 = Окно, 3 = Скрыт).
10-11 Режим GFX. (Используется для альфа-блендинга и мозаики).
12 Мозаика. Включить эффект пикселизации.
13 Цветность. (0 = 4bpp/16 цветов, 1 = 8bpp/256 цветов).
14-15 Форма. (0 = Квадрат, 1 = Горизонтальный, 2 = Вертикальный).
Attribute 1: X-координата и размер

Второй атрибут контролирует горизонталь и размер спрайта.

Биты Назначение
0-8 X-координата. Позиция по горизонтали (0-511).
9-11 Не используются (в обычном режиме).
12 Horizontal Flip. Отразить по горизонтали.
13 Vertical Flip. Отразить по вертикали.
14-15 Размер. Вместе с формой из Attr0 определяет размеры в пикселях.
Attribute 2: Тайлы и палитра

Третий атрибут связывает спрайт с графическими данными.

Биты Назначение
0-9 Индекс тайла. Номер начального тайла в видеопамяти.
10-11 Приоритет. Слой отрисовки относительно фонов (0-3).
12-15 Палитра. Номер палитры (0-15), если используется режим 4bpp.

Размеры спрайтов

Спрайты в GBA не обязаны быть размером 8x8. Комбинируя биты Shape (из Attr0) и Size (из Attr1), мы можем получить следующие размеры:

Size / Shape 0 (Square) 1 (Horizontal) 2 (Vertical)
0 8x8 16x8 8x16
1 16x16 32x8 8x32
2 32x32 32x16 16x32
3 64x64 64x32 32x64

Это позволяет создавать огромных боссов или длинные платформы, используя всего один аппаратный спрайт.

Программирование спрайтов

Чтобы работать с OAM удобно, нам нужно создать структуру данных на языке C, которая будет точно соответствовать «железу». Поскольку мы не хотим вручную сдвигать биты каждый раз (val << 8), мы используем битовые поля или макросы. Для простоты и надежности в GBA-разработке чаще используют макросы и typedef.

typedef unsigned short u16;
typedef unsigned int   u32;

// Структура одного объекта OAM
typedef struct {
    u16 attr0;
    u16 attr1;
    u16 attr2;
    u16 fill; // Заполнитель для выравнивания (всего 8 байт на спрайт)
} OBJ_ATTR;

// Указатель на начало OAM памяти
#define OAM_MEM ((volatile OBJ_ATTR*)0x07000000)

Загрузка графики

Тайлы спрайтов хранятся в VRAM, но отдельно от тайлов фона. Обычно для спрайтов выделяются верхние блоки памяти (начиная с адреса 0x06010000 в видеорежимах 0-2). Это называется OBJ VRAM.

Процесс вывода спрайта на экран выглядит так:

  1. Скопировать тайлы (картинку) в OBJ VRAM.
  2. Скопировать палитру в OBJ Palette RAM (0x05000200).
  3. Настроить атрибуты в OAM.

Движение: Изменяем координаты

Движение — это просто изменение координат XX и YY в каждом кадре. Однако есть нюанс: экран обновляется 60 раз в секунду. Если мы будем менять координаты в момент, когда луч экрана рисует изображение, мы получим артефакт, называемый «разрывом кадра» (screen tearing).

Чтобы этого избежать, мы должны менять OAM только во время VBlank (Vertical Blank) — короткой паузы, когда луч возвращается из правого нижнего угла в левый верхний.

Формула обновления позиции проста:

Pnew=Pold+V⋅ΔtP_{new} = P_{old} + V \cdot Δt

Где:

  • PnewP_{new} — новая координата (X или Y).
  • PoldP_{old} — текущая координата.
  • VV — скорость (пикселей за кадр).
  • ΔtΔt — шаг времени (в нашем случае всегда 1 кадр).

Пример кода движения

// Простая функция ожидания VBlank
void vid_vsync() {
    volatile u16* vcount = (volatile u16*)0x04000006;
    while(*vcount < 160); // Ждем, пока луч уйдет за пределы экрана
}

int main() {
    int x = 96;
    int y = 64;

    // Инициализация графики (пропущена для краткости)
    // ...

    OBJ_ATTR* player = &OAM_MEM[0]; // Берем первый слот OAM

    while(1) {
        vid_vsync(); // Ждем безопасного времени

        // Простая логика движения (псевдокод ввода)
        // if (KEY_RIGHT) x++;
        // if (KEY_LEFT) x--;

        // Обновляем атрибуты
        // Сбрасываем старые координаты (маска 0xFF) и ставим новые
        player->attr0 = (player->attr0 & ~0xFF) | (y & 0xFF);
        player->attr1 = (player->attr1 & ~0x1FF) | (x & 0x1FF);
    }
}

Анимация: Смена кадров

Анимация спрайта — это иллюзия, создаваемая быстрой сменой картинок. В терминах GBA это означает изменение индекса тайла (Tile Index) в attr2.

Представьте, что у вас есть анимация бега персонажа, состоящая из 4 кадров. Каждый кадр занимает, например, 4 тайла в памяти (размер спрайта 16x16). Тайлы лежат в памяти последовательно.

Математически индекс тайла для текущего кадра вычисляется так:

Tcurrent=Tbase+(Fcurrent⋅Sframe)T_{current} = T_{base} + (F_{current} \cdot S_{frame})

Где:

  • TcurrentT_{current} — индекс тайла, который мы запишем в attr2.
  • TbaseT_{base} — индекс самого первого тайла анимации в VRAM.
  • FcurrentF_{current} — номер текущего кадра анимации (0, 1, 2...).
  • SframeS_{frame} — «шаг» или размер одного кадра в тайлах (сколько тайлов занимает одна картинка).

Реализация таймера анимации

Мы не можем менять кадр каждый цикл процессора (это будет слишком быстро). Нам нужно замедление. Обычно вводят переменную-счетчик.

int frame = 0;
int timer = 0;

while(1) {
    vid_vsync();

    timer++;
    if(timer > 10) { // Каждые 10 обновлений экрана (6 раз в секунду)
        timer = 0;
        frame++;
        if(frame > 3) frame = 0; // Зацикливаем анимацию (0-1-2-3-0...)
    }

    // Применяем формулу: База = 0, Шаг = 4 (для спрайта 16x16)
    int tile_id = 0 + frame * 4;

    // Обновляем attr2, сохраняя остальные флаги (палитру, приоритет)
    player->attr2 = (player->attr2 & 0xFC00) | (tile_id & 0x03FF);
}

Особенности координат и "заворачивание"

Интересный факт: координаты спрайтов в GBA работают по модулю.

  • Y-координата имеет 8 бит (0-255), хотя высота экрана 160.
  • X-координата имеет 9 бит (0-511), хотя ширина экрана 240.

Если вы запишете в Y значение 250, спрайт не исчезнет далеко внизу. Поскольку экран имеет высоту 160, значения от 160 до 255 находятся в «невидимой зоне», но если спрайт большой, его нижняя часть может «вылезти» сверху экрана. Это поведение нужно учитывать при реализации камеры.

Заключение

Спрайты — это главные действующие лица вашей игры. Понимание того, как работает OAM, позволяет эффективно управлять сотней объектов одновременно. Мы разобрали три кита спрайтовой графики:

  1. Атрибуты OAM — пульт управления спрайтом.
  2. VBlank — правильное время для обновлений.
  3. Манипуляция индексами — основа анимации.

В следующей статье мы оживим нашу игру по-настоящему: научимся обрабатывать нажатия кнопок (Input Handling) и напишем простую игровую логику, чтобы наш персонаж слушался игрока.

Обработка ввода, игровая логика и коллизии

Обработка ввода, игровая логика и коллизии

В предыдущих статьях мы создали фундамент нашей игры: настроили окружение, научились рисовать тайловые фоны и выводить на экран анимированные спрайты. Однако пока что наш персонаж — это просто картинка, которая движется по заранее прописанному сценарию или стоит на месте. Это не игра, а мультфильм.

Чтобы превратить проект в игру, нам нужно добавить интерактивность. Игрок должен управлять героем, а герой должен взаимодействовать с миром — собирать монеты, прыгать по платформам или врезаться в врагов. В этой статье мы разберем три кита игрового процесса: чтение ввода (кнопок), организацию игрового цикла и физику столкновений (коллизии).

Чтение ввода: Как GBA видит кнопки

Game Boy Advance имеет 10 кнопок: крестовина (Up, Down, Left, Right), основные кнопки (A, B), плечевые шифты (L, R) и служебные кнопки (Start, Select). В отличие от современных геймпадов с аналоговыми стиками, все кнопки GBA — цифровые. Они могут быть либо нажаты, либо отпущены.

Регистр ввода KEYINPUT

Состояние всех кнопок хранится в одном 16-битном регистре памяти по адресу 0x04000130. Мы будем называть его REG_KEYINPUT.

Главная особенность, которая сбивает с толку новичков: инвертированная логика (Active LOW).

  • Если бит равен 1, кнопка ОТПУЩЕНА.
  • Если бит равен 0, кнопка НАЖАТА.

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

Карта битов выглядит так:

Бит Кнопка Шестнадцатеричное значение
0 A 0x0001
1 B 0x0002
2 Select 0x0004
3 Start 0x0008
4 Right 0x0010
5 Left 0x0020
6 Up 0x0040
7 Down 0x0080
8 R 0x0100
9 L 0x0200

Три состояния кнопки

Просто знать, нажата ли кнопка прямо сейчас, недостаточно. В играх нам нужны разные типы реакций:

  1. Held (Удержание): Персонаж бежит, пока мы держим стрелку вправо.
  2. Down (Нажатие): Персонаж прыгает только один раз в момент касания кнопки A. Если мы держим A, он не должен улетать в космос.
  3. Up (Отпускание): Зарядка выстрела происходит, пока держим B, и выстрел происходит, когда отпускаем.

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

Давайте напишем простой менеджер ввода на C:

// Определения кнопок
#define KEY_A      0x0001
#define KEY_B      0x0002
#define KEY_SELECT 0x0004
#define KEY_START  0x0008
#define KEY_RIGHT  0x0010
#define KEY_LEFT   0x0020
#define KEY_UP     0x0040
#define KEY_DOWN   0x0080
#define KEY_R      0x0100
#define KEY_L      0x0200

// Глобальные переменные для хранения состояния
u16 key_curr = 0; // Текущее состояние
u16 key_prev = 0; // Предыдущее состояние

// Функция опроса (вызывать один раз за кадр)
void key_poll() {
    key_prev = key_curr;
    // Инвертируем значение (~), чтобы 1 означала "нажато"
    // Это удобнее для человеческого восприятия
    key_curr = ~(*(volatile u16*)0x04000130) & 0x03FF;
}

// Состояние "Удерживается"
u32 key_held(u32 key) {
    return key_curr & key;
}

// Состояние "Только что нажато" (Hit)
// Кнопка нажата сейчас, но НЕ была нажата в прошлом кадре
u32 key_hit(u32 key) {
    return key_curr & ~key_prev & key;
}

// Состояние "Только что отпущено" (Released)
// Кнопка НЕ нажата сейчас, но БЫЛА нажата в прошлом кадре
u32 key_released(u32 key) {
    return ~key_curr & key_prev & key;
}

Теперь в игровом цикле мы можем писать логичный код:

if (key_held(KEY_RIGHT)) player.x++; // Бег
if (key_hit(KEY_A)) player_jump();   // Прыжок

Игровая логика и структура кадра

Игры работают в бесконечном цикле. Один проход этого цикла называется кадром. На GBA мы стремимся к стабильным 60 кадрам в секунду (FPS). Чтобы игра работала предсказуемо, порядок действий внутри цикла должен быть строгим.

Типичный игровой цикл (Game Loop) выглядит так:

  1. VBlank (Вертикальная синхронизация): Ждем начала паузы отрисовки.
  2. Input (Ввод): Опрашиваем кнопки (key_poll).
  3. Logic (Логика): Обновляем мир игры. Двигаем персонажей, проверяем коллизии, считаем таймеры.
  4. Render (Отрисовка): Подготавливаем данные для видеопамяти. Копируем новые координаты спрайтов в OAM.

Важно разделять логику и отрисовку. Логика меняет переменные (x, y, hp), а отрисовка просто берет эти переменные и переносит их в видеопамять. Не пишите в OAM посреди проверки коллизий.

Коллизии: AABB

Самая сложная часть для новичка — научить объекты не проходить сквозь друг друга. В 3D-играх используется сложная математика с полигонами, но на GBA стандартом является AABB (Axis-Aligned Bounding Box) — выровненный по осям ограничивающий прямоугольник.

Суть метода проста: мы представляем каждый объект (персонажа, врага, стену) как прямоугольник. "Выровненный по осям" означает, что этот прямоугольник не может вращаться; его стороны всегда параллельны краям экрана. Это значительно упрощает математику.

Математика пересечения

Чтобы проверить, пересекаются ли два прямоугольника (Игрок и Враг), нам не нужно проверять каждый пиксель. Достаточно проверить, что они НЕ разделены пространством ни по одной из осей.

Два прямоугольника пересекаются тогда и только тогда, когда выполняются все четыре условия одновременно:

Collision=(x1<x2+w2)∧(x1+w1>x2)∧(y1<y2+h2)∧(y1+h1>y2)\text{Collision} = (x_1 < x_2 + w_2) \land (x_1 + w_1 > x_2) \land (y_1 < y_2 + h_2) \land (y_1 + h_1 > y_2)

Где:

  • x1,y1x_1, y_1 — координаты верхнего левого угла первого объекта.
  • w1,h1w_1, h_1 — ширина и высота первого объекта.
  • x2,y2x_2, y_2 — координаты верхнего левого угла второго объекта.
  • w2,h2w_2, h_2 — ширина и высота второго объекта.
  • ∧\land — логическое "И".

Если хотя бы одно условие ложно (например, x1>x2+w2x_1 > x_2 + w_2, то есть первый объект находится полностью правее второго), то пересечения нет.

Реализация на C

Создадим простую структуру для хитбокса (зоны поражения) и функцию проверки:

typedef struct {
    int x, y;
    int width, height;
} AABB;

// Возвращает 1 (true), если есть пересечение, иначе 0 (false)
int check_collision(AABB a, AABB b) {
    return (a.x < b.x + b.width) &&
           (a.x + a.width > b.x) &&
           (a.y < b.y + b.height) &&
           (a.y + a.height > b.y);
}

Реакция на коллизию

Обнаружить столкновение — это полдела. Нужно правильно на него отреагировать. Реакция зависит от типа объекта:

  1. Сбор предмета (Монетка): Если пересечение есть -> Увеличить счет, пометить монетку как "неактивную" (скрыть спрайт).
  2. Вредный объект (Шипы): Если пересечение есть -> Уменьшить здоровье, отбросить игрока назад.
  3. Твердый объект (Стена): Это самое сложное. Нам нужно не просто зафиксировать факт, а вытолкнуть игрока из стены, чтобы он не застрял.

Для стен обычно используют проверку перед движением:

// Пробуем двигаться по X
player.x += velocity_x;
if (check_wall_collision(player)) {
    // Если врезались, отменяем движение
    player.x -= velocity_x;
    velocity_x = 0;
}

// Пробуем двигаться по Y
player.y += velocity_y;
if (check_wall_collision(player)) {
    player.y -= velocity_y;
    velocity_y = 0;
}

Разделение движения по осям X и Y критически важно. Если двигать игрока сразу по диагонали и проверять коллизию, он может застрять углом в стене или провалиться сквозь пол.

Оптимизация: Fixed Point и хитбоксы

В первой статье мы говорили о числах с фиксированной точкой. В логике коллизий они тоже важны. Координаты спрайтов на экране — целые числа (пиксели). Но координаты в логике игры должны быть более точными (Fixed Point), чтобы обеспечить плавное движение.

Однако для проверки AABB мы обычно конвертируем координаты обратно в целые числа (пиксели), так как коллизия с точностью до 1/256 пикселя избыточна и дорога для процессора.

Также помните: Хитбокс != Спрайт. Спрайт персонажа может быть 32x32 пикселя, но вокруг него может быть много пустого места (прозрачные пиксели). Если использовать весь размер спрайта для коллизий, игрок будет умирать, даже не коснувшись шипов визуально. Хорошей практикой считается делать хитбокс чуть меньше видимого спрайта.

Заключение

Теперь у вас есть полный набор инструментов для создания простой игры:

  1. Вы умеете рисовать мир (тайлы).
  2. Вы умеете рисовать героев (спрайты).
  3. Вы умеете управлять героем (ввод).
  4. Вы умеете взаимодействовать с миром (коллизии).

В следующей статье мы добавим в нашу игру звук и музыку, используя движок GBA Direct Sound и программируемые каналы PSG, чтобы мир стал по-настоящему живым.

Звуковая подсистема, прерывания и прямой доступ к памяти (DMA)

Звуковая подсистема, прерывания и прямой доступ к памяти (DMA)

Мы уже создали графику, настроили движение персонажа и обработали коллизии. Наша игра выглядит живой, но она всё ещё «немая». Звук — это 50% атмосферы игры. Представьте Super Mario без звука прыжка или Doom без рычания монстров.

Однако работа со звуком на Game Boy Advance (GBA) ставит перед нами серьезную техническую проблему. Воспроизведение качественного цифрового аудио требует передачи данных тысячи раз в секунду. Если мы заставим центральный процессор (CPU) заниматься этим вручную, у него не останется времени на обработку игровой логики, и игра превратится в слайд-шоу.

В этой статье мы разберем три взаимосвязанные темы:

  1. Звуковая архитектура GBA: Как устроены каналы PSG и Direct Sound.
  2. Прерывания (Interrupts): Как заставить процессор реагировать на события мгновенно.
  3. DMA (Direct Memory Access): Как передавать данные (например, аудиосэмплы) без участия процессора.

Анатомия звука GBA

Звуковая подсистема GBA — это гибрид двух эпох. Она содержит в себе полное звуковое ядро от старого Game Boy (Game Boy Color) для обратной совместимости и новые цифровые каналы для воспроизведения сэмплов.

Всего нам доступно 6 каналов:

1. PSG Каналы (Programmable Sound Generator)

Это «чиптюн» каналы, генерирующие волны математически. Они почти не нагружают процессор и память.

  • Канал 1 (Square with Sweep): Прямоугольная волна с возможностью изменения частоты (sweep). Идеально для звуков прыжков, падений или усиления (power-up).
  • Канал 2 (Square): Прямоугольная волна без sweep. Используется для основной мелодии.
  • Канал 3 (Wave): Канал произвольной волны. Мы можем загрузить в него крошечный сэмпл (32 полубайта), чтобы получить более сложные тембры (например, бас).
  • Канал 4 (Noise): Генератор белого шума. Незаменим для звуков взрывов, ударов и перкуссии (барабанов).

2. Direct Sound (Каналы A и B)

Это главное нововведение GBA. Direct Sound позволяет воспроизводить 8-битные PCM-сэмплы (Pulse Code Modulation) — то есть реальные записи звука, как в формате .wav.

Именно через эти каналы звучат голоса персонажей, реалистичная музыка и сложные звуковые эффекты. Однако, в отличие от PSG, Direct Sound не генерирует звук сам. Мы должны «скармливать» ему байты данных с определенной скоростью (частотой дискретизации).

Проблема производительности

Допустим, мы хотим воспроизвести звук с частотой 16 кГц (16 000 сэмплов в секунду). Это значит, что каждые 62.5 микросекунды мы должны брать новый байт из памяти звука и класть его в регистр аудио.

Если делать это в основном цикле while(1), процессор будет занят только перекладыванием байтов. Игра остановится. Нам нужен механизм, который будет делать это автоматически.

Здесь на сцену выходят Прерывания и DMA.

Прерывания (Interrupts)

В наших прошлых уроках мы использовали опрос (polling) — мы постоянно спрашивали в цикле: «Нажата ли кнопка? Наступил ли VBlank?». Это как если бы вы ждали доставку пиццы и каждые 5 секунд открывали дверь, чтобы проверить курьера.

Прерывание — это дверной звонок. Вы занимаетесь своими делами, а когда курьер приходит, он звонит, вы прерываетесь, забираете пиццу и возвращаетесь к делам.

Когда происходит аппаратное событие (нажатие кнопки, окончание отрисовки строки, срабатывание таймера), процессор может приостановить выполнение основной программы, прыгнуть в специальную функцию (ISR — Interrupt Service Routine), выполнить её и вернуться назад.

Настройка прерываний

Чтобы прерывания заработали, нужно включить три «рубильника»:

  1. IME (Interrupt Master Enable): Главный рубильник процессора. Если он выключен (0), процессор игнорирует все звонки.
  2. IE (Interrupt Enable): Регистр, где мы выбираем, какие именно события нас интересуют (например, только VBlank и Timer 0).
  3. Флаги в периферии: В регистрах самого таймера или дисплея нужно разрешить отправку сигнала прерывания.

DMA: Прямой доступ к памяти

Даже с прерываниями копирование данных — это рутинная работа. Процессор — это архитектор, ему не пристало таскать кирпичи. Для этого есть DMA (Direct Memory Access).

DMA — это специализированный аппаратный блок, который умеет делать только одну вещь: очень быстро копировать массив данных из одного места памяти в другое. Пока DMA работает, процессор может отдыхать или (в некоторых режимах) заниматься другими делами.

У GBA есть 4 канала DMA (0, 1, 2, 3). Для звука обычно используются каналы 1 и 2.

Как работает звук через DMA?

Схема воспроизведения сэмпла выглядит так:

  1. Мы загружаем звуковой файл (массив байтов) в память (ROM или RAM).
  2. Мы настраиваем Таймер на нужную частоту воспроизведения (например, 16 кГц).
  3. Мы настраиваем DMA так: «Копируй данные из массива в звуковой буфер (FIFO). Делай это каждый раз, когда тикает Таймер».
  4. Процессор запускает этот механизм и забывает о нем. DMA сам подкидывает дрова в топку звукового чипа по сигналу таймера.

Математика звука: Настройка таймера

Таймеры GBA работают на частоте системной шины — 16.78 МГц (точнее 2242^{24} Гц). Таймер — это 16-битный счетчик, который увеличивается каждый такт (или каждые 64/256/1024 такта, если включен прескейлер). Когда счетчик переполняется (доходит до 65535 и сбрасывается в 0), он посылает сигнал DMA.

Чтобы получить нужную частоту дискретизации (Sample Rate), нам нужно вычислить начальное значение таймера. Таймер будет считать от этого значения до переполнения.

Формула расчета значения для регистра таймера:

Vtimer=65536−FcpuFsampleV_{timer} = 65536 - \frac{F_{cpu}}{F_{sample}}

Где:

  • VtimerV_{timer} — значение, которое мы записываем в регистр данных таймера (REG_TMxD).
  • 6553665536 — максимальное значение 16-битного счетчика (2162^{16}).
  • FcpuF_{cpu} — частота процессора GBA, равная 16 777 21616\ 777\ 216 Гц.
  • FsampleF_{sample} — желаемая частота воспроизведения звука (например, 1102511025 Гц, 1600016000 Гц, 2205022050 Гц).

Пример: Мы хотим воспроизвести звук с частотой 16 кГц (1600016000 Гц).

Vtimer=65536−1677721616000V_{timer} = 65536 - \frac{16777216}{16000}

Vtimer=65536−1048.576≈64487V_{timer} = 65536 - 1048.576 \approx 64487

В шестнадцатеричной системе это 0xFBE7. Если мы запишем это число в таймер, он будет переполняться ровно 16 000 раз в секунду, каждый раз заставляя DMA отправить новый байт звука в динамики.

Реализация на C

Давайте посмотрим, как это выглядит в коде. Мы будем использовать Direct Sound Channel A.

// Регистры звука
#define REG_SOUNDCNT_H  *(volatile u16*)0x04000082
#define REG_SOUNDCNT_X  *(volatile u16*)0x04000084
#define REG_FIFO_A      *(volatile u32*)0x040000A0

// Регистры DMA 1
#define REG_DMA1SAD     *(volatile u32*)0x040000BC // Источник
#define REG_DMA1DAD     *(volatile u32*)0x040000C0 // Назначение
#define REG_DMA1CNT     *(volatile u32*)0x040000C4 // Управление

// Регистры Таймера 0
#define REG_TM0D        *(volatile u16*)0x04000100
#define REG_TM0CNT      *(volatile u16*)0x04000102

// Константы управления DMA
#define DMA_DST_FIXED   0x00400000 // Адрес назначения не меняется (FIFO)
#define DMA_REPEAT      0x02000000 // Повторять бесконечно
#define DMA_32          0x04000000 // Копировать по 32 бита
#define DMA_SYNC_TO_TIMER 0x30000000 // Синхронизация с таймером 0 (бит 28-29)
#define DMA_ENABLE      0x80000000 // Включить DMA

// Наш звуковой сэмпл (конвертированный wav файл)
extern const unsigned char sound_data[];
extern const unsigned int sound_length;

void play_sound() {
    // 1. Включаем звук глобально (Master Enable)
    REG_SOUNDCNT_X = 0x0080;

    // 2. Настраиваем Direct Sound A
    // Включаем вывод справа и слева, таймер 0, сброс FIFO
    REG_SOUNDCNT_H = 0x0B00;

    // 3. Настраиваем Таймер 0 на 16 кГц
    // 0xFBE7 рассчитано по формуле выше
    REG_TM0D = 0xFBE7;
    REG_TM0CNT = 0x0080; // Включаем таймер

    // 4. Настраиваем DMA 1
    REG_DMA1SAD = (u32)sound_data;      // Откуда брать: массив звука
    REG_DMA1DAD = (u32)&REG_FIFO_A;     // Куда класть: звуковой буфер

    // Самое важное: настройки DMA
    // Копируем 32 бита, не меняем адрес назначения (всегда в FIFO),
    // повторяем при каждом сигнале Таймера 0.
    REG_DMA1CNT = DMA_DST_FIXED | DMA_REPEAT | DMA_32 |
                  DMA_SYNC_TO_TIMER | DMA_ENABLE;
}

Разбор магии FIFO

Вы могли заметить странность: мы копируем 32 бита (DMA_32), хотя сэмплы 8-битные. И мы копируем в один и тот же адрес REG_FIFO_A.

Звуковой буфер GBA (FIFO — First In, First Out) вмещает 32 байта. DMA работает очень быстро и «закидывает» туда сразу пачку данных (4 байта за раз). Звуковой чип затем медленно «достает» оттуда по одному байту с той скоростью, которую задает таймер. Если буфер опустеет, звук прервется. DMA следит за тем, чтобы буфер всегда был полон.

Двойная буферизация (Double Buffering)

Приведенный выше код имеет недостаток: он может проиграть только один короткий звук, который целиком помещается в память. А что делать с фоновой музыкой, которая длится 3 минуты?

Мы не можем загрузить 30 МБ музыки в 256 КБ оперативной памяти. Мы используем кольцевой буфер или двойную буферизацию.

  1. Выделяем небольшой буфер в памяти.
  2. Запускаем воспроизведение из начала буфера.
  3. Пока проигрывается первая половина, мы (с помощью прерываний!) быстро загружаем новую порцию музыки во вторую половину.
  4. Когда проигрывание доходит до второй половины, мы обновляем первую.

Это создает иллюзию бесконечного потока музыки, используя минимум памяти.

Заключение

Сегодня мы коснулись самой сложной части низкоуровневой разработки — синхронизации железа. Мы узнали:

  • Как GBA объединяет старые PSG каналы и новые Direct Sound каналы.
  • Зачем нужны прерывания (чтобы не опрашивать железо постоянно).
  • Как DMA позволяет перекладывать данные без участия процессора.
  • Как использовать математику для настройки точной частоты воспроизведения.

Теперь ваша игра может звучать! В следующей статье мы разберем работу с файловой системой картриджа и сохранение прогресса (SRAM), чтобы игрокам не приходилось начинать игру заново каждый раз.