เมื่อฤดูร้อนที่ผ่านมา ผมทำงานเป็นหัวหน้าฝ่ายเทคนิคในเกม 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
Callback ที่แนบมากับอินสแตนซ์ ResourceLoader
หลักหลังจากโหลดทรัพยากรทั้งหมดแล้วเท่านั้น หน้าจอการโหลดเกมเพียงแค่รอให้เรียกใช้การเรียกกลับนั้นก่อนที่จะเปลี่ยนไปที่หน้าจอถัดไป
แน่นอนว่าคุณสามารถทำสิ่งต่างๆ ได้อีกมากมายด้วยอินเทอร์เฟซนี้ แบบฝึกหัดสำหรับผู้อ่านคือฟีเจอร์เพิ่มเติม 2-3 อย่างที่ควรศึกษา ได้แก่ การเพิ่มการรองรับความคืบหน้า/เปอร์เซ็นต์ การเพิ่มการโหลดรูปภาพ (โดยใช้ประเภทรูปภาพ) การเพิ่มการแยกวิเคราะห์ไฟล์ JSON โดยอัตโนมัติ และแน่นอนว่าคือการจัดการข้อผิดพลาด
ฟีเจอร์ที่สำคัญที่สุดสำหรับบทความนี้คือฟิลด์ baseurl ซึ่งช่วยให้เราเปลี่ยนแหล่งที่มาของไฟล์ที่เราขอได้อย่างง่ายดาย คุณตั้งค่าเครื่องมือหลักได้อย่างง่ายดายเพื่อให้ยอมรับพารามิเตอร์การค้นหาประเภท ?uselocal
ใน URL เพื่อขอทรัพยากรจาก URL ที่แสดงโดยเว็บเซิร์ฟเวอร์ในเครื่องเดียวกัน (เช่น python -m SimpleHTTPServer
) ซึ่งแสดงเอกสาร HTML หลักสำหรับเกม ขณะเดียวกันก็ใช้ระบบแคชหากไม่ได้ตั้งค่าพารามิเตอร์
แหล่งข้อมูลการจัดแพ็กเกจ
ปัญหาอย่างหนึ่งของการโหลดทรัพยากรแบบลูกโซ่คือไม่มีวิธีรับจำนวนไบต์ทั้งหมดของข้อมูลทั้งหมด ผลที่ตามมาคือไม่มีวิธีสร้างกล่องโต้ตอบความคืบหน้าที่เรียบง่ายและเชื่อถือได้สำหรับการดาวน์โหลด เนื่องจากเราจะดาวน์โหลดเนื้อหาทั้งหมดและแคชไว้ การดำเนินการนี้อาจใช้เวลานานพอสมควรสำหรับเกมขนาดใหญ่ การแสดงกล่องโต้ตอบความคืบหน้าที่ดูดีต่อผู้เล่นจึงเป็นสิ่งสำคัญ
วิธีที่ง่ายที่สุดในการแก้ปัญหานี้ (ซึ่งยังช่วยให้เราได้รับข้อดีอื่นๆ อีก 2-3 อย่าง) คือการแพ็กเกจไฟล์ทรัพยากรทั้งหมดเป็นแพ็กเกจเดียว ซึ่งเราจะดาวน์โหลดด้วยการเรียก XHR ครั้งเดียว ซึ่งจะทำให้เราได้รับเหตุการณ์ความคืบหน้าที่จำเป็นต่อการแสดงแถบความคืบหน้าที่ดูดี
การสร้างรูปแบบไฟล์ชุดที่กำหนดเองนั้นไม่ยากนัก และยังช่วยแก้ปัญหาได้อีกด้วย แต่จะต้องสร้างเครื่องมือสำหรับสร้างรูปแบบชุด ทางเลือกอื่นคือการใช้รูปแบบที่เก็บถาวรที่มีอยู่ซึ่งมีเครื่องมืออยู่แล้ว จากนั้นจึงต้องเขียนตัวถอดรหัสเพื่อเรียกใช้ในเบราว์เซอร์ เราไม่จำเป็นต้องใช้รูปแบบที่เก็บถาวรที่บีบอัดเนื่องจาก HTTP สามารถบีบอัดข้อมูลโดยใช้อัลกอริทึม gzip หรือ deflate ได้อยู่แล้ว ด้วยเหตุนี้ เราจึงเลือกใช้รูปแบบไฟล์ TAR
TAR เป็นรูปแบบที่ค่อนข้างเรียบง่าย ทุกระเบียน (ไฟล์) จะมีส่วนหัวขนาด 512 ไบต์ ตามด้วยเนื้อหาของไฟล์ที่เพิ่มให้มีขนาด 512 ไบต์ ส่วนหัวมีฟิลด์ที่เกี่ยวข้องหรือน่าสนใจเพียงไม่กี่ฟิลด์สำหรับวัตถุประสงค์ของเรา ซึ่งส่วนใหญ่คือประเภทและชื่อไฟล์ ซึ่งจัดเก็บไว้ในตำแหน่งคงที่ภายในส่วนหัว
ฟิลด์ส่วนหัวในรูปแบบ TAR จะจัดเก็บไว้ในตำแหน่งคงที่ที่มีขนาดคงที่ในบล็อกส่วนหัว เช่น ระบบจะจัดเก็บการประทับเวลาการแก้ไขล่าสุดของไฟล์ที่ 136 ไบต์จากจุดเริ่มต้นของส่วนหัว และมีความยาว 12 ไบต์ ฟิลด์ตัวเลขทั้งหมดจะได้รับการเข้ารหัสเป็นตัวเลขฐานแปดที่จัดเก็บในรูปแบบ ASCII หากต้องการแยกวิเคราะห์ฟิลด์ เราจะดึงฟิลด์จากบัฟเฟอร์อาร์เรย์ และสำหรับฟิลด์ตัวเลข เราจะเรียกใช้ parseInt()
โดยตรวจสอบว่าได้ส่งพารามิเตอร์ที่ 2 เพื่อระบุฐานแปดที่ต้องการ
ฟิลด์ที่สำคัญที่สุดอย่างหนึ่งคือฟิลด์ประเภท นี่คือเลขฐานแปดแบบหลักเดียวซึ่งบอกให้เราทราบว่าระเบียนมีไฟล์ประเภทใด สำหรับวัตถุประสงค์ของเรา มีเพียง 2 ประเภทระเบียนที่น่าสนใจ ได้แก่ ไฟล์ปกติ ('0'
) และไดเรกทอรี ('5'
) หากเรากำลังจัดการกับไฟล์ TAR ที่กำหนดเอง เราอาจสนใจลิงก์สัญลักษณ์ ('2'
) และอาจรวมถึงฮาร์ดลิงก์ ('1'
) ด้วย
ส่วนหัวแต่ละรายการจะตามด้วยเนื้อหาของไฟล์ที่อธิบายโดยส่วนหัวนั้นทันที (ยกเว้นประเภทไฟล์ที่ไม่มีเนื้อหาของตัวเอง เช่น ไดเรกทอรี) จากนั้นจะตามด้วยเนื้อหาของไฟล์และ Padding เพื่อให้แน่ใจว่าส่วนหัวทุกส่วนจะเริ่มต้นที่ขอบเขต 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 บิตพื้นฐาน แต่ปัญหานี้จะได้รับการแก้ไขเมื่อมี API การแปลงที่สะดวกกว่าในเบราว์เซอร์
โค้ดจะสแกนArrayBuffer
เพื่อแยกส่วนหัวของบันทึก ซึ่งรวมถึงฟิลด์ส่วนหัว TAR ที่เกี่ยวข้องทั้งหมด (และฟิลด์ที่ไม่เกี่ยวข้องบางส่วน) รวมถึงตำแหน่งและขนาดของข้อมูลไฟล์ภายใน ArrayBuffer
นอกจากนี้ โค้ดยังสามารถดึงข้อมูลเป็นArrayBuffer
มุมมองและจัดเก็บไว้ในรายการส่วนหัวของระเบียนที่ส่งคืนได้ด้วย
โค้ดนี้พร้อมให้ใช้งานฟรีภายใต้ใบอนุญาตโอเพนซอร์สที่เป็นมิตรและอนุญาตให้ใช้ได้ที่ https://github.com/subsonicllc/TarReader.js
FileSystem API
เราใช้ FileSystem API เพื่อจัดเก็บเนื้อหาของไฟล์จริงและเข้าถึงในภายหลัง API นี้ค่อนข้างใหม่ แต่ก็มีเอกสารประกอบที่ยอดเยี่ยมอยู่แล้ว ซึ่งรวมถึงบทความเกี่ยวกับ FileSystem ของ HTML5 Rocks ที่ยอดเยี่ยม
FileSystem API มีข้อควรระวัง ข้อหนึ่งคือเป็นอินเทอร์เฟซที่ขับเคลื่อนด้วยเหตุการณ์ ซึ่งทำให้ API ไม่บล็อกซึ่งเหมาะสำหรับ UI แต่ก็ทำให้ใช้งานได้ยากเช่นกัน การใช้ FileSystem API จาก WebWorker จะช่วยบรรเทาปัญหานี้ได้ แต่จะต้องแยกทั้งระบบการดาวน์โหลดและการแตกไฟล์ออกเป็น WebWorker ซึ่งอาจเป็นแนวทางที่ดีที่สุดด้วยซ้ำ แต่ผมไม่ได้เลือกแนวทางนี้เนื่องจากมีข้อจำกัดด้านเวลา (ผมยังไม่คุ้นเคยกับ WorkWorkers) จึงต้องรับมือกับลักษณะการทำงานแบบอะซิงโครนัสที่ขับเคลื่อนด้วยเหตุการณ์ของ API
ความต้องการของเราส่วนใหญ่เน้นไปที่การเขียนไฟล์ลงในโครงสร้างไดเรกทอรี ซึ่งต้องทำตามขั้นตอนต่างๆ สำหรับแต่ละไฟล์ ก่อนอื่นเราต้องใช้เส้นทางไฟล์และเปลี่ยนเป็นรายการ ซึ่งทำได้ง่ายๆ โดยการแยกสตริงเส้นทางตามอักขระตัวคั่นเส้นทาง (ซึ่งจะเป็นเครื่องหมายทับเสมอ เช่นเดียวกับ URL) จากนั้นเราต้องวนซ้ำแต่ละองค์ประกอบในรายการผลลัพธ์ ยกเว้นรายการสุดท้าย โดยสร้างไดเรกทอรีแบบเรียกซ้ำ (หากจำเป็น) ในระบบไฟล์ในเครื่อง จากนั้นเราจะสร้างไฟล์ แล้วสร้าง FileWriter
และสุดท้ายก็เขียนเนื้อหาของไฟล์
อีกสิ่งสำคัญที่ต้องคำนึงถึงคือขีดจำกัดขนาดไฟล์ของ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
);
}