Nghiên cứu điển hình – Câu chuyện về một trò chơi HTML5 có Web Audio

Fieldrunners

Ảnh chụp màn hình Fieldrunners
Ảnh chụp màn hình Fieldrunners

Fieldrunners là một trò chơi thủ thành từng đoạt giải thưởng, ban đầu được phát hành cho iPhone vào năm 2008. Kể từ đó, trò chơi này đã được chuyển sang nhiều nền tảng khác. Một trong những nền tảng mới nhất là trình duyệt Chrome vào tháng 10 năm 2011. Một trong những thách thức khi chuyển Fieldrunners sang nền tảng HTML5 là cách phát âm thanh.

Fieldrunners không sử dụng hiệu ứng âm thanh phức tạp, nhưng có một số kỳ vọng về cách tương tác với hiệu ứng âm thanh. Trò chơi có 88 hiệu ứng âm thanh, trong đó có thể có nhiều hiệu ứng âm thanh phát cùng một lúc. Hầu hết các âm thanh này đều rất ngắn và cần được phát kịp thời nhất có thể để tránh tạo ra bất kỳ sự ngắt kết nối nào với bản trình bày đồ hoạ.

Một số thử thách đã xuất hiện

Trong khi chuyển Fieldrunners sang HTML5, chúng tôi gặp phải vấn đề về việc phát âm thanh bằng thẻ Âm thanh và sớm quyết định tập trung vào API Âm thanh web. Việc sử dụng WebAudio đã giúp chúng tôi giải quyết các vấn đề như cung cấp cho chúng tôi số lượng lớn hiệu ứng đồng thời phát lại mà Fieldrunners yêu cầu. Tuy nhiên, trong khi phát triển hệ thống âm thanh cho Fieldrunners HTML5, chúng tôi đã gặp phải một số vấn đề nhỏ mà các nhà phát triển khác có thể cần lưu ý.

Bản chất của AudioBufferSourceNodes

AudioBufferSourceNodes là phương thức chính để phát âm thanh bằng WebAudio. Bạn cần phải hiểu rằng đây là đối tượng dùng một lần. Bạn tạo một AudioBufferSourceNode, chỉ định một vùng đệm cho nút đó, kết nối nút đó với biểu đồ và phát nút đó bằng noteOn hoặc noteGrainOn. Sau đó, bạn có thể gọi noteOff để dừng phát, nhưng bạn sẽ không thể phát lại nguồn bằng cách gọi noteOn hoặc noteGrainOn – bạn phải tạo một AudioBufferSourceNode khác. Tuy nhiên, bạn có thể (và đây là điểm mấu chốt) sử dụng lại cùng một đối tượng AudioBuffer cơ bản (trên thực tế, bạn thậm chí có thể có nhiều AudioBufferSourceNode đang hoạt động trỏ đến cùng một thực thể AudioBuffer!). Bạn có thể tìm thấy một đoạn phát lại của Fieldrunners trong Give Me a Beat.

Nội dung không lưu vào bộ nhớ đệm

Khi phát hành, máy chủ HTML5 của Fieldrunners đã nhận được một lượng lớn yêu cầu về tệp âm nhạc. Kết quả này là do Chrome 15 tiến hành tải tệp xuống theo từng phần rồi không lưu tệp vào bộ nhớ đệm. Để giải quyết vấn đề này, chúng tôi đã quyết định tải tệp nhạc giống như các tệp âm thanh khác. Việc này không tối ưu nhưng một số phiên bản của các trình duyệt khác vẫn làm như vậy.

Tắt tiếng khi không tập trung

Trước đây, việc phát hiện thời điểm thẻ của trò chơi không được lấy tiêu điểm là rất khó khăn. Fieldrunners bắt đầu chuyển đổi trước khi Chrome 13 ra mắt, trong đó API Hiển thị trang đã thay thế nhu cầu về mã phức tạp của chúng tôi để phát hiện việc làm mờ thẻ. Mọi trò chơi đều nên sử dụng Visibility API để viết một đoạn mã nhỏ nhằm tắt tiếng hoặc tạm dừng âm thanh nếu không tạm dừng toàn bộ trò chơi. Vì Fieldrunners sử dụng API requestAnimationFrame, nên việc tạm dừng trò chơi được xử lý ngầm, nhưng không tạm dừng âm thanh.

Tạm dừng âm thanh

Thật kỳ lạ, trong khi nhận được phản hồi về bài viết này, chúng tôi được thông báo rằng kỹ thuật mà chúng tôi đang sử dụng để tạm dừng âm thanh không phù hợp – chúng tôi đang sử dụng một lỗi trong quá trình triển khai hiện tại của Web Audio để tạm dừng phát âm thanh. Vì vấn đề này sẽ được khắc phục trong tương lai, nên bạn không thể chỉ tạm dừng âm thanh bằng cách ngắt kết nối một nút hoặc đồ thị con để tạm dừng phát.

