Как стать автором
Обновить

Java для Sega Mega Drive — возможно ли это?

Уровень сложностиСредний
Время на прочтение12 мин
Количество просмотров4.4K

Введение

В этом проекте я хотел ответить на вопрос: возможно ли написать игру на Java для Sega Mega Drive/Genesis. Не хочу раскрывать спойлеры, но ответом будет «да».
Несколько лет назад я повстречал проект Java Grinder, который позволяет писать код для различных ретро процессоров на Java, в том числе для Sega Mega Drive. По сути, он интерпретирует байт-код из файлов .class, полученных после компиляции, в код на Ассемблере 68K. Если файлу класса нужны другие файлы классов, то они тоже считываются и обрабатываются. Все вызовы методов API записываются в выходном коде, либо как встроенный ассемблерный код, либо как вызовы предварительно написанных функций, выполняющих свою задачу.
Сама по себе система довольно проста, но мне ещё многому предстоит научиться, а качественную информацию искать не так просто. На самом деле в этом проекте я впервые занялся настоящим программированием для Mega Drive.

Подготовка

Java Grinder изначально был сделан для линукса, и на данный момент нет порта для windows, поэтому либо придётся использовать линукс, либо WSL. Я использовал WSL, поэтому все дальнейшие примеры буду приводить на нем. Чтобы начать создавать свои проекты необходимо выполнить несколько шагов:

  1. установите в ваш wsl утилиту make для сборки проектов и javac(openjdk) для компиляции java файлов.

  2. клонируйте репозиторий Java Grinder, перейдите в папку репозитория и выполните команду wsl make. В результате должен создаться файл java_grinder.

  3. выполните команду make java для создания библиотеки классов JavaGrinder.jar в папке build.

  4. клонируйте репозиторий naken_asm, перейдите в папку репозитория и выполните команду .configure, после этого выполните команду make. Созданный файл naked_asm переместите в директорию Java Grinder.

  5. Создайте папку projects в директории Java Grinder или перейдите в папку samples и клонируйте туда репозиторий Empty-project-Java-Grinder. Это будет ваш шаблонный проект для создания программ и игр на Sega Mega Drive/Genesis. При создании новой игры просто скопируйте папку проекта и поменяйте название на название вашего проекта.

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

Шрифт

На данный момент в шрифте доступны только заглавные английские буквы.
Для вывода текста на экран нужно сначала использовать функцию для установки начальных координат, где будет размещаться текст, SegaGenesis.setCursor(int X, int Y), X должен располагаться в диапазоне от 0 до 28, Y — от 0 до 40. После этого можно использовать либо функцию SegaGenesis.printChar(char c), которая печатает один символ, либо SegaGenesis.print(String text), которая печатает текст целиком. Для удобства можете использовать функции из класса Text. Будьте внимательны, функции print не переносят текст на новую строку, если вы вышли за пределы экрана, вам придется регулировать это самим.

Графика

Чтобы научиться выводить что-либо на экран, необходимо разобраться в структуре графики на платформе Sega и в методах её кодирования. Подробнее об этом можно узнать в данной статье. Вкратце, в Sega используется тайловая графика, где каждый тайл имеет размер 8x8 пикселей и в памяти рома занимает 32 байта. Для вывода изображения на экран используется чип VDP(Video Display Processor).
Данные в VDP загружаются в определенном формате, который тесно связан с палитрой. Суть этого кодирования заключается в присвоении каждому пикселю тайла определенного индекса цвета из палитры, который может варьироваться от 0 до 15(0x0-0xF). Продемонстрируем данный подход на примере персонажа Lemming из игры Lemmings Return для Mobile. Этот персонаж является самым маленьким из мне известных, который использует все 16 цветов палитры и полностью помещается всего на два тайла. Если вы знаете других таких же маленьких персонажей или меньше, напишите в комментариях.
Тайлы лемминга увеличенные вдвое + изображение палитры + демонстрация как данные храниться в VDP:

Lemming
Lemming

Палитра

