การเพิ่มเกมเพลย์ WebRTC ลงในประสบการณ์การใช้งาน The Hobbit
เราได้ขยายการทดลองของ Chrome เมื่อปีที่แล้วอย่างการเดินทางผ่านมิดเดิ้ลเอิร์ธด้วยเนื้อหาใหม่ๆ เพื่อต้อนรับภาพยนตร์เรื่องใหม่ของฮอบบิทอย่าง "The Hobbit: The Battle of the Five Armies" เป้าหมายหลักในครั้งนี้คือการขยายการใช้งาน WebGL เนื่องจากเบราว์เซอร์และอุปกรณ์จำนวนมากขึ้นสามารถดูเนื้อหาและทำงานร่วมกับความสามารถของ WebRTC ใน Chrome และ Firefox ได้ การทดสอบในปีนี้มี 3 เป้าหมาย ได้แก่
- เกมเพลย์แบบ P2P โดยใช้ WebRTC และ WebGL ใน Chrome สำหรับ Android
- สร้างเกมผู้เล่นหลายคนที่เล่นง่ายและอิงตามอินพุตการสัมผัส
- โฮสต์ใน Google Cloud Platform
การกําหนดเกม
ตรรกะเกมสร้างขึ้นจากการตั้งค่าแบบตารางกริดที่มีกองกำลังเคลื่อนที่บนกระดานเกม วิธีนี้ช่วยให้เราลองเล่นเกมเพลย์บนกระดาษได้ง่ายขณะกำหนดกฎ การใช้การตั้งค่าตามตารางกริดยังช่วยในการตรวจหาการชนกันในเกมเพื่อรักษาประสิทธิภาพที่ดีไว้ด้วย เนื่องจากคุณจะต้องตรวจสอบการชนกับวัตถุในไทล์เดียวกันหรือใกล้เคียงเท่านั้น เราทราบตั้งแต่ต้นว่าต้องการมุ่งเน้นเกมใหม่ไปที่การต่อสู้ระหว่างกองกำลังหลัก 4 กองกำลังของมิดเดิ้ลเอิร์ธ ได้แก่ มนุษย์ คนแคระ เอลฟ์ และออร์ค นอกจากนี้ เกมยังต้องเล่นได้แบบสบายๆ ภายในการทดสอบของ Chrome และไม่ต้องมีการโต้ตอบมากเกินไป เราเริ่มต้นด้วยการกำหนดสนามรบ 5 แห่งบนแผนที่ Middle-earth ซึ่งทำหน้าที่เป็นห้องเกมที่ผู้เล่นหลายคนสามารถแข่งขันกันแบบผู้เล่นต่อผู้เล่น การแสดงผู้เล่นหลายคนในห้องบนหน้าจออุปกรณ์เคลื่อนที่และอนุญาตให้ผู้ใช้เลือกผู้ที่จะท้าทายนั้นเป็นเรื่องท้าทายอยู่แล้ว เราจึงตัดสินใจให้มีเพียงปุ่มเดียวสำหรับการท้าทายและยอมรับ และใช้ห้องเพื่อแสดงเหตุการณ์และผู้ที่เป็นราชาแห่งเนินเขาคนปัจจุบันเท่านั้น เพื่อให้การโต้ตอบและฉากง่ายขึ้น แนวทางนี้ยังช่วยแก้ปัญหาบางอย่างเกี่ยวกับการจับคู่และช่วยให้เราจับคู่ผู้สมัครที่ดีที่สุดสำหรับการต่อสู้ได้อีกด้วย ในการทดสอบ Chrome ก่อนหน้านี้กับ Cube Slam เราพบว่าการจัดการเวลาในการตอบสนองในเกมแบบผู้เล่นหลายคนนั้นเป็นเรื่องยากมาก หากผลลัพธ์ของเกมขึ้นอยู่กับเวลาในการตอบสนอง คุณต้องทำนายสถานะของคู่ต่อสู้อยู่เสมอ ว่าคู่ต่อสู้คิดว่าคุณอยู่ที่ไหน และซิงค์กับแอนิเมชันในอุปกรณ์ต่างๆ บทความนี้อธิบายความท้าทายเหล่านี้โดยละเอียด เราทำให้เกมนี้เป็นแบบผลัดกันเล่นเพื่อให้เล่นได้ง่ายขึ้น
ตรรกะเกมสร้างขึ้นจากการตั้งค่าแบบตารางกริดที่มีกองกำลังเคลื่อนที่บนกระดานเกม วิธีนี้ช่วยให้เราลองเล่นเกมเพลย์บนกระดาษได้ง่ายขณะกำหนดกฎ การใช้การตั้งค่าตามตารางกริดยังช่วยในการตรวจหาการชนกันในเกมเพื่อรักษาประสิทธิภาพที่ดีไว้ด้วย เนื่องจากคุณจะต้องตรวจสอบการชนกับวัตถุในไทล์เดียวกันหรือใกล้เคียงเท่านั้น
ส่วนต่างๆ ของเกม
ในการสร้างเกมแบบผู้เล่นหลายคนนี้ เราต้องสร้างส่วนสำคัญ 2-3 ส่วนดังนี้
- API การจัดการผู้เล่นฝั่งเซิร์ฟเวอร์จะจัดการผู้ใช้ การจัดแมตช์ เซสชัน และสถิติเกม
- เซิร์ฟเวอร์ที่ช่วยสร้างการเชื่อมต่อระหว่างผู้เล่น
- API สำหรับจัดการการส่งสัญญาณ AppEngine Channels API ที่ใช้เชื่อมต่อและสื่อสารกับผู้เล่นทุกคนในห้องเกม
- เครื่องมือสร้างเกม JavaScript ที่จัดการการซิงค์สถานะและการรับส่งข้อความ RTC ระหว่างผู้เล่น/คู่สนทนา 2 คน
- มุมมองเกม WebGL
การจัดการผู้เล่น
เราใช้ห้องเกมหลายห้องที่ทำงานพร้อมกันสำหรับแต่ละสนามรบเพื่อรองรับผู้เล่นจำนวนมาก เหตุผลหลักในการจำกัดจำนวนผู้เล่นต่อห้องเกมคือการช่วยให้ผู้เล่นใหม่ไต่ขึ้นสู่อันดับสูงสุดของลีดเดอร์บอร์ดได้ภายในระยะเวลาที่เหมาะสม ขีดจำกัดนี้ยังเชื่อมโยงกับขนาดของออบเจ็กต์ JSON ที่อธิบายห้องเกมซึ่งส่งผ่าน Channel API ซึ่งมีขีดจำกัด 32 KB ด้วย เราต้องจัดเก็บผู้เล่น ห้อง คะแนน เซสชัน และความสัมพันธ์ของข้อมูลเหล่านี้ในเกม โดยเริ่มจากการใช้ NDB สำหรับเอนทิตีและใช้อินเทอร์เฟซการค้นหาเพื่อจัดการความสัมพันธ์ NDB เป็นอินเทอร์เฟซของ Google Cloud Datastore การใช้ NDB ได้ผลดีมากในช่วงแรก แต่ไม่นานเราก็พบปัญหาเกี่ยวกับวิธีที่เราต้องใช้ ระบบเรียกใช้การค้นหากับฐานข้อมูลเวอร์ชัน "ที่คอมมิตแล้ว" (การเขียน NDB มีการอธิบายอย่างละเอียดในบทความเชิงลึกนี้) ซึ่งอาจมีความล่าช้า 2-3 วินาที แต่เอนทิตีเองจะไม่เกิดความล่าช้าดังกล่าวเนื่องจากมีการตอบกลับจากแคชโดยตรง เราขออธิบายด้วยตัวอย่างโค้ดเพื่อให้เข้าใจได้ง่ายขึ้น
// 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,
}
หลังจากเพิ่มการทดสอบหน่วย เราเห็นปัญหาอย่างชัดเจนและได้เลิกใช้การค้นหาเพื่อเก็บความสัมพันธ์ไว้ในรายการที่คั่นด้วยคอมมาใน memcache แทน วิธีนี้ดูเหมือนจะเป็นการแฮ็ก แต่ได้ผลและ AppEngine Memcache มีระบบที่คล้ายกับธุรกรรมสําหรับคีย์โดยใช้ฟีเจอร์ "เปรียบเทียบและตั้งค่า" ที่ยอดเยี่ยม ตอนนี้การทดสอบผ่านแล้ว
ขออภัย Memcache ไม่ได้เป็นเทคโนโลยีที่สมบูรณ์แบบแต่มีข้อจำกัดอยู่2-3 ข้อ ข้อที่โดดเด่นที่สุดคือขนาดค่า 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 และไม่ต้องทํางานเพิ่มเติมเพื่อปรับขนาดบริการส่งสัญญาณเมื่อใช้ Channels API
มีข้อกังวลบางอย่างเกี่ยวกับเวลาในการตอบสนองและความเสถียรของ Channels API แต่ก่อนหน้านี้เราได้ใช้ API นี้กับโปรเจ็กต์ CubeSlam และพิสูจน์แล้วว่าใช้งานได้กับผู้ใช้หลายล้านคนในโปรเจ็กต์นั้น เราจึงตัดสินใจใช้ API นี้อีกครั้ง
เนื่องจากเราไม่ได้เลือกใช้ไลบรารีของบุคคลที่สามเพื่อช่วยในการใช้งาน WebRTC เราจึงต้องสร้างไลบรารีของเราเอง แต่โชคดีที่เราสามารถนํางานจำนวนมากที่เราทําในโปรเจ็กต์ CubeSlam มาใช้ซ้ำได้ เมื่อผู้เล่นทั้ง 2 คนเข้าร่วมเซสชัน ระบบจะตั้งค่าเซสชันเป็น "ทำงานอยู่" จากนั้นผู้เล่นทั้ง 2 คนจะใช้รหัสเซสชันที่ทำงานอยู่นั้นเพื่อเริ่มต้นการเชื่อมต่อแบบเพียร์ต่อเพียร์ผ่าน Channel API หลังจากนั้น การสื่อสารทั้งหมดระหว่างผู้เล่น 2 รายจะจัดการผ่าน RTCDataChannel
นอกจากนี้ เรายังต้องใช้เซิร์ฟเวอร์ STUN และ TURN เพื่อช่วยสร้างการเชื่อมต่อและรับมือกับ NAT และไฟร์วอลล์ อ่านรายละเอียดเพิ่มเติมเกี่ยวกับการตั้งค่า WebRTC ในบทความ WebRTC ในชีวิตจริง: STUN, TURN และ Signaling ของ HTML5 Rocks
จำนวนเซิร์ฟเวอร์ TURN ที่ใช้ยังต้องปรับขนาดได้ตามความหนาแน่นของการรับส่งข้อมูลด้วย ในการแก้ปัญหานี้ เราได้ทดสอบ Google Deployment Manager ซึ่งช่วยให้เราปรับใช้ทรัพยากรใน Google Compute Engine และติดตั้งเซิร์ฟเวอร์ TURN ได้อย่างยืดหยุ่นโดยใช้เทมเพลต ฟีเจอร์นี้ยังอยู่ในรุ่นอัลฟ่า แต่สำหรับวัตถุประสงค์ของเราแล้ว ฟีเจอร์นี้ทำงานได้อย่างราบรื่น สำหรับเซิร์ฟเวอร์ TURN เราใช้ coturn ซึ่งเป็นการใช้งาน STUN/TURN ที่รวดเร็ว มีประสิทธิภาพ และดูเหมือนว่าจะเชื่อถือได้
Channel API
Channel API ใช้เพื่อส่งการสื่อสารทั้งหมดไปยังและจากห้องเกมฝั่งไคลเอ็นต์ Player Management API ของเราใช้ Channel API สำหรับการแจ้งเตือนเกี่ยวกับเหตุการณ์ในเกม
การทำงานกับ Channels 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 ของ iframe
เครื่องมือเกม
เราได้สร้างแอปพลิเคชันส่วนหน้าโดยใช้แนวทาง Entity-Component-System (ECS) เพื่อให้เกมเอนจิ้นเป็นแบบไดนามิกมากที่สุด เมื่อเราเริ่มการพัฒนา ยังไม่มีการกำหนดภาพเค้าโครงและข้อกำหนดเฉพาะด้านฟังก์ชันการทำงาน ดังนั้นการที่เราเพิ่มฟีเจอร์และตรรกะได้เมื่อการพัฒนาดำเนินไปจึงมีประโยชน์อย่างยิ่ง เช่น โปรโตไทป์แรกใช้ระบบการแสดงผลภาพพิมพ์แคนวาสแบบง่ายเพื่อแสดงเอนทิตีในตารางกริด หลังจากทำซ้ำ 2-3 ครั้ง เราได้เพิ่มระบบการชนและระบบสำหรับผู้เล่นที่ AI ควบคุม ในระหว่างที่โปรเจ็กต์ดำเนินอยู่ เราอาจเปลี่ยนไปใช้ระบบโปรแกรมแสดงผล 3 มิติได้โดยไม่ต้องเปลี่ยนโค้ดที่เหลือ เมื่อส่วนที่เป็นเครือข่ายทำงานได้ ระบบ AI จะแก้ไขให้ใช้คำสั่งระยะไกลได้
ตรรกะพื้นฐานของเกมที่มีผู้เล่นหลายคนคือการส่งการกำหนดค่าของคําสั่งการดำเนินการไปยังบุคคลอื่นผ่าน DataChannel และปล่อยให้การจําลองทํางานราวกับว่าเป็นผู้เล่น AI นอกจากนี้ ยังมีตรรกะในการตัดสินว่าตอนนี้เป็นเทิร์นไหน หากผู้เล่นกดปุ่มส่ง/โจมตี ให้จัดคิวคำสั่งหากเข้ามาขณะที่ผู้เล่นยังดูภาพเคลื่อนไหวก่อนหน้าอยู่ เป็นต้น
หากมีเพียงผู้ใช้ 2 คนสลับกันเล่น ผู้ใช้ทั้ง 2 คนจะแชร์ความรับผิดชอบในการส่งตาต่อให้คู่ต่อสู้ได้เมื่อเล่นจบ แต่ในกรณีนี้จะมีผู้เล่นคนที่ 3 เข้ามาเกี่ยวข้อง ระบบ AI มีประโยชน์อีกครั้ง (ไม่ใช่แค่สำหรับการทดสอบ) เมื่อเราต้องการเพิ่มศัตรูอย่างแมงมุมและโทรลล์ ในการทำให้พอดีกับขั้นตอนแบบผลัดกันเล่น จะต้องสร้างและดำเนินการเหมือนกันทุกประการทั้ง 2 ด้าน ปัญหานี้แก้ไขได้โดยให้พาร์ทเนอร์รายหนึ่งควบคุมระบบการเลี้ยวและส่งสถานะปัจจุบันไปยังพาร์ทเนอร์ระยะไกล จากนั้นเมื่อถึงตาของ Spider เครื่องมือจัดการการเปลี่ยนตาจะอนุญาตให้ระบบ AI สร้างคําสั่งที่จะส่งไปยังผู้ใช้ระยะไกล เนื่องจากเอนจิ้นเกมจะดำเนินการกับคำสั่งและรหัสเอนทิตีเท่านั้น ระบบจะจำลองเกมในทั้ง 2 ด้านให้เหมือนกัน หน่วยทั้งหมดยังมีคอมโพเนนต์ ai ที่ช่วยให้ทำการทดสอบอัตโนมัติได้ง่ายอีกด้วย
การใช้โปรแกรมแสดงผลภาพพิมพ์แคนวาสที่เรียบง่ายกว่าในช่วงเริ่มต้นการพัฒนาขณะที่มุ่งเน้นตรรกะเกมเป็นวิธีที่ดีที่สุด แต่ความสนุกที่แท้จริงเริ่มต้นขึ้นเมื่อเรานำเวอร์ชัน 3 มิติมาใช้และทำให้ฉากมีชีวิตชีวาด้วยสภาพแวดล้อมและภาพเคลื่อนไหว เราใช้ three.js เป็นเอนจิ้น 3 มิติ และทำให้เกมเล่นได้ง่ายๆ เนื่องจากสถาปัตยกรรม
ระบบจะส่งตำแหน่งของเมาส์ไปยังผู้ใช้ระยะไกลบ่อยขึ้นและแสดงคำแนะนำที่ละเอียดอ่อนเกี่ยวกับตำแหน่งเคอร์เซอร์ในขณะนี้