Estudo de caso - A história de um jogo HTML5 com áudio da web

Fieldrunners

Captura de tela do Fieldrunners
Captura de tela do Fieldrunners

O Fieldrunners é um premiado jogo no estilo de defesa de torre que foi lançado originalmente para iPhone em 2008. Desde então, ele tem sido transferido para muitas outras plataformas. Uma das plataformas mais recentes foi o navegador Chrome, em outubro de 2011. Um dos desafios da portabilidade do Fieldrunners para uma plataforma HTML5 era como reproduzir o som.

O Fieldrunners não faz uso complicado de efeitos sonoros, mas traz algumas expectativas de como pode interagir com esses efeitos. O jogo tem 88 efeitos sonoros, sendo que um grande número deles será reproduzido ao mesmo tempo. A maioria desses sons é muito curta e precisa ser reproduzida da forma mais pontual possível, para evitar que haja desconexão com a apresentação gráfica.

Apareceram alguns desafios

Ao transferir o Fieldrunners para HTML5, encontramos problemas na reprodução de áudio com a tag de áudio e, no início, decidimos nos concentrar na API Web Audio. Usar o WebAudio nos ajudou a resolver problemas como fornecer o alto número de efeitos simultâneos que o Fieldrunners exige. Ainda assim, ao desenvolver um sistema de áudio para o Fieldrunners em HTML5, nos deparamos com alguns problemas específicos que outros desenvolvedores podem querer conhecer.

Natureza de AudioBufferSourceNodes

Os AudioBufferSourceNodes são seu método principal para reproduzir sons com o WebAudio. É muito importante entender que eles são um objeto de uso único. Você cria um AudioBufferSourceNode, atribui um buffer a ele, conecta-o ao gráfico e o reproduz com noteOn ou noteGrainOn. Depois disso, você pode chamar noteOff para interromper a reprodução, mas não poderá reproduzir a fonte novamente chamando noteOn ou noteGrainOn. É necessário criar outro AudioBufferSourceNode. No entanto, você pode, e isso é fundamental, reutilizar o mesmo objeto AudioBuffer subjacente (na verdade, você pode até ter vários AudioBufferSourceNodes ativos que apontam para a mesma instância do AudioBuffer). É possível encontrar um snippet de reprodução do Fieldrunners na ferramenta Faça um som.

Conteúdo não armazenado em cache

No lançamento, o servidor HTML5 do Fieldrunners mostrou um grande número de solicitações de arquivos de música. Isso aconteceu porque o Chrome 15 continuava a fazer o download do arquivo em partes e, depois, não armazená-lo em cache. Como resposta, na época, decidimos carregar arquivos de música como o restante dos nossos arquivos de áudio. Isso não é o ideal, mas algumas versões de outros navegadores ainda fazem isso.

Silenciar quando fora de foco

Antes, era difícil detectar quando a guia do jogo estava fora de foco. O Fieldrunners começou a fazer a portabilidade antes do Chrome 13, quando a API Page Visibility substituiu a necessidade de nosso código complicado para detectar o desfoque da guia. Todos os jogos precisam usar a API Visibility para criar um pequeno snippet de desativar ou pausar o som caso não pause o jogo todo. Como o Fieldrunners usava a API requestAnimationFrame, a pausa do jogo era processada implicitamente, mas não a pausa de som.

Pausar sons

Curiosamente, enquanto reunimos feedback sobre este artigo, fomos informados que a técnica que usamos para pausar sons não era adequada. Estávamos utilizando um bug na implementação atual do Web Audio para pausar a reprodução de sons. Como isso será corrigido no futuro, não é possível simplesmente pausar o som desconectando um nó ou subgráfico para interromper a reprodução.

Uma arquitetura simples do nó do Web Audio

O Fieldrunners tem um modelo de áudio muito simples. Esse modelo é compatível com o seguinte conjunto de recursos:

  • Controlar o volume dos efeitos sonoros.
  • Controlar o volume da faixa de música de fundo.
  • Desativar todo o áudio.
  • Desativa a reprodução de sons quando o jogo é pausado.
  • Ative esses mesmos sons novamente quando o jogo for retomado.
  • Desativar todo o áudio quando a guia do jogo perder o foco.
  • Reinicie a reprodução depois que um som for reproduzido, conforme necessário.

Para alcançar os recursos acima com o Web Audio, ela usou três dos nós possíveis: DestinationNode, GainNode, AudioBufferSourceNode. Os AudioBufferSourceNodes reproduzem os sons. Os GainNodes conectam os AudioBufferSourceNodes. O DestinationNode, criado pelo contexto do Web Audio chamado destino, reproduz sons para o player. O Web Audio tem muitos outros tipos de nós, mas com eles podemos criar um gráfico muito simples dos sons em um jogo.

