280 lines
9.6 KiB
Markdown
280 lines
9.6 KiB
Markdown
# 实时通信模块详设文档
|
||
|
||
> **文档版本**: v1.0
|
||
> **撰写人**: 通信架构师
|
||
> **创建日期**: 2024年9月11日
|
||
|
||
## 1. WebSocket通信协议
|
||
|
||
### 1.1 协议选型
|
||
|
||
- **WebSocket**: 基于TCP的全双工通信协议,提供持久化连接,是实现游戏实时对战、状态同步的最佳选择。
|
||
- **WSS (WebSocket Secure)**: 在生产环境强制使用WSS协议,确保所有通信数据都经过TLS加密,保障数据安全。
|
||
|
||
### 1.2 消息格式
|
||
|
||
所有客户端与服务端之间的WebSocket消息都采用统一的JSON格式进行封装,便于解析和扩展。
|
||
|
||
```json
|
||
{
|
||
"type": "MESSAGE_TYPE_ENUM",
|
||
"payload": {
|
||
"key1": "value1",
|
||
"key2": "value2"
|
||
},
|
||
"timestamp": "2024-09-11T10:00:00.000Z",
|
||
"client_message_id": "optional-client-uuid-for-ack"
|
||
}
|
||
```
|
||
|
||
- `type`: 消息类型,用于路由到不同的处理逻辑。
|
||
- `payload`: 消息体,包含具体业务数据。
|
||
- `timestamp`: 消息发送的UTC时间戳。
|
||
- `client_message_id`: (可选) 客户端生成的消息ID,用于实现消息确认(ACK)机制。
|
||
|
||
### 1.3 心跳机制
|
||
|
||
为维持连接活性并检测死链,采用双向心跳机制:
|
||
|
||
- **客户端**: 每隔25秒向服务端发送一个`PING`消息。
|
||
- **服务端**:
|
||
- 收到`PING`消息后,立即回复一个`PONG`消息。
|
||
- 如果在60秒内未收到任何客户端消息(包括`PING`),则认为连接已断开,主动关闭该WebSocket连接。
|
||
|
||
```typescript
|
||
// 客户端PING消息
|
||
{
|
||
"type": "PING",
|
||
"payload": {},
|
||
"timestamp": "..."
|
||
}
|
||
|
||
// 服务端PONG消息
|
||
{
|
||
"type": "PONG",
|
||
"payload": {},
|
||
"timestamp": "..."
|
||
}
|
||
```
|
||
|
||
## 2. 消息类型与数据结构
|
||
|
||
### 2.1 消息类型枚举 (`GameMessageType`)
|
||
|
||
```typescript
|
||
// 客户端 -> 服务端 (C2S)
|
||
export enum ClientToServerMessageType {
|
||
// --- 系统级 ---
|
||
PING = 'PING', // 心跳检测
|
||
AUTHENTICATE = 'AUTHENTICATE', // 身份认证
|
||
|
||
// --- 房间管理 ---
|
||
CREATE_ROOM = 'CREATE_ROOM', // 创建房间
|
||
JOIN_ROOM = 'JOIN_ROOM', // 加入房间
|
||
LEAVE_ROOM = 'LEAVE_ROOM', // 离开房间
|
||
GET_ROOM_LIST = 'GET_ROOM_LIST', // 获取房间列表
|
||
PLAYER_READY = 'PLAYER_READY', // 玩家准备
|
||
|
||
// --- 游戏逻辑 ---
|
||
PLACE_PLANES = 'PLACE_PLANES', // 放置飞机
|
||
EXECUTE_ATTACK = 'EXECUTE_ATTACK', // 执行攻击
|
||
SURRENDER = 'SURRENDER' // 投降
|
||
}
|
||
|
||
// 服务端 -> 客户端 (S2C)
|
||
export enum ServerToClientMessageType {
|
||
// --- 系统级 ---
|
||
PONG = 'PONG', // 心跳响应
|
||
AUTHENTICATED = 'AUTHENTICATED', // 认证成功
|
||
ERROR = 'ERROR', // 错误通知
|
||
|
||
// --- 房间与游戏状态同步 ---
|
||
ROOM_LIST_UPDATE = 'ROOM_LIST_UPDATE', // 房间列表更新
|
||
ROOM_STATE_UPDATE = 'ROOM_STATE_UPDATE',// 房间状态更新
|
||
GAME_STATE_UPDATE = 'GAME_STATE_UPDATE',// 游戏状态更新
|
||
|
||
// --- 游戏事件通知 ---
|
||
GAME_STARTED = 'GAME_STARTED', // 游戏开始
|
||
PLACEMENT_PHASE_START = 'PLACEMENT_PHASE_START', // 放置阶段开始
|
||
BATTLE_PHASE_START = 'BATTLE_PHASE_START', // 对战阶段开始
|
||
TURN_CHANGE = 'TURN_CHANGE', // 回合变更
|
||
ATTACK_RESULT = 'ATTACK_RESULT', // 攻击结果
|
||
GAME_OVER = 'GAME_OVER', // 游戏结束
|
||
PLAYER_RECONNECTED = 'PLAYER_RECONNECTED',// 玩家重连
|
||
PLAYER_DISCONNECTED = 'PLAYER_DISCONNECTED'// 玩家断线
|
||
}
|
||
```
|
||
|
||
### 2.2 核心消息体 (`Payload`) 详解
|
||
|
||
#### `AUTHENTICATE` (C2S)
|
||
- **描述**: 客户端连接后发送的第一条消息,用于身份认证。
|
||
- **Payload**:
|
||
```typescript
|
||
interface AuthenticatePayload {
|
||
token: string; // 从HTTP登录接口获取的JWT
|
||
}
|
||
```
|
||
|
||
#### `AUTHENTICATED` (S2C)
|
||
- **描述**: 服务端认证成功后返回的消息。
|
||
- **Payload**:
|
||
```typescript
|
||
interface AuthenticatedPayload {
|
||
userId: string;
|
||
nickname: string;
|
||
// ... 其他用户信息
|
||
}
|
||
```
|
||
|
||
#### `CREATE_ROOM` (C2S)
|
||
- **描述**: 客户端请求创建新房间。
|
||
- **Payload**:
|
||
```typescript
|
||
interface CreateRoomPayload {
|
||
roomName: string;
|
||
isPublic: boolean;
|
||
password?: string; // 如果是私密房间
|
||
}
|
||
```
|
||
|
||
#### `ROOM_STATE_UPDATE` (S2C)
|
||
- **描述**: 当房间状态(如玩家加入/退出/准备)发生变化时,服务端向房间内所有客户端广播。
|
||
- **Payload**: `RoomState` 对象 (详见后端设计文档)
|
||
|
||
#### `PLACE_PLANES` (C2S)
|
||
- **描述**: 玩家在布置阶段提交飞机布局。
|
||
- **Payload**:
|
||
```typescript
|
||
interface PlacePlanesPayload {
|
||
planes: {
|
||
center: { x: number, y: number };
|
||
direction: 'up' | 'down' | 'left' | 'right';
|
||
}[];
|
||
}
|
||
```
|
||
|
||
#### `EXECUTE_ATTACK` (C2S)
|
||
- **描述**: 玩家在对战阶段执行攻击。
|
||
- **Payload**:
|
||
```typescript
|
||
interface ExecuteAttackPayload {
|
||
position: { x: number, y: number };
|
||
}
|
||
```
|
||
|
||
#### `GAME_STATE_UPDATE` (S2C)
|
||
- **描述**: 游戏核心状态发生变化时,服务端向游戏内玩家广播。
|
||
- **Payload**: `GameState` 对象 (详见游戏核心逻辑设计文档),但会根据接收玩家进行数据裁剪(如隐藏对手未被攻击的飞机位置)。
|
||
|
||
#### `ATTACK_RESULT` (S2C)
|
||
- **描述**: 服务端通知攻击结果。
|
||
- **Payload**:
|
||
```typescript
|
||
interface AttackResultPayload {
|
||
attackerId: string;
|
||
position: { x: number, y: number };
|
||
result: 'miss' | 'hit' | 'destroy';
|
||
targetPlaneId?: string; // 如果击中
|
||
isGameOver: boolean;
|
||
}
|
||
```
|
||
|
||
#### `ERROR` (S2C)
|
||
- **描述**: 服务端向客户端发送错误信息。
|
||
- **Payload**:
|
||
```typescript
|
||
interface ErrorPayload {
|
||
code: number; // 错误码
|
||
message: string; // 错误信息
|
||
requestType?: string; // 导致错误的请求类型
|
||
}
|
||
```
|
||
|
||
## 3. 断线重连机制
|
||
|
||
### 3.1 核心流程
|
||
|
||
1. **客户端检测断线**:
|
||
- WebSocket `onclose` 事件被触发。
|
||
- 或,发送`PING`后超过10秒未收到`PONG`。
|
||
|
||
2. **自动重连**:
|
||
- 客户端立即尝试重新建立WebSocket连接。
|
||
- 采用**指数退避算法**进行重连尝试,例如:首次延迟1秒,然后2秒, 4秒, 8秒... 直到成功或达到最大重连次数(5次)。
|
||
|
||
3. **重连后认证**:
|
||
- 新连接建立后,客户端必须立即发送`AUTHENTICATE`消息,并携带之前的JWT。
|
||
|
||
4. **服务端处理重连**:
|
||
- 服务端通过JWT识别出这是同个玩家的重连请求。
|
||
- 服务端查找该玩家当前是否处于某个游戏会话中。
|
||
- 如果在游戏中,服务端将最新的`GameState`发送给该玩家,并向房间内所有玩家广播`PLAYER_RECONNECTED`事件。
|
||
|
||
5. **状态同步**:
|
||
- 客户端收到完整的`GameState`后,恢复游戏界面,确保与服务器状态一致。
|
||
|
||
### 3.2 服务端实现要点
|
||
|
||
- **会话持久化**: 玩家的`userId`和其`connectionId`的映射关系需要存储在Redis中,并设置合理的过期时间(如5分钟),以便在断线期间保留会话信息。
|
||
- **游戏状态恢复**: `GameStateMachine`实例必须在玩家断线时保留在内存中,直到游戏结束或超时。当玩家重连时,可以从该实例获取最新状态。
|
||
|
||
```typescript
|
||
// Redis中存储的重连会话信息
|
||
// Key: "reconnect:session:{userId}"
|
||
// Value (HASH):
|
||
// connectionId: "previous-connection-id"
|
||
// gameId: "current-game-id"
|
||
// roomCode: "current-room-code"
|
||
// expireAt: "timestamp"
|
||
```
|
||
|
||
## 4. 多节点部署与消息同步
|
||
|
||
### 4.1 挑战
|
||
|
||
当后端WebSocket服务部署在多个节点上时,同一房间的两个玩家可能连接到不同的服务器实例。一个玩家发送的消息需要被广播给连接在另一台服务器上的对手。
|
||
|
||
### 4.2 解决方案:Redis Pub/Sub
|
||
|
||
使用Redis的发布/订阅(Pub/Sub)机制作为消息总线,实现跨节点通信。
|
||
|
||
1. **消息流**:
|
||
- 客户端A向服务器S1发送消息(如`EXECUTE_ATTACK`)。
|
||
- S1处理消息,更新游戏状态。
|
||
- S1将需要广播的消息(如`ATTACK_RESULT`, `GAME_STATE_UPDATE`)发布到一个特定的Redis频道,例如`game-room:{roomCode}`。
|
||
- 所有后端服务器实例(S1, S2, ...)都订阅了相关的频道。
|
||
- S2接收到`game-room:{roomCode}`频道的消息。
|
||
|
||
2. **消息投递**:
|
||
- S2查找连接在本机且属于`roomCode`房间的客户端(即客户端B)。
|
||
- S2将消息通过WebSocket连接发送给客户端B。
|
||
|
||
### 4.3 `socket.io` 与 `socket.io-redis-adapter`
|
||
|
||
为简化实现,推荐使用`socket.io`库及其官方Redis适配器`socket.io-redis-adapter`。
|
||
|
||
- **`socket.io`**: 提供了房间(room)、广播(broadcast)、命名空间(namespace)等高级抽象,并内置了心跳和自动重连机制。
|
||
- **`socket.io-redis-adapter`**: 自动处理了上述的Redis Pub/Sub逻辑。只需简单配置,即可实现多节点间的无缝消息广播。
|
||
|
||
#### 示例配置
|
||
```typescript
|
||
import { Server } from 'socket.io';
|
||
import { createAdapter } from '@socket.io/redis-adapter';
|
||
import { createClient } from 'redis';
|
||
|
||
const io = new Server();
|
||
|
||
const pubClient = createClient({ url: 'redis://localhost:6379' });
|
||
const subClient = pubClient.duplicate();
|
||
|
||
Promise.all([pubClient.connect(), subClient.connect()]).then(() => {
|
||
io.adapter(createAdapter(pubClient, subClient));
|
||
io.listen(3000);
|
||
});
|
||
|
||
// 使用方法
|
||
io.to('some-room-code').emit('event_name', { data: '...' });
|
||
```
|
||
|
||
此方案将所有跨节点通信的复杂性都交由`socket.io-redis-adapter`处理,使业务代码可以专注于游戏逻辑,无需关心底层的消息路由。 |