《Cocos 游戏联机实战》2.2 输入广播与帧对齐

帧同步的每一帧都要集齐所有玩家的输入才能推进。本节讲输入如何采集、量化为位掩码、打包广播并按帧对齐:给出输入延迟窗口的估算方法、基于序号的去重,以及依赖抽象 Transport 的输入缓冲与帧推进器纯 TS 实现,并用假传输层断言乱序到达仍按帧号对齐。

2.2 输入广播与帧对齐

2.1 节保证了「同一输入 → 同一状态」。这一节解决输入从哪来、按什么规则喂给模拟。

帧同步里客户端要做两件事:把自己的输入发出去,把所有人的输入按帧收齐。听起来简单,但「收齐」有严格的定义:第 N 帧必须同时拿到玩家 0 和玩家 1 的第 N 帧输入,模拟才能推进。少一个人的,这一帧就推不动。这一节把采集、打包、对齐、去重这条链路写成可跑的 L1 代码。

2.2.1 一帧输入长什么样

输入不是「鼠标坐标」这种连续量,而是一组离散的动作:按了右键、按了跳、松开了右键。把它量化成整数,才能进 2.1 的定点世界:

export const KEY_RIGHT = 1 << 0;  // bit0
export const KEY_JUMP  = 1 << 1;  // bit1
export const KEY_DASH  = 1 << 2;  // bit2

// 一帧的输入就是一个位掩码
const keys = KEY_RIGHT | KEY_JUMP;  // 同时按右和跳

用位掩码有三个好处:体积小(一个 16 位整数能装 16 个动作)、天然确定(整数比较,不涉及浮点)、可压缩(第 3 章做增量时,keys 常连续多帧不变,只传变化)。

2.2.2 输入消息格式(服务端约定)

客户端把自己那一帧的输入发给服务端,服务端汇总后广播。两端只约定消息格式,实现见站内 game/server/ 专题与《游戏服务端编程实践》,本卷不写服务端代码。

客户端上行(input 消息):

字段类型含义方向
frameuint32该输入归属的逻辑帧号C → S
playerIduint8玩家编号(0 / 1)C → S
keysuint16按键位掩码C → S
sequint32该玩家单调递增序号,用于去重与防重放C → S

服务端下行(input-batch 消息,汇总一帧内所有玩家):

{ "type": "input-batch", "frame": 120, "inputs": [
  { "playerId": 0, "keys": 1, "seq": 118 },
  { "playerId": 1, "keys": 0, "seq": 121 } ] }

服务端只做两件事:收集(等到所有人都交了第 N 帧)和广播(把这一帧的输入原样转发给所有人)。它不参与模拟,也不裁决谁对——这正是帧同步与状态同步的根本区别(见 2.2.8)。

2.2.3 推进条件与输入延迟窗口

推进规则一句话:第 N 帧只有在集齐所有玩家的第 N 帧输入后才能推进。 用 InputBuffer 表达就是 isReady(N)——这一帧里每个 playerId 都存在;只要缺一个,tryAdvance() 就返回 null,客户端停在当前帧等待。后果是帧率被最慢的人拖着走:玩家 1 卡了 200ms,玩家 0 也跟着卡,因为它的第 N 帧输入没到。

所以不要让本地输入「立刻生效」,而是延迟若干帧再应用:本地按下的那一刻,把它标记成「第 current + inputDelay 帧的输入」再发出去。这样等逻辑跑到那一帧时,别人的输入早就在路上了。

inputDelay 要多大?让它盖住「单程延迟 + 抖动」:

inputDelay 帧数 ≈ ceil( (单程延迟 + 抖动) / 每帧时长 ) + 1

60 Hz 下每帧 16.7ms。给几个场景(参考值,需在你的网络条件下复测):

网络场景单程延迟抖动建议 inputDelay
局域网< 5ms1ms1 帧
同城宽带20ms10ms3 帧
跨地域 4G60ms40ms8 帧
弱网150ms120ms20 帧

inputDelay 的取舍很清楚:调大更稳(很少等待),但手感更钝(按键到画面响应多等 inputDelay 帧)。一般 4–8 帧是甜点区;超过 12 帧玩家能明显感到「操作发飘」。这也解释了为什么帧同步适合节奏偏慢的 RTS、不适合快节奏 FPS。

缓解手感的一个技巧:本地输入立即用于表现层反馈(按下瞬间播放动画、音效),但逻辑状态仍等 inputDelay 帧后才变。表现与逻辑分离,手感回来了,确定性不破。

2.2.4 抽象 Transport 与假传输层

