Ностальгия: роемся у «Танчиков» под капотом

в 4:56, , рубрики: game development, Игровые приставки, консольные игры, ностальгия, метки: ,

Ностальгия: роемся у «Танчиков» под капотомМногие из нас выросли на «Танчиках», «Марио» и прочих нетленных шедеврах времён рассвета игровой индустрии. Приятно порой вспомнить, как днями напролёт резались с друзьями у экранов телевизоров, меняя джойстики как перчатки. Но время не стоит на месте, и одни интересы сменяются другими. Однако, порой любовь к старым-добрым игрушкам не угасает.
Я отношу себя к людям именно таким, и мой интерес к старым играм пошёл в сторону реверс-инжиниринга, что и привело меня в IT-сферу, где я и осел с концами.

Я хочу рассказать вам о том, что же под капотом у железных монстров из знаменитой игры Battle City (в простонародье «Танчики») с не менее знаменитой приставки Nintendo Entertainment System (сокращённо NES, в России более известен её китайский клон «Dendy»). Мне в своё время эта информация показалась довольно любопытной — надеюсь, такой же она покажется и вам.

Предыстория

Несколько лет назад, изучая разнообразные графические библиотеки, я поставил перед собой цель написать точный клон «Танчиков». Так уж сложилось, что как раз в тот момент один мой знакомый занимался созданием довольно интересного ремейка путём модификации кода оригинальной игры, так что я решил проконсультироваться у него (кстати, огромное ему спасибо за консультацию и помощь в подготовке статьи). Тогда меня поразило то, какими методами реализовывался некоторый функционал. Вот и решил я, спустя годы, поделиться этими знаниями с общественностью: всё-таки это часть истории геймдева, которая может показаться интересной как ценителям классики, так и разработчикам современных игр.

Хочется сказать отдельное спасибо человеку под ником Griever за труды по декомпиляциии игры. Прежде всего благодаря полученным им исходникам стало возможно настолько подробно вникнуть в суть.

Карты уровней

Карты в Battle City состоят из тайлов — блоков размером 8x8 пикселей. Весь фон, видимый на экране, является картой — гипотетически даже со счётчиком жизней можно взаимодействовать, но по факту это, конечно же, не удастся.

Однако, в сериализованном виде уровни хранятся более компактно: для этого тайлы компонуются в блоки размером 2x2 тайла. Всего имеется 16 разновидностей блоков, которые приведены в таблице ниже. Хранится только основная часть карты (13x14 блоков), по которой и ездят танки — нет смысла сохранять статичные стены и вспомогательную информацию. Для записи одного блока используется 4 бита, таким образом, вся карта занимает 91 байт.

Блок Код Блок Код Блок Код Блок Код
Ностальгия: роемся у «Танчиков» под капотом 0 Ностальгия: роемся у «Танчиков» под капотом 1 Ностальгия: роемся у «Танчиков» под капотом 2 Ностальгия: роемся у «Танчиков» под капотом 3
Ностальгия: роемся у «Танчиков» под капотом 4 Ностальгия: роемся у «Танчиков» под капотом 5 Ностальгия: роемся у «Танчиков» под капотом 6 Ностальгия: роемся у «Танчиков» под капотом 7
Ностальгия: роемся у «Танчиков» под капотом 8 Ностальгия: роемся у «Танчиков» под капотом 9 Ностальгия: роемся у «Танчиков» под капотом A Ностальгия: роемся у «Танчиков» под капотом B
Ностальгия: роемся у «Танчиков» под капотом C Ностальгия: роемся у «Танчиков» под капотом D Ностальгия: роемся у «Танчиков» под капотом E Ностальгия: роемся у «Танчиков» под капотом F

В отличии от блоков, разновидностей самих тайлов гораздо больше, а именно — 256, т.е. ровно столько, сколько вмещает одна страница знакогенератора — участок видеопамяти, где каждому тайлу соответствует индекс от 0 до 255. Тайлы используются как для создания окружения уровня, так и для отображения информации о количестве вражеских танков, жизней и т.п. Но непосредственно в элементах уровня их используется всего 22 — из них 6 для формирования перечисленных блоков, остальные 15 — дополнительные тайлы кирпичных блоков, о них стоит рассказать подробнее.

