Как я писал свою первую игру для Dendy

в 17:34, , рубрики: dendy, Famicom, Nes, ассемблер, разработка игр, Си
Как я писал свою первую игру для Dendy - 1

Что такое Dendy? Что так любит детвора? Это электронная игра! Ооо, дендиии...

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

Фото взял из обзора моей старой денди (она в хорошем состоянии и даже работает)
Фото взял из обзора моей старой денди (она в хорошем состоянии и даже работает)

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

Краткая история создания игры

Идея создать свою собственную игру для денди появлялась у меня довольно давно, но плотно занялся я этим вопросом только в конце 2022го года.

Основным стимулом для разработки игры послужило мое желание создавать свои модули расширения для консоли, так как для денди их практически не выпускалось, а у меня по этому поводу есть много идей и планов (например, геймпад с бОльшим количеством клавиш, модуль подключения обычной клавиатуры, модуль выхода в интернет (sic!) и т.д.).

Более-менее освоив курс статей за 2-3 недели, в конце января 2023го было принято решение разработать пошаговую стратегию про стимпанковых мехов для закрепления навыков. Думал, что ограничусь созданием минимального геймплея, но разработка зашла немного дальше.

В итоге с начала февраля началась активная разработка игры и продолжалась она до середины апреля.

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

Актуальное состояние геймплея боевого режима

Актуальное состояние геймплея боевого режима

Особенности разработки игры для Денди

Перед началом разработки всегда встает вопрос о выборе инструментов. Я остановился на следующем наборе рабочих программ:

  • СС65 - компилятор языка си для процессоров MOS 6502 (поддерживается до сих пор)

  • Visual Studio Code - использовал как основной редактор в комбинации с СС65 и батником (код батника приведу ниже) для сборки проекта

  • YY-CHR - отличный редактор для создания пиксельартов в формате старых консолей (можно открыть ROM-файл игры и посмотреть на спрайты, которые там используются)

  • NEXXT v0.20.0 - программа для набора фонов из готового тайлсета и создания метаспрайтов (большой спрайт, состоящий из нескольких базовых спрайтов 8х8 пикселей)

  • GIMP - использую для подготовки изображений и конвертации их в .BMP в съедобном для YY-CHR виде

Выбор компилятора СС65 немного спорный (у него слабый оптимизатор кода и иногда он может выдавать неочевидный ассемблерный код), но он фигурировал в цикле статей по которому я учился, поэтому на нем и остановился. Более перспективным является использование LLVM-MOS SDK, которое позволяет разрабатывать игры для денди на современных языках с применением абстракций (это реализуется за счет применения очень мощного оптимизатора) и, по заверениям разработчиков, позволяет полностью избавиться от ассемблерных вставок (даже для таких узких моментов, как обработка нулевого спрайта (Sprite Zero Hit)).

Кроме это СС65 требует довольной сложной настройки конфигурации рома и распределения памяти между сегментами, но все это +- неплохо документировано. Вот пример конфигурации моего проекта (nes.cfg):

Hidden text
MEMORY {
#RAM Addresses:
    # Zero page
    ZP: start = $00, size = $100, type = rw, define = yes;
	#note, the c compiler uses about 10-20 zp addresses, and it puts them after yours.
	
	OAM1: start = $0200, size = $0100, define = yes;
	#note, sprites stored here in the RAM
	
	RAM: start = $0300, size = $0400, define = yes;
	#note, I located the c stack at 700-7ff, see below

#INES Header:
    HEADER: start = $0, size = $10, file = %O ,fill = yes;

#ROM Addresses:
    # Используется половина PRG ROM
    #PRG: start = $c000, size = $3ffa, file = %O ,fill = yes, define = yes;
    # Используется вся PRG ROM
    PRG: start = $8000, size = $7ffa, file = %O ,fill = yes, define = yes;

    # Hardware Vectors at end of the ROM (тут хранятся адреса обработчиков прерываний)
    VECTORS: start = $fffa, size = $6, file = %O, fill = yes;

#1 Bank of 8K CHR ROM (Тут хранятся спрайты)
    CHR: start = $0000, size = $2000, file = %O, fill = yes;
}