Cấu trúc nút Web Audio đơn giản

Fieldrunners có mô hình âm thanh rất đơn giản. Mô hình đó có thể hỗ trợ tập tính chất sau:

  • Điều chỉnh âm lượng của hiệu ứng âm thanh.
  • Điều chỉnh âm lượng của bản nhạc nền.
  • Tắt tất cả âm thanh.
  • Tắt âm thanh khi trò chơi bị tạm dừng.
  • Bật lại những âm thanh đó khi trò chơi tiếp tục.
  • Tắt tất cả âm thanh khi thẻ của trò chơi mất tiêu điểm.
  • Khởi động lại quá trình phát sau khi phát âm thanh nếu cần.

Để đạt được các tính năng trên bằng Web Audio, ứng dụng này đã sử dụng 3 nút có thể có: DestinationNode, GainNode, AudioBufferSourceNode. AudioBufferSourceNodes phát âm thanh. GainNodes kết nối các AudioBufferSourceNodes với nhau. DestinationNode, do ngữ cảnh Web Audio tạo, được gọi là đích đến, phát âm thanh cho người chơi. Web Audio có nhiều loại nút khác nhưng chỉ với những nút này, chúng ta có thể tạo một biểu đồ rất đơn giản cho âm thanh trong trò chơi.

Biểu đồ đồ thị nút

Biểu đồ nút Âm thanh web dẫn từ các nút lá đến nút đích. Fieldrunners sử dụng 6 nút khuếch đại cố định, nhưng 3 nút là đủ để dễ dàng kiểm soát âm lượng và kết nối số lượng lớn hơn các nút tạm thời sẽ phát lại vùng đệm. Đầu tiên là nút khuếch đại chính, kết nối mọi nút con với đích đến. Ngay sau nút khuếch đại chính là hai nút khuếch đại, một nút cho kênh nhạc và một nút để liên kết tất cả hiệu ứng âm thanh.

Fieldrunners có thêm 3 nút khuếch đại do sử dụng không đúng cách một lỗi dưới dạng tính năng. Chúng tôi đã sử dụng các nút đó để cắt bớt các nhóm âm thanh đang phát khỏi biểu đồ, từ đó dừng tiến trình của chúng. Chúng tôi làm điều này để tạm dừng âm thanh. Vì cách này không chính xác, nên giờ đây, chúng ta sẽ chỉ sử dụng tổng cộng 3 nút khuếch đại như mô tả ở trên. Nhiều đoạn mã sau đây sẽ bao gồm các nút không chính xác, cho thấy những gì chúng tôi đã làm và cách chúng tôi khắc phục vấn đề đó trong thời gian ngắn. Nhưng về lâu dài, bạn không nên sử dụng các nút của chúng tôi sau nút 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 );
}

Hầu hết các trò chơi đều cho phép điều khiển riêng hiệu ứng âm thanh và nhạc. Bạn có thể dễ dàng thực hiện việc này bằng biểu đồ ở trên. Mỗi nút khuếch đại có một thuộc tính "gain" (khuếch đại) có thể được đặt thành bất kỳ giá trị thập phân nào từ 0 đến 1, có thể dùng để điều khiển âm lượng. Vì muốn kiểm soát âm lượng của kênh nhạc và kênh hiệu ứng âm thanh riêng biệt, nên chúng ta có một nút khuếch đại cho mỗi kênh để kiểm soát âm lượng của kênh đó.

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

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

Chúng ta có thể sử dụng cùng một khả năng này để điều khiển âm lượng của mọi thứ, của hiệu ứng âm thanh và âm nhạc. Việc đặt mức tăng của nút chính sẽ ảnh hưởng đến tất cả âm thanh trong trò chơi. Nếu đặt giá trị khuếch đại thành 0, bạn sẽ tắt âm thanh và nhạc. AudioBufferSourceNodes cũng có tham số khuếch đại. Bạn có thể theo dõi danh sách tất cả âm thanh đang phát và điều chỉnh riêng giá trị khuếch đại của từng âm thanh để có âm lượng tổng thể. Nếu đang tạo hiệu ứng âm thanh bằng thẻ Âm thanh, thì bạn sẽ phải làm như vậy. Thay vào đó, biểu đồ nút của Web Audio giúp bạn dễ dàng sửa đổi âm lượng của vô số âm thanh. Cách điều khiển âm lượng này cũng giúp bạn có thêm quyền kiểm soát mà không cần thao tác phức tạp. Chúng ta có thể chỉ cần đính kèm trực tiếp AudioBufferSourceNode vào nút chính để phát nhạc và kiểm soát mức tăng của chính nút đó. Nhưng bạn sẽ phải đặt giá trị này mỗi khi tạo AudioBufferSourceNode để phát nhạc. Thay vào đó, bạn chỉ thay đổi một nút khi người chơi thay đổi âm lượng nhạc và khi khởi chạy. Bây giờ, chúng ta có một giá trị khuếch đại trên các nguồn vùng đệm để làm việc khác. Đối với nhạc, một cách sử dụng phổ biến có thể là tạo hiệu ứng chuyển tiếp từ bản âm thanh này sang bản âm thanh khác khi một bản âm thanh kết thúc và một bản âm thanh khác bắt đầu. Web Audio cung cấp một phương thức hiệu quả để thực hiện việc này một cách dễ dàng.

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

