Trải nghiệm của người Hobbit 2014

Thêm lối chơi WebRTC vào Trải nghiệm người Hobbit

Daniel Isaksson
Daniel Isaksson

Cùng lúc với bộ phim "Người hoblo: Trận chiến của năm cánh quân", chúng tôi đã mở rộng thử nghiệm Chrome của năm ngoái, đó là Hành trình qua Trung địa với một số nội dung mới. Lần này, trọng tâm chính là mở rộng khả năng sử dụng WebGL vì sẽ có thêm nhiều trình duyệt và thiết bị có thể xem nội dung và hoạt động với các tính năng WebRTC trong Chrome và Firefox. Chúng tôi có 3 mục tiêu trong thử nghiệm năm nay:

  • Lối chơi P2P bằng WebRTC và WebGL trên Chrome dành cho Android
  • Tạo một trò chơi nhiều người chơi dễ chơi dựa trên tính năng nhập bằng cách chạm
  • Lưu trữ trên Google Cloud Platform

Định nghĩa trò chơi

Logic trò chơi được xây dựng dựa trên cách thiết lập lưới, trong đó quân đội di chuyển trên bảng trò chơi. Việc này giúp chúng tôi dễ dàng thử lối chơi trên giấy vì chúng tôi đặt ra các quy tắc. Việc sử dụng chế độ thiết lập dựa trên lưới cũng giúp phát hiện va chạm trong trò chơi nhằm duy trì hiệu suất tốt vì bạn chỉ phải kiểm tra các va chạm với các vật thể trong cùng hoặc lân cận. Chúng tôi biết ngay từ đầu rằng mình muốn tập trung trò chơi mới vào trận chiến giữa bốn lực lượng chính Trung Địa, Người, Người lùn, Yêu tinh và Orc. Trò chơi cũng phải đủ thân mật để có thể chơi trong Thử nghiệm Chrome và không cần quá nhiều tương tác để tìm hiểu. Chúng tôi bắt đầu bằng cách xác định năm Chiến trường trên bản đồ Trung địa. Đây là phòng trò chơi nơi nhiều người chơi có thể thi đấu trong một trận đấu ngang hàng. Việc chiếu nhiều người chơi trong phòng lên màn hình thiết bị di động và việc cho phép người dùng chọn người để tham gia thử thách chính là một thách thức. Để việc tương tác và bối cảnh trở nên dễ dàng hơn, chúng tôi quyết định chỉ cung cấp một nút để thử thách và chấp nhận, đồng thời chỉ sử dụng phòng này để hiển thị các sự kiện và ai là vị vua hiện tại của ngọn đồi. Hướng dẫn này cũng giải quyết được một số vấn đề về việc tìm kiếm người chơi và giúp chúng tôi so khớp các ứng viên phù hợp nhất cho một trận chiến. Trong thử nghiệm Chrome Cube Slam trước đây, chúng tôi nhận thấy rằng việc xử lý độ trễ trong trò chơi nhiều người chơi sẽ mất rất nhiều công sức nếu kết quả của trò chơi phụ thuộc vào kết quả đó. Bạn liên tục phải đưa ra giả định về vị trí của trạng thái của đối thủ, nơi đối thủ nghĩ rằng bạn đang ở đó và đồng bộ hoá điều đó với ảnh động trên các thiết bị khác nhau. Bài viết này giải thích chi tiết hơn về những thách thức này. Để dễ dàng hơn một chút, chúng tôi đã tạo trò chơi này theo lượt.

Logic trò chơi được xây dựng dựa trên cách thiết lập lưới, trong đó quân đội di chuyển trên bảng trò chơi. Việc này giúp chúng tôi dễ dàng thử lối chơi trên giấy vì chúng tôi đặt ra các quy tắc. Việc sử dụng chế độ thiết lập dựa trên lưới cũng giúp phát hiện xung đột trong trò chơi nhằm duy trì hiệu suất tốt vì bạn chỉ phải kiểm tra các xung đột với các đối tượng trong cùng hoặc ở lân cận.

Các phần của trò chơi

Để tạo ra trò chơi dành cho nhiều người chơi này, chúng tôi phải xây dựng một số phần chính:

  • API quản lý người chơi phía máy chủ xử lý người dùng, quá trình so khớp, phiên hoạt động và số liệu thống kê của trò chơi.
  • Máy chủ giúp thiết lập kết nối giữa những người chơi.
  • Một API để xử lý tín hiệu API Kênh AppEngine dùng để kết nối và giao tiếp với tất cả người chơi trong phòng trò chơi.
  • Công cụ phát triển trò chơi JavaScript xử lý việc đồng bộ hoá trạng thái và thông báo RTC giữa hai trình phát/ứng dụng ngang hàng.
  • Chế độ xem trò chơi WebGL.

