# 状态同步 ⭐

服务器说了算,客户端只负责渲染;MMO/RPG 的标配方案

前置知识:第02章 网络同步基础 + 第03章 帧同步Lockstep


阅读指南(初学者必看)

为什么你需要学习状态同步?

状态同步是游戏行业用得最多的同步方案。与帧同步不同,状态同步的服务器是唯一权威——客户端只负责渲染和预测。理解状态同步,是理解"服务器权威架构"的入口。

学完本章,你能回答:

  • 状态同步的核心原理是什么?为什么服务器必须是权威?
  • 全量同步和增量同步怎么选?增量同步如何处理丢包?
  • 客户端预测和服务器校正怎么配合?为什么不能只靠服务器?
  • 插值和外推怎么让画面不卡顿?

本文结构

第一部分:核心原理——服务器权威,客户端渲染
第二部分:状态快照与增量更新——带宽优化
第三部分:客户端预测与服务器校正——消除操作延迟
第四部分:插值平滑与外推——让画面不卡顿

一、核心原理

生活类比:状态同步就像 GPS 导航。服务器是卫星,告诉每个客户端"你现在应该在这个位置"。

核心思想:
- 服务器是唯一权威(Authoritative Server)
- 服务器运行完整游戏逻辑
- 服务器把状态广播给所有客户端
- 客户端只负责渲染和预测

数据流:
客户端A ──输入──▶ 服务器 ──状态──▶ 所有客户端
                    │
                 计算新状态
                    │
客户端B ──输入──▶ 服务器 ──状态──▶ 所有客户端

每帧传输数据(比帧同步大得多):
{ frame: 1234, 
  entities: [
    { id: 1, x: 100.5, y: 200.3, hp: 80, state: "running" },
    { id: 2, x: 150.2, y: 180.1, hp: 100, state: "idle" }
  ]
}

状态同步 vs 帧同步

维度 帧同步 状态同步
权威 客户端各自计算 服务器唯一权威
带宽 小(只传输入) 大(传状态)
客户端计算 重(跑完整逻辑) 轻(只插值渲染)
防作弊 弱(需额外校验) 强(服务器说了算)
适用人数 2-10人 10-10000+人
网络容错 差(一致性要求高) 好(可丢包插值)
代表游戏 星际争霸、LOL 魔兽世界、绝地求生

二、状态快照与增量更新

2.1 全量同步 vs 增量同步

全量同步(每帧发送完整状态):
  优点:简单可靠
  缺点:带宽大(100 个实体 × 每实体 100 字节 = 10KB/帧 × 20帧/秒 = 200KB/秒)

增量同步(只发送变化的部分):
  优点:带宽小
  缺点:需要处理丢包(缺了某个增量,后续状态不对)

  基础快照 + 增量:
  Frame 100: { base: true, entities: [...] }         ← 全量
  Frame 101: { base: 100, delta: {1: {x: 102, y: 201}} }  ← 增量
  Frame 102: { base: 100, delta: {1: {x: 104}} }          ← 增量

2.2 增量同步丢包处理

两种策略:

  1. 定期发送全量快照作为基准(如每 5 秒一次),客户端收到全量快照后可以重建状态
  2. 检测增量包序号不连续,主动请求重传或拉取全量快照

2.3 二进制序列化

不要用JSON!用二进制协议:

// 自定义二进制格式
// [帧号: uint16][实体数量: uint8]
// 对每个实体:[实体ID: uint8][变更掩码: uint16][变更字段...]

function encodeDelta(frame, deltas) {
    // 估算最大大小
    const maxSize = 2 + 1 + deltas.length * (1 + 2 + 16);
    const buf = new ArrayBuffer(maxSize);
    const view = new DataView(buf);
    let offset = 0;

    view.setUint16(offset, frame, true); offset += 2;
    view.setUint8(offset, deltas.length); offset += 1;

    for (let delta of deltas) {
        view.setUint8(offset, delta.id); offset += 1;
        view.setUint16(offset, delta.mask, true); offset += 2;

        // 根据掩码写入具体字段
        if (delta.mask & 0x01) { view.setFloat32(offset, delta.x, true); offset += 4; }
        if (delta.mask & 0x02) { view.setFloat32(offset, delta.y, true); offset += 4; }
        if (delta.mask & 0x04) { view.setUint16(offset, delta.hp, true); offset += 2; }
        // ...
    }

    return buf.slice(0, offset);
}

三、客户端预测与服务器校正

3.1 为什么需要预测

网络延迟50ms:

  • T=0:玩家按右键
  • T=50ms:服务器收到,计算新位置
  • T=100ms:客户端收到新位置

如果客户端什么都不做,玩家会感觉到100ms的延迟!

解决方案:客户端预测操作结果并立刻显示。

3.2 预测代码示例

class ClientPrediction {
    constructor() {
        this.serverState = { x: 0, y: 0 };   // 服务器权威状态
        this.predictedState = { x: 0, y: 0 }; // 本地预测状态
        this.pendingInputs = [];              // 已发送但未确认的操作
    }

    // 玩家输入
    onInput(input) {
        // 1. 立刻应用到预测状态
        this.applyInput(this.predictedState, input);

        // 2. 记录到待确认队列
        this.pendingInputs.push({
            seq: this.nextSeq++,
            input: input,
            timestamp: Date.now()
        });

        // 3. 发送给服务器
        this.sendToServer(input);
    }

