Как написать игру для nes

Время на прочтение
8 мин

Количество просмотров 35K

Впервые я задумался о том, как разрабатывают игры под приставки где-то через 20 минут после того, как в самый первый раз увидел Turbo Pascal. На глаза иногда попадался Subor с клавиатурой, и появилась мысль: «Наверное можно набрать какую-то программу, а потом в нее поиграть». Но интерес быстро затух, потому что абсолютно никакой информации по этой теме тогда не было доступно. Следующий раз эта же идея всплыла, когда увидел вполне играбельные эмуляторы старых консолей. Тогда стало ясно, что вбивать листинг в саму консоль и необязательно. Где-то очень потом появился Хабр с благожелательной аудиторией для таких вещей. В какой-то момент даже начал собирать разрозненную инфу чтобы написать мануал самому, и вот сегодня наткнулся на готовый учебник, который явно надо перевести.

Разработка под старые консоли документирована вдоль и поперек, но именно по NES 99% информации относятся к разработке на Ассемблере. Меня почему-то зарубило, что надо освоить именно работу с С.

следующая >>>
image

Всем привет.
image

Меня зовут Дуг. Я пишу игры для NES вот уже год, и решил начать этот блог. Я намерен написать туториал по разработке игр под NES, чтобы вдохновить других людей делать собственные игры.

Особенностью блога будет использование чистого С, так что другие программисты смогут начать писать быстро и не особо вникая в ассемблер для процессора 6502. Насколько мне известно, других туториалов такого формата по компилятору cc65 пока нет, кроме нескольких примеров игр на сайте Shiru.

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

Я постараюсь максимально упростить обучение, и использовать самые простые примеры. Также рекомендую начать с простейшей идеи игры. Читателю явно захочется сделать новую Зелду, но это не получится. Простейшая игра потребует 2-3 месяца на разработку, Зелда — 2-3 года. Такой проект скорее всего будет заброшен. Ориентируйтесь на Пакман, хотя бы первое время.

Память консоли

Поговорим о структуре памяти. У NES два независимых адресных пространства — память процессора с диапазоном $0-$FFFF и память PPU — видеочипа.

Начнем с памяти процессора.

  • Первые $800 это RAM.
  • Диапазон $6000-$7FFF некоторые игры используют для работы с SRAM (сохранение на картрирдж с батарейкой), или как дополнительный Work RAM.
  • На пространство $8000-$FFFF отображается ROM. Некоторые мапперы (дополнительный процессор в картридже) могут использовать более 32k ROM, но они все равно обычно работают через $8000-$FFFF.
  • Адрес $FFFC-$FFFD это вектор reset, который указывает на начало программы.

Здесь более подробная информация.

У PPU свое, независимое адресное пространство. Оно имеет размер $3FFF, но местами зеркалируется. Доступ к нему идет через регистры в памяти процессора. Видеопамяти хватает на 4 экранных буфера, но в подавляющем большинстве игр используется только 2 — для реализации прокрутки.

  • $0-$1FFF = здесь хранятся спрайты
  • $2000-$23FF = Таблица имен 0
  • $2400-$27FF = Таблица имен 1
  • $2800-$2BFF = Таблица имен 2
  • $2C00-$2FFF = Таблица имен 3
    При этом таблицы 2 и 3 это зеркало таблиц 0 и 1
  • $3F00-$3F1F = палитра

Таблица имен, nametable, связывает тайлы фона и их позицию на экране.

Зеркалирование позволяет управлять горизонтальной или вертикальной прокруткой, но всему свое время.

image

Еще в PPU есть отдельная область памяти OAM, Object Attribute Memory, размером 256 байт. Доступ к ней реализован через регистры в адресном пространстве процессора, и она позволяет управлять отображением спрайтов.

Вот подробная информация по памяти PPU:
http://wiki.nesdev.com/w/index.php/PPU_memory_map

Еще один момент. Есть два типа картриджей. В некоторых два ROM чипа — PRG-ROM с исполняемым кодом и CHR-ROM с графикой. В таком случае графика автоматически отображается в адреса $0-1FFF PPU. Это позволяет очень просто сделать отрисовку — просто записать номер тайла в таблицу. Мы будем использовать этот формат.

Другой тип картриджа использует CHR-RAM вместо CHR-ROM. Это позволяет подгрузить часть графики в эту дополнительную оперативную память. Это сложная техника, и в этом туториале не рассматривается.

Теперь можно посмотреть на софт, используемый для разработки.

  • Компилятор
  • Редактор тайлов
  • Графический редактор
  • Notepad++
  • Хороший эмулятор
  • Упаковщик тайлов

В этом туториале рассматривается только cc65. Это один из лучших компиляторов для 6502, процессора NES.

Я использую версию 2.15 (для проверки введите ‘cc65 —version’ в консоли). Файлы из разных версий несовместимы, поэтому при необходимости используйте nes.lib из комплекта вашего компилятора.

Во-вторых, надо создать графику. Я использую YY-CHR

Для предобработки графики нужен любой графический редактор: Photoshop или GIMP, по вкусу.

Код удобно писать в Notepad++. У него есть подсветка сишного синтаксиса и нумерация строк — это облегчает отладку.

image

А теперь эмулятор. Я использую FCEUX 90% времени, потому что в нем есть крутой дебаггер и инструменты для работы с памятью, просмотрщики спрайтов и все такое. Но он не самый точный в эмуляции. Игры надо будет тестировать где-то еще. Судя по отзывам, самые точные эмуляторы это Nintendulator, Nestopia, и puNES. Еще желательно подгрузить более точную палитру — лежит здесь.

Есть две версии FCEUX — SDL и Win32. Первая работает почти везде, вторая только в Windows. Так вот, отладчик есть только во второй. Так что в случае альтернативной ОС придется воспользоваться виртуалкой или Wine.

И наконец расстановщик тайлов. Мы можем сделать игру без него, но он точно поможет. Я рекомендую NES Screen Tool. Он отлично показывает ограничения консоли по цветам и отлично подходит для одноэкранных игр. Для игр с прокруткой лучше подойдет Tiled map editor.

Как же всем этим пользоваться?

image

Надо сжать изображение до адекватного размера, например 128 пикселей в ширину. Потом преобразовать в 4 цвета и подправить при необходимости огрехи. Теперь можно копипастить в YY-CHR.

В YY-CHR надо проверить, чтобы цвет был двухбитный.

image

Палитра сейчас не имеет значения, потому что она все равно задается в другом месте.

Как работает сс65

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

Дл упрощения работы будем использовать .bat-скрипты и Makefile. Это позволит автоматизировать процесс и собирать образ картриджа в одно касание.

Процесс примерно такой. cc65 компилирует файл с кодом на С в ассемблерный код. ca65 собирает объектный файл. ld65 линкует его в образ картриджа .nes, который можно запустить в эмуляторе. Настройки хранятся в .cfg файле.

В приставке используется 8-битный процессор MOS 6502. Он не умеет просто обращаться к переменным больше 8 бит. Адресация 16-битная, из математики есть только сложение, вычитание и битовые сдвиги. Так что код придется писать с учетом этих факторов.

  • Большая часть переменных должна быть типа unsigned char — 8 бит, значения 0-255
  • Лучше не передавать значения в функции, или делать это через директиву fastcall, которая передает аргументы через 3 регистра — A,X,Y
  • Массивы не должны быть длинее 256 байт
  • printf отсутствует
  • ++g заметно быстрее, чем g++
  • cc65 не может ни передавать структуры по значению, ни возвращать их из функции
  • Глобальные переменные намного быстрее локальных, даже структуры

Испольуйте опцию -O для оптимизации. Есть еще опции i,r,s, которые иногда комбинируют в -Oirs, но они, например, могут удалить чтение из регистра процессора, значение которого не используется. А это фатально.

Здесь еще немного рекомендаций по использованию компилятора.

Оптимизация жизненно необходима, потому что у 8-битного процессора очень мало ресурсов, и в некоторых случаях надо следить за временем выполнения кода. А обычный код на С этим требованиям не соответствует.

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

extern unsigned char foo;

а если это символ из нулевой страницы памяти, то добавьте директиву

#pragma zpsym (“foo”);

