Заикание в ритм-играх - симптомы и лечение на примере Lofi Ping Pong
Привет, Gamin. Меня зовут Коля и я хочу рассказать тебе о проблеме, с который ты можешь столкнуться при разработке ритм-игры (и с которой точно столкнешься, если разработка будет под мобильные устройства). Ну и о её решении тоже поведаю, конечно.
В марте 2019 я выпустил в Steam свою первую ритм-игру Lofi Ping Pong. Это настольный теннис, в котором мяч надо отбивать в такт треку. Летом 2020 мне захотелось отдохнуть от разработки второй музыкальной поделки, так что решил портировать Пинг понг на мобилки и Switch. Тут-то и появились сложности — мяч вдруг начал лететь отрывисто, «заикаться». Чтобы вкратце разобраться с недугом, требуется вступление.
Особенность музыкальных игр
Не буду рассказывать про архитектуру ритм-игр в целом (собираюсь сделать это после выпуска второго проекта), но пояснить за основную идею я обязан.
В большинстве игр обязательно есть какое-либо движение — будь то носящийся по всей карте Meat Boy или падающий с потолка ящик в Portal. Любое перемещение становится плавным, когда мы добавляем один волшебный ингредиент — Delta Time, то есть время между фреймами. С помощью него, как известно, мы перестаем зависеть от выдаваемого фпс, что делает, к примеру, скорость персонажа всегда одинаковой.
И тут тебе приходит сумасшедшая мысль сделать игру, в которой что-то происходит в такт музыке. Ты набросал на бумаге идею, лезешь в любимый движок, чтобы воплощать её в жизнь. К примеру, захотелось сделать настольный теннис, в котором мяч отбиваешь в такт треку, как метроном. Что может быть проще?
У нас есть расстояние между началом и концом полета мяча S. Чтобы рассчитать время полёта T, требуется знать скорость песни — её BPM (beats per minute). Это количество ударов (долей) в минуту, как если бы вы отстукивали темп песни ладошкой по коленке и записали количество шлепков за 60 секунд. Количество чего-либо в единицу времени есть частота, значит, чтобы найти время одного удара (период), достаточно перевернуть её с ног на голову, не забыв перевести в секунды, умножив на 60 (T = 60 / BPM).
В школе вроде учили, что скорость V = S / T. Не забудем добавить в формулу наш любимый delta time, и готово!
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 нам не нужен.
Проявление заиканий и решение
Разобравшись с главным концептом, ты делаешь основную игровую петлю, тестируешь на ПК, все идёт прекрасно. До того момента, как ты решишь запустить игру на мобильном устройстве.
Тут наступает ужасное — мяч летит отрывисто, заикается, как будто игра идёт в 15 фпс. Ты профайлишь игру, но все показатели в норме, да и остальные элементы игры, не зависящие от хода музыки, ведут себя адекватно. Может, мы рано решили избавиться от Delta time?
Ты начинаешь дебажить позицию песни каждый кадр — и что же ты видишь! Оказывается, позиция трека не обновляется покадрово, а скачет, как ей вздумается! Вместо того, чтобы в окне дебага видеть «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;
}
Сравнение с фиксами и без: https://youtu.be/FImUjpqzqmk
На видео разницы между 1 м и 2 м почти не видно, там дело больше в багах с вылетом мяча.
Теперь, наконец, гештальт закрыт. Я перерыл старый код, заново переписал игру, сделал порты на мобилки и сегодня выходит последний порт на Nintendo Switch. Надеюсь, было полезно и хоть немного интересно, ребятки. Оставлю ссылки на всевозможные сторы, если захотите посмотреть. Хоть выдохнуть могу!
- 09 декабря 2020, 01:12
- 015
А не проще один раз пройтись по сырому аудио-файлу, составить массив временных отрезков, через который надо отбивать мяч и ориентироваться только по нему (а не по звуковому файлу), используя deltaTime? Чтобы не зависеть от реализации воспроизведения музыки на разных устройствах.
Или во время игры получается так что трек воспроизводится каждый раз с разной, плавающей, скоростью?
Если ты имеешь в виду опираться на системный таймер, а не на аудиодвижок, то ты прав. Если нет, то проблема была не в том.
Да, самому засечь время между нужными точками и самому считать таймер, а не полагаться на текущее время аудиодорожки.
невыполнимо для подгружаемых треков. куча ненужной ручной работы для заготовленных. особенно с учетом того, что темп трека часто намеренно плывет
можно продолжать анализировать звуковую дорожку во время прогрузки и дополнять массив таймеров прямо во время игры
Можно и таймер ускорять/замедлять.