Örnek Olay - Web Sesi İçeren Bir HTML5 Oyununun Öyküsü

Fieldrunners

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

Fieldrunners, 2008'de ilk olarak iPhone için yayınlanan, ödüllü bir savunma kule oyunu. O zamandan beri birçok platforma taşındı. En son platformlardan biri, Ekim 2011'de Chrome tarayıcıydı. Fieldrunners'ı bir HTML5 platformuna taşımanın zorluklarından biri ses çalma işlemiydi.

Fieldrunners'da ses efektleri karmaşık bir şekilde kullanılmasa da ses efektleriyle nasıl etkileşim kurulabileceğiyle ilgili bazı beklentiler var. Oyunda 88 ses efekti bulunuyor ve bunların çoğunun aynı anda çalınması beklenebilir. Bu seslerin çoğu çok kısadır ve görsel sunumla bağlantının kopmasını önlemek için mümkün olduğunca zamanında çalınması gerekir.

Bazı zorluklar ortaya çıktı

Fieldrunners'ı HTML5'e taşırken Audio etiketiyle ses oynatmayla ilgili sorunlarla karşılaştık ve bunun yerine Web Audio API'ye odaklanmaya karar verdik. WebAudio'yu kullanmak, Fieldrunners'ın gerektirdiği yüksek 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 farkında olması gereken birkaç ayrıntılı sorunla karşılaştık.

AudioBufferSourceNodes'un yapısı

AudioBufferSourceNodes, WebAudio ile ses çalma için birincil yönteminizdir. Bu kartların tek kullanımlık olduğunu anlamak çok önemlidir. Bir AudioBufferSourceNode oluşturur, ona bir arabellek atar, grafiğe bağlar ve noteOn veya noteGrainOn ile çalarsınız. Ardından, oynatmayı durdurmak için noteOff'u çağırabilirsiniz ancak noteOn veya noteGrainOn'u çağırarak kaynağı tekrar oynatamazsınız. Bunun için başka bir AudioBufferSourceNode oluşturmanız gerekir. Ancak, temelde aynı 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ınamayan içerikler

Fieldrunners HTML5 sunucusu, yayınlandığında müzik dosyaları için çok sayıda istek gösterdi. Bu sonuç, Chrome 15'in dosyayı parçalara ayırarak indirip önbelleğe almamasından kaynaklanmaktadır. Bu talep üzerine, müzik dosyalarını diğer ses dosyalarımız gibi yüklemeye karar verdik. Bu, en iyi seçenek olmasa da diğer tarayıcıların bazı sürümleri bunu yapmaya devam etmektedir.

Odak dışındayken sessize alma

Oyununuzun sekmesinin odaktan çıktığı durumları tespit etmek daha önce zordu. Fieldrunners, Chrome 13'ten önce taşıma işlemine başladı. Bu sürümde, sekme bulanıklığını algılamak için karmaşık kodumuzun yerini Sayfa Görünürlük API'si aldı. Her oyun, tüm oyunu değilse de seslerini kapatmak veya duraklatmak için küçük bir snippet yazmak üzere Görünürlüğü API'sini kullanmalıdır. Fieldrunners, requestAnimationFrame API'sini kullandığından oyun duraklatma işlemi dolaylı olarak ele alındı ancak ses duraklatma işlemi ele alınmadı.

Sesleri duraklatma

Bu makaleyle ilgili geri bildirim alırken sesleri duraklatma için kullandığımız tekniğin uygun olmadığı konusunda bilgilendirildik. Seslerin oynatılmasını duraklatma 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 Node mimarisi

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

  • Ses efektlerinin ses seviyesini kontrol etme.
  • Arka plan müziği parçasının ses seviyesini kontrol edin.
  • Tüm sesleri kapatın.
  • Oyun duraklatıldığında seslerin çalınmasını devre dışı bırakın.
  • Oyun devam ettiğinde aynı sesleri tekrar açın.
  • Oyunun sekmesi odağını kaybettiğinde tüm sesleri kapatın.
  • Bir ses çaldıktan sonra gerektiğinde oynatmayı yeniden başlatın.

