Studi Kasus - Kisah Game HTML5 dengan Audio Web

Pelari lapangan

Screenshot Fieldrunners
Screenshot Fieldrunners

Fieldrunners adalah game gaya pertahanan menara pemenang penghargaan dan awalnya dirilis untuk iPhone pada tahun 2008. Sejak saat itu telah di-porting ke berbagai platform lain. Salah satu platform terbaru adalah browser Chrome pada Oktober 2011. Salah satu tantangan membawa Fieldrunners ke platform HTML5 adalah cara memutar suara.

Fieldrunners tidak menggunakan efek suara yang rumit, tetapi dilengkapi dengan beberapa ekspektasi tentang bagaimana ia dapat berinteraksi dengan efek suaranya. Game ini memiliki 88 efek suara yang diperkirakan akan dimainkan dalam jumlah besar sekaligus. Sebagian besar audio ini sangat singkat dan harus diputar tepat waktu sebaik mungkin untuk menghindari pemutusan hubungan dengan presentasi grafis.

Beberapa Tantangan Muncul

Saat mem-porting Fieldrunners ke HTML5, kami mengalami masalah terkait pemutaran audio dengan tag Audio dan sejak awal memutuskan untuk berfokus pada Web Audio API. Menggunakan WebAudio membantu kami memecahkan masalah seperti memberi kami banyak efek serentak yang diperlukan Fieldrunners. Namun, saat mengembangkan sistem audio untuk Fieldrunners HTML5, kami mengalami beberapa masalah rumit yang mungkin perlu diperhatikan oleh developer lain.

Sifat AudioBufferSourceNodes

AudioBufferSourceNodes adalah metode utama Anda untuk memutar suara dengan WebAudio. Sangat penting untuk dipahami bahwa keduanya adalah objek sekali pakai. Anda membuat AudioBufferSourceNode, menetapkan buffer, menghubungkannya ke grafik, dan memutarnya dengan noteOn atau noteGrainOn. Setelah itu, Anda dapat memanggil noteOff untuk menghentikan pemutaran, tetapi Anda tidak akan dapat memutar sumber lagi dengan memanggil noteOn atau noteGrainOn - Anda harus membuat AudioBufferSourceNode lain. Namun, Anda dapat - dan ini adalah kuncinya - menggunakan kembali objek AudioBuffer dasar yang sama (bahkan, Anda bahkan dapat memiliki beberapa AudioBufferSourceNode aktif yang mengarah ke instance AudioBuffer yang sama). Anda dapat menemukan cuplikan pemutaran dari Fieldrunners di Provide Me a Beat.

Konten tanpa cache

Saat perilisan, server HTML5 Fieldrunners menampilkan permintaan file musik dalam jumlah yang sangat besar. Hasil ini muncul dari Chrome 15 yang melanjutkan untuk mengunduh file dalam potongan dan kemudian tidak meng-cache-nya. Sebagai respons, saat itu kami memutuskan untuk memuat file musik seperti file audio lainnya. Melakukan hal ini kurang optimal tetapi beberapa versi browser lain masih melakukan hal ini.

Membisukan saat tidak fokus

Sulit untuk mendeteksi saat tab game Anda tidak fokus sebelumnya. Fieldrunners mulai melakukan porting sebelum Chrome 13 dengan Page Visibility API menggantikan kebutuhan kode yang rumit untuk mendeteksi pemburaman tab. Setiap game harus menggunakan Visibility API untuk menulis cuplikan kecil guna membisukan atau menjeda suaranya jika tidak menjeda seluruh game. Karena Fieldrunners menggunakan requestAnimationFrame API, jeda game ditangani secara implisit, tetapi suara tidak dijeda.

Menjeda suara

Anehnya, saat mendapatkan umpan balik atas artikel ini, kami diberi tahu bahwa teknik yang kami gunakan untuk menjeda suara tidak tepat - kami memanfaatkan suatu bug dalam penerapan Audio Web saat ini untuk menjeda pemutaran suara. Karena hal ini akan diperbaiki pada masa mendatang, Anda tidak dapat hanya menjeda suara dengan memutuskan node atau subgrafik untuk menghentikan pemutaran.

