Studi Kasus - Kisah Game HTML5 dengan Audio Web

Fieldrunners

Screenshot Fieldrunners
Screenshot Fieldrunners

Fieldrunners adalah game gaya pertahanan menara pemenang penghargaan yang awalnya dirilis untuk iPhone pada tahun 2008. Sejak itu, aplikasi ini telah di-porting ke banyak platform lain. Salah satu platform terbaru adalah browser Chrome pada Oktober 2011. Salah satu tantangan dalam mem-port Fieldrunners ke platform HTML5 adalah cara memutar suara.

Fieldrunners tidak menggunakan efek suara yang rumit, tetapi memiliki beberapa ekspektasi tentang cara berinteraksi dengan efek suaranya. Game ini memiliki 88 efek suara yang sebagian besar dapat diputar sekaligus. Sebagian besar suara ini sangat singkat dan perlu diputar sesegera mungkin agar tidak terjadi pemutusan dengan presentasi grafis.

Beberapa Tantangan Muncul

Saat mem-port Fieldrunners ke HTML5, kami mengalami masalah pemutaran audio dengan tag Audio dan sejak awal memutuskan untuk berfokus pada Web Audio API. Penggunaan WebAudio membantu kami mengatasi masalah seperti memberikan jumlah efek serentak yang tinggi yang diputar yang diperlukan Fieldrunners. Namun, saat mengembangkan sistem audio untuk Fieldrunners HTML5, kami mengalami beberapa masalah yang mungkin perlu diketahui developer lain.

Sifat AudioBufferSourceNodes

AudioBufferSourceNodes adalah metode utama untuk memutar suara dengan WebAudio. Sangat penting untuk memahami bahwa token ini 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. Anda dapat - dan ini adalah kuncinya - menggunakan kembali objek AudioBuffer yang mendasarinya yang sama, (bahkan, Anda dapat memiliki beberapa AudioBufferSourceNodes aktif yang mengarah ke instance AudioBuffer yang sama). Anda dapat menemukan cuplikan pemutaran dari Fieldrunners di Give Me a Beat.

Konten yang tidak di-cache

Saat dirilis, server HTML5 Fieldrunners menampilkan permintaan file musik dalam jumlah besar. Hasil ini muncul karena Chrome 15 melanjutkan untuk mendownload file dalam beberapa bagian, lalu tidak meng-cache-nya. Sebagai respons pada saat itu, kami memutuskan untuk memuat file musik seperti file audio lainnya. Tindakan ini kurang optimal, tetapi beberapa versi browser lain masih melakukannya.

Menonaktifkan suara saat tidak fokus

Sebelumnya, mendeteksi kapan tab game Anda tidak difokuskan adalah hal yang sulit. Fieldrunners mulai melakukan porting sebelum Chrome 13, saat Page Visibility API menggantikan kebutuhan akan kode rumit kami 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 API requestAnimationFrame, jeda game ditangani secara implisit, tetapi tidak untuk jeda suara.

Menjeda suara

Anehnya, saat mendapatkan masukan untuk artikel ini, kami diberi tahu bahwa teknik yang kami gunakan untuk menjeda suara tidak sesuai - kami menggunakan bug dalam implementasi Web Audio saat ini untuk menjeda pemutaran suara. Karena masalah ini akan diperbaiki pada masa mendatang, Anda tidak dapat menjeda suara dengan memutuskan koneksi node atau subgrafik untuk menghentikan pemutaran.

Arsitektur Node Audio Web Sederhana

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

  • Mengontrol volume efek suara.
  • Mengontrol volume trek musik latar belakang.
  • Membisukan semua audio.
  • Nonaktifkan suara pemutaran 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 Web Audio, Web Audio menggunakan 3 dari kemungkinan node yang disediakan: DestinationNode, GainNode, AudioBufferSourceNode. AudioBufferSourceNodes memutar suara. GainNodes menghubungkan AudioBufferSourceNodes secara bersamaan. DestinationNode, yang dibuat oleh konteks Web Audio, yang disebut tujuan, memutar suara untuk pemutar. Web Audio memiliki lebih banyak jenis node, tetapi hanya dengan node ini kita dapat membuat grafik yang sangat sederhana untuk suara dalam game.

