跳到主要内容
游戏周边服务

本页尚未翻译,当前显示英文版本。

Leaderboards

一个榜单模型,各个维度组合起来就能覆盖所有类型的排行榜: 每日排名、最佳圈速榜、公会总分、赛季、锦标赛。

何时使用

  • 分数需要给玩家排名(每日排名、最佳圈速榜、公会总分),而且要用同一个声明式模型实现,而不是每个排行榜(leaderboard)一套系统。
  • 你需要标准读取(前 N 名、我附近的名次、指定的所有者列表),又不想额外做数据建模。
  • 周期(cycle)必须按计划结束、归档(绝不删除),并带着最终榜单触发奖励 hook。
  • 可疑分数绝不能进入榜单:提交前 hook 负责校验、封顶或拒绝,并给出有类型的原因。
  • 锦标赛就是同一个排行榜,再加上报名窗口(entry window)、参赛人数上限和每周期尝试次数。
  • 无需使用:如果这个数值从不在玩家之间比较。个人计数器或生涯总计就是普通的数据。这个模块只给结果排序,从不计算结果,也不运行淘汰赛对阵。

分工

主体在本页中的职责
player读取前 N 名 / 我附近 / 自己的名次(rank),订阅名次变化
backend-service提交(submit)结果;在提交前 hook 中修正或拒绝结果;周期结束时发放奖励
operator声明排行榜;提前结束周期、修正记录(留有审计)、监控提交频率

概览

声明 weekly-score:所有者、聚合方式、每周一重置、由服务端提交、排序键
[Leaderboard("weekly-score")]
public static class WeeklyScore
{
    public static Owner Owner = Owner.Player;
    public static Aggregation Agg = Aggregation.Best;              // set · best · increment · decrement
    public static Reset Reset = Reset.Weekly(DayOfWeek.Monday);    // Monday 00:00 UTC
    public static Submit Submit = Submit.ServerOnly;               // the default — clients are refused

    [Rank(1, Sort.Descending)] public static int Score;            // ranks first, high to low
    [Rank(2, Sort.Ascending)]  public static int ElapsedMs;        // equal scores: the faster run wins
    [Display]                  public static string Map;           // travels with the row, never ranks it
}
@Leaderboard('weekly-score')
export class WeeklyScore {
  static owner = Owner.Player;
  static agg = Aggregation.Best;                   // set · best · increment · decrement
  static reset = Reset.weekly(DayOfWeek.Monday);   // Monday 00:00 UTC
  static submit = Submit.ServerOnly;               // the default — clients are refused

  @rank(1, Sort.Descending) static score: number;      // ranks first, high to low
  @rank(2, Sort.Ascending) static elapsedMs: number;   // equal scores: the faster run wins
  @display() static map: string;                       // travels with the row, never ranks it
}
@leaderboard("weekly-score")
class WeeklyScore:
    owner = Owner.PLAYER
    agg = Aggregation.BEST                        # set · best · increment · decrement
    reset = Reset.weekly(DayOfWeek.MONDAY)        # Monday 00:00 UTC
    submit = Submit.SERVER_ONLY                   # the default — clients are refused

    score: int = rank(1, Sort.DESCENDING)         # ranks first, high to low
    elapsed_ms: int = rank(2, Sort.ASCENDING)     # equal scores: the faster run wins
    map: str = display()                          # travels with the row, never ranks it
即将推出 —— Go

Go 中基于特性的声明方式尚未确定(O-1),确定写法后会补上示例。

USTRUCT(PSLeaderboard = (Name = "weekly-score", Owner = "Player", Aggregation = "Best",
                        Reset = "Weekly:Monday", Submit = "ServerOnly"))
struct FWeeklyScore
{
    GENERATED_BODY()

    UPROPERTY(PSRank = (Order = 1, Sort = "Descending")) int32   Score;      // ranks first, high to low
    UPROPERTY(PSRank = (Order = 2, Sort = "Ascending"))  int32   ElapsedMs;  // equal scores: the faster run wins
    UPROPERTY(PSDisplay)                                 FString Map;        // travels with the row, never ranks it
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — 同一个模板类可以在 Unity 中编译,基线为 2021.3,这里没有用 C# 12 语法
[Leaderboard("weekly-score")]
public static class WeeklyScore
{
    public static Owner Owner = Owner.Player;
    public static Aggregation Agg = Aggregation.Best;              // set · best · increment · decrement
    public static Reset Reset = Reset.Weekly(DayOfWeek.Monday);    // Monday 00:00 UTC
    public static Submit Submit = Submit.ServerOnly;               // the default — clients are refused

