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

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

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