Разработка ретро-игр — это всегда танец балансировки функций, скорости обработки и места на картридже. Неаккуратный код или непродуманный дизайн могут привести к неаккуратной и медленной игре. Однако продуманный дизайн и искусный код могут создать игру с прекрасной хореографией. Программирование поведения Прожорливых Призраков на Трапезе — пример одного из таких танцев развития и дизайна.

Самое голодное мясо

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

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

Однако логика некоторых врагов может быть немного сложнее. Сложные враги с особыми паттернами, способностями или поведением ставят перед игроками дополнительные задачи и заставляют их использовать свои знания о персонаже игрока и игровом мире. В качестве примера можно привести поведение и слабости Ненасытного Призрака из The Meating.

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

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

if (playerX › ghostX) distance = playerX — ghostX; else distance = ghostX — playerX; if (distance ‹= 64) Ghost.status = GHOST_ATTACK;

Но, похоже, мы не учли еще кое-что.

Скрытие мяса

Чтобы придать нашим врагам больше реализма, мы должны наделить его видением. А если между игроком и призраком будет блок или платформа, то призрак не «увидит» игрока и не нападет на него. Взгляните на себя:

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

Во-первых, давайте разберемся, как работает карта уровня.

Наша игровая карта разделена на метатайлы. Посмотрите на эту картинку выше: 16 столбцов и 15 строк образуют 240 ячеек. В каждой из этих ячеек находится метатайл — «графика» размером 16х16 пикселей.

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

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

Вернемся к нашему призраку. Предположим, координата X нашего минотавра равна 96. Призрак находится в позиции 160.

Рассчитаем расстояние между ними:

расстояние = 160–96 = 64.

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

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

Это может выглядеть так:

// Сбросить переменную playerDetectedplayerDetected = 0;

Итак, расстояние между игроком и призраком составляет 4 метатайла или 64 пикселя. Этого достаточно для начала атаки, но мы еще не проверили метатайлы между игроком и призраком. Что ж, давайте сразу это исправим.

if (distance ‹= 64) { while (player_mt_x != ghost_mt_x) // Расчет смещения метатайла:metatile = current_map[offset]; } } if (playerDetected) Ghost.status = GHOST_ATTACK;

Как видите, ничего сложного.

Однако мы не думали о том, как быстро сработает такая математика. Измерим скорость этих вычислений.

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

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

Можем ли мы ускорить эти вычисления? Да мы можем!

Плотно консервированное мясо

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

беззнаковый символ cacheMap[30];

Один байт содержит 8 бит, поэтому сгруппируем 8 метатайлов в один байт. Если метатайл сплошной, мы устанавливаем соответствующий бит в 1. Если метатайл не является сплошным, мы оставляем здесь 0.

Первые 8 метатайлов в нашей карте пусты, поэтому это 8 нулевых битов подряд. Если мы преобразуем его в десятичный формат, то получим 0. Сохраним этот байт в кеш:

cacheMap[0] = 0;

Переходим к следующему байту.

Здесь тоже 0, как ни странно, так что

cacheMap[1] = 0;

Следующие 7 строк (или 14 байт) также будут нулевыми.

cacheMap[2] = 0; cacheMap[3] = 0; cacheMap[4] = 0; cacheMap[5] = 0; cacheMap[6] = 0; cacheMap[7] = 0; cacheMap[8] = 0; cacheMap[9] = 0; cacheMap[10] = 0; cacheMap[11] = 0; cacheMap[12] = 0; cacheMap[13] = 0; cacheMap[14] = 0; cacheMap[15] = 0;

А вот в восьмом ряду нашей карты есть платформа.

0000 1110 бин = 14 дес, поэтому

cacheMap[16] = 14;

В конце концов мы дойдем до нижнего ряда:

1111 1111 бин = 255 дес.

cacheMap[28] = 255; cacheMap[29] = 255;

Кажется, наш кеш готов.

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

unsigned char cacheMap[30]; const unsigned char cacheMask[8] = { 128, 64, 32, 16, 8, 4, 2, 1 }; void makeLevelCache (void) { unsigned char cachePos = 0; unsigned char cacheByte = 0; unsigned char cacheBit = 0; unsigned char metatile = 0; cacheMap[cachePos] = cacheByte; metatile = current_map[i]; cacheBit = cacheMask[i & 7]; } }

