Fieldrunners
Fieldrunners là một trò chơi kiểu phòng thủ tháp từng đoạt giải thưởng, ban đầu được phát hành cho iPhone vào năm 2008. Kể từ đó, nó đã được chuyển sang nhiều nền tảng khác. Một trong những nền tảng gần đây 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 một cách 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ó một số lượng lớn hiệu ứng phát cùng một lúc. Hầu hết các âm thanh này rất ngắn và cần được phát kịp thời nhất có thể để tránh tạo ra sự ngắt kết nối với bản trình bày đồ hoạ.
Một số thử thách đã xuất hiện
Trong quá trình 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à quyết định tập trung vào Web Audio API ngay từ đầu. Việc sử dụng WebAudio đã giúp chúng tôi giải quyết các vấn đề như cung cấp số lượng hiệu ứng phát đồng thời cao mà Fieldrunners yêu cầu. Tuy nhiên, trong quá trình 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ể muố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. Điều quan trọng là bạn phải hiểu rằng đó là đối tượng dùng một lần. Bạn tạo một AudioBufferSourceNode, chỉ định vùng đệm cho AudioBufferSourceNode, kết nối AudioBufferSourceNode với biểu đồ và phát AudioBufferSourceNode bằng noteOn hoặc noteGrainOn. Sau đó, bạn có thể gọi noteOff để dừng phát, nhưng 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 chính) sử dụng lại cùng một đối tượng AudioBuffer cơ bản (thực tế, bạn thậm chí có thể có nhiều AudioBufferSourceNodes đang hoạt động trỏ đến cùng một thực thể AudioBuffer!). Bạn có thể tìm thấy đoạn trích phát từ Fieldrunners trong Give Me a Beat.
Nội dung không lưu vào bộ nhớ đệm
Tại thời điểm phát hành, máy chủ HTML5 của Fieldrunners cho thấy một số lượng lớn yêu cầu về tệp nhạc. Kết quả này là do Chrome 15 tiếp tục tải tệp xuống theo từng phần rồi không lưu 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 như các tệp âm thanh còn lại. Việc này không tối ưu nhưng một số phiên bản trình duyệt khác vẫn thực hiện việc này.
Tắt tiếng khi không lấy nét
Trước đây, việc phát hiện thời điểm thẻ trò chơi của bạn mất tiêu điểm rất khó. Fieldrunners bắt đầu chuyển đổi trước Chrome 13, trong đó Page Visibility API (API Khả năng hiển thị trang) đã thay thế nhu cầu sử dụng mã phức tạp của chúng tôi để phát hiện hiệu ứng làm mờ thẻ. Mọi trò chơi đều phải sử dụng API Visibility để viết một đoạn mã nhỏ nhằm tắt 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 phải tạm dừng âm thanh.
Tạm dừng âm thanh
Thật kỳ lạ là trong khi nhận được ý kiến 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 chúng tôi đang sử dụng để tạm dừng âm thanh là không phù hợp – chúng tôi đang sử dụng một lỗi trong cách 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 để dừng phát.
Cấu trúc nút âm thanh web đơn giản
Fieldrunners có một mô hình âm thanh rất đơn giản. Mô hình đó có thể hỗ trợ bộ tính năng sau:
- Kiểm soát âm lượng của hiệu ứng âm thanh.
- Điều khiển âm lượng của bản nhạc phát trong nền.
- Tắt tất cả âm thanh.
- Tắt âm thanh phát 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 trong số các nút có thể cung cấp: 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 ra, được gọi là đích đến, phát âm thanh cho trình phát. Web Audio có nhiều loại nút hơn, 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 đồ nút Âm thanh trên web dẫn từ nút lá đến nút đích. Fieldrunners sử dụng 6 nút tăng cường cố định, nhưng 3 nút là đủ để dễ dàng kiểm soát âm lượng và kết nối nhiều nút tạm thời hơn sẽ phát các vùng đệm. Trước tiên, nút lợi ích chính sẽ đính kèm mọi nút con vào đích đến. Ngay lập tức, hai nút gain (độ lợi) được đính kèm vào nút gain chính, 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 tăng do sử dụng lỗi làm tính năng không chính xác. Chúng tôi đã sử dụng các nút đó để cắt các nhóm âm thanh phát từ biểu đồ, giúp dừng tiến trình của các nhóm âm thanh đó. Chúng tôi làm như vậy để tạm dừng âm thanh. Vì không chính xác, nên hiện tại chúng ta sẽ chỉ sử dụng 3 nút tổng độ lợ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 ta đã làm và cách khắc phục trong ngắn hạn. Tuy nhiên, 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 tăng cường có một thuộc tính "gain" (mức tăng cường) có thể được đặt thành bất kỳ giá trị thập phân nào trong khoảng từ 0 đến 1. Giá trị này có thể được dùng để kiểm soát âm lượng. Vì chúng ta muốn điều khiển âm lượng của kênh nhạc và hiệu ứng âm thanh riêng biệt, nên chúng ta có một nút tăng âm lượng cho mỗi kênh để có thể điều khiển âm lượng của chúng.
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 khả năng tương tự để điều khiển âm lượng của mọi thứ, của hiệu ứng âm thanh và nhạc. Việc đặt độ lợi 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ị độ lợi thành 0, bạn sẽ tắt âm thanh và nhạc. AudioBufferSourceNodes cũng có tham số tăng. 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ị tăng của từng âm thanh cho âm lượng tổng thể. Nếu đang tạo hiệu ứng âm thanh bằng thẻ Âm thanh, 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. Việc điều chỉnh âm lượng theo cách này cũng giúp bạn có thêm năng lượng mà không gặp rắc rối. Chúng ta chỉ cần đính kèm AudioBufferSourceNode trực tiếp vào nút chính để phát nhạc và kiểm soát độ lợi của chính nút đó. Tuy nhiên, 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ị tăng trên các nguồn bộ đệm để làm một việc khác. Đối với âm nhạc, một cách sử dụng phổ biến là tạo hiệu ứng chuyển tiếp từ một bản âm thanh sang một 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 hay để 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 tiếp cụ thể. Nếu biết chức năng đặt giá trị của WebAudio trong lần truyền ban đầu của hệ thống âm thanh, chúng ta có thể đã làm được.
Âm thanh tạm dừng
Khi người chơi tạm dừng trò chơi, họ có thể mong đợi một số âm thanh vẫn phát. Âm thanh là một phần quan trọng trong phản hồi cho các thao tác nhấn phổ biến trên 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 ta vẫn muốn những giao diện đó tiếp tục 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. Dễ dàng dừng những âm thanh đó bằng Web Audio, hoặc ít nhất 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 qua trạng thái đó. 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 vận chuyển Fieldrunners, chúng tôi phát hiện ra rằng việc chỉ 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 đến 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 ta đang lợi dụng một lỗi, thì cấu trúc mã âm thanh của chúng ta sẽ rất khác. Do đó, việc này đã ảnh hưởng đến một số phần của bài viết này. Phương thức này có tác động trực tiếp ở đây, nhưng cũng có tác động trong các đoạn mã của chúng ta trong Losing Focus và Give Me a Beat. Để biết cách thức 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 ta đã tạo các nút để rút ngắn thời gian 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ẽ đóng vai trò quan trọng đối với 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 xuất hiện. Không nhìn thấy thì không nhớ, nên âm thanh cũng sẽ biến mất. Có một số thủ thuật có thể được thực hiện để xác định trạng thái hiển thị cụ thể cho trang của trò chơi, nhưng việc này đã trở nên dễ dàng hơn nhiều nhờ Visibility API.
Fieldrunners sẽ chỉ chơi 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, ngữ cảnh Âm thanh trên web sẽ tiếp tục phát các hiệu ứng lặp lại và bản nhạc trong 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ã nhận biết API Visibility (API Khả năng hiển thị) rất nhỏ.
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 kênh 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 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ừ nơi dừng lại, giống như trò chơi sẽ tiếp tục từ nơi dừng lại. Nhưng đây là hành vi ngoài dự kiến. Bạn không chỉ cần ngắt kết nối để dừng phát.
API Chế độ hiển thị của trang giúp bạn dễ dàng biết được thời điểm thẻ không còn ở tiêu điểm. Nếu bạn đã 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.
Give Me a Beat
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 một thẻ mới. Bây giờ, chúng ta cần thực sự 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ư một nhân vật chết, Fieldrunners chỉ phát một âm thanh một lần trong thời lượng của âm thanh đó. Nếu cần âm thanh sau khi phát xong, thì âm thanh có thể bắt đầu lại nhưng không được phát trong khi đang phát. Đây là quyết định cho thiết kế âm thanh của Fieldrunners vì trò chơi này có những âm thanh được yêu cầu phát nhanh, nếu được phép khởi động lại thì sẽ bị giật hoặc tạo ra âm thanh hỗn tạp khó chịu nếu được phép phát nhiều bản sao. AudioBufferSourceNodes dự kiến sẽ được dùng dưới dạng một lần. Tạo một nút, đính kèm một vùng đệm, đặt giá trị boolean của vòng lặp 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à tuỳ ý gọi noteOff.
Đối với Fieldrunners, mã sẽ 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 );
}
}
Quá nhiều nội dung phát trực tuyến
Ban đầu, Fieldrunners được phát hành với nhạc nền được phát bằng thẻ Âm thanh. Khi phát hành, chúng tôi phát hiện thấy các tệp nhạc được yêu cầu với số lần không tương xứng so với số lần yêu cầu nội dung trò chơi còn lạ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 phát trực tuyến của tệp nhạc 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 các thử nghiệm gần đây, Chrome đã lưu các bản nhạc phát trực tuyến vào bộ nhớ đệm, nhưng các trình duyệt khác có thể chưa làm việc này. Phát trực tuyến các tệp âm thanh lớn bằng thẻ Âm thanh 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 nên tải nhạc theo cách tương tự như 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 ta sẽ tải các kênh theo cách tương tự như cách 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 thú vị để mang đến Chrome và HTML5. Ngoài hàng núi công việc đưa hàng nghìn dòng C++ vào javascript, một số vấn đề nan giải 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 tượng nào khác, AudioBufferSourceNodes là các đối tượng sử dụng một lần. Tạo các noteOn, đính kèm một 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.