Web Audio ile yukarıdaki özelliklere ulaşmak için sağlanan olası düğümlerden 3'ü kullanıldı: DestinationNode, GainNode, AudioBufferSourceNode. Sesleri AudioBufferSourceNodes oynatır. GainNodes, AudioBufferSourceNodes'ı birbirine bağlar. Web Audio bağlamı tarafından oluşturulan DestinationNode (hedef), oynatıcı için sesler çalar. Web Audio'da çok daha fazla 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 gider. Fieldrunners'da 6 kalıcı kazanç düğümü kullanılmış olsa da ses seviyesini kolayca kontrol etmek ve tamponları oynatacak daha fazla sayıda geçici düğüm bağlamak için 3 düğüm yeterlidir. İlk olarak, her alt düğümü hedefe bağlayan bir ana kazanç düğümü. Ana kazanç düğümüne hemen 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, bir hatanın özellik olarak yanlış kullanılması nedeniyle 3 ekstra kazanç düğümüne sahipti. Bu düğümleri, çalan ses gruplarını grafikten kesip ilerlemelerini durdurmak için kullandık. Bunu sesleri duraklatmak için yaptık. Bu doğru olmadığından, yukarıda açıklandığı gibi yalnızca 3 toplam kazanç düğümü kullanacağız. Aşağıdaki snippet'lerin çoğunda, yanlış düğümlerimiz, ne yaptığımızı ve kısa vadede bu sorunu nasıl düzelteceğimizi gösterir. Ancak uzun vadede coreEffectsGain düğümümüzden sonra düğümlerimizi kullanmamak isteyebilirsiniz.

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ı olarak 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 ve temel olarak sesi kontrol etmek için kullanılabilen bir "gain" özelliği vardır. Müzik ve ses efekti kanallarının ses seviyelerini ayrı ayrı kontrol etmek istediğimizden, her biri için ses seviyelerini 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 efektlerinin ve müziğin ses seviyesini kontrol etmek için de kullanabiliriz. Ana düğümün kazancı ayarlandığında oyundaki tüm sesler etkilenir. Kazanç değerini 0 olarak ayarladığınızda ses ve müzik kapatılır. AudioBufferSourceNodes'un bir kazanç parametresi de vardır. Çalınan tüm seslerin listesini takip edebilir ve genel ses seviyesi için kazanç değerlerini tek tek ayarlayabilirsiniz. Ses etiketleriyle ses efekti oluşturuyorsanız bunu yapmanız gerekir. Bunun yerine Web Audio'nun düğüm grafiği, 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 ek güç elde etmenizi de sağlar. Müzik çalmak ve kendi kazancını kontrol etmek için doğrudan ana düğüme bir AudioBufferSourceNode ekleyebilirdik. Ancak müzik çalmak için her AudioBufferSourceNode oluşturduğunuzda bu değeri ayarlamanız gerekir. Bunun yerine, yalnızca bir oyuncu müzik ses düzeyini değiştirdiğinde ve uygulama başlatıldığında bir düğümü değiştirirsiniz. Artık başka bir şey yapmak için arabellek kaynaklarında bir kazanç değerine sahibiz. Müzikte bu işlevin yaygın bir kullanım şekli, bir ses parçası sona ererken diğeri başladığında ses geçişi oluşturmaktır. Web Audio, bunu kolayca gerçekleştirmek için güzel bir yöntem sunar.

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

Fieldrunners'da özel olarak geçiş efekti kullanılmamıştır. Ses sistemini ilk kez kontrol ederken WebAudio'nun değer ayarlama işlevini biliyor olsaydık bunu yapardık.

Sesleri duraklatma

Oyuncular oyunu duraklattığında bazı seslerin çalmaya devam etmesini bekleyebilir. Ses, oyun menülerindeki kullanıcı arayüzü öğelerine sık sık basılması için geri bildirimin önemli bir parçasıdır. Fieldrunners'da oyun duraklatılmışken kullanıcının etkileşimde bulunabileceği çeşitli arayüzler bulunduğundan, kullanıcıların oyun oynamaya devam etmesini istiyoruz. Ancak uzun veya döngü halinde çalan seslerin çalmaya devam etmesini istemiyoruz. Bu sesleri Web Audio ile durdurmak oldukça kolaydır veya en azından öyle olduğunu düşünürdük.

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

Duraklatılmış efektler düğümü hâlâ bağlıdır. Oyunun duraklatıldı durumunu yoksaymasına izin verilen tüm sesler, bu durumda çalmaya devam eder. Oyun duraklatıldığında bu düğümleri yeniden bağlayabilir ve tüm sesleri anında tekrar çalabilir.

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