Arsitektur Node Audio Web Sederhana

Fieldrunners memiliki model audio yang sangat sederhana. Model tersebut dapat mendukung set fitur berikut:

  • Mengontrol volume efek suara.
  • Mengontrol volume trek musik latar belakang.
  • Membisukan semua audio.
  • Menonaktifkan pemutaran suara saat game dijeda.
  • Aktifkan kembali suara yang sama saat game dilanjutkan.
  • Nonaktifkan semua audio saat tab game kehilangan fokus.
  • Mulai ulang pemutaran setelah suara diputar sesuai kebutuhan.

Untuk mencapai fitur di atas dengan Audio Web, metode ini menggunakan 3 dari kemungkinan node yang disediakan: DestinationNode, GainNode, AudioBufferSourceNode. AudioBufferSourceNodes memutar suara. GainNode menghubungkan AudioBufferSourceNodes secara bersamaan. DestinationNode, yang dibuat oleh konteks Audio Web, yang disebut tujuan, dan memutar suara untuk pemutar. Audio Web memiliki lebih banyak jenis node, tetapi dengan node ini saja kita dapat membuat grafik yang sangat sederhana untuk suara dalam game.

Diagram Grafik Node

Grafik node Web Audio mengarah dari node daun ke node tujuan. Fieldrunners menggunakan 6 node perolehan permanen, tetapi 3 sudah cukup untuk memungkinkan kontrol volume yang mudah dan menghubungkan sejumlah besar node sementara yang akan memutar buffer. Pertama, node penguatan master yang melampirkan setiap node turunan ke tujuan. Dua node node yang langsung terhubung ke node master adalah dua node perolehan, satu untuk saluran musik dan satu lagi untuk menautkan semua efek suara.

Fieldrunners memiliki 3 node perolehan tambahan karena kesalahan penggunaan bug sebagai fitur. Kami menggunakan node tersebut untuk memotong kumpulan suara yang diputar dari grafik untuk menghentikan progresnya. Kami melakukannya untuk menjeda suara. Karena jawaban ini salah, sekarang kita hanya akan menggunakan 3 node perolehan total seperti yang dijelaskan di atas. Banyak cuplikan berikut ini akan menyertakan node yang salah, menunjukkan apa yang telah kita lakukan, dan cara memperbaikinya dalam jangka pendek. Namun dalam jangka panjang, Anda sebaiknya tidak menggunakan node setelah node 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 );
}

Sebagian besar game memungkinkan kontrol efek suara dan musik terpisah. Hal ini dapat dengan mudah dicapai dengan grafik di atas. Setiap node penguatan memiliki atribut "gain" yang dapat ditetapkan ke nilai desimal apa pun antara 0 dan 1, yang pada dasarnya dapat digunakan untuk mengontrol volume. Karena kita ingin mengontrol volume saluran musik dan efek suara secara terpisah, kita memiliki node penguatan untuk masing-masing saluran yang dapat digunakan untuk mengontrol volumenya.

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

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

Kita bisa menggunakan kemampuan yang sama ini untuk mengontrol volume semuanya, efek suara dan musik. Menetapkan perolehan node master akan memengaruhi semua suara dari game. Jika Anda menyetel nilai penguatan ke 0, Anda akan membisukan suara dan musik. AudioBufferSourceNodes juga memiliki parameter perolehan. Anda dapat melacak daftar semua suara yang diputar dan menyesuaikan nilai penguatannya satu per satu untuk volume keseluruhan. Jika Anda membuat efek suara dengan tag Audio, inilah yang harus Anda lakukan. Alih-alih, grafik node Web Audio memudahkan Anda mengubah volume suara untuk suara yang tak terhitung jumlahnya. Mengontrol volume dengan cara ini juga memberi Anda daya ekstra tanpa kerumitan. Kita cukup melampirkan AudioBufferSourceNode langsung ke node master untuk memutar musik dan mengontrol perolehannya sendiri. Namun, Anda harus menyetel nilai ini setiap kali membuat AudioBufferSourceNode untuk memutar musik. Sebagai gantinya, ubah satu node hanya saat pemutar mengubah volume musik dan saat peluncuran. Sekarang kita memiliki nilai keuntungan pada sumber {i>buffer<i} untuk melakukan sesuatu yang lain. Untuk musik, satu penggunaan umum adalah untuk membuat cross fade dari satu trek audio ke trek audio lainnya saat satu trek muncul dan lainnya masuk. Audio Web menyediakan metode yang bagus untuk melakukan ini dengan mudah.

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