SEGMENTS {
    HEADER:   load = HEADER,         type = ro;
    STARTUP:  load = PRG,            type = ro,  define = yes;
    LOWCODE:  load = PRG,            type = ro,                optional = yes;
    INIT:     load = PRG,            type = ro,  define = yes, optional = yes;
    CODE:     load = PRG,            type = ro,  define = yes;
    RODATA:   load = PRG,            type = ro,  define = yes;
    DATA:     load = PRG, run = RAM, type = rw,  define = yes;
    VECTORS:  load = VECTORS,        type = rw;
    CHARS:    load = CHR,            type = rw;
    BSS:      load = RAM,            type = bss, define = yes;
    HEAP:     load = RAM,            type = bss, optional = yes;
    ZEROPAGE: load = ZP,             type = zp;
	OAM:	  load = OAM1,			 type = bss, define = yes;
	ONCE:     load = PRG,            type = ro,  define = yes;
}

FEATURES {
    CONDES: segment = INIT,
        type = constructor,
        label = __CONSTRUCTOR_TABLE__,
        count = __CONSTRUCTOR_COUNT__;
    CONDES: segment = RODATA,
        type = destructor,
        label = __DESTRUCTOR_TABLE__,
        count = __DESTRUCTOR_COUNT__;
    CONDES: type = interruptor,
        segment = RODATA,
        label = __INTERRUPTOR_TABLE__,
        count = __INTERRUPTOR_COUNT__;
}

SYMBOLS {
    __STACK_SIZE__: type = weak, value = $0100;      # 1 page stack
	__STACK_START__: type = weak, value = $700;
}

А вот текст батника:

Hidden text
:: Запускать компиляцию из директории проекта такой командо:
:: start /b  %cc65bat% <имя_файла_без_разширения>
:: %cc65bat% - имя системной переменной, котороая хранить путь к этому батнику (можно назвать как удобно)
@echo off

set name=%~1

:: Переводит в .asm
:: -Oi - оптимизатор
cc65 -Oi %name%.c --add-source
:: Из .asm компилирует объектный файл
ca65 reset.s
ca65 %name%.s
ca65 asm4c.s
::  Линкует файлы
 ld65 -C nes.cfg -o %name%.nes reset.o %name%.o asm4c.o nes.lib
:: -C указывает использовать конфиг файл
:: ld65 -C nes.cfg -o %name%.nes reset.o %name%.o nes.lib

del *.o

start D:Programsemulatorsfceux-2.6.4fceux.exe %name%.nes

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

После компиляции пустого проекта и освоения основных возможностей консолей нужно было продумать архитектуру проекта. Я больших проектов на голом Си раньше не писал, поэтому как мог пытался сделать проект модульным. Поэтому остановился на системе Режимов игры (подскажите правильный термин в комментариях), т.е. в один момент времени игра всегда находится в одном из режимов (режим стартового экрана, режим открытого мира, диалоговой режим, режим битвы и т.д.). Вот пример боевого режима (реализует бесконечный цикл, итерация которого реализует один ход игрока или ИИ):

