การเพิ่มเกมเพลย์ WebRTC ลงใน Hobbit Experience
ทันเวลาสำหรับภาพยนตร์เรื่อง Hobbit เรื่อง "The Hobbit: The Battle of the Five Armies" ที่เราได้ดำเนินการเพื่อขยายการทดลองของ Chrome ในปีที่แล้วที่มีชื่อว่า A Journey through Middle-earth ด้วยเนื้อหาใหม่ จุดมุ่งเน้นหลักในครั้งนี้คือการขยายการใช้งาน WebGL เนื่องจากเบราว์เซอร์และอุปกรณ์ต่างๆ จึงสามารถดูเนื้อหาและทำงานกับความสามารถ WebRTC ใน Chrome และ Firefox ได้ เรามี 3 เป้าหมายในการทดลองในปีนี้ ได้แก่
- การเล่นเกมแบบ P2P โดยใช้ WebRTC และ WebGL ใน Chrome สำหรับ Android
- สร้างเกมแบบผู้เล่นหลายคนที่เล่นง่ายและอิงตามการป้อนข้อมูลด้วยการสัมผัส
- โฮสต์ใน Google Cloud Platform
นิยามของเกม
ตรรกะของเกมสร้างขึ้นด้วยแนวตารางกริดที่มีกองทหารย้ายไปอยู่บนกระดานเกม ซึ่งช่วยให้เราลองเล่นเกมบนกระดาษได้ง่ายๆ เพราะเป็นตัวกำหนดกฎ การใช้การตั้งค่าแบบตารางกริดยังช่วยในการตรวจจับการชนในเกมเพื่อรักษาประสิทธิภาพที่ดี เนื่องจากคุณเพียงต้องตรวจสอบการชนกับวัตถุในชิ้นส่วนเดียวกันหรือชิ้นส่วนที่อยู่ใกล้เคียงเท่านั้น เรารู้ตั้งแต่แรกว่าอยากจะมุ่งเน้นเกมใหม่ไปที่การต่อสู้ระหว่างกองกำลังหลัก 4 พลังของมิดเดิลเอิร์ธ มนุษย์ คนแคระ เอลฟ์ และออร์ค นอกจากนี้ยังต้องเป็นเกมที่เป็นกันเองมากพอสำหรับการเล่นในการทดลองของ Chrome และไม่ต้องมีการโต้ตอบมากนักในการเรียนรู้ เราเริ่มจากการกำหนดสมรภูมิ 5 แห่งบนแผนที่มิดเดิลเอิร์ธ ซึ่งทำหน้าที่เป็นห้องเกมที่ผู้เล่นหลายคนสามารถแข่งขันกันแบบเพียร์ทูเพียร์ การแสดงผู้เล่นหลายคนในห้องบนหน้าจออุปกรณ์เคลื่อนที่ และการอนุญาตให้ผู้ใช้เลือกผู้ที่จะท้า เป็นความท้าทายมากจริงๆ เพื่อให้การโต้ตอบและฉากเป็นไปอย่างง่ายดายยิ่งขึ้น เราจึงตัดสินใจให้มีปุ่มเพียงปุ่มเดียวเพื่อท้าทายและยอมรับ จะใช้เฉพาะห้องนี้เพื่อแสดงเหตุการณ์ต่างๆ และว่าใครคือกษัตริย์แห่งขุนเขาในปัจจุบัน ทิศทางนี้ช่วยแก้ปัญหาบางอย่างเกี่ยวกับการจับคู่ที่ตรงกันและช่วยให้เราจับคู่ผู้สมัครที่ดีที่สุดสำหรับการต่อสู้ได้ ในการทดลอง Cube Slam ของ Chrome ก่อนหน้านี้ เราได้เรียนรู้ว่าต้องทำงานอย่างหนักในการจัดการกับเวลาในการตอบสนองในเกมแบบผู้เล่นหลายคน หากผลลัพธ์ของเกมต้องอาศัยผู้เล่นนั้น คุณต้องคาดเดาอยู่ตลอดว่าสถานะของฝ่ายตรงข้ามจะอยู่ที่ตำแหน่งไหน ขณะที่ฝ่ายตรงข้ามคิดว่าตัวคุณอยู่กับคุณและซิงค์ข้อมูลนั้นกับภาพเคลื่อนไหวบนอุปกรณ์ต่างๆ บทความนี้จะอธิบายปัญหาเหล่านี้อย่างละเอียดยิ่งขึ้น เราได้สร้างเกมนี้แบบผลัดกันเล่นเพื่อให้ง่ายยิ่งขึ้น
ตรรกะของเกมสร้างขึ้นด้วยแนวตารางกริดที่มีกองทหารย้ายไปอยู่บนกระดานเกม ซึ่งช่วยให้เราลองเล่นเกมบนกระดาษได้ง่ายๆ เพราะเป็นตัวกำหนดกฎ การใช้การตั้งค่าแบบตารางกริดยังช่วยในการตรวจจับการชนในเกมเพื่อรักษาประสิทธิภาพที่ดี เนื่องจากคุณเพียงต้องตรวจสอบการชนกับวัตถุในชิ้นส่วนเดียวกันหรือชิ้นส่วนที่อยู่ใกล้เคียงเท่านั้น
ส่วนต่างๆ ของเกม
หากต้องการสร้างเกมสำหรับผู้เล่นหลายคนนี้ เราต้องสร้างส่วนสำคัญบางอย่างขึ้นมา
- API การจัดการผู้เล่นฝั่งเซิร์ฟเวอร์จะจัดการกับผู้ใช้ การจับคู่ผู้เล่น เซสชัน และสถิติเกม
- เซิร์ฟเวอร์ที่จะช่วยสร้างการเชื่อมต่อระหว่างผู้เล่น
- API สำหรับจัดการการส่งสัญญาณ AppEngine Channels API ที่ใช้เชื่อมต่อและสื่อสารกับผู้เล่นทุกคนในห้องเล่นเกม
- เครื่องมือเกม JavaScript ที่จัดการการซิงค์สถานะและการส่งข้อความ RTC ระหว่างผู้เล่น/เพียร์ 2 คน
- มุมมองเกม WebGL
การจัดการผู้เล่น
เราใช้ห้องเกมพร้อมกันจำนวนมากต่อ Battleground เพื่อรองรับผู้เล่นจำนวนมาก เหตุผลหลักในการจำกัดจำนวนผู้เล่นต่อห้องเกมคือการให้ผู้เล่นใหม่ไปถึงจุดสูงสุดในลีดเดอร์บอร์ดได้ในเวลาที่เหมาะสม ขีดจำกัดยังเชื่อมโยงกับขนาดของออบเจ็กต์ JSON ที่อธิบายห้องเกมที่ส่งผ่าน Channel API ในขนาดจำกัดที่ 32kb เราต้องจัดเก็บผู้เล่น ห้อง คะแนน เซสชัน และความสัมพันธ์ของผู้เล่นในเกม ในการทำเช่นนี้ ก่อนอื่นเราใช้ NDB สำหรับเอนทิตี และใช้อินเทอร์เฟซข้อความค้นหาเพื่อจัดการกับความสัมพันธ์ NDB เป็นอินเทอร์เฟซไปยัง Google Cloud Datastore การใช้ NDB ทำงานได้ดีเยี่ยมในช่วงแรก แต่ในไม่ช้าเราก็พบกับปัญหาเรื่องวิธีการใช้งาน การค้นหาจะทำงานกับฐานข้อมูลเวอร์ชัน "ที่คอมมิต" (การเขียน NDB อธิบายไว้อย่างยาวมากในบทความเจาะลึกนี้) ซึ่งอาจมีความล่าช้าหลายวินาที แต่เอนทิตีเองก็ไม่ล่าช้าเพราะตอบสนองจากแคชโดยตรง การอธิบายด้วยโค้ดตัวอย่างอาจจะง่ายขึ้นเล็กน้อย:
// example code to explain our issue with eventual consistency
def join_room(player_id, room_id):
room = Room.get_by_id(room_id)
player = Player.get_by_id(player_id)
player.room = room.key
player.put()
// the player Entity is updated directly in the cache
// so calling this will return the room key as expected
player.room // = Key(Room, room_id)
// Fetch all the players with room set to 'room.key'
players_in_room = Player.query(Player.room == room.key).fetch()
// = [] (an empty list of players)
// even though the saved player above may be expected to be in the
// list it may not be there because the query api is being run against the
// "committed" version and may still be empty for a few seconds
return {
room: room,
players: players_in_room,
}
หลังจากเพิ่มการทดสอบ 1 หน่วย เราเห็นปัญหาได้อย่างชัดเจน และเราย้ายออกจากการค้นหาเพื่อเก็บความสัมพันธ์ไว้ในรายการที่คั่นด้วยคอมมาใน memcache แทน นี่อาจดูเหมือนเป็นการแฮ็กเล็กน้อย แต่ก็ได้ผล และ Memcache ของ AppEngine มีระบบที่เหมือนกับธุรกรรมสำหรับคีย์ต่างๆ โดยใช้ฟีเจอร์ "เปรียบเทียบและตั้งค่า" ที่ยอดเยี่ยม การทดสอบจึงผ่านอีกครั้ง
ขออภัย Memcache ไม่ใช่สายรุ้งและยูนิคอร์นทั้งหมด แต่มาพร้อมกับข้อจำกัดบางประการ รายการที่โดดเด่นที่สุดคือขนาดค่า 1 MB (ต้องไม่มีห้องที่เกี่ยวข้องกับสมรภูมิมากเกินไป) และการหมดอายุของคีย์ หรือตามที่เอกสารอธิบายไว้ว่า
เราพิจารณาใช้ที่เก็บคีย์-ค่าที่ยอดเยี่ยมอีกแห่งที่ชื่อว่า Redis แต่ในตอนนั้นการตั้งค่าคลัสเตอร์ที่รองรับการปรับขนาดเป็นเรื่องที่น่ากังวลเล็กน้อย และเนื่องจากเรามักให้ความสำคัญกับการสร้างประสบการณ์มากกว่าการดูแลรักษาเซิร์ฟเวอร์ เราจึงไม่พัฒนาไปในทิศทางนั้น ในทางกลับกัน Google Cloud Platform เพิ่งเปิดตัวฟีเจอร์คลิกเพื่อทำให้ใช้งานได้แบบง่ายๆ โดยหนึ่งในตัวเลือกก็คือคลัสเตอร์ Redis จึงเป็นตัวเลือกที่น่าสนใจมาก
สุดท้ายเราพบ Google Cloud SQL และย้ายความสัมพันธ์ไปยัง MySQL งานหนักมาก แต่ในที่สุดก็เป็นไปด้วยดี การอัปเดตต่างๆ ทำงานได้เต็มประสิทธิภาพและการทดสอบก็ยังคงผ่านอยู่ นอกจากนี้ยังทำให้การใช้การจับคู่และการสะสมคะแนนเชื่อถือได้ขึ้นมากอีกด้วย
เมื่อเวลาผ่านไป ข้อมูลค่อยๆ ย้ายจาก NDB และ Memcache ไปยัง SQL แต่โดยทั่วไปแล้ว ผู้เล่นสนามรบและเอนทิตีห้องจะยังคงจัดเก็บอยู่ใน NDB ในขณะที่ระบบจะจัดเก็บเซสชันและความสัมพันธ์ระหว่างรายการเหล่านั้นใน SQL
นอกจากนี้เรายังต้องติดตามว่าใครกำลังแข่งกับใคร และจับคู่ผู้เล่นกันโดยใช้กลไกการจับคู่ที่พิจารณาระดับทักษะและประสบการณ์ของผู้เล่น เราอิงตามการจับคู่ในไลบรารีโอเพนซอร์ส Glicko2
เนื่องจากเกมนี้เป็นเกมแบบผู้เล่นหลายคน เราจึงต้องการแจ้งให้ผู้เล่นคนอื่นๆ ในห้องทราบเกี่ยวกับเหตุการณ์ต่างๆ เช่น "ใครเข้าหรือออก" "ใครชนะหรือแพ้" และมีชาเลนจ์ที่ต้องยอมรับหรือไม่ เพื่อรับมือกับปัญหานี้ เรามีความสามารถในการรับการแจ้งเตือนใน Player Management API
การตั้งค่า WebRTC
เมื่อผู้เล่น 2 คนจับคู่กันเพื่อต่อสู้กัน ระบบจะใช้บริการส่งสัญญาณเพื่อให้เพื่อนที่ตรงกันทั้ง 2 คนที่จับคู่กันและเริ่มการสื่อสารกัน
มีไลบรารีของบุคคลที่สามจำนวนมากที่คุณสามารถใช้สำหรับบริการส่งสัญญาณ และยังทำให้การตั้งค่า WebRTC ง่ายขึ้นด้วย โดยตัวเลือกบางรายการ ได้แก่ PeerJS, SimpleWebRTC และ PubNub WebRTC SDK PubNub ใช้โซลูชันเซิร์ฟเวอร์ที่โฮสต์ และสำหรับโปรเจ็กต์นี้ เราต้องการโฮสต์บน Google Cloud Platform ไลบรารีอีก 2 ตัวใช้เซิร์ฟเวอร์ Node.js ที่เราติดตั้งไว้บน Google Compute Engine ได้ แต่เราก็ต้องมั่นใจว่าไลบรารีดังกล่าวสามารถรองรับผู้ใช้พร้อมกันหลายพันคน ซึ่งเป็นสิ่งที่เราทราบอยู่แล้วว่า Channel API ทำได้
ในกรณีนี้ หนึ่งในข้อได้เปรียบหลักๆ ของการใช้ Google Cloud Platform คือการปรับขนาด การปรับขนาดทรัพยากรที่จำเป็นสำหรับโปรเจ็กต์ AppEngine จะทำได้อย่างง่ายดายผ่าน Google Developers Console และไม่จำเป็นต้องดำเนินการใดๆ เพิ่มเติมเพื่อขยายบริการส่งสัญญาณเมื่อใช้ Channel API
มีข้อกังวลเกี่ยวกับเวลาในการตอบสนองและประสิทธิภาพของ Channel API เกิดขึ้น แต่ก่อนหน้านี้เราได้ใช้ API นี้สำหรับโครงการ CubeSlam และได้พิสูจน์แล้วว่าใช้งานได้สำหรับผู้ใช้หลายล้านคนในโปรเจ็กต์ดังกล่าว เราจึงตัดสินใจใช้อีกครั้ง
เนื่องจากเราไม่ได้เลือกใช้ไลบรารีของบุคคลที่สามในการช่วยในเรื่อง WebRTC เราจึงต้องสร้างขึ้นมาเอง โชคดีที่เราสามารถนำงานจำนวนมากที่เราทำสำหรับโครงการ CubeSlam มาใช้ซ้ำได้ เมื่อผู้เล่นทั้งสองเข้าร่วมเซสชันแล้ว เซสชันจะมีการตั้งค่าเป็น "ใช้งานอยู่" จากนั้นผู้เล่นทั้งสองจะใช้รหัสเซสชันที่ใช้งานอยู่นั้นเพื่อเริ่มการเชื่อมต่อระหว่างเครื่องผู้ใช้ผ่าน Channel API หลังจากนั้นการสื่อสารทั้งหมดระหว่างผู้เล่นทั้งสองจะได้รับการจัดการผ่าน RTCDataChannel
นอกจากนี้เรายังต้องใช้เซิร์ฟเวอร์ STUN และ TURN เพื่อช่วยสร้างการเชื่อมต่อและรับมือกับ NAT และไฟร์วอลล์ อ่านรายละเอียดเพิ่มเติมเกี่ยวกับการตั้งค่า WebRTC ในบทความของ HTML5 Rocks ที่ WebRTC ในชีวิตจริง: STUN, TURN และการส่งสัญญาณ
จำนวนเซิร์ฟเวอร์ TURN ที่ใช้จะต้องสามารถปรับขนาดได้โดยขึ้นอยู่กับการรับส่งข้อมูล เพื่อจัดการกับปัญหานี้ เราได้ทดสอบตัวจัดการการทำให้ใช้งานได้ของ Google ซึ่งช่วยให้เราทำให้ทรัพยากรใช้งานได้แบบไดนามิกบน Google Compute Engine และติดตั้งเซิร์ฟเวอร์ TURN โดยใช้เทมเพลต นี่ยังคงอยู่ในรุ่นอัลฟ่า แต่สำหรับวัตถุประสงค์ของเรา มันทำงานได้อย่างไม่มีที่ติ สำหรับเซิร์ฟเวอร์ TURN เราใช้ coturn ซึ่งเป็นการใช้งาน STUN/TURN ที่รวดเร็วมาก มีประสิทธิภาพ และน่าเชื่อถือ
Channel API
Channel API ใช้เพื่อส่งการสื่อสารทั้งหมดไปยังและจากห้องเกมในฝั่งไคลเอ็นต์ API การจัดการโปรแกรมเล่นของเราใช้ Channel API สำหรับการแจ้งเตือนเกี่ยวกับเหตุการณ์ในเกม
การทำงานกับ Channel API เกิดข้อขัดข้องเล็กน้อย ตัวอย่างหนึ่งก็คือ เนื่องจากข้อความอาจไม่เรียงตามลำดับ เราจึงต้องตัดข้อความทั้งหมดไว้ในออบเจ็กต์แล้วจัดเรียง ต่อไปนี้เป็นตัวอย่างโค้ดเกี่ยวกับวิธีการทำงาน
var que = []; // [seq, packet...]
var seq = 0;
var rcv = -1;
function send(message) {
var packet = JSON.stringify({
seq: seq++,
msg: message
});
channel.send(packet);
}
function recv(packet) {
var data = JSON.parse(packet);
if (data.seq <= rcv) {
// ignoring message, older or already received
} else if (data.seq > rcv + 1) {
// message from the future. queue it up.
que.push(data.seq, packet);
} else {
// message in order! update the rcv index and emit the message
rcv = data.seq;
emit('message', data.message);
// and now that we have updated the `rcv` index we
// will check the que for any other we can send
setTimeout(flush, 10);
}
}
function flush() {
for (var i=0; i<que.length; i++) {
var seq = que[i];
var packet = que[i+1];
if (data.seq == rcv + 1) {
recv(packet);
return; // wait for next flush
}
}
}
นอกจากนี้เรายังต้องการเก็บ API ต่างๆ ของเว็บไซต์แบบแยกส่วนและแยกออกจากโฮสติ้งของเว็บไซต์ และเริ่มด้วยการใช้โมดูลที่ติดตั้งใน GAE แต่หลังจากที่ทำให้ทุกอย่างใช้งานได้กับเวอร์ชันพัฒนาซอฟต์แวร์ เราก็ตระหนักว่า Channel API ไม่ทำงานกับโมดูลในเวอร์ชันที่ใช้งานจริงเลย แต่เราได้เปลี่ยนไปใช้อินสแตนซ์ GAE แบบแยกต่างหากและพบปัญหา CORS ที่บังคับให้เราใช้ postMessage Bridge ของ iframe
เครื่องมือเกม
เพื่อให้เครื่องมือเกมมีไดนามิกมากที่สุดเท่าที่จะเป็นไปได้ เราจึงสร้างแอปพลิเคชันส่วนหน้าโดยใช้แนวทาง entity-component-system (ECS) ตอนที่เราเริ่มพัฒนา Wi-Fi นั้นไม่มีการตั้งข้อกำหนดเฉพาะด้านการทำงานไว้ การสามารถเพิ่มคุณลักษณะและตรรกะในขณะที่พัฒนาไปเรื่อยๆ ก็มีประโยชน์มาก ตัวอย่างเช่น ต้นแบบแรกใช้ระบบการแสดงผลบนผืนผ้าใบแบบง่ายเพื่อแสดงเอนทิตีในตารางกริด มีการทำซ้ำ 2 ครั้งในภายหลัง มีการเพิ่มระบบสำหรับการชน และอีกระบบสำหรับผู้เล่นที่ควบคุมโดย AI ในระหว่างโปรเจ็กต์ เราสามารถเปลี่ยนไปใช้ระบบการแสดงผล 3 มิติโดยไม่ต้องเปลี่ยนโค้ดที่เหลือ เมื่อส่วนเครือข่ายเริ่มทำงานแล้ว คุณสามารถแก้ไขระบบ AI ให้ใช้คำสั่งจากระยะไกลได้
ดังนั้นตรรกะพื้นฐานของผู้เล่นหลายคนคือการส่งการกำหนดค่าของคำสั่งแอ็กชันไปยังเพียร์อื่นผ่าน DataChannels และให้การจำลองทำงานราวกับว่าเป็นผู้เล่น AI ยิ่งไปกว่านั้น ยังมีตรรกะในการตัดสินว่าจะเลี้ยวใดคือเมื่อผู้เล่นกดปุ่มผ่าน/โจมตี จะออกคำสั่งในคิวหากเข้ามาในขณะที่ผู้เล่นยังดูภาพเคลื่อนไหวก่อนหน้าอยู่ เป็นต้น
ถ้ามีเพียงผู้ใช้ 2 คนผลัดกันเล่น เพื่อนทั้งสองคนก็สามารถแชร์หน้าที่ในการผลัดกันเล่นให้กับคู่แข่งเมื่อทำเสร็จแล้ว แต่มีผู้เล่นคนที่ 3 เข้ามาเกี่ยวข้อง ระบบ AI กลับมาใช้งานได้อีกครั้ง (ไม่ใช่แค่การทดสอบเท่านั้น) เมื่อเราจำเป็นต้องเพิ่มศัตรูอย่างสไปเดอร์และโทรลล์ ในการทำให้รูปแบบดังกล่าวเหมาะสมกับขั้นตอนแบบผลัดกันเล่น จะต้องมีการสร้างและเรียกใช้ให้เหมือนกันทุกประการกับทั้ง 2 ฝั่ง ซึ่งแก้ไขได้โดยการให้เพียร์ 1 เครื่องควบคุมระบบการเลี้ยวและส่งสถานะปัจจุบันไปยังเครื่องระยะไกล จากนั้นเมื่อสไปเดอร์หมุน ตัวจัดการ Turn Manager จะอนุญาตให้ระบบ AI สร้างคำสั่งที่ส่งไปยังผู้ใช้ระยะไกล เนื่องจากเครื่องมือเกมจะแค่ตอบสนองต่อคำสั่งและรหัสเอนทิตี เกมจึงจะมีการจำลองเหมือนกันทั้ง 2 ฝั่ง นอกจากนี้ ทุกหน่วยยังมีองค์ประกอบ AI ซึ่งช่วยให้ทำการทดสอบอัตโนมัติได้ง่าย
ในช่วงแรกของการพัฒนาโดยที่ยังโฟกัสตรรกะของเกมอยู่จึงเป็นสิ่งที่ควรทำ แต่ความสนุกจริงๆ เริ่มต้นขึ้นเมื่อมีการนำเวอร์ชัน 3 มิติมาใช้ และฉากต่างๆ โลดแล่นมีชีวิตพร้อมสภาพแวดล้อมและภาพเคลื่อนไหว เราใช้ three.js เป็นเครื่องมือแบบ 3 มิติ และการเข้าสู่สถานะที่เล่นได้อย่างง่ายดายเนื่องจากสถาปัตยกรรม
ตำแหน่งเมาส์จะส่งไปยังผู้ใช้ระยะไกลบ่อยครั้งขึ้นและคำแนะนำเล็กน้อยเกี่ยวกับตำแหน่งของเคอร์เซอร์ในขณะนั้น