Ностальгия: роемся у «Танчиков» под капотомЕсли карта состоит из тайлов размером 8x8 пикселов, то как же получается уничтожать кирпичные мини-блоки размером 4x4 пиксела? Дело в том, что на самом деле не существует «кирпичей» такого размера, вместо этого существует 16 видов обычных тайлов — по одному на каждое состояние. Т.е. при попадании по блоку размером 4x4, в реальности заменяется целый тайл, а сам кирпич переходит в другое состояния, в зависимости от того, куда попал снаряд.

Ностальгия: роемся у «Танчиков» под капотом

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

Расположение точек респауна и орла на карте фиксированы, объекты будут появляться на своих местах независимо от того, что находится в месте их появления. Это и происходит, если во встроенном редакторе уровней «замуровать» эти участки.

Если кому-то интересно позаниматься картостроительством, то для этих целей существует хороший редактор Quarrel, который работает прямиком с образом игры.

Генерация случайных чисел

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

Для компьютера с полностью детерминированным поведением генерация псевдослучайных чисел является ещё более сложной задачей. Например, при каждом запуске, оказывая одинаковое воздействие (нажимая одни и те же кнопки в одно и то же время), мы будем получать одинаковый ход игры — на этом принципе даже основаны записи игрового процесса, где для каждого кадра хранятся нажатые в тот момент кнопки. Но для нас это не столь критично: важно получать как можно более «случайные» значения в рамках одного сеанса.

Рассмотрим, как обстоят дела с Battle City. Здесь для генерации псевдослучайного значения используется несколько «случайных» сущностей — это предыдущее случайно значение, счётчик секунд и поочерёдно байты т.н. нулевой страницы — первых 256-ти байт оперативной памяти, которые процессор NES может адресовать быстрее и проще, чем остальные. Именно поэтому нулевая страница содержит наиболее часто используемые, и, как следствие, наиболее часто изменяемые переменные, что обеспечивает более равномерное распределение.

В итоге на конечное значение числа влияет множество факторов: нажатые игроком кнопки, координаты всех танков и пуль на экране, количество очков обоих игроков, состояние множества таймеров (таких как бонусные) и даже состояние звуковых каналов! Конечно, это далеко не полный список.

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

uint8 seed = 0;
uint8 index = 0;

uint8 rand()
{
	seed = (seed << 3) - seed + secondsCounter + zeroPage[++index];
	return seed;
}

Как видно из кода, мы можем в любой момент получить псевдослучайно значение в диапазоне от 0 до 255. При том его распределение достаточно адекватно, в чём можно убедиться по нетленному геймплею.

Бонусы

В игре есть ровно семь типов бонусов — каска, часы, лопата, звезда, граната, танк и пистолет. При этом в оригинальной игре последний не используется вовсе, зато его можно встретить в пиратских модификациях — там он делает мгновенный максимальный апгрейд, т.е. эквивалентен трём взятым звёздам (а в некоторых вариациях ещё и позволяет уничтожать «лес»). Действия же остальных бонусов, думаю, известны каждому. Но, на всякий случай, напомню: часы заставляют врагов застыть, лопата создаёт броню вокруг штаба, звезда увеличивает мощность танка, граната уничтожает всех противников на экране (при этом очки за них не начисляются), а танк увеличивает количество жизней.

Ностальгия: роемся у «Танчиков» под капотом

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

Судя по всему, изначально планировалось включить в игру восемь бонусов, но в итоге их осталось шесть. Однако, при появлении бонуса берётся случайное значение по модулю 8 (от 0 до 7), которое является индексом в таблице бонусов, где место не включенных в игру бонусов повторно заняли звезда и граната. В итоге вероятность их выпадения равна 1/4, в то время как вероятность выпадения остальных бонусов равна 1/8.

Бонусы часы и каска действуют 10 секунд (при респауне игрока каска действует 3 секунды, включая время на респаун), бонус лопата — 20 секунд.

