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 消息):
| 字段 | 类型 | 含义 | 方向 |
|---|---|---|---|
frame | uint32 | 该输入归属的逻辑帧号 | C → S |
playerId | uint8 | 玩家编号(0 / 1) | C → S |
keys | uint16 | 按键位掩码 | C → S |
seq | uint32 | 该玩家单调递增序号,用于去重与防重放 | 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 |
|---|---|---|---|
| 局域网 | < 5ms | 1ms | 1 帧 |
| 同城宽带 | 20ms | 10ms | 3 帧 |
| 跨地域 4G | 60ms | 40ms | 8 帧 |
| 弱网 | 150ms | 120ms | 20 帧 |
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 回滚与追帧 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。