กรณีศึกษา - SONAR การพัฒนาเกม HTML5

ฌอน มิดเดิลดิช
ฌอน มิดเดิลดิช

เกริ่นนำ

เมื่อฤดูร้อนที่ผ่านมา ฉันทำงานเป็นหัวหน้าฝ่ายเทคนิคของเกม WebGL เชิงพาณิชย์ที่ชื่อว่า SONAR โปรเจ็กต์นี้ใช้เวลาประมาณ 3 เดือนและเสร็จสิ้นใหม่ทั้งหมดใน JavaScript ระหว่างการพัฒนา SONAR เราต้องคิดหาวิธีที่สร้างสรรค์เพื่อแก้ไขปัญหาหลายอย่างในแหล่งน้ำ HTML5 ใหม่และที่ยังไม่ผ่านการทดสอบ โดยเฉพาะอย่างยิ่ง เราต้องการวิธีแก้ปัญหาที่ดูง่ายๆ อย่างเราจะดาวน์โหลดและแคชข้อมูลเกมมากกว่า 70 MB เมื่อผู้เล่นเริ่มเกมได้อย่างไร

แพลตฟอร์มอื่นๆ มีวิธีแก้ไขปัญหานี้สำเร็จรูปอยู่แล้ว เกมคอนโซลและเกม PC ส่วนใหญ่จะโหลดทรัพยากรจาก 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 ธรรมดา จากนั้นโค้ดเรียกกลับที่ใช้สำหรับไฟล์เหล่านี้จะตรวจสอบข้อมูลดังกล่าวและส่งคำขอเพิ่มเติม (คำขอที่ผูกไว้) สำหรับทรัพยากร Dependency ไฟล์การกำหนดออบเจ็กต์เกมอาจแสดงโมเดลและวัสดุ ส่วนโค้ดเรียกกลับสำหรับวัสดุอาจขอรูปภาพพื้นผิว

ระบบจะเรียกใช้โค้ดเรียกกลับ oncomplete ที่แนบกับอินสแตนซ์ ResourceLoader หลักหลังจากโหลดทรัพยากรทั้งหมดแล้วเท่านั้น หน้าจอโหลดเกมสามารถรอให้เรียกใช้โค้ดเรียกกลับก่อนที่จะเปลี่ยนเป็นหน้าจอถัดไป

แน่นอนว่าคุณสามารถใช้อินเทอร์เฟซนี้ได้มากขึ้น เพื่อเป็นแบบฝึกหัดสำหรับผู้อ่าน ฟีเจอร์เพิ่มเติมอีก 2-3 อย่างที่ควรค่าแก่การตรวจสอบคือการเพิ่มการสนับสนุนความคืบหน้า/เปอร์เซ็นต์ การเพิ่มการโหลดรูปภาพ (โดยใช้ประเภทรูปภาพ) การเพิ่มการแยกวิเคราะห์ไฟล์ JSON โดยอัตโนมัติ และการจัดการข้อผิดพลาด

ฟีเจอร์ที่สำคัญที่สุดสำหรับบทความนี้คือช่อง baseurl ซึ่งช่วยให้เราเปลี่ยนแหล่งที่มาของไฟล์ที่เราขอได้อย่างง่ายดาย การตั้งค่าเครื่องมือหลักให้อนุญาตพารามิเตอร์การค้นหาประเภท ?uselocal ใน URL สามารถขอทรัพยากรจาก URL ที่ให้บริการโดยเว็บเซิร์ฟเวอร์ภายในเดียวกัน (เช่น python -m SimpleHTTPServer) ซึ่งให้บริการเอกสาร HTML หลักสำหรับเกม ขณะเดียวกันใช้ระบบแคชหากไม่มีการตั้งค่าพารามิเตอร์

แหล่งข้อมูลเกี่ยวกับแพ็กเกจ

ปัญหาหนึ่งเกี่ยวกับการโหลดทรัพยากรแบบเชนคือ ไม่มีวิธีที่จะได้จำนวนไบต์ที่สมบูรณ์ของข้อมูลทั้งหมด ผลที่ตามมาคือไม่มีวิธีสร้างกล่องโต้ตอบความคืบหน้าที่เรียบง่ายและเชื่อถือได้สำหรับการดาวน์โหลด เนื่องจากเรากำลังจะดาวน์โหลดเนื้อหาทั้งหมดและแคชเนื้อหาไว้ วิธีนี้อาจใช้เวลานานกว่าเกมขนาดใหญ่ การแสดงกล่องโต้ตอบความคืบหน้าที่ดีให้กับผู้เล่นจึงค่อนข้างมีความสำคัญมาก

การแก้ไขที่ง่ายที่สุดสำหรับปัญหานี้ (ซึ่งมีข้อดีอีก 2-3 ข้อ) คือการจัดแพ็กเกจไฟล์ทรัพยากรทั้งหมดไว้ในแพ็กเกจเดียว ซึ่งเราจะดาวน์โหลดด้วยการเรียก XHR เพียงครั้งเดียว ซึ่งทำให้เราเห็นเหตุการณ์ความคืบหน้าที่ต้องแสดงแถบความคืบหน้าอย่างชัดเจน