    [Rank(1, Sort.Descending)] public static int Score;            // ranks first, high to low
    [Rank(2, Sort.Ascending)]  public static int ElapsedMs;        // equal scores: the faster run wins
    [Display]                  public static string Map;           // travels with the row, never ranks it
}

声明和你的其他 schema 放在一起,可以在服务端项目里,也可以在 UE 或 Unity 项目里;推送会编译它并上传:排行榜出现在管理面板中,内容为空,下一次重置(reset)已经排好。计划使用 UTC,所以这个排行榜在周一 00:00 UTC 结束;用 Reset.Weekly(DayOfWeek.Monday, at: "03:00") 可以调整时刻。重置不支持按玩家本地时间:同一张榜单不可能在二十四个不同时刻结束。

排序键是一个列表,而不是“分数加一条平局规则”。字段按你编的序号依次参与排名,各有各的方向;最后一层归平台:键相同时,先提交的排名更高,所以两次相同的成绩在两次读取之间永远不会互换位置。不在键中的字段(这里是 Map)只用于展示,从不改变行的位置。

Agg 决定第二次提交如何作用于某个所有者在当前周期中唯一的那条记录:

Agg第二次提交幂等
Set用提交的值替换记录是
Best仅当新值按排序键排得更高时才替换是
Increment把提交的值加到记录上:击杀数、圈数、公会贡献否:需带幂等键(idempotency key)
Decrement减去这些值否:需带幂等键

没超过 Best 记录的提交不算错误:它返回已接受,排序不变。重试时会重复生效的正是 Increment 和 Decrement,所以它们和其他所有可重试的写入一样,要带幂等键。

提交只需一次调用;在这个排行榜上,调用来自服务端代码,因为声明就是这么规定的:

一次 Submit:两个排名字段和一个展示字段,由掌握结果的函数发出
await PlayServ.Leaderboards.Submit("weekly-score", playerId,
    score: 4200, elapsedMs: 61230, map: "caves");
await PlayServ.leaderboards.submit('weekly-score', playerId,
  { score: 4200, elapsedMs: 61230, map: 'caves' });
await playserv.leaderboards.submit("weekly-score", player_id,
    score=4200, elapsed_ms=61230, map="caves")
即将推出 —— Go

Go 中基于特性的声明方式尚未确定(O-1),确定写法后会补上示例。

由其他主体调用

Unreal 中也有这个调用。这个排行榜保留默认的 Submit.ServerOnly,所以平台只接受云函数(cloud function)或凭主机密钥提交的房间主机发来的提交。声明 Submit.Players 后,同一个调用在客户端也能用。参见 Access & Roles。

由其他主体调用

Unity 中也有这个调用。这个排行榜保留默认的 Submit.ServerOnly,所以平台只接受云函数(cloud function)或凭主机密钥提交的房间主机发来的提交。声明 Submit.Players 后,同一个调用在客户端也能用。参见 Access & Roles。

这个调用里有三点值得单独说明:

调用中的内容含义
PlayServ · playserv云函数句柄,以及 SDK 启动时交给你的客户端实例。API 相同,调用方不同:在 Go 中它们是 ps 和 psv,每段示例都用其调用方手里的那一个
提交方掌握比赛结果的函数。在 Tanks 中是房间的 on dispose hook(Rooms),它运行时手里就有最终状态
playerId来自 Auth & Players 的平台玩家 id,而不是你自己取的名字:hook 从 payload 中读取它(Tanks 中的排行榜里的 e.By.PlayerId),房间主机则提交它所拥有席位的 id

值就是声明中列出的字段:平台拒绝未声明的字段,而不是把它存下来。

每个游戏都需要的读取,以及让它们保持最新的订阅:

前 100 名、我附近的窗口、按所有者列表读取公会的行,以及实时名次订阅
var top     = await playserv.Leaderboards.Top("weekly-score", 100);
var around  = await playserv.Leaderboards.AroundMe("weekly-score", 5);
var members = await playserv.Group("guild-42").GetMembers();
var guild   = await playserv.Leaderboards.ForOwners("weekly-score", members);

var live = playserv.Leaderboards.OnRankChanged("weekly-score", r => UpdateHud(r.Rank, r.Score));
live.Cancel();                                    // later, when the HUD closes
const top     = await playserv.leaderboards.top('weekly-score', 100);
const around  = await playserv.leaderboards.aroundMe('weekly-score', 5);
const members = await playserv.group('guild-42').getMembers();
const guild   = await playserv.leaderboards.forOwners('weekly-score', members);

const live = playserv.leaderboards.onRankChanged('weekly-score', (r) => updateHud(r.rank, r.score));
live.cancel();                                    // later, when the HUD closes
top     = await playserv.leaderboards.top("weekly-score", 100)
around  = await playserv.leaderboards.around_me("weekly-score", 5)
members = await playserv.group("guild-42").get_members()
guild   = await playserv.leaderboards.for_owners("weekly-score", members)

live = playserv.leaderboards.on_rank_changed("weekly-score", lambda r: update_hud(r.rank, r.score))
live.cancel()                                     # later, when the HUD closes
即将推出 —— Go

Go 中基于特性的声明方式尚未确定(O-1),确定写法后会补上示例。

Client->Leaderboards->Of<FWeeklyScore>()->Get(
    TPSOnResult<FPSBoard*>::CreateWeakLambda(this, [this](const TPSResult<FPSBoard*>& Result)
    {
        if (!Result.HasValue()) { return; }
        OnBoard(Result.Value());
    }));

// in OnBoard(FPSBoard* Board): the page, the window, and the guild rows
Board->Entries->Select().Page(100).Then(
    TPSOnResult<TPSPage<FPSLeaderboardEntry>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<FPSLeaderboardEntry>>& Top)
    {
        if (!Top.HasValue()) { return; }
        Hud->ShowTop(Top.Value().Rows);
    }));

