מקרה לדוגמה – SONAR, פיתוח משחקי HTML5

Sean Middleditch
Sean Middleditch

מבוא

בקיץ שעבר עבדתי כמנהל טכני במשחק מסחרי ב-WebGL שנקרא SONAR. הפרויקט הושלם תוך כ-3 חודשים, והוא נוצר לגמרי מהתחלה ב-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 תופעל רק אחרי שכל המשאבים ייטענו. מסך הטעינה של המשחק יכול פשוט להמתין להפעלה של פונקציית ה-callback הזו לפני המעבר למסך הבא.

כמובן שאפשר לעשות הרבה יותר עם הממשק הזה. כתרגיל לקורא, כדאי לבדוק כמה תכונות נוספות: הוספת תמיכה בהתקדמות/באחוזים, הוספת טעינת תמונות (באמצעות סוג התמונה), הוספת ניתוח אוטומטי של קובצי JSON וכמובן טיפול בשגיאות.

התכונה החשובה ביותר במאמר הזה היא השדה baseurl, שמאפשר לנו להחליף בקלות את מקור הקבצים שאנחנו מבקשים. קל להגדיר את מנוע הליבה כך שיאפשר פרמטר שאילתה מסוג ?uselocal בכתובת ה-URL לבקש משאבים מכתובת URL שמוצגת על ידי אותו שרת אינטרנט מקומי (כמו python -m SimpleHTTPServer) ששימש להצגת מסמך ה-HTML הראשי של המשחק, תוך שימוש במערכת המטמון אם הפרמטר לא מוגדר.

משאבי אריזה

אחת הבעיות בטעינה של משאבים בשרשרת היא שאין דרך לקבל ספירת בייט מלאה של כל הנתונים. כתוצאה מכך, אין דרך ליצור תיבת דו-שיח פשוטה ואמינה עם מדד התקדמות להורדות. אנחנו מורידים את כל התוכן ומאחסנים אותו במטמון, וזה יכול לקחת זמן רב למשחקים גדולים יותר. לכן חשוב מאוד להציג לשחקן תיבת דו-שיח יפה עם סטטוס התקדמות.

הפתרון הפשוט ביותר לבעיה הזו (שמספק לנו גם כמה יתרונות נוספים) הוא לארוז את כל קובצי המשאבים בחבילה אחת, שאנחנו נוריד באמצעות קריאה יחידה של XHR. כך נקבל את אירועי ההתקדמות שאנחנו צריכים כדי להציג סרגל התקדמות נחמד.

יצירת פורמט קובץ חבילה מותאם אישית היא לא קשה במיוחד, והיא אפילו תפתור כמה בעיות, אבל תצטרכו ליצור כלי ליצירת פורמט החבילה. פתרון חלופי הוא להשתמש בפורמט ארכיון קיים שכבר יש לו כלים, ואז לכתוב מקודד שיפעל בדפדפן. אנחנו לא צריכים פורמט ארכיון דחוס כי כבר אפשר לדחוס נתונים באמצעות HTTP באמצעות אלגוריתמים של gzip או deflate. לכן בחרנו בפורמט הקובץ TAR.

TAR הוא פורמט פשוט יחסית. לכל רשומה (קובץ) יש כותרת של 512 בייטים, ואחריה תוכן הקובץ שמשולב עד 512 בייטים. הכותרת מכילה רק כמה שדות רלוונטיים או מעניינים למטרות שלנו, בעיקר סוג הקובץ והשם שלו, שנשמרים במיקומים קבועים בכותרת.

שדות הכותרת בפורמט TAR נשמרים במיקומים קבועים בגודל קבוע בבלוק הכותרת. לדוגמה, חותמת הזמן של השינוי האחרון בקובץ מאוחסנת ב-136 בייטים מתחילת הכותרת, והיא באורך 12 בייטים. כל השדות המספריים מקודדים כמספרים אוקטליים ששמורים בפורמט ASCII. כדי לנתח את השדות, אנחנו מחלצים את השדות ממאגר ה-array שלנו, ובשדות מספריים אנחנו קוראים לפונקציה 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, אבל אפשר לתקן את זה ברגע ש-Conversion API נוח יותר יהיה זמין בדפדפנים.

הקוד פשוט סורק את ArrayBuffer ומנתח את כותרות הרשומות, כולל כל שדות הכותרת הרלוונטיים של TAR (וגם כמה שדות לא רלוונטיים במיוחד), וגם את המיקום והגודל של נתוני הקובץ ב-ArrayBuffer. אפשר גם להשתמש בקוד כדי לחלץ את הנתונים בתור תצוגה של ArrayBuffer ולשמור אותם ברשימת כותרות הרשומות שהוחזרו.

הקוד זמין בחינם בכפוף לרישיון קוד פתוח ידידותי ומשוחרר בכתובת https://github.com/subsonicllc/TarReader.js.

FileSystem API

כדי לאחסן את תוכן הקבצים ולגשת אליהם מאוחר יותר, השתמשנו ב-FileSystem API. ה-API חדש יחסית, אבל כבר יש לו מסמכי עזרה מצוינים, כולל המאמר המצוין בנושא FileSystem ב-HTML5 Rocks.

ל-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
  );
}