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.join | C→S | mode | 进队,mode 如 "ranked" / "casual" |
match.queued | S→C | ticket, position | 已进队,ticket 是本次匹配票据 |
match.found | S→C | ticket, roomId, opponent, confirmDeadlineMs | 匹配成功,需在截止时间内确认 |
match.confirm | C→S | ticket | 本端确认进入 |
match.ready | S→C | roomId, seed | 双方都确认,可进入房间 |
match.cancel | C→S | ticket, reason | 取消(含超时自动取消) |
match.cancelled | S→C | ticket, 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 房间生命周期 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。