Создаём музыкальную игру с библиотекой Oboe от Google — часть 2

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

Помещаем звуки хлопков в очередь

Теперь начинается самое интересное. Пора заняться механикой геймплея. Игра проигрывает последовательность хлопков в определённое время. Это называется паттерном хлопка.

Для этой простой игры паттерн хлопка представляет собой просто три хлопка, которые начинаются во время первой доли первого такта фоновой дорожки. Пользователь должен повторить тот же паттерн, начиная с первой доли второго такта.

Когда нужно проигрывать хлопки в паттерне?

У фоновой дорожки темп равен 120 долям в минуту или 1 доле каждые 0.5 секунды. Её частота дискретизации равна 48 кГц (48,000 фреймов в секунду). Поэтому нам нужно проигрывать звук хлопка в следующее время фоновой дорожки:

ДоляВремя (секунды)Номер фрейма
100
20.524000
3148000

Синхронизация событий хлопков с фоновой дорожкой

При каждом вызове onAudioReady() (с помощью микшера) фреймы фоновой дорожки записываются в аудиопоток. Посчитав записанные фреймы, можно узнать точную позицию воспроизведения фоновой дорожки и, соответственно, когда нужно проигрывать звук хлопка.

С учётом этого, вот как можно проигрывать звук хлопка в нужное время:

  • Помещаем количество фреймов события хлопка в очередь планирования;
  • Каждый раз, когда фрейм должен быть записан, проверяем, соответствует ли номер текущего фрейма фрейму события в начале очереди;
  • Если это так, то удаляем событие из очереди и проигрываем звук хлопка.

Почему бы не использовать таймер?

Использование таймера на основе системных часов для помещения хлопков в очередь дало бы нам неоптимальную точность по несколькими причинам:

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

Ключевой момент: связывая события воспроизведения хлопков с обратным вызовом onAudioReady(), можно обеспечить идеальную синхронизацию с фоновой дорожкой.

Связь между потоками

В игре есть три потока: поток OpenGL, поток пользовательского интерфейса (основной) и аудиопоток в реальном времени.

События хлопков помещаются в очередь из потока интерфейса и удаляются оттуда аудиопотоком.

Доступ к очереди производится из разных потоков, поэтому она должна быть потокобезопасна. Также она должна быть неблокирующей, чтобы не блокировать аудиопоток. Это справедливо для любого объекта, совместно используемого с аудиопотоком. Почему? Потому что блокировка аудиопотока может вызвать помехи, а это никому не нужно.

Добавляем код

В игре уже есть шаблон класса LockFreeQueue, который потокобезопасен при использовании с одним потоком на чтение (аудиопотоком) и одним потоком на запись (поток интерфейса).

Чтобы объявить LockFreeQueue нужно передать два параметра:

  1. Тип данных каждого элемента. Мы используем тип int64_t,  поскольку он позволяет выразить значительно большие номера кадров, чем в любой из дорожек, которую вы могли бы создать за разумное время;
  2. Ёмкость очереди (должна быть степенью двойки). У нас три хлопка, поэтому берём значение, равное четырём.

Откройте Game.h и добавьте следующие объявления:

private:
    // ...имеющийся код...  
    Mixer mMixer;
    
    LockFreeQueue<int64_t, 4> mClapEvents;
    std::atomic mCurrentFrame { 0 };

Обратите внимание на то, что mCurrentFrame является std::atomic, так доступ к нему осуществляется из потока интерфейса.

Теперь в Game.cpp в start() поместите события хлопков в очередь:

void Game::start() {
    // ...имеющийся код... 
    mMixer.addTrack(mBackingTrack);

    mClapEvents.push(0);
    mClapEvents.push(24000);
    mClapEvents.push(48000);
    // ...имеющийся код... 
}

И обновите onAudioReady до такого вида:

DataCallbackResult Game::onAudioReady(AudioStream *oboeStream, void *audioData, int32_t numFrames) {

   int64_t nextClapEvent;

   for (int i = 0; i < numFrames; ++i) {

       if (mClapEvents.peek(nextClapEvent) && mCurrentFrame == nextClapEvent){
           mClap->setPlaying(true);
           mClapEvents.pop(nextClapEvent);
       }
       mMixer.renderAudio(static_cast<int16_t*>(audioData)+(kChannelCount*i), 1);
       mCurrentFrame++;
   }

   return DataCallbackResult::Continue;
}

Цикл for делает numFrames итераций, проверяя, произошло ли событие хлопка, убирает его из очереди, если это так, и затем создаёт один фрейм. Адресная арифметика используется для того, чтобы сказать mMixer, где в audioData создать фрейм.

Соберите и запустите игру. Три хлопка должны прозвучать именно во время доли в начале игры. Нажатие на экран всё ещё проигрывает звуки хлопка.

