[Перевод] Создание ритм-игры в Unity
Итак, вы хотите или пытались создать ритм-игру, но игровые элементы и музыка быстро рассинхронизировались, и теперь вы не знаете, что делать. Эта статья вам в этом поможет. Я играл в ритм-игры со старшей школы и часто зависал на DDR в местном зале аркадных автоматов. Сегодня я всегда ищу новые игры этого жанра, и такие проекты, как Crypt of the Necrodancer или Bit.Trip.Runner, показывают, что в этом жанре можно сделать ещё многое. Я немного работал над прототипами ритм-игр в Unity, и в результате потратил месяц на создание короткой ритм-игры/головоломки Atomic Beats. В этой статье я расскажу о самых полезных техниках создания кода, которым я научился при создании этих игр. Информацию о них я не смог нигде больше найти, или она была изложена не так подробно.
Во-первых, я должен выразить огромную признательность Ю Чао за пост Music Syncing in Rhythm Games [перевод на Хабре]. Ю рассмотрел основы синхронизации таймингов аудио с игровым движком в Unity и выложил исходный код своей игры Boots-Cut, что очень помогло мне в создании своего проекта. Вы можете изучить его пост, если хотите узнать краткое введение в синхронизацию музыки в Unity, но я рассмотрю эту тему подробнее и обширнее. В моём коде активно используется и информация из статьи, и код Boots-Cut.
В основе любой ритм-игры лежат тайминги. Люди чрезвычайно чувствительны к любым искажениям в таймингах ритмов, поэтому очень важно, чтобы все действия, движения и ввод в ритм-игре были непосредственно синхронизированы с музыкой. К сожалению, традиционные методы отслеживания времени в Unity наподобие Time.timeSinceLevelLoad и Time.time быстро терят синхронизацию с воспроизводимым звуком. Поэтому мы будем получать доступ напрямую к аудиосистеме с помощью AudioSettings.dspTime, в котором используется истинное количество аудиосэмплов, обработанных аудиосистемой. Благодаря этому он всегда сохраняет синхронизацию с воспроизводимой музыкой (возможно, это не так в случае очень длинных аудиофайлов, когда в действие вступают эффекты сэмплирования, но в случае композиций обычной длины система должна работать идеально). Эта функция будет ядром нашего отслеживания времени композиции, и на её основе мы создадим главный класс.
Класс Conductor — это основной класс управления композициями, на основании которого будет строиться остальная часть ритм-игры. С помощью него мы будем отслеживать позицию композиции и управлять всеми другими синхронизированными действиями. Для отслеживания композиции нам понадобится несколько переменных
//Song beats per minute
//This is determined by the song you're trying to sync up to
public float songBpm;
//The number of seconds for each song beat
public float secPerBeat;
//Current song position, in seconds
public float songPosition;
//Current song position, in beats
public float songPositionInBeats;
//How many seconds have passed since the song started
public float dspSongTime;
//an AudioSource attached to this GameObject that will play the music.
public AudioSource musicSource;
При запуске сцены нам нужно выполнить вычисления для определения переменных, а также записать для справки время запуска композиции.
void Start()
{
//Load the AudioSource attached to the Conductor GameObject
musicSource = GetComponent();
//Calculate the number of seconds in each beat
secPerBeat = 60f / songBpm;
//Record the time when the music starts
dspSongTime = (float)AudioSettings.dspTime;
//Start the music
musicSource.Play();
}
Если создать пустой GameObject с прикреплённым к нему таким скриптом, а затем добавите Audio Source с композицией и запустите программу, то увидите, что скрипт зафиксирует время начала композиции, но больше ничего не произойдёт. Также нам понадобится вручную ввести BPM музыки, которую мы добавляем к Audio Source.
Благодаря всем этим значениям мы сможем отслеживать позицию в композиции в реальном времени при обновлении игры. Мы определим тайминг композиции, сначала в секундах, затем в долях. Доли — это значительно более удобный способ отслеживания композиции, потому что они позволяют нам добавлять действия и тайминги во времени параллельно с композицией, допустим, в долях 1, 3 и 5.5, без необходимости вычисления секунд между долями. Добавим следующие вычисления в функцию Update () класса Conductor:
void Update()
{
//determine how many seconds since the song started
songPosition = (float)(AudioSettings.dspTime - dspSongTime);
//determine how many beats since the song started
songPositionInBeats = songPosition / secPerBeat;
}
Так мы получаем разность между текущим временем по данным аудиосистемы и временем запуска композиции, что даёт общее количество секунд, которое воспроизводится композиция. Мы сохраним его в переменную songPosition.
Учтите, что счёт в музыке обычно начинается с единицы с долями 1–2–3–4 и так далее, а songPositionInBeats начинается с 0 и увеличивается с этого значения, поэтому третья доля композиции будет соответствовать songPositionInBeats, равной 2.0, а не 3.0.
На этом этапе, если вы хотите создать традиционную игру в стиле Dance Dance Revolution, то вам нужно создавать ноты в соответствии с долей, в которую их нужно нажать, интерполировать их позицию относительно линии нажатия, а затем записать songPositionInBeats, когда будет нажата клавиша, и сравнить значение с нужной долей ноты. Ю Чао рассматривает пример такой схемы в своей статье. Чтобы не повторяться, я рассмотрю другие потенциально полезные техники, которые можно надстроить поверх класса Conductor. Я использовал их при создании Atomic Beats.
Если вы создаёте собственную музыку для ритм-игры, то легко сделать так, чтобы первая доля точно совпадала с началом музыки, что при правильно указанном BPM надёжно привяжет значение songPositionInBeats класса Conductor к композиции.
Однако если вы используете готовую музыку, то есть большая вероятность того, что перед началом композиции есть небольшая пауза. Если этого не учесть, то songPositionInBeats класса Conductor будет думать, что первая доля началась при начале воспроизведения композиции, а не в настоящий момент доли. Всё, что будет в дальнейшем привязано к значениям долей, не синхронизируется с музыкой.
Чтобы это исправить, можно добавить переменную, учитывающую это смещение. Добавим в класс Conductor следующее:
//The offset to the first beat of the song in seconds
public float firstBeatOffset;
В Update () переменная songPosition:
songPosition = (float)(AudioSettings.dspTime - dspSongTime);
заменяется на:
songPosition = (float)(AudioSettings.dspTime - dspSongTime - firstBeatOffset);
Теперь songPosition будет правильно вычислять позицию в песне с учётом истинной первой доли. Однако вам придётся вручную вводить смещение до первой доли, потому для каждого файла оно будет уникальным. Кроме того, во время этого смещения будет короткое окно, в котором songPosition окажется отрицательным. Это может и не влиять на игру, но какой-нибудь код, зависящий от значений songPosition или songPositionInBeats, возможно, не сможет в это время обрабатывать отрицательные числа.
Если вы работаете с композицией, которая проигрывается от начала до конца, то для отслеживания позиции будет достаточно показанного выше класса Conductor. Но если у вас короткая дорожка, которая зациклена, и вы хотите работать с этим лупом, то необходимо встроить в Conductor поддержку повторов.
Если у вас есть идеально зацикленный фрагмент (например, если темп композиции 120bpm, а зацикливаемый фрагмент имеет длину 4 доли, то он должен быть длиной ровно 8.0 секунды при 2.0 секунды на долю), загруженный в Audio Source класса Conductor, то поставьте флажок лупа. Conductor будет работать так же, как и раньше, и передавать в songPosition общее время после первого запуска клипа. Чтобы определить позицию лупа, нам нужно каким-то образом сообщить Conductor, сколько долей есть в одном лупе, и сколько лупов уже было воспроизведено. Добавим в класс Conductor следующие переменные:
//the number of beats in each loop
public float beatsPerLoop;
//the total number of loops completed since the looping clip first started
public int completedLoops = 0;
//The current position of the song within the loop in beats.
public float loopPositionInBeats;
Теперь при каждом обновлении SongPositionInBeats мы также можем обновлять Update () позицию лупа.
//calculate the loop position
if (songPositionInBeats >= (completedLoops + 1) * beatsPerLoop)
completedLoops++;
loopPositionInBeats = songPositionInBeats - completedLoops * beatsPerLoop;
Это даёт нам маркер, сообщающий с помощью loopPositionInBeats, сколько долей мы прошли в лупе, что пригодится для многих других синхронизируемых элементов. Не забудьте ввести в GameObject Conductor количество долей лупа.
Также нам следует внимательно отнестись здесь к подсчёту долей. Музыка всегда начинается с 1, поэтому 4-дольное измерение имеет вид 1–2–3–4-, а в нашем классе loopPositionInBeats начинается с 0.0 и зацикливается на 4.0. Поэтому точная середина лупа, которая при подсчёте музыкальных долей будет равна 3, в loopPositionInBeats будет иметь значение 2.0. Можно модифицировать loopPositionInBeats, чтобы учесть это, но это повлияет на все другие вычисления, поэтому будьте внимательны при вставке нот.
Также для оставшихся инструментов будет полезно добавить в класс Conductor ещё два аспекта. Во-первых, аналоговую версию LoopPositionInBeats под названием LoopPositionInAnalog, измеряющую позицию в лупе в интервале от 0 до 1.0. Второй — это экземпляр класса Conductor для удобного вызова из других классов. Добавим в класс Conductor следующие переменные:
//The current relative position of the song within the loop measured between 0 and 1.
public float loopPositionInAnalog;
//Conductor instance
public static Conductor instance;
В функцию Awake () добавим:
void Awake()
{
instance = this;
}
а в функцию Update () добавим:
loopPositionInAnalog = loopPositionInBeats / beatsPerLoop;
Было бы очень полезно синхронизировать с долями движение или поворот, чтобы элементы находились в нужных местах. В моей игре Atomic Beats я использовал это для динамического поворота нот вокруг центральной оси. Изначально они размещались по окружности в соответствии с их долей внутри лупа, а затем вся игровая область поворачивалась так, чтобы ноты сопоставлялись с линией нажатия в их долю.
Чтобы добиться этого, создадим новый скрипт под названием SyncedRotation, и прикрепим его к GameObject, который нужно вращать. В функцию Update () скрипта SyncedRotation добавим:
void Update()
{
this.gameObject.transform.rotation = Quaternion.Euler(0, 0, Mathf.Lerp(0, 360, Conductor.instance.loopPositionInAnalog));
}
Этот код будет интерполировать поворот GameObject, к которому привязана эта игра, в интервале от 0 до 360 градусов, поворачивая его так, чтобы он завершал один полный оборот в конце каждого лупа. Это полезно в качестве примера, но для циклического движения или покадровой анимации более полезной была бы синхронизация анимации лупов, чтобы они идеально совпадали с темпом.
Инструмент Unity Animator чрезвычайно мощный, но он не всегда точен. Для надёжного выравнивания анимаций и музыки мне пришлось побороться с классом Animator и его склонностью к постепенной рассинхронизации с темпом. Кроме того, было сложно подстроить одинаковые анимации под разные темпы, чтобы при переключении между композициями не приходилось переопределять ключевые кадры анимации под текущий темп. Вместо этого мы можем обращаться непосредственно к циклу анимации, и задавать позицию в этом цикле в соответствии с тем, где мы находимся в лупе класса Conductor.
Во-первых, создадим новый класс под названием SyncedAnimation, и добавим в него следующие переменные:
//The animator controller attached to this GameObject
public Animator animator;
//Records the animation state or animation that the Animator is currently in
public AnimatorStateInfo animatorStateInfo;
//Used to address the current state within the Animator using the Play() function
public int currentState;
Прикрепим его к новому или уже имеющемуся GameObject, который нужно анимировать. В этом примере мы просто будем двигать объект вперёд-назад по экрану, но такой же принцип можно применить к любой анимации, будь до настройка свойства, или покадровая анимация. Добавим к GameObject элемент Animator и создадим новый Animator Controller под названием SyncedAnimController, а также Animation Clip под названием BackAndForth. Загрузим контроллер в класс Animator, прикреплённый к GameObject, и добавим в дерево анимаций Animation в качестве анимации по умолчанию.
Для примера я настроил анимацию так, чтобы она сначала двигала объект вправо на 6 единиц, затем влево на -6, а потом обратно к 0.
Теперь для синхронизации анимации добавим в функцию Start () класса SyncedAnimation следующий код, инициализирующий информацию об Animator:
void Start()
{
//Load the animator attached to this object
animator = GetComponent();
//Get the info about the current animator state
animatorStateInfo = animator.GetCurrentAnimatorStateInfo(0);
//Convert the current state name to an integer hash for identification
currentState = animatorStateInfo.fullPathHash;
}
Затем добавим в Update () следующий код, чтобы задать анимацию:
void Update()
{
//Start playing the current animation from wherever the current conductor loop is
animator.Play(currentState, -1, (Conductor.instance.loopPositionInAnalog));
//Set the speed to 0 so it will only change frames when you next update it
animator.speed = 0;
}
Так мы позиционируем анимацию в точном кадре анимации относительно одного полного лупа. Например, если использовать представленную выше анимацию, то при нахождении посередине лупа, позиция GameObject как раз будет пересекать 0. Это можно применить к любой созданной вами анимации, которую вы хотите синхронизировать с темпом Conductor.
Стоит также заметить, что для создания бесшовного цикла из анимаций необходимо настроить касательные отдельных ключевых кадров анимации на кривой анимации. Настройка Linear создаст прямую линию, выходящую из одного ключевого кадра в следующий, а Constant будет сохранять анимацию в одном значении до следующего ключевого кадра, что даст дёрганое и резкое движение.
Хоть этот способ и полезен, он влияет на все переходы анимации, потому что заставляет animationState оставаться в том состоянии, в котором оно было при первоначальном запуске скрипта. Этот способ полезен для объектов, которым требуется только бесконечно использовать одну синхронизированную анимацию, но для создания более сложных объектов с разными синхронизированными анимациями необходимо добавить код, обрабатывающий эти переходы и задающий переменную currentState в соответствии с нужным состоянием анимации.
Это всего лишь некоторые аспекты, которые оказались полезными для меня при создании Atomic Beats. Часть из них собрана из других источников или создана из необходимости, но большинство я не смог найти в Интернете в готовом виде, поэтому надеюсь, это вам пригодится! Возможно, часть моей системы перестанет быть полезной в больших проектах из-за ограничений ЦП или аудиосистемы, но она будет хорошим фундаментом для игры на гейм-джем или хобби-проекта.
Создание ритм-игры, или игровых элементов, синхронизированных с музыкой, может быть сложной. Чтобы всё соответствовало единому темпу, может понадобиться хитрый код, результат, позволяющий играть с постоянным темпом, может оказаться очень привлекательным для игрока. В этом жанре можно сделать гораздо больше, чем игры в традиционном стиле Dance Dance Revolution, и я надеюсь, что эта статья поможет вам реализовать такие проекты. Рекомендую также при возможности оценить мою игру Atomic Beats. Я сделал её за один месяц весной этого года, в ней есть 8 коротких композиций и она бесплатна!