Board->Entries->SelectAround(MyPlayerId, /*Radius*/ 5,
    TPSOnResult<TArray<FPSLeaderboardEntry>>::CreateWeakLambda(this, [this](const TPSResult<TArray<FPSLeaderboardEntry>>& Around)
    {
        if (!Around.HasValue()) { return; }
        Hud->ShowWindow(Around.Value());
    }));

// guild rows: the member list first, then the entries for exactly those owners
Guild->Members->Select().Then(
    TPSOnResult<TArray<FPSMember>>::CreateWeakLambda(this, [this](const TPSResult<TArray<FPSMember>>& Members)
    {
        if (!Members.HasValue()) { return; }
        TArray<FPSPlayerId> Owners;
        for (const FPSMember& Member : Members.Value()) { Owners.Add(Member.PlayerId); }
        Board->Entries->Select().ForOwners(Owners).Then(OnGuildRows);
    }));

TPSSubscription MyRank = Board->Subscribe->Mine(
    [this](const FPSLeaderboardEntry& Mine) { UpdateHud(Mine.Rank, Mine.Score); });
MyRank.Unsubscribe();                             // later, when the HUD closes
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
Unity C# is the same C# API here — 在 Unity 中基于生成的类型原样运行
var top     = await playserv.Leaderboards.Top("weekly-score", 100);
var around  = await playserv.Leaderboards.AroundMe("weekly-score", 5);
var members = await playserv.Group("guild-42").GetMembers();
var guild   = await playserv.Leaderboards.ForOwners("weekly-score", members);

var live = playserv.Leaderboards.OnRankChanged("weekly-score", r => UpdateHud(r.Rank, r.Score));
live.Cancel();                                    // later, when the HUD closes

AroundMe("weekly-score", 5) 是按名次划定的窗口,而不是分页:你上方五行、下方五行,再加你自己,共十一行;到了榜单边缘,两侧对称截短,所以第 2 名拿到的是两侧都变短的窗口,而不是平移后的窗口。Top 是分页的:它返回前 N 行和一个游标,用 after: 遍历其余部分。

