เกริ่นนำ
เมื่อฤดูร้อนที่ผ่านมา ฉันทำงานเป็นหัวหน้าฝ่ายเทคนิคของเกม 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
);
}