5.1 重连流程与状态恢复
第 4 章把客户端预测与和解写完了,但那一整套推导有一个从未点破的前提:链路一直还在。移动网络切基站、Wi-Fi 进电梯、手机息屏被系统挂起、玩家切后台回消息——任何一次都足以让 WebSocket 断开。断开之后的第一个问题不是「怎么补数据」,而是**「怎么回到同一局」**。
站内 game/server/ 与服务端书稿讲的是服务端如何保存会话、如何做断线判定;本节只讲客户端这一侧:掉线时客户端把自己存成了什么状态,重连握手要带什么证据,以及怎么把服务端给的恢复点接回本地的模拟。
5.1.1 掉线是常态,不是异常
先把心态摆正:在移动端,掉线是设计内事件,不是错误分支。一个把掉线当异常处理的客户端,代码会长成一堆 if (disconnected) return; 的散弹,状态越滚越乱。正确的做法是把它当成状态机里的一条正常边——连上、断开、重连,都是状态转移。
区分两件事也很重要:传输断开(socket 断了)与会话失效(服务端认为你这个玩家已经离开)。前者客户端自己就能重连;后者只能回到大厅开新局。本节的流程覆盖前者,并在重连次数耗尽时明确落到后者。
5.1.2 客户端会话状态机
会话状态用 const 对象加 as const 表示,不要用 enum——Cocos 的脚本编译链只做类型剥离,enum 会生成运行时代码,属于被禁的可擦除语法之外。五个状态:
| 状态 | 含义 | 可以转到 |
|---|---|---|
idle | 未连接,本局尚未开始或已结束 | connecting |
connecting | 首次建连中 | connected / reconnecting / failed |
connected | 已连接,正常收发快照与输入 | reconnecting |
reconnecting | 掉线后正在尝试恢复 | connected / failed |
failed | 重连次数耗尽,本局结束 | idle(回大厅开新局) |
关键点:reconnecting 是独立状态,不是 connecting 的重用。首连要带身份去换会话,重连要带上一局的会话令牌与进度去认领原会话,两者握手内容不同,混成一个状态迟早写错。
5.1.3 重连握手要带什么
重连能不能回到同一局,全看握手带了三样证据:会话令牌(证明你是上一局的谁)、上次收到的快照序号(证明你模拟到哪一帧)、本地未确认的输入(证明你有几步操作服务端还没收到)。消息格式如下,客户端负责发、服务端负责回(实现见服务端书稿):
| 方向 | 消息 | 字段 | 说明 |
|---|---|---|---|
| C→S | hello | sessionToken | 首次建连,换取会话 |
| C→S | resume | sessionToken / lastSnapshotSeq / pendingInputs | 重连,认领原会话 |
| S→C | welcome | playerId / roomId / tickRate | 首连成功 |
| S→C | resumed | snapshotSeq / mode | 重连成功,附带恢复点 |
| S→C | resync | full 或 replay[] | 恢复数据本体 |
pendingInputs 尤其不能省:客户端断线前最后几步输入可能没发出去,重连后必须补发,否则服务端的世界里你会「凭空停了一下」。
5.1.4 恢复点对齐:补发还是全量
服务端拿到 lastSnapshotSeq 后有两种回法,选择依据是离线时长与缺口大小:
| 策略 | 适用 | 代价 |
|---|---|---|
补发缺失快照(replay[]) | 缺口小(几帧到几十帧) | 客户端要能重放快照,服务端要留历史 |
直接给最新全量(full) | 缺口大(离线超过几秒) | 一次传输量大,但客户端逻辑简单 |
本卷推荐按缺口帧数二选一:小于一个滑动窗口就补发,超过就全量。全量的实现更简单,优先保证「能回来」,再优化「回来得快」。
5.1.5 重连期间客户端怎么表现
掉线到重连成功之间,画面上继续跑还是冻住?三种做法的取舍:
- 冻结世界:最省事、最不会错。玩家看到画面停住加一个转圈遮罩,语义清晰。适合竞速这类强同步玩法。
- 继续本地预测:手感最顺,但离线越久,本地预测的偏差越大,重连后回滚幅度也越大,容易「瞬移」。适合偏差可容忍的慢节奏玩法。
- 重连遮罩:无论选哪种,都要给玩家一个明确的「正在重连」反馈,否则玩家会以为游戏卡死而强退。
《方块冲刺》是双人竞速,推荐冻结世界 + 遮罩:同步要求高,冻结带来的「停顿感」比「瞬移感」更容易被接受。
5.1.6 L1:会话状态机 + 假传输层
下面这段是纯 TypeScript、零 cc 依赖的会话状态机。它不碰真实 WebSocket,而是依赖一个抽象的 Transport 接口——这样测试才能注入「想断就断」的假实现:
// assets/scripts/net/session/session.ts —— 纯 TS,无 cc 依赖
export const SessionState = {
Idle: 'idle',
Connecting: 'connecting',
Connected: 'connected',
Reconnecting: 'reconnecting',
Failed: 'failed',
} as const;
export type SessionState = (typeof SessionState)[keyof typeof SessionState];
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;
}
export interface ReconnectTicket {
readonly sessionToken: string;
readonly lastSnapshotSeq: number;
readonly pendingInputs: readonly number[];
}
export interface ReconnectConfig {
readonly maxAttempts: number;
readonly baseDelayMs: number;
}
export type Scheduler = (fn: () => void, delayMs: number) => void;
export class Session {
private readonly createTransport: () => Transport;
private readonly schedule: Scheduler;
private readonly config: ReconnectConfig;
private readonly onState: (state: SessionState) => void;
private readonly ticket: ReconnectTicket;
private state: SessionState = SessionState.Idle;
private attempts = 0;
constructor(
createTransport: () => Transport,
schedule: Scheduler,
config: ReconnectConfig,
onState: (state: SessionState) => void,
ticket: ReconnectTicket,
) {
this.createTransport = createTransport;
this.schedule = schedule;
this.config = config;
this.onState = onState;
this.ticket = ticket;
}
get current(): SessionState {
return this.state;
}
connect(): void {
this.setState(SessionState.Connecting);
this.open(false);
}
private open(resume: boolean): void {
const transport = this.createTransport();
transport.onMessage((msg) => {
if (msg.type === 'welcome' || msg.type === 'resumed') {
this.attempts = 0;
this.setState(SessionState.Connected);
}
});
transport.onClose(() => this.handleClose());
if (resume) {
transport.send({ type: 'resume', payload: this.ticket });
} else {
transport.send({
type: 'hello',
payload: { sessionToken: this.ticket.sessionToken },
});
}
}
private handleClose(): void {
if (this.state === SessionState.Failed) {
return;
}
this.attempts += 1;
if (this.attempts > this.config.maxAttempts) {
this.setState(SessionState.Failed);
return;
}
this.setState(SessionState.Reconnecting);
this.schedule(() => this.open(true), this.config.baseDelayMs * this.attempts);
}
private setState(next: SessionState): void {
if (this.state === next) {
return;
}
this.state = next;
this.onState(next);
}
}
配套的假传输层与手动调度器(真实代码里 schedule 用 setTimeout,测试里换成手动触发,时间就完全确定):
// 测试辅助:可模拟断连的假传输层
import type { NetMessage, Transport } from './session';
export class FakeTransport implements Transport {
private messageHandler: ((msg: NetMessage) => void) | null = null;
private closeHandler: (() => void) | null = null;
readonly sent: NetMessage[] = [];
send(msg: NetMessage): void { this.sent.push(msg); }
onMessage(handler: (msg: NetMessage) => void): void { this.messageHandler = handler; }
onClose(handler: () => void): void { this.closeHandler = handler; }
close(): void {}
deliver(msg: NetMessage): void { this.messageHandler?.(msg); } // 模拟服务端下发
drop(): void { this.closeHandler?.(); } // 模拟链路断开
}
export class FakeNetwork {
private readonly transports: FakeTransport[] = [];
next(): FakeTransport { const t = new FakeTransport(); this.transports.push(t); return t; }
current(): FakeTransport { return this.transports[this.transports.length - 1]; }
}
export class ManualScheduler {
private queue: Array<() => void> = [];
schedule(fn: () => void, _delayMs: number): void { this.queue.push(fn); }
flush(): number {
const pending = this.queue;
this.queue = [];
for (const fn of pending) { fn(); }
return pending.length;
}
get pendingCount(): number { return this.queue.length; }
}
两个断言,一个证明「重连后收敛回 connected」,一个证明「次数耗尽落到 failed」:
import { describe, expect, it } from 'vitest';
import { Session, SessionState } from './session';
import { FakeNetwork, ManualScheduler } from './fake-transport';
describe('Session 重连', () => {
it('断连后重连并收敛回 connected,握手带上快照序号', () => {
const net = new FakeNetwork();
const sched = new ManualScheduler();
const states: SessionState[] = [];
const session = new Session(
() => net.next(),
(fn, delay) => sched.schedule(fn, delay),
{ maxAttempts: 3, baseDelayMs: 100 },
(s) => states.push(s),
{ sessionToken: 'tok-1', lastSnapshotSeq: 7, pendingInputs: [11, 12] },
);
session.connect();
net.current().deliver({ type: 'welcome' });
expect(session.current).toBe(SessionState.Connected);
net.current().drop();
expect(session.current).toBe(SessionState.Reconnecting);
sched.flush();
expect(net.current().sent[0]).toEqual({
type: 'resume',
payload: { sessionToken: 'tok-1', lastSnapshotSeq: 7, pendingInputs: [11, 12] },
});
net.current().deliver({ type: 'resumed' });
expect(session.current).toBe(SessionState.Connected); // 收敛回断连前的状态
});
it('重连次数超过上限后进入 failed', () => {
const net = new FakeNetwork();
const sched = new ManualScheduler();
const session = new Session(
() => net.next(),
(fn, delay) => sched.schedule(fn, delay),
{ maxAttempts: 2, baseDelayMs: 50 },
() => {},
{ sessionToken: 'tok-1', lastSnapshotSeq: 0, pendingInputs: [] },
);
session.connect();
net.current().deliver({ type: 'welcome' });
net.current().drop(); sched.flush();
net.current().drop(); sched.flush();
net.current().drop();
expect(session.current).toBe(SessionState.Failed);
});
});
跑起来:
cd mini2d && npx vitest run src/net/session.test.ts
第二个断言能通过,是因为每次 drop 让 attempts 加一,第三次 drop 时 attempts=3 已经超过 maxAttempts=2,状态直接落到 failed。这正是「次数上限」要兜的场景:不能无限重连,否则玩家盯着一个永远连不上的转圈界面。
5.1.7 落到工程:重连遮罩
把状态机的输出接进 UI,就是一个显示/隐藏遮罩的组件。它只依赖 cc 公开 API,能过 tsc --noEmit:
import { _decorator, Component, Label, Node } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('ReconnectOverlay')
export class ReconnectOverlay extends Component {
@property(Node)
mask: Node | null = null;
@property(Label)
tip: Label | null = null;
/** 会话进入 reconnecting 时调用 */
show(attempt: number): void {
if (this.mask) {
this.mask.active = true;
}
if (this.tip) {
this.tip.string = `连接已断开,正在重连(第 ${attempt} 次)`;
}
}
/** 回到 connected 时调用 */
hide(): void {
if (this.mask) {
this.mask.active = false;
}
}
}
mask.active 控制的是「本地激活状态」;节点还可能因为父节点未激活而实际不可见,所以别用它去反推「屏幕上一定看得见」。
下一节处理重连流程里反复出现的两个细节:消息丢了怎么补、重复了怎么防——也就是超时、重传与幂等。
阅读导航:上一节:目录 · 下一节:5.2 超时、重传与幂等 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。