《Cocos 游戏联机实战》6.1 匹配流程与客户端角色

匹配是联机第一关。服务端的匹配算法不在本书范围,本节只讲客户端在匹配里要做什么:拆开排队、已匹配待确认、确认中、已进入、取消这套客户端状态机,给出进队、匹配成功、确认、取消四条消息的字段与方向,用假传输层写出可单测的匹配客户端,并处理重复提交与确认超时两个真实坑。

6.1 匹配流程与客户端角色

双人竞速要开局,第一步是把两个陌生人凑到同一张桌上——这一步叫匹配。站内 game/server/ 已有 315 篇、书稿《游戏服务端编程实践》另有 43 篇,讲的是服务端怎么撮合:段位分桶、延迟分层、连败保护。这些是服务端话题,本卷不重复。本节反过来问一个客户端问题:匹配这件事里,客户端到底负责什么。 答案比想象中少,但每一步做错都会变成玩家眼里的「卡在排队界面出不来」——一句话:算法在服务端,状态机在客户端。

6.1.1 客户端在匹配里的四个职责

职责做什么做错的后果
进队 / 退队发进队请求,随时能取消取消按钮点了没反应,玩家被迫干等
展示排队状态排队中、已匹配、确认中、已进入界面骗人,玩家不知道自己在等什么
确认进入匹配成功后弹确认,双方都点了才开局一方没点也开局,对手进去面对空房
超时兜底确认超时、匹配失败、连接断开卡在中间态,只能杀进程重来

这四件事全是客户端自己的状态管理,与服务端撮合谁和谁无关,所以能脱离网络单独测。

6.1.2 客户端匹配状态机

匹配客户端的全部复杂度,就是六个状态和它们之间的边:

状态含义出口
idle没在匹配join → queuing
queuing排队中,等服务端撮合收 match.found → pending;cancel → cancelled
pending已匹配到对手,等本端点确认confirm → confirming;超时 → cancelled
confirming本端已确认,等对手收 match.ready → entered;收 match.cancelled → cancelled
entered双方确认,进入房间交给 6.2 的房间状态机
cancelled取消 / 失败 / 超时join → queuing(可重来)

两条容易画错的边:queuing 不能直接到 entered,中间必须经过 pending 与 confirming(见 6.1.5);cancelled 不是终态,玩家取消后还能再点一次匹配,所以它必须能回到 queuing。

6.1.3 消息格式约定

匹配只涉及几条消息,两去两回。方向 C→S 为客户端发服务端,S→C 反之:

消息方向字段说明
match.joinC→Smode进队,mode 如 "ranked" / "casual"
match.queuedS→Cticket, position已进队,ticket 是本次匹配票据
match.foundS→Cticket, roomId, opponent, confirmDeadlineMs匹配成功,需在截止时间内确认
match.confirmC→Sticket本端确认进入
match.readyS→CroomId, seed双方都确认,可进入房间
match.cancelC→Sticket, reason取消(含超时自动取消)
match.cancelledS→Cticket, reason本次匹配作废,双方都收到

match.cancelled 的关键在双方都收到:只要一方在确认阶段取消或超时,服务端必须让另一端也退出去,否则另一端会永远卡在 confirming。这条约定是 6.1.6 第一条断言的依据。

6.1.4 排队 UI 与取消

坑在取消按钮的语义:它必须真的发出 match.cancel,等服务端回 match.cancelled 后才把界面切到「已取消」。点一下就把界面切走、不等回执,会出现「我以为取消了、其实进了房间」的错位。所以 UI 不该另存状态,而应只读状态机、onState 变化时重绘——6.1.7 的 Cocos 面板就是这么接的。

6.1.5 确认进入:为什么双方都要点

最省事的做法是「匹配成功直接开局」。真实项目里这会造成高频事故:A 匹配到了 B,但 A 已切到后台或已点了取消,服务端照样开局,B 进去面对空房间,只能等超时判负。所以中间加一道确认进入:服务端匹配上双方后先只发 match.found,两边都点「确认」才发 match.ready 真正开局。它带来两个客户端必须处理的边界:

  • 确认超时:match.found 带一个确认截止时间,到点没确认就自动发 match.cancel,理由 confirm-timeout。
  • 防重复提交:确认按钮可能被连点,confirm 只能发一次,否则服务端收到两条 match.confirm,幂等性没做好就可能重复开局。

6.1.6 L1:匹配客户端 + 假传输层 + vitest

全卷网络代码都依赖一个抽象接口,而不是直接依赖 WebSocket,这样单测里才能注入「延迟、丢包、乱序」的假实现。下面是接口与最简假实现(两端直连):

// net/transport.ts —— 全卷通用:网络代码依赖接口,不依赖 WebSocket
export interface Transport {
  send(data: string): void;
  onMessage(handler: (data: string) => void): void;
  close(): void;
}