Quản lý người chơi

Để hỗ trợ số lượng lớn người chơi, chúng tôi sử dụng nhiều phòng trò chơi song song cho mỗi Battleground. Lý do chính của việc giới hạn số lượng người chơi trên mỗi phòng trò chơi là để người chơi mới có thể đạt được vị trí đầu tiên của bảng xếp hạng trong một khoảng thời gian hợp lý. Giới hạn này cũng được liên kết với kích thước của đối tượng json mô tả phòng trò chơi được gửi qua Channel API có giới hạn là 32 kb. Chúng tôi phải lưu trữ người chơi, phòng, điểm số, phiên và mối quan hệ của họ trong trò chơi. Để làm điều này, trước tiên chúng tôi sử dụng NDB cho các thực thể và sử dụng giao diện truy vấn để xử lý các mối quan hệ. NDB là một giao diện cho Google Cloud Datastore. Ban đầu, sử dụng NDB rất hiệu quả nhưng ngay sau đó chúng tôi lại gặp vấn đề về cách sử dụng. Truy vấn được chạy dựa trên phiên bản "đã cam kết" của cơ sở dữ liệu (Ghi NDB được giải thích chi tiết trong bài viết chuyên sâu này) có thể có độ trễ vài giây. Tuy nhiên, bản thân các thực thể không gặp phải độ trễ đó khi phản hồi trực tiếp từ bộ nhớ đệm. Có thể sẽ dễ giải thích hơn một chút bằng mã ví dụ:

// 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,
    }

Sau khi thêm kiểm thử đơn vị, chúng tôi có thể thấy rõ vấn đề và chúng tôi đã bỏ qua truy vấn để giữ mối quan hệ trong danh sách được phân tách bằng dấu phẩy trong memcache. Điều này có vẻ giống như một cuộc tấn công nhưng nó hoạt động và bộ nhớ đệm của AppEngine có một hệ thống giống như giao dịch dành cho các khóa bằng cách sử dụng tính năng “so sánh và thiết lập” tuyệt vời, vì vậy giờ các bài kiểm thử đã vượt qua.

Thật không may, memcache không phải toàn bộ đều có ứng dụng cầu vồng hay kỳ lân mà đi kèm với một vài giới hạn, đáng chú ý nhất là kích thước giá trị 1MB (không thể có quá nhiều phòng liên quan đến chiến trường) và thời hạn khoá, hoặc như tài liệu giải thích:

Chúng tôi đã cân nhắc việc sử dụng một kho khoá-giá trị tuyệt vời khác, Redis. Nhưng tại thời điểm đó, việc thiết lập một cụm có thể mở rộng có chút khó khăn và vì chúng tôi muốn tập trung vào việc xây dựng trải nghiệm hơn là bảo trì máy chủ, nên chúng tôi đã không đi theo lộ trình đó. Mặt khác, Google Cloud Platform gần đây đã phát hành một tính năng Nhấp để triển khai đơn giản, với một trong các lựa chọn là cụm Redis, vì vậy đó sẽ là một lựa chọn rất thú vị.

Cuối cùng, chúng tôi tìm thấy Google Cloud SQL và chuyển các mối quan hệ sang MySQL. Đó là rất nhiều công việc nhưng cuối cùng nó hoạt động rất hiệu quả. Các bản cập nhật giờ đây đã hoàn toàn nguyên tử và các bài kiểm thử vẫn đạt. Điều này cũng giúp việc triển khai tính năng so khớp và giữ điểm số trở nên đáng tin cậy hơn nhiều.

Theo thời gian, ngày càng có nhiều dữ liệu được chuyển từ NDB và memcache sang SQL, nhưng nhìn chung, người chơi, các thực thể trận đấu và phòng vẫn được lưu trữ trong NDB trong khi các phiên hoạt động và mối quan hệ giữa chúng đều được lưu trữ trong SQL.

Chúng tôi cũng phải theo dõi người chơi và ghép nối người chơi với nhau bằng cơ chế ghép nối có tính đến cấp độ kỹ năng và kinh nghiệm của người chơi. Chúng tôi đã dựa trên quá trình tìm kiếm này trên thư viện nguồn mở Glicko2.

