《Cocos 游戏联机实战》4.1 本地预测

从服务端权威带来的操作延迟感讲起,用一条完整时间线说明本地按下到服务端权威状态到达之间发生了什么,交代输入序号与「未确认输入队列」的设计,划清可预测与不可预测动作的边界,并给出 predict 模块的 L1 纯 TS 实现与「100 ms 延迟下本地位置立即变化」的 vitest 断言。

4.1 本地预测

前三章回答的是「两端的世界怎么对齐」:1.1 划清权威边界,第 2、3 章分别讲了帧同步与状态同步两条路线。这一章回答另一个问题——在世界对齐之前,客户端自己的角色该怎么动。它是全卷的枢纽:没有预测,状态同步就只能「等服务端点头才动」,手感会直接崩掉。

先说清楚与站内的分工。game/server/ 那 315 篇与书稿《游戏服务端编程实践》讲的是服务端怎么算权威——输入怎么校验、世界怎么推进、快照怎么下发。本节一个字都不碰那些,只讲客户端在权威到达之前,凭什么敢先动,以及怎么动得不心虚。

4.1.1 延迟感:服务端权威的代价

1.1 定下的权威模型是服务端权威:角色的最终位置由服务端说了算,客户端只是「复述」和「表现」。这条规则保证了公平——但它有一个直接的副作用:如果客户端老老实实等权威,那么从按下按键到画面响应,中间隔着整整一个 RTT。

在 H5 / 微信小游戏 / 原生的真实网络里,一个同城服务器 RTT 大约 30–60 ms,跨地域 80–150 ms,弱网下还会更高。这些数字看起来不大,但操作延迟感是能被感知的:一般认为 100 ms 以上的输入延迟,玩家就会觉得「按键粘手」;格斗、跑酷这类需要精确时机的手感,20 ms 的差异都摸得出来。于是矛盾出现了——要公平就得等服务端权威,手感就差;要手感就得本地立即响应,可本地又说了不算,会和服务端打架。

本地预测(client-side prediction)就是解这个矛盾的。它的核心想法只有一句:本地先按「如果服务端也这么算」的结果动起来,等权威到达后再核对,对了就继续,错了就悄悄改回来。

4.1.2 时间线:按下那一刻到权威到达

把一次「按下右方向键」拆成时间线,就明白预测到底预测了什么。设 RTT 为 100 ms:

时刻事件谁在做
t₀玩家按下「右」客户端
t₀立刻用本地模拟器推进一帧,画面当场向右移动客户端
t₀同时把输入 {seq: N, moveX: 1} 发给服务端客户端 → 服务端
t₀ + 50 ms服务端收到输入,按权威规则推进世界服务端
t₀ + 100 ms服务端回快照 {ackSeq: N, state}服务端 → 客户端
t₀ + 100 ms客户端和解:比对预测与权威,必要时回滚重演客户端

关键在第 2 行和第 6 行:本地那一步是「猜」,服务端那一步是「准」。 玩家在 t₀ 就看到了反应,根本感受不到那 100 ms;而服务端在 t₀ + 100 ms 送来的权威状态,是用来「纠正猜错的部分」的。如果本地模拟器和服务端用的是同一套确定性规则(2.1 讲的定点数与确定性模拟就是为这个准备的),那么大多数时候预测结果和权威结果完全一致,客户端只需要悄悄丢掉已确认的输入、什么都不用改;只有不一致时,才需要动用 4.2 的和解与重演。

4.1.3 输入序号与未确认输入队列

预测能成立,靠的是一个很朴素的记账结构:输入序号 + 未确认输入队列。

  • 客户端每采样到一个输入帧,就给它一个单调递增的序号 seq。序号只增不减,且只在逻辑帧上分配(不是渲染帧)。
  • 已经本地应用、但还没被服务端确认的输入,全部留在**未确认队列(pending queue)**里。
  • 服务端每次回快照,都会带上 ackSeq——「我已经处理到序号 N 了」。客户端据此把队列里 seq <= ackSeq 的输入丢掉。

