Практический пример: история HTML5-игры с веб-аудио

Полевые бегуны

Скриншот Филдраннерс
Скриншот Филдраннерс

Fieldrunners — это отмеченная наградами игра в стиле Tower Defense, которая изначально была выпущена для iPhone в 2008 году. С тех пор она была портирована на многие другие платформы. Одной из последних платформ стал браузер Chrome в октябре 2011 года. Одной из проблем при переносе Fieldrunners на платформу HTML5 было воспроизведение звука.

В Fieldrunners нет сложного использования звуковых эффектов, но есть некоторые ожидания относительно того, как они могут взаимодействовать со звуковыми эффектами. В игре 88 звуковых эффектов, большое количество которых можно воспроизводить одновременно. Большинство этих звуков очень короткие, и их необходимо воспроизводить как можно быстрее, чтобы избежать разрыва с графическим представлением.

Появились некоторые проблемы

При портировании Fieldrunners на HTML5 мы столкнулись с проблемами воспроизведения звука с помощью тега Audio и на раннем этапе решили вместо этого сосредоточиться на API веб-аудио . Использование WebAudio помогло нам решить такие проблемы, как предоставление большого количества одновременно воспроизводимых эффектов, которое требуется для Fieldrunners. Тем не менее, при разработке аудиосистемы для Fieldrunners HTML5 мы столкнулись с несколькими тонкими проблемами, о которых, возможно, захотят знать другие разработчики.

Природа AudioBufferSourceNodes

AudioBufferSourceNodes — это ваш основной метод воспроизведения звуков с помощью WebAudio. Очень важно понимать, что они являются объектом одноразового использования. Вы создаете AudioBufferSourceNode, назначаете ему буфер, подключаете его к графику и воспроизводите его с помощью noteOn или noteGrainOn. После этого вы можете вызвать noteOff, чтобы остановить воспроизведение, но вы не сможете снова воспроизвести источник, вызвав noteOn или noteGrainOn — вам придется создать еще один AudioBufferSourceNode. Однако вы можете — и это важно — повторно использовать один и тот же базовый объект AudioBuffer (на самом деле, вы даже можете иметь несколько активных AudioBufferSourceNodes, которые указывают на один и тот же экземпляр AudioBuffer!). Вы можете найти фрагмент воспроизведения Fieldrunners в «Give Me a Beat».

Некэшируемый контент

На момент выпуска HTML5-сервер Fieldrunners показывал огромное количество запросов к музыкальным файлам. Этот результат возник из-за того, что Chrome 15 начал загружать файл частями, а затем не кэшировать его. В ответ мы решили загружать музыкальные файлы так же, как и остальные наши аудиофайлы. Это неоптимально, но некоторые версии других браузеров все еще делают это.

Тишина, когда вы не в фокусе

Раньше было сложно обнаружить, когда вкладка вашей игры находится не в фокусе. Fieldrunners начали портировать до Chrome 13, где API видимости страниц заменил необходимость в нашем запутанном коде для обнаружения размытия вкладок. Каждая игра должна использовать API видимости для написания небольшого фрагмента, позволяющего отключить или приостановить звук, если не приостановить всю игру. Поскольку Fieldrunners использовали API requestAnimationFrame , неявно обрабатывалась приостановка игры, но не приостановка звука.

Приостановка звука

Как ни странно, при получении отзывов на эту статью нам сообщили, что метод, который мы использовали для приостановки звука, не подходит — мы использовали ошибку в текущей реализации Web Audio для приостановки воспроизведения звуков. Поскольку в будущем это будет исправлено, вы не сможете просто приостановить звук, отключив узел или подграф, чтобы остановить воспроизведение.

Простая архитектура узла веб-аудио

У Fieldrunners очень простая аудиомодель. Эта модель может поддерживать следующий набор функций:

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

Для достижения вышеуказанных функций с помощью веб-аудио использовались 3 из предоставленных возможных узлов: DestinationNode, GainNode, AudioBufferSourceNode. AudioBufferSourceNodes воспроизводят звуки. GainNodes соединяют AudioBufferSourceNodes вместе. DestinationNode, созданный контекстом веб-аудио и называемый местом назначения, воспроизводит звуки для проигрывателя. В Web Audio есть гораздо больше типов узлов, но только с их помощью мы можем создать очень простой график звуков в игре.

Диаграмма графа узла

Граф узлов веб-аудио ведет от конечных узлов к целевому узлу. Fieldrunners использовали 6 узлов постоянного усиления, но 3-х достаточно, чтобы можно было легко контролировать громкость и подключить большее количество временных узлов, которые будут воспроизводить буферы. Сначала главный узел усиления, присоединяющий каждый дочерний узел к месту назначения. Сразу к главному узлу усиления подключены два узла усиления: один для музыкального канала, а другой для связи всех звуковых эффектов.

