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 כדי להשהות את ההפעלה של הצלילים. הבעיה הזו תתוקן בעתיד, ולכן אי אפשר פשוט להשהות את הצליל על ידי ניתוק של צומת או תת-תרשים כדי לעצור את ההפעלה.
ארכיטקטורה פשוטה של צומת 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 );
}
ברוב המשחקים אפשר לשלוט בנפרד באפקטים הקוליים ובמוזיקה. אפשר לעשות את זה בקלות בעזרת התרשים שלמעלה. לכל צומת הגברה יש מאפיין "הגברה" שאפשר להגדיר לכל ערך עשרוני בין 0 ל-1, ושאפשר להשתמש בו כדי לשלוט בעוצמת הקול. אנחנו רוצים לשלוט בעוצמת הקול של ערוצי המוזיקה והאפקטים הקוליים בנפרד, אז יש לנו צומת הגברה לכל אחד מהם, שבו אנחנו יכולים לשלוט בעוצמת הקול שלהם.
function setArbitraryVolume() {
var musicGainNode = this.nodes.backgroundMusicGain;
// set music volume to 50%
musicGainNode.gain.value = 0.5;
}
אנחנו יכולים להשתמש באותה יכולת כדי לשלוט בעוצמת הקול של כל דבר, של אפקטים קוליים ומוזיקה. הגדרת ההגבר של הצומת הראשי תשפיע על כל הצלילים במשחק. אם תגדירו את ערך ההגברה ל-0, הצלילים והמוזיקה יושתקו. גם ל-AudioBufferSourceNodes יש פרמטר של הגברה. אתם יכולים לעקוב אחרי רשימה של כל הצלילים שמושמעים ולשנות את ערכי ההגברה שלהם בנפרד כדי לשלוט בעוצמת הקול הכוללת. אם הייתם יוצרים אפקטים קוליים באמצעות תגי אודיו, זה מה שהייתם צריכים לעשות. לעומת זאת, תרשים הצמתים של Web Audio מאפשר לשנות בקלות רבה יותר את עוצמת הקול של אינספור צלילים. השליטה בעוצמת הקול בדרך הזו גם נותנת לכם יותר כוח בלי להסתבך. אנחנו יכולים פשוט לצרף AudioBufferSourceNode ישירות לצומת הראשי כדי להשמיע מוזיקה ולשלוט בהגברה שלו. אבל תצטרכו להגדיר את הערך הזה בכל פעם שתיצרו AudioBufferSourceNode כדי להשמיע מוזיקה. במקום זאת, אתם משנים צומת אחד רק כששחקן משנה את עוצמת הקול של המוזיקה ובהפעלה. עכשיו יש לנו ערך הגברה במקורות המאגר כדי לעשות משהו אחר. במוזיקה, שימוש נפוץ אחד יכול להיות ליצירת מעבר הדרגתי מטראק אודיו אחד לאחר, כשאחד מסתיים והשני מתחיל. ב-Web Audio יש שיטה נחמדה לעשות את זה בקלות.
function arbitraryCrossfade( track1, track2 ) {
track1.gain.linearRampToValueAtTime( 0, 1 );
track2.gain.linearRampToValueAtTime( 1, 1 );
}
ב-Fieldrunners לא נעשה שימוש ספציפי ב-crossfading. אם היינו יודעים על הפונקציונליות של הגדרת הערך של 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 קל מאוד לדעת מתי הכרטיסייה כבר לא נמצאת במיקוד. אם כבר יש לכם קוד יעיל להשהיית צלילים, צריך רק כמה שורות כדי לכתוב השהיית צלילים כשכרטיסיית המשחקים מוסתרת.
תן לי קצב
עכשיו יש לנו כמה דברים מוגדרים. יש לנו תרשים של צמתים. אנחנו יכולים להשהות את הצלילים כשהשחקן משהה את המשחק, ולהשמיע צלילים חדשים לאלמנטים כמו תפריטי המשחק. אנחנו יכולים להשהות את כל הצלילים והמוזיקה כשהמשתמש עובר לכרטיסייה חדשה. עכשיו אנחנו צריכים להשמיע צליל.
במקום להשמיע כמה עותקים של הצליל עבור כמה מופעים של ישות במשחק, כמו דמות שמתה, ב-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.