История успеха: игра на HTML5 с Web Audio

Fieldrunners

Скриншот Fieldrunners
Скриншот игры Fieldrunners

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

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

Возникли проблемы

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

Особенности AudioBufferSourceNode

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

Контент, не подлежащий кешированию

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

Отключение звука, когда окно неактивно

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

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

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

Простая архитектура узлов Web Audio

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

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

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

Граф

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

В Fieldrunners было три дополнительных узла усиления из-за того, что ошибка использовалась как функция. Мы использовали эти узлы, чтобы отсечь группы воспроизводимых звуков от графа, что останавливает их прогресс. Это нужно, чтобы приостановить воспроизведение звуков. Поскольку это неправильно, мы будем использовать только три узла усиления, как описано выше. Многие из следующих фрагментов будут включать наши неправильные узлы, показывая, что мы сделали и как мы исправим это в краткосрочной перспективе. Но в долгосрочной перспективе лучше не использовать наши узлы после узла 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 );
}

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

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

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

Мы можем использовать эту же способность, чтобы контролировать громкость всего, звуковых эффектов и музыки. Настройка усиления главного узла повлияет на все звуки в игре. Если вы установите значение усиления на 0, звук и музыка будут отключены. У AudioBufferSourceNodes также есть параметр усиления. Вы можете отслеживать список всех воспроизводимых звуков и настраивать их значения усиления по отдельности для общей громкости. Если бы вы создавали звуковые эффекты с помощью тегов Audio, вам пришлось бы сделать именно это. Но граф узлов 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 не делает самостоятельно.

Внимание теряется

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

Игра Fieldrunners будет работать только на активной вкладке, поскольку для вызова цикла обновления используется requestAnimationFrame. Но контекст Web Audio продолжит воспроизводить зацикленные эффекты и фоновые треки, пока пользователь находится на другой вкладке. Но мы можем остановить это с помощью очень маленького фрагмента кода, который знает о 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();
    }
  });
}

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

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

Включи бит

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

Вместо того чтобы воспроизводить несколько копий звука для нескольких экземпляров игрового объекта, например умирающего персонажа, Fieldrunners воспроизводит один звук только один раз в течение его продолжительности. Если звук нужен после того, как он закончился, его можно перезапустить, но не во время воспроизведения. Это решение для звукового дизайна Fieldrunners, поскольку в нем есть звуки, которые должны воспроизводиться быстро, что в противном случае привело бы к заиканию, если бы им было разрешено перезапускаться, или создало бы неприятную какофонию, если бы им было разрешено воспроизводить несколько экземпляров. Узлы AudioBufferSourceNode предназначены для однократного использования. Создайте узел, прикрепите буфер, при необходимости задайте логическое значение цикла, подключитесь к узлу на графике, который приведет к месту назначения, вызовите 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. Повторим ещё раз: AudioBufferSourceNode – это одноразовые объекты. Создайте их, прикрепите Audio Buffer, подключите его к графу Web Audio и воспроизведите с помощью noteOn или noteGrainOn. Хотите воспроизвести звук ещё раз? Затем создайте другой AudioBufferSourceNode.