Давайте продолжим обсуждение графики и рассмотрим, как хранится палитра в Java Grinder. Это важно для понимания работы других графических элементов.
В Sega Mega Drive используется 9 битная палитра. Подробнее об этом можно прочитать здесь или здесь. В памяти консоли один цвет палитры занимает 2 байта. Например значение белого цвета 0хEEE будет храниться как 0x0E, 0xEE.
В Java Grinder палитра храниться в массиве short[] palette и загружается с помощью API метода SegaGenesis.setPaletteColorsAtIndex(int index, short[] palette) в VDP CRAM ("Color RAM" — «цветовое ОЗУ»).
В массиве palette содержаться значения цветов 9 битной палитры в 16-ричном формате от 0x000 до 0xEEE. Максимальное количество элементов в массиве не должно превышать 16. Если вы используете меньше цветов, то рекомендуется неиспользуемые цвета приравнять 0x000.
Пример палитры лемминга из предыдущего раздела:

public static short[] palette =  
  {     
    0xECE, 0x0A0, 0x0C0, 0x080, 0xEEE, 0x88C, 0xAAE, 0x246,  
    0x8AE, 0x68C, 0x66A, 0xE80, 0xEA0, 0xC60, 0xC40, 0xA00   
  };

Значение палитры можно преобразовать из RGB в 9 битную по данному алгоритму

((color.B >> 5) << 9) | ((color.G >> 5) << 5) | ((color.R >> 5) << 1).  

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

b = (color9bit >> 9) & 0x7;  
g = (color9bit >> 5) & 0x7;  
r = (color9bit >> 1) & 0x7;  
Color = (r << 5, g << 5, b << 5);

Если хотите точно конвертировать 9-битную палитру в RGB, вам необходимо найти таблицу соответствий или вывести ее самому.

Задний фон(background)

Для создания заднего фона и вывода его на экран нам потребуется 4 вещи:

  1. массив palette из предыдущего раздела.

  2. массив pattern В этом массиве хранятся отдельные части изображения — тайлы. Они записываются последовательно, сверху вниз и слева направо. Каждый элемент массива представляет собой одну строку тайла.

  3. массив images Это так называемая тайловая карта(tilemap) в которой последовательно хранятся индексы тайлов из массива pattern.

  4. API для загрузки данных в VDP:

Загрузка палитры в VDP CRAM, начиная с индекса 0.

SegaGenesis.setPaletteColors(short[] palette)

Загрузка тайлов в VDP VRAM, начиная с индекса 0.

SegaGenesis.setPatternTable(int[] pattern)

Загрузка тайловой карты в конец VDP

SegaGenesis.setImageData(int[] image)

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

Спрайты

Спрайты создаются очень похоже на задний фон, но для их отрисовки требуется больше вызовов API. Кроме того, спрайты не содержат тайловую карту, то есть они не оптимизированы, в отличие от заднего фона. Это означает, что одни и те же тайлы спрайтов могут встречаться несколько раз в VDP. На самом деле это можно оптимизировать, но данная тема выходит за рамки данной статьи, об этом можете почитать здесь.
Спрайты отрисовываются в виртуальном пространстве 512x512 пикселей, где координаты (128,128) совпадают с верхним левым углом телеэкрана.
Внутри консоли спрайты рендерятся в обратном порядке, т.е. сверху вниз, слева направо.
Пример:

Класс спрайта компьютерной мыши можете посмотреть здесь.