У Fieldrunners было 3 дополнительных узла усиления из-за неправильного использования ошибки как функции. Мы использовали эти узлы, чтобы отсекать группы воспроизводимых звуков из графа, что останавливало их прогресс. Мы сделали это, чтобы приостановить звуки. Поскольку это неверно, теперь мы будем использовать только 3 узла общего усиления, как описано выше. Многие из следующих фрагментов будут включать наши неправильные узлы, показывающие, что мы сделали и как мы это исправим в краткосрочной перспективе. Но в долгосрочной перспективе вы не захотите использовать наши узлы после узла coreEffectsGain.

function AudioManager() {
  // map for loaded sounds
  this.sounds = {};

  // create our permanent nodes
  this.nodes = {
    destination: this.audioContext.destination,
    masterGain: this.audioContext.createGain(),

    backgroundMusicGain: this.audioContext.createGain(),

    coreEffectsGain: this.audioContext.createGain(),
    effectsGain: this.audioContext.createGain(),
    pausedEffectsGain: this.audioContext.createGain()
  };

  // and setup the graph
  this.nodes.masterGain.connect( this.nodes.destination );

  this.nodes.backgroundMusicGain.connect( this.nodes.masterGain );

  this.nodes.coreEffectsGain.connect( this.nodes.masterGain );
  this.nodes.effectsGain.connect( this.nodes.coreEffectsGain );
  this.nodes.pausedEffectsGain.connect( this.nodes.coreEffectsGain );
}

Большинство игр позволяют раздельно управлять звуковыми эффектами и музыкой. Этого можно легко достичь с помощью приведенного выше графика. Каждый узел усиления имеет атрибут «усиление», которому можно установить любое десятичное значение от 0 до 1, которое можно использовать, по сути, для управления громкостью. Поскольку мы хотим управлять громкостью каналов музыки и звуковых эффектов отдельно, у нас есть узел усиления для каждого из них, где мы можем контролировать их громкость.

function setArbitraryVolume() {
  var musicGainNode = this.nodes.backgroundMusicGain;

  // set music volume to 50%
  musicGainNode.gain.value = 0.5;
}

Мы можем использовать эту же способность для управления громкостью всего, звуковых эффектов и музыки. Установка усиления главного узла повлияет на весь звук в игре. Если вы установите значение усиления на 0, вы отключите звук и музыку. AudioBufferSourceNodes также имеет параметр усиления. Вы можете отслеживать список всех воспроизводимых звуков и индивидуально настраивать их значения усиления для общей громкости. Если бы вы создавали звуковые эффекты с помощью аудиотегов, вам нужно было бы сделать именно это. Вместо этого граф узлов Web Audio значительно упрощает изменение громкости бесчисленного количества звуков. Управление громкостью таким образом также дает вам дополнительную мощность без каких-либо сложностей. Мы могли бы просто подключить AudioBufferSourceNode непосредственно к главному узлу для воспроизведения музыки и управлять его собственным усилением. Но вам придется устанавливать это значение каждый раз, когда вы создаете AudioBufferSourceNode для воспроизведения музыки. Вместо этого вы меняете один узел только тогда, когда плеер меняет громкость музыки и при запуске. Теперь у нас есть значение усиления источников буфера, чтобы сделать что-то еще. Для музыки обычно используется создание плавного перехода от одной звуковой дорожки к другой, когда одна уходит, а другая появляется. Web Audio предоставляет хороший способ легко это сделать.

function arbitraryCrossfade( track1, track2 ) {
  track1.gain.linearRampToValueAtTime( 0, 1 );
  track2.gain.linearRampToValueAtTime( 1, 1 );
}

Fieldrunners не использовали конкретно кроссфейдинг. Если бы мы знали о функции настройки значений WebAudio во время первоначального этапа разработки звуковой системы, мы, скорее всего, так и сделали бы.

Приостановка звука

Когда игрок ставит игру на паузу, он может ожидать, что некоторые звуки все еще будут воспроизводиться. Звук — это важная часть обратной связи при обычном нажатии элементов пользовательского интерфейса в игровых меню. Поскольку в Fieldrunners есть несколько интерфейсов, с которыми пользователь может взаимодействовать, пока игра приостановлена, мы по-прежнему хотим, чтобы они играли. Однако мы не хотим, чтобы продолжали воспроизводиться длинные или зацикленные звуки. Эти звуки довольно легко остановить с помощью Web Audio, по крайней мере, мы так думали.

