Chuyển trò chơi nhiều người chơi từ C++ sang web bằng Cheerp, WebRTC và Firebase

Giới thiệu

Công ty của chúng tôi Công nghệ nghiêng cung cấp các giải pháp chuyển các ứng dụng máy tính để bàn truyền thống sang web. Trình biên dịch C++ của chúng tôi cổ vũ tạo ra sự kết hợp giữa WebAssugging và JavaScript, cung cấp cả tương tác trình duyệt đơn giản, và hiệu suất cao.

Để làm ví dụ về ứng dụng của nó, chúng tôi quyết định chuyển một trò chơi nhiều người chơi lên web và chọn Thế giới. Teeworlds là một trò chơi cổ điển XNUMXD nhiều người chơi với một cộng đồng người chơi nhỏ nhưng năng động (bao gồm cả tôi!). Nó nhỏ cả về tài nguyên tải xuống cũng như yêu cầu về CPU và GPU - một ứng cử viên lý tưởng.

Chuyển trò chơi nhiều người chơi từ C++ sang web bằng Cheerp, WebRTC và Firebase
Chạy trên trình duyệt Teeworlds

Chúng tôi quyết định sử dụng dự án này để thử nghiệm giải pháp chung cho việc chuyển mã mạng lên web. Điều này thường được thực hiện theo những cách sau:

  • XMLHttpRequest/tìm nạp, nếu phần mạng chỉ bao gồm các yêu cầu HTTP hoặc
  • WebSockets.

Cả hai giải pháp đều yêu cầu lưu trữ một thành phần máy chủ ở phía máy chủ và không cho phép sử dụng làm giao thức truyền tải UDP. Điều này rất quan trọng đối với các ứng dụng thời gian thực như phần mềm hội nghị truyền hình và trò chơi, vì nó đảm bảo việc phân phối và thứ tự của các gói giao thức TCP có thể trở thành trở ngại cho độ trễ thấp.

Có cách thứ ba - sử dụng mạng từ trình duyệt: WebRTC.

Kênh dữ liệu RTC Nó hỗ trợ cả truyền dẫn đáng tin cậy và không đáng tin cậy (trong trường hợp sau, nó cố gắng sử dụng UDP làm giao thức truyền tải bất cứ khi nào có thể) và có thể được sử dụng cả với máy chủ từ xa và giữa các trình duyệt. Điều này có nghĩa là chúng ta có thể chuyển toàn bộ ứng dụng sang trình duyệt, bao gồm cả thành phần máy chủ!

Tuy nhiên, điều này còn gặp thêm một khó khăn nữa: trước khi hai WebRTC ngang hàng có thể giao tiếp, chúng cần thực hiện một quá trình bắt tay tương đối phức tạp để kết nối, việc này yêu cầu một số thực thể bên thứ ba (một máy chủ báo hiệu và một hoặc nhiều máy chủ CHOÁNG/XOAY).

Lý tưởng nhất là chúng tôi muốn tạo một API mạng sử dụng WebRTC nội bộ nhưng càng gần với giao diện UDP Sockets càng tốt mà không cần thiết lập kết nối.

Điều này sẽ cho phép chúng tôi tận dụng WebRTC mà không cần phải tiết lộ các chi tiết phức tạp đối với mã ứng dụng (điều mà chúng tôi muốn thay đổi ít nhất có thể trong dự án của mình).

WebRTC tối thiểu

WebRTC là một bộ API có sẵn trong các trình duyệt cung cấp khả năng truyền âm thanh, video và dữ liệu tùy ý ngang hàng.

Kết nối giữa các máy ngang hàng được thiết lập (ngay cả khi có NAT ở một hoặc cả hai bên) bằng cách sử dụng máy chủ STUN và/hoặc TURN thông qua cơ chế gọi là ICE. Các máy ngang hàng trao đổi thông tin ICE và các tham số kênh thông qua ưu đãi và câu trả lời của giao thức SDP.

