Örnek olay: Web Audio ile HTML5 oyununun hikayesi

Fieldrunners

Fieldrunners ekran görüntüsü
Fieldrunners ekran görüntüsü

Fieldrunners, ilk olarak 2008'de iPhone için yayınlanan, ödüllü bir kule savunma oyunudur. O zamandan beri birçok platforma taşındı. En son platformlardan biri, Ekim 2011'de Chrome Tarayıcı oldu. Fieldrunners'ı bir HTML5 platformuna taşımanın zorluklarından biri, sesin nasıl çalınacağıydı.

Fieldrunners, ses efektlerini karmaşık bir şekilde kullanmaz ancak ses efektleriyle nasıl etkileşim kurabileceğine dair bazı beklentiler vardır. Oyunda 88 ses efekti var ve bunların büyük bir kısmının aynı anda çalması beklenebilir. Bu seslerin çoğu çok kısadır ve grafik sunumla herhangi bir kopukluk oluşturmamak için mümkün olduğunca zamanında çalınması gerekir.

Bazı sorunlar oluştu

Fieldrunners'ı HTML5'e taşırken Audio etiketiyle ses çalma konusunda sorunlar yaşadık ve erken bir aşamada bunun yerine Web Audio API'ye odaklanmaya karar verdik. WebAudio'yu kullanmak, Fieldrunners'ın gerektirdiği çok sayıda eşzamanlı efekt oynatma gibi sorunları çözmemize yardımcı oldu. Yine de Fieldrunners HTML5 için bir ses sistemi geliştirirken diğer geliştiricilerin de bilmek isteyebileceği birkaç küçük sorunla karşılaştık.

AudioBufferSourceNodes'un yapısı