การสร้างรูปแบบไฟล์ Bundle ที่กำหนดเองไม่ยากมาก และอาจแก้โจทย์ได้ 2-3 ข้อ แต่ก็ต้องอาศัยการสร้างเครื่องมือสร้างรูปแบบ Bundle ด้วย อีกวิธีหนึ่งคือการใช้รูปแบบที่เก็บถาวรที่มีอยู่ซึ่งมีเครื่องมืออยู่แล้ว จากนั้นต้องเขียนตัวถอดรหัสเพื่อเรียกใช้ในเบราว์เซอร์ เราไม่ต้องการรูปแบบที่เก็บถาวรที่บีบอัด เนื่องจาก HTTP สามารถบีบอัดข้อมูลโดยใช้ gzip หรือ deflate อัลกอริทึมได้อยู่แล้ว ด้วยเหตุนี้ เราจึงตัดสินใจใช้รูปแบบไฟล์ TAR

TAR เป็นรูปแบบที่ค่อนข้างเรียบง่าย ทุกระเบียน (ไฟล์) มีส่วนหัวขนาด 512 ไบต์ ตามด้วยเนื้อหาของไฟล์ที่เพิ่มเป็น 512 ไบต์ ส่วนหัวมีฟิลด์ที่เกี่ยวข้องหรือน่าสนใจเพียงไม่กี่ฟิลด์สำหรับวัตถุประสงค์ของเรา โดยส่วนใหญ่จะเป็นประเภทไฟล์และชื่อไฟล์ ซึ่งจัดเก็บไว้ในตำแหน่งคงที่ภายในส่วนหัว

ฟิลด์ส่วนหัวในรูปแบบ TAR จะจัดเก็บไว้ที่ตำแหน่งคงที่ซึ่งมีขนาดคงที่ในบล็อกส่วนหัว ตัวอย่างเช่น การประทับเวลาการแก้ไขล่าสุดของไฟล์จะจัดเก็บไว้ที่ 136 ไบต์จากจุดเริ่มต้นของส่วนหัวและยาว 12 ไบต์ ช่องตัวเลขทั้งหมดมีการเข้ารหัสเป็นตัวเลขฐานแปดที่จัดเก็บในรูปแบบ ASCII ในการแยกวิเคราะห์ฟิลด์ เราจะแยกฟิลด์จากบัฟเฟอร์อาร์เรย์ และสำหรับช่องตัวเลข เราเรียกว่า parseInt() โดยต้องส่งผ่านพารามิเตอร์ที่ 2 เพื่อระบุฐานฐานแปดที่ต้องการ

