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

Fieldrunners é um jogo premiado de defesa de torre lançado originalmente para iPhone em 2008. Desde então, ele foi 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 foi como reproduzir o som.

O Fieldrunners não usa efeitos sonoros complicados, mas tem algumas expectativas de como interagir com eles. O jogo tem 88 efeitos sonoros, dos quais um grande número pode ser reproduzido 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 com a tag "Audio" e decidimos nos concentrar na API Web Audio. O uso do WebAudio nos ajudou a resolver problemas, como a reprodução de um grande número de efeitos simultâneos necessários para o Fieldrunners. Ainda assim, ao desenvolver um sistema de áudio para o Fieldrunners HTML5, encontramos alguns problemas sutis que outros desenvolvedores podem querer conhecer.

Natureza dos AudioBufferSourceNodes

Os AudioBufferSourceNodes são seu método principal de reprodução de sons com o WebAudio. É muito importante entender que eles são objetos de uso único. Você cria um AudioBufferSourceNode, atribui um buffer, conecta ao gráfico e reproduz com noteOn ou noteGrainOn. Depois disso, você pode chamar noteOff para interromper a reprodução, mas não será possível 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 ter vários AudioBufferSourceNodes ativos que apontam para a mesma instância de AudioBuffer. Você pode encontrar um trecho de reprodução dos Fieldrunners em "Give Me a Beat".

Conteúdo sem armazenamento em cache

Na versão, o servidor Fieldrunners HTML5 mostrou um número enorme de solicitações de arquivos de música. Esse resultado surgiu porque o Chrome 15 começou a fazer o download do arquivo em partes e depois não armazenou em cache. Como resposta, decidimos carregar arquivos de música como o restante dos nossos arquivos de áudio. Isso não é ideal, mas algumas versões de outros navegadores ainda fazem isso.

Silenciar quando o foco estiver fora

Detectar quando a guia do jogo estava fora do foco era difícil. O Fieldrunners começou a ser transferido 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 gravar um pequeno snippet para silenciar ou pausar o som, se não pausar o jogo inteiro. Como o Fieldrunners usava a API requestAnimationFrame, a pausa do jogo era processada implicitamente, mas não a pausa do som.

Pausar sons

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

Uma arquitetura simples de nó de áudio da Web

O Fieldrunners tem um modelo de áudio muito simples. Esse modelo pode oferecer suporte ao seguinte conjunto de recursos:

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

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

Gráfico de nó

Um gráfico de nó do Web Audio leva dos nós de folha ao nó de destino. O Fieldrunners usava seis nós de ganho permanentes, mas três são suficientes para permitir o 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 conectando cada nó filho ao destino. Imediatamente anexado ao nó de ganho principal, há dois nós de ganho, um para um canal de música e outro para vincular todos os efeitos sonoros.

Os Fieldrunners tinham três nós de ganho extras devido ao uso incorreto de um bug como recurso. Usamos esses nós para cortar grupos de sons reproduzidos do gráfico, o que interrompe o progresso deles. Fizemos isso para pausar os sons. Como isso não está correto, agora vamos usar apenas 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 corrigimos isso em curto prazo. Mas, a longo prazo, você não vai querer usar nossos nós depois do 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 controlar separadamente os efeitos sonoros e a música. Isso pode ser facilmente feito 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 deles, onde 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úsicas. A configuração do ganho do nó mestre afeta todos os sons 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 acompanhar uma lista de todos os sons que estão sendo reproduzidos e ajustar os valores de ganho individualmente para o volume geral. Se você estivesse criando efeitos sonoros com tags de áudio, teria que fazer isso. Em vez disso, o gráfico de nós do Web Audio facilita muito a modificação do volume de vários sons. Controlar o volume dessa forma também oferece mais potência sem complicações. Poderíamos anexar um AudioBufferSourceNode diretamente ao nó mestre para tocar música e controlar o ganho dele. No entanto, você precisa definir esse valor sempre que criar um AudioBufferSourceNode para tocar música. Em vez disso, você muda um nó apenas quando um 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 é criar um crossfade de uma faixa de áudio para outra quando uma sai e outra entra. O Web Audio oferece um bom método para fazer isso com facilidade.

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