Hidden text
void start_battle_mode (void) {
    
    All_Off ();
    change_background_palette ();
    PPU_ADDRESS = 0x20;
	PPU_ADDRESS = 0x00;
    // Выводит на экран поля необходимые для боя
    UnRLE(BATTLE_BG);

    draw_battle_map ();

    PPU_ADDRESS = 0x00;
    PPU_ADDRESS = 0x00;
    All_On ();

    // Инициализируем мехов для текущего уровня
    initialization_of_mechs ();
    // Инициализируем прочность деталей мехов перед боем максимальным значением
    set_all_parts_to_maximum_value ();
    // Подсчитываем сколько мехов участвует в текущей битве
    number_of_mechs_in_battle = levels_info [current_location][0];
    // Выводим на поле всех мехов на стартовые позиции
    draw_all_mechs ();
   
    // Начинаем бой с хода меха игрока
    index_selected_mech = 0;
    p_selected_mech = &mechs[0];
    
    // Начисляем игроку очки действия
    p_selected_mech->action_points_ = 
    pilots [p_selected_mech->pilot_index_].action_points_;

    // Начинаем бой с режима меню
    game_mode = MENU_MODE;
    parameters_shown = false;
    submenu_shown = false;
    battle_menu_shown = false;
    
    // Внутри цикла нельзя менять p_selected_mech и index_selected_mech
    while (1) {
        Wait_Vblank ();
         
        
        if (parameters_shown == false) {
            All_Off ();
            p_mech = p_selected_mech;
            draw_all_parameters ();
            clear_status ();
            hide_weapon_submenu ();
            PPU_ADDRESS = 0x00;
            PPU_ADDRESS = 0x00;
            All_On ();
            parameters_shown = true;
        }

        switch (game_mode) {
            case MENU_MODE:
                Wait_Vblank ();
                 
                //  Выводим количество очков действия текущего меха
                value = p_selected_mech->action_points_;
                draw_action_points ();
                PPU_ADDRESS = 0x00;
                PPU_ADDRESS = 0x00;
              
                start_menu_mode ();
                game_mode = pointer_position + ATTACK_MODE;
                
                break;
            case ENEMY_MOVE_MODE: // Тут выполняется ИИ врага
                start_enemy_move_mode ();
                game_mode = END_OF_TURN_MODE;
                victory_conditions_check ();
                break;

            case ATTACK_MODE:
                start_attack_mode ();
                // Возвращаемсяв меню выбора действий
                game_mode = MENU_MODE;
                // Проверяем условия победы
                // В случае победы или поражения меняет game_mode
                victory_conditions_check ();
                break;

            case MOVE_MODE:
                move_mech ();
                game_mode = MENU_MODE; 
                break;
            
            case EQUIP_MODE:
                // Пока режима использования предметов нет, возврат в меню
                game_mode = MENU_MODE; 
                break;

            case VIEW_MAP_MODE: // Режим осмотра карты
                start_view_map_mode ();
                parameters_shown = false;
                submenu_shown = false;
                game_mode = MENU_MODE;
                break;
            
            case END_OF_TURN_MODE:
                // Записываем случайных номер кадра в качестве случайного числа
                // Это нужно, так ход врага происходит за случайно время
                // random_number = Frame_Count;
                // Инициализируем заново генератор случайных чисел
                srand (Frame_Count);
                // Заканчиваем ход текущего меха
                start_end_of_turn_mode ();
                // Запускает меню уже для следующего меха
                if (index_selected_mech == 0)
                    game_mode = MENU_MODE;
                else
                    game_mode = ENEMY_MOVE_MODE;
                break;

            case THE_BATTLE_CONTINUES_MODE:
                game_mode = MENU_MODE;
                break;

            case THE_BATTLE_IS_WON_MODE:
                hide_mechs ();
                // Тут выполняем сценарий победы
                p_text      = LOCATION_VICTORY_END_TEXT [current_location]; 
                number_of_lines = 1;
                start_dialog_screen_mode_mode ();

                game_mode = END_OF_BATTLE;
                break;

            case THE_BATTLE_IS_LOST_MODE:
                hide_mechs ();
                // Сценарий проигрыша
                p_text      = LOCATION_LOSING_END_TEXT [current_location]; 
                number_of_lines = 1;
                start_dialog_screen_mode_mode ();

                game_mode = END_OF_BATTLE;
                break;

            case END_OF_BATTLE:
                // Запускаек режим оценки результатов битвы
                // Оценка результатов и возврат на карту мира или в диалоговое окно
                start_end_of_battle_mode ();
                return;
                break;
        }  
    }
}

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

Прерывание конца кадра (NMI)

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

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