Сами носители бонусов — мигающие танки — появляются, если у врага в ангарах остаётся 17, 10 или 3 танка. Т.е. танки под номерами 4, 11 и 18 — бонусные.

Стоит упомянуть, что секунда по меркам игры длится немного дольше обычной: у NTSC-версии консоли частота обновления экрана составляет 60 кадров в секунду, и самым простым способом вести счёт секунд является в каждом кадре увеличивать номер секунды, если счётчик кадров по модулю 60 равен нулю. Но для упрощения вычислений, а также, чтобы обнуление счётчика кадров в результате переполнения не влияло на подсчёт таким способом, это число округлили до 64 (0x40) — для взятия числа по этому модулю достаточно произвести логическое умножение на 0x3F. Получается, что игровая секунда составляет 1.06 реальной секунды, но это не касается временных отрезков, которые измеряются количеством кадров.

Занимательный факт, известный каждому знакомому с игрой: перед тем, как перестать действовать, защита вокруг штаба начинает «мигать», превращаясь то в кирпичное, то в бронированное ограждение (происходит это в последние 4 секунды действия бонуса). При этом, даже если защита вокруг штаба была частично или полностью уничтожена, каждый раз при мигании она восстанавливается, пока полностью не превратится обратно в целую кирпичную стену.

Интеллект противников

Конечно, AI в танчиках не представляет собой тактический анализатор, но кое-какая логика всё же высчитывается.

Начнём с того, что игра имеет динамическую сложность. В качестве показателя сложности используется значение задержки между респаунами врагов, которое зависит от уровня и количества игроков — чем больше эти значения, тем быстрее будет происходить респаун. Время респауна в кадрах для заданного уровня (нумерация с нуля) и количества игроков можно вычислить по следующей формуле: 190 - level * 4 - (player_count - 1) * 20. Чтобы получить время в секундах, надо просто умножить результат на 60.

Существует три периода поведения танков: сначала они движутся хаотично, затем они преследуют игроков, и, в конце концов, начинают атаковать штаб. Длительность первых двух периодов одинакова и равна восьми периодам респауна. Т.е., поделив время респауна на 8, мы получим длительность в секундах (или, умножив на 8, получим то же самое время в кадрах) — например, для первого уровня и одного игрока это будет 23 секунды. Третий же период будет длиться до тех пор, пока счётчик секунд не обнулится (достигнув 256), и цикл периодов не начнётся заново.

Вся эта вражеская тактика основана на системе команд, для чего выделено 8 байтов: два под танки игроков и шесть под вражеские танки. Четыре старших бита такого байта используются под команду, оставшиеся четыре младших — под её аргументы (как правило это направление движения). В итоге получается 16 команд, под каждую из которых существует обработчик. Обработчики вызываются раз в кадр для каждого танка после обработки движения.

Поверхностно рассмотрим существующие команды:

  • 0 — NOP (танк отсутствует)
  • 1..7 — обработать взрыв танка (по команде на кадр анимации)
  • 8 — обработать статус (скольжение по льду и т.п.)
  • 9 — изменить направление
  • A — проверить на пересечение границы тайла
  • B — двигаться к штабу
  • C — двигаться к танку второго игрока
  • D — двигаться к танку первого игрока
  • E..F — команды управления респауном

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

При пересечении вражеским танком границы тайла (когда обе коодринаты становятся кратны восьми), существует вероятность 1/16, что для танка будет вызвана эта функция. Если же координаты танка не были кратны восьми, либо же не была вызвана функция смены направления, и при всём этом танк упирается в препятствие, то с вероятностью 1/4 произойдёт следующее: танк сменит направление на противоположное, если хотя бы одна из координат не кратна восьми, в ином случае танку будет дана команда смены направления.

При выполнении команды смены направления происходит несколько другое: с вероятностью 1/2 вызывается описанная выше функция, иначе с равными вероятностями циклически берётся либо предыдущее, либо следующее направление из списка: вверх, влево, вниз, вправо (т.е. танк поворачивается либо по часовой, либо против часовой стрелки).

Можно представить всю эту логику в виде такого псевдокода:

void changeDirection(Tank tank)
{
	// period duration in seconds, respawn time in frames
	const int periodDuration = respawnTime / 8;
	
	if (time() < periodDuration)
	{
		// first period
		tank.setRandomDirection();
		tank.setCommand(cmdCheckTileReach);
	}
	else if (time() < periodDuration * 2)
	{
		// second period
		if ((firstPlayer.isAlive && tank.number % 2 == 0) || !secondPlayer.isAlive)
		{
			tank.setCommand(cmdMoveToFirstPlayer);
		}
		else
		{		
			tank.setCommand(cmdMoveToSecondPlayer);
		}
	}
	else
	{
		// third period
		tank.setCommand(cmdMoveToEagle);
	}
}

void onCheckTileReachCommand(Tank tank)
{
	if (!tank.isPlayer && tank.x % 8 == 0 && tank.y % 8 == 0 && rand() % 16 == 0)
	{
		changeDirection(tank);
	}
	else if (!tank.isPlayer && frontTile.isBarrier && rand() % 4 == 0)
	{
		 if (tank.x % 8 != 0 || tank.y % 8 != 0)
		 {
			  tank.invertDirection();
		 }
		 else
		 {
			  tank.setCommand(cmdChangeDirection);
		 }
	}
}

void onChangeDirectionCommand(Tank tank)
{
	if (rand() % 2 == 0)
	{
		 changeDirection(tank);
	}
	else if (rand() % 2 == 0)
	{
		 tank.rotateClockwise();
	}
	else
	{
		 tank.rotateAnticlockwise();
	}
}

Интересная ситуация получается, когда танк упирается в препятствие или стену — поскольку в таком случае, даже если танк не повернул, одна из координат постоянно кратна восьми и перед ним существует тайл-препятствие, то вероятность поворота возрастает в разы. Поэтому танки практически не застревают на месте: даже попадая в угол или нишу, они довольно быстро оттуда выезжают.

Не менее интересно обстоят дела с вражескими выстрелами. Сами по себе они происходят абсолютно обособленно от танков — у каждого врага есть одна пуля, а выстрел в каждом кадре сам решает, запустить эту пулю или нет. Если пуля уже в полёте, то выстрела не происходит, иначе существует вероятность 1/32, что танк выстрелит. В таком случае, пуля оказывается на передней границе танка по её центру и наследует направление своего танка.

Стоит сказать пару слов и о поведении танков игроков в режиме демонстрации. Тут всё просто: если на экране есть бонусы, танки стремятся к ним (при этом могут застрять по пути, если пуля пролетает мимо препятствия, в которое они упираются). Иначе они просто преследуют первый доступный танк, а если таковых не осталось — стоят на месте. Снаряды выпускаются по тем же правилам, что и у вражеских танков. Огонь по своим в демо-режиме не даёт эффекта.

Обработка коллизий

Расчёт коллизий всегда был довольно ресурсоёмкой задачей, что может быть критично для такого процессора, как у NES. Просчёт взаимодействия между танками с реализацией «в лоб» требовал бы сравнить каждую пару танков, что, несмотря на их небольшое количество, всё же довольно дорогостоящая процедура для процессора с тактовой частотой 1.76 МГц. Но не стоит забывать, что кроме танков по карте передвигаются и выпущенные снаряды. Поэтому разработчики пошли на довольно интересную хитрость.

Дело в том, что каждый танк… «рисует» под собой невидимую стену. Таким образом, обнаружение коллизии между танками происходит на этапе обнаружения коллизий между танками и элементами уровня, для чего достаточно лишь определить, имеется ли по определённой координате препятствие. Имеет это и побочные эффекты: наверняка многие замечали, что если пытаться «въехать» сзади в движущийся вперёд танк, движение игрока блокируется, пока танк не отъедет на некоторое расстояние — как раз в этот момент он переезжает на следующий тайл, «рисуя» там новую стенку и «стирая» старую.

Ностальгия: роемся у «Танчиков» под капотом

Тот же эффект можно заметить, когда в танк попадает снаряд. Визуально соприкосновение возникает на разной дистанции от границы спрайта танка (как внутрь, так и наружу) — от 0 до 7 пикселей. Но если танки рисуют под собой стену, то пули — нет. Более того, на расчёт коллизий между снарядами уходит большая часть времени кадра! И в особых случаях его может даже не хватить, тогда все эти операции перенесутся на следующий кадр, а текущий не будет изменён. Т.е. да, и в «Танчиках» бывают лаги.

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