Давайте двигаться дальше.

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

Чтобы проверить наличие препятствия, нам нужно немного поработать с первым байтом кеша, который равен 1 (0000 0001 двоичный).

Нам нужно сохранить только те биты, которые находятся справа от игрока, а остальные биты мы сбросим. Применим для этого булеву алгебру:

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

1000 0000 И 1100 0000 = 1000 0000 (128)

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

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

И второй байт:

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

Теперь попробуем написать код. Этот код будет полноценно работать только в том частном случае, когда игрок и противник находятся в разных половинах экрана. Но этого будет достаточно, чтобы узнать, как это работает.

Сначала подготовим маски для левой и правой сторон экрана:

// leftMask — массив 0..127, содержащий маски левой стороныconst unsigned char cacheLeftMask[] = { 127,127,127,127,127,127,127,127,127,127,127,127,127,127,127,127, 63,63 ,63,63,63,63,63,63,63,63,63,63,63,63,63,63,

31,31,31,31,31,31,31, 31,31,31,31,31,31,31,31,31,

15,15,15,15,15,15,15,15,15,15,15,15,15,15,15,15, 7,7,7,7, 7,7,7,7,7,7,7,7,7,7,7,7, 3,3,3,3,3,3,3,3,3,3 ,3,3,3,3,3,3, 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1, 1, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 }; // rightMask — массив 0..127, содержащий маски правой стороныconst unsigned char cacheRightMask[] = { 0,0,0,0,0 ,0,0,0,0,0,0,0,0,0,0,0, 128,128,128,128,128,128,128,128,128,128,128,128,128,128,128,128,

192,192,192,192,192,192,192,192,192,192, 192,192,192,192,192,192,

224,224,224,224,224,224,224,224,224,224, 224,224,224,224,224,224,

240,240,240,240,240,240,240,240,240,240,240,240,240,240,240,240, 248,248,248,248,248,248,248,248,248,248,248,248,248,248,248,248, 252,252,252,252,252,252,252,252,252,252,252,252,252,252,252,252, 254,254,254,254,254,254,254,254,254,254,254,254,254,254,254,254 };

А теперь напишем подпрограмму:

unsigned char checkDistance (unsigned char maxDist) { unsigned char Distance, cachePos, playerResult, вражеский Результат; const unsigned char* maskTable; distance = playerX — вражеский X; расстояние = вражеский X — playerX; if (distance ‹= maxDist) { // расчет смещение кэшаcachePos = (playerY ›› 3) & 0xfe; maskTable = cacheRightMask; maskTable = cacheLeftMask; cachePos = (enemyY ›› 3) & 0xfe; maskTable = cacheRightMask; maskTable = cacheLeftMask; enemyResult = !(cacheMap[cachePos] & maskTable[ вражеский X & 0x7f]); возврат (playerResult | вражеский результат); } }

Все еще не понял? Все в порядке, давайте просто резюмируем:

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

Ладно, составим.

Работа выполнена! Теперь мы должны выполнить тест еще раз.

Очевидно, что этот способ гораздо быстрее, чем перебирать каждый метатайл между противником и игроком. Конечно, мы должны потратить 256 байт, чтобы сохранить эти две таблицы в PRG-ROM. Но согласитесь, оно того стоит. Наше мясо упаковано в тележку UoROM с 256KiB ROM, так что я могу себе это позволить. К тому же размер этих таблиц можно сильно уменьшить, но это добавит еще немного вычислений. Я выбираю скорость. Решайте сами, каким должен быть ваш баланс.

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

Не выходите вперед и проведите хорошую сессию ретро-кодирования!

Хотите больше советов прямо от ретро-профессионалов? Добро пожаловать в наш Дискорд!

Первоначально опубликовано на https://megacatstudios.com.