Estudo de caso: a história de um jogo HTML5 com Web Audio

Fieldrunners

Captura de tela do Fieldrunners
Captura de tela do Fieldrunners

O Fieldrunners é um jogo premiado de defesa de torres lançado originalmente para iPhone em 2008. Desde então, ele foi portado para muitas outras plataformas. Uma das plataformas mais recentes foi o navegador Chrome, em outubro de 2011. Um dos desafios de portar o Fieldrunners para uma plataforma HTML5 foi como reproduzir som.

O Fieldrunners não usa efeitos sonoros de forma complicada, mas tem algumas expectativas sobre como interagir com eles. O jogo tem 88 efeitos sonoros, e muitos deles podem ser tocados ao mesmo tempo. A maioria desses sons é muito curta e precisa ser reproduzida o mais rápido possível para evitar qualquer desconexão com a apresentação gráfica.

Alguns desafios apareceram

Ao portar o Fieldrunners para HTML5, encontramos problemas com a reprodução de áudio usando a tag de áudio e decidimos focar na API Web Audio. O uso do WebAudio nos ajudou a resolver problemas como o alto número de efeitos simultâneos que o Fieldrunners exige. No entanto, ao desenvolver um sistema de áudio para o Fieldrunners HTML5, encontramos alguns problemas sutis que outros desenvolvedores talvez queiram conhecer.

Natureza dos AudioBufferSourceNodes

Os AudioBufferSourceNodes são o principal método de reprodução de sons com a WebAudio. É muito importante entender que eles são objetos de uso único. Você cria um AudioBufferSourceNode, atribui um buffer a ele, o conecta 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, é possível reutilizar o mesmo objeto AudioBuffer subjacente (na verdade, você pode até ter vários AudioBufferSourceNodes ativos que apontam para a mesma instância de AudioBuffer!). Você pode encontrar um trecho de reprodução de Fieldrunners em Give Me a Beat.

Conteúdo sem armazenamento em cache

No lançamento, o servidor HTML5 do Fieldrunners mostrou um número enorme de solicitações de arquivos de música. Esse resultado ocorreu porque o Chrome 15 fez o download do arquivo em partes e não o armazenou em cache. Em resposta, 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.

Silenciando quando fora de foco

Antes, era difícil detectar quando a guia do jogo estava fora de foco. O Fieldrunners começou a ser portado antes do Chrome 13, em que a API Page Visibility substituiu a necessidade de um código complexo para detectar o desfoque de guias. Todo jogo precisa usar a API Visibility para escrever um pequeno snippet que silencia ou pausa o som, se não pausar o jogo inteiro. Como o Fieldrunners usava a API requestAnimationFrame, a pausa do jogo era processada de forma implícita, mas não a pausa do som.

Pausando sons

Curiosamente, ao receber feedback sobre este artigo, fomos informados de que a técnica que estávamos usando para pausar sons não era apropriada. Estávamos usando um bug na implementação atual da Web Audio para pausar a reprodução de sons. Como isso será corrigido no futuro, não é possível pausar o som desconectando um nó ou subgráfico para interromper a reprodução.

Uma arquitetura simples de nó da API Web Audio

O Fieldrunners tem um modelo de áudio muito simples. Esse modelo pode aceitar o seguinte conjunto de atributos:

  • Controlar o volume dos efeitos sonoros.
  • Controle o volume da música de fundo.
  • Desativar todo o áudio.
  • Desative os sons quando o jogo estiver pausado.
  • Ative os mesmos sons quando o jogo for retomado.
  • Desative todo o áudio quando a guia do jogo perder o foco.
  • Reinicie a reprodução depois que um som for tocado, conforme necessário.

Para alcançar os recursos acima com a Web Audio, ela usou três dos nós possíveis fornecidos: DestinationNode, GainNode e AudioBufferSourceNode. Os AudioBufferSourceNodes reproduzem os sons. Os GainNodes conectam os AudioBufferSourceNodes. O DestinationNode, criado pelo contexto de áudio da Web, chamado destination, reproduz sons para o jogador. A Web Audio tem muitos outros tipos de nós, mas com esses já é possível criar um gráfico bem simples para os sons de um jogo.