好友榜就是用 ForOwners 实现的。平台不保存好友关系图;你传入游戏里已有的所有者,比如某个群组的成员,或你自己数据中的一组 id。每一行返回的都是它在完整榜单中的名次,而不是在列表中的名次。

OnRankChanged 只推送本地玩家自己的名次,别的都不推:一个有五万名参赛者的排行榜,不会把每次重排都推给每个客户端。回调收到变化的那一行(名次、排名字段、展示字段),Cancel() 结束订阅。名次本身是快照:提交陆续写入时,相隔一秒的两次读取可能不同,但你自己的提交在你下一次读取中总是可见。

模型

排行榜声明什么。

维度取值设置方式
所有者玩家 · 群组Owner = Owner.Player:公会榜就是同一个排行榜,改用 Owner.Group
排序键一个或多个已声明字段,各自升序或降序[Rank(1, Sort.Descending)] int Score
聚合方式替换 · 取最佳 · 累加 · 递减Agg = Aggregation.Best
重置UTC 计划;周期到期而不删除Reset = Reset.Weekly(DayOfWeek.Monday)
谁可以提交仅服务端(默认)· 玩家Submit = Submit.ServerOnly
展示字段需声明且有类型;从不参与排序[Display] string Map
所有者列表读取时选择,无需声明ForOwners("weekly-score", ids):好友、公会、大厅
锦标赛规则报名窗口 · 参赛人数上限 · 每周期尝试次数 · 必须报名Rules = Tournament.Define(…),见 锦标赛 下的表格

没有作用域这个维度:按地区、按房间或按赛季分榜,就是每个榜各用一个 key,你的代码引用的就是这个 key。

每个排行榜始终成立的事实。

始终成立含义
方向与聚合运算符首次写入后不可更改:改了就会悄悄重排历史;想改变机制,应该开新的一代(generation),而不是编辑
每个所有者每一代只有一个条目第二次提交不会产生第二行
条目不是实体:没有自己的生命周期,也没有状态机。它由首次提交创建,此后由排行榜声明的聚合运算符修改
排序键之外的字段从不影响排序它们用于展示,正因如此才单独声明
一代到期而不删除:open → expired → evicted from retention,到期的代在声明的保留期内仍然可读
排行榜是创作内容:在代码中声明,按 key 寻址,以 seed 所有权模式进入管理控制台,所以设计师对计划的修改在下次推送后依然保留
a submit, from server codeyour pre-submit hook: accept, correct or rejectthe open cycle, ranked by the order keythe reset (Monday 00:00 UTC) closes itthe closed cycle, readable under its labelthe next cycle opens, emptyyour cycle-closed hook: reads by the label, grants rewardsevicted when retention ends, with an event acceptedthe close event carries the label
一个周期:提交先经过你的拦截型 hook,重置结束榜单,周期结束 hook 按标签读取它,同时下一个周期从空榜开始

周期是什么,结束一个周期会发生什么。

含义
重置结束一个周期,而不是删除它
已结束的周期不再接受提交,并以其标签保持可读:Top("weekly-score", 100, cycle: label),这是一个读取参数,而不是导出任务
结束事件携带该标签,所以处理器读取的恰好是刚结束的榜单,而不是刚开始的空榜

排行榜上的两个 hook,类型各不相同。

Hook允许做什么
pre-submit一个拦截型 hook(gatekeeper),在写入前调用,平台会等它返回。它返回三种裁决之一:接受、接受修正后的提交,或拒绝。它可以对照你自己的实体修正数值,或给数值封顶;但不能更改记录的所有者或所属排行榜,这两者已经确定。它失败即拦截(fail-closed),拒绝会以有类型的 Problem(Core)送达调用方
cycle-closed一个观察型 hook(observer):事后触发,不能否决,它失败了周期也照样结束
weekly-score 上的两个 hook:pre-submit 拒绝不可能的分数,cycle-closed 给前 10 名发奖
[Before(Leaderboards.Submit, board: "weekly-score")]
public static Verdict Validate(Submission s) =>
    s.Score > 10_000 ? s.Reject("score above the map maximum") : s.Accept();