Fieldrunners không sử dụng hiệu ứng chuyển mờ cụ thể. Nếu biết về chức năng cài đặt giá trị của WebAudio trong lần đầu tiên sử dụng hệ thống âm thanh, chúng tôi có thể đã làm vậy.

Tạm dừng âm thanh

Khi người chơi tạm dừng trò chơi, họ có thể muốn một số âm thanh vẫn tiếp tục phát. Âm thanh là một phần quan trọng trong phản hồi cho thao tác nhấn thông thường vào các thành phần giao diện người dùng trong trình đơn trò chơi. Vì Fieldrunners có một số giao diện để người dùng tương tác trong khi trò chơi bị tạm dừng, nên chúng tôi vẫn muốn những giao diện đó hoạt động. Tuy nhiên, chúng ta không muốn bất kỳ âm thanh dài hoặc lặp lại nào tiếp tục phát. Việc dừng những âm thanh đó bằng Web Audio khá dễ dàng, hoặc ít nhất là chúng tôi nghĩ vậy.

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

Nút hiệu ứng bị tạm dừng vẫn được kết nối. Mọi âm thanh được phép bỏ qua trạng thái tạm dừng của trò chơi sẽ tiếp tục phát trong suốt thời gian đó. Khi trò chơi tiếp tục, chúng ta có thể kết nối lại các nút đó và phát lại tất cả âm thanh ngay lập tức.

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

Sau khi phát hành Fieldrunners, chúng tôi phát hiện ra rằng việc ngắt kết nối một nút hoặc đồ thị con sẽ không tạm dừng việc phát AudioBufferSourceNodes. Chúng tôi thực sự đã tận dụng một lỗi trong WebAudio hiện đang dừng phát các nút không được kết nối với nút Đích trong biểu đồ. Vì vậy, để đảm bảo chúng ta sẵn sàng cho bản sửa lỗi trong tương lai, chúng ta cần một số mã như sau:

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

Nếu biết sớm hơn rằng chúng tôi đang lạm dụng một lỗi, thì cấu trúc mã âm thanh của chúng tôi sẽ rất khác. Do đó, một số phần trong bài viết này đã bị ảnh hưởng. Nó có hiệu ứng trực tiếp ở đây nhưng cũng có trong các đoạn mã của chúng tôi trong phần Mất tiêu điểm và Cho tôi một nhịp. Để biết cách hoạt động thực tế của tính năng này, bạn cần thay đổi cả biểu đồ nút Fieldrunners (vì chúng tôi đã tạo các nút để rút ngắn quá trình phát) và mã bổ sung sẽ ghi lại và cung cấp các trạng thái tạm dừng mà Web Audio không tự thực hiện.

Mất tiêu điểm

Nút chính của chúng tôi sẽ phát huy tác dụng cho tính năng này. Khi người dùng trình duyệt chuyển sang một thẻ khác, trò chơi sẽ không còn hiển thị nữa. Xa mặt cách lòng, và âm thanh cũng nên biến mất. Có một số thủ thuật có thể dùng để xác định trạng thái hiển thị cụ thể cho trang của trò chơi, nhưng API Chế độ hiển thị giúp việc này trở nên dễ dàng hơn nhiều.

Fieldrunners sẽ chỉ phát dưới dạng thẻ đang hoạt động nhờ sử dụng requestAnimationFrame để gọi vòng lặp cập nhật. Tuy nhiên, bối cảnh Âm thanh web sẽ tiếp tục phát các hiệu ứng lặp lại và bản nhạc nền trong khi người dùng đang ở một thẻ khác. Nhưng chúng ta có thể ngăn chặn điều đó bằng một đoạn mã rất nhỏ nhận biết 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();
    }
  });
}

