Разработка ретро-игр — это всегда танец балансировки функций, скорости обработки и места на картридже. Неаккуратный код или непродуманный дизайн могут привести к неаккуратной и медленной игре. Однако продуманный дизайн и искусный код могут создать игру с прекрасной хореографией. Программирование поведения Прожорливых Призраков на Трапезе — пример одного из таких танцев развития и дизайна.
Самое голодное мясо
Если вы играли в платформеры, то наверняка заметили, что почти все игры этого жанра имеют одну общую черту: враги. Враги меняются от игры к игре, но все они имеют возможность напасть на нас в самый неудобный момент, создавая дополнительные игровые сложности.
Некоторые враги довольно глупы, как эти солдаты 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.