Для вывода спрайта на экран нам нужно использовать функции API:

  • SegaGenesis.setPaletteColorsAtIndex(int index, short[] palette) функция работает аналогично функции SegaGenesis.setPaletteColors(short[] palette), которая используется для загрузки палитры заднего фона, единственное отличие в том что можно задать индекс начала загрузки палитры. Значение индекса должно быть от 0 до 63, если передать индекс за пределы диапазона, то это может привести к непредвиденным последствиям.

  • SegaGenesis.setPatternTableAtIndex(int index, int[] patterns) Функция работает аналогично функции SegaGenesis.setPatternTable(int[] pattern). Параметр index определяет адрес, с которого начинается загрузка тайлов в видеопамять(VDP). Не рекомендуется записывать в диапазон [0x0460, 0x0479], так как в эти адреса загружается шрифт и в диапазон [0x0600, 0x071F], так как там хранятся данные тайловой карты.

  • SegaGenesis.setSpritePosition(int index, int x, int y) Функция настраивает позицию спрайта по индексу спрайта из Sprite Attribute Table, не путать с индексом из функции SegaGenesis.setPatternTableAtIndex. Чтобы спрайт отобразился на экране, значения x и y должны быть в диапазоне x=(128, 448) y=(128, 352).

  • SegaGenesis.setSpriteConfig1(int index, int value) Это так называемое первое слово конфигурации спрайта, в которое входит: горизонтальный размер спрайта в тайлах, вертикальный размер спрайта в тайлах, индекс следующего спрайта который нужно отобразить.

  • SegaGenesis.setSpriteConfig2(int index, int value) Второе слово в которое входит: номер палитры, отражение по горизонтали или вертикали(опционально), адрес спрайта в VDP.

Управление

На данный момент реализовано только 3 кнопочное управление, без кнопки Mode. В API содержится метод для получения кода текущей нажатой кнопки SegaGenesis.getJoypadValuePort1. Для удобства работы с этим методом в коде определены константы, которые соответствуют кодам кнопок:

public static final int JOYPAD_START = 0x2000;  
public static final int JOYPAD_A = 0x1000;  
public static final int JOYPAD_C = 0x0020;  
public static final int JOYPAD_B = 0x0010;  
public static final int JOYPAD_RIGHT = 0x0008;  
public static final int JOYPAD_LEFT = 0x0004;  
public static final int JOYPAD_DOWN = 0x0002;  
public static final int JOYPAD_UP = 0x0001;

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

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

  int keyCode = SegaGenesis.getJoypadValuePort1();  
  if (keyCode == SegaGenesis.JOYPAD_A){
    //Действия для кнопки А
}  

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

На данный момент неизвестно, как автор планировал работу с джойстиком, поскольку в единственном демонстрационном примере для Sega MD отсутствует реализация работы с ним.

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

int keyCode = SegaGenesis.getJoypadValuePort1();
if ((keyCode & SegaGenesis.JOYPAD_A) != 0) {
  //Do something
}

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

В итоге код для нажатия всех кнопок выглядит так:

int keyCode = SegaGenesis.getJoypadValuePort1();

// проверка нажатий кнопок
if (!pressed) {
  // проверка нажатия кнопки вверх 0x80
  if ((keyCode & SegaGenesis.JOYPAD_UP) != 0) {
    if (y < 0x7F) {
      continue;
    }
    //Do something
    Timer.wait(1);
  }
  // проверка нажатия кнопки вниз 0x82
  if ((keyCode & SegaGenesis.JOYPAD_DOWN) != 0) {
    if (y > 0x160) {
      continue;
    }
    //Do something
    Timer.wait(1);
  }
  // проверка нажатия кнопки влево 0x84
  if ((keyCode & SegaGenesis.JOYPAD_LEFT) != 0) {
    if (x < 0x7E) {
      continue;
    }
    //Do something
    Timer.wait(1);
  }
  // проверка нажатия кнопки вправо 0x88
  if ((keyCode & SegaGenesis.JOYPAD_RIGHT) != 0) {
    if (x > 0x1C0) {
      continue;
    }
    //Do something
    Timer.wait(1);
  }
  // проверка нажатия кнопки A 0xD080
  if ((keyCode & SegaGenesis.JOYPAD_A) != 0) {
    //Do something
    pressed = true;
  }
  // проверка нажатия кнопки B 0x90
  if ((keyCode & SegaGenesis.JOYPAD_B) != 0) {
    //Do something
    pressed = true;
  }
  // проверка нажатия кнопки C 0xA0
  if ((keyCode & SegaGenesis.JOYPAD_C) != 0) {
    //Do something
    pressed = true;
  }
  // проверка нажатия кнопки START 0xE080
  if ((keyCode & SegaGenesis.JOYPAD_START) != 0) {
    //Do something
    pressed = true;
  }
} else if (keyCode == 0xCC80 || keyCode == 0xC080) {
  pressed = false;
}