为什么非要序号?因为快照必须能无歧义地说清「我处理到哪一帧」。没有序号,客户端拿到一份快照根本不知道它对应的是自己发出去的第几个输入,也就无法判断「哪些预测还算数、哪些该推翻」。序号是预测与和解之间唯一的对账凭据。另一个容易忽略的细节:队列长度约等于 RTT 内产生的输入帧数。60 Hz 逻辑帧、RTT 100 ms,队列里常年有约 6 个输入;RTT 涨到 300 ms,就有约 18 个——这个数字在 4.2 估算重演代价时会再次出现。

4.1.4 哪些能预测、哪些不能

预测不是「把服务端逻辑照抄一份」那么简单,它只适用于一类动作。 判据有两条:结果是否只由本地输入决定、以及猜错了能不能悄悄改回来。

动作能预测?原因
移动、加速、减速✅纯本地输入驱动,改回来只是挪一下坐标
跳跃、二段跳✅同上;起跳瞬间的弧线由本地物理决定
动画起播、音效✅纯表现层,与服务端状态无关
自己角色的朝向✅本地输入决定
伤害结算❌需要服务端裁决,涉及对方状态与数值
掉落、金币拾取❌涉及随机数与其他玩家,本地算不准
胜负判定、名次❌必须是服务端权威结论
道具生成位置❌由服务端随机种子决定

一句话概括:「我自己怎么动」可以预测,「我和世界、我和别人之间发生了什么」不能预测。 把不可预测的动作硬塞进预测里,只会制造出「本地显示吃到了金币、服务端说没吃到」这类打脸时刻——这比延迟感更伤玩家信任。

4.1.5 L1:把传输层抽象成接口

预测要能脱离网络单测,第一件事就是别让逻辑直接依赖 WebSocket。卷四全程依赖一个抽象的 Transport 接口(完整定义见 1.2 与 5.1,这里给出本节用到的形态):

// assets/scripts/net/session/session.ts —— 纯 TS,无 cc 依赖
export interface NetMessage { readonly type: string; readonly payload?: unknown; }

export interface Transport {
  send(msg: NetMessage): void;
  onMessage(handler: (msg: NetMessage) => void): void;
  onClose(handler: () => void): void;
  close(): void;
}

// assets/scripts/net/transport/fake-clock.ts —— 可手动推进的假时钟
export interface Clock { now(): number; }

export class FakeClock implements Clock {
  private t = 0;
  now(): number { return this.t; }
  advance(ms: number): void { this.t += ms; }
}

真实的 WebSocketTransport 是它的一个实现;测试里则用可配置延迟、丢包、乱序的假实现。假实现依赖上面这个可手动推进的时钟,测试因此完全确定、可复现,不用真等 100 ms:

// assets/scripts/net/transport/simulated-transport.ts —— 纯 TS,无 cc 依赖
import type { NetMessage, Transport } from '../session/session';
import type { Clock } from './fake-clock';

export interface NetProfile {
  readonly latencyMs: number; readonly jitterMs: number;
  readonly loss: number; readonly reorder: number; // 丢包率、乱序率 0–1
}

interface Queued { at: number; seq: number; msg: NetMessage; }

function makeRng(seed: number): () => number {
  let s = seed >>> 0;
  return () => {
    s ^= s << 13; s >>>= 0;
    s ^= s >>> 17;
    s ^= s << 5; s >>>= 0;
    return s / 0xffffffff;
  };
}

export class SimulatedTransport implements Transport {
  private clock: Clock;
  private profile: NetProfile;
  private rng: () => number;
  private queue: Queued[] = [];
  private handler: ((msg: NetMessage) => void) | null = null;
  private closeHandler: (() => void) | null = null;
  private seq = 0;
  private dropped = 0;

  constructor(clock: Clock, profile: NetProfile, seed = 0x9e3779b9) {
    this.clock = clock;
    this.profile = profile;
    this.rng = makeRng(seed);
  }

  send(msg: NetMessage): void {
    if (this.rng() < this.profile.loss) { this.dropped++; return; }
    const jitter = (this.rng() * 2 - 1) * this.profile.jitterMs;
    const orderShift = this.rng() < this.profile.reorder ? -this.profile.latencyMs * 0.5 : 0;
    const at = this.clock.now() + Math.max(0, this.profile.latencyMs + jitter + orderShift);
    this.queue.push({ at, seq: this.seq++, msg });
  }

