Skip to content

多人游戏同步

预计阅读 25 分钟
版本信息
最后更新:2026-07-09 · 适用基础库:≥ 3.9.0

多人游戏的核心难题是网络延迟。如何在 50-200ms 的延迟下让多个玩家看到一致且流畅的游戏世界?本章从前端实时应用(如在线文档、聊天)的经验出发,深入讲解游戏同步的经典方案。


前端视角:从 CRDT 到帧同步

前端对比:实时协作 vs 游戏同步
维度前端实时协作(在线文档)游戏多人同步
延迟容忍度高(100-500ms 可接受)极低(> 50ms 影响操作手感)
数据量小(文本编辑操作)大(位置/旋转/动作为主,但频率高)
一致性要求最终一致性(CRDT/OT)强一致性(状态同步)/ 乐观执行(帧同步)
典型方案WebSocket + CRDTWebSocket + 帧同步/状态同步
冲突解决自动合并(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-ServerP2P 或 Server-Relay
队头阻塞✅ 存在(丢包重传阻塞后续包)❌ 可配置为无序模式避免
NAT 穿透不需要(直连服务器)需要 STUN/TURN
微信小游戏支持✅ 原生 wx.connectSocket⚠️ 需要验证

何时考虑 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/内存使用率等关键指标

📚 相关阅读


📝 课后练习

练习 1:实现客户端预测的基础框架

题目: 基于上述 ClientPredictionSystem,补充以下功能:

  1. 将待确认输入队列限制在 60 个以内(超过则玩家可能作弊)
  2. 检测到显著偏差后触发"橡皮筋"回弹(不是瞬移,而是逐步拉回)

参考答案:

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 人对战的房间匹配系统,要求:

  1. 玩家加入匹配队列
  2. 匹配成功后创建 WebSocket 房间
  3. 30 秒未匹配到对手则匹配超时

参考答案: 参见 07 微信 API 与开放能力 中的 WebSocket 和云开发章节,将云函数用于匹配逻辑,WebSocket 用于实时通信。

用心学习,持续实践