    // 收到服务器校正
    onServerCorrection(serverState) {
        this.serverState = serverState;

        // 重新从服务器状态开始,重放所有待确认输入
        this.predictedState = { ...serverState };

        // 过滤掉已经被服务器确认的输入
        this.pendingInputs = this.pendingInputs.filter(p => p.seq > serverState.ackedSeq);

        // 重放剩余输入
        for (let pending of this.pendingInputs) {
            this.applyInput(this.predictedState, pending.input);
        }
    }

    applyInput(state, input) {
        if (input.type === 'move') {
            state.x += input.dx;
            state.y += input.dy;
        }
    }
}

3.3 平滑校正(Smooth Correction)

如果预测和服务器状态差异太大,直接"瞬移"会很突兀。

function smoothCorrect(predicted, server, factor = 0.3) {
    // 渐进式校正:每帧向服务器状态移动30%
    return {
        x: predicted.x + (server.x - predicted.x) * factor,
        y: predicted.y + (server.y - predicted.y) * factor
    };
}

// 如果差异很小,直接采纳服务器状态
// 如果差异很大,可能是作弊或严重丢包,强制瞬移

四、插值平滑与外推

4.1 插值(Interpolation)

用于其他玩家的位置显示

服务器 20Hz 更新,客户端 60Hz 渲染 → 每 3 帧才有一次新数据,中间帧会卡顿。

解决方案:插值

class Interpolator {
    constructor(delay = 100) {  // 延迟100ms缓冲
        this.buffer = [];        // 收到的状态包队列
        this.delay = delay;
    }

    onStateReceived(state) {
        this.buffer.push({
            state: state,
            timestamp: Date.now()
        });

        // 清理过旧的包
        const cutoff = Date.now() - this.delay * 2;
        this.buffer = this.buffer.filter(b => b.timestamp > cutoff);
    }

    getInterpolatedPosition(now) {
        const renderTime = now - this.delay;

        // 找到 renderTime 前后两个状态包
        let prev = null, next = null;
        for (let i = 0; i < this.buffer.length - 1; i++) {
            if (this.buffer[i].timestamp <= renderTime &&
                this.buffer[i + 1].timestamp >= renderTime) {
                prev = this.buffer[i];
                next = this.buffer[i + 1];
                break;
            }
        }

        if (!prev || !next) {
            return prev ? prev.state.position : null;
        }

        // 线性插值
        const t = (renderTime - prev.timestamp) / (next.timestamp - prev.timestamp);
        return {
            x: prev.state.position.x + (next.state.position.x - prev.state.position.x) * t,
            y: prev.state.position.y + (next.state.position.y - prev.state.position.y) * t
        };
    }
}

4.2 外推(Extrapolation)

用于自己的位置显示(当没有新数据时猜测未来位置):

class Extrapolator {
    constructor() {
        this.lastPosition = { x: 0, y: 0 };
        this.velocity = { x: 0, y: 0 };
        this.lastUpdateTime = 0;
    }

    onUpdate(position, timestamp) {
        const dt = timestamp - this.lastUpdateTime;
        if (dt > 0) {
            this.velocity = {
                x: (position.x - this.lastPosition.x) / dt,
                y: (position.y - this.lastPosition.y) / dt
            };
        }
        this.lastPosition = position;
        this.lastUpdateTime = timestamp;
    }

    getExtrapolatedPosition(now) {
        const dt = now - this.lastUpdateTime;
        return {
            x: this.lastPosition.x + this.velocity.x * dt,
            y: this.lastPosition.y + this.velocity.y * dt
        };
    }
}

自问自答

Q:状态同步和帧同步可以混用吗? A:可以。很多游戏的核心战斗用帧同步(保证确定性),周边系统用状态同步(聊天、背包、商店)。关键是明确哪些数据走帧同步,哪些走状态同步,避免状态冲突。

Q:增量同步丢包了怎么办? A:两种策略——1)定期发送全量快照作为基准(如每 5 秒一次),客户端收到全量快照后可以重建状态;2)检测增量包序号不连续,主动请求重传或拉取全量快照。

Q:客户端预测错了怎么办? A:服务器会发回真实状态,客户端收到后纠正。关键是平滑校正——不能直接跳到正确位置(玩家会感觉"被拉扯"),要在几帧内平滑过渡。差距小时用插值,差距大时直接跳转。

Q:插值平滑会不会增加延迟? A:会。插值意味着客户端始终渲染"过去"的状态,插值周期就是额外的延迟。典型值是 100ms,加上 RTT/2 的网络延迟,总延迟约 RTT/2 + 100ms。这是画面流畅度的代价。


实践任务

  • 任务1:实现一个状态同步的实时游戏(服务器 10Hz 广播位置,客户端渲染)
  • 任务2:实现客户端预测 + 服务器校正,对比有无预测的操作延迟
  • 任务3:实现插值平滑,对比有无插值的视觉效果(录像对比)
  • 任务4:实现增量同步,模拟丢包场景,测试状态恢复能力
  • 任务5:对比状态同步和帧同步在同样场景下的带宽占用
  • 任务6:实现外推预测,测试丢包时的画面表现

与其他章节的关联

本章内容 关联章节 关联点
服务器权威 第08章 游戏安全与反作弊 服务器权威是反作弊的基础——客户端不可信
客户端预测 第05章 预测与回滚 第05章是预测的进阶——预测错了就回滚
插值平滑 第02章 网络同步基础 插值是解决网络抖动的核心手段
增量同步 第06章 自定义UDP协议与KCP 增量同步对丢包敏感,需要可靠 UDP 支持
状态快照 第05章 预测与回滚 回滚也依赖状态快照的保存和恢复
二进制序列化 第13章 弱网优化与实战 协议精简是弱网优化的重要手段

上一章:03-帧同步Lockstep 下一章:05-预测与回滚