ประสบการณ์ The Hobbit ปี 2014

การเพิ่มเกมเพลย์ 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 มิติ และทำให้เกมเล่นได้ง่ายๆ เนื่องจากสถาปัตยกรรม

ระบบจะส่งตำแหน่งของเมาส์ไปยังผู้ใช้ระยะไกลบ่อยขึ้นและแสดงคำแนะนำที่ละเอียดอ่อนเกี่ยวกับตำแหน่งเคอร์เซอร์ในขณะนี้