Trước khi viết bài viết này, chúng tôi nghĩ rằng việc ngắt kết nối bản chính là đủ để tạm dừng tất cả âm thanh thay vì tắt tiếng. Bằng cách ngắt kết nối nút tại thời điểm đó, chúng tôi đã ngăn nút đó và các nút con của nút đó xử lý và phát. Khi kết nối lại, tất cả âm thanh và nhạc sẽ bắt đầu phát từ thời điểm bị ngắt, giống như trò chơi sẽ tiếp tục từ thời điểm bị ngắt. Nhưng đây là hành vi không mong muốn. Chỉ ngắt kết nối thôi thì chưa đủ để tạm dừng phát.

API Chế độ hiển thị trang giúp bạn dễ dàng biết được khi nào thẻ của bạn không còn được chú ý. Nếu đã có mã hiệu quả để tạm dừng âm thanh, bạn chỉ cần viết vài dòng để tạm dừng âm thanh khi thẻ trò chơi bị ẩn.

Cho tôi nghe vài giai điệu nào

Chúng ta đã thiết lập một vài thứ. Chúng ta có một biểu đồ gồm các nút. Chúng ta có thể tạm dừng âm thanh khi người chơi tạm dừng trò chơi và phát âm thanh mới cho các thành phần như trình đơn trò chơi. Chúng ta có thể tạm dừng tất cả âm thanh và nhạc khi người dùng chuyển sang thẻ mới. Bây giờ, chúng ta cần phát âm thanh.

Thay vì phát nhiều bản sao âm thanh cho nhiều thực thể của một thực thể trò chơi như nhân vật chết, Fieldrunners chỉ phát một âm thanh một lần trong suốt thời lượng của âm thanh đó. Nếu cần âm thanh sau khi phát xong, thì âm thanh đó có thể khởi động lại nhưng không phải khi đang phát. Đây là quyết định cho thiết kế âm thanh của Fieldrunners vì nó có âm thanh được yêu cầu phát nhanh, nếu không sẽ bị giật nếu được phép khởi động lại hoặc tạo ra một bản nhạc không thú vị nếu được phép phát nhiều phiên bản. AudioBufferSourceNodes dự kiến sẽ được dùng làm một lần. Tạo một nút, đính kèm một vùng đệm, đặt giá trị boolean lặp lại nếu cần, kết nối với một nút trên biểu đồ sẽ dẫn đến đích, gọi noteOn hoặc noteGrainOn và gọi noteOff (không bắt buộc).

Đối với Fieldrunners, mã này có dạng như sau:

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

Phát trực tuyến quá nhiều

Fieldrunners ban đầu được ra mắt với nhạc nền được phát bằng thẻ Audio. Khi phát hành, chúng tôi phát hiện ra rằng tệp âm nhạc đang được yêu cầu với số lần không tương xứng so với phần còn lại của nội dung trò chơi. Sau khi nghiên cứu, chúng tôi phát hiện ra rằng tại thời điểm đó, trình duyệt Chrome không lưu các đoạn tệp âm nhạc được truyền trực tuyến vào bộ nhớ đệm. Điều này khiến trình duyệt yêu cầu phát bản nhạc sau mỗi vài phút khi bản nhạc kết thúc. Trong thử nghiệm gần đây, Chrome đã lưu các bản nhạc được phát trực tuyến vào bộ nhớ đệm, tuy nhiên, các trình duyệt khác có thể chưa làm được việc này. Việc truyền trực tuyến các tệp âm thanh lớn bằng thẻ Audio cho các chức năng như phát nhạc là tối ưu, nhưng đối với một số phiên bản trình duyệt, bạn có thể muốn tải nhạc theo cách bạn tải hiệu ứng âm thanh.

Vì tất cả hiệu ứng âm thanh đều phát qua Web Audio, nên chúng tôi cũng chuyển việc phát nhạc nền sang Web Audio. Điều này có nghĩa là chúng tôi sẽ tải các bản nhạc theo cách tương tự như khi tải tất cả hiệu ứng bằng XMLHttpRequest và loại phản hồi 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();
}

Tóm tắt

Fieldrunners là một trò chơi rất thú vị khi đưa lên Chrome và HTML5. Ngoài khối lượng công việc khổng lồ để chuyển đổi hàng nghìn dòng mã C++ sang JavaScript, một số tình huống khó xử và quyết định thú vị dành riêng cho HTML5 cũng xuất hiện. Xin nhắc lại một điều nếu không có điều nào khác, AudioBufferSourceNodes là đối tượng dùng một lần. Tạo các nút này, đính kèm Audio Buffer, kết nối với biểu đồ Web Audio và phát bằng noteOn hoặc noteGrainOn. Bạn cần phát lại âm thanh đó? Sau đó, hãy tạo một AudioBufferSourceNode khác.