Dungeon Crawler на Commodore 64. Часть вторая
Посленовогодний апдейт новостей о разработке игры на Commodore 64 на ассемблере.
А добавил я вот что:
HUD
Здесь совсем все просто. Графику можно банально положить в область экранной памяти при загрузке программы.
Рисовал я в программе Multipaint, а вот переносил в формат ассемблера с помощью скрипта на Python, который берет PNG изображение и попиксельно (и по знакоместам) конвертирует его в нужную последовательность байт. На это пришлось пойти, потому что Multipaint очень криво раскидывает цвета изображения по памяти, и получается что-то такое:
Сразу скажу, что художник из меня не очень. Так как действие игры происходит в российских реалиях, сперва я не придумал ничего лучше, чем нарисовать HUD в виде знаменитого забора. Но насколько забавно это выглядело в редакторе, насколько несуразно выглядит в самой игре:
Поэтому я нарисовал кое-то попроще:
Карта и перемещение игрока
Карта представляет собой банальный двухмерный массив байт размером 24*24 (576 байт). Один байт (8 бит) кодируется следующим образом:
- Биты 0-3 – номер текстуры от 0 до 15.
- Бит 4 – наличие спрайта на блоке.
- Бит 5 – наличие триггера на блоке.
- Бит 6 – необходимость отрисовки блока. Можно использовать, например, для невидимых стен.
- Бит 7 – наличие коллизии. Можно использовать, например, для секретов (неосязаемые стены).
Получилось как-то так (можно увидеть недоработанный триггер двери и графический артефакт, с которым я до конца так и не разобрался. Но об этом позже):
В будущем уровни я планирую рисовать в замечательной программе Tiled. Она сохраняет все данные в JSON или XML, и это дело можно очень удобно сконвертировать в формат ассемблера.
Триггеры
Все триггеры хранятся в памяти в следующем виде:
- Флаги. Всего два флага: single use и avaliable. Первый переключает второй в ноль. Второй говорит, что триггер можно использовать.
- Позиция на карте по X.
- Позиция на карте по Y.
- Маска ключей, которые нужны для активации триггера (сравнивается с ключами, которые есть у игрока).
- Адрес функции, которая активирует триггер.
- 4 аргумента триггера.
Как это работает.
Когда игрок нажимает F (да, у нас тут вполне современное управление), проверяется, есть ли вообще на блоке перед игроком триггер. Если есть, то сравниваются координаты блока с координатами триггера. Потом проверяется наличие у игрока нужных ключей.
А дальше уже интереснее. Сперва я проверял каждый триггер на его вид (дверь, телепорт и т.д.) условными переходами, но это оказалось очень затратно с точки зрения быстродействия и памяти. Поэтому я решил поступить иначе. Теперь каждый триггер содержит в себе адрес функции, которая его активирует. И адрес подставляется сразу в команду вызова этой функции. То есть используется самомодифицирующийся код.
Пока что готовы такие триггеры:
- Дверь. Позволяет телепортировать игрока за дверь, а потом вернуть обратно. Этот же триггер можно использовать для разделения зон. Например, сделать телепорт, который сперва посылает в одну зону, а потом в другую.
- Телепорт. Просто телепортирует игрока на конкретный блок карты.
- Убрать блок. Убирает коллизию и отрисовку блока.
- Убрать спрайт. Убирает коллизию блока и сам спрайт. Может использоваться, если игрок, например подобрал патроны или ключ.
- Вывод текста. Выводит текст на экран (140 символов). Об этом позже.
- Смена текстуры. Просто меняется номер текстуры на блоке.
На один блок карты можно прикреплять сразу несколько триггеров.
Здесь, например, срабатывают два триггера - текстура кнопки меняется, а соседний блок убирается:
Спрайты
Ох и намучился я с ними. Спрайты хранятся в памяти почти также, как и триггеры:
- Позиция на карте по X.
- Позиция на карте по Y.
- Адрес изображения спрайта в памяти.
- Цвета (всего 6 штук).
Можно было бы написать собственные функции для попиксельной отрисовки спрайтов. Но это очень затратно для Commodore, тем более, что очень много процессорного времени используется для рейкастинга и отрисовки стен.
Благо, что в Commodore на аппаратном уровне зашиты аж 8 спрайтов размером 24*21 пиксель (12*21 пиксель в многоцветном режиме). Изображение для одного спрайта занимает всего 64 байта, а пользоваться ими очень просто. Нужно просто записывать в нужные регистры видеочипа параметры: позицию, значения цветов, указатель на изображение и т.д. Все это происходит налету.
Один игровой спрайт состоит из 4 аппаратных спрайтов. Каждый аппаратный спрайт может содержать один уникальный цвет и два общих для всех спрайтов. Из-за этого и можно указать 6 цветов: два цвета одинаковых для всех спрайтов, и 4 цвета уникальных для каждого из 4 спрайтов (возможно потом придется обойтись меньшим количеством цветов, если я соберусь использовать оставшиеся 4 аппаратных спрайта).
Так почему же я страдал?
Видеочип в Commodore 64 может обращаться всего к 16 Кб оперативной памяти (общей с процессором). И все изображения спрайтов обязательно должны лежать в пределах этих 16 килобайт. Но из-за того, что почти вся память в зоне, которую я использую, занята экранной памятью, кодом игры и таблицами, то для спрайтов места почти не остается. А самая большая проблема, что в том месте, куда спрайты можно положить (на картинке - первая пустая область), зеркалится Character ROM (генератор символов для текстового режима). Процессор воспринимает эту область, как RAM, а вот видеочип уже видит ее как Character ROM. Поэтому в эту область графику класть нельзя.
Решил я эту проблему тем, что положил данные спрайтов вообще в другое место в памяти, а в области, на которую смотрит видеочип, выделил 4 блока по 64 байта. В эти блоки изображения банально копируются при необходимости. Думаю, для более динамичной игры такой подход бы не подошел, но в моем случае подгрузка вообще не заметна.
Текстовый режим
В Commodore 64 есть замечательная штука, которая называется Растровые прерывания. Видеочип отрисовывает экран построчно. В PAL версии компьютера (который у меня), видеочип проходит 312 линий для полной отрисовки экрана (это непосредственно экран, рамка и так называемый VBlank - время, которое тратится на возврат луча в начальную позицию).
Видеочип отрисовывает одну линию экрана за 63 такта процессора (в хорошем случае). Но так как процессор и видеочип разделяют одну шину данных, то существуют так называемые badlines, когда видеочип полностью блокирует процессор на 40 тактов. В таком случае за отрисовку одной линии процессор отрабатывает всего 23 такта.
Это я все к чему? На каждой линии отрисовки можно довольно просто вызвать растровое прерывание. В этом случае видеочип говорит процессору: "Забей на все, что ты сейчас делаешь и иди делай то, что я тебе скажу в другом месте, а потом возвращайся обратно".
Таким образом я сделал разделение экрана на текстовую и графическую зоны. Когда видеочип достигает 209 линии отрисовки, он говорит процессору, что надо переключить режим на текстовый. Тоже самое происходит в конце экрана на 255 линии, когда процессор переключает режим обратно на графический.
За счет этого я очень просто, без необходимости создания функций для вывода текста в графическом режиме, могу печатать любой текст в нижней части экрана.
Но без проблем не обошлось. Тот артефакт, про который я рассказывал в начале статьи, возникал из-за того, что процессор переключал режим еще до того, как видеочип отрисовал всю линию (вся операция занимала меньше 63 тактов). Решилось это банальным добавлением нескольких команд NOP (No OPeration) в прерывание.
Но и сейчас проблема до конца не решена. Если посмотреть на изображение, видно, что криво отображается последняя линия графики над текстом. Что с этим делать, я пока не понял.
Подгрузка данных с дискеты
Поскольку по моим подсчетам один уровень, текстуры для него, триггеры, спрайты и т.д. на данный момент занимают примерно 2 388 байт (и это я еще не посчитал графику для спрайтов, которая будет тоже занимать немало места), памяти для нескольких уровней не хватит ни при каком желании.
Поэтому я принял решение, что такие вещи будут подгружаться по ходу игры при переходе на следующий уровень.
Сама загрузка данных из файлов с дискеты происходит нетривиально. Для этого надо пользоваться командами из KERNAL (местный BIOS).
Сперва используем функцию SETLFS, которая сохраняет в нужные регистры памяти логический номер файла, номер устройства, с которого будет считываться файл (нужно указать 8 - стандартный номер дисковода).
Сюда же надо передать параметр secondary address. Если 0, то данные будут загружаться в то место памяти, которое надо передать позднее в функцию LOAD. Если 1, то данные будут загружаться в место, указанное в заголовке файла.
Потом используем функцию SETNAM, которая сохраняет адрес, где лежит имя файла в обычном текстовом виде, а также длину названия.
И наконец функцией LOAD загружаем данные из файла.
Вот на этом видео, например, в память подгружаются значения цветов экрана. Можно было бы сразу поместить эти значения в память, но так как цвета для multicolor mode берутся из области стандартной экранной памяти в текстовом режиме, загрузка программы может пойти не так, как надо.
На этом пока все. А если было интересно, подписывайтесь на Telegram канал, где я рассказываю о ходе разработки этой игры. Обновления выходят почти каждый день=)