В дальнейшем курсе эти конструкции будут использоваться редко. Единственное исключение — импорт большого бинарного файла. В этом случае оптимально будет завернуть его в ассемблерный файл:

.export _foo
_foo:
.incbin "foo.bin"

а потом импортировать в С как

extern unsigned char foo[];

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

Можно вызывать функции, написанные на ассемблере, через __fastcall__. В этом случае аргументы передадутся в функцию через регистры, а не стек — экономит время. В некоторых случаях без ассемблерного кода не обойтись, например при инициализации приставки. В любом случае, чем меньше аргументов передается в функцию, тем лучше. Сравним две функции, причем переменные test и A глобальные:

void Test (char A) {
 test = A;
}
// функция с одним аргументом компилируется в 19 команд ассемблера

_Test

jsr pusha
 ldy #$00
 lda (sp),y
 sta _test  ; test = A;

 jmp incsp1

pusha: ldy sp 
 beq @L1 
 dec sp
 ldy #0 
 sta (sp),y 
 rts 

@L1: dec sp+1 
 dec sp 
 sta (sp),y 
 rts 

incsp1:

 inc sp
 bne @L1
 inc sp+1
@L1: rts

void Test (void) {
 test = A;
}
// аргумент не передается, компилируется в 3 команды

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


 asm ("Z: bit $2002") ;
 asm ("bpl Z") ;

Кроме того, я заменил громоздкий код инициализации crt0.s на компактный reset.s, и подправил конфигурацию для всего этого. Эти файлы иногда будут меняться. nes.lib используется стандартный, из состава компилятора. Проект собирается с опцией –add-source, которая не удаляет промежуточные ассемблерные файлы — можно порассматривать сгенерированный код.

Удобней определить переменные в сишном коде, а потом импортировать в ассемблерный через

.import _Foo

Но это вопрос вкуса, на мой взгляд, такой код наглядней.

Hello World

Эта программа будет просто печатать текст на экране. Надо помнить, что приставка вообще не знает про кодировку ASCII и работу с текстом в любом виде. Но зато есть возможность вывести картинки размером 8х8 поверх фона.

Так что делаем массив спрайтов-букв, чтобы адреса букв в нем соответсвовали их ASCII-кодам. Потом их можно будет дернуть из кода на С.

image

Код инициализации приставки пока берем как есть, после его выполнения происходит переход на main().

Нам надо сделать такие операции:

  • Выключить экран
  • Настроить палитру
  • Вывести заветные слова
  • Отключить прокрутку
  • Включить экран
  • Повторить

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

Код инициализации заполняет память нулями, так что весь экран будет залит нулевым тайлом — в нашем случае, он пустой. А вся палитра заполнена серым цветом.

Для вывода на экран надо записать координаты начала заливки начиная со старшего байта по адресу $2006, а потом записывать номера тайлов в $2007. PPU будет выводить тайлы с соответствующими номерами один за другим, с переходом на новую строку. Можно перенастроить PPU на шаг вывода, равный 32 — тайлы будут выводиться один под другим. Нам же надо выставить шаг 1, через регистр $2000. Пересчитать координаты экрана в адрес можно через NES screen tool.

Нам также надо заполнить первые 4 цвета палитры — они отвечают за фон. Они записываются по адресу $3F00.

Запись в регистры PPU ломает положение прокрутки, так что ее тоже надо сбросить. Иначе картинка может уехать за экран. Мы делаем это через регистры $2006 и $2005.

lesson1.c


#define PPU_CTRL  *((unsigned char*)0x2000)
#define PPU_MASK  *((unsigned char*)0x2001)
#define PPU_STATUS  *((unsigned char*)0x2002)
#define SCROLL   *((unsigned char*)0x2005)
#define PPU_ADDRESS  *((unsigned char*)0x2006)
#define PPU_DATA  *((unsigned char*)0x2007)

unsigned char index;
const unsigned char TEXT[]={
"Hello World!"};

const unsigned char PALETTE[]={
0x1f, 0x00, 0x10, 0x20
}; //black, gray, lt gray, white

void main (void) {
 // turn off the screen
 PPU_CTRL = 0;
 PPU_MASK = 0;

 // load the palette
 PPU_ADDRESS = 0x3f; // set an address in the PPU of 0x3f00
 PPU_ADDRESS = 0x00;
 for(index = 0; index < sizeof(PALETTE); ++index){
  PPU_DATA = PALETTE[index];
  }

 // load the text
 PPU_ADDRESS = 0x21; // set an address in the PPU of 0x21ca
 PPU_ADDRESS = 0xca;  // about the middle of the screen
 for( index = 0; index < sizeof(TEXT); ++index ){
  PPU_DATA = TEXT[index];
  }

 // reset the scroll position 
 PPU_ADDRESS = 0;
 PPU_ADDRESS = 0;
 SCROLL = 0;
 SCROLL = 0;

 // turn on screen
 PPU_CTRL = 0x90; // NMI on
 PPU_MASK = 0x1e; // screen on

 // infinite loop
 while (1); 
}

image

Ссылка на код:

Дропбокс
Гитхаб
На Гитхабе чуть исправил Makefile, чтобы корректно работал под Windows.

Строка
ONCE: load = PRG, type = ro, optional = yes;
внутри секции segments{} в файлах .cfg нужна для совместимости со свежей версией cc65.

Включение экрана через “PPUMASK = 0x1e” описано в Вики.

Все файлы здесь размером 0х4000. Это самый маленький возможный размер PRG ROM. 90% игр сюда не влезут, и будут отображаться на адреса $8000-$FFFF. У нас же игра загружается в адреса $C000-$FFFF и зеркалируется в $8000-$BFFF. Для разработки большей игры надо будет перенастроить адрес начала ROM на $8000, и выставить размер тоже $8000. А еще включить второй банк PRG ROM в секции header.

Изложено в формате ‘нет времени объяснять’. Подробное раскрытие каждого небольшого пункта потребует статьи большего объёма, чем этот обзор. Это будет сделано впоследствии, если у читателей обнаружатся конкретные интересы.

Хотите написать игру или демо для NES, Famicom, Денди?

Изучаем. Кладезь актуальной мудрости — NesDev Wiki. Вводные для начинающих — Nerdy Nights на английском, другая на русском. Помним, что старые описания неполны и неточны. Отдельно изучаем 6502. Книг и статей много, поиск начинаем отсюда.

Выбираем между ассемблером и C. Тысяча игр на ассемблере, десятки на C. Код на ассемблере в разы эффективнее, на C пишется в разы быстрее. С ассемлером проще получить помощь зала. Для отчаянных есть экзотика, от BASIC до минимальных Lisp и Python.

Выбираем ассемблер. Три популярных, десятки других. NESASM стар, имеет искусственные ограничения (пришёл с MS-DOS, перепилен с PCE/TG16) и мелкие глюки. Легко освоить, большинство старых уроков под него, выдержит средний проект. Современная альтернатива — ASM6, прост в освоении, но не так популярен. Выбор профессионала — CA65. Мощен и гибок, но сложен в освоении (линкер и конфигурация памяти).

Выбираем компилятор C — CC65. Для работы с железом есть библиотеки neslib и KNES, либо пишем свою. По скорости потолок чистого C — подобие Super Mario Bros. Можно писать частично на C, частично на ассемблере, переписывать фрагменты кода по ходу дела. Очень удобно для прототипирования.

Пишем код. Любимый текстовый редактор, bat или make файлы. Есть развитая IDE для CC65 — NESICIDE, но WIP и почти никем не используется.

Рисуем. Отдельные тайлы редактируем непосредственно в YY-CHR, NES Screen Tool, Tile Layer Pro и других, более сложные изображения импортируем из обычных форматов там же. Для сложных проектов может понадобиться свой велосипед. Без трюков NES не может отобразить полноценную картинку на весь экран, аналога Art Studio не ищите.

Музыка и звуки. Два основных трекера, но нужен проигрыватель, выбирается в зависимости от используемого ассемблера. Пишем в FamiTracker, играем FamiTone или Gradual Games Sound Engine. Пишем в Musetracker, играем MUSE. Штатного плеера FamiTracker хватит для простого демо, но не для игр. По желанию пишем свой плеер, оба трекера имеют текстовый экспорт. Для отчаянных есть древности и экзотика.

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

