Заикание в ритм-играх - симптомы и лечение на примере Lofi Ping Pong

Привет, инди-Дтф. Меня зовут Коля и я хочу рассказать тебе о проблеме, с который ты можешь столкнуться при разработке ритм-игры (и с которой точно столкнешься, если разработка будет под мобильные устройства). Ну и о её решении тоже поведаю, конечно.

Летом этого 2020го года я решил сделать порт своей ритм-игры Lofi Ping Pong на мобилки и Switch. Я писал о её результатах, которых она добилась за год существования в Steam в статье ниже.

Изначально игра была сделана на Gamemaker Studio 2, но для порта я пересобрал её на Unity (по многим причинам, но не потому что Гамак плохой). Поэтому куски кода, встречающиеся в статье, буду на C#, а не gml.

Геймплей игры

Особенность музыкальных игр

Не буду рассказывать про архитектуру ритм-игр в целом (собираюсь сделать это после выпуска второго проекта), но пояснить за основную идею я обязан.
В большинстве игр обязательно есть какое-либо движение - будь то носящийся по всей карте Meat Boy или падающий с потолка ящик в Portal. Любое перемещение становится плавным, когда мы добавляем один волшебный ингредиент - Delta Time, то есть время между фреймами. С помощью него, как известно, мы перестаем зависеть от выдаваемого фпс, что делает, к примеру, скорость персонажа всегда одинаковой.

В общем delta time - ван лав
В общем delta time - ван лав

И тут тебе приходит сумасшедшая мысль сделать игру, в которой что-то происходит в такт музыке. Ты набросал на бумаге идею, лезешь в любимый движок, чтобы воплощать её в жизнь. К примеру, захотелось сделать настольный теннис, в котором мяч отбиваешь в такт треку, как метроном. Что может быть проще?
У нас есть расстояние между началом и концом полета мяча S. Чтобы рассчитать время полёта T, требуется знать скорость песни - её BPM (beats per minute). Это количество ударов (долей) в минуту, как если бы вы отстукивали темп песни ладошкой по коленке и записали количество шлепков за 60 секунд. Количество чего-либо в единицу времени есть частота, значит, чтобы найти время одного удара (период), достаточно перевернуть её с ног на голову, не забыв перевести в секунды, умножив на 60 (T = 60 / BPM).
В школе вроде учили, что скорость V = S / T. Не забудем добавить в формулу наш любимый delta time, и готово!

Заикание в ритм-играх - симптомы и лечение на примере Lofi Ping Pong
void Start() { distance = endPoint - startPoint; } void MoveBall() { float timeBetweenBeats = 60 / bpm; Vector3 velocity = distance / timeBetweenBeats; transform.position += velocity * Time.deltaTime; }

Как только мяч долетает до нужной позиции, мы нажимаем кнопку, меняя end point и start point местам, и движение продолжается, но в обратную сторону. И всё идёт прекрасно, пока ты так не поиграешь 10, 30, 60 секунд. После этого начнётся сильнейший рассинхрон между играющей песней и скачущим мячом - каждый новый удар будет всё дальше удаляться от реального бита песни.
Проблема в том, что ты не следишь за настоящей позицией трека. Да, мы включили в формулу его скорость, но этого недостаточно. Правильным методом окажется перемещение мяча с помощью интерполяции по положению музыки (точнее даже будет сказать "экстраполяции").

Позиция трека. Все переменные будут ниже в коде
Позиция трека. Все переменные будут ниже в коде

Тебе надо следить за позицией трека и считать, как много времени прошло с предыдущего бита. Время между битами - как расстояние между start point и end point. Delta between beats будет отображать позицию нашего мяча. Но это довольно странно ставить знак равно между временем (delta between beats) и координатой (позиция мяча). Понятнее будет перевести всё в доли- поделив delta between beats на time between beats мы получим процент между соседними битами. Этот процент будет таким же у мяча между начальной и конечной позицией.

Аналогия в игре
Аналогия в игре
void MoveBall() { float trackPosition = audioSource.time; float timeBetweenBeats = 60 / bpm; float deltaBetweenBeats = trackPosition - timeBetweenBeats * lastBeat; float percent = deltaBetweenBeats / timeBetweenBeats; transform.position = startPoint + distance * percent; }

Весь этот блок был написан, чтобы показать зачем и как использовать в качестве двигателя мяча именно сам трек, а не просто его скоростную характеристику. Это и есть то самое ядро, на котором строится ритм игра. Помимо этого, как и в других играх, есть куча нюансов, типа начального оффсета у песни или как учесть визуальный/аудио лаг у игрока в перемещении мяча. Самое главное, мы поняли, что Delta Time нам не нужен.

Заикание в ритм-играх - симптомы и лечение на примере Lofi Ping Pong

Проявление заиканий и решение

Разобравшись с главным концептом, ты делаешь основную игровую петлю, тестируешь на ПК, все идёт прекрасно. До того момента, как ты решишь запустить игру на мобильном устройстве.
Тут наступает ужасное - мяч летит отрывисто, заикается, как будто игра идёт в 15 фпс. Ты профайлишь игру, но все показатели в норме, да и остальные элементы игры, не зависящие от хода музыки, ведут себя адекватно. Может, мы рано решили избавиться от Delta time?

