跳转到内容

多人游戏架构

En Parlant~ 的多人游戏功能使用 WebSocket 中继服务器实时连接两位玩家。没有点对点通信——所有数据都通过中继服务器传输。这使得网络架构保持简洁,并能在防火墙和 NAT 环境下可靠运行。

Player A (host) Relay Server Player B (joiner)
| | |
|--- create_game(name) ----------->| |
|<-- game_created(code) -----------| |
| |<--- join_game(code, name) -----|
|<-- game_joined(name) ------------|--- game_joined(name) -------->|
| | |
|--- game_move(uci, times) ------->|--- game_move(uci, times) --->|
|<-- game_move(uci, times) --------|<-- game_move(uci, times) ----|
| | |
|--- heartbeat ------------------>|--- peer_heartbeat ----------->|

中继服务器是一个轻量的转发层。它在内存中维护房间,在每个房间的两位玩家之间路由事件,并处理清理工作。服务器端没有棋局逻辑或走法验证——客户端拥有最终权威。

  • 前端(客户端):socket.io-client —— 标准的 Socket.IO JavaScript 客户端
  • 后端(中继):socketioxide —— 基于 Axum 构建的 Rust Socket.IO 服务器
  • **协议:**Socket.IO over WebSocket(如需要可自动回退到 HTTP 长轮询)

之所以选择 Socket.IO 而非原始 WebSocket,是因为它开箱即用地提供了自动重连、房间/命名空间管理以及结构化的事件处理。

房主点击 Multiplayer 并输入显示名称。客户端发送一个包含名称的 create_game 事件。服务器:

  1. 生成一个唯一的 6 字符房间代码
  2. 创建房间并将房主添加为第一位玩家
  3. 返回 game_created(code) 以便房主分享代码

加入者输入房间代码和显示名称。客户端发送 join_game(code, name) 事件。服务器:

  1. 根据代码查找房间
  2. 将加入者添加为第二位玩家
  3. 向双方发送 game_joined(name) —— 每位玩家都会收到对方的显示名称

当两位玩家都进入房间后:

  • 房主始终执白,加入者始终执黑。 这由加入顺序决定,无需协商。
  • 走法以 UCI 格式发送(例如 e2e4),同时附带双方的时钟时间。
  • 中继服务器将每个 game_move 事件转发给对手。双方也会收到自己走法的回传作为确认。

多个事件处理游戏结束的场景:

  • 认输 —— 转发给对手以触发认输对话框
  • 提和 —— 转发给对手,对手可以接受或忽略
  • 接受和棋 —— 转发给双方以和棋结束游戏

游戏结束后,任一玩家都可以发送 ready 事件表示想要再赛。当双方都发送了 ready 后,客户端重置棋盘并交换颜色(或保持不变——由客户端决定)。

房间代码为 6 个字符,格式为 XX-XX-XX 以便口头分享时易于辨认。字符集排除了视觉上容易混淆的字符:

  • 不使用 0O(数字零与字母 O)
  • 不使用 1IL(数字一、大写 I 与大写 L)

这避免了通过语音聊天读代码或从朋友消息中手动输入时出现的”那是零还是 O?“问题。

多人游戏连接需要检测玩家是否断线——无论是网络中断、关闭应用还是笔记本电脑进入休眠。En Parlant~ 为此使用了心跳系统:

  1. 每个客户端每 5 秒 向服务器发送一次 heartbeat 事件
  2. 服务器确认心跳并将其作为 peer_heartbeat 转发给对手
  3. 客户端记录最后一次收到对手 peer_heartbeat 的时间
  4. isPeerAlive(timeoutMs) 函数检查对手的最后一次心跳是否在可接受的阈值范围内

这驱动了界面中的连接状态指示器。如果心跳停止到达,玩家会看到对手可能已断线,并可以选择等待或离开游戏。

中继服务器自动清除空闲房间以防止内存泄漏:

  • 房间在 30 分钟 无活动后被视为空闲
  • 清理任务每 60 秒 运行一次,清除所有超过空闲阈值的房间
  • 房间被清理时,所有剩余连接将被断开

没有持久化存储。如果服务器重启,所有房间都会消失。这是有意为之的——中继服务器是无状态且临时性的。正在进行的游戏需要重新开始,但这在实际使用中很少发生。

默认中继运行在 Fly.io 上,提供低延迟的 WebSocket 连接和自动 TLS。请参阅多人游戏服务器设置指南了解如何运行自己的中继服务器。

在开发过程中测试多人游戏功能:

  1. 克隆并运行中继服务器:

    Terminal window
    git clone https://github.com/DarrellThomas/en-parlant-relay.git
    cd en-parlant-relay
    cargo run

    服务器在端口 3210 上启动。

  2. 在 En Parlant~ 中,将中继服务器 URL 更改为 ws://localhost:3210

  3. 打开两个应用实例(或一个应用加一个开发模式)来模拟双方玩家。

走法、心跳和所有事件的工作方式与生产环境中继完全相同——唯一的区别是连接 URL。