匹配与开房
匹配相关逻辑集中在房间 matchmaker_room,服务端实现类为 MatchmakerRoom。
| 用途 | 使用的消息 |
|---|---|
| 大厅排队(自动匹配) | match:find、match:cancel |
| 房间码组队 | party:* |
成局后服务端统一下发 match:found;客户端用 joinById(roomId, joinOptions) 进入 game_room(默认绑定 GameRoomExample,见 帧同步与游戏房)。
本地联调:启动服务后打开 MatchmakingDemo.html,流程说明见 多 V 多自动匹配 · 先用浏览器跑通。
客户端接入步骤
按顺序实现即可;换玩法通常只改请求字段,不改变「收 match:found → joinById」这一段。
joinOrCreate("matchmaker_room", { token }),等待mm:ready。- 发送
match:find(排队)或party:*(组队);字段含义见下文「主动匹配」「被动开房」两节。 - 监听
match:found,用roomId与joinOptions调用joinById进入game_room(多数项目需在 options 中再次带上token,与 鉴权 一致)。
统一契约与各场景
所有成局路径的终点相同:match:found → joinById → game_room。
| 场景 | 客户端动作 | 工程含义 |
|---|---|---|
| 大厅排队 | match:find / match:cancel | modeId + playersPerMatch + region 与可选 skill / tags 共同决定排队落在哪条队列(详见下文 队列维度与 region);match:cancel 与 match:cancelled 对应取消结果。 |
| Party | party:create … party:start | 与排队共用 match:found,客户端可共用同一套进局代码;房主与人数限制由服务端校验。 |
| 人数 | playersPerMatch(2~100) | 凑满几人成局,且即开局时创建的 game_room 本局玩家总数(主动排队与 Party 同义)。常见写法:1v1 → 2,2v2 → 4,4v4 → 8,依此类推,上限 100。同一套消息承载不同人数;队列如何分段见 MatchmakingService。 |
| 多实例 | 客户端仍只连接 matchmaker_room | 队列与 match:found 跨进程投递依赖 Redis(含频道 colyseus:mm:notify);详见「前置条件」。 |
| 进对局 | 合并 joinOptions 再 joinById | matchId、seatIndex、reconnectKey 等与 帧同步与游戏房 契约对齐;对局房在 onJoin 中校验 options。 |
| 鉴权 | 匹配房与对局房均使用 JWT | 与 鉴权 一致;成局参数由服务端写入 joinOptions,客户端勿自行编造 seatIndex / matchId。 |
小结:多个入口(随机匹配、房间码等)可以共用同一 onMessage("match:found") + joinById 分支,日志与监控也可围绕 match:queued / match:found / 进房失败 统一打点。
前置条件
- 客户端使用
joinOrCreate("matchmaker_room", options),options 与 鉴权 一致:需携带有效 JWT(token或accessToken)。 - 服务端需 Redis 可用:排队、分布式锁、跨实例
match:found通知均依赖 Redis;Redis 不可用时,同进程内仍可收到匹配结果,多实例场景下可能无法把结果投递到其它节点上的客户端。
加入成功后,服务端会向该客户端发送 mm:ready,payload 含 sessionId。
主动匹配(排队)
客户端发送 match:find,body 要点(与源码 MatchFindRequest 一致):
| 字段 | 说明 |
|---|---|
modeId | 玩法 / 模式标识,默认 "default"。 |
playersPerMatch | 本局玩家总数(整数 2~100)。队列凑满该人数后创建 game_room 并下发 match:found,对局房规模与此一致。典型:1v1 填 2,2v2 填 4,4v4 填 8,以此类推。与 Party 的 party:create 中同名字段语义相同。 |
region | 分区字符串,与 modeId、playersPerMatch 拼成一条逻辑队列;不表示客户端自动按地理位置选服,含义完全由业务约定。未传时服务端通常按 global 入队。详见 队列维度与 region。 |
skill | 可选,预留段位或匹配分;是否参与「分桶 / 扩队列」以服务端 MatchmakingService 实现为准(可能只挂在票据上供后续扩展)。 |
tags | 可选,字符串标签等;同上,是否并入 Redis 排队键以源码为准。 |
队列维度与 region
路人 match:find 时,只有 排队键一致 的玩家才会被凑成同一局。实现上常见 Redis 队列键形如 queue:{modeId}:{playersPerMatch}:{region}(与 多 V 多自动匹配 · 三个参数 一致;精确格式以仓库 MatchmakingService 为准)。
与 region 一起决定「排哪条队」的字段 | 作用(直观理解) |
|---|---|
modeId | 哪种玩法 / 哪条模式池。"ranked" 与 "casual"、"duel" 互不混排。 |
playersPerMatch | 这一局要凑几个人。4 人与 10 人即使用同一 modeId,也会因人数不同而 不同队列。 |
region | 在「同玩法、同人数」下再切一刀。"global" = 全服一条队;"cn" / "na" = 按运营划区;行会 ID、赛事服 ID、分片号 = 只和同一字符串的人匹配,避免路人混进行会内战池。 |
skill(可选) | 协议预留;若服务端把它纳入队列维度,则相近分段才会同桌(具体看实现)。 |
tags(可选) | 协议预留;若实现把标签算进键或过滤条件,则用于跨模式标签匹配等扩展。 |
记法:把 region 想成「队列名的第三段后缀」,而不是地图 API 里的「省市区」。不传时多数实现会落成 global,与显式传 "global" 通常等价。
Party(party:create):同样携带 modeId、playersPerMatch、region,语义与主动匹配一致——便于队伍数据与后续 game_room 落在同一套模式 / 分区标签下;好友建房时请与约定玩法使用 同一 region 字符串,避免「创建了房但统计或扩展逻辑按区过滤时对不上号」。
match:queued 里的 queueKey:服务端可把当前票据所在的队列标识回给客户端,便于日志与调试;取消排队 match:cancel 应在 同一 matchmaker_room 连接 上发送,以便摘除正确票据。
服务端行为概要:
- 写入 Redis(队列 ZSET + 票据 KV),回
match:queued(含queueKey)。 - 用短锁 + Lua 从队列头取出
playersPerMatch张票据(该值即本局game_room玩家总数),组成matchId并创建game_room。 - 向相关客户端发
match:found,并通过 Redis 频道colyseus:mm:notify广播,保证多进程节点也能收到。
取消排队:发送 match:cancel,服务端回 match:cancelled(ok 表示是否成功移除)。
被动开房(Party / 房间码)
| 消息 | 方向 | 说明 |
|---|---|---|
party:create | 客户端 → 服务端 | body:modeId、playersPerMatch(2~100:队伍满员人数,房主仅在达到该人数后可 party:start;同时也是随后创建的 game_room 玩家规模,与 match:find 同义;例 1v1=2、2v2=4、4v4=8)、可选 region(与 match:find 的 region 同义,见 队列维度与 region)。 |
party:created | 服务端 → 客户端 | partyId、partyCode(6 位易读码)、modeId、playersPerMatch、leaderUserId、isLeader 等。 |
party:join | 客户端 → 服务端 | body:partyCode(不区分大小写,服务端会 trim 并转大写)。 |
party:joined | 服务端 → 客户端 | 当前人数 count、playersPerMatch、房主信息等。 |
party:update | 服务端 → 客户端 | 成员变化时向在线成员广播人数与房主标识。 |
party:leave | 客户端 → 服务端 | 离开队伍;若队伍为空则关闭 party。 |
party:left | 服务端 → 客户端 | ok。 |
party:start | 客户端 → 服务端 | 仅房主可发;人数达到 playersPerMatch 后创建 game_room 并下发 match:found,随后关闭 party。 |
party:error | 服务端 → 客户端 | 业务错误说明,如房间码无效、非房主开始、人数不足等。 |
Party 数据在 Redis 中有 TTL(与实现一致,约 30 分钟量级),过期后房间码失效。
match:found 与进入 game_room
匹配成功或 party 开局后,客户端会收到 match:found,核心字段:
| 字段 | 说明 |
|---|---|
roomName | 固定 game_room。 |
roomId | Colyseus 房间 ID,用于 joinById。 |
matchId | 对局 ID。 |
seatIndex | 座位序号。 |
reconnectKey | 断线重连校验用(见 帧同步与游戏房)。 |
joinOptions | 建议原样作为加入 game_room 的 options(含 matchId、seatIndex、reconnectKey 等)。 |
推荐客户端按同一顺序实现(与上文「客户端接入步骤」一致):
joinOrCreate("matchmaker_room", { token })- 发送
match:find或 Party 系列消息 - 监听
match:found joinById(roomId, { ...joinOptions, token })(若项目封装已在底层附带鉴权字段,可按封装省略重复token)
服务端源码位置
MatchmakerRoom 完整实现不在本文粘贴,请直接读服务端仓库:
src/rooms/MatchmakerRoom.tssrc/services/matchmaking/MatchmakingService.tssrc/services/matchmaking/types.tssrc/app.config.ts:define("matchmaker_room", …)(与 项目结构 一致)
生命周期与消息注册顺序见 生命周期。
继承 MatchmakerRoom(服务端扩展)
父类已注册 match:*、party:*、Redis 通知桥与 match:found 投递。子类扩展时,先执行 super.onCreate(options),再叠加自定义逻辑。
约束:
tryMatch、deliverNotifications等为private,子类不能覆盖。涉及凑桌算法、队列维度、Redis 数据结构时,请改MatchmakingService或复制改写MatchmakerRoom。
下面给三个分开的扩展示例:按段位主动匹配、被动开房邀约匹配、主动开房匹配。
示例 A:按段位主动匹配(match:find)
目标:把客户端传来的段位分,按区间映射到 skill,再交给父类现有排队流程。
游戏场景:实时竞技排位(如 1v1 天梯、3v3 赛季)、需要按段位或隐藏分分池,优先保证对局公平性。
// src/rooms/RankedMatchmakerRoom.ts
import { Client } from "colyseus";
import { MatchmakerRoom } from "./MatchmakerRoom";
function bucketByRating(rating: number): string {
if (rating < 1200) return "bronze";
if (rating < 1800) return "silver";
if (rating < 2400) return "gold";
return "diamond";
}
export class RankedMatchmakerRoom extends MatchmakerRoom {
onCreate(options: any) {
super.onCreate(options);
// 新增一个入口消息,内部转发到父类已支持的 match:find
this.onMessage("ranked:find", (client: Client, body: any) => {
const rating = Number(body?.rating ?? 0);
const skill = bucketByRating(Number.isFinite(rating) ? rating : 0);
client.send("ranked:accepted", { skill });
client.send("mm:hint", {
forwardTo: "match:find",
payload: {
modeId: body?.modeId || "ranked",
playersPerMatch: body?.playersPerMatch ?? 2,
region: body?.region || "global",
skill, // 进入父类排队维度
tags: body?.tags ?? [],
},
});
});
}
}说明:生产里通常直接在客户端发 match:find 并带 skill。这里示例 ranked:find 是为了演示“继承后增加业务入口,再落回统一匹配协议”。
客户端最小示例(对应 A)
import Colyseus from "colyseus.js";
const client = new Colyseus.Client("ws://127.0.0.1:2567");
const token = "YOUR_JWT_ACCESS_TOKEN";
async function runRanked() {
// 连接到示例 A 对应的房间名
const mm = await client.joinOrCreate("matchmaker_ranked_room", { token });
// 所有扩展入口最终仍收敛到 match:found
const found = await new Promise<any>((resolve) => {
mm.onMessage("match:found", resolve);
mm.send("ranked:find", {
rating: 1670,
modeId: "ranked",
playersPerMatch: 2,
region: "global",
});
});
const game = await client.joinById(found.roomId, { ...found.joinOptions, token });
console.log("ranked joined:", game.roomId, found.matchId);
}using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Colyseus;
using Colyseus.Schema;
public static class RankedClientExample
{
public static async Task Run(Colyseus.Client client, string token)
{
// 1) 连接到 A 场景房间(按段位入口)
var mm = await client.JoinOrCreate<DynamicSchema>(
"matchmaker_ranked_room",
new Dictionary<string, object> { { "token", token } });
// 2) 先挂 match:found,再发 ranked:find(避免先到消息被漏接)
var foundTcs = new TaskCompletionSource<Dictionary<string, object>>();
mm.OnMessage<Dictionary<string, object>>("match:found", p => foundTcs.TrySetResult(p));
// 3) 发起段位匹配请求
mm.Send("ranked:find", new Dictionary<string, object> {
{ "rating", 1670 },
{ "modeId", "ranked" },
{ "playersPerMatch", 2 },
{ "region", "global" },
});
// 4) 收到成局后进入 game_room(joinOptions 原样合并 token)
var found = await foundTcs.Task;
var roomId = found["roomId"]?.ToString();
var joinOptions = found["joinOptions"] as Dictionary<string, object> ?? new Dictionary<string, object>();
var gameOptions = new Dictionary<string, object>(joinOptions) { { "token", token } };
var game = await client.JoinById<DynamicSchema>(roomId, gameOptions);
Console.WriteLine($"ranked joined: {game.RoomId}");
}
}var _client: Colyseus.Client
var _mm: Colyseus.Room
var _token := "YOUR_JWT_ACCESS_TOKEN"
func run_ranked() -> void:
# 1)连接到 A 场景房间(按段位入口)
_client = Colyseus.Client.new("ws://127.0.0.1:2567")
_mm = _client.join_or_create("matchmaker_ranked_room", {"token": _token})
if _mm:
# 2)先绑定消息处理,再发送 ranked:find,避免漏接 match:found
_mm.message_received.connect(_on_ranked_message)
_mm.send_message("ranked:find", {
"rating": 1670,
"modeId": "ranked",
"playersPerMatch": 2,
"region": "global",
})
func _on_ranked_message(type: Variant, data: Variant) -> void:
if str(type) == "match:found" and typeof(data) == TYPE_DICTIONARY:
# 3)统一收口:收到 match:found 后 join_by_id 进入 game_room
var d: Dictionary = data
var opts := {"token": _token}
var join_options: Variant = d.get("joinOptions", {})
if typeof(join_options) == TYPE_DICTIONARY:
for k in (join_options as Dictionary):
opts[k] = (join_options as Dictionary)[k]
_client.join_by_id(str(d.get("roomId", "")), opts)示例 B:被动开房邀约匹配(party:join)
目标:对受邀加入者做额外校验(例如 inviteToken),校验通过再允许继续 Party 流程。
游戏场景:好友邀约组队(如副本车队、工会活动、主播开黑房)、需要“仅受邀可进”与房间码并存。
// src/rooms/InviteMatchmakerRoom.ts
import { Client } from "colyseus";
import { MatchmakerRoom } from "./MatchmakerRoom";
function verifyInviteToken(token: string): boolean {
// 按你的业务替换:JWT、签名串、数据库白名单等
return token.length >= 16;
}
export class InviteMatchmakerRoom extends MatchmakerRoom {
onCreate(options: any) {
super.onCreate(options);
this.onMessage("invite:join", (client: Client, body: any) => {
const inviteToken = String(body?.inviteToken || "");
if (!verifyInviteToken(inviteToken)) {
client.send("party:error", { message: "邀约无效或已过期" });
return;
}
// 校验通过后,引导客户端按既有协议执行 party:join
client.send("mm:hint", {
forwardTo: "party:join",
payload: { partyCode: String(body?.partyCode || "").toUpperCase() },
});
});
}
}说明:这里把“受邀资格”与“加入 Party”拆开;Party 成员管理、party:update、party:start 仍走父类逻辑。
客户端最小示例(对应 B)
import Colyseus from "colyseus.js";
const client = new Colyseus.Client("ws://127.0.0.1:2567");
const token = "YOUR_JWT_ACCESS_TOKEN";
async function runInviteJoin() {
// 连接到示例 B 对应的房间名
const mm = await client.joinOrCreate("matchmaker_invite_room", { token });
// 1) 邀约校验入口
mm.send("invite:join", {
partyCode: "ABCD23",
inviteToken: "INVITE_TOKEN_123456",
});
// 2) 若服务端返回 mm:hint -> party:join,则按提示继续
mm.onMessage("mm:hint", (hint: any) => {
if (hint?.forwardTo === "party:join") {
mm.send("party:join", hint.payload);
}
});
// 3) 人满并由房主 party:start 后,统一收到 match:found
const found = await new Promise<any>((resolve) => mm.onMessage("match:found", resolve));
const game = await client.joinById(found.roomId, { ...found.joinOptions, token });
console.log("invite joined:", game.roomId, found.matchId);
}using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Colyseus;
using Colyseus.Schema;
public static class InviteClientExample
{
public static async Task Run(Colyseus.Client client, string token)
{
// 1) 连接到 B 场景房间(邀约入口)
var mm = await client.JoinOrCreate<DynamicSchema>(
"matchmaker_invite_room",
new Dictionary<string, object> { { "token", token } });
// 2) 监听 mm:hint,按服务端提示转发到 party:join
mm.OnMessage<Dictionary<string, object>>("mm:hint", hint =>
{
if (hint.TryGetValue("forwardTo", out var f) && f?.ToString() == "party:join")
{
var payload = hint["payload"] as Dictionary<string, object> ?? new Dictionary<string, object>();
mm.Send("party:join", payload);
}
});
// 3) 先挂 match:found,再发 invite:join
var foundTcs = new TaskCompletionSource<Dictionary<string, object>>();
mm.OnMessage<Dictionary<string, object>>("match:found", p => foundTcs.TrySetResult(p));
mm.Send("invite:join", new Dictionary<string, object> {
{ "partyCode", "ABCD23" },
{ "inviteToken", "INVITE_TOKEN_123456" },
});
// 4) 收到成局后进入 game_room
var found = await foundTcs.Task;
var roomId = found["roomId"]?.ToString();
var joinOptions = found["joinOptions"] as Dictionary<string, object> ?? new Dictionary<string, object>();
var gameOptions = new Dictionary<string, object>(joinOptions) { { "token", token } };
await client.JoinById<DynamicSchema>(roomId, gameOptions);
}
}var _client: Colyseus.Client
var _mm: Colyseus.Room
var _token := "YOUR_JWT_ACCESS_TOKEN"
func run_invite_join() -> void:
# 1)连接到 B 场景房间(邀约入口)
_client = Colyseus.Client.new("ws://127.0.0.1:2567")
_mm = _client.join_or_create("matchmaker_invite_room", {"token": _token})
if _mm:
# 2)先绑定消息处理,再发 invite:join
_mm.message_received.connect(_on_invite_message)
_mm.send_message("invite:join", {
"partyCode": "ABCD23",
"inviteToken": "INVITE_TOKEN_123456",
})
func _on_invite_message(type: Variant, data: Variant) -> void:
var t := str(type)
if t == "mm:hint" and typeof(data) == TYPE_DICTIONARY:
# 服务端提示需要转发到 party:join(保留统一 Party 流程)
var hint: Dictionary = data
if str(hint.get("forwardTo", "")) == "party:join":
_mm.send_message("party:join", hint.get("payload", {}))
elif t == "match:found" and typeof(data) == TYPE_DICTIONARY:
# 3)统一收口:收到 match:found 后进 game_room
var d: Dictionary = data
var opts := {"token": _token}
var join_options: Variant = d.get("joinOptions", {})
if typeof(join_options) == TYPE_DICTIONARY:
for k in (join_options as Dictionary):
opts[k] = (join_options as Dictionary)[k]
_client.join_by_id(str(d.get("roomId", "")), opts)示例 C:主动开房匹配(party:create)
目标:房主主动开房时注入业务参数(例如活动场次、固定区域),并限制可开的配置。
游戏场景:自定义房 / 活动房(如赛事服、教学房、约战房)、由房主决定模式与人数上限,再等待成员加入。
// src/rooms/HostCreateMatchmakerRoom.ts
import { Client } from "colyseus";
import { MatchmakerRoom } from "./MatchmakerRoom";
const ALLOWED_MODES = new Set(["ranked", "casual", "event_5v5"]);
export class HostCreateMatchmakerRoom extends MatchmakerRoom {
onCreate(options: any) {
super.onCreate(options);
this.onMessage("host:create", (client: Client, body: any) => {
const modeId = String(body?.modeId || "casual");
if (!ALLOWED_MODES.has(modeId)) {
client.send("party:error", { message: `不支持的 modeId: ${modeId}` });
return;
}
const playersPerMatch = Number(body?.playersPerMatch ?? 2);
if (!Number.isInteger(playersPerMatch) || playersPerMatch < 2 || playersPerMatch > 10) {
client.send("party:error", { message: "playersPerMatch 仅允许 2~10" });
return;
}
client.send("mm:hint", {
forwardTo: "party:create",
payload: {
modeId,
playersPerMatch,
region: body?.region || "global",
},
});
});
}
}说明:房主主动开房是“先建 Party 再等人”。真正开局仍由房主触发 party:start,并由父类统一下发 match:found。
客户端最小示例(对应 C)
import Colyseus from "colyseus.js";
const client = new Colyseus.Client("ws://127.0.0.1:2567");
const token = "YOUR_JWT_ACCESS_TOKEN";
async function runHostCreate() {
// 连接到示例 C 对应的房间名(房主端)
const mm = await client.joinOrCreate("matchmaker_host_room", { token });
// 1) 房主主动创建 Party
mm.send("host:create", {
modeId: "event_5v5",
playersPerMatch: 4,
region: "global",
});
// 2) 若服务端返回 mm:hint -> party:create,则按提示继续
mm.onMessage("mm:hint", (hint: any) => {
if (hint?.forwardTo === "party:create") {
mm.send("party:create", hint.payload);
}
});
// 3) 人满后房主触发 party:start(这里只演示消息)
mm.send("party:start", {});
// 4) 统一收口:match:found -> joinById
const found = await new Promise<any>((resolve) => mm.onMessage("match:found", resolve));
const game = await client.joinById(found.roomId, { ...found.joinOptions, token });
console.log("host joined:", game.roomId, found.matchId);
}using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Colyseus;
using Colyseus.Schema;
public static class HostCreateClientExample
{
public static async Task Run(Colyseus.Client client, string token)
{
// 1) 连接到 C 场景房间(房主主动开房入口)
var mm = await client.JoinOrCreate<DynamicSchema>(
"matchmaker_host_room",
new Dictionary<string, object> { { "token", token } });
// 2) 服务端若返回 mm:hint -> party:create,则按提示执行
mm.OnMessage<Dictionary<string, object>>("mm:hint", hint =>
{
if (hint.TryGetValue("forwardTo", out var f) && f?.ToString() == "party:create")
{
var payload = hint["payload"] as Dictionary<string, object> ?? new Dictionary<string, object>();
mm.Send("party:create", payload);
}
});
// 3) 先挂 match:found,再发 host:create / party:start
var foundTcs = new TaskCompletionSource<Dictionary<string, object>>();
mm.OnMessage<Dictionary<string, object>>("match:found", p => foundTcs.TrySetResult(p));
mm.Send("host:create", new Dictionary<string, object> {
{ "modeId", "event_5v5" },
{ "playersPerMatch", 4 },
{ "region", "global" },
});
mm.Send("party:start", new Dictionary<string, object>());
// 4) 收到成局后进入 game_room
var found = await foundTcs.Task;
var roomId = found["roomId"]?.ToString();
var joinOptions = found["joinOptions"] as Dictionary<string, object> ?? new Dictionary<string, object>();
var gameOptions = new Dictionary<string, object>(joinOptions) { { "token", token } };
await client.JoinById<DynamicSchema>(roomId, gameOptions);
}
}var _client: Colyseus.Client
var _mm: Colyseus.Room
var _token := "YOUR_JWT_ACCESS_TOKEN"
func run_host_create() -> void:
# 1)连接到 C 场景房间(房主主动开房入口)
_client = Colyseus.Client.new("ws://127.0.0.1:2567")
_mm = _client.join_or_create("matchmaker_host_room", {"token": _token})
if _mm:
# 2)先绑定消息处理,再发 host:create
_mm.message_received.connect(_on_host_message)
_mm.send_message("host:create", {
"modeId": "event_5v5",
"playersPerMatch": 4,
"region": "global",
})
_mm.send_message("party:start", {})
func _on_host_message(type: Variant, data: Variant) -> void:
var t := str(type)
if t == "mm:hint" and typeof(data) == TYPE_DICTIONARY:
# 服务端提示需要转发到 party:create
var hint: Dictionary = data
if str(hint.get("forwardTo", "")) == "party:create":
_mm.send_message("party:create", hint.get("payload", {}))
elif t == "match:found" and typeof(data) == TYPE_DICTIONARY:
# 3)统一收口:收到 match:found 后进 game_room
var d: Dictionary = data
var opts := {"token": _token}
var join_options: Variant = d.get("joinOptions", {})
if typeof(join_options) == TYPE_DICTIONARY:
for k in (join_options as Dictionary):
opts[k] = (join_options as Dictionary)[k]
_client.join_by_id(str(d.get("roomId", "")), opts)注册方式(按场景拆房间名)
若三种流程都要并存,建议注册三个房间名,客户端按入口选择连接目标:
// src/app.config.ts — 片段
import { RankedMatchmakerRoom } from "./rooms/RankedMatchmakerRoom";
import { InviteMatchmakerRoom } from "./rooms/InviteMatchmakerRoom";
import { HostCreateMatchmakerRoom } from "./rooms/HostCreateMatchmakerRoom";
gameServer.define("matchmaker_ranked_room", RankedMatchmakerRoom);
gameServer.define("matchmaker_invite_room", InviteMatchmakerRoom);
gameServer.define("matchmaker_host_room", HostCreateMatchmakerRoom);如果只保留一个房间名,也可以把三类入口消息都加在同一个子类里,然后统一 define("matchmaker_room", YourMatchmakerRoom)。
系统自带默认(无需写服务端匹配规则扩展)
基线客户端示例(默认 matchmaker_room)
前置:通过 HTTP 登录取得 JWT,作为 token 使用(见 JWT 与房间鉴权)。 本节展示默认入口;上文「继承 MatchmakerRoom」里的 A/B/C 只是把入口消息或房间名换掉,成局后仍统一到 match:found -> joinById。 目标:以最少改动跑通默认 matchmaker_room 完整链路(排队、成局、进房)。
游戏场景:通用快速接入(先跑通完整匹配链路),适用于原型验证、联调阶段与未细分匹配策略的中小型玩法。
消息顺序:必须先注册 match:found 监听,再发送 match:find,否则可能丢失最先到达的成局消息。
import Colyseus from "colyseus.js";
/** 与 match:found 载荷对应,便于类型提示;字段以服务端实际为准 */
interface MatchFoundPayload {
roomName: string;
roomId: string;
matchId: string;
seatIndex: number;
reconnectKey: string;
joinOptions: Record<string, unknown>;
}
const ENDPOINT = "ws://127.0.0.1:2567";
/** HTTP 登录拿到的 JWT;进房字段名用 token(与 accessToken 等价见鉴权文档) */
const TOKEN = "YOUR_JWT_ACCESS_TOKEN";
/**
* 只等某一条下行消息一次(收到后自动取消监听)。
* 用于保证:先挂上 match:found,再发 match:find,避免极端情况下丢事件。
*/
function onceMessage<T>(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} timeout`));
}, ms);
dispose = room.onMessage(type, (payload: T) => {
clearTimeout(timer);
dispose?.();
resolve(payload);
});
});
}
/** 主动排队 → 收 match:found → joinById 进 game_room(最小闭环) */
async function runQueueMatch() {
const client = new Colyseus.Client(ENDPOINT);
// 1)进入匹配房;options 必须能通过 @RequireAuth(与 rooms/auth 一致)
const mm = await client.joinOrCreate("matchmaker_room", { token: TOKEN });
// 2)可选:监听调试下行(生产可按需保留)
mm.onMessage("mm:ready", (p) => console.log("mm:ready", p)); // 进房成功握手
mm.onMessage("match:queued", (p) => console.log("match:queued", p)); // 已入队,含 queueKey
mm.onMessage("party:update", (p) => console.log("party:update", p)); // Party 流程用
mm.onMessage("party:error", (p) => console.warn("party:error", p));
// 3)关键:先注册 match:found,再发 match:find(顺序不要反)
const foundP = onceMessage<MatchFoundPayload>(mm, "match:found");
mm.send("match:find", {
modeId: "ranked", // 玩法/池子标识,与队列维度相关
playersPerMatch: 2, // 成局人数=本局 game_room 规模;1v1=2,2v2=4,4v4=8;2~100
region: "global", // 队列键第三段:分区字符串,与 modeId、playersPerMatch 共同决定排哪条队;见「队列维度与 region」
});
const found = await foundP;
// 4)进对局房:roomId + joinOptions 来自 match:found;务必合并 token 进最终 options
const game = await client.joinById(found.roomId, {
...found.joinOptions,
token: TOKEN,
});
console.log("joined game_room", game.roomId, found.matchId);
}
runQueueMatch().catch(console.error);using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Colyseus;
using Colyseus.Schema;
/// <summary>
/// 主动匹配最小示例:JoinOrCreate(matchmaker_room) → Send(match:find) → 等 match:found → JoinById(game_room)。
/// DynamicSchema 便于先跑通;上线建议对状态做 schema-codegen 后替换泛型。
/// </summary>
public static class MatchmakingExample
{
public static async Task<(Room<DynamicSchema> mm, Room<DynamicSchema> game)> Run(
Colyseus.Client client,
string accessToken)
{
// 1)进入匹配房
var mm = await client.JoinOrCreate<DynamicSchema>(
"matchmaker_room",
new Dictionary<string, object> { { "token", accessToken } });
// 2)先订阅 match:found(用 TCS 在回调里完成异步等待)
var foundTcs = new TaskCompletionSource<Dictionary<string, object>>();
mm.OnMessage<Dictionary<string, object>>("match:found", p => foundTcs.TrySetResult(p));
// 3)再发起排队(避免竞态:监听晚于发送会丢 match:found)
mm.Send("match:find", new Dictionary<string, object> {
{ "modeId", "ranked" },
{ "playersPerMatch", 2 }, // 成局人数=game_room 规模;1v1=2,2v2=4,4v4=8;2~100
{ "region", "global" }, // 分区键,与 modeId、playersPerMatch 组成排队维度
});
var found = await foundTcs.Task;
// 4)解析 roomId / joinOptions;joinOptions 建议原样并入进房字典
var roomId = found["roomId"]?.ToString();
if (string.IsNullOrEmpty(roomId))
throw new InvalidOperationException("match:found 缺少 roomId");
var joinOptions = found["joinOptions"] as Dictionary<string, object> ?? new Dictionary<string, object>();
var gameOptions = new Dictionary<string, object>(joinOptions) { { "token", accessToken } };
// 5)进入 game_room
var game = await client.JoinById<DynamicSchema>(roomId, gameOptions);
return (mm, game);
}
}# 主动匹配最小示例(API 以当前 Colyseus Godot 插件为准,见 Godot 接入页)
# 要点:join 后立刻 connect(message_received),再在 mm:ready 里发 match:find,避免 match:found 先到却无人处理。
var _client: Colyseus.Client
var _mm: Colyseus.Room
var _token := "YOUR_JWT_ACCESS_TOKEN"
func start_match_example() -> void:
# 1)构造客户端并进入匹配房
_client = Colyseus.Client.new("ws://127.0.0.1:2567")
_mm = _client.join_or_create("matchmaker_room", {"token": _token})
if _mm:
# 2)统一在此处理所有下行(含 mm:ready / match:found)
_mm.message_received.connect(_on_mm_message)
func _on_mm_message(type: Variant, data: Variant) -> void:
var t := str(type)
if t == "mm:ready":
# 进匹配房成功后再发排队(message_received 已连接,故能收到后续 match:found)
_mm.send_message("match:find", {
"modeId": "ranked",
"playersPerMatch": 2, # 成局人数=game_room 规模;1v1=2,2v2=4,4v4=8;2~100
"region": "global", # 队列键第三段,全服一条队常用 global;见匹配文档「队列维度与 region」
})
elif t == "match:queued":
# 已入队;data 内常有 queueKey,便于调试
print("match:queued ", data)
elif t == "match:found" and typeof(data) == TYPE_DICTIONARY:
# 成局:用 roomId + joinOptions 进 game_room;opts 先放 token 再合并 joinOptions
var d: Dictionary = data
var room_id: String = str(d.get("roomId", ""))
var join_options: Variant = d.get("joinOptions", {})
var opts := {"token": _token}
if typeof(join_options) == TYPE_DICTIONARY:
for k in (join_options as Dictionary):
opts[k] = (join_options as Dictionary)[k]
var _game = _client.join_by_id(room_id, opts)
elif t == "party:error":
push_warning("party:error " + str(data))延伸阅读
- 多模式参数与浏览器联调:多 V 多自动匹配
- Party、队伍聊天与进
game_room串联:组队下副本(Party + 队伍聊天) - 小游戏分享拉人、Party 邀约与三端客户端示例:小游戏邀请好友对战(Party 邀约)
game_room帧同步与重连:帧同步与游戏房