Примечание: В данном примере реализовано многокнопочное управление для стрелок. Если вы хотите сделать многокнопочное управление для остальных кнопок, достаточно убрать строку pressed = true;. Это позволит обрабатывать несколько нажатий одновременно, что может быть полезно в некоторых игровых сценариях.

Звуки

Для того чтобы проиграть хоть какую-нибудь мелодию на Sega Mega Drive, необходимо знать как работает звук на платформе. Вкратце, для воспроизведения звука на Sega используется: z80 CPU, z80 RAM, Yamaha 2612, PSG, Audio Mixer. Мы можем напрямую взаимодействовать только с z80 RAM, а он уже непосредственно будет управлять всем остальным. Примерная схема взаимодействия выглядит так:

Начнем с подготовки файлов музыки и звуков. Музыкальный файл должен быть монофоническим, с глубиной звука 8 бит и, желательно, с частотой дискретизации 44100 Гц. Рекомендуется использовать файлы с расширением .wav, поскольку у данного формата вся необходимая информация содержится в заголовке, а данные хранятся в исходном(RAW) формате. Для удобства конвертирования вашего звукового файла под данные ограничения можете воспользоваться моей программой z80GrinderConverter.

Для работы с z80 используются API методы:

  • loadZ80(byte[] code): Загружает код размером до 8 килобайт в Z80 RAM. Z80 будет сброшен через API, и загруженный код начнет выполняться.

  • resetZ80(): Сбрасывает состояние Z80, возвращая его к начальному состоянию.

  • pauseZ80(): Приостанавливает выполнение Z80. Это необходимо для того, чтобы главный процессор m68000 мог получить доступ к каким-либо ресурсам в пространстве Z80.

  • startZ80(): Запускает выполнение Z80 снова, после того как он был приостановлен или остановлен.

Есть 3 способа как проиграть мелодию: загрузить звук в z80 RAM плюс код инициализации, загрузить ноты и их последовательность в z80 RAM плюс код инициализации, загрузить данные из wav файла в ром и wav проигрыватель в z80 RAM и передать адрес начала в определенное смещение в z80 RAM. Разберем каждый способ по порядку

Способ 1. Загрузить звук с кодом инициализации в z80 RAM.
Для начала нам понадобиться код инициализации. Код инициализации это код на ассемблере z80, Который передается через процессор M68000 в z80 RAM как скомпилированный массив данных. Чтобы его получить вы можете скомпилировать файл z80_play_dac.asm с помощью naked_asm, перевести его в java байт-массив и выделить из него код инициализации, или можете использовать готовый массив кода инициализации:

  static byte loopDelay = 62;//задержка. сколько раз будет выполнен цикл  
public static byte[] z80_init_code =  
  {  
      62,   43,   50,    0,   64,   62, -128,   50,  
       1,   64,  -35,   33,   58,    0,   33,  112,  
      23,   62,   42,   50,    0,   64,  -35,  126,  
       0,   50,    1,   64,  -35,   35,    6,   loopDelay,  
      16,   -2,   43,  125,   -2,    0,   32,  -23,  
     124,   -2,    0,   32,  -28,   62,   43,   50,  
       0,   64,   62,    0,   50,    1,   64,  -61,  
      55,    0,  
}  

После вставки кода инициализации в z80 RAM свободного места у вас остается 512-58=454 байт, этого обычно достаточно для небольшого звукового эффекта, но не для проигрывания полной мелодии.

Способ 2. Записать ноты или мелодии и проигрывать их по заданному сценарию
Автор Java Grinder для реализации данного способа, использовал гитарные аккорды и проигрывал их в цикле по заданному порядку. Можете модифицировать данный код для создания своей собственной мелодии, после чего скомпилировать его с помощью naked_asm и перевести в java байт-массив.