Для среднего или большого проекта наверняка понадобятся особые конвертеры и утилиты. Пишем на чём угодно, лишь бы работало. Обычно C++ или Python.

Отлаживаем в эмуляторах. Средства отладки развиты в FCEUX и NintendulatorDX. Высокая точность эмуляции в Nestopia и puNES, но отладчика в них нет. Даже самые точные эмуляторы не показывают всех глюков, проверяем во всех четырёх. Если пишем на C с готовой низкоуровневой библиотекой, на железе наверняка заработает. Выжимаем максимум на ассемблере — обязательно проверяем на железе при помощи Flash-картриджей (EverDrive N8, InviteNES, PowerPak — ищем в интернете) или самодельного картриджа и программатора ПЗУ.

Успехов!

Только зарегистрированные и авторизованные пользователи могут оставлять комментарии.

В 1980-х годах, когда приставки только появлялись, вышла NES — Nintendo Entertainment System. В Россию она попала в виде китайского клона «Денди», «Кенги» и прочих, поэтому если у вас была восьмибитная приставка, то это была NES.

У NES было очень мало памяти и очень медленный по нынешним меркам процессор. Эта статья о том, как сделать крутую игру в очень ограниченных условиях.

Та самая приставка, справа пока ещё две кнопки вместо четырёх.

Для разбора мы взяли видео из канала Morphcat Games — How we fit an NES game into 40 Kilobytes. Там разработчики повторяют опыт геймдизайнеров прошлого и пишут игру для старого железа. Как обычно, если знаете английский, то лучше посмотрите видео целиком, а если нет — держите наш текстовый вариант.

Почему именно 40 килобайт

В 1980-х объём памяти на цифровых устройствах измеряли в килобайтах, потому что ещё не было таких продвинутых её технологий. В большинстве картриджей для восьмибитных приставок было по 40 килобайт памяти. Для сравнения, это в сто тысяч раз меньше, чем на флешке в 4 гигабайта. Даже эта статья весит больше, чем 40 килобайт, так что по современным меркам этого действительно мало.

Два блока памяти в картриджах, 8 и 32 килобайта, в сумме — 40 килобайт.

Чтобы использовать больше памяти, нужно было идти на всякие ухищрения — ставить расширители памяти или отдельные блоки для работы с несколькими картриджами одновременно. Так как почти ни у кого из геймеров такой роскоши не было, то разработчики использовали только 40 доступных килобайт.

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

Одна из игр, которая взорвала мозг всем в своё время, была та самая «Супер Марио»: в ней было огромное количество разнообразных уровней разной сложности, боссы, секретные уровни и непростой, очень насыщенный геймплей. Были уровни на земле, под землёй, под водой и даже на небе; у героя было несколько режимов — низкий, высокий, в белом комбинезоне. А как вам идея разрушаемого мира? А как вам атаки с воздуха? Короче, «Марио» была безумной, невероятной игрой для своего времени, а всё благодаря оптимизациям.

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

«Супер Марио» — игра, в которую играл каждый, у кого была приставка.

Логика игры

Чтобы в игру было интереснее играть дольше пяти минут, разработчики поставили такие требования:

  1. Это будет платформер — игра, где главному герою нужно бегать и прыгать по платформам, залезать наверх и скакать через препятствия.
  2. Герой сможет ловко двигаться и стрелять по врагам.
  3. Чтобы можно было играть компанией, делают мультиплеер на четырёх человек.

Так как у нас ограничения по памяти, всю игру пишут на Ассемблере — это язык, который работает напрямую с процессором. С одной стороны, код Ассемблера исполняется очень быстро; с другой — в нём работа идёт тупо с перекладыванием данных из одной ячейки процессора в другую. Это примерно как готовить суши, работая с индивидуальными рисинками.

Память распределили так:

  • 8 килобайт на графику,
  • 32 килобайта на сам код игры и хранение данных.

Персонажи

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

Каждая клеточка — это мини-квадратик 8 на 8 пикселей.
В каждом таком квадратике можно что-то нарисовать, но использовать при этом только три цвета.
Если объединить несколько квадратиков в один, получится метаспрайт. В нашем случае — персонаж.
Приставка может использовать одновременно только 4 вида палитры, поэтому у нас получается 4 цветных главных героя и нераскрашенный злодей.
Новое ограничение: на экране одновременно может быть только 8 спрайтов — на большее не хватает памяти. Поэтому для злодея места не остаётся. Можно пойти на хитрость и показывать их быстро-быстро по очереди, но тогда картинка будет мерцать и выглядеть хуже.
Разработчики радикально уменьшили размеры героев и злодея до одного спрайта. Теперь они выглядят более условно, зато помещаются на экран.
Меньше размер героя — больше свободного места для дизайна злодеев, боссов и спецэффектов. Сейчас в табличке собраны все варианты того, как может выглядеть персонаж в игре — и в прыжках, и на бегу.

Большой босс и оптимизация памяти

Если с персонажем всё стало проще, когда его уменьшили, то с боссом всё немного сложнее. Он большой, занимает много места и у него много анимации. Задача — сделать так, чтобы боссы занимали как можно меньше места в памяти.

Большой босс и все его варианты анимации.
Если мы распределим все спрайты по таблице один в один, то у нас быстро закончится место и один кусочек не поместится. Запомните эту картинку как пример неоптимизированной работы с памятью.
Для начала разработчики разбили босса горизонтально на три части, и каждая анимируется отдельно. Видно, что анимация причёски состоит из трёх картинок, каждая из которых немного отличается от остальных.
Если разбить картинки с причёской на отдельные квадратики, то мы заметим, что у них есть повторяющиеся части. Поэтому достаточно нарисовать одну деталь, а потом использовать её во всех трёх вариантах причёски.
Находим оставшиеся одинаковые части и тоже оставляем только одну из них.
А вот тут видно, что это один и тот же спрайт, только в зеркальном виде. Компьютеру несложно нарисовать его отражённым, поэтому тоже можно смело оставить только один из них. С последними треугольничками в каждой картинке — то же самое: это отзеркаленные первые спрайты.
В итоге вся верхняя часть босса вместе с анимацией поместилась в четырёх спрайтах. Это и есть оптимизация: было 16 спрайтов, стало 4.
То же самое делают для средней части. Сейчас она занимает 3 × 8 = 24 спрайта.
А сейчас — 7.
После полной оптимизации босс занимает всего 21 спрайт. Из этих кусочков собирается итоговый вид босса.
Сравните с первоначальным вариантом до оптимизации 🙂

Карта

Для карт у нас столько же памяти, сколько и на спрайты (то есть мало), поэтому разработчики будут действовать так же:

  • разбивать фон на отдельные ячейки;
  • смотреть, как можно оптимизировать эти ячейки для хранения в памяти;
  • смотреть, можно ли что-то использовать повторно, для экономии памяти.

Главная задача на этом этапе — максимальная экономия видеопамяти. Для этого каждый экран с уровнем игры разбивается не на метаплитки 2 × 2, как в примере выше, с персонажем, а на метаметаплитки или суперплитки — 4 × 4 ячейки. Вот для чего это нужно:

Если разбить просто на квадратики 8 × 8, как в памяти, то вся видимая на экране часть уровня займёт 960 байт. Это почти килобайт, и это очень много.
Разбивают уровень на метаплитки 16 × 16. Теперь на одну карту нужно 240 байт, чтобы пометить каждую такую метаплитку, но это всё равно много. Уменьшаем дальше.
Теперь уровень делится на супербольшие плитки по 16 ячеек в каждой. В итоге для того, чтобы пронумеровать каждую такую суперплитку, нужно всего 60 байт. Уже можно работать.
Вот так собираются метаплитки — из четырёх ячеек в памяти.
Теперь можно собирать такие метаплитки в виртуальные наборы и каждой присвоить какой-то код. Но и это ещё не всё.
Вот теперь получилась суперплитка. Это готовый блок для уровня, и чтобы собрать такое, нужно совсем немного памяти.
Коллекция виртуальных суперплиток. С ними можно сделать любые уровни и фоны.

Рисуем карты (и оптимизируем их)

Даже 60 байт на экран, которые у нас получились, — это всё равно очень много, ведь нужно сделать много разных карт, написать логику поведения персонажей и сделать меню, заставки и титры. Каждый байт на счету.