AudioManager.prototype.pauseEffects = function() {
  this.nodes.effectsGain.disconnect();
}

Узел приостановленных эффектов все еще подключен. Любые звуки, которым разрешено игнорировать состояние паузы в игре, будут продолжать воспроизводиться в этом состоянии. Когда игра возобновляет паузу, мы можем повторно подключить эти узлы и мгновенно воспроизвести все звуки снова.

AudioManager.prototype.resumeEffects = function() {
  this.nodes.effectsGain.connect( this.nodes.coreEffectsGain );
}

После поставки Fieldrunners мы обнаружили, что само по себе отключение узла или подграфа не приостанавливает воспроизведение AudioBufferSourceNodes. Фактически мы воспользовались ошибкой в ​​WebAudio, которая в настоящее время останавливает воспроизведение узлов, не подключенных к узлу назначения в графе. Итак, чтобы убедиться, что мы готовы к этому будущему исправлению, нам нужен код, подобный следующему:

AudioManager.prototype.pauseEffects = function() {
  this.nodes.effectsGain.disconnect();

  var now = Date.now();
  for ( var name in this.sounds ) {
    var sound = this.sounds[ name ];

    if ( !sound.ignorePause && ( now - sound.source.noteOnAt < sound.buffer.duration * 1000 ) ) {
      sound.pausedAt = now - sound.source.noteOnAt;
      sound.source.noteOff();
    }
  }
}

AudioManager.prototype.resumeEffects = function() {
  this.nodes.effectsGain.connect( this.nodes.coreEffectsGain );

  var now = Date.now();
  for ( var name in this.sounds ) {
    if ( sound.pausedAt ) {
      this.play( sound.name );
      delete sound.pausedAt;
    }
  }
};

Если бы мы знали об этом раньше, что злоупотребляем ошибкой, структура нашего аудиокода была бы совсем другой. Таким образом, это затронуло ряд разделов данной статьи. Это оказывает прямое влияние здесь, а также на наши фрагменты кода в Losing Focus и Give Me a Beat. Чтобы понять, как это на самом деле работает, необходимо внести изменения как в граф узлов Fieldrunners (поскольку мы создали узлы для замыкания воспроизведения), так и в дополнительный код, который будет записывать и обеспечивать состояния паузы, чего Web Audio не делает сам по себе.

Потеря фокуса

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

Fieldrunners будет воспроизводиться только как активная вкладка благодаря использованию requestAnimationFrame для вызова цикла обновления. Но контекст веб-аудио будет продолжать воспроизводить зацикленные эффекты и фоновые дорожки, пока пользователь находится на другой вкладке. Но мы можем остановить это с помощью очень небольшого фрагмента кода Visibility API.

function AudioManager() {
  // map and node setup
  // ...

  // disable all sound when on other tabs
  var self = this;
  window.addEventListener( 'webkitvisibilitychange', function( e ) {
    if ( document.webkitHidden ) {
      self.nodes.masterGain.disconnect();

      // As noted in Pausing Sounds disconnecting isn't enough.
      // For Fieldrunners calling our new pauseEffects method would be
      // enough to accomplish that, though we may still need some logic
      // to not resume if already paused.
      self.pauseEffects();
    } else {
      self.nodes.masterGain.connect( this.nodes.destination );
      self.resumeEffects();
    }
  });
}

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

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

Дай мне бит

У нас уже есть несколько вещей. У нас есть граф узлов. Мы можем приостанавливать звуки, когда игрок ставит игру на паузу, и воспроизводить новые звуки для таких элементов, как игровые меню. Мы можем приостанавливать весь звук и музыку, когда пользователь переключается на новую вкладку. Теперь нам нужно воспроизвести звук.

Вместо воспроизведения нескольких копий звука для нескольких экземпляров игрового объекта, например смерти персонажа, Fieldrunners воспроизводит один звук только один раз за его длительность. Если звук необходим после завершения воспроизведения, его можно возобновить, но не во время воспроизведения. Это решение для аудиодизайна Fieldrunners, поскольку в нем есть звуки, которые требуется воспроизводить быстро, которые в противном случае могли бы заикаться, если бы им разрешили перезапустить, или создать неприятную какофонию, если бы было разрешено воспроизводить несколько экземпляров. Ожидается, что AudioBufferSourceNodes будут использоваться как одноразовые. Создайте узел, присоедините буфер, при необходимости установите логическое значение цикла, подключитесь к узлу на графике, который приведет к месту назначения, вызовите noteOn или noteGrainOn и, при необходимости, вызовите noteOff.