// 单测替身:send 立即投递给对端
export class FakeTransport implements Transport {
  peer: FakeTransport | null = null;
  readonly sent: string[] = [];
  private handler: ((data: string) => void) | null = null;
  send(data: string): void {
    this.sent.push(data);
    this.peer?.deliver(data);
  }
  onMessage(handler: (data: string) => void): void { this.handler = handler; }
  close(): void { this.peer = null; }
  deliver(data: string): void { this.handler?.(data); }
}

匹配客户端本体。状态只在这里存一份,UI 通过 onState 订阅:

// net/match/match-client.ts
import type { Transport } from '../transport';

export type MatchState =
  | 'idle' | 'queuing' | 'pending' | 'confirming' | 'entered' | 'cancelled';

export interface MatchClientOptions {
  transport: Transport;
  now: () => number;
  confirmTimeoutMs?: number;
  onState?: (state: MatchState) => void;
}

interface ServerMsg {
  t: string; ticket?: string; roomId?: string; opponent?: string; reason?: string;
}

export class MatchClient {
  state: MatchState = 'idle';
  ticket = '';
  roomId = '';
  opponent = '';
  private readonly opt: MatchClientOptions;
  private readonly timeout: number;
  private confirmSent = false;
  private deadline = 0;
  constructor(opt: MatchClientOptions) {
    this.opt = opt;
    this.timeout = opt.confirmTimeoutMs ?? 8000;
    opt.transport.onMessage((raw) => this.handle(raw));
  }
  join(mode: string): void {
    if (this.state !== 'idle' && this.state !== 'cancelled') return;
    this.confirmSent = false;
    this.ticket = '';
    this.setState('queuing');
    this.opt.transport.send(JSON.stringify({ t: 'match.join', mode }));
  }
  cancel(): void {
    if (this.state === 'idle' || this.state === 'entered') return;
    this.opt.transport.send(JSON.stringify({ t: 'match.cancel', ticket: this.ticket }));
    this.setState('cancelled');
  }
  confirm(): void {
    // 防重复提交:只有 pending 且未发过才允许
    if (this.state !== 'pending' || this.confirmSent) return;
    this.confirmSent = true;
    this.setState('confirming');
    this.opt.transport.send(JSON.stringify({ t: 'match.confirm', ticket: this.ticket }));
  }
  // 由帧循环每帧调用;到点未确认则自动退出
  tick(): void {
    if (this.state !== 'pending' || this.opt.now() < this.deadline) return;
    this.opt.transport.send(
      JSON.stringify({ t: 'match.cancel', ticket: this.ticket, reason: 'confirm-timeout' }),
    );
    this.setState('cancelled');
  }
  private handle(raw: string): void {
    let msg: ServerMsg;
    try { msg = JSON.parse(raw) as ServerMsg; } catch { return; }
    if (msg.t === 'match.queued') {
      this.ticket = msg.ticket ?? '';
    } else if (msg.t === 'match.found') {
      this.ticket = msg.ticket ?? '';
      this.roomId = msg.roomId ?? '';
      this.opponent = msg.opponent ?? '';
      this.confirmSent = false;
      this.deadline = this.opt.now() + this.timeout;
      this.setState('pending');
    } else if (msg.t === 'match.ready') {
      this.roomId = msg.roomId ?? this.roomId;
      this.setState('entered');
    } else if (msg.t === 'match.cancelled') {
      this.setState('cancelled');
    }
  }
  private setState(next: MatchState): void {
    if (this.state === next) return;
    this.state = next;
    this.opt.onState?.(next);
  }
}

测试需要一个「服务端替身」。它不是服务端实现——只按上面的消息约定把两端串起来,撮合谁和谁、怎么选对手一概不写:

// net/match/match-server.stub.ts —— 仅测试用,不含匹配算法
import { FakeTransport } from '../transport';

interface UpMsg { t: string; ticket?: string; reason?: string; }

export class MatchServerStub {
  private waiting: { t: FakeTransport; ticket: string } | null = null;
  private pair: { a: FakeTransport; b: FakeTransport; roomId: string } | null = null;
  private confirmedBy = new Set<FakeTransport>();
  private seq = 0;