Примечательно то, что снаряд имеет две точки, по которым проверяются коллизии. Они находятся по бокам от носа пули, и фактически может быть обработано две коллизии одновременно (иначе, после обнаружения хотя бы одной из них, снаряд исчезает). Это и происходит, если, например, целиться в центр кирпичного блока: уничтожается целая полоска.

Ещё одна интересная вещь: медленные пули (подробнее о скорости чуть ниже) обрабатываются не каждый кадр, а через кадр. Теоретически может возникнуть ситуация, когда игра будет тормозить в каждом нечётном кадре, и процессор не будет успевать дойти до обработки коллизий такой пули, откладывая её. А ведь в следующем кадре он и вовсе не должен будет её обрабатывать! Из-за подобного стечения обстоятельств медленные пули в некоторых чрезвычайных случаях могут пролетать стенки насквозь, когда игра сильно притормаживает.

Движение

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

То же самое касается и снарядов — их существует два типа: быстрые, летящие со скоростью 4 пикселя в кадр, и медленные, имеющие скорость 2 пикселя в кадр.

Танк Скорость, px/frame Тип снаряда Танк Скорость, px/frame Тип снаряда
Ностальгия: роемся у «Танчиков» под капотом 3/4 Медленный Ностальгия: роемся у «Танчиков» под капотом 2/4 Медленный
Ностальгия: роемся у «Танчиков» под капотом 3/4 Быстрый Ностальгия: роемся у «Танчиков» под капотом 4/4 Медленный
Ностальгия: роемся у «Танчиков» под капотом 3/4 Быстрый Ностальгия: роемся у «Танчиков» под капотом 2/4 Быстрый
Ностальгия: роемся у «Танчиков» под капотом 3/4 Быстрый Ностальгия: роемся у «Танчиков» под капотом 2/4 Медленный

Поскольку при движении необходимо обрабатывать и коллизии, то уместно было бы оптимизировать рассчёты, как можно более равномерно распределив их по кадрам. При самом простом подходе достаточно было бы в чётных кадрах обрабатывать перемещение всех врагов, а в нечётных только передвижение шустрого БТР. Но тогда нечётные кадры могли бы быть перегружены, и игра бы просто лагала.

На самом деле нагрузка распределена довольно хитро. Производится «XOR» между номером кадра и номером танка, и только потом проверяется чётность/нечётность результата. В итоге получается, что в чётных кадрах обрабатывается одна половина медленных танков, а в нечётных — другая.

И немного информации о скольжении на льду. Тут всё просто: после того, как игрок отпустит кнопку направления на льду, танк будет ехать сам ещё 28 кадров (т.е. проедет 21 пиксель) или пока не выедет со льда. В этот период состояние его гусениченого трака не меняется и звук езды не проигрывается, а если нажать кнопку направления уже на льду — будет проигран характерный звук, если таймер скольжения в этот момент равен нулю.

Пасхальные яйца

Пасхальные яйца встречаются во многих играх того времени, не обошли стороной они и Battle City.

Если на титульном экране выбрать режим CONSTRUCTION, войти и выйти из него 7 раз (нажимая START, START), затем зажать на первом джойстике кнопку «вниз», нажав на втором кнопку A 8 раз, потом зажать на первом джойстике «вправо», нажав на втором кнопку B 12 раз, и, наконец, нажать на первом джойстике кнопку START, то мы увидим трагическую историю любви.

Ностальгия: роемся у «Танчиков» под капотом

Кроме того, в игре можно встретить неиспользуемые данные — имена разработчиков (RYOUITI OOKUBO, TAKEFUMI HYOUDOU, JUNKO OZAWA) и иероглифы, означающие имя главного героя лавстори и название улицы (возможно, на которой жил он сам или его возлюбленная).

Ностальгия: роемся у «Танчиков» под капотом

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

Эпилог

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

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

Автор: horror_x

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js