Первый вариант — уменьшить количество памяти для отрисовки карты: сделать их симметричными, что даст нам 30 байт вместо 60. Мы рисуем одну половинку карты, а потом просто отзеркаливаем её. Сравним с картой, которую мы бы хотели получить:

Вроде всё на месте, а выглядит плохо — сразу видна симметрия и доступ наверх закрыт блоками.

И вот тут разработчики делают очередной хитрый ход, который даст им немного дополнительной памяти для графики. Смотрите:

  1. Они дают для хранения одной суперплитки один байт.
  2. Считают по картинке, сколько получилось суперплиток в прошлом разделе — 96.
  3. Так как программисты начинают считать с нуля, то самое большое число, которое получится, — 95, а это 1011111 в двоичной системе счисления.
  4. В этом длинном числе всего 7 цифр, а в байте их 8, поэтому остаётся один лишний бит из каждого числа.
  5. 4 суперплитки дадут 4 бита.
  6. Эти 4 бита можно использовать, чтобы сдвинуть по кругу ряд с зеркальным отражением и получить как бы новый ряд, уже без видимой симметрии.

Если вы не знаете, что такое двоичная система счисления, — почитайте нашу статью об этом, а потом вернитесь сюда.

4 суперплитки дают 4 бита. Посмотрим, что можно с ними сделать.
Сначала делают симметричный уровень…
А затем сдвигают верхнюю полосу вправо по кругу. 1100 — это 12 в десятичной системе счисления, именно столько сдвигов вправо нужно сделать, чтобы получилось как на картинке.
То же самое делают с третьей строкой и получают уже приемлемое начало уровня.

Действуя таким образом, разработчики могут менять уровни до неузнаваемости, не затрачивая при этом вообще лишней памяти. Помним, что наш экран — это ещё не весь уровень, сверху нужно нарисовать ещё много раз по столько же.

Добавляем в игру сложный режим

Когда игрок прошёл все уровни, ему можно дать возможность поиграть на повышенной сложности: он уже знает всю игру и может пройти более сложные ловушки и боссов. Например, сложный режим может отличаться дизайном уровней и поведением противников.

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

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

В чем оптимизация, брат

  1. Уменьшили персонажей: маленькие спрайты — меньше памяти.
  2. Оптимизировали графику: вместо больших повторяющихся картинок — много маленьких повторяющихся картинок.
  3. Оптимизировали архитектуру уровней: сделали их симметричными, но сдвинули ряды по кругу влево-вправо, чтобы добавить разнообразия.
  4. Для дополнительного разнообразия ввели новые цветовые палитры.
  5. Более сложные уровни не хранили в памяти целиком. Для них хранились лишь дополнительные ловушки и враги. А на фоне лежали те же старые уровни.
  6. И всё это на чистом Ассемблере.

image

2. Фундаментальные понятия

Содержание:

  • Работа с данными
  • Регистры процессора
  • Память
  • Как задаются данные
  • Как сделать данные человекочитаемыми
  • Соединяем всё вместе

Что такое компьютер?

Вопрос кажется простым, но он затрагивает самую суть того, что делаем мы как программисты. Пока скажем, что «компьютер» — это нечто, исполняющее программу. «Программа» — это просто последовательность команд, а под исполнением программы подразумевается, что команды выполняются с начала и одна за другой. (Если вы читаете программу и сами исполняете команды, то поздравляю! Вы — компьютер!)

У каждого компьютера есть конкретный набор команд, которые он умеет исполнять. Мы называем его набором команд компьютера (да, очень оригинальное название). Набор команд можно описать множеством способов, но пока давайте будем считать, что команды в наборе команд обозначены числами. То есть программа — это просто перечень чисел, каждое из которых задаёт определённое выполняемое действие. Вот пример гипотетического набора команд:

  • 1: двигаться вперёд
  • 2: повернуть влево
  • 3: повернуть вправо

По сути, это набор команд Logo — языка программирования «черепашьей графики», позволяющего перемещать робота по листу бумаги при помощи ручки, чтобы он создавал интересные рисунки. Изображение: Valiant Technology Ltd., CC-BY-SA 3.0.

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

1 1 1 3 1 1 2 1 1 1 1

Работа с данными

Часто команды, которые должен исполнять компьютер, получают в каком-нибудь виде данные. Компьютеры часто складывают числа; гораздо проще иметь одну команду сложения, чем целое множество подобных команд:

  • 1: прибавить 1
  • 2: прибавить 2
  • 3: прибавить 3
  • 4: прибавить 4

Или же вместо одной команды «прибавить 1», вызываемой нужное количество раз, которую было бы столь же неудобно использовать. Программа, прибавляющая к числу 1000, занимала бы в 1000 раз больше места на накопителе (и выполнялась бы в 1000 раз дольше) по сравнению с программой, прибавляющей 1!

Сопровождающие команду данные должны находиться в какой-то части программы. В различных языках программирования эта задача решается по-разному. В некоторых языках «код» (команды) должен храниться совершенно отдельно от данных, в других они объединяются. Оба подхода имеют свои плюсы и минусы, но пока давайте рассмотрим объединённые команды и данные.

В нашем гипотетическом компьютере для «сложения каких-то чисел» набор команд мог бы выглядеть следующим образом:

  • 1: сохранить следующее число как «первое число»
  • 2: прибавить следующее число к первому числу, если оно сохранено

Программа, складывающая числа 2 и 7, выглядела бы так:

1 2 2 7

Пошагово двигаясь по программе по одному числу за раз, мы видим команду «1» («сохранить следующее число как первое число»). Следующее число — это «2», поэтому 2 сохраняется как первое число. Далее мы видим команду «2» («прибавить следующее число к первому числу»). Следующее число — это «7», поэтому наша программа прибавляет 7 к 2, получая результат 9. Здесь данные и команды перемешаны. Увидев «1 2 2 7», невозможно понять, какие из «2» — это команда «прибавить следующее число к первому числу», а какие — алгебраическое число «2», не посмотрев на начало и не пройдя пошагово всю программу.

Где находится результат (9)? Как дальше в программе нам сделать что-нибудь с этим результатом? И что же подразумевается под «сохранением» чего-либо?

Регистры процессора

Как мы только что увидели, программам часто требуется место для временного хранения данных. В большинстве компьютеров для этого используются регистры — небольшие участки внутри процессора, каждое из которых может хранить одно значение. [«Значения» — это просто числа; как мы уже делали с командами в наборе команд, можно взять значение любого типа и представить его в виде числа при условии, если у нас есть какое-то сопоставление между числами и обозначаемыми ими данными. Например, Unicode при помощи 32-битного числа описывает все возможные символы из каждой системы письма на Земле. (Подробнее о «битах» мы поговорим ниже.)]

Регистры могут быть обобщёнными или связанными с определёнными типами функциональности. Например, в процессоре NES есть регистр под названием накопитель (accumulator), часто сокращаемый до «A»; он занимается всеми математическими операциями. В наборе команд 6502 есть команды, работающие следующим образом:

  • сохранить следующее число в накопитель
  • прибавить следующее число к накопителю, результат записать в накопитель
  • поместить куда-нибудь число из накопителя

Это решает проблему сохранения чисел и доступа к ним. Но мы не ответили на ещё один вопрос: когда мы «помещаем куда-нибудь число из накопителя», где находится это «куда-нибудь»? Процессор 6502 консоли NES имеет всего три регистра, поэтому сложные программы не могут использовать для хранения результатов только регистры.

Память

Компьютеры предоставляют программам доступ к какому-то объёму (непостоянной) памяти для временного хранения, что позволяет компьютеру иметь небольшое количество (дорогих) регистров, в то же время обеспечивая возможность хранения за пределами самой программы приемлемого количества значений. Эта память выглядит как последовательность «ящиков» размером с регистр, каждый из которых содержит одно значение и ссылка к которому осуществляется по номеру. NES предоставляет разработчику программу с двумя килобайтами (2 КБ) пространства памяти, пронумерованную от нуля до 2047 — номер в пространстве памяти называется его адресом (как адрес дома). То есть рассмотренный нами ранее набор команд 6502 на самом деле ближе к такому:

  • сохранить следующее число в накопитель
  • прибавить следующее число к накопителю, результат записать в накопитель
  • поместить число из накопителя по адресу памяти следующего числа