  connect(): FakeTransport {
    const client = new FakeTransport();
    const server = new FakeTransport();
    client.peer = server;
    server.peer = client;
    server.onMessage((raw) => this.onUp(server, JSON.parse(raw) as UpMsg));
    return client;
  }
  private onUp(server: FakeTransport, msg: UpMsg): void {
    if (msg.t === 'match.join') {
      const ticket = `T${++this.seq}`;
      if (!this.waiting) {
        this.waiting = { t: server, ticket };
        this.send(server, { t: 'match.queued', ticket });
        return;
      }
      const other = this.waiting;
      this.waiting = null;
      const roomId = `R${++this.seq}`;
      this.pair = { a: other.t, b: server, roomId };
      this.send(other.t, { t: 'match.found', ticket: other.ticket, roomId, opponent: 'peer' });
      this.send(server, { t: 'match.found', ticket, roomId, opponent: 'peer' });
    } else if (msg.t === 'match.confirm') {
      this.confirmedBy.add(server);
      if (this.pair && this.confirmedBy.has(this.pair.a) && this.confirmedBy.has(this.pair.b)) {
        this.send(this.pair.a, { t: 'match.ready', roomId: this.pair.roomId });
        this.send(this.pair.b, { t: 'match.ready', roomId: this.pair.roomId });
      }
    } else if (msg.t === 'match.cancel') {
      if (this.waiting?.t === server) this.waiting = null;
      for (const t of this.pair ? [this.pair.a, this.pair.b] : [server]) {
        this.send(t, { t: 'match.cancelled', ticket: msg.ticket ?? '', reason: msg.reason ?? 'user' });
      }
    }
  }
  private send(target: FakeTransport, msg: unknown): void {
    target.send(JSON.stringify(msg));
  }
}

两条断言分别锁住取消广播与确认超时;confirm 的防重复提交由上面的 confirmSent 守卫保证:

// net/match/match-client.test.ts
import { describe, expect, it } from 'vitest';
import { MatchClient } from './match-client';
import { MatchServerStub } from './match-server.stub';

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

function pair() {
  const server = new MatchServerStub();
  const clock = new FakeClock();
  const ta = server.connect();
  const tb = server.connect();
  const a = new MatchClient({ transport: ta, now: clock.now, confirmTimeoutMs: 5000 });
  const b = new MatchClient({ transport: tb, now: clock.now, confirmTimeoutMs: 5000 });
  return { clock, ta, a, b };
}

describe('MatchClient', () => {
  it('任一方取消则双方都收到取消', () => {
    const { a, b } = pair();
    a.join('ranked');
    b.join('ranked');
    expect([a.state, b.state]).toEqual(['pending', 'pending']);
    a.confirm();
    b.cancel();
    expect([a.state, b.state]).toEqual(['cancelled', 'cancelled']);
  });
  it('确认超时自动退出', () => {
    const { clock, ta, a, b } = pair();
    a.join('ranked');
    b.join('ranked');
    clock.advance(5001);
    a.tick();
    expect(a.state).toBe('cancelled');
    const cancel = ta.sent.map((s) => JSON.parse(s)).find((m) => m.t === 'match.cancel');
    expect(cancel.reason).toBe('confirm-timeout');
  });
});

6.1.7 L2:把状态机接进 Cocos 面板

Cocos 侧 UI 是一个 Component 订阅状态机。Button.EventType.CLICK、Label.string、Button.interactable 都已核对 cc.d.ts 存在:

import { _decorator, Button, Component, Label } from 'cc';
import { MatchClient, type MatchState } from './net/match/match-client';

const { ccclass, property } = _decorator;

const STATE_TEXT: Record<MatchState, string> = {
  idle: '准备匹配', queuing: '排队中…', pending: '匹配成功,请确认',
  confirming: '等待对手确认…', entered: '正在进入房间…', cancelled: '已取消',
};

@ccclass('MatchPanel')
export class MatchPanel extends Component {
  @property(Label)
  statusLabel: Label | null = null;
  @property(Button)
  cancelButton: Button | null = null;
  private client: MatchClient | null = null;

  onLoad(): void {
    this.cancelButton?.node.on(Button.EventType.CLICK, this.onCancelClick, this);
  }
  bind(client: MatchClient): void {
    this.client = client;
    this.applyState(client.state);
  }
  update(): void { this.client?.tick(); }
  applyState(state: MatchState): void {
    if (this.statusLabel) this.statusLabel.string = STATE_TEXT[state];
    if (this.cancelButton) this.cancelButton.interactable = state !== 'entered';
  }
  private onCancelClick(): void { this.client?.cancel(); }
}

创建客户端时把 onState 直接指向面板(onState: (s) => panel.applyState(s)),再 panel.bind(client);回调记得在 onDestroy 里 off。

6.1.8 工程决策

  • 状态机只有一份。UI、按钮可用性、日志全读同一个 state,别在面板里再存一个 isQueuing 布尔——两处状态迟早对不上。
  • 取消要等回执。本地先切界面会让「取消失败」无从表达;以服务端 match.cancelled 为准。
  • 票据要回带。cancel / confirm 都带上 ticket,服务端才能判断这是哪一次匹配,避免上一次的迟到消息污染这一次。

下一节从匹配交出去的那一刻接着讲:拿到 roomId 之后,房间从创建到销毁,客户端要跟住哪些状态。

阅读导航:上一节:目录 · 下一节:6.2 房间生命周期 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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