Vì đây là trò chơi dành cho nhiều người chơi, chúng tôi muốn thông báo cho những người chơi khác trong phòng về các sự kiện như "ai đã vào hoặc rời đi", "ai thắng hay thua cuộc" và liệu có thử thách cần chấp nhận không. Để xử lý vấn đề này, chúng tôi đã tích hợp khả năng nhận thông báo vào API Quản lý người chơi.

Thiết lập WebRTC

Khi hai người chơi cùng nhau xuất hiện trong một trận chiến, dịch vụ báo hiệu được dùng để khiến hai người chơi đã khớp nói chuyện với nhau và giúp bắt đầu kết nối ngang hàng.

Bạn có thể sử dụng một số thư viện của bên thứ ba cho dịch vụ báo hiệu tín hiệu, đồng thời đơn giản hoá quá trình thiết lập WebRTC. Có một số lựa chọn là PeerJS, SimpleWebRTCSDK WebRTC PubNub. PubNub sử dụng giải pháp máy chủ được lưu trữ và đối với dự án này, chúng tôi muốn lưu trữ trên Google Cloud Platform. Hai thư viện còn lại sử dụng máy chủ nút.js mà chúng tôi có thể đã cài đặt trên Google Compute Engine nhưng chúng tôi cũng phải đảm bảo rằng thư viện này có thể xử lý hàng nghìn người dùng đồng thời, điều mà chúng tôi biết API Kênh có thể làm được.

Một trong những ưu điểm chính khi sử dụng Google Cloud Platform trong trường hợp này là mở rộng quy mô. Việc mở rộng quy mô tài nguyên cần thiết cho dự án AppEngine dễ dàng được xử lý thông qua Google Developers Console và không cần thực hiện thêm hành động nào để mở rộng dịch vụ báo hiệu khi sử dụng API Kênh.

Có một số lo ngại về độ trễ và mức độ mạnh mẽ của API Kênh nhưng trước đây chúng tôi đã sử dụng API này cho dự án CubeSlam và chứng minh được rằng API này hoạt động với hàng triệu người dùng trong dự án đó, vì vậy chúng tôi quyết định sử dụng lại.

Vì chúng tôi không chọn sử dụng thư viện của bên thứ ba để trợ giúp về WebRTC nên chúng tôi phải tự xây dựng thư viện của riêng mình. Thật may là chúng tôi có thể sử dụng lại rất nhiều công việc đã làm cho dự án CubeSlam. Khi cả hai người chơi đã tham gia vào một phiên, phiên đó sẽ được đặt thành "đang hoạt động" và sau đó cả hai người chơi sẽ sử dụng mã phiên đang hoạt động đó để bắt đầu kết nối ngang hàng thông qua Channel API. Sau đó, tất cả hoạt động giao tiếp giữa hai trình phát sẽ được xử lý qua RTCDataChannel.

Chúng tôi cũng cần các máy chủ STUN và TURN để giúp thiết lập kết nối và xử lý NAT và tường lửa. Đọc thêm chuyên sâu về cách thiết lập WebRTC trong bài viết HTML5 Rocks WebRTC trong thế giới thực: STUN, TURN và tín hiệu.

Số lượng máy chủ TURN được sử dụng cũng cần có khả năng mở rộng tuỳ thuộc vào lưu lượng truy cập. Để xử lý vấn đề này, chúng tôi đã thử nghiệm Trình quản lý triển khai của Google. Nhờ công cụ này, chúng tôi có thể linh động triển khai các tài nguyên trên Google Compute Engine và cài đặt máy chủ TURN bằng cách sử dụng mẫu. Thử nghiệm này vẫn còn trong giai đoạn thử nghiệm alpha nhưng vì mục đích của chúng tôi, tính năng này đã hoạt động hoàn hảo. Đối với máy chủ TURN, chúng tôi sử dụng coTurn, đây là một cách triển khai STUN/TURN rất nhanh, hiệu quả và có vẻ đáng tin cậy.

API Kênh

Channel API được dùng để gửi mọi thông tin liên lạc đến và đi từ phòng trò chơi ở phía máy khách. API quản lý người chơi của chúng tôi đang sử dụng API kênh cho các thông báo về các sự kiện của trò chơi.