Gráfico de nós

Um gráfico de nós do Web Audio vai dos nós das folhas para o nó de destino. O Fieldrunners usou seis nós de ganho permanentes, mas três são suficientes para permitir o controle fácil sobre o volume e conectar um número maior de nós temporários que reproduzirão buffers. Primeiro, um nó de ganho mestre anexando todos os nós filhos ao destino. Dois nós de ganho estão imediatamente anexados ao nó de ganho mestre, um para um canal de música e outro para vincular todos os efeitos sonoros.

O Fieldrunners tinha três nós de ganho extras devido ao uso incorreto de um bug como recurso. Usamos esses nós para retirar grupos de sons em reprodução do gráfico, o que interrompe seu progresso. Fizemos isso para pausar sons. Como essa opção não está correta, agora só usaríamos três nós de ganho total, conforme descrito acima. Muitos dos snippets a seguir vão incluir nossos nós incorretos, mostrando o que fizemos e como corrigiríamos isso a curto prazo. Mas, no longo prazo, é melhor não usar nossos nós após o nó 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 );
}

A maioria dos jogos permite o controle separado dos efeitos sonoros e da música. Isso pode ser feito facilmente com o gráfico acima. Cada nó de ganho tem um atributo "gain" que pode ser definido como qualquer valor decimal entre 0 e 1, que pode ser usado basicamente para controlar o volume. Como queremos controlar o volume da música e dos canais de efeitos sonoros separadamente, temos um nó de ganho para cada um, em que podemos controlar o volume.

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

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

Podemos usar essa mesma capacidade para controlar o volume de tudo: efeitos sonoros e música. A definição do ganho do nó mestre afetará todo o som do jogo. Se você definir o valor do ganho como 0, o som e a música serão silenciados. Os AudioBufferSourceNodes também têm um parâmetro de ganho. Você pode rastrear uma lista de todo o som em reprodução e ajustar os valores de ganho individualmente para o volume geral. Se estivesse criando efeitos sonoros com tags de áudio, você teria que fazer isso. Em vez disso, o gráfico de nós do Web Audio facilita muito a modificação do volume de inúmeros sons. Controlar o volume dessa forma também fornece potência extra sem complicações. Poderíamos simplesmente anexar um AudioBufferSourceNode diretamente ao nó mestre para reproduzir música e controlar seu próprio ganho. Mas você teria que definir esse valor sempre que criar um AudioBufferSourceNode com a finalidade de reproduzir música. Em vez disso, você só muda um nó quando o player muda o volume da música e na inicialização. Agora temos um valor de ganho nas fontes de buffer para fazer outra coisa. Para música, um uso comum pode ser criar um cross-fade de uma faixa de áudio para outra conforme uma sai e outra entra. O Web Audio fornece um bom método para fazer isso facilmente.

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

O Fieldrunners não fazia uso específico de crossfading. Se conhecêssemos a funcionalidade de configuração de valor da WebAudio durante nossa passagem original do sistema de som, provavelmente teríamos usado.

Pausar sons

Quando um jogador pausa um jogo, ele pode esperar que alguns sons continuem sendo reproduzidos. O som é uma parte importante do feedback do pressionamento comum de elementos da interface do usuário nos menus do jogo. Como o Fieldrunners tem diversas interfaces com as quais o usuário pode interagir enquanto o jogo está pausado, ainda queremos que elas sejam reproduzidas. No entanto, não queremos que sons longos ou em loop continuem sendo reproduzidos. É muito fácil parar esses sons com o Web Audio, ou pelo menos pensávamos que era.

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

O nó de efeitos pausados ainda está conectado. Todos os sons com permissão para ignorar o estado pausado do jogo continuarão a ser reproduzidos. Quando o jogo for retomado, poderemos reconectar esses nós e todo o som será reproduzido instantaneamente.

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

Depois de lançar o Fieldrunners, descobrimos que desconectar um nó ou subgráfico por si só não pausará a reprodução dos AudioBufferSourceNodes. Na verdade, aproveitamos um bug no WebAudio que atualmente interrompe a reprodução de nós não conectados ao nó de destino no gráfico. Portanto, para garantir que estamos prontos para essa correção futura, precisamos de um código como o seguinte:

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;
    }
  }
};

Se soubéssemos disso antes, que estávamos abusando de um bug, a estrutura do nosso código de áudio seria muito diferente. Por isso, isso afetou várias seções deste artigo. Isso tem um efeito direto aqui, mas também em nossos snippets de código em Losing Focus e Faça um som. Saber como isso realmente funciona requer alterações no gráfico de nós do Fieldrunners (já que criamos nós para encurtar a reprodução) e no código adicional que registrará e fornecerá os estados de pausa que o Web Audio não faz por conta própria.

