Синхронизация ритма в музыкальных играх

в 6:13, , рубрики: rhythm game, unity3d, музыкальная игра, разработка игр, метки:

image

Недавно я начал работу в Unity над битбоксовой музыкальной игрой Boots-Cut. В процессе прототипирования базовых механик игры я обнаружил, что довольно сложно правильно синхронизировать ноты с музыкой. В Интернете по этой теме нашлось довольно мало статей. Поэтому в своей статье я постараюсь дать наиболее важные подсказки по разработке музыкальной игры (особенно в Unity).

Выяснилось, что самыми важными являются следующие три аспекта:

  • Использование AudioSettings.dspTime вместо Time.timeSinceLevelLoad для отслеживания позиции в песне.
  • Нужно всегда использовать позицию в песне для обновления движений.
  • Не обновляйте ноты в каждом кадре по разнице во времени, интерполируйте их.

Учтём это и приступим к работе!

Основной класс

Необходимо создать класс SongManager для отслеживания позиции в песне, создания нот и других функций управления песней.

Отслеживание позиции

Во всех музыкальных играх нужно отслеживать позицию в песне, чтобы знать, какую ноту следует создать. Ниже представлены поля, необходимые для отслеживания позиции в песне:

//текущая позиция в песне (в секундах)
float songPosition;

//текущая позиция в песне (в ударах)
float songPosInBeats;

//длительность удара
float secPerBeat;

//сколько времени (в секундах) прошло после начала песни
float dsptimesong;

Мы инициализируем эти поля в функции Start():

void Start()
{
    //вычисление количества секунд в одном ударе
    //объявление bpm выполняется ниже
    secPerBeat = 60f / bpm;
    
    //запись времени начала песни
    dsptimesong = (float) AudioSettings.dspTime;

    //начало песни
    GetComponent<AudioSource>().Play();
}

Для удобства мы преобразуем bpm в secPerBeat. Позже secPerBeat будет использоваться для вычисления позиции в песне в ударах, что очень важно для создания нот.

Кроме того, мы записываем время начала песни в dsptimesong. Мы используем AudioSettings.dspTime вместо Time.timeSinceLevelLoad, потому что Time.timeSinceLevelLoad обновляется только в каждом кадре, а AudioSettings.dspTime обновляется чаще, так как это таймер аудиосистемы. Чтобы сохранять темп песни, нужно использовать таймер аудиосистемы. Таким образом нам удастся избежать задержки, вызванной разницей во времени между обновлениями кадров и обновлениями аудио.

В функции Update() вычисляется позиция в песне с помощью AudioSettings.dspTime:

void Update()
{
    //вычисление позиции в секундах
    songPosition = (float) (AudioSettings.dspTime - dsptimesong);

    //вычисление позиции в ударах
    songPosInBeats = songPosition / secPerBeat;
}

Мы вычисляем позицию в секундах вычитанием из текущего AudioSettings.dspTime времени начала песни (dsptimesong). Мы получили позицию в секундах, однако в мире музыки ноты записываются в ударах. Поэтому лучше преобразовать позицию в секундах в позицию в ударах. Разделив songPosition на secPerBeat (секунда / (секунда/удар) ), мы получим позицию в ударах.

Посмотрите на рисунок:

Синхронизация ритма в музыкальных играх - 2

Позиция нот в ударах: 1, 2, 2.5, 3, 3.5, 4.5, а длительность удара — 0,5 с. Поэтому, если после начала песни прошло 1,75 с (songPosition == 1.75), то мы знаем, что находимся в позиции 1.75 (songPosition) / 0.5 (secPerBeat) = 3.5 удара, и необходимо создать ноту удара 3.5.

Информация о песне

Перейдём к полям, в которые мы записали информацию о песне:

//количество ударов в минуту
float bpm;

//сохранение всех позиций нот в ударах
float[] notes;

//индекс ноты, которую нужно создать следующей
int nextIndex = 0;

Для простоты я демонстрирую песню только с одной дорожкой нот (в Guitar Hero Mobile сделано три дорожки, а в Taikono Tatsujin — всего одна).

bpm — это количество ударов в минуту. Как мы видели, для удобства они преобразуются в secPerBeat.

notes — это массив, в котором хранятся все позиции нот в ударах. Например, для представленных на рисунке нот массив notes будет содержать {1f, 2f, 2.5f, 3f, 3.5f, 4.5f}:

Синхронизация ритма в музыкальных играх - 3

И, наконец, nextIndex — это целое число, нужное для обхода массива. Оно инициализируется со значением 0, потому что следующая создаваемая нота будет первой нотой песни. При создании ноты счётчик nextIndex увеличивается на единицу.

Создание нот

Мы определяем, должна ли создаваться нота, в функции Update(). Однако сначала нужно определить, сколько ударов будет показываться заранее.

Например, для следующей дорожки:

Синхронизация ритма в музыкальных играх - 4

текущая позиция в ударах равна 1, но удар 3 уже создан. Это означает, что заранее показываются 3 удара.

Добавим под songPosInBeats = songPosition / secPerBeat;, следующие строки:

if (nextIndex < notes.Length && notes[nextIndex] < songPosInBeats + beatsShownInAdvance)
{
    Instantiate( /* префаб ноты */ );

    //инициализация полей ноты

    nextIndex++;
}

Сначала нужно проверить, не осталось ли нот в песне (nextIndex < notes.Length). Если ноты ещё остались, то мы проверяем, достигла ли песня удара, при котором должна создаваться следующая нота (notes[nextIndex] < songPosInBeats + beatsShownInAdvance). Если достигла, создаём ноту и увеличиваем nextIndex, чтобы отслеживать следующую ноту, которую нужно создать.

Движение нот

Наконец, поговорим о том, как перемещать созданные ноты в соответствии с темпом песни. Это довольно просто, если вспомнить пункт «Не обновляйте ноты в каждом кадре по разнице во времени, интерполируйте их».

Всегда обновляйте движение по позиции в песне, потому что:

  1. Таймер аудиосистемы имеет разницу во времени с таймером кадров
  2. Удары могут находиться ровно посередине двух кадров (что приводит к разнице во времени)

Итак, как же двигать ноты? Интерполяцией!

Для упрощения я вырежу весь код в классе MusicNote и оставлю только функцию Update(), в которой мы двигаем каждую ноту:

//функция обновления нот
void Update()
{
    transform.position = Vector2.Lerp(
        SpawnPos,
        RemovePos,
        (BeatsShownInAdvance - (beatOfThisNote - songPosInBeats)) / BeatsShownInAdvance
    );    
}

На представленной ниже схеме это чётко видно:

Синхронизация ритма в музыкальных играх - 5

Заключение

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

Благодарю за прочтение статьи, надеюсь, она будет полезна. Моя собственная музыкальная игра Boots-Cuts будет готова в следующем году, следите за информацией.

Автор: PatientZero

Источник

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


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