Diagram Grafik Node

Grafik node Audio Web mengarah dari node daun ke node tujuan. Fieldrunners menggunakan 6 node gain permanen, tetapi 3 node sudah cukup untuk mengontrol volume dengan mudah dan menghubungkan lebih banyak node sementara yang akan memutar buffering. Pertama, node gain master yang melampirkan setiap node turunan ke tujuan. Dua node gain, satu untuk saluran musik dan satu lagi untuk menautkan semua efek suara, langsung dilampirkan ke node gain master.

Fieldrunners memiliki 3 node gain tambahan karena penggunaan bug yang salah sebagai fitur. Kami menggunakan node tersebut untuk memotong grup suara yang diputar dari grafik yang menghentikan progresnya. Kita melakukan ini untuk menjeda suara. Karena tidak benar, sekarang kita hanya akan menggunakan 3 node total gain seperti yang dijelaskan di atas. Banyak cuplikan berikut yang akan menyertakan node yang salah, yang menunjukkan apa yang kita lakukan, dan cara memperbaikinya dalam jangka pendek. Namun, dalam jangka panjang, sebaiknya Anda 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 terpisah untuk efek suara dan musik. Hal ini dapat dilakukan dengan mudah menggunakan grafik di atas. Setiap node gain memiliki atribut "gain" yang dapat disetel ke nilai desimal 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 gain untuk masing-masing saluran tempat kita dapat mengontrol volumenya.

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

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

Kita dapat menggunakan kemampuan yang sama ini untuk mengontrol volume semuanya, mulai dari efek suara hingga musik. Menetapkan gain node master akan memengaruhi semua suara dari game. Jika Anda menetapkan nilai gain ke 0, suara dan musik akan dibisukan. AudioBufferSourceNodes juga memiliki parameter gain. Anda dapat melacak daftar semua suara yang diputar dan menyesuaikan nilai gainnya satu per satu untuk volume keseluruhan. Jika Anda membuat efek suara dengan tag Audio, inilah yang harus Anda lakukan. Sebagai gantinya, grafik node Web Audio mempermudah Anda mengubah volume suara dari banyak suara. 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 gain-nya sendiri. Namun, Anda harus menetapkan nilai ini setiap kali membuat AudioBufferSourceNode untuk tujuan memutar musik. Sebagai gantinya, Anda hanya mengubah satu node saat pemutar mengubah volume musik dan saat peluncuran. Sekarang kita memiliki nilai gain pada sumber buffering untuk melakukan hal lain. Untuk musik, salah satu penggunaan umum dapat digunakan untuk membuat cross fade dari satu trek audio ke trek audio lainnya saat satu trek audio berhenti dan trek audio lainnya mulai diputar. Web Audio menyediakan metode yang bagus untuk melakukan hal ini dengan mudah.

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

Fieldrunners tidak menggunakan crossfading secara spesifik. Jika kita mengetahui fungsi setelan nilai WebAudio selama proses awal sistem suara, kita mungkin akan melakukannya.

Suara Jeda

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

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

Node efek yang dijeda masih terhubung. Semua suara yang diizinkan untuk mengabaikan status game yang dijeda akan terus diputar. Saat game dijeda, kita dapat menghubungkan kembali node tersebut dan membuat semua suara diputar lagi secara instan.

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

Setelah mengirimkan Fieldrunners, kami menemukan bahwa memutuskan koneksi node atau subgrafik saja tidak akan menjeda pemutaran AudioBufferSourceNodes. Kami sebenarnya memanfaatkan bug di WebAudio yang saat ini menghentikan pemutaran node yang tidak terhubung ke node Tujuan dalam grafik. Jadi, untuk memastikan kita siap menghadapi 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 && ( 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;
    }
  }
};