所有网络代码依赖一个抽象接口,而不是直接依赖 WebSocket。这样测试才能注入「延迟 / 丢包 / 乱序」,生产环境才换得动底层。

// assets/scripts/net/protocol.ts
export interface NetMessage {
  readonly type: string;
  readonly payload: unknown;
}

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

假传输层让测试完全掌控到达时机,用确定性随机保证可复现:

// assets/scripts/net/transport.ts
import { NetMessage, Transport } from './protocol';

export class FakeTransport implements Transport {
  readonly sent: NetMessage[] = [];
  private handler: ((msg: NetMessage) => void) | null = null;
  private readonly lossRate: number;
  private rngState: number;

  constructor(options: { lossRate?: number; seed?: number } = {}) {
    this.lossRate = options.lossRate ?? 0;
    this.rngState = options.seed ?? 1;
  }

  send(msg: NetMessage): void {
    if (this.lossRate > 0 && this.nextRand() < this.lossRate) return; // 丢包
    this.sent.push(msg);
  }

  onMessage(handler: (msg: NetMessage) => void): void {
    this.handler = handler;
  }

  close(): void {
    this.handler = null;
  }

  // 测试驱动:手动投递一条消息,可乱序、可重复、可延迟到任意时刻
  deliver(msg: NetMessage): void {
    this.handler?.(msg);
  }

  private nextRand(): number {
    this.rngState = (Math.imul(this.rngState, 1664525) + 1013904223) | 0;
    return (this.rngState >>> 0) / 4294967296;
  }
}

「延迟」不靠 setTimeout,而靠测试决定何时调用 deliver:想模拟「玩家 1 的输入晚 3 帧到」,就在逻辑跑到第 N+3 帧时才 deliver。这让所有时序测试都是同步的、瞬时的。

2.2.5 L1 实现:输入缓冲与帧推进器

缓冲按 frame → (playerId → input) 组织,天然支持乱序写入:

// assets/scripts/net/input-sync.ts
export type PlayerId = number;

export interface InputFrame {
  frame: number;
  player: PlayerId;
  keys: number;
  seq: number;
}

export const INPUT_MESSAGE = 'input';

export class InputBuffer {
  private readonly players: PlayerId[];
  private readonly byFrame = new Map<number, Map<PlayerId, InputFrame>>();

  constructor(players: PlayerId[]) {
    this.players = players.slice().sort((a, b) => a - b);
  }

  // 返回 true 表示是新输入;false 表示同帧重复(丢弃)
  push(input: InputFrame): boolean {
    let byPlayer = this.byFrame.get(input.frame);
    if (!byPlayer) {
      byPlayer = new Map();
      this.byFrame.set(input.frame, byPlayer);
    }
    if (byPlayer.has(input.player)) return false;
    byPlayer.set(input.player, input);
    return true;
  }

  isReady(frame: number): boolean {
    const byPlayer = this.byFrame.get(frame);
    if (!byPlayer) return false;
    return this.players.every((p) => byPlayer.has(p));
  }

  take(frame: number): InputFrame[] | null {
    if (!this.isReady(frame)) return null;
    const byPlayer = this.byFrame.get(frame)!;
    const ordered = this.players.map((p) => byPlayer.get(p)!);
    this.byFrame.delete(frame);
    return ordered;
  }
}

take 按 players 排序返回,保证了输入的应用顺序也是确定的——如果两端遍历 Map 的顺序不同,即使输入内容一样,累加顺序不同也可能导致定点结果不同(2.1 的遍历陷阱)。

推进器只管一件事:当前帧齐了就前进,否则原地等。

export class FrameAdvancer {
  private frame = 0;
  private readonly buffer: InputBuffer;

  constructor(buffer: InputBuffer) {
    this.buffer = buffer;
  }

  current(): number {
    return this.frame;
  }

  tryAdvance(): InputFrame[] | null {
    const inputs = this.buffer.take(this.frame);
    if (inputs === null) return null; // 输入不齐,本帧等待
    this.frame += 1;
    return inputs;
  }
}

2.2.6 vitest:乱序到达仍按帧对齐

把输入经假传输层投递,故意打乱顺序:

import { describe, expect, it } from 'vitest';
import { FakeTransport } from '../net/transport';
import { FrameAdvancer, InputBuffer, InputFrame, INPUT_MESSAGE } from '../net/input-sync';

const inputMsg = (frame: number, player: number, keys: number, seq: number) => ({
  type: INPUT_MESSAGE,
  payload: { frame, player, keys, seq },
});