Perda do foco

Nosso nó mestre entra em cena para esse recurso. Quando um usuário do navegador alterna para outra guia, o jogo não fica mais visível. Fora da vista, fora da mente e, portanto, o som deve desaparecer. Há truques que podem ser usados para determinar estados de visibilidade específicos para a página de um jogo, mas isso se tornou muito mais fácil com a API Visibility.

O Fieldrunners só reproduzirá como a guia ativa, graças ao uso de requestAnimationFrame para chamar seu loop de atualização. No entanto, o contexto do Web Audio continuará reproduzindo efeitos em loop e faixas em segundo plano enquanto o usuário estiver em outra guia. No entanto, podemos impedir isso com um snippet muito pequeno que reconhece a API de visibilidade.

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();
    }
  });
}

Antes de escrever este artigo, acreditávamos que desconectar o mestre seria suficiente para pausar todo o som em vez de desativá-lo. Na época, ao desconectar o nó, impedimos o processamento e a reprodução dele e de seus filhos. Quando o dispositivo era reconectado, todos os sons e músicas começaram a ser tocados de onde pararam, assim como o jogo continuava de onde parou. Mas esse comportamento é inesperado. Não basta se desconectar para interromper a reprodução.

Com a API Page Visibility, é muito fácil saber quando sua guia não está mais em foco. Se você já tem um código eficaz para pausar sons, bastam algumas linhas para escrever a pausa de som quando a guia de jogos está oculta.

Faça um som

Temos algumas coisas definidas agora. Temos um gráfico de nós. Podemos pausar sons quando o jogador pausa o jogo e tocar novos sons para elementos como menus do jogo. Podemos pausar todo o som e a música quando o usuário alterna para uma nova guia. Agora precisamos de fato tocar um som.

Em vez de reproduzir várias cópias do som para diversas instâncias de uma entidade do jogo, como um personagem morrendo, o Fieldrunners toca um som apenas uma vez durante o período. Se o som for necessário após o término da reprodução, ele poderá ser reiniciado, mas não durante a reprodução. Essa é uma decisão para o design de áudio do Fieldrunners, porque ele tem sons que precisam ser reproduzidos rapidamente e que, de outra forma, travam se fossem reiniciados, ou criaria uma cacofonia inapropriada se fosse possível tocar várias instâncias. Os AudioBufferSourceNodes devem ser usados como imagem única. Crie um nó, anexe um buffer, defina um valor booleano de loop, se necessário, conecte-se a um nó no gráfico que leve ao destino, chame noteOn ou noteGrainOn e, opcionalmente, noteOff.

Para o Fieldrunners, ele tem a seguinte aparência:

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 );
  }
}

Há muito fluxo

O Fieldrunners era lançado originalmente com música de fundo tocada com uma tag de áudio. No lançamento, descobrimos que os arquivos de música eram solicitados um número desproporcional de vezes em relação ao que o restante do conteúdo do jogo era solicitado. Após algumas pesquisas, descobrimos que, na época, o navegador Chrome não estava armazenando em cache os blocos transmitidos dos arquivos de música. Isso fazia com que o navegador solicitasse a faixa em reprodução em intervalos de alguns minutos, quando ela terminava. Em testes mais recentes, o Chrome armazenou em cache as faixas transmitidas em cache. No entanto, outros navegadores podem não estar fazendo isso ainda. O ideal é fazer o streaming de arquivos de áudio grandes com a tag "Áudio" para funcionalidades como reprodução de música. No entanto, para algumas versões de navegadores é possível carregar as músicas da mesma forma que você carrega os efeitos sonoros.

Como todos os efeitos sonoros estavam sendo reproduzidos pelo áudio da Web, também passamos a reprodução da música de fundo para o Web Audio. Isso significava que carregaríamos as faixas da mesma forma que carregamos todos os efeitos, com XMLHttpRequests e o tipo de resposta 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();
}

Resumo

Foi um grande prazer trazer o Fieldrunners para o Chrome e o HTML5. Fora de seu próprio monte de trabalho trazendo milhares de linhas C++ para JavaScript, surgem alguns dilemas interessantes e decisões específicas relacionadas ao HTML5. Para reiterar apenas um, os AudioBufferSourceNodes são objetos de uso único. Crie-os, anexe um buffer de áudio, conecte-o ao gráfico do Web Audio e reproduza com noteOn ou noteGrainOn. Precisa tocar esse som de novo? Depois crie outro AudioBufferSourceNode.