本页尚未翻译,当前显示英文版本。
Matchmaking
把玩家送进合适的房间。 匹配票据描述玩家本人,并筛选其他玩家。匹配器确定分配结果、预留席位,之后游戏流量直接发往房间。
匹配器只在路径上出现一次,用来决定你该去哪里。它不在对局的路径上:它的结果是一个分配(placement)加一个有时限的席位预留,从加入那一刻起,游戏流量直接发往房间。所以队列再忙,也不会拖慢游戏。
何时使用
- 你需要按声明的条件(模式、地区、名次)把玩家路由进房间,而不是自己拼一个大厅列表。
- 匹配条件必须来自平台数据,而不是客户端的自报:在入队前 hook 中写入名次(rank)。
- 队列应在服务器端随时间放宽条件,而客户端只持有一张匹配票据(ticket),从不轮询。
- 小队(party)必须一起进入同一场对局:群组要么整体进入,要么都不进入。
- 你运行外部匹配器,只需要把它的决定落实为分配 + 席位(seat)预留。
- 无需使用:如果玩家自己挑选会话。房间浏览器和
Join已经够用。
分工
| 主体 | 在本页中的职责 |
|---|---|
player | 创建和取消自己的匹配票据,以小队成员身份加入 |
match-organizer | 声明匹配队列和放宽规则;读取分配结果 |
backend-service | 在入队前写入可信条件;执行外部匹配器的决定 |
概览
Find 调用返回一个可加入的预留席位// client — one call for the common case
var seat = await playserv.Matchmaking.Find("ranked-duo");
var room = await playserv.Rooms.Join(seat);// client — one call for the common case
const seat = await playserv.matchmaking.find('ranked-duo');
const room = await playserv.rooms.join(seat);# client — one call for the common case
seat = await playserv.matchmaking.find("ranked-duo")
room = await playserv.rooms.join(seat)Go 中基于特性的声明方式尚未确定(O-1),确定写法后会补上示例。
// client — one call for the common case
Client->Matchmaking->Of<FRankedDuo>()->Tickets->Create(FPSTicketClaim{ .Mode = TEXT("duo") },
TPSOnResult<FPSTicket*>::CreateWeakLambda(this, [this](const TPSResult<FPSTicket*>& TicketResult)
{
if (!TicketResult.HasValue()) { return; }
// the seat arrives as the ticket's outcome
TPSSubscription Placement = TicketResult.Value()->Subscribe([this](const FPSSeat& Seat)
{
Client->Rooms->Join(Seat,
TPSOnResult<FPSRoom*>::CreateWeakLambda(this, [this](const TPSResult<FPSRoom*>& JoinResult)
{
if (!JoinResult.HasValue()) { return; }
EnterMatch(JoinResult.Value());
}));
});
}));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.// client — one call for the common case
var seat = await playserv.Matchmaking.Find("ranked-duo");
var room = await playserv.Rooms.Join(seat);ranked-duo 队列:双向筛选和两级放宽阶梯[Matchmaker("ranked-duo")]
public static class RankedDuo
{
public static Size Size = Size.Exactly(4, multiple: 2);
public static string Filter = "mode == 'duo' && region == self.region";
public static Relax[] Relax =
{
Relax.After(15.Seconds(), "abs(rank - self.rank) < 300"),
Relax.After(45.Seconds(), "abs(rank - self.rank) < 800"),
};
}@Matchmaker('ranked-duo')
export class RankedDuo {
static size = Size.exactly(4, { multiple: 2 });
static filter = "mode == 'duo' && region == self.region";
static relax = [
Relax.after(seconds(15), 'abs(rank - self.rank) < 300'),
Relax.after(seconds(45), 'abs(rank - self.rank) < 800'),
];
}@matchmaker("ranked-duo")
class RankedDuo:
size = Size.exactly(4, multiple=2)
filter = "mode == 'duo' && region == self.region"
relax = [
Relax.after(seconds(15), "abs(rank - self.rank) < 300"),
Relax.after(seconds(45), "abs(rank - self.rank) < 800"),
]Go 中基于特性的声明方式尚未确定(O-1),确定写法后会补上示例。
USTRUCT(PSMatchmaker = (Name = "ranked-duo", Size = "Exactly:4", Multiple = 2,
Filter = "mode == 'duo' && region == self.region"))
struct FRankedDuo
{
GENERATED_BODY()
UPROPERTY(PSRelax = (After = "15s", Filter = "abs(rank - self.rank) < 300")) FPSRelax First;
UPROPERTY(PSRelax = (After = "45s", Filter = "abs(rank - self.rank) < 800")) FPSRelax Second;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI[Matchmaker("ranked-duo")]
public static class RankedDuo
{
public static Size Size = Size.Exactly(4, multiple: 2);
public static string Filter = "mode == 'duo' && region == self.region";
public static Relax[] Relax =
{
Relax.After(15.Seconds(), "abs(rank - self.rank) < 300"),
Relax.After(45.Seconds(), "abs(rank - self.rank) < 800"),
};
}不能信任客户端的条件,在入队前 hook 中写入:
[Before(Matchmaking.Enqueue)] // the server has the last word
public static async Task<Ticket> StampRank(Ticket t)
{
var rows = await PlayServ.Leaderboards.ForOwners("ranked", new[] { t.Player });
t.Properties["rank"] = rows[0].Rank; // the row carries its rank in the full table
return t;
}// the server has the last word
export const stampRank = before(Matchmaking.enqueue, async (t: Ticket) => {
const rows = await PlayServ.leaderboards.forOwners('ranked', [t.player]);
t.properties.rank = rows[0].rank; // the row carries its rank in the full table
return t;
});@before(matchmaking.enqueue) # the server has the last word
async def stamp_rank(t: Ticket) -> Ticket:
rows = await playserv.leaderboards.for_owners("ranked", [t.player])
t.properties["rank"] = rows[0].rank # the row carries its rank in the full table
return tGo 中基于特性的声明方式尚未确定(O-1),确定写法后会补上示例。
hook 是云函数:它在平台上执行,不在引擎里执行。用 C#、TypeScript 或 Python 编写,Unreal 客户端只需调用上面的 Find。引擎内编写的函数已在规划中:Blueprint 图表,以及 C++(转译为平台校验过的脚本目标)。两者都像其他声明一样经过分析并推送,执行仍在平台上。
hook 是云函数:它在平台上执行,不在引擎里执行。用 C#、TypeScript 或 Python 编写,Unity 客户端只需调用上面的 Find。
这里读取的是 Leaderboards 中的按所有者列表读取,和好友分组用的是同一种;返回的每一行都带着该所有者在完整榜单中的名次。hook 能查询别人的行,是因为榜单的谓词允许云函数这样做;玩家会话问同样的问题,只能拿到自己的那一行。
模型
匹配票据携带什么。
| 部分 | 含义 | 谁采信 |
|---|---|---|
自我描述 | 参与者声明的属性:评分、模式、语言、所选地图 | 未经检查谁都不信:这是调用方的自报 |
要求 | 其他参与者必须满足的谓词 | 平台,因为由平台来应用它 |
匹配票据的参与者是一个主体或一个群组(group):群组整体进入,这就是小队。它的票据不可拆分:群组要么整体进入名单(roster),要么都不进入。
队列类型声明什么。
| 声明项 | 含义 |
|---|---|
属性 | 按名称和类型声明。票据中未在此声明的属性按校验失败拒绝,而不是忽略 |
名单大小 | 最小值、最大值和一个兼容步长:名单可接受的倍数,这样“五人”队伍就是 五人,而不是二到十之间的任意人数 |
要求阶梯 | 一组带时间窗口的有序谓词:每一级都是更宽的要求,外加一个时间,超时后匹配(matchmaking)进入下一级。放宽是一项声明,绝不是处理器里的任意逻辑 |
谓词语言 | 与其他所有地方用的是同一种,其词汇包含票据自身的属性,所以“评分与我相差 ±100 以内”可以表达。 |
双向性 | 是否接受 A 接受 B、而 B 不接受 A 的名单。没有默认值 |
票据有效期 | 过期后票据转为 expired 并发出事件 |
结果 | RoomPlacement:一个房间引用加上该房间中的预留,用于同步游戏;或 RosterSet:只有名单,没有房间也没有预留,用于对手离线的异步游戏 |
匹配票据的状态。 created → queued → matched · cancelled · expired,后三个是终态。
| 始终成立 | 含义 |
|---|---|
每个参与者在每个队列中只有一张有效票据 | 第二张属于冲突,而不是第二份申请:请读取已有的那张 |
配对原因 | 可观测:它会进入匹配事件和历史记录。对于内置算法,原因就是阶梯的级别;覆盖实现可能没有级别,那么原因就是它声明的一个不透明值,但原因始终存在 |
过期 | 是一种结果,不是错误:“名单未能在声明的时间内凑齐”是正常结束,作为票据的结果送达 |
结果 | 通过订阅送达,而不是轮询:客户端只持有一张票据,从不重复询问 |
matched | 是原子的:对于 RoomPlacement,要么名单匹配成功且每个参与者都持有预留,要么票据留在队列中。对于 RosterSet,原子结果只有名单本身 |
断线会取消匹配票据
匹配票据是“现在就要玩”的申请,所以断线会取消它。给不在场的玩家配对,会让其他所有人的名单变差。
错误
- 同一队列中的第二张票据属于冲突;不要重试,读取已有的票据。
- 未声明的属性,或引用未声明属性的要求,属于校验失败,而不是静默忽略,否则之后会表现为“没有找到对手”。
- 队列已暂停返回 unavailable,而不是 forbidden:调用方的权限没有问题,情况只是暂时的,所以应带退避重试。
- **无法为结果创建房间(room)**同样返回 unavailable,带退避重试。
- 预留失败属于冲突,值得重试:票据仍留在队列中。
- 找不到的票据或别人的票据,以及谓词不允许进入该队列的主体,都返回 not found,因此拒绝结果既不暴露票据,也不暴露队列。
限制
具体数值将随平台限制一章给出;到达每个上限时的行为现在已经确定。
- 队列中的票据数:创建按冲突拒绝,已有票据不会为了腾出空间而被移除。
- 票据有效期:转为
expired并发出事件。 - 阶梯级数:级数过多的声明在声明阶段即遭拒绝。
- 票据中的群组大小:按校验失败拒绝票据。
- 每种队列类型声明的属性数:在声明阶段拒绝。
- 票据创建速率:速率限制拒绝,并附带可重试的截止时间。
- 匹配历史的保留期:超过声明的期限后,条目不再可读。