Заикание в ритм-играх - симптомы и лечение на примере Lofi Ping Pong

Ты начинаешь дебажить позицию песни каждый кадр - и что же ты видишь! Оказывается, позиция трека не обновляется покадрово, а скачет, как ей вздумается! Вместо того, чтобы в окне дебага видеть "0, 16, 33, 49, 65, 80..." (мс), показывается вот это "0, 0, 0, 48, 48, 65, 65, 65...". Аудиодвижок просто-напросто живёт своей жизнью и отказывается подчиняться обновлению каждый кадр (те кто работают в Гамаке знают, что если во время теста игры она у вас крашнется, то аудио продолжит работать в отрыве от картинки).
ПК, как известно, платформа помощнее, чем мобильные устройства, и эти фризы там не так заметны (хотя они есть, если знаешь, с чем сравнивать).
Что ж, значит нам придётся вручную "догонять" позицию трека, чтобы она плавно переходила от одного значения к следующему. Плавно... где-то я это слышал... delta time! Почему бы здесь нам не использовать нашего старого друга, ведь всё же мы будем увеличивать позицию искусственным путём.
Но есть куча неправильных и один правильный метод, как это сделать. Оба метода я попробовал на уже выпущенной игре, так что смогу показать примеры работоспособности прямо от игроков.

Пример первый, неправильный. Давайте введём новую переменную для отслеживания позиции трека в предыдущем фрейма lastFrameTrackPosition. Тогда мы каждый кадр можем сравнивать нынешнюю позицию песни и её позицию на предыдущем кадре. Если они совпадают, значит положение песни "не прибавилось", и мы сделаем это сами. Если позиция трека так долго не обновлялась, что lastFrameTrackPositon убежала вперед, то мы сами её увеличим.

​private float FixTrackPosition() { float trackPosition = audioSource.time; if (trackPosition == lastFrameTrackPosition) { trackPosition += Time.deltaTime; } else if (trackPosition < lastFrameTrackPosition) { float delt = lastFrameTrackPosition - trackPosition; trackPosition = lastFrameTrackPosition + delt; } lastFrameTrackPosition = trackPosition; return trackPosition; } void MoveBall() { float trackPosition = FixTrackPosition(); float timeBetweenBeats = 60 / bpm; float deltaBetweenBeats = trackPosition - timeBetweenBeats * lastBeat; float percent = deltaBetweenBeats / timeBetweenBeats; transform.position = startPoint + distance * percent; }

Этот метод будет работать уже лучше, но всё ещё не идеально, а самое главное - будут случаться непредвиденные действия со стороны мяча. Например, он может развить огромную скорость и улететь за пределы уровня.
Можно вновь обвинить Delta time и сказать, что дело в нём, но это не так. Точнее, мы просто слегка неправильно его используем.
Давайте оставим переменную lastFrameTrackPosition и введём ещё одну - trackPositionContainer, которая поможет нам не изменять позицию трека напрямую через прибавку delta time, но с помощью постепенного приближения (известного как easing). Мы опять начнём со сравнения положения песни в текущий и предыдущий кадр. Делаем только одно сравнение - не равны ли они, и если они и правда отличаются, то мы приблизим значение trackPositionContainer к позиции трека с помощью среднего арифметического. И возвращать в качестве позиции песни для MoveBall() мы будем именно приблИженное значение контейнера, но не самой рваной позиции трека.

private float FixTrackPosition() { float trackPosition = audioSource.time; if (trackPosition != lastFrameTrackPosition) { trackPositionContainer = (trackPositionContainer + trackPosition) / 2f; lastFrameTrackPosition = trackPosition; } float trackPositionToReturn = trackPositionContainer; trackPositionContainer += Time.deltaTime; return trackPositionToReturn; } void MoveBall() { float trackPosition = FixTrackPosition(); float timeBetweenBeats = 60 / bpm; float deltaBetweenBeats = trackPosition - timeBetweenBeats * lastBeat; float percent = deltaBetweenBeats / timeBetweenBeats; transform.position = startPoint + distance * percent; }
Сравнение с фиксами и без. На видео разницы между 1м и 2м почти не видно, там дело больше в багах с вылетом мяча.

Теперь, наконец, гештальт закрыт. Я перерыл старый код, заново переписал игру, сделал порты на мобилки и сегодня выходит последний порт на Nintendo Switch. Надеюсь, было полезно и хоть немного интересно, ребятки.

5454
6 комментариев

Оу, а я как раз на днях купил (какой-то патриотический закуп у меня вышел случайно, оказвыается) потому что порадовало сочетание pong'а и lo fi в названии и трейлер заинтриговал, кому только на дтф не встретишь)

2
Ответить

Это наверное даже не в "Инди", а в "Геймдев" надо.

2
Ответить

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

1
Ответить

С удовольствием читал. Спасибо за интересную статью. Хороших вам продаж

1
Ответить

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

Ответить

Да, когда искал фиксы, натыкался как раз на сравнение с онлайном

Ответить