Hidden text
_Wait_Vblank:
	LDA _Frame_Count
	@loop:
		CMP _Frame_Count
		BEQ @loop
	RTS

В этой функции происходит ожидание изменение значения счетчика кадров, так как счетчик кадров инкрементируется при каждом срабатывании прерывания конца кадра. Вот функция обработки прерывания конца кадра (NMI):

Hidden text
void NMI (void) {
    ++Frame_Count;
    OAM_ADDRESS = 0;
    OAM_ADDRESS = 0;
    OAM_DMA     = 0x02; // push sprite data to OAM from $200-2ff

	// Сброс скрола
    SCROLL = 0x00;
    SCROLL = 0x00;
    // Выход из прерывания
    // Без него не получится реализовать корректный выход из прерывания
    asm ("rti");
}   

Обработчик прерывания реализует сброс управляющих регистров и загрузку буфера с информацией о спрайтах в видеопамять (OAM_DMA = 0x02;).

Здесь 0x02 указывает на адрес ОЗУ, с которого начинается блок памяти в 256 байт. В нем хранится информация о 64х спрайтах. По 4 байта на спрайт: координаты, номер тайла и атрибуты.

Уточнение. Подробную структуру памяти NES можно найти в куче статей на хабре (в конце я приведу список полезных ссылок), а лучше на nesdev.org (самый лучший ресурс по разработке для NES). Свою статью я не хочу перегружать общедоступной информацией, а постараюсь осветить несколько интересных моментов, которые могут быть не очевидны при разработке.

Изменение фона без остановки вывода графики

При создании игры с интерактивным фоном (мерцание огня, вывод текста, чисел и т.д.) постоянно приходится редактировать тайлы фона (менять одни тайлы на другие).

Самый очевидный и простой способ редактирования фона является временное отключение вывода графики через управляющие регистры. Так выглядят функции выключения и включения вывода графики в моем случае:

Hidden text
void All_Off (void) {
    Wait_Vblank (); // wait till NMI
	PPU_MASK = 0x00;
}
void All_On (void) {
    Wait_Vblank (); // wait till NMI
	PPU_MASK = b0001_1110;
}

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

PPU_CTRL = b1001_0000; /*
            |||| ||||
            |||| ||++- Выбор базовой таблицы имен
            |||| ||    (0 = $2000; 1 = $2400; 2 = $2800; 3 = $2C00)
            |||| |+--- VRAM address increment per CPU read/write of PPUDATA
            |||| |     (0: add 1, going across; 1: add 32, going down)
            |||| +---- Sprite pattern table address for 8x8 sprites
            ||||       (0: $0000; 1: $1000; ignored in 8x16 mode)
            |||+------ Background pattern table address (0: $0000; 1: $1000)
            ||+------- Sprite size (0: 8x8 pixels; 1: 8x16 pixels – see PPU OAM#Byte 1)
            |+-------- PPU master/slave select
            |          (0: read backdrop from EXT pins; 1: output color on EXT pins)
            +--------- Generate an NMI at the start of the
                        vertical blanking interval (0: off; 1: on)
*/

	PPU_MASK = b0001_1110; /*
                |||| ||||
                |||| |||+ - включает режим в оттенках серого
                |||| ||+ - включает показ фона в крайнем левом столбце
                |||| |+ - включает показ спрайтов в крайнем левом столбце
                |||| + - включает показ фона
                |||+ - включает показ спрайтов
                ||+ - Emphasize red (green on PAL/Dendy) (0: off; 1: on)
                |+ - Emphasize green (red on PAL/Dendy) (0: off; 1: on)
                + - Emphasize blue (0: off; 1: on)
                */

Из кода выше очевидно, что, редактируя регистр PPU_MASK можно управлять отображением спрайтов и тайлов фона. Примерно таким же образом управляется и все остальное. Здесь намного меньше управляющих регистров, чем в том же STM32.

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