หนึ่งในฟิลด์ที่สำคัญที่สุดคือฟิลด์ประเภท นี่คือเลขฐานแปดหลักเดียวที่บอกให้เรารู้ว่าระเบียนนี้มีไฟล์ประเภทใด ระเบียนที่น่าสนใจเพียง 2 ประเภทสำหรับวัตถุประสงค์ของเราคือไฟล์ปกติ ('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 ที่มีอยู่ แล้วก็พบเครื่องอ่าน 2-3 ตัว แต่ไม่มีตัวไหนที่ไม่มีทรัพยากร Dependency อื่นๆ หรือตัวอ่านที่สามารถใส่ลงในฐานของโค้ดที่มีอยู่ได้ง่ายๆ ฉันจึงเลือกเขียนเนื้อหาของตัวเอง นอกจากนี้ฉันได้ใช้เวลาเพิ่มประสิทธิภาพการโหลดให้ดีที่สุดเท่าที่จะเป็นไปได้ และตรวจสอบว่าเครื่องมือถอดรหัสจัดการกับข้อมูลไบนารีและสตริงภายในที่เก็บถาวรได้อย่างง่ายดาย

ปัญหาแรกๆ ที่ผมต้องแก้ไขคือวิธีโหลดข้อมูลจากคำขอ XHR เดิมทีผมเริ่มต้นด้วยวิธีการ "สตริงไบนารี" แต่การแปลงจากสตริงไบนารีไปเป็นรูปแบบไบนารีที่ใช้งานได้ง่ายกว่าอย่างเช่น ArrayBuffer ไม่ใช่เรื่องง่ายๆ และการแปลงดังกล่าวก็รวดเร็วเป็นพิเศษ การแปลงเป็นวัตถุ Image เป็นเรื่องที่น่าเจ็บปวดไม่แพ้กัน

ฉันตัดสินใจโหลดไฟล์ TAR เป็น ArrayBuffer โดยตรงจากคำขอ XHR และเพิ่มฟังก์ชันอำนวยความสะดวกเล็กๆ น้อยๆ สำหรับการแปลงชิ้นส่วนจาก ArrayBuffer เป็นสตริง ปัจจุบันโค้ดของฉันรองรับเฉพาะอักขระ ANSI/8 บิตพื้นฐานเท่านั้น แต่สามารถแก้ไขได้เมื่อมี Conversion API ที่ใช้งานได้สะดวกขึ้นในเบราว์เซอร์

โค้ดเพียงแค่สแกนส่วนหัวของระเบียนที่แยกวิเคราะห์ออกของ ArrayBuffer ซึ่งประกอบด้วยช่องส่วนหัว TAR ที่เกี่ยวข้องทั้งหมด (และบางช่องที่ไม่เกี่ยวข้องกัน) ตลอดจนตำแหน่งและขนาดของข้อมูลไฟล์ภายใน ArrayBuffer นอกจากนี้ โค้ดยังเลือกดึงข้อมูลเป็นมุมมอง ArrayBuffer แล้วเก็บข้อมูลนั้นในรายการส่วนหัวของระเบียนที่แสดงผลได้

คุณสามารถใช้โค้ดนี้ได้ฟรีภายใต้ใบอนุญาตโอเพนซอร์สที่ผ่อนปรนและเป็นมิตรที่ https://github.com/subsonicllc/TarReader.js

API ระบบไฟล์

เราใช้ FileSystem API สำหรับการจัดเก็บเนื้อหาไฟล์และการเข้าถึงในภายหลัง API นี้ค่อนข้างใหม่ แต่ก็มีเอกสารดีๆ อยู่บ้าง รวมถึงบทความ HTML5 Rocks FileSystem ที่ยอดเยี่ยม

อย่างไรก็ตาม FileSystem API ไม่ได้มีข้อควรระวังแต่อย่างใด เหตุผลหนึ่งคืออินเทอร์เฟซที่ขับเคลื่อนด้วยเหตุการณ์ ซึ่งทั้งคู่นี้ทำให้ API ไม่บล็อกซึ่งเป็นสิ่งที่เหมาะกับ UI แต่ก็อาจทำให้ใช้งานยากด้วย การใช้ FileSystem API จาก WebWorker จะช่วยลดปัญหานี้ได้ แต่จะต้องแยกระบบดาวน์โหลดและคลายการแพคข้อมูลออกทั้งหมดเป็น WebWorker ซึ่งอาจจะเป็นวิธีที่ดีที่สุด แต่ก็ไม่ใช่วิธีที่เราใช้เนื่องจากข้อจำกัดด้านเวลา (ฉันยังไม่คุ้นกับ WorkWorkers) ฉันจึงต้องจัดการกับ API ที่มาจากเหตุการณ์แบบอะซิงโครนัส

ความต้องการของเราจะมุ่งเน้นที่การเขียนไฟล์เป็นโครงสร้างไดเรกทอรี ซึ่งต้องระบุชุดขั้นตอนสำหรับแต่ละไฟล์ ก่อนอื่น เราต้องนำเส้นทางของไฟล์มาเปลี่ยนเป็นรายการ ซึ่งทำได้ง่ายๆ โดยการแยกสตริงเส้นทางในอักขระตัวแบ่งเส้นทาง (ซึ่งก็คือเครื่องหมายทับ เช่น URL เสมอ) จากนั้นเราจะต้องทำซ้ำแต่ละองค์ประกอบในรายการผลลัพธ์เพื่อบันทึกเป็นการสร้างไดเรกทอรีล่าสุดซ้ำๆ (หากจำเป็น) ในระบบไฟล์ในเครื่อง จากนั้นเราก็สามารถสร้างไฟล์ แล้วสร้าง FileWriter และสุดท้ายก็เขียนเนื้อหาของไฟล์

สิ่งสำคัญอันดับ 2 ที่ควรคำนึงถึงคือขีดจำกัดขนาดไฟล์ในพื้นที่เก็บข้อมูล PERSISTENT ของ FileSystem API เราต้องการพื้นที่เก็บข้อมูลถาวรเนื่องจากพื้นที่เก็บข้อมูลชั่วคราวสามารถล้างได้ทุกเมื่อ รวมถึงขณะที่ผู้ใช้อยู่ระหว่างเล่นเกมของเราก่อนที่แอปจะพยายามโหลดไฟล์ที่นำออก

แอปที่กำหนดเป้าหมายไปยัง Chrome เว็บสโตร์จะไม่มีขีดจำกัดพื้นที่เก็บข้อมูลเมื่อใช้สิทธิ์ unlimitedStorage ในไฟล์ Manifest ของแอปพลิเคชัน อย่างไรก็ตาม เว็บแอปทั่วไปยังคงขอพื้นที่ด้วยอินเทอร์เฟซคำขอโควต้าทดลองได้

function allocateStorage(space_in_bytes, success, error) {
  webkitStorageInfo.requestQuota(
    webkitStorageInfo.PERSISTENT,
    space_in_bytes,
    function() {
      webkitRequestFileSystem(PERSISTENT, space_in_bytes, success, error);      
    },
    error
  );
}