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

שון מידץ'
שון מידץ'

מבוא

בקיץ שעבר עבדתי כמנהלת טכנית במשחק 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, וכמובן, טיפול בשגיאות.

התכונה החשובה ביותר במאמר הזה היא השדה של כתובת ה-URL הבסיסית, שמאפשר לנו להחליף בקלות את המקור של הקבצים שאנחנו מבקשים. קל להגדיר את המנוע המרכזי כדי לאפשר לסוג ?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 ביט בסיסיים, אבל אפשר לתקן זאת כשממשק API להמרות נוח יותר יהיה זמין בדפדפנים.

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

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

ממשק API של FileSystem

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

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

הצרכים שלנו מתמקדים בעיקר בכתיבת קבצים למבנה של ספריות. לשם כך נדרשת סדרה של שלבים לכל קובץ. תחילה עלינו לקחת את הנתיב של הקובץ ולהפוך אותו לרשימה. לשם כך, קל לעשות פיצול של מחרוזת הנתיב בתו מפריד הנתיב (שהוא תמיד הקו הנטוי הקדמי, כמו כתובות אתרים). לאחר מכן אנחנו צריכים לחזור על כל רכיב ברשימה שתתקבל ולשמור אותה עבור האחרון, וליצור ספרייה באופן רקורסיבי (במקרה הצורך) במערכת הקבצים המקומית. אחר כך אנחנו יכולים ליצור את הקובץ, ליצור 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
  );
}