Os Fieldrunners não usaram especificamente a transição suave. Se soubéssemos da funcionalidade de definição de valor do WebAudio durante nossa passagem original do sistema de som, provavelmente o faríamos.

Pausar sons

Quando um jogador pausa um jogo, alguns sons ainda podem ser ouvidos. O som é uma parte importante do feedback para o pressionamento comum de elementos da interface do usuário nos menus do jogo. Como o Fieldrunners tem várias interfaces para o usuário interagir enquanto o jogo está pausado, queremos que elas continuem funcionando. No entanto, não queremos que sons longos ou em loop continuem tocando. É muito fácil interromper 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 pausado ainda está conectado. Todos 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 tocados novamente instantaneamente.

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

Depois de enviar o Fieldrunners, descobrimos que desconectar um nó ou subgrafo sozinho não pausa a reprodução dos AudioBufferSourceNodes. Na verdade, aproveitamos um bug no WebAudio que interrompe a reprodução de nós não conectados ao nó de destino no gráfico. Para garantir que estamos prontos para a 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, algumas seções deste artigo foram afetadas. Isso tem um efeito direto aqui, mas também nos snippets de código em "Perder o foco" e "Dê um ritmo". Para saber como isso funciona, é necessário fazer mudanças no gráfico de nós do Fieldrunners (já que criamos nós para encurtar a reprodução) e no código adicional que vai gravar e fornecer os estados de pausa que o Web Audio não faz sozinho.

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 som também precisa desaparecer. Há 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á executado como a guia ativa porque usa requestAnimationFrame para chamar o loop de atualização. No entanto, o contexto do Web Audio continua reproduzindo efeitos em loop e faixas em segundo plano enquanto o usuário está 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, pensávamos que desconectar o master seria suficiente para pausar todo o som, em vez de silenciar. Ao desconectar o nó na época, impedimos que ele e os filhos processassem e tocassem. Quando ele foi reconectado, todos os sons e músicas começaram a tocar de onde pararam, assim como o jogo continuou de onde parou. Mas esse é um comportamento inesperado. Não basta desconectar para interromper a reprodução.

A API Page Visibility facilita saber quando a 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 do som quando a guia de jogos está oculta.

Give Me a Beat

Agora temos algumas coisas configuradas. Temos um gráfico de nós. Podemos pausar os sons quando o jogador pausar o jogo e reproduzir novos sons para elementos como menus de jogos. Podemos pausar todos os sons e músicas quando o usuário muda para uma nova guia. Agora precisamos reproduzir 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. Se o som for necessário depois de terminar de tocar, ele poderá ser reiniciado, mas não enquanto estiver tocando. Essa é uma decisão para o design de áudio de Fieldrunners, já que ele tem sons que são solicitados para serem reproduzidos rapidamente, o que causaria gagueira se fosse permitido reiniciar ou criar uma cacofonia desagradável se fosse permitido reproduzir várias instâncias. 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 leva ao destino, chame noteOn ou noteGrainOn e, opcionalmente, chame noteOff.

Para os Fieldrunners, é 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 );
  }
}

Muita transmissão

O Fieldrunners foi lançado originalmente com música de fundo reproduzida com uma tag de áudio. Na versão, descobrimos que os arquivos de música eram 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 fragmentos de streaming dos arquivos de música. Isso resultou no navegador solicitando a faixa em reprodução a cada poucos minutos, conforme ela terminava. Em testes mais recentes, o Chrome armazenava em cache as faixas transmitidas, mas outros navegadores ainda não fazem isso. O streaming de arquivos de áudio grandes com a tag "Audio" para funcionalidades como a reprodução de música é ideal, mas para algumas versões de navegador, talvez seja melhor carregar a música da mesma forma que os efeitos sonoros.

Como todos os efeitos sonoros eram reproduzidos pelo Web Audio, também mudamos a reprodução da música de fundo para o Web Audio. Isso significa que carregamos 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 própria montanha de trabalho que traz milhares de linhas de C++ para o JavaScript, alguns dilemas e decisões interessantes específicos do HTML5 surgem. Para reiterar um se nenhum dos outros, os AudioBufferSourceNodes são objetos de uso único. Crie, anexe um buffer de áudio, conecte-o ao gráfico do Web Audio e toque com noteOn ou noteGrainOn. Precisa ouvir o som de novo? Em seguida, crie outro AudioBufferSourceNode.