Gráfico de nós

Um gráfico de nós da API Web Audio leva dos nós de folha ao nó de destino. O Fieldrunners usou seis nós de ganho permanente, mas três são suficientes para permitir um controle fácil do volume e conectar um número maior de nós temporários que vão reproduzir buffers. Primeiro, um nó de ganho mestre que anexa cada nó filho ao destino. Dois nós de ganho estão conectados diretamente 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 remover grupos de sons em reprodução do gráfico, o que interrompe o progresso deles. Fizemos isso para pausar os sons. Como isso não está correto, agora usaríamos apenas três nós de ganho total, conforme descrito acima. Muitos dos snippets a seguir incluem nossos nós incorretos, mostrando o que fizemos e como corrigiríamos isso no curto prazo. Mas, a longo prazo, você não vai querer usar nossos nós depois do 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 para controlar o volume. Como queremos controlar o volume dos canais de música e 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, de efeitos sonoros e música. Definir o ganho do nó principal afeta todo o som do jogo. Se você definir o valor de 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 todos os sons em reprodução e ajustar os valores de ganho individualmente para o volume geral. Se você estivesse criando efeitos sonoros com tags de áudio, é isso que teria que fazer. Em vez disso, o gráfico de nós da Web Audio facilita muito a modificação do volume de inúmeros sons. Controlar o volume dessa forma também oferece mais potência sem complicações. Poderíamos simplesmente anexar um AudioBufferSourceNode diretamente ao nó mestre para tocar música e controlar o próprio ganho. Mas você teria que definir esse valor sempre que criasse um AudioBufferSourceNode para tocar música. Em vez disso, você muda um nó apenas quando um jogador muda o volume da música e no lançamento. Agora temos um valor de ganho nas fontes de buffer para fazer outra coisa. Para música, um uso comum é criar um crossfade de uma faixa de áudio para outra, quando uma sai e outra entra. A Web Audio oferece um método simples para fazer isso.

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

O Fieldrunners não usou o crossfading de forma específica. Se soubéssemos da funcionalidade de configuração de valor da WebAudio durante nossa passagem original do sistema de som, provavelmente teríamos.

Pausando sons

Quando um jogador pausa um jogo, ele pode esperar que alguns sons ainda sejam reproduzidos. O som é uma ótima parte do feedback para o pressionamento comum de elementos da interface do usuário em menus de jogos. Como o Fieldrunners tem várias interfaces para o usuário interagir enquanto o jogo está pausado, ainda queremos que elas sejam reproduzidas. No entanto, não queremos que sons longos ou em loop continuem tocando. É bem fácil interromper esses sons com a Web Audio, ou pelo menos era o que pensávamos.

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

O nó de efeitos pausados ainda está conectado. Os sons que podem ignorar o estado pausado do jogo vão continuar tocando. Quando o jogo é retomado, podemos reconectar esses nós e fazer com que todos os sons sejam reproduzidos novamente de forma instantânea.

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 não pausa a reprodução dos AudioBufferSourceNodes. Na verdade, aproveitamos um bug na WebAudio que interrompe a reprodução de nós não conectados ao nó de destino no gráfico. Para garantir que estejamos prontos para essa correção futura, precisamos de um código como este:

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 antes que estávamos abusando de um bug, a estrutura do nosso código de áudio seria muito diferente. Por isso, várias seções deste artigo foram afetadas. Ele tem um efeito direto aqui, mas também nos nossos snippets de código em "Perdendo o foco" e "Dê um ritmo". Para saber como isso funciona, é preciso fazer mudanças no gráfico de nós do Fieldrunners (já que criamos nós para interromper a reprodução) e no código adicional que vai gravar e fornecer os estados de pausa que a Web Audio não faz por conta própria.

Perda de foco

