Fieldrunners
Fieldrunners هي لعبة استراتيجية ناجحة من نوع ألعاب الدفاع عن الأبراج، وقد تم إصدارها في الأصل على أجهزة iPhone في عام 2008. ومنذ ذلك الحين، تم نقله إلى العديد من المنصات الأخرى. كان متصفّح Chrome أحد أحدث المنصات في تشرين الأول (أكتوبر) 2011. كان من بين التحديات التي واجهناها في نقل لعبة Fieldrunners إلى منصة HTML5 كيفية تشغيل الصوت.
لا تستخدم لعبة Fieldrunners التأثيرات الصوتية بشكل معقّد، ولكنّها تتضمّن بعض التوقعات حول كيفية تفاعلها مع التأثيرات الصوتية. تحتوي اللعبة على 88 تأثيرًا صوتيًا، ومن المتوقّع أن يتم تشغيل عدد كبير منها في الوقت نفسه. معظم هذه الأصوات قصيرة جدًا ويجب تشغيلها في الوقت المناسب قدر الإمكان لتجنّب حدوث أي انقطاع في العرض المرئي.
ظهرت بعض التحديات
أثناء نقل لعبة Fieldrunners إلى HTML5، واجهنا مشاكل في تشغيل الصوت باستخدام علامة Audio وقرّرنا في وقت مبكر التركيز على Web Audio API بدلاً من ذلك. ساعدنا استخدام WebAudio في حلّ مشاكل، مثل توفير عدد كبير من التأثيرات المتزامنة التي تتطلبها لعبة Fieldrunners. ومع ذلك، أثناء تطوير نظام صوتي للعبة Fieldrunners HTML5، واجهنا بعض المشاكل الدقيقة التي قد يريد المطوّرون الآخرون الانتباه إليها.
طبيعة AudioBufferSourceNodes
إنّ AudioBufferSourceNodes هي الطريقة الأساسية لتشغيل الأصوات باستخدام WebAudio. من المهم جدًا معرفة أنّه يمكن استخدامها لمرة واحدة فقط. يمكنك إنشاء AudioBufferSourceNode وتخصيص مخزن مؤقت لها وربطها بالرسم البياني وتشغيلها باستخدام noteOn أو noteGrainOn. بعد ذلك، يمكنك استدعاء noteOff لإيقاف التشغيل، ولكن لن تتمكّن من تشغيل المصدر مرة أخرى من خلال استدعاء noteOn أو noteGrainOn، وعليك إنشاء AudioBufferSourceNode آخر. يمكنك إعادة استخدام عنصر AudioBuffer الأساسي نفسه، وهذا هو المفتاح (في الواقع، يمكنك أيضًا الحصول على عدّة عناصر AudioBufferSourceNodes نشطة تشير إلى مثيل AudioBuffer نفسه). يمكنك العثور على مقتطف تشغيل من لعبة Fieldrunners في ميزة "أريد موسيقى".
المحتوى الذي لا يتم تخزينه مؤقتًا
عند الإصدار، أظهر خادم Fieldrunners HTML5 عددًا كبيرًا من طلبات ملفات الموسيقى. حدثت هذه المشكلة بسبب مواصلة إصدار Chrome 15 تنزيل الملف على شكل أجزاء وعدم تخزينه مؤقتًا. وفي ذلك الوقت، قرّرنا تحميل ملفات الموسيقى مثل باقي الملفات الصوتية. إنّ إجراء ذلك ليس مثاليًا، ولكن لا تزال بعض إصدارات المتصفّحات الأخرى تفعل ذلك.
كتم الصوت عندما لا يكون الجهاز في المقدّمة
في السابق، كان من الصعب رصد الحالات التي لا يكون فيها التركيز على علامة تبويب اللعبة. بدأ نقل بيانات Fieldrunners قبل إصدار Chrome 13، حيث حلّت واجهة برمجة التطبيقات Page Visibility API محلّ الحاجة إلى استخدام الرمز البرمجي المعقّد لرصد التمويه في علامات التبويب. يجب أن تستخدم كل لعبة واجهة برمجة التطبيقات Visibility API لكتابة مقتطف صغير لإيقاف الصوت أو إيقافه مؤقتًا، أو إيقاف اللعبة بأكملها مؤقتًا. بما أنّ لعبة Fieldrunners كانت تستخدم واجهة برمجة التطبيقات requestAnimationFrame، تمّت معالجة إيقاف اللعبة مؤقتًا بشكل ضمني، ولكنّه لم يتم إيقاف الصوت مؤقتًا.
إيقاف الأصوات مؤقتًا
من المثير للاهتمام أنّه أثناء تلقّي الملاحظات والآراء حول هذه المقالة، علمنا أنّ الطريقة التي كنا نستخدمها لإيقاف الأصوات مؤقتًا لم تكن مناسبة، فقد كنا نستغلّ خطأ في التنفيذ الحالي لواجهة Web Audio لإيقاف تشغيل الأصوات مؤقتًا. وبما أنّنا سنصلح هذه المشكلة في المستقبل، لا يمكنك إيقاف الصوت مؤقتًا من خلال فصل عقدة أو رسم فرعي لإيقاف التشغيل.
بنية بسيطة لعقدة Web Audio
يتضمّن Fieldrunners نموذجًا صوتيًا بسيطًا جدًا. يمكن أن يتيح هذا النموذج مجموعة الميزات التالية:
- التحكّم في مستوى صوت التأثيرات الصوتية
- التحكّم في مستوى صوت الأغنية المشغّلة في الخلفية
- كتم كل الأصوات
- أوقِف تشغيل الأصوات عندما تكون اللعبة متوقفة مؤقتًا.
- يمكنك إعادة تشغيل هذه الأصوات نفسها عند استئناف اللعبة.
- إيقاف كل الأصوات عندما تفقد علامة تبويب اللعبة التركيز
- إعادة تشغيل التشغيل بعد تشغيل صوت حسب الحاجة
لتحقيق الميزات المذكورة أعلاه باستخدام Web Audio، استُخدِمت 3 من العقد المتاحة: DestinationNode وGainNode وAudioBufferSourceNode. وتعمل عناصر AudioBufferSourceNodes على تشغيل الأصوات. تربط عناصر GainNodes عناصر AudioBufferSourceNodes معًا. إنّ DestinationNode الذي أنشأه سياق Web Audio، والذي يُعرف باسم الوجهة، يشغّل الأصوات للمشغّل. تتضمّن Web Audio أنواعًا كثيرة من العقد، ولكن يمكننا إنشاء رسم بياني بسيط جدًا للأصوات في اللعبة باستخدام هذه العقد فقط.
ينقل رسم بياني لعقد Web Audio البيانات من العقد الورقية إلى العقدة الوجهة. استخدمت لعبة Fieldrunners 6 عقد اكتساب دائمة، ولكن 3 عقد كافية للسماح بالتحكّم بسهولة في مستوى الصوت وربط عدد أكبر من العقد المؤقتة التي ستشغّل وحدات التخزين المؤقت. أولاً، هناك عقدة اكتساب رئيسية تربط كل عقدة فرعية بالوجهة. يتم إرفاق عقدتَيّ "تضخيم" مباشرةً بعقدة "التضخيم الرئيسي"، إحداهما للقناة الموسيقية والأخرى لربط جميع التأثيرات الصوتية.
كان لدى Fieldrunners 3 عقد اكتساب إضافية بسبب الاستخدام غير الصحيح لأحد الأخطاء كميزة. لقد استخدمنا هذه العقد لقطع مجموعات من الأصوات التي يتم تشغيلها من الرسم البياني، ما يؤدي إلى إيقاف تقدّمها. لقد فعلنا ذلك لإيقاف الأصوات مؤقتًا. وبما أنّ هذا الإجراء غير صحيح، سنستخدم الآن 3 عقد فقط للزيادة الإجمالية كما هو موضّح أعلاه. ستتضمّن العديد من المقتطفات التالية العقد غير الصحيحة، وتعرض الإجراءات التي اتّخذناها وكيفية حلّ هذه المشكلة على المدى القصير. ولكن على المدى الطويل، ننصحك بعدم استخدام العقد بعد عقدة 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 );
}
تسمح معظم الألعاب بالتحكّم بشكل منفصل في المؤثرات الصوتية والموسيقى. ويمكن تنفيذ ذلك بسهولة باستخدام الرسم البياني أعلاه. تحتوي كلّ عقدة مكسب على سمة "مكسب" يمكن ضبطها على أيّ قيمة عشرية بين 0 و1، ويمكن استخدامها للتحكّم بشكل أساسي في مستوى الصوت. بما أنّنا نريد التحكّم في مستوى صوت الموسيقى وقنوات المؤثرات الصوتية بشكل منفصل، لدينا عقدة كسب لكل قناة يمكننا من خلالها التحكّم في مستوى صوتها.
function setArbitraryVolume() {
var musicGainNode = this.nodes.backgroundMusicGain;
// set music volume to 50%
musicGainNode.gain.value = 0.5;
}
يمكننا استخدام هذه الميزة نفسها للتحكّم في مستوى صوت كل المحتوى، بما في ذلك التأثيرات الصوتية والموسيقى. سيؤثّر ضبط مستوى الكسب في العقدة الرئيسية في جميع الأصوات الصادرة من اللعبة. إذا ضبطت قيمة الكسب على 0، سيتم كتم صوت المحتوى والموسيقى. تحتوي AudioBufferSourceNodes أيضًا على مَعلمة gain. يمكنك تتبُّع قائمة بكل الأصوات التي يتم تشغيلها وضبط قيم الكسب بشكل فردي لمستوى الصوت العام. إذا كنت تُنشئ تأثيرات صوتية باستخدام علامات الصوت، هذا هو ما عليك فعله. بدلاً من ذلك، يسهّل الرسم البياني للعقد في 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();
}
});
}
قبل كتابة هذه المقالة، اعتقدنا أنّ فصل الجهاز الرئيسي سيكون كافيًا لإيقاف كل الأصوات مؤقتًا بدلاً من كتم الصوت. من خلال فصل العقدة في ذلك الوقت، أوقفنا العقدة الفرعية وتلك التي تليها عن المعالجة والتشغيل. عند إعادة الاتصال، سيبدأ تشغيل جميع الأصوات والموسيقى من حيث توقّفت، تمامًا كما ستستمر لعبة الفيديو من حيث توقّفت. ولكن هذا سلوك غير متوقّع. لا يكفي إلغاء الربط لإيقاف التشغيل.
تسهِّل واجهة برمجة التطبيقات Page Visibility API معرفة ما إذا كانت علامة التبويب لم تعُد في المقدّمة. إذا كان لديك رمز برمجي فعّال لإيقاف الأصوات مؤقتًا، ما عليك سوى كتابة بضعة أسطر لإيقاف الأصوات مؤقتًا عندما تكون علامة التبويب "الألعاب" مخفية.
Give Me a Beat
سنُجري بعض الإعدادات الآن. لدينا رسم بياني للعقد. يمكننا إيقاف الأصوات مؤقتًا عندما يوقف اللاعب اللعبة مؤقتًا، وتشغيل أصوات جديدة لعناصر مثل قوائم الألعاب. يمكننا إيقاف كل الأصوات والموسيقى مؤقتًا عندما ينتقل المستخدم إلى علامة تبويب جديدة. الآن نحتاج إلى تشغيل صوت.
بدلاً من تشغيل نُسخ متعددة من الصوت لمثيلات متعددة لكائن في اللعبة، مثل موت شخصية، يشغِّل 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 في الأصل مع موسيقى في الخلفية يتم تشغيلها باستخدام علامة Audio. عند الإصدار، اكتشفنا أنّه يتم طلب ملفات الموسيقى بعدد غير متناسب مع عدد مرات طلب باقي محتوى اللعبة. بعد إجراء بعض الأبحاث، اكتشفنا أنّ متصفّح Chrome لم يكن يخزّن مؤقتًا أجزاء الملفات الموسيقية التي يتم بثها. وقد أدّى ذلك إلى طلب المتصفّح تشغيل المقطع الصوتي كل بضع دقائق عند انتهائه. في الاختبارات الأخيرة، أنشأ Chrome ذاكرة تخزين مؤقت للمقاطع الصوتية التي يتم بثها، ولكن قد لا تفعل المتصفّحات الأخرى ذلك بعد. إنّ بث الملفات الصوتية الكبيرة باستخدام علامة Audio للحصول على وظائف مثل تشغيل الموسيقى هو الخيار الأمثل، ولكن في بعض إصدارات المتصفّح، قد تحتاج إلى تحميل الموسيقى بالطريقة نفسها التي تحمّل بها المؤثرات الصوتية.
بما أنّ جميع المؤثرات الصوتية كانت يتم تشغيلها من خلال Web Audio، نقلنا تشغيل الموسيقى في الخلفية إلى Web Audio أيضًا. وهذا يعني أنّنا سنحمّل المقاطع الصوتية بالطريقة نفسها التي حمّلنا بها جميع التأثيرات باستخدام طلبات XMLHttpRequest ونوع استجابة 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++ إلى JavaScript، تثير بعض المعضلات والقرارات المثيرة للاهتمام الخاصة بـ HTML5. للتذكير مرة أخرى، إنّ AudioBufferSourceNodes هي عناصر يتم استخدامها لمرة واحدة فقط. أنشئ هذه العناصر، وأضِف "تخزينًا مؤقتًا للصوت"، واربطه بالرسم البياني لـ Web Audio، وشغِّل العنصر باستخدام noteOn أو noteGrainOn. هل تحتاج إلى تشغيل هذا الصوت مرة أخرى؟ بعد ذلك، أنشئ عنصر AudioBufferSourceNode آخر.