Khi làm việc với API Kênh, chúng tôi gặp phải một số lỗi nhanh. Một ví dụ là vì các thông báo có thể đến không theo thứ tự, chúng ta phải gói tất cả các thông báo vào một đối tượng và sắp xếp chúng. Dưới đây là một số mã ví dụ về cách hoạt động của tính năng này:

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
    }
  }
}

Chúng tôi cũng muốn giữ lại các API khác nhau của trang web theo mô-đun và tách biệt với việc lưu trữ trang web, đồng thời bắt đầu bằng cách sử dụng các mô-đun tích hợp trong GAE. Thật không may sau khi tất cả hoạt động trong nhà phát triển, chúng tôi nhận ra rằng Channel API không hoạt động với các mô-đun trong phiên bản chính thức. Thay vào đó, chúng tôi đã chuyển sang sử dụng các phiên bản GAE riêng biệt và gặp phải các sự cố CORS buộc chúng tôi phải sử dụng Cầu PostMessage của iframe.

Công cụ phát triển trò chơi

Để giúp công cụ phát triển trò chơi trở nên linh hoạt nhất có thể, chúng tôi đã xây dựng ứng dụng giao diện người dùng bằng phương pháp hệ thống thành phần thực thể (ECS). Khi chúng tôi bắt đầu phát triển, khung dây và thông số kỹ thuật chức năng chưa được thiết lập, vì vậy rất hữu ích khi có thể thêm các tính năng và logic khi quá trình phát triển tiến triển. Ví dụ: nguyên mẫu đầu tiên sử dụng một hệ thống kết xuất canvas đơn giản để hiển thị các thực thể trong một lưới. Một vài vòng lặp sau, một hệ thống va chạm đã được thêm vào và một hệ thống dành cho người chơi sử dụng trí tuệ nhân tạo (AI). Ở giữa dự án, chúng ta có thể chuyển sang hệ thống kết xuất 3d mà không thay đổi phần còn lại của mã. Khi các bộ phận kết nối mạng đã thiết lập và chạy hệ thống ai có thể được sửa đổi để sử dụng lệnh từ xa.

Vì vậy, logic cơ bản của chế độ nhiều người chơi là gửi cấu hình của lệnh hành động cho đối tượng ngang hàng khác thông qua DataChannels và để mô phỏng hoạt động như thể người chơi dựa trên trí tuệ nhân tạo (AI). Ngoài ra, có logic để quyết định đến lượt chơi nếu người chơi nhấn nút truyền/tấn công, xếp hàng lệnh nếu người chơi đến trong khi người chơi vẫn đang xem ảnh động trước đó, v.v.

Nếu chỉ có hai người dùng đổi lượt, cả hai người ngang hàng đều có thể chia sẻ trách nhiệm chuyển lượt chơi cho đối thủ khi họ đã xong, nhưng có một người chơi thứ ba tham gia. Hệ thống AI lại trở nên hữu ích hơn (không chỉ dành cho mục đích thử nghiệm) khi chúng ta cần thêm những kẻ thù như nhện và quỷ khổng lồ. Để chúng phù hợp với luồng theo lượt, nó phải được tạo và thực thi giống hệt nhau ở cả hai bên. Vấn đề này được giải quyết bằng cách cho phép một đồng nghiệp điều khiển hệ thống rẽ và gửi trạng thái hiện tại cho ứng dụng ngang hàng từ xa. Sau đó, khi đến nơi trình thu thập dữ liệu, trình quản lý rẽ sẽ cho phép hệ thống AI tạo một lệnh được gửi đến người dùng từ xa. Vì công cụ phát triển trò chơi chỉ thao tác theo các lệnh và entity-id:nên trò chơi sẽ được mô phỏng giống nhau ở cả hai bên. Tất cả các đơn vị cũng có thể có thành phần AI để dễ dàng kiểm thử tự động.

Tốt nhất là bạn nên có một trình kết xuất canvas đơn giản hơn khi bắt đầu quá trình phát triển mà vẫn tập trung vào logic trò chơi. Nhưng niềm vui thực sự bắt đầu khi phiên bản 3d được triển khai và các cảnh trở nên sống động với môi trường và hoạt ảnh. Chúng tôi sử dụng three.js làm công cụ 3d và rất dễ để đạt được trạng thái có thể phát do kiến trúc.

Vị trí chuột được gửi thường xuyên hơn đến người dùng từ xa và các gợi ý nhỏ về ánh sáng 3d về vị trí hiện tại của con trỏ.