  onMessage(handler: (msg: NetMessage) => void): void { this.handler = handler; }
  onClose(handler: () => void): void { this.closeHandler = handler; }
  close(): void { this.queue.length = 0; this.closeHandler?.(); }

  /** 测试驱动:投递所有已到点的消息(另有 drop() 模拟断开,见 5.1)。 */
  pump(): void {
    const now = this.clock.now();
    this.queue.sort((a, b) => (a.at - b.at) || (a.seq - b.seq));
    const due = this.queue.filter((q) => q.at <= now);
    this.queue = this.queue.filter((q) => q.at > now);
    for (const q of due) this.handler?.(q.msg);
  }

  get droppedCount(): number { return this.dropped; }
}

用 makeRng 而不是 Math.random,是为了让「丢包 30%」这种测试每次跑出同一串结果——否则失败无法复现,这本身就违背了卷四「逻辑必须先能单测」的初衷。

4.1.6 L1:确定性模拟器与预测器

预测的第二个前提是确定性:同一份 state 加同一个 input,永远得到同一个结果。坐标用定点数(整数,单位 1/100 像素)而非浮点,避免跨端漂移:

// assets/scripts/net/predict/sim.ts —— 纯 TS,无 cc 依赖
export interface PlayerState {
  x: number; y: number; vx: number; vy: number; onGround: boolean;
}

export interface InputFrame {
  seq: number; moveX: number; jump: boolean;
}

export const GRAVITY = 90;
export const MOVE_ACCEL = 40;
export const MAX_RUN = 180;
export const JUMP_VELOCITY = -260;

function clamp(v: number, lo: number, hi: number): number {
  return v < lo ? lo : v > hi ? hi : v;
}

/** 确定性推进一帧:同样的 state + input 永远得到同样的结果。 */
export function step(state: PlayerState, input: InputFrame): PlayerState {
  const vx = clamp(state.vx + input.moveX * MOVE_ACCEL, -MAX_RUN, MAX_RUN);
  let vy = state.vy + GRAVITY;
  let onGround = state.onGround;
  if (input.jump && state.onGround) { vy = JUMP_VELOCITY; onGround = false; }
  let y = state.y + vy;
  if (y >= 0) { y = 0; vy = 0; onGround = true; }
  return { x: state.x + vx, y, vx, vy, onGround };
}

在它之上是预测器:负责分配序号、应用输入、维护未确认队列。

// assets/scripts/net/predict/predictor.ts —— 纯 TS,无 cc 依赖
import { step, type InputFrame, type PlayerState } from './sim';

export interface Snapshot {
  ackSeq: number;     // 服务端已确认的输入序号
  state: PlayerState; // 该序号对应的权威状态
}

export class Predictor {
  private predicted: PlayerState;
  private pending: InputFrame[] = [];
  private nextSeq = 0;

  constructor(initial: PlayerState) {
    this.predicted = { ...initial };
  }

  /** 采样到一个新输入:立即应用并缓存,返回输入序号。 */
  applyInput(moveX: number, jump: boolean): number {
    const input: InputFrame = { seq: this.nextSeq++, moveX, jump };
    this.pending.push(input);
    this.predicted = step(this.predicted, input);
    return input.seq;
  }

  get state(): PlayerState { return this.predicted; }
  unacked(): InputFrame[] { return this.pending.slice(); }

  dropAcked(ackSeq: number): InputFrame[] {
    this.pending = this.pending.filter((i) => i.seq > ackSeq);
    return this.pending.slice();
  }

  reset(state: PlayerState): void { this.predicted = { ...state }; }
}

dropAcked / reset 是 4.2 和解会用到的方法,这里一并给出——预测与和解本就是同一份数据结构的两个视角。

4.1.7 vitest:100 ms 延迟下位置立即变化

现在可以验证「预测真的消除了延迟感」了。断言的核心是:在时钟推进、服务端快照到达之前,本地位置已经变了。

import { describe, it, expect } from 'vitest';
import { FakeClock } from '../net/transport/fake-clock';
import { SimulatedTransport } from '../net/transport/simulated-transport';
import { Predictor } from '../net/predict/predictor';
import { type PlayerState } from '../net/predict/sim';

const START: PlayerState = { x: 0, y: 0, vx: 0, vy: 0, onGround: true };