Как задаются данные

И это приводит нас к последнему вопросу этой главы — как все эти числа представлены внутри компьютера?

Ранее мы использовали «стандартные» десятичные числа (по основанию 10). Это те числа, которые мы пользуемся повседневно, например, «2», или «7», или «2048». Однако компьютеры работают на электрических токах, которые могут быть или «включенными» или «выключенными», без каких-то промежуточных значений. Эти токи образуют основу всех данных внутри компьютера, поэтому компьютеры используют двоичные числа (по основанию 2).

Наименьшей единицей информации, которую может обработать компьютер, является «бит» (bit, сокращение от binary digit). Бит хранит в себе одно из двух значений — 0 или 1, «включено» или «выключено». Если мы объединим как одно число несколько битов, то сможем задавать больший диапазон значений. Например, двумя битами можно задать четыре разных значения:

00 01 10 11

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

000 001 010 011 100 101 110 111

Каждый добавленный нами бит позволяет задавать в два раза больше значений, аналогично тому, как каждый десятичный разряд, добавляемый к десятичному числу, позволяет задавать в десять раз больше значений (1 → 10 → 100 → 1000). Объединённые вместе восемь бит, задающих одно значение, используются настолько часто, что имеют собственное название: байт (byte). В байте может храниться одно из 256 значений. Так как четыре бита являются половиной байта, их иногда называют полубайтом (nybble). В полубайте может храниться одно из 16 значений.

Часто говорят, что компьютеры (в том числе и видеоигровые консоли), имеют определённую битность. Современные десктопные компьютеры/ноутбуки обычно являются 64-битными, старые версии Windows, например, Windows XP, называют 32-битными операционными системами, а NES — это 8-битная система. Все эти числа характеризуют размер регистров компьютера — количество битов, которые может одновременно хранить один регистр. [Несколько усложняет понимание то, что адресная шина NES имеет ширину 16 бит, то есть NES может обрабатывать 65536 различных адресов памяти, а не 256. Однако каждый адрес памяти всё равно хранит только один байт.] Так как NES — это «8-битный» компьютер, каждый его регистр хранит 8-битное значение (один байт). Кроме того, каждый адрес памяти может хранить один байт.

Как же работать с числами больше 255? Игроки в Super Mario Bros. часто зарабатывают десятки тысяч очков, и одного байта явно недостаточно для хранения таких чисел. Когда нам нужно задать значение намного больше, чем может храниться в одном байте, мы используем несколько байтов. В двух байтах (16 бит) можно хранить одно из 65536 значений, а при увеличении количества байтов возможности задания чисел резко возрастают. В трёх байтах можно хранить число до 16777215, а в четырёх — до 4294967295. Когда мы используем таким образом больше одного байта, мы всё равно ограничены размером регистра компьютера. Чтобы работать с 16-битным числом на 8-битной системе, нам нужно получать или сохранять число в двух частях — «младший» (low) байт справа, «старший» (high) байт слева. [Именно из-за необходимости работы с такими значениями из нескольких регистров у процессоров есть порядок следования байтов (endianness) — т. е., у них определено, какой байт идёт первым при работе с большими числами. В процессорах с прямым порядком байтов (он называется little-endian), например, в 6502, сначала идёт младший байт, а затем старший. В процессорах с обратным порядком байтов (big-endian), например, в Motorola 68000, ситуация противоположная — ожидается, что сначала идёт старший байт, а за ним следует младший. Большинство современных процессоров является little-endian из-за очень популярной архитектуры x86 компании Intel, тоже являющейся little-endian.]

Так как управляющий консолью NES процессор 6502 одновременно работает с восемью битами данных, для задания чисел меньшего размера всё равно используется восемь бит. Это может быть неэффективно, поэтому при необходимости в одном байте часто хранят несколько значений меньшего размера. Один байт может содержать два четырёхбитных числа, или четыре двухбитных числа, или даже восемь отдельных значений «включено»/«выключено» (мы называем их флагами).

Например, байт 10110100 может задавать:

  • Одно 8-битное значение: 180
  • Два 4-битных значения: 11 (1011) и 4 (0100)
  • Четыре 2-битных значения: 2 (10), 3 (11), 1 (01) и 0 (00)
  • Восемь значений «включено»/«выключено» (или «истина»/«ложь»): вкл., выкл., вкл., вкл., выкл., вкл., выкл., выкл.
  • Любое другое сочетание битовых длин, в сумме составляющих восемь

Для удобства обсуждения таких ситуаций, когда в одном байте хранится несколько значений, обычно каждому биту в байте присваивается номер, почти так же, как мы давали название «младшему» и «старшему» байтам в 16-битном значении. Самый правый бит называется «бит 0» и счёт идёт до самого левого «бита 7». Вот пример:

байт:   1 0 1 1 0 1 0 0
№ бита: 7 6 5 4 3 2 1 0

Как сделать данные человекочитаемыми

Как мы видели, байты — это очень гибкий способ задания различных типов данных в компьютерной системе. Однако недостаток использования байтов заключается в том, что их сложно читать. Приходится прикладывать усилия, чтобы мысленно преобразовать «10110100» в десятичное число «180». Когда вся программа представлена в виде последовательности байтов, проблема сильно усугубляется.

Для решения этой проблемы основная часть кода представлена в виде шестнадцатеричных чисел. «Шестнадцатеричное» означает «по основанию 16»; одно шестнадцатеричное (hexadecimal, «hex») число может содержать одно из шестнадцати значений. Вот числа от нуля до пятнадцати, представленные в шестнадцатеричном виде:

0 1 2 3 4 5 6 7 8 9 a b c d e f

Шестнадцатеричная запись полезна, потому что шестнадцатеричное число и полубайт хранят одинаковый диапазон значений. Это значит, что можно задать байт двумя шестнадцатеричными значениям, то есть в гораздо более компактном и удобном виде.

Работа с числами, которые могут быть десятичными, двоичными или шестнадцатеричными, может запутывать. Например, число «10» обозначает 10, если оно десятичное, 2, если оно двоичное или 16, если оно шестнадцатеричное. Чтобы понимать, какое значение мы имеем в виду, принято использовать префиксы. 10 — это десятичное число, %10 — двоичное, а $10 — шестнадцатеричное.

Соединяем всё вместе

Рассмотрев множество вопросов, связанных с работой компьютеров (и программ), давайте ещё раз взглянем на весь процесс, происходящий при выполнении программы.

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

С самого начала программы процессор многократно выполняет трёхэтапный процесс. Сначала процессор получает следующий байт из программы. В процессоре есть специальный регистр под названием счётчиком программ (program counter), он отслеживает, каким будет следующий номер байта программы. Счётчик программ (program counter, PC) работает совместно с регистром под названием адресная шина (address bus), отвечающим за получение и сохранение байтов из программы или из памяти, для получения байтов.

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

3. Приступаем к разработке

Содержание:

  • Настройка среды разработки
  • Текстовый редактор
  • Ассемблер и компоновщик
  • Эмулятор
  • Графические инструменты
  • Инструменты для создания музыки
  • Соединяем всё вместе
  • Дальнейшие шаги

Давайте начнём программировать для NES! В этой главе мы настроим среду разработки, установим все инструменты, которые необходимы для описанной в этой книге работы, а затем соберём и запустим самую простую игру, чтобы убедиться, что всё работает.

Настройка среды разработки

Здесь перечислены все инструменты, которые мы будем устанавливать. Некоторые из них мы будем использовать сразу (и постоянно), другие более специализированы и пригодятся позже. Для каждой категории я указал конкретное ПО, которое буду использовать в этой книге; однако есть множество других вариантов, так что освоившись с рекомендуемыми мной, вы сможете поэкспериментировать и с другими инструментами.

  • Текстовый редактор (на ваш выбор)
  • Ассемблер/компоновщик (ca65 и ld65)
  • Эмулятор (Nintaco)
  • Графический инструмент, способный считывать/сохранять изображения в формате NES (NES Lightbox)
  • Инструмент для создания музыки (FamiStudio)

