排位赛
适用于「路人单排 / 多排自动匹配」:玩家进 matchmaker_room 排队,服务端按 modeId、人数、分区 等成局,全员收到 match:found 后 joinById 进入 game_room。与 小游戏邀请好友对战(Party 邀约) 的「建房拉人」互补:排位走 主动匹配 match:find,一般不走 party:create。
场景目标
- 用框架自带的
matchmaker_room+match:find/match:cancel完成排队与成局,无需在客户端或服务端自写一套匹配循环。 modeId(如ranked、ranked_solo)区分段位赛、娱乐赛等;playersPerMatch表示凑满几人成局,同时也是开局时创建的game_room内玩家规模(1v1 填2,2v2 填4,4v4 填8,以此类推);与region组合即可多模式共存,且受服务端 2~100 的统一人数限制。- 段位、MMR、禁赛、赛季等 业务规则 放在 HTTP / 数据库或 继承
MatchmakerRoom的服务端扩展里(例如入队前校验、匹配成功写战绩),框架侧只保证房间契约与 Redis 队列语义。
推荐消息流
- 客户端
joinOrCreate("matchmaker_room", { token }),收到mm:ready后再发业务消息(避免丢下行)。 - 发送
match:find,body 中带modeId、playersPerMatch、region等与玩法一致的参数。 - 等待
match:queued/match:found;需要离开时发match:cancel。 - 收到
match:found后使用返回的roomId与joinOptions,合并本端token后joinById进入game_room。
协议字段、Redis 与继承扩展见 匹配与开房。下面给出本场景专用的 三端客户端示例(与匹配文档基线一致,侧重 modeId + 可选 skill)。
客户端示例代码(match:find)
以下为 客户端 示例:连接默认 matchmaker_room,先注册 match:found(或先 message_received 再于 mm:ready 后发排队),再发 match:find;modeId 用排位池标识(如 ranked / ranked_solo),可选 skill 传 MMR 或段位分(与 MatchFindRequest 一致);成局后 joinById。离开队列发 match:cancel,服务端回 match:cancelled。不在此实现匹配算法,排队与成局由框架 MatchmakerRoom 完成。
match:find 核心参数(与 匹配与开房「主动匹配」一致;region 与 modeId、playersPerMatch、skill、tags 如何共同划分队列见 队列维度与 region):
| 字段 | 含义 |
|---|---|
modeId | 玩法 / 模式池标识;排位常用 ranked、ranked_solo 等,与 Redis 队列维度绑定,不同字符串互相独立排队。 |
playersPerMatch | 凑满几人成一局(整数 2~100,与 Party、match:find 共用同一套限制)。达到人数后服务端创建 game_room 并下发 match:found;该值即本局 game_room 的预期玩家总数。写法与对战规模对应:1v1 → 2,2v2 → 4,4v4 → 8,依此类推。 |
region | 分区字符串(常见实现里排队键第三段:queue:{modeId}:{playersPerMatch}:{region})。与 modeId、playersPerMatch 绑定:三者完全一致才会进同一条路人队。"global" = 全服一条队;也可用 "cn"、行会 ID 等收窄池子。非自动定位。详见 匹配与开房 · 队列维度与 region。 |
skill(可选) | 匹配分 / 段位分,预留字段;具体是否参与撮合以服务端 MatchmakingService 为准,可配合继承扩展使用。 |
ts
import Colyseus from "colyseus.js";
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";
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 → 进 game_room */
export async function rankedQueueAndJoin(skill?: number) {
const client = new Colyseus.Client(ENDPOINT);
const mm = await client.joinOrCreate("matchmaker_room", { token: TOKEN });
mm.onMessage("mm:ready", (p) => console.log("mm:ready", p));
mm.onMessage("match:queued", (p) => console.log("match:queued", p));
mm.onMessage("match:cancelled", (p) => console.log("match:cancelled", p));
const foundP = onceMessage<MatchFoundPayload>(mm, "match:found");
mm.send("match:find", {
modeId: "ranked", // 排位池标识,与 Redis 队列维度一致;多模式用不同字符串
playersPerMatch: 2, // 成局人数=本局 game_room 规模;1v1=2,2v2=4,4v4=8;服务端 2~100
region: "global", // 队列分区第三段;与 Party、其他 match:find 参数共同决定排哪条队
...(skill != null ? { skill } : {}), // 可选 MMR/段位分,见上文表格
});
const found = await foundP;
const game = await client.joinById(found.roomId, {
...found.joinOptions,
token: TOKEN,
});
console.log("ranked in game_room", game.roomId, found.matchId, "seat", found.seatIndex);
return { client, mm, game };
}
/** 取消当前排队(仍在 matchmaker_room 内时调用) */
export function rankedCancelQueue(mm: Colyseus.Room) {
mm.send("match:cancel", {});
}csharp
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Colyseus;
using Colyseus.Schema;
/// <summary>排位:match:find → match:found → JoinById(game_room)。可选 skill 传 MMR。</summary>
public static class RankedClientExample
{
public static async Task<(Room<DynamicSchema> mm, Room<DynamicSchema> game)> QueueAndJoin(
Colyseus.Client client,
string accessToken,
int? skillMmR = null)
{
var mm = await client.JoinOrCreate<DynamicSchema>(
"matchmaker_room",
new Dictionary<string, object> { { "token", accessToken } });
var foundTcs = new TaskCompletionSource<Dictionary<string, object>>();
mm.OnMessage<Dictionary<string, object>>("match:found", p => foundTcs.TrySetResult(p));
// match:find 核心字段见上文表格
var findBody = new Dictionary<string, object> {
{ "modeId", "ranked" }, // 排位池
{ "playersPerMatch", 2 }, // 成局人数=game_room 规模;1v1=2,2v2=4,4v4=8;2~100
{ "region", "global" }, // 分区键;与 modeId、playersPerMatch 组成排队维度
};
if (skillMmR.HasValue)
findBody["skill"] = skillMmR.Value; // 可选 MMR,见上文表格
mm.Send("match:find", findBody);
var found = await foundTcs.Task;
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 } };
var game = await client.JoinById<DynamicSchema>(roomId, gameOptions);
return (mm, game);
}
public static void CancelQueue(Room<DynamicSchema> mm) =>
mm.Send("match:cancel", new Dictionary<string, object>());
}gdscript
# 排位排队:mm:ready 后发 match:find;收到 match:found 后 join_by_id 进 game_room
var _client: Colyseus.Client
var _mm: Colyseus.Room
var _token := "YOUR_JWT_ACCESS_TOKEN"
func start_ranked_queue() -> void:
_client = Colyseus.Client.new("ws://127.0.0.1:2567")
_mm = _client.join_or_create("matchmaker_room", {"token": _token})
if _mm:
_mm.message_received.connect(_on_ranked_mm_message)
func _on_ranked_mm_message(type: Variant, data: Variant) -> void:
var t := str(type)
if t == "mm:ready":
_mm.send_message("match:find", {
"modeId": "ranked", # 排位池标识,见上文表格
"playersPerMatch": 2, # 成局人数=game_room 规模;1v1=2,2v2=4,4v4=8;2~100
"region": "global", # 分区键第三段;全服常用 global
"skill": 1500, # 可选 MMR;不需要可整键删除
})
elif t == "match:queued":
print("match:queued ", data)
elif t == "match:cancelled":
print("match:cancelled ", data)
elif t == "match:found" and typeof(data) == TYPE_DICTIONARY:
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)
elif t == "party:error":
push_warning("party:error " + str(data))
func cancel_ranked_queue() -> void:
if _mm:
_mm.send_message("match:cancel", {})与 Party、多模式文档的关系
- Party / 房间码组队:组队下副本(Party + 队伍聊天)、小游戏邀请好友对战。
- 同一匹配房多模式、动态人数演示:多 V 多自动匹配。