describe('4.1 本地预测', () => {
  it('100 ms 延迟下,本地按下后位置立即变化', () => {
    const clock = new FakeClock();
    const transport = new SimulatedTransport(clock, {
      latencyMs: 100, jitterMs: 0, loss: 0, reorder: 0,
    });
    const predictor = new Predictor(START);

    const before = predictor.state.x;
    const seq = predictor.applyInput(1, false); // 玩家按下「右」
    // 时钟没走、服务端快照还没回来,本地画面已经动了
    expect(seq).toBe(0);
    expect(predictor.state.x).toBeGreaterThan(before);

    // 输入走网络;即便等满 100 ms,本地位置也不会「退回去等」
    transport.send({ type: 'input', payload: { seq, moveX: 1, jump: false } });
    clock.advance(100);
    transport.pump();
    expect(predictor.state.x).toBeGreaterThan(before);
  });
});

注意断言里的两个时刻:applyInput 之后立刻检查,位置就变了;clock.advance(100) 之后位置仍然是预测值,没有因为「服务端还没回」而回退。这正是玩家感知不到那 100 ms 的原因。

4.1.8 接进 Cocos:输入采样组件(L2)

预测器本身是纯逻辑,接进 Cocos 的活是「按逻辑帧采样输入」。这里沿用卷一 4.2 的固定步长累积器,让逻辑帧与渲染帧解耦——输入必须在 60 Hz 逻辑帧上采样,不能跟着渲染帧走,否则不同帧率设备上预测结果会不一样:

import { _decorator, Component, input, Input, EventKeyboard, KeyCode, director } from 'cc';
import { Predictor } from './predictor';
const { ccclass, property } = _decorator;

@ccclass('LocalInputSampler')
export class LocalInputSampler extends Component {
    @property
    fixedDt = 1 / 60;

    private predictor: Predictor | null = null;
    private acc = 0;
    private readonly held = new Set<number>();

    start() {
        this.predictor = new Predictor({ x: 0, y: 0, vx: 0, vy: 0, onGround: true });
        input.on(Input.EventType.KEY_DOWN, this.onKeyDown, this);
        input.on(Input.EventType.KEY_UP, this.onKeyUp, this);
    }

    onDestroy() {
        input.off(Input.EventType.KEY_DOWN, this.onKeyDown, this);
        input.off(Input.EventType.KEY_UP, this.onKeyUp, this);
    }

    private onKeyDown(e: EventKeyboard) { this.held.add(e.keyCode); }
    private onKeyUp(e: EventKeyboard) { this.held.delete(e.keyCode); }

    update() {
        this.acc += director.getDeltaTime();
        while (this.acc >= this.fixedDt) {
            this.acc -= this.fixedDt;
            const moveX = (this.held.has(KeyCode.ARROW_RIGHT) ? 1 : 0)
                        - (this.held.has(KeyCode.ARROW_LEFT) ? 1 : 0);
            this.predictor?.applyInput(moveX, this.held.has(KeyCode.SPACE));
        }
        const s = this.predictor?.state;
        if (s) this.node.setPosition(s.x / 100, -s.y / 100, 0);
    }
}

按键状态用 Set 记录「按住」,在逻辑帧里一次性读出方向,而不是在每个按键事件里直接改位置——这样一次逻辑帧对应一个输入,序号才不会乱。input 与 Input.EventType 的成员名都取自 cc.d.ts,跨端(H5 / 微信 / 原生)由引擎统一派发。

4.1.9 工程决策

给《方块冲刺》的落地建议:

  1. 只预测「自己怎么动」。 移动、跳跃、动画起播放客户端预测;伤害、掉落、胜负判定一律等服务端。
  2. 输入在逻辑帧上采样,坐标用定点整数。 不同帧率设备必须得到同样的输入序列;浮点预测跨端必然漂移,而漂移会被和解当成「误差」反复纠正。
  3. 序号只增不减、只按逻辑帧分配。 序号一旦复用或跳变,快照的 ackSeq 就失去意义。
  4. 假传输层的随机数用可复现的种子。 否则「丢包 30%」的失败用例无法重放。

预测让本地先动起来了,但它只是「猜」。猜对了皆大欢喜,猜错了——位置对不上、甚至角色来回弹——就轮到下一节的和解与重演来处理。

阅读导航:上一节:目录 · 下一节:4.2 服务器和解与重演 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画