Как мы разрабатывали игры для xkcd
20 апреля xkcd опубликовал Escape Speed — четырнадцатый ежегодный комикс к Дню смеха, который мы разработали вместе. Escape Speed — это большая игра про исследование космоса, нарисованная Рэндалом Манро. Я писал код движка и редактора, а игровой логикой и обработкой ресурсов занимался davean. Карту игры редактировали Патрик Клэп, Эмбер, Кевин, Бенджамин Стаффин и Дженел Шейн.
Это был один из самых амбициозных (и самых запоздавших) комиксов к Дню смеха, выпущенных нами. Чтобы реализовать его, необходимо было добиться баланса графики, физики, сюжета, игровой логики и скорости рендеринга. Мы решили, что стоит потратить больше времени, чтобы сделать всё правильно.
Игра стала духовным наследником прошлогоднего комикса Gravity про исследование космоса. Наша цель заключалась в том, чтобы углубить игру, увеличив карту и добавив новые сложности с орбитальной механикой.
В статье мы расскажем несколько историй о разработке этих двух игр.
Сферические коровы
Существует старая физическая шутка об упрощении модели для облегчения расчётов [прим. пер.: в русскоязычной шутке обычно используется «сферический конь»]
В этом комиксе мы реализовали её буквально.
Когда в 2015 году мы делали Hoverboard, Рэндал принял умное решение, чтобы не рисовать анимацию цикла ходьбы: дал персонажу игрока ховерборд. Благодаря этому мы также избавились от кучи багов с коллизиями.
Работая над Gravity, я снова столкнулся с подобным затруднением. Вот цитата из нашего чата:
Упрощение требований почти всегда помогает. Я люблю, когда упрощение и понятнее, и легче реализовать. Силовой щит — это дешёвое решение, но оно работает!
Проектирование ориентации в пустом пространстве
Космос огромен и по большей части пуст. В отличие от движения на ховерборде, которое позволяет обнаруживать что-то новое, при произвольном перемещении в игре про космос игрок быстро потеряется:
Мы знали, что основной проблемой дизайна станет вопрос о том, как направлять игроков в геймплейно важные места. Рэндал предложил создать «компас», указывающий на ближайший ориентир. Эта идея эволюционировала в облако точек вокруг корабля с небольшими подсказками о расстоянии до объекта и его размере:
Ещё одна полезная механика заключалась во вращении камеры так, чтобы гравитация была направлена вниз. Изначально это было сделано для того, чтобы планеты вращались под игроком, когда он попадал на их орбиту (переворачивая текст в правильную сторону), однако мы также выяснили, что это становится полезной подсказкой, когда игрок к чему-то приближается. Фон из звёзд и «навигатор» из облака точек помогали придать этому вращению камеры естественность.
Затем мы добавили на карту знаки. Это дало игрокам понимание наличия важных для сюжета областей ещё до того, как они попадут в них.
Стеганография карты коллизий
При создании комикса с возможностью исследований всегда возникает одна проблема — карты коллизий. Мы должны были иметь возможность указывать, через какие области можно проходить, независимо от их внешнего вида, чтобы можно было создавать секретные ходы!
Обычно мы прятали данные о коллизиях в самом младшем бите (least significant bit, LSB) одного из цветовых каналов. Когда Рэндал рисует карту, он использует сплошные цвета (обычно красный) для обозначения специальных проходимых областей, а мы обновляем цвета в своих скриптах обработки изображений.
Такое решение мы использовали для Hoverboard и Gravity. В каждом кадре мы рендерим пространство в непосредственной близости от игрока во внеэкранный canvas и проверяем значения LSB для определения проходимости (например, чётное = сплошная область, нечётное = проходимая). Можно выполнить в консоли JS ze.goggles()
, чтобы увидеть эти скрытые canvas коллизий (с оверлеями отладки).
Это работает вполне неплохо, но нужно быть аккуратным с антиалиасингом. Так как при изменении размера в изображение добавляются интерполированные значения в градациях серого, это может вызвать появление в изображениях неожиданных значений LSB.
В Hoverboard используется хак: при проверках LSB учитываются только тёмные пиксели (значения канала < 100). В Gravity мы хотели использовать и светлые, и тёмные проходимые области, поэтому davean написал собственный алгоритм изменения размеров изображений, сохраняющий значения LSB.
Также мы начали использовать в изображениях прозрачность, чтобы создать несколько наложенных друг на друга слоёв с движущимся фоном из звёзд. Хотя теоретически всё выглядело прекрасно, добавление альфы вызвало множество проблем. Мы постоянно находили области с фантомными коллизиями, которых не было на исходных изображениях. Потратив кучу времени на монотонную отладку и изучение пикселей, я обнаружил, что canvas использует во вспомогательном хранилище premultiplied alpha. Это может приводить к тому, что значения в canvas не совпадают с тем, что было записано.
Мы воспользовались аварийным планом, предложенным davean: рендерили данные о коллизиях в LSB альфа-канала (сам по себе альфа-канал не premultiplied). Это решение имело и свои проблемы (например, при объединении нескольких слоёв в canvas коллизий их значения альфы суммируются), но работало достаточно хорошо для выпуска готовой игры.
Потрясающий трюк с SVG, который испортил Safari
Для Escape Speed мы хотели придумать более удобное решение с картами коллизий. При использовании альфа-канала постоянно возникала проблема того, что некоторые закодированные значения могли быть совсем немного прозрачными. Я надеялся обрабатывать данные изображений на стороне клиента, чтобы исправлять значения альфы (например, округлять 254 до 255), но на практике это оказалось неприемлемым с точки зрения производительности.
А что если мы сможем переопределить цветовые каналы при помощи CSS? Оказалось, что это возможно сделать при помощи фильтров SVG! Можно использовать матричное преобразование для сопоставления красного канала с яркостью, синего с альфой, а зелёный оставить для коллизий:
<filter id="bw" x="0" y="0" width="100%" height="100%">
<feColorMatrix
type="matrix"
values="1 0 0 0 0
1 0 0 0 0
1 0 0 0 0
0 0 1 0 0"
/>
</filter>
Вот как выглядят данные изображений при таком сопоставлении цветов:
Это было потрясающее решение. На ранних этапах разработки оно работало прекрасно. К сожалению, при дальнейшем тестировании выяснилось, что фильтры SVG недопустимо медленны в Safari.
И нам снова нужно что-то придумывать…
Это было 29 марта, и в тот момент мы уже хотели реализовать то, что скорее всего будет работать. Чёрт с ней, с изящностью. Мы перешли на решение, которого избегали всё это время: к отдельным изображениям карт коллизий. Это удвоило объём скачиваемых изображений, но всё оказалось не так плохо: монохромные изображения хорошо сжимаются, а благодаря HTTP/2 или QUIC это меньше влияет на производительность, чем в наших играх с тайлами начала 2010-х.
Неожиданная ниша для TypeScript
Сложность наших игр возрастала, то же самое происходило и с сопутствующими массивами данных:
В Hoverboard имелся массив позиций монет
В Gravity был блоб JSON, описывающий местоположения и размеры планет
В Escape Speed добавили карту на TypeScript и IDE:
Мы ещё на ранних этапах разработки понимали: чтобы обеспечить эргономичность редактирования и расширения игровой карты, нужен интерактивный редактор. Как ни странно, я выяснил, что для этого идеально подходит редактор TypeScript в стиле VSCode. Это следовало из пары решений, нацеленных на эффективность:
Мы знали, что нам нужна проверка lint или валидация карты, чтобы отлавливать ошибки до попадания в продакшен.
Наш конвейер обработки ресурсов выводит данные JSON со всеми названиями слоёв и размерностями. Мы можем использовать это в типах, чтобы обеспечить редакторам обратную связь по возможным опциям и отслеживание опечаток в реальном времени!
export type LocationName = keyof typeof imageData.locations export type LayerName<Name extends LocationName> = keyof (typeof imageData)['locations'][Name]['layers']
Простейший способ обеспечения удобного интерактивного редактирования — это добавление Monaco.
По сравнению с созданием валидатора в CI (медленная обратная связь при итерациях) или с визуальным редактором (огромный объём работы), TypeScript было легко освоить и достаточно просто с ним работать. Благодаря тому, что всё помещалось в веб-страницу, можно было крайне просто знакомить с системой новых сотрудников: им не приходилось клонировать репозиторий и устанавливать тулчейн, они одним щелчком получали доступ к удобному редактору кода.
В редакторе есть встроенный rollup
для компиляции файлов карт в браузере и prettier
для обеспечения неизменного стиля оформления кода. Меня очень удивило, что логичный путь привёл к созданию собственного IDE, и на самом деле уменьшил объём того, что нам пришлось разрабатывать!
Увеличивающиеся проблемы игрового движка: постоянные такты
Этот баг проявился так, как это происходит с самыми хитрыми багами: через наблюдение, казавшееся невозможным. Рэндал заметил, что у корабля почему-то тяга меньше, чем это было утром того же дня. Оказалось, что он тестировал игру в офисе, на экране своего графического планшета. Позже, при запуске десктопной версии, гравитация почему-то казалась неправильной. Многие люди проверили эту проблему, но не заметили разницы.
Рэндал недавно приобрёл M2 Macbook Pro с дисплеем, имеющим переменную частоту обновления 120 Гц. Ох, не может быть. Я протестировал баг на своём игровом мониторе и, разумеется, тоже его заметил.
Часть кода физики была взята ещё из Hoverboard, в которой такты игры синхронизировались с отрисовкой кадров. Чем больше кадров, тем быстрее такты физики. Однако накопив за восемь лет опыта, при работе над Gravity я дополнил этот код, чтобы учитывать дельту времени между кадрами. Теоретически, это компенсировало бы разницу во времени отрисовки кадров. Я изучил функцию тактов физики и устранил пару пропущенных ранее проблем, однако движение не улучшилось.
Я воспользовался глубокими знаниями davean по методикам программирования игр. Мы вместе изучили код в Discord. Ни один из нас не смог обнаружить проблем с формулами, пока мы не начали пошагово изучать состояние физики такт за тактом. В конце концов, мы выявили более глубокую проблему!
У корабля было специальное поведение при запуске из приземлённого состояния: он получает дополнительный прирост тяги. Хоть это и нереалистично, благодаря этому запуск казался более отзывчивым. Эта «скорость запуска» обеспечивала увеличение обычного ускорения в 2500 раз, но длилось это только один кадр. Даже с учётом дельты времени кадра интегратор физики был недостаточно точным, чтобы ускорение такой величины на один такт был одинаковым при 60 и 120 Гц.
Решение заключалось в переходе на постоянные такты. Вместо того, чтобы менять такты при обновлении кадров, мы используем постоянную частоту тактов физики в 120 Гц. При отрисовке кадров мы выполняем физические расчёты, чтобы они «догнали» текущее время с фиксированным шагом. Однако теперь, когда такты физики не синхронизированы с кадрами, мы вынуждены выполнять линейную интерполяцию позиции корабля, чтобы учесть кадр, происходящий между двумя тактами физики.
Вероятно, это обязательное требование для любого, кто сам писал игровой физический движок, но до недавнего времени нас вполне устраивало наивное решение. Никто не замечал колебаний в поведении, пока мы слегка не изменили поведение корабля, чтобы он смог взлететь с начальной планеты.
Одна из прекрасных особенностей возврата к старому коду заключается в демонстрации того, как изменилось твоё мышление. Изучая старый исходный код Hoverboard, я мог предугадать баги, которые упустил. Выделялись места, в которых я бы выбрал другие решения. Теперь я лучше понимаю эти проблемы, потому что у меня больше опыта. Возможно, когда-нибудь я так же взгляну и на Escape Speed.