Способ 3. Написать свой проигрыватель или использовать уже готовый.
Для этого метода нужно: разместить код проигрывателя в z80 RAM, разместить музыкальные данные в ROM, определить адрес и длину музыкальных данных в ROM, записать адрес и длину в определенное место в z80 RAM.
К сожалению тут мы сталкиваемся с одним из ограничений Java, максимальный размер статического массива не должен превышать 8 242 элементов. Мы можем преодолеть данное ограничение использовав вместо типа byte тип int(самый большой тип данных в Grinder на данный момент), тогда получаем что максимальный размер файла который можно загрузить в один массив равно 8 242 * 4 = 32968 байт или почти 33 Килобайт. Именно такой длины музыкальный файл мы можем загрузить без проблем в ROM, для его загрузки в память ROM, мы должны просто сослаться на него, например: byte[] b = z80_code или создать пустую функцию в файле, где у вас расположен массив z80_code и просто вызвать ее, например: public static void init(){}. Если этого размера вам недостаточно, то придется создавать несколько массивов и вызывать их все по очереди в функции обертке. К сожалению, запись музыки в ROM один в один не получится, так как между массивами будет вставлено 4 байта, указывающие на размер массива.
На данный момент нет примера пользовательского проигрывателя wav-файлов, а также нет мелодии, которую можно было бы воспроизвести с его помощью.

Вывод

На текущий момент движок очень сырой и лучше всего подойдет для создания каких-нибудь живых книг или визуальных новелл, желательно без музыки или очень короткой, так как на нем очень просто отображать задний фон, но сложно портировать музыку. Для более сложных проектов, таких как платформеры, лучше использовать другие инструменты, например SGDK или BasieGaxorz(BEX). Более полный список всех собранных инструментов и движков можно посмотреть здесь.

Демо

На данный момент существует всего два проекта для Sega Mega Drive сделанных на Java Grinder.

  1. sega_genesis_java_demo.bin - это демо версия от разработчика для демонстрации возможности движка.

  2. Dr. Sukebe x-boobs - это эротически-юморная игра которая является портом игры с j2me.

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

Ограничения

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

  1. Нельзя создавать объекты, ключевое слово new недоступно

  2. Нельзя оставлять пустое условие if.

  3. Нельзя присваивать enum начальное значение. Есть возможность создавать enum, но нельзя их использовать.

  4. Поля класса обязательно должны быть static final или без final, но тогда без инициализации.

  5. нельзя использовать адреса в VDP в диапазоне [0x0460, 0x0479], так как там находится шрифт;

  6. Команды SegaGenesis.setPalettePointer(17), SegaGenesis.setPaletteColor(0x000) не понятно зачем нужны. Может быть не работают

  7. Максимальный размер статического массива не должен превышать 8 242 элементов.

  8. В шрифте доступны только большие английские буквы, БЕЗ ЦИФР И ЗНАКОВ ПРЕПИНАНИЯ.

  9. Поддерживаются только три типа чисел: byte, short, int.

  10. Нельзя инициализировать поле в методе

  11. Нельзя обратиться к элементу массива char[]. Например нельзя писать chr_arr[0]

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

  13. Графика и музыка по умолчанию не шифруются, в отличии от SGDK.

Советы

  1. если выдает ошибку Couldn't find “ИмяПоля” ** Error setting statics ../common/JavaCompiler.cxx:2416 попробуйте сделать поле final.

  2. Если не удается скомпилировать проект, хотя до этого он компилировался, попробуйте удалить все .class файлы.

Ссылки

  1. https://habr.com/ru/articles/471914/

  2. https://www.copetti.org/ru/writings/consoles/mega-drive-genesis/

  3. https://megacatstudios.com/ru/blogs/retro-development/sega-genesis-mega-drive-vdp-graphics-guide-v1-2a-03-14-17

  4. Не вошедшее

  5. Версия статьи на Дзене

  6. Веб версия статьи

Теги:
Хабы:
Всего голосов 19: ↑19 и ↓0+25
Комментарии4

Публикации

Истории

Работа

Java разработчик
215 вакансий

Ближайшие события

25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань
20 – 22 июня
Летняя айти-тусовка Summer Merge
Ульяновская область