AudioBufferSourceNodes, WebAudio ile ses çalmak için kullandığınız birincil yöntemdir. Bunların tek kullanımlık nesneler olduğunu anlamak çok önemlidir. Bir AudioBufferSourceNode oluşturur, buna bir arabellek atar, grafiğe bağlar ve noteOn veya noteGrainOn ile oynatırsınız. Bundan sonra, oynatmayı durdurmak için noteOff'u çağırabilirsiniz ancak noteOn veya noteGrainOn'u çağırarak kaynağı tekrar oynatamazsınız. Başka bir AudioBufferSourceNode oluşturmanız gerekir. Ancak aynı temel AudioBuffer nesnesini yeniden kullanabilirsiniz (aslında aynı AudioBuffer örneğini işaret eden birden fazla etkin AudioBufferSourceNode'unuz bile olabilir). Give Me a Beat'te Fieldrunners'dan bir oynatma snippet'i bulabilirsiniz.

Önbelleğe alınmayan içerik

Fieldrunners HTML5 sunucusu, oyunun yayınlanmasıyla birlikte müzik dosyaları için çok sayıda istek aldı. Bu sonuç, Chrome 15'in dosyayı parçalar halinde indirmesinden ve ardından önbelleğe almamasından kaynaklanmıştır. O dönemde, müzik dosyalarını diğer ses dosyalarımız gibi yüklemeye karar vermiştik. Bu yöntem ideal olmasa da diğer tarayıcıların bazı sürümleri hâlâ bu şekilde çalışır.

Odaklanma oturumu dışında sessize alma

Oyununuzun sekmesinin ne zaman odaktan çıktığını tespit etmek daha önce zordu. Fieldrunners, Chrome 13'ten önce taşıma işlemine başladı. Chrome 13'te Page Visibility API, sekme bulanıklaştırmayı algılamak için karmaşık kodumuza olan ihtiyacı ortadan kaldırdı. Her oyun, tüm oyunu duraklatmasa bile sesini kapatmak veya duraklatmak için küçük bir snippet yazmak üzere Visibility API'yi kullanmalıdır. Fieldrunners, requestAnimationFrame API'yi kullandığından oyunun duraklatılması dolaylı olarak işleniyordu ancak sesin duraklatılması işlenmiyordu.

Sesleri duraklatma

İlginç bir şekilde, bu makaleyle ilgili geri bildirim alırken sesleri duraklatmak için kullandığımız tekniğin uygun olmadığı konusunda bilgilendirildik. Seslerin oynatılmasını duraklatmak için Web Audio'nun mevcut uygulamasındaki bir hatadan yararlanıyorduk. Bu sorun gelecekte düzeltileceğinden, oynatmayı durdurmak için bir düğümün veya alt grafiğin bağlantısını keserek sesi duraklatamazsınız.

Basit bir Web Audio düğüm mimarisi

Fieldrunners'ın çok basit bir ses modeli vardır. Bu model aşağıdaki özellik grubunu destekleyebilir:

  • Ses efektlerinin ses seviyesini kontrol etme
  • Arka plan müzik parçasının ses seviyesini kontrol edin.
  • Tüm sesleri kapatma
  • Oyun duraklatıldığında ses çalmayı kapatın.
  • Oyun devam ettirildiğinde aynı sesleri tekrar açın.
  • Oyunun sekmesi odağı kaybettiğinde tüm sesleri kapatın.
  • Bir ses çalındıktan sonra oynatmayı gerektiği gibi yeniden başlatın.

Web Audio ile yukarıdaki özellikleri elde etmek için sunulan olası düğümlerden 3'ünü kullandı: DestinationNode, GainNode, AudioBufferSourceNode. AudioBufferSourceNodes, sesleri çalar. GainNode'lar, AudioBufferSourceNode'ları birbirine bağlar. Web Audio bağlamı tarafından oluşturulan ve hedef olarak adlandırılan DestinationNode, oyuncu için sesleri çalar. Web Audio'da daha birçok düğüm türü vardır ancak yalnızca bunlarla bir oyundaki sesler için çok basit bir grafik oluşturabiliriz.

Düğüm Grafiği

Web Audio düğüm grafiği, yaprak düğümlerden hedef düğüme doğru ilerler. Fieldrunners 6 kalıcı kazanç düğümü kullandı ancak 3 düğüm, ses seviyesini kolayca kontrol etmek ve arabellekleri oynatacak daha fazla sayıda geçici düğüm bağlamak için yeterlidir. İlk olarak, her alt düğümü hedefe bağlayan bir ana kazanç düğümü. Ana kazanç düğümüne doğrudan bağlı iki kazanç düğümü vardır. Bunlardan biri müzik kanalı, diğeri ise tüm ses efektlerini bağlamak içindir.

Fieldrunners'da, bir hatanın özellik olarak yanlış kullanılması nedeniyle 3 ekstra kazanç düğümü vardı. Bu düğümleri, çalan ses gruplarını grafikten kırparak ilerlemelerini durdurmak için kullandık. Bunu sesleri duraklatmak için yaptık. Bu doğru olmadığından, artık yukarıda açıklandığı gibi yalnızca 3 toplam kazanç düğümü kullanacağız. Aşağıdaki snippet'lerin çoğunda yanlış düğümlerimiz yer alacak. Bu düğümlerde ne yaptığımızı ve kısa vadede bunu nasıl düzelteceğimizi göstereceğiz. Ancak uzun vadede, coreEffectsGain düğümümüzden sonra düğümlerimizi kullanmak istemezsiniz.

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

Çoğu oyunda ses efektleri ve müzik ayrı ayrı kontrol edilebilir. Bu, yukarıdaki grafiğimizle kolayca yapılabilir. Her kazanç düğümünün, 0 ile 1 arasında herhangi bir ondalık değere ayarlanabilen bir "kazanç" özelliği vardır. Bu özellik, ses seviyesini kontrol etmek için kullanılabilir. Müzik ve ses efekti kanallarının ses seviyesini ayrı ayrı kontrol etmek istediğimiz için her birinin ses seviyesini kontrol edebileceğimiz bir kazanç düğümümüz var.

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

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

Aynı özelliği ses efektleri ve müzik gibi her şeyin ses seviyesini kontrol etmek için de kullanabiliriz. Ana düğümün kazancını ayarlamak, oyundaki tüm sesleri etkiler. Kazanç değerini 0 olarak ayarlarsanız ses ve müzik kapatılır. AudioBufferSourceNode'ların da bir kazanç parametresi vardır. Çalan tüm seslerin listesini takip edebilir ve genel ses seviyesi için kazanç değerlerini ayrı ayrı ayarlayabilirsiniz. Ses efektlerini ses etiketleriyle oluşturuyorsanız bu adımları uygulamanız gerekir. Web Audio'nun düğüm grafiği ise sayısız sesin ses düzeyini değiştirmeyi çok daha kolay hale getirir. Ses seviyesini bu şekilde kontrol etmek, karmaşık işlemler yapmadan ekstra güç elde etmenizi de sağlar. Müzik çalmak ve kendi kazancını kontrol etmek için doğrudan ana düğüme bir AudioBufferSourceNode ekleyebiliriz. Ancak müzik çalmak için her AudioBufferSourceNode oluşturduğunuzda bu değeri ayarlamanız gerekir. Bunun yerine, yalnızca oyuncu müzik ses seviyesini değiştirdiğinde ve oyun başlatıldığında bir düğümü değiştirirsiniz. Şimdi başka bir işlem yapmak için tampon kaynaklarında bir kazanç değerimiz var. Müzik için yaygın bir kullanım, bir ses parçasından diğerine geçiş yaparken bir ses parçasının sesini azaltıp diğerinin sesini artırarak geçiş efekti oluşturmaktır. Web Audio, bu işlemi kolayca gerçekleştirmek için kullanışlı bir yöntem sunar.

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

Fieldrunners, çapraz geçişi özel olarak kullanmadı. Ses sistemini ilk kez oluştururken WebAudio'nun değer ayarlama işlevini bilseydik muhtemelen kullanırdık.

Sesleri duraklatma

Oyuncular bir oyunu duraklattıklarında bazı seslerin çalmaya devam etmesini bekleyebilir. Ses, oyun menülerindeki kullanıcı arayüzü öğelerine basıldığında geri bildirim vermenin önemli bir parçasıdır. Fieldrunners'da oyun duraklatıldığında kullanıcının etkileşimde bulunabileceği çeşitli arayüzler olduğundan, bu arayüzlerin çalışmaya devam etmesini istiyoruz. Ancak uzun veya döngüsel seslerin çalmaya devam etmesini istemiyoruz. Web Audio ile bu sesleri durdurmak oldukça kolaydır veya en azından biz öyle düşünmüştük.

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

Duraklatılan efektler düğümü hâlâ bağlıdır. Oyunun duraklatılmış durumunu yoksaymasına izin verilen sesler çalmaya devam eder. Oyun devam ettiğinde bu düğümleri yeniden bağlayabilir ve tüm seslerin anında tekrar çalınmasını sağlayabiliriz.

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

Fieldrunners'ı yayınladıktan sonra, bir düğümün veya alt grafiğin bağlantısını kesmenin AudioBufferSourceNodes'un oynatılmasını duraklatmayacağını fark ettik. Aslında WebAudio'da, grafikteki hedef düğüme bağlı olmayan düğümlerin oynatılmasını durduran bir hatadan yararlandık. Gelecekteki bu düzeltmeye hazır olduğumuzdan emin olmak için aşağıdaki gibi bir koda ihtiyacımız var:

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

Bir hatayı kötüye kullandığımızı daha önce bilseydik ses kodumuzun yapısı çok farklı olurdu. Bu nedenle, bu makalenin bazı bölümleri de bu durumdan etkilenmiştir. Bu, doğrudan burada etkili olduğu gibi Losing Focus ve Give Me a Beat'teki kod snippet'lerimizde de etkilidir. Bunun gerçekte nasıl çalıştığını bilmek için hem Fieldrunners düğüm grafiğinde (oynatmayı kısaltmak için düğümler oluşturduğumuzdan) hem de Web Audio'nun kendi başına yapmadığı duraklatılmış durumları kaydedip sağlayacak ek kodda değişiklikler yapılması gerekir.

Odağın kaybedilmesi

Bu özellik için ana düğümümüz devreye girer. Tarayıcı kullanıcısı başka bir sekmeye geçtiğinde oyun artık görünmez. Gözden uzak olan gönülden de uzak olur. Ses de öyle olmalı. Bir oyunun sayfasının belirli görünürlük durumlarını belirlemek için yapılabilecek hileler vardır ancak Visibility API ile bu işlem çok daha kolay hale gelmiştir.

Fieldrunners, güncelleme döngüsünü çağırmak için requestAnimationFrame kullandığından yalnızca etkin sekme olarak oynatılır. Ancak Web Audio bağlamı, kullanıcı başka bir sekmedeyken döngü efektlerini ve arka plan parçalarını oynatmaya devam eder. Ancak Visibility API'yi kullanan çok küçük bir snippet ile bunu durdurabiliriz.

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

Bu makaleyi yazmadan önce, ana sesin bağlantısını kesmenin sesi kapatmak yerine tüm sesleri duraklatmak için yeterli olacağını düşünmüştük. Düğümün bağlantısını keserek düğümün ve alt öğelerinin işlenmesini ve oynatılmasını durdurduk. Yeniden bağlandığında, oyunun kaldığı yerden devam etmesi gibi tüm sesler ve müzikler de kaldığı yerden çalmaya başlar. Ancak bu beklenmedik bir davranıştır. Oynatmayı durdurmak için bağlantıyı kesmek yeterli değildir.

Page Visibility API, sekmenizin ne zaman odakta olmadığını anlamayı çok kolaylaştırır. Sesleri duraklatmak için zaten etkili bir kodunuz varsa oyun sekmesi gizlendiğinde ses duraklatma için yalnızca birkaç satır yazmanız yeterlidir.

Bir ritim çal

Şu anda birkaç şeyi ayarladık. Düğümlerden oluşan bir grafiğimiz var. Oyuncu oyunu duraklattığında sesleri duraklatabilir ve oyun menüleri gibi öğeler için yeni sesler çalabiliriz. Kullanıcı yeni bir sekmeye geçtiğinde tüm sesleri ve müzikleri duraklatabiliriz. Şimdi de ses çalmamız gerekiyor.

Fieldrunners, bir karakterin ölmesi gibi bir oyun öğesinin birden fazla örneği için sesin birden fazla kopyasını oynatmak yerine, bir sesi süresi boyunca yalnızca bir kez oynatır. Ses, çalma işlemi bittikten sonra gerekirse yeniden başlatılabilir ancak çalma işlemi devam ederken yeniden başlatılamaz. Bu, Fieldrunners'ın ses tasarımıyla ilgili bir karardır. Oyunda hızlı bir şekilde çalınması istenen sesler vardır. Bu seslerin yeniden başlatılmasına izin verilirse sesler kesintili olur, birden fazla örneğinin çalınmasına izin verilirse de rahatsız edici bir gürültü oluşur. AudioBufferSourceNodes'un tek seferlik kullanılması beklenir. Düğüm oluşturun, arabellek ekleyin, gerekirse döngü boole değerini ayarlayın, grafikte hedefe yönlendirecek bir düğüme bağlanın, noteOn veya noteGrainOn'u çağırın ve isteğe bağlı olarak noteOff'u çağırın.

Fieldrunners için bu şöyle görünür:

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

Çok fazla canlı yayın yapma

Fieldrunners ilk olarak Audio etiketiyle çalınan arka plan müziğiyle kullanıma sunulmuştu. Oyun yayınlandığında, müzik dosyalarının oyunun diğer içeriklerine kıyasla çok daha fazla sayıda istendiğini fark ettik. Yaptığımız araştırmalar sonucunda, Chrome tarayıcının o dönemde müzik dosyalarının akışla aktarılan parçalarını önbelleğe almadığını fark ettik. Bu durum, tarayıcının her birkaç dakikada bir, çalınan parça bittiğinde yeni parça istemesine neden oluyordu. Daha yeni testlerde Chrome, akışla aktarılan parçaları önbelleğe aldı ancak diğer tarayıcılar bunu henüz yapmıyor olabilir. Müzik çalma gibi işlevler için ses etiketiyle büyük ses dosyalarını oynatmak idealdir ancak bazı tarayıcı sürümlerinde müziğinizi ses efektlerini yüklediğiniz şekilde yüklemeniz gerekebilir.

Tüm ses efektleri Web Audio üzerinden çalındığından arka plan müziğini de Web Audio'ya taşıdık. Bu, parçaları tüm efektleri yüklediğimiz şekilde, XMLHttpRequest'ler ve arraybuffer yanıt türüyle yükleyeceğimiz anlamına geliyordu.

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

Özet

Fieldrunners'ı Chrome ve HTML5'e taşımak çok keyifliydi. Binlerce C++ satırını JavaScript'e dönüştürme işinin yanı sıra HTML5'e özgü bazı ilginç ikilemler ve kararlar ortaya çıktı. Diğerlerini unutsanız bile AudioBufferSourceNode'ların tek kullanımlık nesneler olduğunu unutmayın. Bunları oluşturun, bir ses arabelleği ekleyin, Web Audio grafiğine bağlayın ve noteOn veya noteGrainOn ile oynatın. Bu sesi tekrar çalmanız mı gerekiyor? Ardından başka bir AudioBufferSourceNode oluşturun.