《Cocos 游戏联机实战》5.1 重连流程与状态恢复

第 4 章讲完预测与和解,前提一直是「链路还在」。本节处理链路断掉之后怎么回到同一局:给出客户端会话状态机(idle/connecting/connected/reconnecting/failed 用 const 对象表示)、重连握手要携带的会话令牌与快照序号、恢复点对齐的两种策略,并用假传输层写一个可跑的重连状态机与 vitest 断言。

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→ShellosessionToken首次建连,换取会话
C→SresumesessionToken / lastSnapshotSeq / pendingInputs重连,认领原会话
S→CwelcomeplayerId / roomId / tickRate首连成功
S→CresumedsnapshotSeq / mode重连成功,附带恢复点
S→Cresyncfull 或 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 超时、重传与幂等 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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