Fieldrunner tidak menggunakan crossfading secara khusus. Seandainya kita mengetahui fungsi setelan nilai WebAudio selama tahap awal sistem suara yang mungkin kita miliki.

Menjeda Suara

Saat pemain menjeda game, mereka dapat mengharapkan beberapa suara masih diputar. Suara adalah bagian yang bagus dari masukan untuk penekanan umum pada elemen antarmuka pengguna di menu game. Karena Fieldrunners memiliki sejumlah antarmuka bagi pengguna untuk berinteraksi saat game dijeda, kita tetap ingin mereka tetap bermain. Namun, kita tidak ingin suara yang lama atau berulang terus diputar. Cukup mudah untuk menghentikan suara tersebut dengan Audio Web, atau setidaknya menurut kami.

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

Node efek yang dijeda masih terhubung. Suara apa pun yang diizinkan untuk mengabaikan status game yang dijeda akan terus diputar. Saat game dilanjutkan, kita dapat menghubungkan kembali node tersebut dan semua suara akan langsung diputar kembali.

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

Setelah mengirimkan Fieldrunners, kami mendapati bahwa memutuskan sambungan node atau subgrafik saja tidak akan menjeda pemutaran AudioBufferSourceNodes. Kami memanfaatkan bug di WebAudio yang saat ini menghentikan pemutaran node yang tidak terhubung ke node Tujuan dalam grafik. Jadi, untuk memastikan kita siap dengan perbaikan di masa mendatang, kita memerlukan beberapa kode seperti berikut:

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 &amp;&amp; ( now - sound.source.noteOnAt &lt; 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;
    }
  }
};

Seandainya kita tahu ini sebelumnya, bahwa kita menyalahgunakan {i>bug<i}, struktur kode audio kita akan sangat berbeda. Oleh karena itu, hal ini memengaruhi beberapa bagian dalam artikel ini. Dampak langsung ada di sini tetapi juga di cuplikan kode kami di Kehilangan Fokus dan Beri Saya Irama. Untuk mengetahui cara kerjanya, Anda harus mengubah grafik node Fieldrunners (karena kita membuat node untuk mempersingkat pemutaran) dan kode tambahan yang akan merekam dan memberikan status dijeda yang tidak dilakukan Audio Web dengan sendirinya.

Kehilangan Fokus

Node master kita berperan untuk fitur ini. Saat pengguna browser beralih ke tab lain, game tidak lagi terlihat. Hilang penglihatan, hilang, dan begitu pula suara itu harus hilang. Ada trik yang dapat dilakukan untuk menentukan status visibilitas tertentu untuk halaman game, tetapi kini jauh lebih mudah dengan Visibility API.

Fieldrunners hanya akan diputar sebagai tab aktif berkat menggunakan requestAnimationFrame untuk memanggil loop updatenya. Namun, konteks Audio Web akan terus memutar efek berulang dan trek latar belakang saat pengguna berada di tab lain. Namun, kita dapat menghentikannya dengan cuplikan berbasis Visibility API yang sangat kecil.

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