Но отключения вывода графики можно избежать, если успеть заменить нужные тайлы фона за время возврата луча. Это работает, если вам нужно заменить до 16 тайлов, а если больше, то корректно они не отредактируются (на экране появятся случайные тайлы из-за нехватки времени возврата луча).

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

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

Вывод заранее подготовленных фонов в программе

Вот так выглядит интерфейс программы редактирования фонов:

Как я писал свою первую игру для Dendy - 4

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

Вывод нарисованного фона уровня в виде Си-кода

Вывод нарисованного фона уровня в виде Си-кода

Если вы выбираете пункт "C code" то получаете массив представленный в синтаксисе языка Си (каждый элемент соответствует номеру тайла из тайлсета на его позиции; на скрине ниже символ "#" имеет номер 0x23, т.е. если фон будет заполнен "решетками", то массив будет состоять из чисел 0х23). Но такое представление фона займет очень много места (1024 байта на один экран, а у нас всего 32 байта). Поэтому есть пункт "С code with RLE" это вывод массива, описывающего фон, в сжатом виде с помощью алгоритма RLE.

Пример тайлсета из моей игры

Пример тайлсета из моей игры
// Пример сжатого фона, представленного в виде массива
const unsigned char START_SCREEN[223]={
0x05,0x00,0x05,0x1f,0x01,0x03,0x05,0x1d,0x02,0x04,0x00,0x05,0x1d,0x14,0x04,0x00,
0x05,0x1d,0x14,0x04,0x00,0x05,0x1d,0x14,0x04,0x00,0x05,0x1d,0x14,0x04,0x00,0x05,
0x1d,0x14,0x04,0x00,0x05,0x1d,0x14,0x04,0x00,0x05,0x09,0x49,0x72,0x6f,0x6e,0x00,
0x53,0x74,0x65,0x61,0x6d,0x00,0x05,0x09,0x14,0x04,0x00,0x05,0x1d,0x14,0x04,0x00,
0x05,0x1d,0x14,0x04,0x00,0x05,0x1d,0x14,0x04,0x00,0x05,0x0a,0x43,0x6f,0x6e,0x74,
0x69,0x6e,0x75,0x65,0x00,0x67,0x61,0x6d,0x65,0x00,0x05,0x05,0x14,0x04,0x00,0x05,
0x1d,0x14,0x04,0x00,0x05,0x0a,0x4e,0x65,0x77,0x00,0x67,0x61,0x6d,0x65,0x00,0x05,
0x0a,0x14,0x04,0x00,0x05,0x1d,0x14,0x04,0x00,0x05,0x0a,0x54,0x77,0x6f,0x00,0x70,
0x6c,0x61,0x79,0x65,0x72,0x73,0x00,0x05,0x07,0x14,0x04,0x00,0x05,0x1d,0x14,0x04,
0x00,0x05,0x1d,0x14,0x04,0x00,0x05,0x1d,0x14,0x04,0x00,0x05,0x1d,0x14,0x04,0x00,
0x05,0x1d,0x14,0x04,0x00,0x05,0x1d,0x14,0x04,0x00,0x05,0x0a,0x28,0x43,0x29,0x53,
0x77,0x61,0x6d,0x70,0x54,0x65,0x63,0x68,0x00,0x32,0x30,0x32,0x33,0x00,0x00,0x14,
0x04,0x00,0x05,0x1d,0x14,0x04,0x00,0x05,0x1d,0x14,0x04,0x00,0x05,0x1d,0x14,0x04,
0x00,0x05,0x1d,0x14,0x11,0x13,0x05,0x1d,0x12,0x00,0x05,0x1e,0x00,0x05,0x00
};

Использование RLE требует использования функции распаковки. Я не стал изобретать велосипед и взял готовую функцию для распаковки таких массивов:

.importzp _joypad1, _joypad1old, _joypad2, _joypad2old,  _Frame_Count
.export _Get_Input, _Wait_Vblank, _UnRLE

