Fieldrunners
Fieldrunners הוא משחק עטור פרסים בסגנון מגדל הגנה, שפורסם במקור ל-iPhone בשנת 2008. מאז הוא הועבר לפלטפורמות רבות אחרות. אחת מהפלטפורמות האחרונות הייתה דפדפן Chrome באוקטובר 2011. אחד מהאתגרים בהעברת Fieldrunners לפלטפורמת HTML5 היה איך להפעיל את הצליל.
באפליקציה Fieldrunners לא נעשה שימוש מורכב באפקטי קול, אבל יש בה ציפיות מסוימות לגבי האופן שבו היא יכולה ליצור אינטראקציה עם אפקטי הקול שלה. במשחק יש 88 אפקטים קוליים, ומספר גדול מהם צפוי לפעול בו-זמנית. רוב הצלילים האלה קצרים מאוד, וצריך להשמיע אותם בזמן הקצר ביותר האפשרי כדי למנוע חוסר התאמה לתוכן הגרפי.
מופיעים אתגרים
במהלך ההעברה של Fieldrunners ל-HTML5 נתקלנו בבעיות בהפעלת אודיו באמצעות תג האודיו, ולכן החלטנו כבר בשלב מוקדם להתמקד ב-Web Audio API. השימוש ב-WebAudio עזר לנו לפתור בעיות כמו הפעלת מספר גדול של אפקטים בו-זמנית, כפי שנדרש במשחק Fieldrunners. עם זאת, במהלך הפיתוח של מערכת אודיו למשחק Fieldrunners HTML5 נתקלנו בכמה בעיות מורכבות שיכול להיות שמפתחים אחרים ירצו לדעת עליהן.
אופי ה-AudioBufferSourceNodes
AudioBufferSourceNodes הם הדרך העיקרית להפעלת צלילים באמצעות WebAudio. חשוב מאוד להבין שמדובר באובייקט לשימוש חד-פעמי. יוצרים AudioBufferSourceNode, מקצים לו מאגר, מחברים אותו לתרשים ומפעילים אותו באמצעות noteOn או noteGrainOn. לאחר מכן אפשר להפעיל את noteOff כדי להפסיק את ההפעלה, אבל לא תוכלו להפעיל שוב את המקור על ידי הפעלת noteOn או noteGrainOn – תצטרכו ליצור עוד AudioBufferSourceNode. עם זאת, אפשר – וזה העניין העיקרי – לעשות שימוש חוזר באותו אובייקט AudioBuffer בסיסי (למעשה, אפשר אפילו ליצור כמה AudioBufferSourceNodes פעילים שמפנים לאותו מופע של AudioBuffer!). קטע מתוך Fieldrunners מופיע ב-Give Me a Beat.
תוכן שלא נשמר במטמון
במהלך ההשקה, השרת של Fieldrunners HTML5 הראה מספר עצום של בקשות לקובצי מוזיקה. התוצאה הזו נובעת מכך שגרסת Chrome 15 ממשיכה להוריד את הקובץ בחלקים ולא שומרת אותו במטמון. בתגובה, החלטנו באותו זמן לטעון קובצי מוזיקה כמו שאנחנו טוענים את שאר קובצי האודיו שלנו. זוהי שיטה לא אופטימלית, אבל בגרסאות מסוימות של דפדפנים אחרים עדיין מתבצעת פעולה כזו.
השבתת הצליל כשהמצלמה לא ממוקדת
בעבר היה קשה לזהות מתי הכרטיסייה של המשחק לא במוקד. צוות Fieldrunners התחיל את תהליך ההעברה לפני Chrome 13, שבו Page Visibility API החליף את הצורך בקוד המורכב שלנו לזיהוי טשטוש הכרטיסייה. בכל משחק צריך להשתמש ב-Visibility API כדי לכתוב קטע קוד קטן להשתקת האודיו או להשהיית האודיו, ואם לא, להשהיית המשחק כולו. מכיוון ש-Fieldrunners השתמש ב-API של requestAnimationFrame, ההשהיה של המשחק טופלה באופן משתמע, אבל לא ההשהיה של האודיו.
השהיית צלילים
באופן מוזר, קיבלנו משוב על המאמר הזה והודענו לנו שהשיטה שבה השתמשנו להשהיית צלילים לא מתאימה – השתמשנו בבאג בהטמעה הנוכחית של Web Audio כדי להשהות את ההפעלה של הצלילים. מכיוון שהבעיה הזו תטופל בעתיד, לא תוכלו פשוט להשהות את הצליל על ידי ניתוק צומת או תת-תרשים כדי להפסיק את ההפעלה.
ארכיטקטורה פשוטה של צמתים של אודיו באינטרנט
למודל האודיו של Fieldrunners יש מבנה פשוט מאוד. המודל הזה יכול לתמוך בקבוצת התכונות הבאה:
- לשלוט בעוצמת האפקטים הקוליים.
- לשלוט בעוצמת הקול של טראק המוזיקה ברקע.
- משתיקים את כל האודיו.
- להשבית את הצליל כשהמשחק מושהה.
- מפעילים מחדש את אותם צלילים כשממשיכים את המשחק.
- השבתת כל האודיו כשהכרטיסייה של המשחק מאבדת את המיקוד.
- מפעילים מחדש את ההפעלה אחרי שהצליל מושמע לפי הצורך.
כדי להשיג את התכונות שלמעלה באמצעות Web Audio, השתמשו ב-3 מהצומתים האפשריים שסופקו: DestinationNode, GainNode ו-AudioBufferSourceNode. הצלילים מופעלים על ידי AudioBufferSourceNodes. ה-GainNodes מחברים את ה-AudioBufferSourceNodes זה לזה. ה-DestinationNode, שנוצר על ידי ההקשר של Web Audio, שנקרא destination, מפעיל צלילים בנגן. ל-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 );
}
ברוב המשחקים אפשר לשלוט בנפרד באפקטים הקוליים ובמוזיקה. אפשר לעשות זאת בקלות באמצעות התרשים שלמעלה. לכל צומת של הגברה יש מאפיין 'gain' שאפשר להגדיר לכל ערך עשרוני בין 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 לא נעשה שימוש ספציפי בהעברה חלקה (crossfade). אם היינו יודעים על הפונקציונליות של הגדרת הערכים ב-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 כ-one-shots. יוצרים צומת, מחברים מאגר, מגדירים ערך בוליאני של לולאה לפי הצורך, מתחברים לצומת בתרשים שיובילו ליעד, קוראים ל-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++ ל-JavaScript, יש כמה דילמות והחלטות מעניינות שספציפיות ל-HTML5. חשוב להדגיש: אובייקטים מסוג AudioBufferSourceNodes הם אובייקטים לשימוש חד-פעמי. יוצרים אותם, מחברים אותם ל-Audio Buffer, מחברים אותו לתרשים Web Audio ומפעילים אותם באמצעות noteOn או noteGrainOn. רוצים להפעיל את הצליל הזה שוב? לאחר מכן יוצרים עוד AudioBufferSourceNode.