Nosso nó mestre entra em ação para esse recurso. Quando um usuário do navegador muda para outra guia, o jogo não fica mais visível. O que os olhos não veem, o coração não sente, e o som também deve desaparecer. Existem truques que podem ser usados para determinar estados de visibilidade específicos para a página de um jogo, mas isso ficou muito mais fácil com a API Visibility.

O Fieldrunners só será reproduzido como a guia ativa porque usa requestAnimationFrame para chamar o loop de atualização. No entanto, o contexto da Web Audio vai continuar tocando efeitos em loop e faixas de fundo enquanto o usuário estiver em outra guia. Mas podemos impedir isso com um snippet muito pequeno que reconhece a API Visibility.

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, achávamos que desconectar o mestre seria suficiente para pausar todo o som em vez de silenciá-lo. Ao desconectar o nó no momento, impedimos que ele e os filhos processem e reproduzam. Quando ele era reconectado, todos os sons e músicas começavam a tocar de onde tinham parado, assim como o jogo continuava de onde tinha parado. Mas esse é um comportamento inesperado. Não basta apenas desconectar para interromper a reprodução.

A API Page Visibility facilita saber quando a guia não está mais em foco. Se você já tiver um código eficaz para pausar sons, basta escrever algumas linhas para pausar o som quando a guia de jogos estiver oculta.

Faça um som

Agora temos algumas coisas configuradas. Temos um gráfico de nós. Podemos pausar os sons quando o jogador pausa o jogo e tocar novos sons para elementos como menus do jogo. Podemos pausar todos os sons e músicas quando o usuário muda para uma nova guia. Agora precisamos tocar um som.

Em vez de reproduzir várias cópias do som para várias instâncias de uma entidade do jogo, como um personagem morrendo, o Fieldrunners reproduz um som apenas uma vez durante a duração dele. Se o som for necessário depois de terminar, ele poderá ser reiniciado, mas não enquanto estiver tocando. Essa é uma decisão para o design de áudio do Fieldrunners, já que ele tem sons que precisam ser reproduzidos rapidamente. Caso contrário, eles podem travar se forem reiniciados ou criar uma cacofonia desagradável se forem reproduzidas várias instâncias. Os AudioBufferSourceNodes devem ser usados como one-shots. Crie um nó, anexe um buffer, defina o valor booleano do loop, se necessário, conecte-se a um nó no gráfico que levará ao destino, chame noteOn ou noteGrainOn e, opcionalmente, chame noteOff.

Para o Fieldrunners, o resultado é mais ou menos assim:

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

Streaming em excesso

O Fieldrunners foi lançado originalmente com música de fundo tocada com uma tag de áudio. No lançamento, descobrimos que os arquivos de música estavam sendo solicitados um número desproporcional de vezes em relação ao restante do conteúdo do jogo. Depois de algumas pesquisas, descobrimos que, na época, o navegador Chrome não estava armazenando em cache os blocos de streaming dos arquivos de música. Isso fazia com que o navegador solicitasse a faixa em reprodução a cada poucos minutos, conforme ela terminava. Em testes mais recentes, o Chrome armazenou em cache as faixas de streaming, mas outros navegadores talvez ainda não façam isso. O streaming de arquivos de áudio grandes com a tag Audio para funcionalidades como reprodução de música é ideal, mas, para algumas versões de navegador, talvez seja melhor carregar a música da mesma forma que você carrega efeitos sonoros.

Como todos os efeitos sonoros estavam sendo reproduzidos pela Web Audio, também transferimos a reprodução da música de fundo para ela. Isso significa 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 muito divertido trazer o Fieldrunners para o Chrome e o HTML5. Além da enorme quantidade de trabalho para converter milhares de linhas de C++ em JavaScript, surgiram alguns dilemas e decisões interessantes específicos do HTML5. Para reiterar um, se nenhum dos outros, AudioBufferSourceNodes são objetos de uso único. Crie-os, anexe um Audio Buffer, conecte-o ao gráfico da API Web Audio e use noteOn ou noteGrainOn. Quer tocar o som de novo? Em seguida, crie outro AudioBufferSourceNode.