.segment "ZEROPAGE"
RLE_LOW:	.res 1
RLE_HIGH:	.res 1
RLE_TAG:	.res 1
RLE_BYTE:	.res 1
.segment "CODE"
_UnRLE:
	tay
	stx <RLE_HIGH
	lda #0
	sta <RLE_LOW

	lda (RLE_LOW),y
	sta <RLE_TAG
	iny
	bne @1
	inc <RLE_HIGH
@1:
	lda (RLE_LOW),y
	iny
	bne @11
	inc <RLE_HIGH
@11:
	cmp <RLE_TAG
	beq @2
	sta $2007
	sta <RLE_BYTE
	bne @1
@2:
	lda (RLE_LOW),y
	beq @4
	iny
	bne @21
	inc <RLE_HIGH
@21:
	tax
	lda <RLE_BYTE
@3:
	sta $2007
	dex
	bne @3
	beq @1
@4:
	rts

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

// Указываем начало таблицы имен, которую мы используем через регистры PPU
PPU_ADDRESS = 0x20;
PPU_ADDRESS = 0x00;
// В функцию распаковки передаем адрес начала массива с опсианием фона
UnRLE(START_SCREEN);

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

Вызов ассемблерных функций из Си-кода

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

_Get_Input:
; At the same time that we strobe bit 0, we initialize the ring counter
; so we're hitting two birds with one stone here
	lda _joypad1
	sta _joypad1old
    lda #$01
    ; While the strobe bit is set, buttons will be continuously reloaded.
    ; This means that reading from JOYPAD1 will only return the state of the
    ; first button: button A.
    sta $4016
    sta _joypad1
    lsr a        ; now A is 0
    ; By storing 0 into JOYPAD1, the strobe bit is cleared and the reloading stops.
    ; This allows all 8 buttons (newly reloaded) to be read from JOYPAD1.
    sta $4016
@loop:
    lda $4016
    lsr a	       ; bit 0 -> Carry
    rol _joypad1  ; Carry -> bit 0; bit 7 -> Carry
    bcc @loop
    rts

Но, так как основную часть кода я пишу на Си, мне постоянно приходится вызывать _Get_Input (). Связывание Си- и асм-кода делается очень просто (это актуально только для сс65 компилятора). В начале асм-файла прописываются импортируемые и экспортируемые имена:

.importzp _joypad1, _joypad1old, _joypad2, _joypad2old,  _Frame_Count
.export _Get_Input, _Wait_Vblank, _UnRLE

.importzp показывает сборщику, что мы обращаемся к си-переменным, расположенным в нулевой странице памяти (zero page). Вот так выглядит объявление таких глобальных переменных (локальные переменные нам недоступны, это вызывает много неприятных моментов, но это отдельный разговор):

#pragma bss-name(push, "ZEROPAGE")
volatile unsigned char joypad1;
volatile unsigned char joypad1old;
#pragma bss-name(pop) // End ZEROPAGE

Директивы препроцессора #pragma позволяют нам разграничить нам участки памяти картриджа, но как всегда - это большой отдельный разговор. Т.е. для передачи си-переменной в асм-код мы вписываем ее после .importzp с добавление символа "_" в начале.

А экспорт работает примерно так же:

// Для использования асм-функций в си-коде достаточно объявить эти заголовки
// и их можно будет использовать как обычные си-функции
void __fastcall__ UnRLE(const unsigned char *data);
void __fastcall__ Get_Input(void);

Вызов си-функций в ассемблерном коде

Теперь давайте рассмотрим обратную ситуацию. Здесь тоже ничего сложного.

; Startup code for cc65/ca65
	; Импорт си-функций
	.import _main, _NMI
	; Экпорт переменных
	.export __STARTUP__: absolute = 1
	; Импорт переменных из Нулевой страницы (Zero Page)
	.importzp _Frame_Count

; Linker generated symbols
	.import __STACK_START__, __STACK_SIZE__
    .include "zeropage.inc"
	.import initlib, copydata
