Fieldrunners
Fieldrunners یک بازی برنده جایزه به سبک دفاع از برج است که در ابتدا برای iPhone در سال ۲۰۰۸ منتشر شد. از آن زمان به پلاتفرمهای بسیار دیگری منتقل شده است. یکی از جدیدترین پلاتفرمها مرورگر Chrome در اکتبر ۲۰۱۱ بود. یکی از چالشهای انتقال Fieldrunners به پلاتفرم HTML5 نحوه پخش صدا بود.
Fieldrunners استفاده پیچیدهای از جلوههای صوتی نمیکند، اما انتظاراتی درباره نحوه تعامل آن با جلوههای صوتیاش وجود دارد. این بازی ۸۸ جلوه صوتی دارد که انتظار میرود تعداد زیادی از آنها بهطور همزمان پخش شوند. بیشتر این صداها بسیار کوتاه هستند و باید در سریعترین زمان ممکن پخش شوند تا از ایجاد هرگونه ناهماهنگی با ارائه گرافیکی جلوگیری شود.
چند چالش ظاهر شد
هنگام انتقال Fieldrunners به HTML5، با مشکلاتی در پخش صدا با برچسب Audio مواجه شدیم و در اوایل تصمیم گرفتیم بهجای آن روی Web Audio API تمرکز کنیم. استفاده از WebAudio به ما کمک کرد تا مشکلاتی مانند ارائه تعداد بالای جلوههای همزمان درحال پخش را که Fieldrunners نیاز دارد حل کنیم. بااینحال، هنگام توسعه سیستم صوتی برای Fieldrunners HTML5، با چند مشکل جزئی مواجه شدیم که ممکن است توسعهدهندگان دیگر بخواهند از آنها مطلع شوند.
ماهیت AudioBufferSourceNodes
AudioBufferSourceNodes روش اصلی شما برای پخش صدا با WebAudio است. بسیار مهم است که بدانید این کدها یکبارمصرف هستند. یک AudioBufferSourceNode ایجاد میکنید، یک میانگیر به آن اختصاص میدهید، آن را به نمودار متصل میکنید، و آن را با noteOn یا noteGrainOn پخش میکنید. پساز آن میتوانید noteOff را برای توقف بازپخش فراخوانی کنید، اما نمیتوانید با فراخوانی noteOn یا noteGrainOn منبع را دوباره پخش کنید - باید AudioBufferSourceNode دیگری ایجاد کنید. میتوانید - و این نکته کلیدی است - از همان شیء AudioBuffer زیربنایی دوباره استفاده کنید (درواقع، حتی میتوانید چندین AudioBufferSourceNode فعال داشته باشید که به همان نمونه AudioBuffer اشاره میکنند!). میتوانید گزیدهای از بازپخش Fieldrunners را در Give Me a Beat پیدا کنید.
محتوای غیرقابل ذخیره در حافظه نهان
در زمان انتشار، سرور Fieldrunners HTML5 تعداد زیادی درخواست برای فایلهای موسیقی نشان داد. این نتیجه از Chrome 15 ناشی شد که فایل را بهصورت تکهتکه بارگیری کرد و سپس آن را در حافظه نهان ذخیره نکرد. در پاسخ به این مشکل، تصمیم گرفتیم فایلهای موسیقی را مانند سایر فایلهای صوتی بار کنیم. انجام این کار بهینه نیست اما برخیاز نسخههای مرورگرهای دیگر هنوز این کار را انجام میدهند.
بیصدا کردن وقتی در کانون توجه نیست
قبلاً تشخیص اینکه برگه بازی شما چه زمانی از تمرکز خارج میشود دشوار بود. Fieldrunners قبلاز Chrome 13 شروع به انتقال کرد، جایی که Page Visibility API جایگزین نیاز به کد پیچیده ما برای تشخیص محو شدن برگه شد. همه بازیها باید از Visibility API برای نوشتن قطعه کد کوچکی استفاده کنند تا درصورت توقف موقت بازی، صدای آن را بیصدا یا موقتاً متوقف کنند. ازآنجاییکه Fieldrunners از میانای برنامهسازی کاربردی requestAnimationFrame استفاده میکرد، توقف بازی بهطور ضمنی انجام میشد، اما توقف صدا انجام نمیشد.
درحال توقف موقت صداها
جالب است که هنگام دریافت بازخورد برای این مقاله مطلع شدیم که تکنیکی که برای توقف موقت صداها استفاده میکردیم مناسب نبود - ما از یک اشکال در پیادهسازی فعلی Web Audio برای توقف موقت پخش صداها استفاده میکردیم. ازآنجاییکه این مشکل در آینده برطرف خواهد شد، نمیتوانید فقط با قطع کردن گره یا زیرگراف، صدا را موقتاً متوقف کنید تا بازپخش متوقف شود.
معماری گره Web Audio ساده
Fieldrunners مدل صوتی بسیار سادهای دارد. این مدل میتواند از مجموعه ویژگیهای زیر پشتیبانی کند:
- کنترل میزان صدای جلوههای صوتی.
- میزان صدای قطعه موسیقی پسزمینه را کنترل کنید.
- همه صداها را بیصدا کنید.
- پخش صدا هنگام توقف موقت بازی را خاموش کنید.
- وقتی بازی ازسر گرفته میشود، همان صداها را دوباره روشن کنید.
- وقتی برگه بازی از کانون توجه خارج میشود، همه صداها خاموش شود.
- درصورت نیاز، بازپخش را پساز پخش صدا بازراهاندازی کنید.
برای دستیابی به ویژگیهای بالا با Web Audio، از ۳ گره ممکن ارائهشده استفاده کرد: DestinationNode، GainNode، AudioBufferSourceNode. AudioBufferSourceNodes صداها را پخش میکند. GainNodes گرههای AudioBufferSourceNodes را به یکدیگر متصل میکند. DestinationNode که توسط بافت Web Audio ایجاد شده است و destination نامیده میشود، صداها را برای پخشکننده پخش میکند. Web Audio انواع بسیار بیشتری از گرهها دارد اما فقط با همینها میتوانیم نمودار بسیار سادهای برای صداها در بازی ایجاد کنیم.
نمودار گره Web Audio از گرههای برگ به گره مقصد منتهی میشود. Fieldrunners از ۶ گره بهره دائمی استفاده کرد، اما ۳ گره برای کنترل آسان میزان صدا و اتصال تعداد بیشتری گره موقت که میانگیرها را بازپخش میکنند کافی است. ابتدا یک گره بهره اصلی که هر گره فرزند را به مقصد متصل میکند. بلافاصله به گره بهره اصلی دو گره بهره متصل میشود، یکی برای کانال موسیقی و دیگری برای پیوند دادن همه جلوههای صوتی.
Fieldrunners بهدلیل استفاده نادرست از یک اشکال بهعنوان ویژگی، ۳ گره بهره اضافی داشت. از آن گرهها برای جدا کردن گروههای صداهای درحال پخش از نمودار استفاده کردیم که پیشرفت آنها را متوقف میکند. این کار را برای توقف موقت صداها انجام دادیم. چون این درست نیست، اکنون فقط از ۳ گره بهره کل همانطور که در بالا توضیح داده شد استفاده میکنیم. بسیاری از گزیدههای زیر شامل گرههای نادرست ما خواهند بود که نشان میدهند چه کاری انجام دادهایم و چگونه آن را در کوتاهمدت برطرف میکنیم. اما در درازمدت، بهتر است بعداز گره 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 );
}
اکثر بازیها امکان کنترل جداگانه جلوههای صوتی و موسیقی را فراهم میکنند. این کار را میتوان بهراحتی با نمودار بالا انجام داد. هر گره بهره دارای یک ویژگی «بهره» است که میتواند روی هر مقدار اعشاری بین ۰ و ۱ تنظیم شود، که میتواند برای کنترل اساسی میزان صدا استفاده شود. چون میخواهیم میزان صدای کانالهای موسیقی و جلوه صوتی را بهطور جداگانه کنترل کنیم، برای هرکدام یک گره بهره داریم که میتوانیم میزان صدای آنها را کنترل کنیم.
function setArbitraryVolume() {
var musicGainNode = this.nodes.backgroundMusicGain;
// set music volume to 50%
musicGainNode.gain.value = 0.5;
}
میتوانیم از همین توانایی برای کنترل میزان صدای همهچیز، جلوههای صوتی، و موسیقی استفاده کنیم. تنظیم بهره گره اصلی بر همه صداهای بازی تأثیر میگذارد. اگر مقدار بهره را روی ۰ تنظیم کنید، صدا و موسیقی را صامت خواهید کرد. AudioBufferSourceNodes یک پارامتر بهره نیز دارد. میتوانید فهرستی از همه صداهای درحال پخش را ردیابی کنید و مقادیر بهره آنها را بهصورت جداگانه برای حجم کلی صدا تنظیم کنید. اگر با برچسبهای صوتی جلوههای صوتی ایجاد میکردید، این کاری بود که باید انجام میدادید. درعوض، نمودار گره Web Audio تغییر دادن میزان صدای تعداد بیشماری صدا را بسیار آسانتر میکند. کنترل کردن میزان صدا به این روش، قدرت بیشتری را بدون پیچیدگی به شما میدهد. میتوانیم AudioBufferSourceNode را مستقیماً به گره اصلی برای پخش موسیقی متصل کنیم و بهره آن را کنترل کنیم. اما باید این مقدار را هربار که AudioBufferSourceNode را برای پخش موسیقی ایجاد میکنید تنظیم کنید. درعوض، فقط وقتی بازیکن میزان صدای موسیقی را تغییر میدهد و در زمان راهاندازی، یک گره را تغییر میدهید. اکنون مقدار بهرهای در منابع میانگیر داریم تا کار دیگری انجام دهیم. برای موسیقی، یک کاربرد رایج میتواند ایجاد یک محو تدریجی متقاطع از یک قطعه صوتی به قطعه دیگر باشد، بهطوریکه یکی خارج میشود و دیگری وارد میشود. Web Audio روش خوبی برای انجام آسان این کار ارائه میدهد.
function arbitraryCrossfade( track1, track2 ) {
track1.gain.linearRampToValueAtTime( 0, 1 );
track2.gain.linearRampToValueAtTime( 1, 1 );
}
Fieldrunners از محو تدریجی متقاطع استفاده خاصی نکرد. اگر در اولین بررسی سیستم صوتی از عملکرد تنظیم مقدار WebAudio مطلع بودیم، احتمالاً این کار را میکردیم.
درحال توقف موقت صداها
وقتی بازیکن بازی را موقتاً متوقف میکند، انتظار دارد برخیاز صداها همچنان پخش شوند. صدا بخش مهمی از بازخورد برای فشردن معمول عناصر میانای کاربر در منوهای بازی است. ازآنجاییکه Fieldrunners تعدادی میانای کاربری دارد که کاربر میتواند درحین توقف بازی با آنها تعامل داشته باشد، همچنان میخواهیم این میاناهای کاربری پخش شوند. اما نمیخواهیم صداهای طولانی یا تکرارشونده بهطور مداوم پخش شوند. متوقف کردن این صداها با Web Audio بسیار آسان است یا حداقل ما اینطور فکر میکردیم.
AudioManager.prototype.pauseEffects = function() {
this.nodes.effectsGain.disconnect();
}
گره جلوههای موقتاً متوقفشده هنوز متصل است. هر صدایی که مجاز به نادیده گرفتن وضعیت توقف موقت بازی باشد، درطول آن ادامه خواهد یافت. وقتی بازی از حالت توقف موقت خارج میشود، میتوانیم آن گرهها را دوباره متصل کنیم و همه صداها را فوراً دوباره پخش کنیم.
AudioManager.prototype.resumeEffects = function() {
this.nodes.effectsGain.connect( this.nodes.coreEffectsGain );
}
پساز عرضه Fieldrunners، متوجه شدیم که قطع اتصال یک گره یا زیرگراف بهتنهایی پخش AudioBufferSourceNodes را موقتاً متوقف نمیکند. ما درواقع از اشکالی در WebAudio استفاده کردیم که درحالحاضر پخش گرههایی را که به گره «مقصد» در نمودار متصل نیستند متوقف میکند. بنابراین برای اطمینان از اینکه برای آن اصلاح آینده آماده هستیم، به کدی مانند کد زیر نیاز داریم:
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;
}
}
};
اگر زودتر میدانستیم که داریم از یک اشکال سوءاستفاده میکنیم، ساختار کد صوتی ما بسیار متفاوت میبود. به همین دلیل، این موضوع بر تعدادی از بخشهای این مقاله تأثیر گذاشته است. این موضوع در اینجا و همچنین در تکهکدهای ما در Losing Focus و Give Me a Beat تأثیر مستقیم دارد. برای دانستن اینکه این درواقع چگونه کار میکند، نیاز به تغییراتی هم در نمودار گره Fieldrunners (چون گرههایی برای کوتاه کردن بازپخش ایجاد کردیم) و هم در کد اضافی است که حالتهای مکث را که Web Audio بهتنهایی انجام نمیدهد، ضبط و ارائه میکند.
ازدست دادن تمرکز
گره اصلی ما برای این ویژگی وارد عمل میشود. وقتی کاربر مرورگر به برگه دیگری میرود، بازی دیگر قابلمشاهده نیست. از دید خارج شد، از ذهن هم خارج شد، و صدا هم باید قطع شود. ترفندهایی وجود دارد که میتوان برای تعیین وضعیتهای رؤیتپذیری خاص برای صفحه بازی انجام داد، اما با Visibility API بسیار آسانتر شده است.
Fieldrunners فقط بهعنوان برگه فعال پخش میشود، زیرا از requestAnimationFrame برای فراخوانی حلقه بهروزرسانی خود استفاده میکند. اما بافت Web Audio به پخش جلوههای حلقهای و قطعههای پسزمینه ادامه میدهد درحالیکه کاربر در برگه دیگری است. اما میتوانیم با قطعه کد بسیار کوچکی که از 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();
}
});
}
قبلاز نوشتن این مقاله، فکر میکردیم قطع کردن اتصال اصلی برای موقتاً متوقف کردن همه صداها بهجای بیصدا کردن آنها کافی است. با قطع اتصال گره در آن زمان، پردازش و پخش آن و فرزندانش را متوقف کردیم. وقتی دوباره متصل شد، همه صداها و موسیقی از جایی که متوقف شده بودند شروع به پخش میکنند، درست همانطور که بازی از جایی که متوقف شده بود ادامه مییابد. اما این رفتار غیرمنتظره است. برای توقف بازپخش، فقط قطع اتصال کافی نیست.
«میانای برنامهسازی کاربردی رؤیتپذیری صفحه» تشخیص اینکه برگه شما دیگر در کانون توجه نیست را بسیار آسان میکند. اگر ازقبل کد مؤثری برای توقف موقت صداها دارید، فقط چند خط لازم است تا توقف موقت صدا را هنگام پنهان شدن برگه بازیها بنویسید.
یک ضربآهنگ برایم پخش کن
اکنون چند مورد را راهاندازی کردهایم. گرافی از گرهها داریم. میتوانیم وقتی بازیکن بازی را موقتاً متوقف میکند صداها را موقتاً متوقف کنیم و برای عناصری مثل منوهای بازی صداهای جدید پخش کنیم. وقتی کاربر به برگه جدیدی میرود، میتوانیم همه صداها و موسیقیها را موقتاً متوقف کنیم. اکنون باید واقعاً صدایی پخش کنیم.
بهجای پخش چندین نسخه از صدا برای چندین نمونه از یک موجودیت بازی مانند مرگ یک شخصیت، Fieldrunners یک صدا را فقط یکبار در طول مدت آن پخش میکند. اگر پساز پایان پخش صدا به آن نیاز باشد، میتواند بازراهاندازی شود اما نه درحالیکه درحال پخش است. این تصمیمی برای طراحی صوتی Fieldrunners است زیرا صداهایی دارد که درخواست میشود بهسرعت پخش شوند و درصورت مجاز بودن به بازراهاندازی، دچار لکنت میشوند یا درصورت مجاز بودن به پخش چندین نمونه، ناهنجاری ناخوشایندی ایجاد میکنند. انتظار میرود AudioBufferSourceNodes بهعنوان یکباره استفاده شود. گره ایجاد کنید، میانگیر متصل کنید، درصورت نیاز مقدار بولی حلقه را تنظیم کنید، به گرهای در نمودار که به مقصد منتهی میشود متصل شوید، noteOn یا noteGrainOn را فراخوانی کنید، و درصورت تمایل noteOff را فراخوانی کنید.
برای Fieldrunners چیزی شبیه این است:
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 );
}
}
جاریسازی بیشازحد
Fieldrunners در ابتدا با موسیقی پسزمینه پخششده با برچسب «صدا» راهاندازی شد. در زمان انتشار، متوجه شدیم که فایلهای موسیقی بهطور نامتناسبی بیشتر از سایر محتوای بازی درخواست میشوند. پساز کمی تحقیق متوجه شدیم که در آن زمان مرورگر Chrome بخشهای جاریسازیشده فایلهای موسیقی را ذخیره نمیکرد. این امر باعث شد که مرورگر هر چند دقیقه یکبار با پایان یافتن قطعه درحال پخش، آن را درخواست کند. در آزمایشهای اخیر، Chrome قطعههای جاریسازیشده را ذخیره کرد، اما ممکن است مرورگرهای دیگر هنوز این کار را انجام ندهند. جاریسازی فایلهای صوتی بزرگ با برچسب «صدا» برای عملکردهایی مثل بازپخش موسیقی بهینه است، اما برای برخیاز نسخههای مرورگر ممکن است بخواهید موسیقیتان را به همان روشی که جلوههای صوتی را بار میکنید بار کنید.
ازآنجاییکه همه جلوههای صوتی ازطریق Web Audio پخش میشدند، پخش موسیقی پسزمینه را نیز به Web Audio منتقل کردیم. این یعنی قطعهها را به همان روشی بار میکنیم که همه جلوهها را با XMLHttpRequests و نوع پاسخ 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();
}
خلاصه
Fieldrunners برای انتقال به Chrome و HTML5 عالی بود. خارج از کوه کار خود را آوردن هزاران خط C++ به جاوا اسکریپت، برخی از معضلات جالب و تصمیمات خاص به HTML5 برانگیختن. برای تکرار یکی از آنها، اگر هیچکدام از موارد دیگر نباشد، AudioBufferSourceNodes اشیای یکبار مصرف هستند. آنها را ایجاد کنید، یک Audio Buffer پیوست کنید، آن را به نمودار Web Audio متصل کنید، و با noteOn یا noteGrainOn پخش کنید. میخواهید آن صدا را دوباره پخش کنید؟ سپس AudioBufferSourceNode دیگری ایجاد کنید.