Fieldrunners'ı kullanıma sunduktan sonra, bir düğümün veya alt grafiğin bağlantısını kesmenin tek başına AudioBufferSourceNodes oynatmasının duraklatılmamasına neden olduğunu fark ettik. Aslında WebAudio'daki, grafikte Hedef düğümüne bağlı olmayan düğümlerin oynatılmasını durduran bir hatadan yararlandık. Bu nedenle, gelecekteki düzeltmeye hazır olmamız 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 önceden bilseydik ses kodumuz çok farklı bir yapıya sahip olurdu. Bu nedenle, bu makalenin bazı bölümleri etkilenmiştir. Bu durum hem burada hem de Losing Focus ve Give Me a Beat'teki kod snippet'lerimizde doğrudan bir etkiye sahiptir. Bunun nasıl çalıştığını anlamak için hem Fieldrunners düğüm grafiğinde (oynatma süresini 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şiklik yapılması gerekir.

Odak kaybetme

Bu özellikte 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örünmeyen sesler de duyulmaz. Bir oyunun sayfası için belirli görünürlük durumlarını belirlemek üzere kullanılabilecek bazı hileler vardır ancak bu işlem Görünürlüğü API ile çok daha kolay hale gelmiştir.

Fieldrunners, güncelleme döngüsünü çağırmak için requestAnimationFrame kullanıldığı için yalnızca etkin sekme olarak oynatılır. Ancak Web Audio bağlamı, kullanıcı başka bir sekmedeyken döngüsel efektleri ve arka plan parçalarını çalmaya devam eder. Ancak bunu çok küçük bir Visibility API farkında snippet'iyle 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 cihazın bağlantısını kesmenin tüm sesleri kapatmak yerine duraklatmak için yeterli olacağını düşünüyorduk. O sırada 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 tüm sesler ve müzikler, oyunun kaldığı yerden devam ettiği gibi kaldığı yerden çalmaya başlar. Ancak bu beklenmedik bir davranıştır. Oynatma işlemini durdurmak için bağlantıyı kesmek yeterli değildir.

Sayfa Görünürlüğü API'si, sekmenizin artık odakta olmadığını öğrenmenizi çok kolaylaştırır. Sesleri duraklatmak için etkili bir kodunuz varsa oyunlar sekmesi gizliyken ses duraklatmayı yazmak için yalnızca birkaç satır yeterlidir.

Give Me a Beat

Şimdi birkaç ayar yaptık. Bir düğüm grafiğimiz var. Oyuncu oyunu duraklattığında sesleri duraklatabilir ve oyun menüleri gibi öğeler için yeni sesler çalabilirsiniz. Kullanıcı yeni bir sekmeye geçtiğinde tüm sesleri ve müzikleri duraklatabiliriz. Şimdi bir ses çalmamız gerekiyor.

Fieldrunners, bir karakterin ölmesi gibi oyun öğesinin birden fazla örneği için sesin birden fazla kopyasını çalmak yerine, sesin süresinin tamamı boyunca yalnızca bir kez çalar. Ses, oynatıldıktan sonra tekrar başlatılabilir ancak oynatılma sırasında başlatılamaz. Bu karar, hızlı bir şekilde çalınması istenen sesler içerdiği için Fieldrunners'ın ses tasarımıyla ilgilidir. Aksi takdirde, yeniden başlatılmasına izin verilirse ses takılır veya birden fazla kez çalınmasına izin verilirse rahatsız edici bir ses karmaşası oluşur. AudioBufferSourceNodes'ın tek seferlik olarak kullanılması beklenir. Bir düğüm oluşturun, bir arabellek ekleyin, gerekirse döngü boole değerini ayarlayın, grafikte hedefe götürecek bir düğüme bağlayı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 yayın

Fieldrunners, ilk olarak ses etiketiyle oynatılan arka plan müziğiyle kullanıma sunulmuştu. Oyun yayınlandığında, müzik dosyalarının oyun içeriğinin geri kalanına kıyasla orantısız sayıda istendiğini tespit ettik. Yaptığımız araştırma sonucunda, Chrome tarayıcının o dönemde müzik dosyalarının aktarılan parçalarını önbelleğe almadığını tespit ettik. Bu durum, tarayıcıda oynatılan parça bittiğinde birkaç dakika içinde tekrar oynatılması isteğine yol açıyordu. Daha yeni yapılan testlerde Chrome, aktarılan parçaları önbelleğe aldı ancak diğer tarayıcılar henüz bunu yapmıyor olabilir. Müzik çalma gibi işlevler için büyük ses dosyalarını Audio etiketiyle yayınlamak en uygun yöntemdir ancak bazı tarayıcı sürümlerinde müziklerinizi ses efektlerini yüklediğiniz şekilde yüklemeniz gerekebilir.

Tüm ses efektleri Web Audio üzerinden çalındığı için arka plan müziğinin çalınmasını da Web Audio'ya taşıdık. Bu, parçaları XMLHttpRequest'ler ve arraybuffer yanıt türüyle tüm efektleri yüklediğimiz şekilde 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 getirmek çok heyecan vericiydi. Binlerce C++ satırını JavaScript'e aktarma konusundaki kendi yığınca çalışmasının dışında, HTML5'e özgü bazı ilginç ikilemler ve kararlar ortaya çıkıyor. Diğerlerinden hiçbirini tekrar etmezsek AudioBufferSourceNodes'un tek kullanımlık nesneler olduğunu belirtmek isteriz. Bu sesleri oluşturun, bir ses arabelleği ekleyin, Web Audio grafiğine bağlayın ve noteOn veya noteGrainOn ile çalın. Bu sesi tekrar çalmanız gerekiyor mu? Ardından başka bir AudioBufferSourceNode oluşturun.