; Тут указываются функции обработчики прерываний
.segment "VECTORS"
    .word _NMI	;$fffa vblank nmi
    .word start	;$fffc reset
   	.word irq	;$fffe irq / brk

В сегменте VECTORS я обращаюсь к функции NMI, которая реализована на си. Ее я показывал выше. Точно так же добавляет символ "_" при импорте и всё.

Полезные функции при разработке игр для NES

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

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

/*Функция выводит строку до символа конца строки
PPU_ADDRESS - записываем сначала старший байт адреса, а затем младший
*p_text      - указатель на начало массива выводимой строки;
Пример использования:
    PPU_ADDRESS = 0x20; // Указываем адрес первого символа в таблицу имен
    PPU_ADDRESS = 0x00;
    p_text       = TEXT2; // Берем адрес начала массива
    set_text ();
*/
void set_text(void) {
    while (*p_text) {
            PPU_DATA =  *p_text;
            ++p_text;
    }
}

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

Вывод многострочного текста:

/*Выводит многострочный текст в виде прямоугольного текста
Все строки массива строк должны иметь одинаковую длину, 
можно выравнивать длину пробелами
hight_byte - задает старший байт адреса вывода первого символа текста
low_byte; - младший байт адреса вывода первого символа
number_of_lines  - задает количество выводимых строк
Пример использования:
    hight_byte = 0x22;
	low_byte = 0xD2;
	p_text = LOCATION_TEXT; // Массив строк
	number_of_lines = 5;
	draw_multiline_text ();
*/
void draw_multiline_text (void) {
    for (i = 0; i < number_of_lines; ++i) {
        PPU_ADDRESS = hight_byte;
        PPU_ADDRESS = low_byte;
        // Выводим текущую строку до символа конца строки
        while (*p_text) {
            PPU_DATA =  *p_text;
            ++p_text;
        }
        // Пропускаем пустой символ
        ++p_text;
        // Отслеживаем переполнение младшего байта адреса PPU
        if (low_byte >= 0xE0)
            hight_byte += 0x01;
        // Переводим адрес вывода на новую строку
        low_byte += 0x20;
    }
}

Принцип примерно тот же, но здесь учитывается, что для перевода текста на следующую строку автоматического инкремента недостаточно. Т.е. каждая строка фона состоит из 32 тайлов и каждый тайл имеет адрес, состоящий из двух байт. Например, нулевой тайл нулевой строки имеет адрес 0x2000, а последний тайл нулевой строки имеет адрес 0х201F. Нулевой тайл первой строки имеет адрес 0x2020 и т.д. Поэтому необходимо отслеживать переполнение младшего байта адреса для вывода текста.

С рисованием фона мы не немного разобрались, давайте напоследок рассмотрим как же выводить метаспрайты на экран. Это тоже очень просто.

// Массивы для отрисовки мехов
// Задает сдвиг спрайтов метатайла меха по оси Y
const unsigned char MetaSprite_Y[] = {0, 0, 
									  8, 8, 
									  16, 16}; 
// Хранит адреса спрайтов для отрисовки метаспрайта меха
const unsigned char MetaSprite_Mech[] = {
	0x00, 0x01, 0x10, 0x11, 0x20, 0x21, // UP direction
	0x02, 0x03, 0x12, 0x13, 0x22, 0x23, // DOWN direction
	0x04, 0x05, 0x14, 0x15, 0x24, 0x25, // RIGHT direction
	0x06, 0x07, 0x16, 0x17, 0x26, 0x27, // left direction
	// Состояние 2
	0x08, 0x09, 0x18, 0x19, 0x28, 0x29, // UP direction
	0x0A, 0x0B, 0x1A, 0x1B, 0x2A, 0x2B, // DOWN direction
	0x0C, 0x0D, 0x1C, 0x1D, 0x2C, 0x2D, // RIGHT direction
	0x0E, 0x0F, 0x1E, 0x1F, 0x2E, 0x2F  // left direction
};
// Младшие два бита определяют номер палитры
// 0b****_**00 - палитра 0
// 0b****_**01 - палитра 1
// 0b****_**10 - палитра 2
// 0b****_**11 - палитра 3
const unsigned char mech_attributes [][MECH_METASPRITE_SIZE] = {
	{0, 0, 0, 0, 0, 0},
	{0x01, 0x01, 0x01, 0x01, 0x01, 0x01}
};