Текстовый редактор

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

  • Sublime Text. Кроссплатформенная, популярная среди разработчиков, простая в освоении; когда вы освоитесь с основами, становится мощным инструментом.
  • Atom. По сути, ответ GitHub’а на Sublime Text. Кроссплатформенная, с гибкими настройками.
  • Visual Studio Code. Качественная платформа Microsoft для редактирования текста. Создавалась для веб-разработки, но имеет возможность расширения для любого вида программирования. Также кроссплатформенная, не только для Windows.
  • Vim, emacs, nano, и т. д. Текстовые редакторы с давней историей, работающие из командной строки. (Лично я пользуюсь Vim, но выбор за вами.)

Ассемблер и компоновщик

Ассемблер компилирует ассемблерный код (который мы будем писать в этой книге) в машинный код — сырой поток байтов, считываемый процессором. Компоновщик (linker) берёт набор файлов, который был пропущен через ассемблер, и превращает их в единый файл программы. Так как у каждого процессора свой машинный код, ассемблеры обычно предназначены только для одного типа процессора. Существует множество вариантов ассемблеров и компоновщиков для процессора 6502, но в этой книге мы будем использовать ca65 и ld65. Они имеют открытый исходный код и являются кроссплатформенными, а также обладают очень полезными функциями для разработки больших программ. ca65 и ld65 — это часть более масштабного комплекта программ «cc65», включающего в себя компилятор C и многое другое.

Mac

Для установки ca65 и ld65 на Mac нужно для начала установить менеджер пакетов Mac Homebrew. Скопируйте команду с главной страницы, вставьте её в терминал и нажмите Enter; выполните инструкции, после чего Homebrew будет готов к работе. Установив Homebrew, введите brew install cc65 и нажмите Enter.

Windows

В Windows необходимо скачать ca65 и ld65 в определённую папку на компьютере. Скачайте последний «Windows Snapshot» с страницы основного проекта cc65. Распакуйте содержимое в C:cc65. Также вам нужно будет дополнить системные пути, чтобы ca65 и ld65 были видны из любой папки. Этот процесс зависит от того, какой версией Windows вы пользуетесь. В самых новых версиях Windows можно нажать правой кнопкой мыши на «Мой компьютер», выбрать «Свойства», «Дополнительные параметры системы», а затем «Переменные среды». Вам нужно будет найти запись %PATH% и добавить в её конец C:cc65bin.

Linux

Вам нужно будет собрать cc65 из исходников. К счастью, это довольно простой процесс. Сначала убедитесь, что у вас есть git и базовая среда для сборки — например в Ubuntu для этого достаточно выполнить sudo apt-get install git build-essential. Затем перейдите в папку, куда вы хотите установить cc65, клонируйте репозиторий cc65 и соберите его:

git clone https://github.com/cc65/cc65.git
cd cc65
make

Затем сделайте программы cc65 доступными из любой папки, выполнив sudo make avail. Эта команда добавит символическую ссылку из вашей папки cc65 в /usr/local/bin.

Эмулятор

Эмулятор — это программа, запускающая программы, предназначенные для другой компьютерной системы. Мы будем использовать эмулятор NES, чтобы запускать создаваемые нами программы на том же компьютере, где их разрабатываем, вместо запуска на аппаратной NES. Существует множество эмуляторов NES (а когда вы наберётесь опыта в разработке для NES, будет интересно и написать собственный!), но для этой книги мы будем использовать Nintaco.

Nintaco.

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

Установка Nintaco на всех платформах происходит одинаково — достаточно скачать его с веб-сайта Nintaco и распаковать. Чтобы запустить Nintaco, нужно дважды нажать на Nintaco.jar. Для запуска Nintaco требуется Java; если на вашем компьютере не установлена Java, скачайте «Java Runtime Environment» с сайта java.com.

Графические инструменты

NES хранит графику в собственном уникальном формате, непохожем на традиционные типы изображений наподобие JPEG или PNG. Нам понадобится программа, способная работать с изображениями NES. Существуют плагины для больших графических пакетов типа Photoshop или GIMP, но мне нравится использовать для этого компактный специализированный инструмент. Для этой книги мы будем использовать NES Lightbox — кроссплатформенную производную от NES Screen Tool.

NES Lightbox.

Windows

Скачайте Windows-установщик (для 64-битных систем). Дважды нажмите на «NES Lightbox Setup 1.0.0.exe», чтобы установить программу.

Mac

Скачайте Mac DMG. Дважды нажмите на файл .dmg, чтобы открыть его, и перетащите приложение NES Lightbox в папку Applications. При первом запуске приложения нужно будет нажать на него правой клавишей мыши и выбрать «Open», потому что оно не «подтверждено» компанией Apple.

Linux

В системах с Ubuntu можно скачать файл Snap, который является автономным пакетом приложения. В случае других дистрибутивов Linux (или если вы предпочитаете AppImage) нужно скачать файл AppImage. Прежде чем запустить файл AppImage, его нужно пометить как исполняемый.

Инструменты для создания музыки

Как и в случае с графикой, звук на NES представлен в уникальном формате — это команды аудиопроцессора, а не что-то типа MP3. Самая популярная программа для создания звука для NES — FamiTracker, это мощный, но сложный инструмент, предназначенный только для Windows. Для этой книги мы будем использовать FamiStudio — кроссплатформенную программу с более дружественным интерфейсом, результаты работы в которой сохраняются в простой для интеграции формат.

FamiStudio.

Windows / Mac / Linux

Скачайте последнюю версию с веб-сайта FamiStudio.

Соединяем всё вместе

Установив все инструменты, нужно убедиться, что они работают. Мы создадим аналог «Hello World» для игр на NES: заполним весь экран одним цветом.

Откройте текстовый редактор и создайте новый файл helloworld.asm. Скопируйте и вставьте в файл следующий код:

Код в текстовом виде

.segment "HEADER"
.byte $4e, $45, $53, $1a, $02, $01, $00, $00

.segment "CODE"
.proc irq_handler
RTI
.endproc

.proc nmi_handler
RTI
.endproc

.proc reset_handler
SEI
CLD
LDX #$00
STX $2000
STX $2001
vblankwait:
BIT $2002
BPL vblankwait
JMP main
.endproc

.proc main
LDX $2002
LDX #$3f
STX $2006
LDX #$00
STX $2006
LDA #$29
STA $2007
LDA #%00011110
STA $2001
forever:
JMP forever
.endproc

.segment "VECTORS"
.addr nmi_handler, reset_handler, irq_handler

.segment "CHARS"
.res 8192
.segment "STARTUP"

Теперь нам нужно воспользоваться ассемблером. В папке, в которую вы сохранили helloworld.asm, выполните команду ca65 helloworld.asm. В результате появится новый файл helloworld.o. Это объектный файл — машинный код. Но он пока не находится в формате, готовом для запуска в эмуляторе. Чтобы преобразовать его, нужно запустить компоновщик. В той же папке выполните команду ld65 helloworld.o -t nes -o helloworld.nes. В результате этого должен появиться новый файл helloworld.nes — файл «ROM» для эмулятора.

Запустите Nintaco и выберите в меню «File» пункт «Open». Выберите только что созданный файл helloworld.nes и нажмите Open. В результате вы увидите зелёный экран.

[В оригинале главы зелёный экран — это настоящий работающий в браузере эмулятор NES! Я воспользовался потрясающим jsnes, созданным Беном Фиршманом. Каждый раз, когда мы будем компилировать файл .nes, я буду включать в текст подобное работающее демо. (Сейчас сложно это понять, но на самом деле эмулятор работает с частотой кадров 60fps.)]

Дальнейшие шаги

Если вы увидели в Nintaco зелёный экран, поздравляю! Ваша среда разработки готова к использованию. В следующей главе мы расскажем, что же делает скопированный нами код, и немного узнаем о том, как работает оборудование NES.

Создание игр для NES на ассемблере 6502: оборудование NES и знакомство с ассемблером

Время на прочтение
7 мин

Количество просмотров 7.1K

В этой части появляется первая играбельная демка в стиле Марио. Для этого надо разобраться с прокруткой и способами отладки.

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

image

Источник

Прокрутка

Регистр $2005 управляет прокруткой фона. Первая запись туда выставляет положение горизонтальной прокрутки, а вторая — вертикальной. Если неизвестно, какая прокрутка была выставлена, можно сбросить на горизонтальную чтением из регистра $2002.