[After(Leaderboards.CycleClosed, board: "weekly-score")]
public static async Task Reward(CycleClosed closed)
{
    var final = await PlayServ.Leaderboards.Top("weekly-score", 10, cycle: closed.Cycle);
    foreach (var row in final)
        await PlayServ.Commerce.Grant(row.PlayerId, entitlement: "chest.gold", origin: Grant.Reward);
}
export const validate = before(Leaderboards.submit, { board: 'weekly-score' },
  (s: Submission) => s.score > 10_000 ? s.reject('score above the map maximum') : s.accept());

export const reward = after(Leaderboards.cycleClosed, { board: 'weekly-score' },
  async (closed: CycleClosed) => {
    const final = await PlayServ.leaderboards.top('weekly-score', 10, { cycle: closed.cycle });
    for (const row of final)
      await PlayServ.commerce.grant(row.playerId, { entitlement: 'chest.gold', origin: Grant.Reward });
  });
@before(leaderboards.submit, board="weekly-score")
def validate(s: Submission) -> Verdict:
    return s.reject("score above the map maximum") if s.score > 10_000 else s.accept()

@after(leaderboards.cycle_closed, board="weekly-score")
async def reward(closed: CycleClosed):
    final = await playserv.leaderboards.top("weekly-score", 10, cycle=closed.cycle)
    for row in final:
        await playserv.commerce.grant(row.player_id, entitlement="chest.gold", origin=Grant.REWARD)
即将推出 —— Go

Go 中基于特性的声明方式尚未确定(O-1),确定写法后会补上示例。

在引擎之外运行

hook 就是云函数:它在平台上执行,不在引擎里。用 C#、TypeScript 或 Python 编写,Unreal 订阅周期结束事件。引擎内编写函数的方式已在计划中:Blueprint 图,以及 C++(转译为经平台校验的脚本目标)。两者都像其他声明一样经过分析并推送,执行仍留在平台上。

在引擎之外运行

hook 就是云函数:它在平台上执行,不在引擎里。用 C#、TypeScript 或 Python 编写,Unity 订阅周期结束事件。

奖励是一次 Catalog & Commerce 发放,而不是本模块的机制:chest.gold 是商品目录 id,reward 来源把这次发放与购买区分开。退款、收回和权益变更事件对它的作用,与对购买的物品(item)完全相同。

锦标赛。 锦标赛就是这个排行榜加上参赛约束:没有第二套机制,也没有单独的实体。以下是四项约束,以及它们的单位和边界行为:

约束声明方式边界行为
报名窗口entryWindow: TimeSpan:周期开启后报名保持开放的时长拒绝窗口关闭后的报名;周期照常运行到重置
参赛人数上限maxEntrants: int:单个周期内的记录数64 人满员时第 65 人以冲突拒绝,而且不淘汰任何人:排行榜如果靠丢掉最差的行腾位置,排名就取决于谁先到
每周期尝试次数attemptsPerCycle: int:每个所有者的提交次数下一次提交返回“尝试次数已用完”:这是冲突,不是权限错误,计数随周期重置
必须报名joinRequired: true:参赛者是一种成员资格,而不是所有玩过的人拒绝非参赛者的提交

每日锦标赛示例完整声明了全部四项约束。

错误

player 会话调用这个排行榜保留给 fn 的操作(向仅限服务端的排行榜提交、提前结束周期)时,平台会在写入任何内容之前以权限错误拒绝;同样的调用从云函数发出就能通过。向已结束的周期提交则属于冲突,应该改为向当前周期重新提交。

限制

每项限制及其边界行为。

限制边界行为数值
每次读取的行数页面截短,“还有更多”保持为 true,用 after: 继续页面上限按项目设置
所有者周围的窗口对称截短窗口上限按项目设置
单个周期内的记录数提交以冲突拒绝;不淘汰每个排行榜的 maxEntrants;未设置时不限
每个所有者每周期的尝试次数冲突“尝试次数已用完”,重置后清零每个排行榜的 attemptsPerCycle;未设置时不限
每个所有者的提交频率限流拒绝,并附带允许重试的时刻频率按项目设置
每个项目的排行榜数量部署时拒绝新的声明上限按项目设置
已结束周期的保留周期移出存储并发出事件;之后读取返回 not-found保留窗口按项目设置