Ồ! Có bao nhiêu chữ viết tắt cùng một lúc? Hãy giải thích ngắn gọn ý nghĩa của những thuật ngữ này:

  • Các tiện ích truyền tải phiên cho NAT (CHOÁNG) — một giao thức bỏ qua NAT và lấy một cặp (IP, cổng) để trao đổi dữ liệu trực tiếp với máy chủ. Nếu anh ta hoàn thành được nhiệm vụ của mình thì các đồng nghiệp có thể trao đổi dữ liệu với nhau một cách độc lập.
  • Truyền tải bằng cách sử dụng rơle xung quanh NAT (XOAY) cũng được sử dụng để truyền tải NAT, nhưng nó thực hiện điều này bằng cách chuyển tiếp dữ liệu thông qua một proxy mà cả hai thiết bị ngang hàng đều nhìn thấy. Nó làm tăng thêm độ trễ và thực hiện tốn kém hơn STUN (vì nó được áp dụng trong toàn bộ phiên giao tiếp), nhưng đôi khi nó là lựa chọn duy nhất.
  • Thiết lập kết nối tương tác (ICE) được sử dụng để chọn phương pháp tốt nhất có thể để kết nối hai thiết bị ngang hàng dựa trên thông tin thu được từ việc kết nối trực tiếp với các thiết bị ngang hàng, cũng như thông tin được nhận bởi bất kỳ số lượng máy chủ STUN và TURN nào.
  • Giao thức mô tả phiên (SDP) là định dạng để mô tả các tham số kênh kết nối, ví dụ: ứng cử viên ICE, codec đa phương tiện (trong trường hợp kênh âm thanh/video), v.v... Một trong những đồng nghiệp gửi Ưu đãi SDP và người thứ hai phản hồi bằng Câu trả lời SDP . . Sau đó, một kênh được tạo ra.

Để tạo kết nối như vậy, các đồng nghiệp cần thu thập thông tin họ nhận được từ máy chủ STUN và TURN và trao đổi thông tin đó với nhau.

Vấn đề là họ chưa có khả năng giao tiếp trực tiếp, do đó phải tồn tại một cơ chế ngoài băng tần để trao đổi dữ liệu này: một máy chủ báo hiệu.

Một máy chủ báo hiệu có thể rất đơn giản vì công việc duy nhất của nó là chuyển tiếp dữ liệu giữa các máy ngang hàng trong giai đoạn bắt tay (như thể hiện trong sơ đồ bên dưới).

Chuyển trò chơi nhiều người chơi từ C++ sang web bằng Cheerp, WebRTC và Firebase
Sơ đồ trình tự bắt tay WebRTC được đơn giản hóa

Tổng quan về mô hình mạng Teeworlds

Kiến trúc mạng của Teeworlds rất đơn giản:

  • Các thành phần máy khách và máy chủ là hai chương trình khác nhau.
  • Khách hàng tham gia trò chơi bằng cách kết nối với một trong nhiều máy chủ, mỗi máy chủ chỉ lưu trữ một trò chơi tại một thời điểm.
  • Tất cả việc truyền dữ liệu trong trò chơi được thực hiện thông qua máy chủ.
  • Một máy chủ chính đặc biệt được sử dụng để thu thập danh sách tất cả các máy chủ công cộng được hiển thị trong ứng dụng khách trò chơi.

Nhờ sử dụng WebRTC để trao đổi dữ liệu, chúng tôi có thể chuyển thành phần máy chủ của trò chơi sang trình duyệt nơi đặt máy khách. Điều này mang đến cho chúng ta một cơ hội tuyệt vời...

Loại bỏ các máy chủ

Việc thiếu logic máy chủ có một lợi thế lớn: chúng tôi có thể triển khai toàn bộ ứng dụng dưới dạng nội dung tĩnh trên Trang Github hoặc trên phần cứng của riêng chúng tôi đằng sau Cloudflare, do đó đảm bảo tải xuống nhanh chóng và thời gian hoạt động cao miễn phí. Trên thực tế, chúng ta có thể quên chúng đi, và nếu may mắn và trò chơi trở nên phổ biến thì cơ sở hạ tầng sẽ không cần phải hiện đại hóa.

Tuy nhiên, để hệ thống hoạt động, chúng ta vẫn phải sử dụng kiến ​​trúc bên ngoài:

  • Một hoặc nhiều máy chủ STUN: Chúng tôi có một số tùy chọn miễn phí để lựa chọn.
  • Ít nhất một máy chủ TURN: không có tùy chọn miễn phí nào ở đây, vì vậy chúng tôi có thể thiết lập máy chủ của riêng mình hoặc trả tiền cho dịch vụ. May mắn thay, hầu hết kết nối có thể được thiết lập thông qua máy chủ STUN (và cung cấp p2p thực sự), nhưng cần có TURN làm tùy chọn dự phòng.
  • Máy chủ báo hiệu: Không giống như hai khía cạnh còn lại, báo hiệu không được chuẩn hóa. Máy chủ báo hiệu thực sự sẽ chịu trách nhiệm gì phụ thuộc phần nào vào ứng dụng. Trong trường hợp của chúng tôi, trước khi thiết lập kết nối, cần trao đổi một lượng nhỏ dữ liệu.
  • Máy chủ chính của Teeworlds: Nó được các máy chủ khác sử dụng để quảng cáo sự tồn tại của họ và được khách hàng sử dụng để tìm máy chủ công cộng. Mặc dù điều này không bắt buộc (khách hàng luôn có thể kết nối với máy chủ mà họ biết theo cách thủ công), nhưng sẽ rất tuyệt nếu có để người chơi có thể tham gia trò chơi với những người ngẫu nhiên.