Sebelum menulis artikel ini, sebaiknya putuskan sambungan master untuk menjeda semua suara, bukan membisukannya. Dengan memutuskan node pada saat itu, kami menghentikannya dan turunannya dari pemrosesan dan pemutaran. Saat perangkat terhubung kembali, semua suara dan musik akan mulai diputar dari posisi terakhir saat gameplay akan dilanjutkan dari posisi terakhir. Tapi ini adalah perilaku yang tidak diharapkan. Tidak cukup dengan memutuskan sambungan untuk menghentikan pemutaran.

Page Visibility API memudahkan Anda mengetahui saat tab tidak lagi menjadi fokus. Jika Anda sudah memiliki kode yang efektif untuk menjeda suara, hanya perlu beberapa baris untuk menulis di jeda suara saat tab game disembunyikan.

Beri Saya Irama

Kami telah menyiapkan beberapa hal. Kita memiliki grafik {i>node<i}. Kita dapat menjeda suara saat pemain menjeda game, dan memutar suara baru untuk elemen seperti menu game. Kita dapat menjeda semua suara dan musik saat pengguna beralih ke tab baru. Sekarang kita perlu benar-benar memutar suara.

Fieldrunners memutar satu suara hanya sekali selama durasinya, bukan memutar beberapa salinan suara untuk beberapa instance entity game seperti karakter sekarat. Jika suara diperlukan setelah selesai diputar, suara dapat dimulai ulang, tetapi tidak saat sudah diputar. Ini adalah keputusan untuk desain audio Fieldrunners karena memiliki suara yang diminta diputar cepat yang akan tersendat jika diizinkan untuk memulai ulang atau membuat hiruk pikuk yang tidak menyenangkan jika diizinkan untuk memutar beberapa instance. AudioBufferSourceNodes diharapkan akan digunakan sebagai satu kali pengambilan. Buat node, lampirkan buffer, setel nilai boolean loop jika perlu, hubungkan ke node pada grafik yang akan mengarah ke tujuan, panggil noteOn atau noteGrainOn, dan secara opsional memanggil noteOff.

Untuk Fieldrunners akan terlihat seperti:

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 &amp;&amp; now - source.noteOnAt &gt; 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 );
  }
}

Terlalu Banyak Streaming

Fieldrunners awalnya diluncurkan dengan musik latar belakang yang diputar dengan tag Audio. Pada saat perilisan, kami mendapati bahwa permintaan file musik tidak proporsional dibandingkan dengan permintaan konten game lainnya. Setelah melakukan penelitian, kami menemukan bahwa pada saat itu browser Chrome tidak menyimpan potongan file musik yang di-streaming dalam cache. Hal ini menyebabkan browser meminta trek yang diputar setiap beberapa menit setelah selesai. Dalam pengujian terbaru, Chrome meng-cache jalur yang di-streaming, tetapi browser lain mungkin belum melakukannya. Streaming file audio berukuran besar dengan tag Audio untuk fungsionalitas seperti pemutaran musik memang optimal, tetapi untuk beberapa versi browser, Anda mungkin ingin memuat musik dengan cara yang sama seperti memuat efek suara.

Karena semua efek suara diputar melalui Audio Web, kami juga memindahkan musik latar belakang ke Audio Web. Ini berarti bahwa kita akan memuat trek dengan cara yang sama seperti saat kita memuat semua efek dengan XMLHttpRequests dan jenis respons 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 &amp;&amp; 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();
}

Ringkasan

Fieldrunners adalah aplikasi baru yang dapat dihadirkan di Chrome dan HTML5. Di luar pekerjaannya sendiri membawa ribuan baris C++ ke dalam JavaScript, beberapa dilema menarik dan keputusan khusus untuk HTML5 membangkitkan hasrat. Untuk mengulangi satu jika tidak ada yang lain, AudioBufferSourceNodes satu kali menggunakan objek. Buat, lampirkan Buffer Audio, hubungkan ke grafik Audio Web, dan mainkan dengan noteOn atau noteGrainOn. Perlu memutar suara itu lagi? Lalu, buat AudioBufferSourceNode lain.