6.2 房间生命周期
上一节结束时,客户端拿到了 roomId 和双方确认的 match.ready。从这一刻起,两个玩家被放进一个房间里,而房间本身也有生命周期:创建、等人、准备、开赛、结算、销毁。这一节只讲客户端视角——房间在服务端怎么维护(心跳、房主迁移、超时回收)属服务端话题,交接给站内 game/server/ 与《游戏服务端编程实践》。
客户端的问题很具体:房间状态变了,我怎么知道、界面怎么跟着变、边界情况(对手掉线)怎么收场。
6.2.1 房间状态与客户端状态机映射
服务端的房间状态与客户端看到的几乎是同一套,但客户端少一档、多一档:
| 服务端房间状态 | 客户端 RoomState | 客户端该做什么 |
|---|---|---|
| 等待中(Waiting) | waiting | 展示房间信息,等对手就位 |
| 准备中(Readying) | ready | 展示倒计时,锁定输入,等 room.start |
| 进行中(Playing) | playing | 跑竞速逻辑(第 3、4 章的同步与预测在这里生效) |
| 结算中(Settling) | settling | 展示结果,停止发输入 |
| 已销毁(Destroyed) | destroyed | 回大厅,清空本地房间上下文 |
客户端比服务端多一档 waiting → ready 的显式准备:服务端只要「人齐了」就能开,客户端却要先把本地资源(赛道贴图、音效)加载完再报「准备」。这档差异就是 6.2.5 里「非法转移被拒绝」要挡的东西。
6.2.2 玩家上下线:房主离开怎么办
双人竞速只有两个玩家,所谓「房主」只是个名义——真正要处理的是对手离开。房间里有三种人会走:自己主动退、对手主动退、对手掉线超时。
| 情况 | 服务端下发 | 客户端处理 |
|---|---|---|
| 本端主动退 | 本地发 room.leave | 直接转 destroyed,不必等回执 |
| 对手主动退 | room.peerLeft { reason: "leave" } | 按当前状态分派(见下) |
| 对手掉线 | room.peerLeft { reason: "timeout" } | 同「对手主动退」 |
| 双方都掉线 | 服务端回收房间 | 本地重连(5.1)成功后收到 room.destroyed |
关键规则:对手离开的处理依赖当前状态。在 waiting / ready 里对手走了,房间凑不齐人,直接 destroyed;在 playing 里对手走了,比赛已经无法进行,本端判胜并进结算——这正是 6.2.5 第二条断言。不要写成「一律销毁」:正在跑的比赛被判胜,体验和「白打一局」完全不同。
6.2.3 房间配置同步:赛道、规则与随机种子
进房后服务端会下发一份房间配置,客户端据此搭场景。字段至少包括:赛道 id、圈数、道具池、以及随机种子。
为什么种子必须由服务端下发,而不是客户端各自 Math.random()?因为双人竞速要求两端的道具刷新、干扰时机完全一致——同一个种子喂进同一个确定性模拟,两端才会得到同一串随机数。客户端各随机各的,开局第一步就分叉了。这条规则在 2.1 确定性模拟与定点数
里已经打过底:确定性模拟的输入必须完全一致,种子是其中一环。
配置只读、进房时同步一次;比赛途中不再改配置(要改就重开房间)。这样客户端可以把配置当成常量缓存,避免中途被改导致两端状态分叉。
6.2.4 消息格式约定
| 消息 | 方向 | 字段 | 说明 |
|---|---|---|---|
room.config | S→C | trackId, laps, seed | 房间配置,含服务端下发的随机种子 |
room.players | S→C | online[] | 在座玩家与在线状态 |
room.ready | C→S | — | 本端资源加载完,报准备 |
room.start | S→C | startTick | 双方就绪,开赛(逻辑帧号) |
room.peerLeft | S→C | reason | 对手离开,reason 为 "leave" / "timeout" |
room.result | S→C | outcome | 结算结果 |
room.destroyed | S→C | — | 房间销毁,回大厅 |
6.2.5 L1:房间状态机 + vitest
状态机把合法转移写成一张表,非法转移直接拒绝。这比在 if/else 里散落判断可靠得多:
// net/room/room-client.ts
export type RoomState = 'waiting' | 'ready' | 'playing' | 'settling' | 'destroyed';
export interface RoomConfig {
trackId: string;
laps: number;
seed: number;
}
export interface RoomMsg {
t: string;
config?: RoomConfig;
reason?: string;
}
// 合法转移表:不在表里的转移一律拒绝
const LEGAL: Record<RoomState, RoomState[]> = {
waiting: ['ready', 'destroyed'],
ready: ['playing', 'destroyed'],
playing: ['settling', 'destroyed'],
settling: ['destroyed'],
destroyed: [],
};
export class RoomClient {
state: RoomState = 'waiting';
config: RoomConfig | null = null;
opponentOnline = true;
outcome: 'none' | 'win' | 'lose' = 'none';
private readonly onState?: (s: RoomState) => void;
constructor(onState?: (s: RoomState) => void) { this.onState = onState; }
ready(): void { this.transition('ready'); }
apply(msg: RoomMsg): void {
switch (msg.t) {
case 'room.config':
this.config = msg.config ?? null;
break;
case 'room.start':
this.transition('playing');
break;
case 'room.peerLeft':
this.opponentOnline = false;
// 竞速里对手离开 = 本端直接进结算并判胜(仅进行中)
if (this.state === 'playing' && this.transition('settling')) this.outcome = 'win';
else this.transition('destroyed');
break;
case 'room.result':
this.outcome = 'lose';
this.transition('settling');
break;
case 'room.destroyed':
this.transition('destroyed');
break;
}
}
private transition(next: RoomState): boolean {
if (!LEGAL[this.state].includes(next)) return false;
this.state = next;
this.onState?.(next);
return true;
}
}
两条断言分别锁住非法转移与对手离开:
// net/room/room-client.test.ts
import { describe, expect, it } from 'vitest';
import { RoomClient } from './room-client';
describe('RoomClient', () => {
it('非法状态转移被拒绝', () => {
const room = new RoomClient();
room.apply({ t: 'room.start' }); // waiting 不能直接 playing
expect(room.state).toBe('waiting');
room.ready();
room.apply({ t: 'room.start' });
expect(room.state).toBe('playing');
});
it('对手离开触发结算', () => {
const room = new RoomClient();
room.apply({ t: 'room.config', config: { trackId: 'city-01', laps: 3, seed: 12345 } });
expect(room.config?.seed).toBe(12345);
room.ready();
room.apply({ t: 'room.start' });
room.apply({ t: 'room.peerLeft', reason: 'timeout' });
expect(room.opponentOnline).toBe(false);
expect(room.state).toBe('settling');
expect(room.outcome).toBe('win');
});
});
注意 room.peerLeft 的分派:进行中判胜进结算,非进行中直接销毁。把它写死成一条路径,测试就会在这里翻车。
6.2.6 L3:房间配置片段
房间配置以 JSON 资源形式放在 assets/resources/ 下,随分包 bundle-levels 一起加载。下面的 room-default.json 与它的 .meta 需要 Cocos Creator 3.8 编辑器打开,本节不可命令行复现——.meta 的 uuid 由编辑器生成,手写会导致资源引用失效:
// assets/resources/config/room-default.json
{
"trackId": "track-city-01",
"laps": 3,
"itemPool": ["boost", "banana"],
"maxPlayers": 2
}
// assets/resources/config/room-default.json.meta
{
"ver": "1.1.0",
"importer": "json",
"imported": true,
"uuid": "8a1f2c34-56bd-4e70-9c11-2d3e4f5a6b7c",
"files": [".json"],
"subMetas": {},
"userData": {}
}
注意配置里没有 seed。 赛道、圈数、道具池是策划配置,属于客户端资源;种子是每局运行时才确定的值,必须由服务端在 room.config 里下发,不能写进这个静态文件。
6.2.7 L2:房间面板
面板订阅房间状态机,并按平台提示离线风险。sys.platform 与 sys.Platform 成员已核对 cc.d.ts 存在:
import { _decorator, Component, Label, sys } from 'cc';
import { RoomClient } from './net/room/room-client';
const { ccclass, property } = _decorator;
@ccclass('RoomPanel')
export class RoomPanel extends Component {
@property(Label)
stateLabel: Label | null = null;
@property(Label)
trackLabel: Label | null = null;
private room: RoomClient | null = null;
bind(room: RoomClient): void {
this.room = room;
this.refresh();
}
refresh(): void {
const r = this.room;
if (!r) return;
if (this.trackLabel) {
this.trackLabel.string = r.config ? `${r.config.trackId} · ${r.config.laps} 圈` : '等待房间配置…';
}
// 小游戏切后台会被系统挂起,回前台走重连而不是重开(见 5.1)
const miniGame = sys.platform === sys.Platform.WECHAT_GAME
|| sys.platform === sys.Platform.BYTEDANCE_MINI_GAME;
if (this.stateLabel) this.stateLabel.string = miniGame && !r.opponentOnline ? '对手可能已离线' : '联机中';
}
}
6.2.8 工程决策
- 状态转移集中在一张表。新增状态只改
LEGAL,不要在业务里散写if (state === ...)。 - 对手离开按状态分派。进行中判胜、其余销毁;写死一条路径是本节最易踩的坑。
- 种子只信服务端。任何「客户端自己随机」的做法都会让两端从第一帧起就分叉。
- 配置进房同步一次即锁定。中途改配置等于让两端跑不同的规则。
- 面板只读状态机。
refresh()不持有自己的状态副本。
下一节讲房间之外的两种「旁观」形态:观战与回放——它们都不产生输入,却都要把同一场比赛在本地重演出来。
阅读导航:上一节:6.1 匹配流程与客户端角色 · 下一节:6.3 观战与回放 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。