Для Fieldrunners это выглядит примерно так:

AudioManager.prototype.play = function( options ) {
  var now = Date.now(),
    // pull from a map of loaded audio buffers
    sound = this.sounds[ options.name ],
    channel,
    source,
    resumeSource;

  if ( !sound ) {
    return;
  }

  if ( sound.source ) {
    var source = sound.source;
    if ( !options.loop && now - source.noteOnAt > sound.buffer.duration * 1000 ) {
      // discard the previous source node
      source.stop( 0 );
      source.disconnect();
    } else {
      return;
    }
  }

  source = this.audioContext.createBufferSource();
  sound.source = source;
  // track when the source is started to know if it should still be playing
  source.noteOnAt = now;

  // help with pausing
  sound.ignorePause = !!options.ignorePause;

  if ( options.ignorePause ) {
    channel = this.nodes.pausedEffectsGain;
  } else {
    channel = this.nodes.effectsGain;
  }

  source.buffer = sound.buffer;
  source.connect( channel );
  source.loop = options.loop || false;

  // Fieldrunners' current code doesn't consider sound.pausedAt.
  // This is an added section to assist the new pausing code.
  if ( sound.pausedAt ) {
    source.start( ( sound.buffer.duration * 1000 - sound.pausedAt ) / 1000 );
    source.noteOnAt = now + sound.buffer.duration * 1000 - sound.pausedAt;

    // if you needed to precisely stop sounds, you'd want to store this
    resumeSource = this.audioContext.createBufferSource();
    resumeSource.buffer = sound.buffer;
    resumeSource.connect( channel );
    resumeSource.start(
      0,
      sound.pausedAt,
      sound.buffer.duration - sound.pausedAt / 1000
    );
  } else {
    // start play immediately with a value of 0 or less
    source.start( 0 );
  }
}

Слишком много потокового вещания

Изначально Fieldrunners запускалась с фоновой музыкой, воспроизводимой с помощью тега Audio. При выпуске мы обнаружили, что музыкальные файлы запрашивались непропорционально часто, по сравнению с остальным игровым контентом. После некоторых исследований мы обнаружили, что в то время браузер Chrome не кэшировал потоковые фрагменты музыкальных файлов. В результате браузер запрашивал воспроизводимую дорожку каждые несколько минут после ее завершения. В ходе недавнего тестирования Chrome кэшировал потоковые треки, однако другие браузеры, возможно, еще не делают этого. Потоковая передача больших аудиофайлов с помощью тега Audio для таких функций, как воспроизведение музыки, является оптимальной, но для некоторых версий браузера вы можете захотеть загружать музыку так же, как звуковые эффекты.

Поскольку все звуковые эффекты воспроизводились через Web Audio, мы также перенесли воспроизведение фоновой музыки в Web Audio. Это означало, что мы будем загружать треки так же, как мы загружали все эффекты с помощью XMLHttpRequests и типа ответа arraybuffer.

AudioManager.prototype.load = function( options ) {
  var xhr,
      // pull from a map of name, object pairs
      sound = this.sounds[ options.name ];

  if ( sound ) {
    // this is a great spot to add success methods to a list or use promises
    // for handling the load event or call success if already loaded
    if ( sound.buffer && options.success ) {
      options.success( options.name );
    } else if ( options.success ) {
      sound.success.push( options.success );
    }

    // one buffer is enough so shortcut here
    return;
  }

  sound = {
    name: options.name,
    buffer: null,
    source: null,
    success: ( options.success ? [ options.success ] : [] )
  };
  this.sounds[ options.name ] = sound;

  xhr = new XMLHttpRequest();
  xhr.open( 'GET', options.path, true );
  xhr.responseType = 'arraybuffer';
  xhr.onload = function( e ) {
    sound.buffer = self._context.createBuffer( xhr.response, false );

    // call all waiting handlers
    sound.success.forEach( function( success ) {
      success( sound.name );
    });
    delete sound.success;
  };
  xhr.onerror = function( e ) {

    // failures are uncommon but you want to do deal with them

  };
  xhr.send();
}

Краткое содержание

Fieldrunners было настоящим прорывом для Chrome и HTML5. Помимо огромной работы по внедрению тысяч строк C++ в JavaScript, возникают некоторые интересные дилеммы и решения, специфичные для HTML5. Повторим одно, а не другое: AudioBufferSourceNodes являются объектами одноразового использования. Создайте их, прикрепите аудиобуфер, подключите его к графику веб-аудио и играйте с помощью noteOn или noteGrainOn. Хотите снова воспроизвести этот звук? Затем создайте еще один AudioBufferSourceNode.