Не стесняйтесь экспериментировать с разными паттернами, изменяя значения фреймов для событий хлопков. Также вы можете добавить больше событий, только не забудьте увеличить ёмкость очереди mClapEvents.

Добавляем очки и визуальный отклик

Игра воспроизводит паттерн из хлопков и ждёт от пользователя его повторения. Осталось завершить игру, добавив очки.

Нажал ли пользователь в нужное время?

После воспроизведения игрой трёх хлопков в первом такте, пользователь должен нажать на экран три раза, начиная с первой доли второго такта.

Не стоит ожидать, что пользователь нажмёт точно в нужное время, ведь это практически невозможно. Вместо этого стоит разрешить допустимое отклонение до и после ожидаемого времени. Мы определим промежуток времени, который называем окном для нажатия.

Если пользователь нажимает на экран в пределах этого окна, то экран должен загореться зелёным, слишком рано — оранжевым, слишком поздно — фиолетовым.

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

Сравниваем события нажатий с окном

Когда пользователь нажимает на экран, нам нужно знать, попало ли нажатие в пределы окна.

Текущее окно хранится как номер фрейма, а событие нажатия является временем аптайма системы (миллисекунды с момента загрузки). Для сравнения их время должно быть в одной единице отсчёта.

Создаём точку отсчёта

Чтобы преобразовать номер фрейма в аптайм, нужно создать точку отсчёта, в которой известны и номер фрейма, и аптайм. Текущий номер фрейма уже хранится в mCurrentFrame. Чтобы создать точку отсчёта, нужно сохранить аптайм, когда mCurrentFrame обновляется внутри onAudioReady.

Обновляем экран

Когда мы узнали, насколько точным было нажатие пользователя, нужно предоставить визуальный отклик.

Для этого воспользуемся ещё одним экземпляром класса LockFreeQueue с объектами TapResult. Эту очередь нужно проверять при каждом обновлении экрана в методе tick().

Если в очереди есть событие, то мы его оттуда убираем и устанавливаем цвет экрана с помощью SetGLScreenColor().

Добавляем код

В Game.h объявите следующие поля:

private:
    // ...имеющийся код... 
    std::atomic mCurrentFrame { 0 };
    LockFreeQueue<int64_t, kMaxQueueItems> mClapWindows;
    LockFreeQueue<TapResult, kMaxQueueItems> mUiEvents;
    std::atomic mLastUpdateTime { 0 };

В Game.cpp добавьте этот код, чтобы начать добавлять окна для нажатий:

void Game::start() {
    // ...имеющийся код... 
    mClapEvents.push(48000);

    mClapWindows.push(96000);
    mClapWindows.push(120000);
    mClapWindows.push(144000);
    // ...имеющийся код... 
}

Добавьте этот код в конец tap(), чтобы удалить окно из очереди, определить, было ли нажатие успешным, и поместить результат в очередь событий интерфейса:

void Game::tap(int64_t eventTimeAsUptime) {
   mClap->setPlaying(true);

   int64_t nextClapWindowFrame;
   if (mClapWindows.pop(nextClapWindowFrame)){

       int64_t frameDelta = nextClapWindowFrame - mCurrentFrame;
       int64_t timeDelta = convertFramesToMillis(frameDelta, kSampleRateHz);
       int64_t windowTime = mLastUpdateTime + timeDelta;
       TapResult result = getTapResult(eventTimeAsUptime, windowTime);
       mUiEvents.push(result);
   }
}

Для простоты окно удаляется из очереди при каждом нажатии пользователя. У пользователя есть только один шанс нажать в нужное время. В качестве улучшения геймплея можно было бы заново помещать окно в очередь, если нажатие произошло слишком рано.

Добавьте этот код в tick() для обработки всех событий интерфейса:

void Game::tick(){

   TapResult r;
   if (mUiEvents.pop(r)) {
       renderEvent(r);
   } else {
       SetGLScreenColor(kScreenBackgroundColor);
   }
}

Наконец, добавьте этот код в конец onAudioReady() для обнуления аптайма при каждой записи в аудиопоток:

DataCallbackResult Game::onAudioReady(AudioStream *oboeStream, void *audioData, int32_t numFrames) {

   // ...имеющийся код... 
   mLastUpdateTime = nowUptimeMillis();

   return DataCallbackResult::Continue;
}

Готово! Соберите и запустите игру.

Вы должны услышать три хлопка в начале игры. Если вы нажмёте на экран три раза в нужное время во время второго такта, то после каждого нажатия экран будет загораться зелёным. Если нажали слишком рано, то оранжевым, слишком поздно — фиолетовым. Удачи!

Перевод статьи «Build a Musical Game using Oboe»

Подобрали три теста для вас:
— А здесь можно применить блокчейн?
Серверы для котиков: выберите лучшее решение для проекта и проверьте себя.
Сложный тест по C# — проверьте свои знания.

Также рекомендуем: