主题切换
多人游戏同步
预计阅读 25 分钟
版本信息
最后更新:2026-07-09 · 适用基础库:≥ 3.9.0
多人游戏的核心难题是网络延迟。如何在 50-200ms 的延迟下让多个玩家看到一致且流畅的游戏世界?本章从前端实时应用(如在线文档、聊天)的经验出发,深入讲解游戏同步的经典方案。
前端视角:从 CRDT 到帧同步
前端对比:实时协作 vs 游戏同步
| 维度 | 前端实时协作(在线文档) | 游戏多人同步 |
|---|---|---|
| 延迟容忍度 | 高(100-500ms 可接受) | 极低(> 50ms 影响操作手感) |
| 数据量 | 小(文本编辑操作) | 大(位置/旋转/动作为主,但频率高) |
| 一致性要求 | 最终一致性(CRDT/OT) | 强一致性(状态同步)/ 乐观执行(帧同步) |
| 典型方案 | WebSocket + CRDT | WebSocket + 帧同步/状态同步 |
| 冲突解决 | 自动合并(OT 变换) | 服务端权威裁决 |
关键差异: 游戏同步不仅要"最终一致",还要保证操作的即时响应。你不能等待服务端确认后再让角色移动——那会有明显的延迟感。这就是为什么游戏需要"客户端预测"这类技术。
两种核心同步架构
状态同步(State Sync)
服务端运行权威游戏逻辑,客户端本质上是"带预测的渲染终端"。
客户端 A 服务端 客户端 B
│ 发送操作 "向前移动" │ │
│ ──────────────────────→ │ │
│ │ 更新权威状态 │
│ 返回新状态 {x:10,y:0} │ │
│ ←────────────────────── │ │
│ │ 广播状态给所有客户端 │
│ │ ───────────────────────→ │ 同步显示优点:
- 防作弊能力强(服务端权威)
- 实现相对简单
- 断线重连容易(只需同步最新状态)
缺点:
- 带宽消耗大(每个对象状态都要同步)
- 延迟感明显(必须等服务端确认)
适用场景: MMORPG、MOBA、棋牌游戏
帧同步(Lockstep / Frame Sync)
所有客户端运行相同的游戏逻辑,只同步玩家输入,确保确定性执行。
客户端 A 服务端 客户端 B
│ 发送输入 {frame:100, │ │
│ input: "move_up"} │ │
│ ──────────────────────→ │ │
│ │ 收集所有玩家输入 │
│ 广播所有输入 │ │
│ ←────────────────────── │ ───────────────────────→ │
│ 本地执行 frame 100 │ │ 本地执行 frame 100优点:
- 带宽极省(只传输入指令)
- 天然支持回放和观战
缺点:
- 需保证逻辑确定性(浮点数、物理引擎都需要处理)
- 所有玩家需等待最慢的客户端
- 断线重连复杂(需重放所有帧)
适用场景: RTS(星际争霸)、格斗游戏、一些休闲对战
微信小游戏同步建议
对于微信小游戏(休闲对战为主),推荐 状态同步 + 客户端预测 方案:
- 帧同步的确定性要求太高,微信小游戏的 JS 环境难以保障
- WebSocket 断线重连机制微信已内置支持
- 服务端可用微信云开发/云函数降低运维成本
客户端预测 (Client-Side Prediction)
核心思想:在等待服务端确认之前,先乐观地执行操作。
typescript
// ClientPrediction.ts — 客户端预测核心逻辑
interface PlayerState {
x: number
y: number
velocityX: number
velocityY: number
sequence: number // 操作序列号
}
class ClientPredictionSystem {
private pendingInputs: Array<{
sequence: number
input: { dx: number; dy: number }
predictedState: PlayerState
}> = []
private serverState: PlayerState | null = null
private sequence = 0
private ws: any // WebSocket 连接实例(由外部注入)
private playerSprite: { x: number; y: number } // 玩家精灵引用
constructor(ws: any, playerSprite: { x: number; y: number }) {
this.ws = ws
this.playerSprite = playerSprite
}
/** 处理本地输入 — 立即预测结果 */
applyInput(dx: number, dy: number): void {
this.sequence++
const input = { dx, dy }
const predictedState = this.simulate(this.serverState!, input)
this.pendingInputs.push({
sequence: this.sequence,
input,
predictedState,
})
// 立即用预测的状态渲染
this.render(predictedState)
// 发送输入到服务端
this.sendToServer({ sequence: this.sequence, input })
}
/** 接收服务端权威状态 — 进行和解 (Reconciliation) */
receiveServerState(serverState: PlayerState): void {
this.serverState = serverState
// 移除已确认的输入
this.pendingInputs = this.pendingInputs.filter((p) => p.sequence > serverState.sequence)
// 重放未确认的输入
let reconciledState = { ...serverState }
for (const pending of this.pendingInputs) {
reconciledState = this.simulate(reconciledState, pending.input)
}
// 如果预测与权威状态偏差过大,做平滑纠正
if (this.isSignificantDeviation(reconciledState)) {
this.smoothCorrect(reconciledState)
} else {
this.render(reconciledState)
}
}
private simulate(state: PlayerState, input: { dx: number; dy: number }): PlayerState {
return {
x: state.x + input.dx,
y: state.y + input.dy,
velocityX: input.dx,
velocityY: input.dy,
sequence: state.sequence,
}
}
private isSignificantDeviation(predicted: PlayerState): boolean {
if (!this.serverState) return false
const dist = Math.sqrt(
(predicted.x - this.serverState.x) ** 2 + (predicted.y - this.serverState.y) ** 2
)
return dist > 5 // 偏差超过 5 个单位需要明显纠正
}
private smoothCorrect(target: PlayerState): void {
// 使用线性插值平滑过渡到正确位置(而非瞬移)
const LERP_FACTOR = 0.2
const current = this.serverState!
this.render({
x: current.x + (target.x - current.x) * LERP_FACTOR,
y: current.y + (target.y - current.y) * LERP_FACTOR,
velocityX: target.velocityX,
velocityY: target.velocityY,
sequence: target.sequence,
})
}
private render(state: PlayerState): void {
// 更新渲染
this.playerSprite.x = state.x
this.playerSprite.y = state.y
}
private sendToServer(data: any): void {
this.ws.send({ type: 'player_input', data: JSON.stringify(data) })
}
}延迟补偿 (Lag Compensation)
服务端在处理射击/碰撞判定时,需要回滚到攻击者视角的时间点进行判定。
typescript
// 服务端:延迟补偿的碰撞检测
class LagCompensationSystem {
// 保存所有玩家最近 500ms 的位置快照
private playerSnapshots = new Map<
string,
Array<{
timestamp: number
position: { x: number; y: number }
}>
>()
/** 记录玩家位置快照 */
recordSnapshot(playerId: string, position: { x: number; y: number }): void {
const snapshots = this.playerSnapshots.get(playerId) || []
snapshots.push({
timestamp: Date.now(),
position: { ...position },
})
// 只保留 500ms 内的快照
const cutoff = Date.now() - 500
this.playerSnapshots.set(
playerId,
snapshots.filter((s) => s.timestamp >= cutoff)
)
}
/** 回滚检测:在攻击者的视角确认是否命中 */
checkHit(
attackerId: string,
targetId: string,
attackerPing: number // 攻击者延迟 (ms)
): boolean {
const now = Date.now()
// 回滚到攻击者发送攻击的时刻
const rollbackTime = now - attackerPing
const targetSnapshots = this.playerSnapshots.get(targetId)
if (!targetSnapshots) return false
// 找到最接近回滚时间的快照
const targetSnapshot = this.interpolateSnapshot(targetSnapshots, rollbackTime)
const attackerPosition = this.interpolateSnapshot(
this.playerSnapshots.get(attackerId)!,
rollbackTime
)
// 使用历史位置做碰撞检测
return this.checkCollision(attackerPosition, targetSnapshot)
}
private interpolateSnapshot(
snapshots: Array<{ timestamp: number; position: { x: number; y: number } }>,
targetTime: number
): { x: number; y: number } {
// 找到目标时间前后的两个快照,线性插值
let before = snapshots[0],
after = snapshots[snapshots.length - 1]
for (let i = 0; i < snapshots.length - 1; i++) {
if (snapshots[i].timestamp <= targetTime && snapshots[i + 1].timestamp >= targetTime) {
before = snapshots[i]
after = snapshots[i + 1]
break
}
}
// 防止除零:如果两个快照时间戳相同,直接返回 before 位置
if (after.timestamp === before.timestamp) {
return { x: before.position.x, y: before.position.y }
}
const t = (targetTime - before.timestamp) / (after.timestamp - before.timestamp)
return {
x: before.position.x + (after.position.x - before.position.x) * t,
y: before.position.y + (after.position.y - before.position.y) * t,
}
}
private checkCollision(a: { x: number; y: number }, b: { x: number; y: number }): boolean {
const dist = Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2)
return dist < 30 // 碰撞半径 30
}
}WebSocket 在微信小游戏中的实现
typescript
// 简易事件总线(GameEvent 的实现)
class GameEvent {
private static listeners = new Map<string, Array<(...args: any[]) => void>>()
static on(event: string, callback: (...args: any[]) => void) {
if (!this.listeners.has(event)) this.listeners.set(event, [])
this.listeners.get(event)!.push(callback)
}
static emit(event: string, ...args: any[]) {
const cbs = this.listeners.get(event)
if (cbs) cbs.forEach((cb) => cb(...args))
}
static off(event: string, callback: (...args: any[]) => void) {
const cbs = this.listeners.get(event)
if (cbs)
this.listeners.set(
event,
cbs.filter((cb) => cb !== callback)
)
}
}
// MultiplayerManager.ts — 微信小游戏 WebSocket 封装
class MultiplayerManager {
private ws: WechatMinigame.SocketTask | null = null
private reconnectAttempts = 0
private readonly MAX_RECONNECT = 5
/** 连接到游戏服务器 */
connect(roomId: string, token: string): Promise<void> {
return new Promise((resolve, reject) => {
this.ws = wx.connectSocket({
url: `wss://game.example.com/ws?room=${roomId}&token=${token}`,
header: { 'content-type': 'application/json' },
protocols: ['game-protocol'],
})
this.ws.onOpen(() => {
console.log('WebSocket 连接成功')
this.reconnectAttempts = 0
resolve()
})
this.ws.onMessage(({ data }) => {
const msg = JSON.parse(data as string)
// 分发消息到对应的处理系统(GameEvent 需自行实现或使用 EventEmitter 库)
GameEvent.emit(`msg:${msg.type}`, msg.payload)
})
this.ws.onError((err) => {
console.error('WebSocket 错误', err)
reject(err)
})
this.ws.onClose(({ code, reason }) => {
console.log(`WebSocket 关闭: ${code} — ${reason}`)
this.tryReconnect(roomId, token)
})
})
}
/** 断线重连 — 指数退避 + 随机抖动 */
private tryReconnect(roomId: string, token: string): void {
if (this.reconnectAttempts >= this.MAX_RECONNECT) {
console.error('重连次数已达上限')
GameEvent.emit('connection:failed')
return
}
// 指数退避:1s, 2s, 4s, 8s, 16s
const baseDelay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 16000)
// 随机抖动:±20%
const jitter = baseDelay * (0.8 + Math.random() * 0.4)
this.reconnectAttempts++
setTimeout(() => {
console.log(`尝试重连 (${this.reconnectAttempts}/${this.MAX_RECONNECT})`)
this.connect(roomId, token)
}, jitter)
}
/** 发送消息 */
send(type: string, payload: any): void {
if (this.ws) {
this.ws.send({
data: JSON.stringify({ type, payload, timestamp: Date.now() }),
})
}
}
/** 关闭连接 */
disconnect(): void {
this.ws?.close({ code: 1000, reason: '玩家离开' })
}
}WebRTC DataChannel 在游戏中的应用
WebSocket(TCP)提供可靠有序传输,但对于快节奏动作游戏(FPS、格斗、竞速),TCP 的队头阻塞(Head-of-Line Blocking)会导致延迟抖动。WebRTC DataChannel 基于 SCTP over DTLS over UDP,支持不可靠/部分可靠传输,更接近原生 UDP Socket。
与 WebSocket 的对比
| 维度 | WebSocket (TCP) | WebRTC DataChannel (SCTP/UDP) |
|---|---|---|
| 传输协议 | TCP(可靠、有序) | UDP(可配置可靠性) |
| 连接模式 | Client-Server | P2P 或 Server-Relay |
| 队头阻塞 | ✅ 存在(丢包重传阻塞后续包) | ❌ 可配置为无序模式避免 |
| NAT 穿透 | 不需要(直连服务器) | 需要 STUN/TURN |
| 微信小游戏支持 | ✅ 原生 wx.connectSocket | ⚠️ 需要验证 |
微信小游戏 WebRTC 兼容性
微信小游戏 WebRTC 支持依赖于系统 WebView 的内核版本。在部分旧版本微信或低端设备上可能不可用。使用前务必通过 wx.canIUse 或特性检测验证。对于大多数休闲小游戏,WebSocket 已足够满足需求。
何时考虑 DataChannel
- 实时对战游戏:格斗、MOBA、竞速等需要 <50ms 延迟的场景
- 语音/视频通话:配合 WebRTC MediaStream
- 大量小数据包传输:如射击游戏中每帧的子弹位置更新(无序模式可避免旧数据阻塞新数据)
typescript
// WebRTC DataChannel 基础示例(概念代码)
// ⚠️ 微信小游戏环境中 RTCPeerConnection 不是全局对象,使用前必须检测可用性
// 大多数小游戏场景 WebSocket 已足够,WebRTC 仅在特定实时对战游戏中才需要
if (typeof RTCPeerConnection === 'undefined') {
console.warn('当前环境不支持 WebRTC,请回退到 WebSocket 方案')
} else {
const pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
})
// 创建不可靠、无序的数据通道(适合实时状态同步)
const unreliableChannel = pc.createDataChannel('game-state', {
ordered: false, // 无序模式,避免队头阻塞
maxRetransmits: 0, // 不重传(丢包就算了,下帧数据会覆盖)
})
// 创建可靠有序的数据通道(适合关键事件,如"玩家死亡")
const reliableChannel = pc.createDataChannel('game-events', {
ordered: true,
maxRetransmits: 3,
})
unreliableChannel.onmessage = (event) => {
// 处理收到的游戏状态更新
const state = JSON.parse(event.data)
updateRemotePlayer(state)
}
} // 结束 else 块(环境检测通过)AOI(Area of Interest)兴趣管理
在多人游戏中,不需要将所有实体的状态同步给所有玩家——一个在屏幕外的玩家不需要知道你 1000 米外的移动细节。AOI(兴趣区域) 通过空间分区,确保每个玩家只接收"感兴趣区域"内的实体更新。
九宫格 AOI
最经典的 AOI 方案:将地图划分为网格,玩家只接收所在格子及周围 8 个相邻格子的实体消息。
┌─────┬─────┬─────┐
│ │ │ │
│ 7 │ 0 │ 1 │ ← 玩家在格子 0,接收 0~8 共 9 个格子
│ │ │ │
├─────┼─────┼─────┤
│ │ │ │
│ 6 │ 👤 │ 2 │
│ │ │ │
├─────┼─────┼─────┤
│ │ │ │
│ 5 │ 4 │ 3 │
│ │ │ │
└─────┴─────┴─────┘typescript
// server/aoi/AOIManager.ts — 服务端 AOI 管理器
interface AOIEntity {
id: string
x: number
y: number
gridX: number // 所在格子的 X 坐标
gridY: number // 所在格子的 Y 坐标
}
class GridAOIManager {
private gridSize: number // 每格大小(像素)
private grid: Map<string, Set<AOIEntity>> = new Map()
// 每个实体的邻居列表(用于离开时清理)
private neighbors: Map<string, Set<string>> = new Map()
constructor(gridSize: number = 500) {
this.gridSize = gridSize
}
/** 获取格子 key */
private gridKey(gx: number, gy: number): string {
return `${gx},${gy}`
}
/** 更新实体位置,返回进入/离开的实体列表 */
update(
entity: AOIEntity,
newX: number,
newY: number
): {
enter: AOIEntity[] // 新进入视野的实体
leave: AOIEntity[] // 离开视野的实体
} {
const oldGridX = entity.gridX
const oldGridY = entity.gridY
const newGridX = Math.floor(newX / this.gridSize)
const newGridY = Math.floor(newY / this.gridSize)
entity.x = newX
entity.y = newY
// 格子未变化,无需处理 AOI
if (oldGridX === newGridX && oldGridY === newGridY) {
return { enter: [], leave: [] }
}
// 移除旧位置
if (oldGridX !== undefined) {
this.removeFromGrid(entity, oldGridX, oldGridY)
}
// 加入新位置
entity.gridX = newGridX
entity.gridY = newGridY
this.addToGrid(entity, newGridX, newGridY)
// 计算视野变化
const oldNeighbors = this.neighbors.get(entity.id) ?? new Set()
const newNeighbors = this.getNeighborIds(newGridX, newGridY)
this.neighbors.set(entity.id, newNeighbors)
const enterIds = [...newNeighbors].filter((id) => !oldNeighbors.has(id))
const leaveIds = [...oldNeighbors].filter((id) => !newNeighbors.has(id))
return {
enter: enterIds.map((id) => this.findEntity(id)).filter(Boolean) as AOIEntity[],
leave: leaveIds.map((id) => this.findEntity(id)).filter(Boolean) as AOIEntity[],
}
}
private addToGrid(entity: AOIEntity, gx: number, gy: number): void {
const key = this.gridKey(gx, gy)
if (!this.grid.has(key)) this.grid.set(key, new Set())
this.grid.get(key)!.add(entity)
}
private removeFromGrid(entity: AOIEntity, gx: number, gy: number): void {
const key = this.gridKey(gx, gy)
this.grid.get(key)?.delete(entity)
}
/** 获取九宫格内所有实体 ID */
private getNeighborIds(gx: number, gy: number): Set<string> {
const ids = new Set<string>()
for (let dx = -1; dx <= 1; dx++) {
for (let dy = -1; dy <= 1; dy++) {
const key = this.gridKey(gx + dx, gy + dy)
this.grid.get(key)?.forEach((e) => ids.add(e.id))
}
}
ids.delete('') // 移除可能的空实体
return ids
}
private findEntity(id: string): AOIEntity | undefined {
// 从 grid 中查找(生产环境建议维护独立的 id→entity Map)
for (const set of this.grid.values()) {
for (const e of set) {
if (e.id === id) return e
}
}
}
/** 实体离开场景时清理 */
remove(entity: AOIEntity): void {
this.removeFromGrid(entity, entity.gridX, entity.gridY)
this.neighbors.delete(entity.id)
}
}其他 AOI 方案
| 方案 | 原理 | 适用场景 | 复杂度 |
|---|---|---|---|
| 九宫格 | 固定网格分区 | MMO、开放世界 | 低 |
| 十字链表 | X/Y 轴分别维护有序链表 | 任意形状地图 | 中 |
| 距离型 AOI | 基于实体间距离的半径判断 | 小规模场景(<100 实体) | 低 |
| 四叉树/R树 | 空间索引树 | 大规模非均匀分布 | 高 |
对于微信小游戏的多人场景(通常 <50 人同屏),九宫格或距离型 AOI已足够使用。
服务端参考实现
前面的章节讲解了客户端预测、延迟补偿、AOI 等客户端侧的核心技术。本节提供一个完整的服务端参考实现,帮助你理解服务端权威(Server Authority) 的工作原理。
为什么需要服务端权威?
在任何多人游戏中,永远不要信任客户端。客户端可以被修改、被逆向,客户端发送的任何数据都可能被伪造:
- 玩家位置可以被修改为任意坐标(瞬移作弊)
- 移动速度可以被改为正常值的数倍(加速作弊)
- 分数可以直接篡改
服务端权威的核心原则:服务端是游戏状态的唯一真实来源(Single Source of Truth)。客户端发送的是"意图"(我想往右走),而不是"结果"(我在 (100, 50)),由服务端验证意图的合法性后再更新状态。
服务端 Tick 与客户端预测的关系
本实现中服务端以 20 tick/s 的固定频率运行游戏循环,每个 tick 将权威状态广播给所有客户端。这与前文讲解的客户端预测形成配合:
- 客户端:立即响应用户输入执行预测,在服务端确认到达前保证操作手感
- 服务端:接收输入、验证合法性、更新权威状态、广播给所有客户端
- 客户端收到权威状态后:进行和解(Reconciliation),纠正预测偏差
教学参考代码说明
typescript
// server/GameServer.ts — 多人游戏服务端参考实现
// 依赖: pnpm add ws
// 注意:这是教学参考代码,生产环境需在此基础上增加鉴权、防作弊、水平扩展等
import { WebSocket, WebSocketServer } from 'ws'
// ===== 数据结构定义 =====
interface Player {
id: string
ws: WebSocket
x: number
y: number
score: number
lastInputTime: number
sessionToken: string // 用于断线重连
}
interface Room {
id: string
players: Map<string, Player>
tickTimer: ReturnType<typeof setInterval> | null
createdAt: number
}
interface ClientMessage {
type: 'join' | 'input' | 'leave' | 'reconnect'
roomId?: string
playerId?: string
sessionToken?: string
input?: { dx: number; dy: number; action?: string }
seq?: number // 输入序列号,用于客户端预测
}
interface ServerMessage {
type: 'joined' | 'playerJoined' | 'playerLeft' | 'gameState' | 'reconnected' | 'error'
playerId?: string
playerCount?: number
sessionToken?: string
state?: GameState
message?: string
}
interface GameState {
timestamp: number
players: Array<{ id: string; x: number; y: number; score: number }>
serverSeq: number // 服务器最后处理的输入序号
}
// ===== 游戏服务器 =====
class GameServer {
private rooms: Map<string, Room> = new Map()
private playerToRoom: Map<string, string> = new Map() // playerId → roomId
private wss: WebSocketServer
// 游戏常量
private readonly TICK_RATE = 20 // 每秒 20 个 tick
private readonly TICK_INTERVAL = 1000 / this.TICK_RATE
private readonly PLAYER_SPEED = 5 // 每 tick 移动距离
private readonly MAX_PLAYERS_PER_ROOM = 4
constructor(port: number = 3000) {
this.wss = new WebSocketServer({ port })
console.log(`游戏服务器启动在 ws://localhost:${port}`)
}
start(): void {
this.wss.on('connection', (ws) => {
console.log('新客户端连接')
let playerId: string | null = null
ws.on('message', (raw) => {
try {
const msg: ClientMessage = JSON.parse(raw.toString())
this.handleMessage(ws, msg, playerId)
// 记录 playerId 用于断线处理
if (msg.type === 'join' && msg.playerId) {
playerId = msg.playerId
}
} catch (err) {
this.send(ws, { type: 'error', message: '消息解析失败' })
}
})
ws.on('close', () => {
if (playerId) {
this.handleDisconnect(playerId)
}
console.log(`客户端断开: ${playerId ?? '未加入房间'}`)
})
ws.on('error', (err) => {
console.error('WebSocket 错误:', err)
})
})
}
// ===== 消息处理 =====
private handleMessage(ws: WebSocket, msg: ClientMessage, currentPlayerId: string | null): void {
switch (msg.type) {
case 'join':
this.handleJoin(ws, msg)
break
case 'input':
this.handleInput(currentPlayerId, msg)
break
case 'reconnect':
this.handleReconnect(ws, msg)
break
case 'leave':
this.handleLeave(currentPlayerId)
break
}
}
/** 玩家加入房间 */
private handleJoin(ws: WebSocket, msg: ClientMessage): void {
const roomId = msg.roomId ?? 'default'
const playerId = msg.playerId ?? this.generateId()
// 创建或获取房间
let room = this.rooms.get(roomId)
if (!room) {
room = {
id: roomId,
players: new Map(),
tickTimer: null,
createdAt: Date.now(),
}
this.rooms.set(roomId, room)
}
// 房间人数检查
if (room.players.size >= this.MAX_PLAYERS_PER_ROOM) {
this.send(ws, { type: 'error', message: '房间已满' })
return
}
// 加入房间
const sessionToken = this.generateId()
const player: Player = {
id: playerId,
ws,
x: 0,
y: 0,
score: 0,
lastInputTime: Date.now(),
sessionToken,
}
room.players.set(playerId, player)
this.playerToRoom.set(playerId, roomId)
// 通知玩家加入成功
this.send(ws, {
type: 'joined',
playerId,
sessionToken,
playerCount: room.players.size,
})
// 广播给其他玩家
this.broadcastToRoom(
roomId,
{
type: 'playerJoined',
playerId,
playerCount: room.players.size,
},
playerId
)
// 启动或重启 tick
this.ensureRoomTicking(roomId)
console.log(
`玩家 ${playerId} 加入房间 ${roomId} (${room.players.size}/${this.MAX_PLAYERS_PER_ROOM})`
)
}
/** 处理玩家输入(客户端预测的核心——服务端只做验证和广播) */
private handleInput(playerId: string | null, msg: ClientMessage): void {
if (!playerId || !msg.input) return
const roomId = this.playerToRoom.get(playerId)
if (!roomId) return
const room = this.rooms.get(roomId)
if (!room) return
const player = room.players.get(playerId)
if (!player) return
// 记录最新输入
player.lastInputTime = Date.now()
// 输入校验:限制单 tick 最大位移,防止加速作弊
const maxStep = this.PLAYER_SPEED * 2 // 允许 2 倍速度(网络抖动容错)
const clampedDx = Math.max(-maxStep, Math.min(maxStep, msg.input.dx))
const clampedDy = Math.max(-maxStep, Math.min(maxStep, msg.input.dy))
// 更新服务端权威位置
player.x += clampedDx
player.y += clampedDy
}
/** 断线重连 */
private handleReconnect(ws: WebSocket, msg: ClientMessage): void {
const { playerId, sessionToken } = msg
if (!playerId || !sessionToken) {
this.send(ws, { type: 'error', message: '缺少 playerId 或 sessionToken' })
return
}
const roomId = this.playerToRoom.get(playerId)
if (!roomId) {
this.send(ws, { type: 'error', message: '未找到玩家或房间已销毁' })
return
}
const room = this.rooms.get(roomId)
if (!room) {
this.send(ws, { type: 'error', message: '房间已销毁' })
return
}
const player = room.players.get(playerId)
if (!player || player.sessionToken !== sessionToken) {
this.send(ws, { type: 'error', message: 'Session 无效' })
return
}
// 更新 WebSocket 连接
player.ws = ws
this.send(ws, {
type: 'reconnected',
playerId,
sessionToken,
state: this.buildGameState(room),
})
console.log(`玩家 ${playerId} 重连成功`)
}
/** 玩家离开 */
private handleLeave(playerId: string | null): void {
if (!playerId) return
this.removePlayer(playerId)
}
/** 断线处理(不立即踢出,给予重连窗口) */
private handleDisconnect(playerId: string): void {
const roomId = this.playerToRoom.get(playerId)
if (!roomId) return
const room = this.rooms.get(roomId)
if (!room) return
// 广播玩家离开(客户端可能只是暂时断网,先通知 UI)
this.broadcastToRoom(roomId, {
type: 'playerLeft',
playerId,
playerCount: room.players.size - 1,
})
// 30 秒重连窗口后移除
setTimeout(() => {
const player = room.players.get(playerId)
if (player && player.ws.readyState !== WebSocket.OPEN) {
this.removePlayer(playerId)
}
}, 30000)
}
/** 从房间移除玩家 */
private removePlayer(playerId: string): void {
const roomId = this.playerToRoom.get(playerId)
if (!roomId) return
const room = this.rooms.get(roomId)
if (!room) return
room.players.delete(playerId)
this.playerToRoom.delete(playerId)
// 房间为空则清理
if (room.players.size === 0) {
this.destroyRoom(roomId)
} else {
this.broadcastToRoom(roomId, {
type: 'playerLeft',
playerId,
playerCount: room.players.size,
})
}
}
// ===== 游戏循环(服务端权威) =====
/** 确保房间 tick 循环运行 */
private ensureRoomTicking(roomId: string): void {
const room = this.rooms.get(roomId)
if (!room || room.tickTimer) return
room.tickTimer = setInterval(() => {
this.tick(roomId)
}, this.TICK_INTERVAL)
}
/** 每个 tick 执行的操作 */
private tick(roomId: string): void {
const room = this.rooms.get(roomId)
if (!room) return
// 广播权威状态给所有玩家
const state = this.buildGameState(room)
this.broadcastToRoom(roomId, { type: 'gameState', state })
}
/** 构建游戏状态快照 */
private buildGameState(room: Room): GameState {
const players: GameState['players'] = []
room.players.forEach((p) => {
players.push({
id: p.id,
x: Math.round(p.x),
y: Math.round(p.y),
score: p.score,
})
})
return {
timestamp: Date.now(),
players,
serverSeq: this.serverSeq++,
}
}
private serverSeq = 0
/** 销毁房间 */
private destroyRoom(roomId: string): void {
const room = this.rooms.get(roomId)
if (!room) return
if (room.tickTimer) {
clearInterval(room.tickTimer)
}
room.players.forEach((p) => {
this.playerToRoom.delete(p.id)
p.ws.close()
})
this.rooms.delete(roomId)
console.log(`房间 ${roomId} 已销毁`)
}
// ===== 工具方法 =====
private broadcastToRoom(roomId: string, msg: ServerMessage, excludePlayerId?: string): void {
const room = this.rooms.get(roomId)
if (!room) return
const data = JSON.stringify(msg)
room.players.forEach((player) => {
if (player.id !== excludePlayerId && player.ws.readyState === WebSocket.OPEN) {
player.ws.send(data)
}
})
}
private send(ws: WebSocket, msg: ServerMessage): void {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(msg))
}
}
private generateId(): string {
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
}
}
// ===== 启动服务器 =====
const server = new GameServer(3000)
server.start()服务端架构最佳实践
- 输入校验永远在服务端做:即使客户端已有校验,服务端也要独立再做一次——攻击者可以绕过客户端校验直接发送 WebSocket 消息
- Tick Rate 选择:休闲游戏 10-20 tick/s 足够,动作游戏可提升到 30-60 tick/s,但需权衡带宽消耗与 CPU 负载
- 断线重连窗口:给予 15-30 秒的重连窗口,避免网络抖动导致玩家被误踢出房间
- 房间生命周期管理:空房间及时销毁,避免内存泄漏和资源浪费
- 监控告警:生产环境务必监控房间数、在线人数、消息延迟、CPU/内存使用率等关键指标
📚 相关阅读
- 07 微信 API 与开放能力 — WebSocket 与云开发详解
- 08 性能优化与包体控制 — 网络请求优化策略
- 游戏设计模式 — 观察者模式在事件总线中的应用
📝 课后练习
练习 1:实现客户端预测的基础框架
题目: 基于上述 ClientPredictionSystem,补充以下功能:
- 将待确认输入队列限制在 60 个以内(超过则玩家可能作弊)
- 检测到显著偏差后触发"橡皮筋"回弹(不是瞬移,而是逐步拉回)
参考答案:
typescript
class EnhancedClientPrediction extends ClientPredictionSystem {
private readonly MAX_PENDING = 60
private readonly SNAP_THRESHOLD = 10 // 超过此距离触发橡皮筋
applyInput(dx: number, dy: number): void {
// 限制待确认输入数(防止作弊和内存溢出)
if (this.pendingInputs.length >= this.MAX_PENDING) {
console.warn('待确认输入过多,丢弃最早的输入')
this.pendingInputs.shift()
}
super.applyInput(dx, dy)
}
rubberBandCorrect(target: PlayerState): void {
const current = this.serverState!
const dist = Math.sqrt((target.x - current.x) ** 2 + (target.y - current.y) ** 2)
if (dist > this.SNAP_THRESHOLD) {
// 橡皮筋效果:逐步拉回,而非瞬移
const STRENGTH = 0.15
const dx = (target.x - current.x) * STRENGTH
const dy = (target.y - current.y) * STRENGTH
this.render({
x: current.x + dx,
y: current.y + dy,
velocityX: target.velocityX,
velocityY: target.velocityY,
sequence: target.sequence,
})
console.log(
`橡皮筋纠正: 偏差 ${dist.toFixed(1)}, 修正 ${Math.sqrt(dx ** 2 + dy ** 2).toFixed(1)}`
)
} else {
// 偏差小,直接采用预测位置
this.render(target)
}
}
}练习 2:设计一个简单的房间匹配系统
题目: 使用微信云开发设计一个 2 人对战的房间匹配系统,要求:
- 玩家加入匹配队列
- 匹配成功后创建 WebSocket 房间
- 30 秒未匹配到对手则匹配超时
参考答案: 参见 07 微信 API 与开放能力 中的 WebSocket 和云开发章节,将云函数用于匹配逻辑,WebSocket 用于实时通信。