// Задает сдвиг спрайтов метатайла по оси X
const unsigned char MetaSprite_X [] = {0, 8, 
									   0, 8,
									   0, 8}; //relative x coordinates
// Отрисовывает выбранного меха по координатам (X, Y)
// Координаты задаются в пикселях от 0 до 255
void draw_mech_to_x_y (void) {
    oam_counter = mech_shift_oam [index_selected_mech];
    // Считываем расцветку меха
    temp = p_selected_mech->color_;

    for (i = 0; i < MECH_METASPRITE_SIZE; ++i ) {
        // Первый байт задает положение спрайта по оси Y
        // + 4 - это для более красивого положения метаспрайта меха относительно клетки
        SPRITES[oam_counter] = MetaSprite_Y [i] + Y; 
        ++oam_counter;
        // Второй байт задает номер выбраного спрайта из .CHR
		SPRITES[oam_counter] = MetaSprite_Mech [i + p_selected_mech->direction_]; 
		++oam_counter;
        // Третий байт задает атрибуты спрайта (поворот, палитра)
		SPRITES[oam_counter] = mech_attributes [temp][i]; 
		++oam_counter;
        // Четвертый байт задает положение спрайта по оси X
		SPRITES[oam_counter] = MetaSprite_X [i] + X; // relative x + master x
		++oam_counter;
	}
}

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

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

Заключение

Можно было еще рассказать о особенностях рисования пиксель арта для денди и способах выковыривания спрайтов из ROM-в других игр и консолей (SNES, SEGA и т.д.), но я решил ограничиться только технической составляющей, так как статья выходит все-таки на хабре :).

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

Прошу прощения, если итоговый текст выглядит как поток сознания (а это и есть поток сознания). Ибо по хорошему нужно было бы писать целый цикл статей по разработке игр для денди от А до Я, а я в первую очередь хотел погрузить читателя в необычный мир разработки ретро игр и, возможно, заинтересовать сабжем (готовых циклов статей по разработке действительно достаточно в сети, но именно какие-то тонкие моменты, о которых я попытался рассказать, часто не озвучиваются). Поэтому данная статья это лишь подборка небольших кейсов, иллюстрирующих глубину разработки софта для старого железа.

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

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

PS: Кроме всего прочего есть мысль выложить на гитхаб проект-заготовку (проект быстрого старта) для создания игр для денди. Такой проект у меня уже почти готов. Стоит ли его разместить на гитхабе? А проект самой игры я выкладывать пока не готов, слишком оно сырое еще.

PS2: Еще есть небольшие наработки по созданию игру с видом от первого лица в том же сеттинге и лоре для Dendy. Задача еще более интересная по моему мнению. Тоже жду ваших комментариев по этому поводу.

Всем спасибо за внимание.

Видео версия статьи

Список основных ресурсов, которые я использовал при разработке:

  • Цикл статей по разработке игры для денди на си - https://habr.com/ru/articles/348022/

  • Википедия по разработке для NES (там все оч подробно описано и с примерами кода) - https://www.nesdev.org/wiki/NES_reference_guide

  • Живой форум по разработке ретро-игр (и не только) - emu-land.net

  • Сайт компилятор СС65 - https://www.cc65.org/

  • Эмулятор который я использую для отладки игры - fceux.com

  • YY-CHR

  • NEXXT

  • Еще несколько хороших статей про устройство консоли (на русском) - http://dendy.migera.ru/nes/g00.html

  • Страница проекта. Там можно скачать ROM-файл для эмулятора

  • Прямая ссылка на скачивание игры - The iron Steam 0.08

Автор: Говард Лавкрафт

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js