Разработка игры для ZX Spectrum на C

Эта непутевая заметка посвящена разработке игры для старого компьютера ZX Spectrum на C. Давайте взглянем на красавца:

Он начал производится в 1982 году, и выпускался до 1992 года. Технические характеристики машины: 8-битный процессор Z80, 16-128кб памяти и прочие экстеншены, например звуковой чип AY-3-8910.

В рамках конкурса Yandex Retro Games Battle 2019 для данной машины я написал игру под названием Interceptor 2020. Так как учить ассемблер для Z80 времени не было, я решил разработать ее на языке Си. В качестве тулчейна я выбрал готовый набор – z88dk, который содержит компиляторы Си, и много вспомогательных библиотек для ускорения реализации приложений для Спектрума. Он также поддерживает множество других Z80 машин, например MSX, калькуляторов Texas Instruments.

Далее я опишу свой поверхностный полет над архитектурой компьютера, тулчейном z88dk, покажу как удалось реализовать ООП подход, использовать паттерны проектирования.

Особенности установки

Установку z88dk следует проводить по мануалу из репозитория, однако для пользователей Ubuntu я хотел бы отметить особенность – если у вас уже установлены компиляторы для Z80 из deb пакетов, то следует их удалить, так как z88dk по умолчанию будет обращаться к ним из папки bin, из-за несовместимости версий тулчейн-компилятор вы скорее всего ничего не сможете собрать.

Hello World

Написать Hello World очень просто:


#include 

void main()
{
    printf("Hello World");
}

Собрать в tap файл еще проще:


zcc +zx -lndos -create-app -o helloworld helloworld.c

Для запуска используйте любой эмулятор ZX Spectrum с поддержкой tap файлов, например онлайн:
http://jsspeccy.zxdemo.org/

Рисуем на картинку на весь экран

tl;dr Картинки рисуются тайлами, тайлами размера 8×8 пикселей, сами тайлы встраиваются в шрифт спектрума, затем строкой из индексов печатается картинка.

Библиотека вывода спрайтов и тайлов sp1 выводит тайлы с помощью UDG. Картинка переводится в набор отдельных UDG (тайлы), затем собирается на экране с помощью индексов. Следует помнить что UDG используется для вывода текста, и если ваша картинка содержит очень большой набор тайлов (например больше 128 тайлов), то придется выходить за границы набора и стирать дефолтный спектрумовский шрифт. Чтобы обойти это ограничение, я использовал базу от 128 – 255 с помощью упрощения изображений, оставляя оригинальный шрифт на месте. Об упрощении картинок ниже.

Для отрисовки полноэкранных картинок нужно вооружиться тремя утилитами:
Gimp
img2spec
png2c-z88dk

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

Путь попроще – взять 32 битное изображение, переключить в Gimp количество цветов до 3-4-х, слегка подредактировать, затем импортировать в img2spec для того чтобы не работать с цветовыми ограничениями вручную, экспортировать png и перевести в Си массив с помощью png2c-z88dk.

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

В результате вы получите h файл, который содержит количество уникальных тайлов, если их больше ~128, то упрощайте в Gimp картинку (увеличьте повторяемость) и проводите процедуру экспорта по новой.

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


// грузим шрифт в память
    unsigned char *pt = fullscreenImage->tiles;

    for (i = 0; i < fullscreenImage->tilesLength; i++, pt += 8) {
            sp1_TileEntry(fullscreenImage->tilesBase + i, pt);
    }

    // ставим курсор в 0,0
    sp1_SetPrintPos(&ps0, 0, 0);

    // печатаем строку
    sp1_PrintString(&ps0, fullscreenImage->ptiles);

Рисуем спрайты на экране

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

Рисуем в Gimp монохромную png картинку 16×16, далее с помощью png2sp1sprite переводим ее в ассемблерный файл asm, в Си коде объявляем массивы из ассемблерного файла, добавляем файл на этапе сборки.

После этапа объявления ресурса спрайта, его надо добавить на экран в нужную позицию, далее пример кода “класса” игрового объекта:


    struct sp1_ss *bubble_sprite = sp1_CreateSpr(SP1_DRAW_MASK2LB, SP1_TYPE_2BYTE, 3, 0, 0);
    sp1_AddColSpr(bubble_sprite, SP1_DRAW_MASK2,    SP1_TYPE_2BYTE, col2-col1, 0);
    sp1_AddColSpr(bubble_sprite, SP1_DRAW_MASK2RB,  SP1_TYPE_2BYTE, 0, 0);
    sp1_IterateSprChar(bubble_sprite, initialiseColour);

