Привет, XYZ. Меня зовут Коля и я хочу рассказать тебе о проблеме, с который ты можешь столкнуться при разработке ритм-игры (и с которой точно столкнешься, если разработка будет под мобильные устройства). Ну и о её решении тоже поведаю, конечно.
В марте 2019 я выпустил в Steam свою первую ритм-игру Lofi Ping Pong. Это настольный теннис, в котором мяч надо отбивать в такт треку. Летом 2020 мне захотелось отдохнуть от разработки второй музыкальной поделки, так что решил портировать Пинг понг на мобилки и Switch. Тут-то и появились сложности — мяч вдруг начал лететь отрывисто, «заикаться». Чтобы вкратце разобраться с недугом, требуется вступление.
Геймплей игры
Не буду рассказывать про архитектуру ритм-игр в целом (собираюсь сделать это после выпуска второго проекта), но пояснить за основную идею я обязан.
В большинстве игр обязательно есть какое-либо движение — будь то носящийся по всей карте Meat Boy или падающий с потолка ящик в Portal. Любое перемещение становится плавным, когда мы добавляем один волшебный ингредиент — Delta Time, то есть время между фреймами. С помощью него, как известно, мы перестаем зависеть от выдаваемого фпс, что делает, к примеру, скорость персонажа всегда одинаковой.
В общем delta time — ван лав
И тут тебе приходит сумасшедшая мысль сделать игру, в которой что-то происходит в такт музыке. Ты набросал на бумаге идею, лезешь в любимый движок, чтобы воплощать её в жизнь. К примеру, захотелось сделать настольный теннис, в котором мяч отбиваешь в такт треку, как метроном. Что может быть проще?
У нас есть расстояние между началом и концом полета мяча S. Чтобы рассчитать время полёта T, требуется знать скорость песни — её BPM (beats per minute). Это количество ударов (долей) в минуту, как если бы вы отстукивали темп песни ладошкой по коленке и записали количество шлепков за 60 секунд. Количество чего-либо в единицу времени есть частота, значит, чтобы найти время одного удара (период), достаточно перевернуть её с ног на голову, не забыв перевести в секунды, умножив на 60 (T = 60 / BPM).
В школе вроде учили, что скорость V = S / T. Не забудем добавить в формулу наш любимый delta time, и готово!
Как только мяч долетает до нужной позиции, мы нажимаем кнопку, меняя end point и start point местам, и движение продолжается, но в обратную сторону. И всё идёт прекрасно, пока ты так не поиграешь 10, 30, 60 секунд. После этого начнётся сильнейший рассинхрон между играющей песней и скачущим мячом — каждый новый удар будет всё дальше удаляться от реального бита песни.
Проблема в том, что ты не следишь за настоящей позицией трека. Да, мы включили в формулу его скорость, но этого недостаточно. Правильным методом окажется перемещение мяча с помощью интерполяции по положению музыки (точнее даже будет сказать «экстраполяции»).
Позиция трека. Все переменные будут ниже в коде
Тебе надо следить за позицией трека и считать, как много времени прошло с предыдущего бита. Время между битами — как расстояние между start point и end point. Delta between beats будет отображать позицию нашего мяча. Но это довольно странно ставить знак равно между временем (delta between beats) и координатой (позиция мяча). Понятнее будет перевести всё в доли- поделив delta between beats на time between beats мы получим процент между соседними битами. Этот процент будет таким же у мяча между начальной и конечной позицией.
Аналогия в игре
Весь этот блок был написан, чтобы показать зачем и как использовать в качестве двигателя мяча именно сам трек, а не просто его скоростную характеристику. Это и есть то самое ядро, на котором строится ритм игра. Помимо этого, как и в других играх, есть куча нюансов, типа начального оффсета у песни или как учесть визуальный/аудио лаг у игрока в перемещении мяча. Самое главное, мы поняли, что Delta Time нам не нужен.
Разобравшись с главным концептом, ты делаешь основную игровую петлю, тестируешь на ПК, все идёт прекрасно. До того момента, как ты решишь запустить игру на мобильном устройстве.
Тут наступает ужасное — мяч летит отрывисто, заикается, как будто игра идёт в 15 фпс. Ты профайлишь игру, но все показатели в норме, да и остальные элементы игры, не зависящие от хода музыки, ведут себя адекватно. Может, мы рано решили избавиться от Delta time?
Ты начинаешь дебажить позицию песни каждый кадр — и что же ты видишь! Оказывается, позиция трека не обновляется покадрово, а скачет, как ей вздумается! Вместо того, чтобы в окне дебага видеть «0, 16, 33, 49, 65, 80…» (мс), показывается вот это «0, 0, 0, 48, 48, 65, 65, 65…». Аудиодвижок просто-напросто живёт своей жизнью и отказывается подчиняться обновлению каждый кадр (те кто работают в Гамаке знают, что если во время теста игры она у вас крашнется, то аудио продолжит работать в отрыве от картинки).
ПК, как известно, платформа помощнее, чем мобильные устройства, и эти фризы там не так заметны (хотя они есть, если знаешь, с чем сравнивать).
Что ж, значит нам придётся вручную «догонять» позицию трека, чтобы она плавно переходила от одного значения к следующему. Плавно… где-то я это слышал… delta time! Почему бы здесь нам не использовать нашего старого друга, ведь всё же мы будем увеличивать позицию искусственным путём.
Но есть куча неправильных и один правильный метод, как это сделать. Оба метода я попробовал на уже выпущенной игре, так что смогу показать примеры работоспособности прямо от игроков.
Пример первый, неправильный. Давайте введём новую переменную для отслеживания позиции трека в предыдущем фрейма lastFrameTrackPosition. Тогда мы каждый кадр можем сравнивать нынешнюю позицию песни и её позицию на предыдущем кадре. Если они совпадают, значит положение песни «не прибавилось», и мы сделаем это сами. Если позиция трека так долго не обновлялась, что lastFrameTrackPositon убежала вперед, то мы сами её увеличим.
Этот метод будет работать уже лучше, но всё ещё не идеально, а самое главное — будут случаться непредвиденные действия со стороны мяча. Например, он может развить огромную скорость и улететь за пределы уровня.
Можно вновь обвинить Delta time и сказать, что дело в нём, но это не так. Точнее, мы просто слегка неправильно его используем.
Давайте оставим переменную lastFrameTrackPosition и введём ещё одну — trackPositionContainer, которая поможет нам не изменять позицию трека напрямую через прибавку delta time, но с помощью постепенного приближения (известного как easing). Мы опять начнём со сравнения положения песни в текущий и предыдущий кадр. Делаем только одно сравнение — не равны ли они, и если они и правда отличаются, то мы приблизим значение trackPositionContainer к позиции трека с помощью среднего арифметического. И возвращать в качестве позиции песни для MoveBall() мы будем именно приблИженное значение контейнера, но не самой рваной позиции трека.
Lofi Ping Pong // Фиксы
Сравнение с фиксами и без. На видео разницы между 1м и 2м почти не видно, там дело больше в багах с вылетом мяча.
Теперь, наконец, гештальт закрыт. Я перерыл старый код, заново переписал игру, сделал порты на мобилки и сегодня выходит последний порт на Nintendo Switch. Надеюсь, было полезно и хоть немного интересно, ребятки. Оставлю ссылки на всевозможные сторы, если захотите посмотреть проект.
Lofi Ping Pong
https://www.nintendo.ru/-/-Nintendo-Switch/Lofi-Ping-Pong-1874054.html
Lofi Ping Pong
https://apps.apple.com/app/id1539408060
Lofi Ping Pong — Apps on Google Play
https://play.google.com/store/apps/details?id=com.Calvares.LoFiPingPong
Lofi Ping Pong on Steam
https://store.steampowered.com/app/1028570/Lofi_Ping_Pong/