בקיץ שעבר עבדתי כראש צוות טכני על משחק מסחרי ב-WebGL בשם SONAR. הפרויקט נמשך כשלושה חודשים, והוא בוצע מאפס לחלוטין ב-JavaScript. במהלך הפיתוח של SONAR, נאלצנו למצוא פתרונות חדשניים למספר בעיות ב-HTML5 החדש והלא בדוק. בפרט, היינו צריכים פתרון לבעיה שנראית פשוטה: איך מורידים ומאחסנים במטמון יותר מ-70MB של נתוני משחק כשהשחקן מתחיל את המשחק?
בפלטפורמות אחרות יש פתרונות מוכנים לבעיה הזו. רוב המשחקים לקונסולות ולמחשב טוענים משאבים מדיסק CD/DVD מקומי או מכונן קשיח. Flash יכול לארוז את כל המשאבים כחלק מקובץ ה-SWF שמכיל את המשחק, ו-Java יכולה לעשות את אותו הדבר עם קובצי JAR. פלטפורמות להפצה דיגיטלית כמו Steam או App Store מוודאות שכל המשאבים יורדו ויותקנו לפני שהשחקן יוכל להתחיל את המשחק.
HTML5 לא מספק לנו את המנגנונים האלה, אבל הוא כן מספק לנו את כל הכלים שאנחנו צריכים כדי לבנות מערכת משלנו להורדת משאבי משחק. היתרון בבניית מערכת משלנו הוא שאנחנו מקבלים את כל השליטה והגמישות שאנחנו צריכים, ויכולים לבנות מערכת שתתאים בדיוק לצרכים שלנו.
אחזור
לפני שהיה לנו מטמון משאבים, היה לנו טוען משאבים פשוט בשרשור. המערכת הזו אפשרה לנו לבקש משאבים נפרדים לפי נתיב יחסי, וכך לבקש עוד משאבים. במסך הטעינה שלנו מוצג מד התקדמות פשוט שמראה כמה נתונים עוד צריך לטעון, והמעבר למסך הבא מתבצע רק אחרי שתור טעינת המשאבים ריק.
העיצוב של המערכת הזו אפשר לנו לעבור בקלות בין משאבים ארוזים לבין משאבים לא ארוזים שמוגשים דרך שרת HTTP מקומי. זה עזר לנו מאוד לחזור במהירות על קוד המשחק ועל הנתונים.
הקוד הבא ממחיש את העיצוב הבסיסי של טוען המשאבים בשרשור שלנו, עם טיפול בשגיאות. כדי לשמור על קריאות הקוד, הסרנו את הקוד המתקדם יותר של טעינת XHR/תמונות.
function ResourceLoader() {
this.pending = 0;
this.baseurl = './';
this.oncomplete = function() {};
}
ResourceLoader.prototype.request = function(path, callback) {
var xhr = new XmlHttpRequest();
xhr.open('GET', this.baseurl + path);
var self = this;
xhr.onreadystatechange = function() {
if (xhr.readyState == 4 && xhr.status == 200) {
callback(path, xhr.response, self);
if (--self.pending == 0) {
self.oncomplete();
}
}
};
xhr.send();
};
השימוש בממשק הזה פשוט למדי, אבל גם גמיש מאוד. קוד המשחק הראשוני יכול לבקש קובצי נתונים מסוימים שמתארים את רמת המשחק הראשונית ואת אובייקטים במשחק. לדוגמה, קובצי JSON פשוטים. פונקציית ה-callback שמשמשת לקבצים האלה בודקת את הנתונים ויכולה לשלוח בקשות נוספות (בקשות בשרשרת) לתלות. יכול להיות שבקובץ ההגדרה של אובייקטים במשחק יופיעו מודלים וחומרים, ואז הקריאה החוזרת (callback) של החומרים עשויה לבקש תמונות של טקסטורות.
הקריאה החוזרת oncomplete שמצורפת למופע הראשי ResourceLoader תופעל רק אחרי שכל המשאבים ייטענו. מסך הטעינה של המשחק יכול פשוט לחכות עד להפעלת הקריאה החוזרת לפני המעבר למסך הבא.
כמובן שאפשר לעשות הרבה יותר עם הממשק הזה. כתרגילים לקורא, כדאי לבדוק כמה תכונות נוספות: הוספת תמיכה בהתקדמות או באחוזים, הוספת טעינת תמונות (באמצעות סוג התמונה), הוספת ניתוח אוטומטי של קובצי JSON וכמובן, טיפול בשגיאות.
התכונה הכי חשובה במאמר הזה היא השדה baseurl, שמאפשר לנו להחליף בקלות את המקור של הקבצים שאנחנו מבקשים. קל להגדיר את מנוע הליבה כך שיאפשר פרמטר שאילתה מסוג ?uselocal בכתובת ה-URL כדי לבקש משאבים מכתובת URL שמסופקת על ידי אותו שרת אינטרנט מקומי (כמו python -m SimpleHTTPServer) שסיפק את מסמך ה-HTML הראשי של המשחק, תוך שימוש במערכת המטמון אם הפרמטר לא מוגדר.
משאבי אריזה
בעיה אחת בטעינה של משאבים בשרשרת היא שאין דרך לקבל ספירת בייטים מלאה של כל הנתונים. התוצאה היא שאין דרך ליצור תיבת דו-שיח פשוטה ואמינה להתקדמות ההורדה. אנחנו הולכים להוריד את כל התוכן ולשמור אותו במטמון, וזה יכול לקחת הרבה זמן במשחקים גדולים יותר. לכן חשוב להציג לשחקן דיאלוג התקדמות נחמד.
הפתרון הכי פשוט לבעיה הזו (שגם נותן לנו כמה יתרונות נוספים) הוא לארוז את כל קובצי המשאבים בחבילה אחת, שאותה נוריד באמצעות קריאת XHR אחת. כך נקבל את אירועי ההתקדמות שדרושים לנו כדי להציג סרגל התקדמות יפה.
יצירת פורמט קובץ חבילה בהתאמה אישית היא לא מסובכת מדי, והיא אפילו יכולה לפתור כמה בעיות, אבל היא דורשת יצירת כלי ליצירת פורמט החבילה. פתרון חלופי הוא להשתמש בפורמט ארכיון קיים שכבר יש לו כלים, ואז לכתוב מפענח שיפעל בדפדפן. אנחנו לא צריכים פורמט ארכיון דחוס כי HTTP כבר יכול לדחוס נתונים באמצעות אלגוריתמים של gzip או deflate. לכן, בחרנו בפורמט קובץ TAR.
TAR הוא פורמט פשוט יחסית. לכל רשומה (קובץ) יש כותרת באורך 512 בייטים, ואחריה תוכן הקובץ עם ריפוד עד 512 בייטים. הכותרת מכילה רק כמה שדות רלוונטיים או מעניינים למטרות שלנו, בעיקר את סוג הקובץ והשם שלו, שמאוחסנים במיקומים קבועים בתוך הכותרת.
שדות הכותרת בפורמט TAR מאוחסנים במיקומים קבועים עם גדלים קבועים בבלוק הכותרת. לדוגמה, חותמת הזמן של השינוי האחרון בקובץ מאוחסנת ב-136 בייט מתחילת הכותרת, והאורך שלה הוא 12 בייט. כל השדות המספריים מקודדים כמספרים אוקטליים שמאוחסנים בפורמט ASCII. כדי לנתח את השדות, אנחנו מחלצים את השדות ממאגר המערכים שלנו, ולשדות מספריים אנחנו קוראים ל-parseInt(), תוך הקפדה על העברת הפרמטר השני כדי לציין את בסיס האוקטלי הרצוי.
אחד השדות החשובים ביותר הוא שדה הסוג. זהו מספר אוקטלי בן ספרה אחת שמציין את סוג הקובץ שהרשומה מכילה. שני סוגי הרשומות שמעניינים אותנו הם קבצים רגילים ('0') וספריות ('5'). אם היינו עובדים עם קובצי TAR שרירותיים, יכול להיות שהיינו מתעניינים גם בקישורים סמליים ('2') ואולי גם בקישורים קשיחים ('1').
אחרי כל כותרת מופיע מיד התוכן של הקובץ שמתואר בכותרת (למעט סוגי קבצים שאין להם תוכן משלהם, כמו ספריות). אחרי התוכן של הקובץ מתווסף ריפוד כדי לוודא שכל כותרת מתחילה בגבול של 512 בייט. לכן, כדי לחשב את האורך הכולל של רשומה של קובץ בקובץ TAR, קודם צריך לקרוא את הכותרת של הקובץ. לאחר מכן אנחנו מוסיפים את אורך הכותרת (512 בייט) לאורך תוכן הקובץ שחולץ מהכותרת. לבסוף, אנחנו מוסיפים את הבייטים הנדרשים כדי שההיסט יתאים ל-512 בייטים. אפשר לעשות זאת בקלות על ידי חלוקת אורך הקובץ ב-512, עיגול המספר כלפי מעלה ואז הכפלה ב-512.
// Read a string out of an array buffer with a maximum string length of 'len'.
// state is an object containing two fields: the array buffer in 'buffer' and
// the current input index in 'index'.
function readString (state, len) {
var str = '';
// We read out the characters one by one from the array buffer view.
// this actually is a lot faster than it looks, at least on Chrome.
for (var i = state.index, e = state.index + len; i != e; ++i) {
var c = state.buffer[i];
if (c == 0) { // at NUL byte, there's no more string
break;
}
str += String.fromCharCode(c);
}
state.index += len;
return str;
}
// Read the next file header out of a tar file stored in an array buffer.
// state is an object containing two fields: the array buffer in 'buffer' and
// the current input index in 'index'.
function readTarHeader (state) {
// The offset of the file this header describes is always 512 bytes from
// the start of the header
var offset = state.index + 512;
// The header is made up of several fields at fixed offsets within the
// 512 byte block allocated for the header. fields have a fixed length.
// all numeric fields are stored as octal numbers encoded as ASCII
// strings.
var name = readString(state, 100);
var mode = parseInt(readString(state, 8), 8);
var uid = parseInt(readString(state, 8), 8);
var gid = parseInt(readString(state, 8), 8);
var size = parseInt(readString(state, 12), 8);
var modified = parseInt(readString(state, 12), 8);
var crc = parseInt(readString(state, 8), 8);
var type = parseInt(readString(state, 1), 8);
var link = readString(state, 100);
// The header is followed by the file contents, then followed
// by padding to ensure that the next header is on a 512-byte
// boundary. advanced the input state index to the next
// header.
state.index = offset + Math.ceil(size / 512) * 512;
// Return the descriptor with the relevant fields we care about
return {
name : name,
size : size,
type : type,
offset : offset
};
};
חיפשתי קוראי TAR קיימים ומצאתי כמה, אבל לכולם היו תלויות אחרות או שהם לא התאימו בקלות ל-codebase הקיים שלנו. לכן בחרתי לכתוב בעצמי. הקדשתי זמן גם לאופטימיזציה של הטעינה ככל האפשר, ולוודא שהפענוח מטפל בקלות בנתונים בינאריים ובנתוני מחרוזות בארכיון.
אחת הבעיות הראשונות שהייתי צריך לפתור הייתה איך לטעון את הנתונים מבקשת XHR. התחלתי עם גישה של 'מחרוזת בינארית'. לצערנו, ההמרה ממחרוזות בינאריות לצורות בינאריות שקל יותר להשתמש בהן, כמו ArrayBuffer, היא לא פשוטה, והיא גם לא מהירה במיוחד. ההמרה לאובייקטים של Image כואבת באותה מידה.
החלטתי לטעון את קובצי ה-TAR כ-ArrayBuffer ישירות מבקשת ה-XHR ולהוסיף פונקציית נוחות קטנה להמרת נתחים מ-ArrayBuffer למחרוזת. בשלב הזה, הקוד שלי מטפל רק בתווים בסיסיים של ANSI/8-bit, אבל אפשר לתקן את זה ברגע ש-API נוח יותר להמרה יהיה זמין בדפדפנים.
הקוד פשוט סורק את ArrayBuffer ומנתח את כותרות הרשומות, שכוללות את כל שדות הכותרת הרלוונטיים של TAR (וכמה שדות לא רלוונטיים), וגם את המיקום והגודל של נתוני הקובץ בתוך ArrayBuffer. אפשר גם להגדיר את הקוד כך שיחלץ את הנתונים כArrayBufferתצוגה וישמור אותם ברשימת הכותרות של הרשומה המוחזרת.
הקוד זמין בחינם ברישיון קוד פתוח ידידותי ומתירני בכתובת https://github.com/subsonicllc/TarReader.js.
FileSystem API
כדי לאחסן את תוכן הקובץ ולגשת אליו מאוחר יותר, השתמשנו ב-FileSystem API. ה-API חדש יחסית, אבל כבר יש לו תיעוד מצוין, כולל המאמר המצוין HTML5 Rocks FileSystem.
יש כמה מגבלות ל-FileSystem API. קודם כל, זה ממשק מבוסס-אירועים. זה הופך את ה-API ללא חוסם, וזה מצוין לממשק משתמש, אבל זה גם מקשה על השימוש בו. שימוש ב-FileSystem API מתוך WebWorker יכול לפתור את הבעיה הזו, אבל זה ידרוש פיצול של כל מערכת ההורדה והפריקה ל-WebWorker. יכול להיות שזו הגישה הכי טובה, אבל לא בחרתי בה בגלל מגבלות זמן (עדיין לא הכרתי את WorkWorkers), אז הייתי צריך להתמודד עם האופי האסינכרוני של ה-API שמבוסס על אירועים.
הצרכים שלנו מתמקדים בעיקר בכתיבת קבצים למבנה של ספריות. לשם כך צריך לבצע סדרה של שלבים לכל קובץ. קודם צריך לקחת את נתיב הקובץ ולהפוך אותו לרשימה. אפשר לעשות את זה בקלות על ידי פיצול מחרוזת הנתיב לפי תו מפריד הנתיב (שזה תמיד קו נטוי, כמו בכתובות URL). לאחר מכן, צריך לבצע איטרציה על כל רכיב ברשימה שמתקבלת, למעט האחרון, וליצור באופן רקורסיבי ספרייה (אם יש צורך) במערכת הקבצים המקומית. אחר כך נוכל ליצור את הקובץ, ואז ליצור FileWriter, ולבסוף לכתוב את תוכן הקובץ.
חשוב גם לזכור את מגבלת גודל הקובץ של האחסון PERSISTENT של FileSystem API. רצינו אחסון קבוע כי האחסון הזמני יכול להתרוקן בכל שלב, כולל בזמן שהמשתמש באמצע משחק, ממש לפני שהמערכת מנסה לטעון את הקובץ שהוצא.
באפליקציות שמיועדות לחנות האינטרנט של Chrome, אין מגבלות אחסון כשמשתמשים בהרשאה unlimitedStorage בקובץ המניפסט של האפליקציה. עם זאת, אפליקציות אינטרנט רגילות עדיין יכולות לבקש נפח אחסון באמצעות ממשק בקשת המכסה הניסיוני.
function allocateStorage(space_in_bytes, success, error) {
webkitStorageInfo.requestQuota(
webkitStorageInfo.PERSISTENT,
space_in_bytes,
function() {
webkitRequestFileSystem(PERSISTENT, space_in_bytes, success, error);
},
error
);
}