99% игр используют только 2 таблицы имен — фактически, это фон экрана. Еще две доступных таблицы зеркалируют первые две. Эмулятор получает информацию о настройках таблиц из заголовка iNES-образа картриджа. Байты 6 и 7 описывают маппер — сопроцессор в картридже. Младший бит байта 6 описывает направление прокрутки. 0 — скролл по вертикали, таблиц имен зеркалируются по горизонтали. 1 — наоборот. В итоге мы получаем доступную для работы область 1х2 экрана (или 2х1, в зависимости от выбранной прокрутки) и скользящее по этой области окно, отрендеренное на телевизоре.

Игра Gauntlet использует четырехстороннюю прокрутку. Это требует 2k дополнительной RAM на картридже. Игры с маппером MMC3 могут переключать режимы прокрутки в середине игры. Но в большинстве случаев режим прокрутки единый для всей игры, и используется всего 2 таблицы имен.

В первом примере мы настроим горизонтальную прокрутку. Это выставляется в файле reset.s. Стрелки на джойстике будут двигать фон. Спрайтами реализован показометр положения фона: H для горизонтального сдвига и V для вертикального. Настоятельно рекомендую запустить этот образ в FCEUX и посмотреть дебаггером таблицы имен во время движения.

image

После пересечения $FF по горизонтали вызывается смена таблицы имен через обращение к регистру PPU_CTRL — он расположен по адресу $2000. Для пользователя это незаметно.

Для подготовки демки использовались такие инструменты: буквы рисовались в Фотошопе, потом индексировались в четырехцветное изображение и копипастились в YY-CHR. Затем их надо сохранить в chr-файл и открыть его в NES Screen Tool, скомпоновать фон, а потом экспортировать с RLE-сжатием как .h файл. Теперь его можно загрузить при запуске приставки. Движение персонажа реализовано через сдвиг фона, а позиция спрайта не меняется.

move_logic()

void move_logic(void) {
 if ((joypad1 & RIGHT) != 0){
   state = Going_Right;
   ++Horiz_scroll;
   if (Horiz_scroll == 0)
      ++Nametable;
 }
 if ((joypad1 & LEFT) != 0){
   state = Going_Left;
   --Horiz_scroll;
   if (Horiz_scroll == 0xff)
      ++Nametable;
 }
 Nametable = Nametable & 1; // меняет по кругу 0<->1
if ((joypad1 & DOWN) != 0){
   state = Going_Down;
   ++Vert_scroll;
   if (Vert_scroll == 0xf0)
      Vert_scroll = 0;
 }
 if ((joypad1 & UP) != 0){
   state = Going_Up;
   --Vert_scroll;
   if (Vert_scroll == 0xff)
      Vert_scroll = 0xef;
  }
 }

А при обновлении кадра происходит обновление спрайтов и выставление положения прокрутки:

every_frame()

void every_frame(void) {
OAM_ADDRESS = 0;
OAM_DMA = 2; // Спрайты пишутся в адреса $200-$2FF RAM
PPU_CTRL = (0x90 + Nametable); // экран и NMI включены
PPU_MASK = 0x1e;
SCROLL = Horiz_scroll;
SCROLL = Vert_scroll;  // выставляется положение прокрутки
Get_Input();
}

В втором примере прокрутка вертикальная, и таблица имен «закольцовывается» через левый и правый край экрана. Это выставлено все в том же reset.s. Для вертикальной прокрутки используются таблицы имен 0 и 2.

Максимальная позиция вертикальной прокрутки равна $EF, потому что экран высотой 240 пикселей. Это обрабатывается аналогично предыдущему примеру. Еще одно отличие — переключение таблиц имен из нулевой во вторую и обратно:

PPU_CTRL = (0x90 + (Nametable << 1));

Исходный код:
Дропбокс
Гитхаб

Простейший платформер

А сейчас будем делать демку с горизонтальной прокруткой и прыжками по платформам. Карты коллизий для 2 страниц фона будут храниться в памяти и займут там $200 байт.

Сначала сделаем гравитацию. Каждый кадр спрайты должны падать на (++Y), если они не стоят на платформе. Будем считать, что низ метаспрайта выравнен с фоном. Так что можно проверять, не провалились ли нижние углы метаспрайта в платформу:

Платформы и гравитация


// Сначала работаем с левым нижним углом метаспрайта
 // В какой мы таблице имен?
 NametableB = Nametable;
 Scroll_Adjusted_X = (X1 + Horiz_scroll + 3); // поправка на прозрачный левый край спрайта
 high_byte = Scroll_Adjusted_X >> 8;
 if (high_byte != 0){ // Если спрайт ушел дальше чем на 255 точек, то переходим на другую таблицу
  ++NametableB;   
  NametableB &= 1; // Она должна меняться 0<->1
 }
 // твердый ли метатайл по нашим координатам?
 collision_Index = (((char)Scroll_Adjusted_X>>4) + ((Y1+16) & 0xf0));
 collision = 0;
 Collision_Down(); // если это платформа, то делаем ++collision

// А теперь правый нижний угол
...точно так же, только (X1 + Horiz_scroll + 12); 

void Collision_Down(void){
 if (NametableB == 0){ // первая карта коллизий
  temp = C_MAP[collision_Index];
  collision += PLATFORM[temp];
 }
 else { // вторая карта коллизий
  temp = C_MAP2[collision_Index];
  collision += PLATFORM[temp];
 }
}
// Массив platform содержит нули и единицы
// и показывает, провалится ли спрайт сквозь нее

// гравитация
if(collision == 0){
   Y_speed += 2;
}
else {
   Y_speed = 0;
   Y1 &= 0xf0; // выровнять по границе метатайла
}

Дальше надо поработать над плавностью движений и прыжков. Понадобится много переменных для координат позиции спрайта и фона, скорости, ускорения и пару констант для максимально допустимых скоростей. Но я на это забил. В итоге скорость прокрутки хранится в старшем полубайте X_speed.
Horiz_scroll += (X_speed >> 4);
Обычно прокрутка фона начинается, когда персонаж приближается к краю экрана. А когда он в центральной части, то движется сам по себе со статичным фоном. Здесь такая техника не используется, опять же для упрощения. Возможно, когда-нибудь сделаю рефакторинг.

Исходный код:
Дропбокс
Гитхаб

Работа с нулевым спрайтом. Отладка

Sprite Zero Hit — это один из способов отследить событие в середине кадра, например изменение позиции горизонтальной прокрутки. Это позволит нам сделать статичный верх экрана, например счетчик очков, и прокрутку нижней части экрана.

Есть несколько способов реализации:

  1. Sprite Zero Hit
  2. Переполнение спрайтов (не надо так делать)
  3. Прерывание звукового процессора (и так тоже)
  4. Некоторые мапперы поддерживают счетчики строк (годится, если использовать MMC3)

Нам годится только первый способ — он самый простой и безглючный.

Нулевой спрайт хранится в OAM по адресам $0-$3. Если он содержит непрозрачный пиксель и этот пиксель отрисуется поверх непрозрачного пикселя фона, то в регистре $2002 выставится бит 0x40. Если же спрайт рисуется поверх прозрачного фона, то игра уходит в бесконечный цикл. Мы можем воспользоваться этим для настройки прокрутки. Процедура написана на Ассемблере.

Сначала сделаем все что надо в V-blank. Потом выставим в ноль горизонтальную прокрутку и включим нужную таблицу имен. Затем вызовем SpriteZero(), и она уйдет в ожидание события — отрисовки строки, где наложатся нужные пиксели. Потом мы можем переключить прокрутки и таблицу имен — это произойдет посреди отрисовки экрана.

// В обработчике NMI прокрутка и таблица имен обнулены - для верхней части экрана
Sprite_Zero(); // ждем события
SCROLL = Horiz_scroll;
SCROLL = 0;  // включаем прокрутку
PPU_CTRL = (0x94 + Nametable);

В нашем примере нулевой спрайт содержит символ нуля, просто для наглядности. И еще сделал, чтобы он исчезал при нажатии Start.

if ((joypad1 & START) > 0){
SPRITE_ZERO[1] = 0xff; // Подменяем спрайт на содержащий одну точку
SPRITE_ZERO[2] = 0x20; // Прячем его за фоном
}     

В первой версии туториала урок здесь и заканчивался, но потом решил расширить тему. Так что сейчас запилим демку с фоном шириной 4 экрана и динамической генерацией фона на метатайлах, без RLE-сжатия.