Chúng tôi quyết định sử dụng máy chủ STUN miễn phí của Google và tự mình triển khai một máy chủ TURN.

Đối với hai điểm cuối cùng chúng tôi đã sử dụng Tường lửa:

  • Máy chủ chính của Teeworlds được triển khai rất đơn giản: dưới dạng danh sách các đối tượng chứa thông tin (tên, IP, bản đồ, chế độ, ...) của từng máy chủ đang hoạt động. Máy chủ xuất bản và cập nhật đối tượng của riêng họ, còn khách hàng lấy toàn bộ danh sách và hiển thị nó cho người chơi. Chúng tôi cũng hiển thị danh sách trên trang chủ dưới dạng HTML để người chơi chỉ cần nhấp vào máy chủ và được đưa thẳng đến trò chơi.
  • Việc báo hiệu có liên quan chặt chẽ đến việc triển khai ổ cắm của chúng tôi, được mô tả trong phần tiếp theo.

Chuyển trò chơi nhiều người chơi từ C++ sang web bằng Cheerp, WebRTC và Firebase
Danh sách máy chủ trong game và trên trang chủ

Triển khai ổ cắm

Chúng tôi muốn tạo một API gần với Ổ cắm Posix UDP nhất có thể để giảm thiểu số lượng thay đổi cần thiết.

Chúng tôi cũng muốn triển khai mức tối thiểu cần thiết để trao đổi dữ liệu đơn giản nhất qua mạng.

Ví dụ: chúng tôi không cần định tuyến thực sự: tất cả các thiết bị ngang hàng đều nằm trên cùng một "mạng LAN ảo" được liên kết với một phiên bản cơ sở dữ liệu Firebase cụ thể.

Do đó, chúng tôi không cần địa chỉ IP duy nhất: các giá trị khóa Firebase duy nhất (tương tự như tên miền) là đủ để xác định duy nhất các đồng nghiệp và mỗi đồng nghiệp cục bộ gán địa chỉ IP "giả" cho từng khóa cần dịch. Điều này loại bỏ hoàn toàn nhu cầu gán địa chỉ IP toàn cầu, đây là một nhiệm vụ không hề nhỏ.

Đây là API tối thiểu chúng tôi cần triển khai:

// Create and destroy a socket
int socket();
int close(int fd);
// Bind a socket to a port, and publish it on Firebase
int bind(int fd, AddrInfo* addr);
// Send a packet. This lazily create a WebRTC connection to the 
// peer when necessary
int sendto(int fd, uint8_t* buf, int len, const AddrInfo* addr);
// Receive the packets destined to this socket
int recvfrom(int fd, uint8_t* buf, int len, AddrInfo* addr);
// Be notified when new packets arrived
int recvCallback(Callback cb);
// Obtain a local ip address for this peer key
uint32_t resolve(client::String* key);
// Get the peer key for this ip
String* reverseResolve(uint32_t addr);
// Get the local peer key
String* local_key();
// Initialize the library with the given Firebase database and 
// WebRTc connection options
void init(client::FirebaseConfig* fb, client::RTCConfiguration* ice);

API này đơn giản và tương tự như API ổ cắm Posix, nhưng có một số điểm khác biệt quan trọng: ghi lại các cuộc gọi lại, gán IP cục bộ và kết nối lười biếng.

Đăng ký cuộc gọi lại

Ngay cả khi chương trình gốc sử dụng I/O không chặn, mã vẫn phải được cấu trúc lại để chạy trong trình duyệt web.

Lý do cho điều này là vòng lặp sự kiện trong trình duyệt bị ẩn khỏi chương trình (có thể là JavaScript hoặc WebAssembly).

Trong môi trường tự nhiên, chúng ta có thể viết mã như thế này

while(running) {
  select(...); // wait for I/O events
  while(true) {
    int r = readfrom(...); // try to read
    if (r < 0 && errno == EWOULDBLOCK) // no more data available
      break;
    ...
  }
  ...
}

Nếu vòng lặp sự kiện bị ẩn đối với chúng ta thì chúng ta cần biến nó thành một thứ như thế này:

auto cb = []() { // this will be called when new data is available
  while(true) {
    int r = readfrom(...); // try to read
    if (r < 0 && errno == EWOULDBLOCK) // no more data available
      break;
    ...
  }
  ...
};
recvCallback(cb); // register the callback

Chỉ định IP cục bộ

ID nút trong "mạng" của chúng tôi không phải là địa chỉ IP mà là khóa Firebase (chúng là các chuỗi trông như thế này: -LmEC50PYZLCiCP-vqde ).

