การเพิ่มเกมเพลย์ WebRTC ลงในประสบการณ์การใช้งาน The Hobbit
เราได้ขยายการทดลองของ Chrome เมื่อปีที่แล้วอย่างการเดินทางผ่านมิดเดิ้ลเอิร์ธด้วยเนื้อหาใหม่ๆ เพื่อต้อนรับภาพยนตร์เรื่องใหม่ของฮอบบิทอย่าง "ฮอบบิท: การต่อสู้ของห้ากองทัพ" จุดมุ่งหมายหลักในครั้งนี้คือการขยายการใช้งาน 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 และคุณไม่ต้องดำเนินการใดๆ เพิ่มเติมในการปรับขนาดบริการรับสัญญาณเมื่อใช้ Channel 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 ที่รวดเร็ว มีประสิทธิภาพ และน่าเชื่อถืออย่างมาก
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) เมื่อเราเริ่มการพัฒนา ยังไม่มีการกำหนดภาพเค้าโครงและข้อกำหนดเฉพาะด้านฟังก์ชันการทำงาน ดังนั้นการที่เราเพิ่มฟีเจอร์และตรรกะได้ในขณะที่การพัฒนาดำเนินไปจึงมีประโยชน์อย่างยิ่ง ตัวอย่างเช่น ต้นแบบแรกใช้ระบบการแสดงผล Canvas แบบง่ายเพื่อแสดงเอนทิตีในตารางกริด หลังจากทำซ้ำ 2-3 ครั้ง เราได้เพิ่มระบบการชนและระบบสำหรับผู้เล่นที่ควบคุมด้วย AI ในระหว่างที่โปรเจ็กต์ดำเนินอยู่ เราอาจเปลี่ยนไปใช้ระบบโปรแกรมแสดงผล 3 มิติได้โดยไม่ต้องเปลี่ยนโค้ดที่เหลือ เมื่อส่วนที่เป็นเครือข่ายทำงานได้ ระบบ AI จะแก้ไขให้ใช้คำสั่งระยะไกลได้
ดังนั้นตรรกะพื้นฐานของผู้เล่นหลายคนก็คือการส่งการกำหนดค่าของ Action-command ไปยังแอปเทียบเท่าผ่าน DataChannels และให้การจำลองทำงานเสมือนว่าเป็น AI Player นอกจากนี้ ยังมีตรรกะในการตัดสินว่าตอนนี้เป็นเทิร์นไหน หากผู้เล่นกดปุ่มส่ง/โจมตี ให้จัดคิวคำสั่งหากเข้ามาขณะที่ผู้เล่นยังดูภาพเคลื่อนไหวก่อนหน้าอยู่ เป็นต้น
หากมีเพียงผู้ใช้ 2 คนสลับกันเล่น ผู้ใช้ทั้ง 2 คนจะแชร์ความรับผิดชอบในการส่งตาให้คู่ต่อสู้ได้เมื่อเล่นจบ แต่มีผู้เล่นคนที่ 3 เข้ามาเกี่ยวข้อง ระบบ AI มีประโยชน์อีกครั้ง (ไม่ใช่แค่สำหรับการทดสอบ) เมื่อเราต้องการเพิ่มศัตรูอย่างแมงมุมและโทรลล์ เราต้องสร้างและดำเนินการเหมือนกันทุกประการทั้ง 2 ด้านเพื่อให้พอดีกับขั้นตอนแบบผลัดกันเล่น ซึ่งแก้ไขได้โดยการให้แอปเทียบเท่าควบคุมระบบเลี้ยวและส่งสถานะปัจจุบันไปยังเครื่องระยะไกล จากนั้นเมื่อถึงตาของ Spider เครื่องมือจัดการการเปลี่ยนตาจะอนุญาตให้ระบบ AI สร้างคําสั่งที่จะส่งไปยังผู้ใช้ระยะไกล เนื่องจากเอนจิ้นเกมจะดำเนินการกับคำสั่งและรหัสเอนทิตีเท่านั้น ระบบจะจำลองเกมในทั้ง 2 ด้านให้เหมือนกัน หน่วยทั้งหมดยังมีคอมโพเนนต์ ai ที่ทําให้การทดสอบอัตโนมัติเป็นเรื่องง่าย
การใช้โปรแกรมแสดงผลภาพพิมพ์แคนวาสที่เรียบง่ายกว่าในช่วงเริ่มต้นการพัฒนาขณะที่มุ่งเน้นที่ตรรกะเกมเป็นตัวเลือกที่ดีที่สุด แต่ความสนุกที่แท้จริงเริ่มต้นขึ้นเมื่อเรานำเวอร์ชัน 3 มิติมาใช้และทำให้ฉากมีชีวิตชีวาด้วยสภาพแวดล้อมและภาพเคลื่อนไหว เราใช้ three.js เป็นเครื่องมือค้นหา 3 มิติ และการเข้าสู่สถานะที่เล่นได้นั้นทำได้ง่ายเนื่องจากสถาปัตยกรรม
ระบบจะส่งตำแหน่งของเมาส์ไปยังผู้ใช้ระยะไกลบ่อยขึ้นและแสดงคำแนะนำที่ละเอียดอ่อนเกี่ยวกับตำแหน่งเคอร์เซอร์ในขณะนี้