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

Sean Middleditch
Sean Middleditch

บทนำ

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

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

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

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

แหล่งข้อมูลการจัดแพ็กเกจ

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

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

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

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

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

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

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

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

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

โค้ดนี้พร้อมให้ใช้งานฟรีภายใต้สัญญาอนุญาตโอเพนซอร์สที่อนุญาตและใช้งานง่ายที่ https://github.com/subsonicllc/TarReader.js

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