多 V 多自动匹配:多模式 × 动态人数
同一套 matchmaker_room:换玩法时改 modeId + playersPerMatch(可选 region)即可,不必为 2 人 / 4 人 / 10 人各写一套房间类型。
先用浏览器跑通
服务端仓库静态页 MatchmakingDemo.html(标题「匹配赛演示 - 小游码匠」):

| 项 | 说明 |
|---|---|
| 路径 | 源码 src/public/MatchmakingDemo.html,构建后在 dist/public/ |
| 打开 | http://localhost:<PORT>/MatchmakingDemo.html,默认端口 2567(PORT 环境变量) |
| 入口 | 根页 http://localhost:<PORT>/ 内也有链到该演示 |
| 子页 | 组局后可进 MatchmakingGame.html(需从演示页带上的 sessionStorage,勿单独冷开) |
多开标签、不同 JWT / demo 用户,在页里改 modeId / playersPerMatch / region,即可试不同人数与队列。
三个参数(与排队键一致)
服务端队列键形如 queue:{modeId}:{playersPerMatch}:{region}(实现见 匹配与开房)。
| 字段 | 作用 | 备注 |
|---|---|---|
modeId | 玩法 / 模式字符串 | 例如 duel 与 brawl_10 要分开,避免混排 |
playersPerMatch | 一局凑几人 | 服务端限制 2~100,且写入键;同 modeId 下 4 人与 10 人不会同一队列 |
region | 分区字符串(排队键第三段) | 非地理 API;不传多为 global。行会/赛事可用行会 ID 收窄池子。与 modeId、playersPerMatch 关系见 匹配与开房 · 队列维度与 region |
match:find 与 party:create 都要带 modeId + playersPerMatch;人满后进 game_room,maxClients 与人数一致。
常见配法速查
| 场景 | modeId 示例 | playersPerMatch |
|---|---|---|
| 1v1 / 切磋 | duel / rank_1v1 | 2 |
| 4 人小队 | dungeon_squad / crafter_4 | 4 |
| 10 人场 | brawl_10 / battlefield | 10 |
| 行会内战 | guild_scrim 等,region 用行会或赛区 ID | 按赛制 5 / 10 / … |
行会「仅本会可进」还要在业务里校验 guildId 等,见 消息协议。
主动匹配 vs 房间码(Party)
| 做法 | 客户端要点 |
|---|---|
| 路人排队 | match:find → 等 match:found → joinById |
| 好友房 | party:create / party:join → 人满后 party:start,同样会 match:found 再进对局 |
示例代码(TypeScript / colyseus.js)
依赖 colyseus.js(与仓库 MatchmakingDemo.html 一致)。请先通过 POST /api/auth/login 等拿到 token(见 API 概览、JWT 与房间鉴权)。Room.onMessage 的回调形态以当前 SDK 版本为准。
下面假设 每个排队客户端各执行一份脚本(例如本地开 多个浏览器标签,每标签不同 TOKEN);playersPerMatch 取 2 时开两个标签即可组局。把 ENDPOINT、TOKEN、MODE_ID、PLAYERS 换成你的环境。
主动匹配:match:find → match:found → joinById
(客户端代码)
import Colyseus from "colyseus.js";
/** 与 `src/services/matchmaking/types.ts` 中 `MatchFoundPayload` 对齐 */
interface MatchFoundPayload {
roomName: string;
roomId: string;
matchId: string;
seatIndex: number;
reconnectKey: string;
joinOptions: Record<string, unknown>;
}
const ENDPOINT = "ws://127.0.0.1:2567";
const TOKEN = "YOUR_JWT_ACCESS_TOKEN";
const MODE_ID = "duel";
const PLAYERS = 2;
const REGION = "global"; // 可选:不需要分区则省略下行里的 region
function onceMessage<T = unknown>(room: Colyseus.Room, type: string, ms = 120000): Promise<T> {
return new Promise((resolve, reject) => {
let dispose: (() => void) | undefined;
const timer = setTimeout(() => {
dispose?.();
reject(new Error(`等待 ${type} 超时 ${ms}ms`));
}, ms);
dispose = room.onMessage(type, (payload: T) => {
clearTimeout(timer);
dispose?.();
resolve(payload);
}) as (() => void) | undefined;
});
}
async function activeMatchMultiV() {
const client = new Colyseus.Client(ENDPOINT);
const mm = await client.joinOrCreate("matchmaker_room", { token: TOKEN });
await onceMessage(mm, "mm:ready", 15000);
// 必须先监听再发送,避免极端情况下抢不到 match:found
const foundPromise = onceMessage<MatchFoundPayload>(mm, "match:found", 120000);
mm.send("match:find", {
modeId: MODE_ID,
playersPerMatch: PLAYERS,
region: REGION,
// skill: 1000,
// tags: ["guild:42"],
});
const found = await foundPromise;
const game = await client.joinById(found.roomId, {
...found.joinOptions,
token: TOKEN,
});
// game 即 game_room;若本地演示页使用 demo 用户,可额外传 demoUserId(与 MatchmakingDemo 一致)
// await client.joinById(found.roomId, { ...found.joinOptions, token: TOKEN, demoUserId: "u1" });
return { mm, game, found };
}
activeMatchMultiV().catch(console.error);取消排队:mm.send("match:cancel", {}),下行 match:cancelled(ok 表示是否从队列移除)。
Party(房间码)最小发送序列
房主与其它客户端仍留在 matchmaker_room 内发消息即可(完整 async 示例见 组队下副本):
(客户端代码)
mm.send("party:create", { modeId: "crafter_4", playersPerMatch: 4, region: "global" });
// 收到 party:created → 把 partyCode 发给队友
mm.send("party:join", { partyCode: "ABC123" });
// 人满且你是房主:
mm.send("party:start", {});
// 全员同样会收到 match:found,再 joinById 进 game_room(与上一段相同)对局与重连
进 game_room 后的座位、断线、reconnectKey 等见 帧同步与游戏房。
压测与多实例
进阶(算法与票据)
match:find 票据里的 skill / tags、段位排序、多实例 Pub/Sub 等以匹配服务代码与 匹配与开房 为准。