По названиям функций можно примерно понять смысл – аллоцируем память для спрайта, добавляем две колонки 8×8, добавляем цвет для спрайта.

В каждом кадре проставляется позиция спрайта:


sp1_MoveSprPix(gameObject->gameObjectSprite, Renderer_fullScreenRect, gameObject->sprite_col, gameObject->x, gameObject->y);

Эмулируем ООП

В Си нет синтаксиса для ООП, что же делать если все равно очень хочется? Надо подключить думку и озариться мыслью что такой вещи как ООП оборудование не существует, все в итоге приходит к одной из машинных архитектур, в которых просто нет понятия объекта и прочих связанных с этим абстракций.

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

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

Также стоит отметить упрощение решения вопросов связанных с архитектурой приложения, ведь 80% архитектурных проблем было решено компьютерными-учеными еще в прошлом веке и описано в литературе посвященной паттернам проектирования. Далее я опишу способы добавить похожий на ООП синтаксис в Си.

За основу хранения данных экземпляра класса удобнее взять структуры Си. Конечно можно использовать байтовый буфер, создать свою собственную структуру для классов, методов, но зачем переизобретать колесо? Ведь мы и так переизобретаем синтаксис.

Данные класса

Пример полей данных “класса” GameObject:


struct GameObjectStruct {
    struct sp1_ss *gameObjectSprite;
    unsigned char *sprite_col;
    unsigned char x;
    unsigned char y;
    unsigned char referenceCount;
    unsigned char beforeHideX;
    unsigned char beforeHideY;
};
typedef struct GameObjectStruct GameObject;

Сохраняем наш класс как “GameObject.h” делаем #include “GameObject.h” в нужном месте и пользуемся.

Методы класса

Возьмем на вооружение опыт разработчиков языка Objective-C, сигнатура метода класса будут представлять из себя функции в глобальном скопе, первым аргументом всегда будет передаваться структура данных, далее идут аргументы метода. Далее пример “метода” “класса” GameObject:


void GameObject_hide(GameObject *gameObject) {
    gameObject->beforeHideX = gameObject->x;
    gameObject->beforeHideY = gameObject->y;
    gameObject->y = 200;
}

Вызов метода выглядит так:


GameObject_hide(gameObject);

Конструкторы и деструкторы реализуются таким же образом. Можно реализовать конструктор как аллокатор и инициализатор полей, однако мне больше нравится осуществлять контроль над аллокацией и инициализацей объектов раздельно.

Работа с памятью

Ручное управление памятью вида с помощью malloc и free обернутых в макросы new и delete для соответствия с C++:


#define new(X) (X*)malloc(sizeof(X))
#define delete(X) free(X)

Для объектов которые используются несколькими классами сразу, реализовано полу-ручное управление памятью на основе подсчета ссылок, по образу и подобию старого механизма Objective-C Runtime ARC:


void GameObject_retain(GameObject *gameObject) {
    gameObject->referenceCount++;
}

void GameObject_release(GameObject *gameObject) {
    gameObject->referenceCount--;

    if (gameObject->referenceCount < 1) { sp1_MoveSprAbs(gameObject->gameObjectSprite, &Renderer_fullScreenRect, NULL, 0, 34, 0, 0);
        sp1_DeleteSpr(gameObject->gameObjectSprite);
        delete(gameObject);
    }
}

Таким образом каждый класс должен объявлять использование общего объекта с помощью retain, освобождать владение через release. В современном варианте ARC используется автоматическое проставление вызовов retain/release.

Звучим!

На Спектруме есть пищалка способная воспроизводить 1-битовую музыку, композиторы того времени умели воспроизводить на ней до 4-х звуковых каналов одновременно.

Spectrum 128k содержит отдельный звуковой чип AY-3-8910, на котором можно воспроизводить трекерную музыку.

Для использования пищалки в z88dk предлагается библиотека

Что предстоит узнать

Мне было интересно ознакомиться со Спектрумом, реализовать игру средствами z88dk, узнать много интересных вещей. Многое мне еще предстоит изучить, например ассемблер Z80, так как он позволяет использовать всю мощь Спектрума, работу с банками памяти, работу со звуковым чипом AY-3-8910. Надеюсь поучаствовать в конкурсе на следующий год!

Ссылки

https://rgb.yandex
https://vk.com/sinc_lair
https://www.z88dk.org/forum/

Исходный код

https://gitlab.com/demensdeum/zx-projects/tree/master/interceptor2020