Điều này thuận tiện vì chúng ta không cần cơ chế gán IP và kiểm tra tính duy nhất của chúng (cũng như loại bỏ chúng sau khi máy khách ngắt kết nối), nhưng thường cần xác định các đồng nghiệp bằng một giá trị số.

Đây chính xác là mục đích sử dụng của các chức năng này. resolve и reverseResolve: Ứng dụng bằng cách nào đó nhận được giá trị chuỗi của khóa (thông qua đầu vào của người dùng hoặc qua máy chủ chính) và có thể chuyển đổi nó thành địa chỉ IP để sử dụng nội bộ. Phần còn lại của API cũng nhận giá trị này thay vì chuỗi để đơn giản.

Điều này tương tự như tra cứu DNS nhưng được thực hiện cục bộ trên máy khách.

Nghĩa là, địa chỉ IP không thể được chia sẻ giữa các máy khách khác nhau và nếu cần một số loại mã định danh toàn cầu nào đó, nó sẽ phải được tạo theo một cách khác.

Kết nối lười biếng

UDP không cần kết nối, nhưng như chúng ta đã thấy, WebRTC yêu cầu quá trình kết nối kéo dài trước khi có thể bắt đầu truyền dữ liệu giữa hai thiết bị ngang hàng.

Nếu chúng tôi muốn cung cấp mức độ trừu tượng tương tự, (sendto/recvfrom với các đồng nghiệp tùy ý không có kết nối trước), thì họ phải thực hiện kết nối “lười” (bị trì hoãn) bên trong API.

Đây là những gì xảy ra trong quá trình giao tiếp thông thường giữa “máy chủ” và “máy khách” khi sử dụng UDP và thư viện của chúng tôi nên làm gì:

  • Cuộc gọi máy chủ bind()để báo cho hệ điều hành biết rằng nó muốn nhận các gói trên cổng được chỉ định.

Thay vào đó, chúng tôi sẽ xuất bản một cổng mở tới Firebase dưới khóa máy chủ và lắng nghe các sự kiện trong cây con của nó.

  • Cuộc gọi máy chủ recvfrom(), chấp nhận các gói đến từ bất kỳ máy chủ nào trên cổng này.

Trong trường hợp của chúng tôi, chúng tôi cần kiểm tra hàng đợi đến của các gói được gửi đến cổng này.

Mỗi cổng có hàng đợi riêng và chúng tôi thêm cổng nguồn và cổng đích vào đầu gói dữ liệu WebRTC để chúng tôi biết hàng đợi nào sẽ chuyển tiếp đến khi có gói mới đến.

Cuộc gọi không bị chặn, vì vậy nếu không có gói nào, chúng ta chỉ cần trả về -1 và đặt errno=EWOULDBLOCK.

  • Máy khách nhận IP và cổng của máy chủ bằng một số phương tiện bên ngoài và gọi sendto(). Điều này cũng thực hiện một cuộc gọi nội bộ. bind(), do đó tiếp theo recvfrom() sẽ nhận được phản hồi mà không thực hiện liên kết một cách rõ ràng.

Trong trường hợp của chúng tôi, máy khách nhận khóa chuỗi từ bên ngoài và sử dụng hàm resolve() để có được một địa chỉ IP.

Tại thời điểm này, chúng tôi bắt đầu bắt tay WebRTC nếu hai thiết bị ngang hàng chưa được kết nối với nhau. Các kết nối tới các cổng khác nhau của cùng một thiết bị ngang hàng sử dụng cùng một Kênh dữ liệu WebRTC.

Chúng tôi cũng thực hiện gián tiếp bind()để máy chủ có thể kết nối lại trong lần tiếp theo sendto() trong trường hợp nó đóng cửa vì lý do nào đó.

Máy chủ được thông báo về kết nối của máy khách khi máy khách viết ưu đãi SDP của mình dưới thông tin cổng máy chủ trong Firebase và máy chủ sẽ phản hồi bằng phản hồi của nó ở đó.

Sơ đồ bên dưới hiển thị một ví dụ về luồng tin nhắn cho sơ đồ ổ cắm và việc truyền tin nhắn đầu tiên từ máy khách đến máy chủ:

Chuyển trò chơi nhiều người chơi từ C++ sang web bằng Cheerp, WebRTC và Firebase
Sơ đồ hoàn chỉnh giai đoạn kết nối giữa client và server

Kết luận

Nếu bạn đã đọc đến đây, có lẽ bạn sẽ quan tâm đến việc xem lý thuyết hoạt động như thế nào. Trò chơi có thể được chơi trên teeworlds.leaningtech.com, thử nó!


Trận đấu giao hữu giữa các đồng nghiệp

Mã thư viện mạng được cung cấp miễn phí tại Github. Tham gia cuộc trò chuyện trên kênh của chúng tôi tại Gitter!

Nguồn: www.habr.com

Thêm một lời nhận xét