Jika kita mengetahui hal ini sebelumnya, bahwa kita menyalahgunakan bug, struktur kode audio kita akan sangat berbeda. Oleh karena itu, hal ini telah memengaruhi sejumlah bagian dalam artikel ini. Hal ini memiliki efek langsung di sini, tetapi juga dalam cuplikan kode kita di Losing Focus dan Give Me a Beat. Untuk mengetahui cara kerjanya, Anda perlu melakukan perubahan pada grafik node Fieldrunners (karena kita membuat node untuk mempersingkat pemutaran) dan kode tambahan yang akan merekam dan memberikan status dijeda yang tidak dilakukan oleh Web Audio sendiri.

Kehilangan Fokus

Node master kami akan berperan untuk fitur ini. Saat pengguna browser beralih ke tab lain, game tidak akan terlihat lagi. Jika tidak terlihat, suaranya juga tidak akan terdengar. Ada trik yang dapat dilakukan untuk menentukan status visibilitas tertentu untuk halaman game, tetapi hal ini menjadi jauh lebih mudah dengan Visibility API.

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

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, kami mengira memutuskan koneksi master akan cukup untuk menjeda semua suara, bukan membisukan suara. Dengan memutuskan koneksi node pada saat itu, kami menghentikan node dan turunannya agar tidak memproses dan memutar. Saat terhubung kembali, semua suara dan musik akan mulai diputar dari posisi terakhir, sama seperti gameplay yang akan dilanjutkan dari posisi terakhir. Namun, ini adalah perilaku yang tidak diharapkan. Memutuskan koneksi saja tidak cukup untuk menghentikan pemutaran.

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

Give Me a Beat

Sekarang kita telah menyiapkan beberapa hal. Kita memiliki grafik node. 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 memutar suara.

Daripada memutar beberapa salinan suara untuk beberapa instance entity game seperti karakter yang mati, Fieldrunners memutar satu suara hanya sekali selama durasi suara tersebut. Jika suara diperlukan setelah selesai diputar, suara dapat dimulai ulang, tetapi tidak saat sedang diputar. Ini adalah keputusan untuk desain audio Fieldrunners karena memiliki suara yang diminta untuk diputar dengan cepat, yang akan tersendat jika diizinkan untuk dimulai ulang atau membuat keributan yang tidak menyenangkan jika diizinkan untuk memutar beberapa instance. AudioBufferSourceNodes diharapkan digunakan sebagai one-shot. Buat node, lampirkan buffer, tetapkan nilai boolean loop jika diperlukan, hubungkan ke node pada grafik yang akan mengarah ke tujuan, panggil noteOn atau noteGrainOn, dan secara opsional panggil noteOff.

Untuk Fieldrunners, tampilannya akan seperti ini:

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

Terlalu Banyak Streaming

Fieldrunners awalnya diluncurkan dengan musik latar belakang yang diputar dengan tag Audio. Saat rilis, kami menemukan bahwa file musik diminta dengan frekuensi yang tidak sebanding dengan frekuensi permintaan konten game lainnya. Setelah melakukan beberapa riset, kami menemukan bahwa pada saat itu browser Chrome tidak meng-cache potongan file musik yang di-streaming. Hal ini menyebabkan browser meminta trek yang diputar setiap beberapa menit setelah selesai. Dalam pengujian terbaru, Chrome meng-cache lagu yang di-streaming, tetapi browser lain mungkin belum melakukannya. Streaming file audio berukuran besar dengan tag Audio untuk fungsi seperti pemutaran musik akan 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 Web Audio, kami juga memindahkan pemutaran musik latar ke Web Audio. Artinya, kita akan memuat trek dengan cara yang sama seperti memuat semua efek dengan XMLHttpRequest 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 && 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 sangat menyenangkan untuk dibawa ke Chrome dan HTML5. Selain pekerjaan yang berat untuk memasukkan ribuan baris C++ ke dalam JavaScript, beberapa dilema dan keputusan menarik yang khusus untuk HTML5 muncul. Untuk mengulanginya jika tidak ada yang lain, AudioBufferSourceNodes adalah objek sekali pakai. Buat, lampirkan Buffer Audio, hubungkan ke grafik Web Audio, dan putar dengan noteOn atau noteGrainOn. Perlu memutar suara itu lagi? Kemudian, buat AudioBufferSourceNode lain.