describe('输入广播与帧对齐', () => {
  it('两人输入乱序到达,仍按帧号对齐推进', () => {
    const transport = new FakeTransport();
    const buffer = new InputBuffer([0, 1]);
    transport.onMessage((msg) => {
      if (msg.type === INPUT_MESSAGE) buffer.push(msg.payload as InputFrame);
    });
    const advancer = new FrameAdvancer(buffer);

    // 本地(玩家 0)输入直接进缓冲
    buffer.push({ frame: 0, player: 0, keys: 1, seq: 0 });
    buffer.push({ frame: 1, player: 0, keys: 0, seq: 1 });

    // 玩家 1 的输入经网络乱序到达:先到帧 1,再到帧 0
    transport.deliver(inputMsg(1, 1, 1, 1));
    transport.deliver(inputMsg(0, 1, 0, 0));

    // 两帧都齐,按 0、1 顺序推进
    expect(advancer.tryAdvance()).not.toBeNull();
    expect(advancer.tryAdvance()).not.toBeNull();
    expect(advancer.current()).toBe(2);
  });

  it('缺一个玩家的输入时不推进,重复输入被去重', () => {
    const buffer = new InputBuffer([0, 1]);
    const advancer = new FrameAdvancer(buffer);
    expect(buffer.push({ frame: 0, player: 0, keys: 1, seq: 0 })).toBe(true);
    expect(buffer.push({ frame: 0, player: 0, keys: 1, seq: 0 })).toBe(false); // 去重
    expect(advancer.tryAdvance()).toBeNull(); // 玩家 1 没到,原地等
  });
});

第一个用例是本节的核心断言:玩家 1 的帧 1 输入先于帧 0 到达,但缓冲按 frame 分桶,推进器仍从帧 0 开始按序消费——到达顺序不影响应用顺序。

2.2.7 帧同步 vs 状态同步的输入角色

同样一份输入,在两种同步模型里的地位完全不同:

维度帧同步(本章)状态同步(第 3、4 章)
网络上跑的是什么输入状态(快照 / 增量)
权威在哪输入由各端产生、服务端只转发状态由服务端权威计算
客户端角色自己算状态接收状态 + 本地预测
带宽极小(只有输入)随实体数增长
一致性依赖确定性模拟服务端裁决

关键差异一句话:帧同步里客户端算出来的状态是「真值」,状态同步里服务端下发的状态才是「真值」。所以帧同步的死穴是确定性(2.1),状态同步的死穴是带宽与延迟(第 3、4 章)。第 4 章的客户端预测看起来和帧同步的回滚有点像,但预测的是「别人的位置」,和解的是「服务端权威状态」——2.3 会专门辨析。

2.2.8 接进 Cocos:采集输入

L2 侧把键盘事件采成位掩码。这里只采集,不发送——发送在会话层。

import { _decorator, Component, EventKeyboard, Input, input, KeyCode } from 'cc';

const { ccclass } = _decorator;

const KEY_RIGHT = 1 << 0;
const KEY_JUMP = 1 << 1;

@ccclass('InputCapture')
export class InputCapture extends Component {
  private keys = 0;

  onEnable(): void {
    input.on(Input.EventType.KEY_DOWN, this.onKeyDown, this);
    input.on(Input.EventType.KEY_UP, this.onKeyUp, this);
  }

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

  private onKeyDown(e: EventKeyboard): void {
    if (e.keyCode === KeyCode.ARROW_RIGHT) this.keys |= KEY_RIGHT;
    if (e.keyCode === KeyCode.SPACE) this.keys |= KEY_JUMP;
  }

  private onKeyUp(e: EventKeyboard): void {
    if (e.keyCode === KeyCode.ARROW_RIGHT) this.keys &= ~KEY_RIGHT;
    if (e.keyCode === KeyCode.SPACE) this.keys &= ~KEY_JUMP;
  }

  consume(): number {
    return this.keys;
  }
}

input.on(...) 注册的回调带 this 目标,onDisable 里要用同一个引用注销,否则会残留监听。跨端差异(H5 用键盘、微信小游戏用触摸按钮、原生用摇杆)由 sys.platform 判断后接到同一份 keys 位掩码上,采集层与同步层就此解耦。

2.2.9 小结与下一节

输入采集、打包、广播、按帧对齐这条链路已经能跑了。但它建立在「输入总能准时到」的假设上——一旦某个玩家的输入迟到甚至丢失,tryAdvance() 就会一直返回 null,整个游戏停住。下一节处理这个最棘手的情况:输入迟到或丢包时怎么不卡住整个游戏。

阅读导航:上一节:2.1 确定性模拟与定点数 · 下一节:2.3 回滚与追帧 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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