При перемещении персонажа на 16 пикселей демка будет дорисовывать 2 столбца тайлов за границей экрана, в нужную таблицу имен. Это можно уместить в V-blank. Для ускорения процедуру записи PPUupdate пришлось написать на Ассемблере и развернуть циклы. Таблица атрибутов фона тоже изменяется в ходе работы.

Получилось сложно и громоздко, и на отладку ушло много времени. Так что будет хороший пример, чтобы показать техники отладки.

Во-первых, реализация прокрутки медленная и не вкладывается во время. Чтобы понять это, пришлось вставить команду

PPU_MASK = 0x1F;

в main() перед ожиданием V-blank. Начиная с этого момента, экранные строки будут рендериться в черно-белом цвете. Этот хак совместим не со всеми эмуляторами, например в FCEUX надо включить опцию ‘old PPU’. Получилось вот так:

image

Половина доступного процессорного времени уже потрачена, и это без музыки и противников. Для профилирования функций сделал запись в переменную до и после выполнения функции, и включил в дебаггере отслеживание записи по адресу этой переменной. А FCEUX умеет считать такты процессора между остановами. Получилось как-то так:


TEST = *((unsigned char*)0xFF) // этот адрес почти никогда не занят
++TEST;
Should_We_Buffer(); // 4422 такта
++TEST; 

Оказалось, что тормозит работа с буфером. Ее можно разбить на две функции покороче, и выполнять их через кадр. Теперь загрузка процессора выглядит получше:

image

Дальше убираю прокрутку влево. Теперь хорошо бы реализовать, чтобы можно было побежать налево и упереться в край экрана. Сразу это не получилось, и отладка методом аналитического тупления в код ((с) DIHALT ) не помогла. Пришлось генерировать карту адресов. Для этого надо вызывать линковщик с опцией:
ld65 ... -Ln “labels.txt”
И компилятор с транслятором с опцией -g.

По этим файлам видно, что подозрительная функция move_logic() находится по адресу $C5B2, так что ставлю туда брейкпоинт. В принципе, можно расставить метки прямо в сишном коде и отключить оптимизацию, но я делал вызов пустой функции в нужном месте (движение персонажа влево) и отслеживал ее точное расположение по карте меток. Но перехват записи переменной компактней и удобней.

Отладку все равно пришлось делать по ассемблерному листингу, но неправильное сравнение ‘if (X_speed < 0)’ нашлось довольно быстро. В этом месте X_speed обнулялась даже если нажать Влево. Изменил сравнение на <=, и все стало хорошо.

В FCEUX для обработки джойстика с включенным отладчиком надо замапить опцию ‘auto-hold’ на кнопку клавиатуры и сначала включить холд, нажать Влево, и потом уже ставить брейкпоинт в отладчике.

Юзер Rainwarrior из Nesdev сделал, а я слегка подправил скрипт на Python, который конвертирует метки ca65 в файл для отладчика FCEUX. На вход он берет label.txt. Пример использования есть в мейкфайле и бат-файле в исходнике к уроку.

image

Несколько версий скрипта

Ссылка на скрипт(rainwarior разрешил использовать и распространять):
Дропбокс

Оригинальная версия, без моих правок:
Форум

Еще одна версия, пригодная для сборки бат-файлом:
Дропбокс

Теперь прокрутка на 4 экрана работает, но сложнее, чем я себе это представлял. Вариант В аналогичен, но в нем вырезана вся отладка. Рекомендую посмотреть таблицы имен отладчиком и разобраться, как работает прокрутка.
Дропбокс
Гитхаб

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

Иначе придется добавлять -g при каждом вызове cc65 и ca65.

Introduction: Make a Custom NES Game.

Here is how to make a NES mod. This can take only 30 seconds if you know what your doing. If you don’t it may take a few minutes, but none the less, it is easy, fun, and free. All software can be downloaded in one convenient pack from: http://nesmods.moonfruit.com/

Be the First to Share

Recommendations

9 Comments

What’s the programing code?

Can you mod N64 ROMS?
I would like to modify GOLDENEYE to use K’nex guns…

That would be epic!!!

Ok,um…what?

The link is dead, Jim.

This video is a bit short and really doesn’t help someone get started modding the game. Giving a better/more in-depth example and step-by-step written instructions might also help prevent people from thinking this is just a spam posting for your site.

The video was originally for the Forbes video contest, and it can’t be more than 30 seconds, so it was the best I could do. I don’t even have advertisments on my site so its not really like I have anything to gain. If someone were to want a more in depth look into the modding, they can contact me or look at my site. I created the site just to share my love of modding NES stuff, ok. So next time you want to bash someones video that they worked hard on, maybe do a little research about what your attacking.

I did do some research before posting my advice recommending that you add a bit more content. I was only trying to be helpful, and wasn’t attacking at all. The fact that you signed up the day you posted a prominent link to your site in what some would consider to be a drive-by instructable warrants a little constructive criticism.

As I said, adding some more content, even a step-by-step in writing in a little more detail than your video will go a long way toward improving your post. Where do I get the editing tools? Why do I want to go to 00005E0? What other areas give interesting results?

As I said, the video was made for the Forbes video contest, so I could only have a 30-second video. Sorry I wasn’t descriptive enough for you. I tried to be as thorough as possible, but there is a limit, and I thought the video was enough for most people, after all, I assumed most people on this website would be able to figure it out. And it is, technically promoting my site, but at the same time, the only reason I put a link to it was because I put the software you would need there in one convenient .zip file, and it is very prominent on my homepage, so you don’t actually have to look around unless you want to. This was my first video I have made, so sorry if it wasn’t to your standards, but I am now working on a series of full length instructional videos that go more in depth on modding, so once those are out, I will publish them here. As for calling it a drive-by Instructable, well remember its original purpose: the contest.

Introduction: Make a Custom NES Game.

Here is how to make a NES mod. This can take only 30 seconds if you know what your doing. If you don’t it may take a few minutes, but none the less, it is easy, fun, and free. All software can be downloaded in one convenient pack from: http://nesmods.moonfruit.com/

Be the First to Share

Recommendations

9 Comments

What’s the programing code?

Can you mod N64 ROMS?
I would like to modify GOLDENEYE to use K’nex guns…

That would be epic!!!

Ok,um…what?

The link is dead, Jim.

This video is a bit short and really doesn’t help someone get started modding the game. Giving a better/more in-depth example and step-by-step written instructions might also help prevent people from thinking this is just a spam posting for your site.

The video was originally for the Forbes video contest, and it can’t be more than 30 seconds, so it was the best I could do. I don’t even have advertisments on my site so its not really like I have anything to gain. If someone were to want a more in depth look into the modding, they can contact me or look at my site. I created the site just to share my love of modding NES stuff, ok. So next time you want to bash someones video that they worked hard on, maybe do a little research about what your attacking.

I did do some research before posting my advice recommending that you add a bit more content. I was only trying to be helpful, and wasn’t attacking at all. The fact that you signed up the day you posted a prominent link to your site in what some would consider to be a drive-by instructable warrants a little constructive criticism.

As I said, adding some more content, even a step-by-step in writing in a little more detail than your video will go a long way toward improving your post. Where do I get the editing tools? Why do I want to go to 00005E0? What other areas give interesting results?

As I said, the video was made for the Forbes video contest, so I could only have a 30-second video. Sorry I wasn’t descriptive enough for you. I tried to be as thorough as possible, but there is a limit, and I thought the video was enough for most people, after all, I assumed most people on this website would be able to figure it out. And it is, technically promoting my site, but at the same time, the only reason I put a link to it was because I put the software you would need there in one convenient .zip file, and it is very prominent on my homepage, so you don’t actually have to look around unless you want to. This was my first video I have made, so sorry if it wasn’t to your standards, but I am now working on a series of full length instructional videos that go more in depth on modding, so once those are out, I will publish them here. As for calling it a drive-by Instructable, well remember its original purpose: the contest.

Понравилась статья? Поделить с друзьями:
  • Как написать игровой сценарий
  • Как написать игровой искусственный интеллект unity 2d
  • Как написать игрового бота на python для web
  • Как написать игривое сообщение мужчине
  • Как написать игрек на клавиатуре