跳到主要内容
示例

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

每日锦标赛

一个带报名窗口、按分组开设房间并发放奖品的每日锦标赛。它完全由声明(declaration)和 hook 搭成,用到的模块都已有专门页面:排行榜、匹配、房间、商业和消息。

声明带报名窗口和次数限制的榜单

锦标赛就是一份普通的 排行榜(leaderboard)声明,外加参赛限制:报名窗口(entry window)、参赛人数上限和每个周期的尝试次数。计分方式不变:排序键、聚合方式和重置规则与其他榜单相同。

daily-tournament:按分数降序,每日重置,报名窗口 2 小时,64 名参赛者,三次尝试
[Leaderboard("daily-tournament")]
public static class DailyTournament
{
    public static Owner Owner = Owner.Player;
    public static Aggregation Agg = Aggregation.Best;
    public static Reset Reset = Reset.Daily();                 // 00:00 UTC
    public static Submit Submit = Submit.ServerOnly;
    public static Tournament Rules = Tournament.Define(
        entryWindow: TimeSpan.FromHours(2), maxEntrants: 64,
        attemptsPerCycle: 3, joinRequired: true);

    [Rank(1, Sort.Descending)] public static int Score;
}
@Leaderboard('daily-tournament')
export class DailyTournament {
  static owner = Owner.Player;
  static agg = Aggregation.Best;
  static reset = Reset.daily();                    // 00:00 UTC
  static submit = Submit.ServerOnly;
  static rules = Tournament.define({ entryWindow: hours(2), maxEntrants: 64,
                                     attemptsPerCycle: 3, joinRequired: true });

  @rank(1, Sort.Descending) static score: number;
}
@leaderboard("daily-tournament")
class DailyTournament:
    owner = Owner.PLAYER
    agg = Aggregation.BEST
    reset = Reset.daily()                          # 00:00 UTC
    submit = Submit.SERVER_ONLY
    rules = Tournament.define(entry_window=hours(2), max_entrants=64,
                              attempts_per_cycle=3, join_required=True)

    score: int = rank(1, Sort.DESCENDING)
即将推出 —— Go

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

USTRUCT(PSLeaderboard = (Name = "daily-tournament", Owner = "Player", Aggregation = "Best",
                        Reset = "Daily", Submit = "ServerOnly"),
        PSTournament = (EntryWindow = "2h", MaxEntrants = 64,
                        AttemptsPerCycle = 3, JoinRequired = "true"))
struct FDailyTournament
{
    GENERATED_BODY()

    UPROPERTY(PSRank = (Order = 1, Sort = "Descending")) int32 Score;
};
// declarations compile into the same pushed model — playserv push from the UE project or CI
Unity C# is the same C# API here — 使用相同的 C# 特性;Unity 客户端在第 3 步读取赛程分组,但无法向它提交
[Leaderboard("daily-tournament")]
public static class DailyTournament
{
    public static Owner Owner = Owner.Player;
    public static Aggregation Agg = Aggregation.Best;
    public static Reset Reset = Reset.Daily();                 // 00:00 UTC
    public static Submit Submit = Submit.ServerOnly;
    public static Tournament Rules = Tournament.Define(
        entryWindow: TimeSpan.FromHours(2), maxEntrants: 64,
        attemptsPerCycle: 3, joinRequired: true);

    [Rank(1, Sort.Descending)] public static int Score;
}

推送后,面板上会出现一个空的赛程分组,报名窗口已排好。joinRequired: true 让参赛者成为一份成员资格(membership)名单,而不是所有玩过的人,因此未报名玩家的提交会遭到拒绝。其余维度,以及每项限制到达边界时的行为,见 Leaderboards。

窗口开启:小队报名,房间就位

报名窗口一开,玩家就像参加普通比赛一样排队:创建或加入一个 小队(party),然后调用一次 Find。匹配器把小队分进锦标赛分组,Rooms 模块为比赛开好房间:走的是每场比赛都用的分配(placement)和席位(seat)流程,只是范围限定在锦标赛队列内。

小队找到锦标赛队列,进入为它开好的房间
var party = await playserv.Matchmaking.Party.Create();
await party.Invite(friendId);
var seat = await playserv.Matchmaking.Find("daily-tournament");
var room = await playserv.Rooms.Join(seat);
const party = await playserv.matchmaking.party.create();
await party.invite(friendId);
const seat = await playserv.matchmaking.find('daily-tournament');
const room = await playserv.rooms.join(seat);
party = await playserv.matchmaking.party.create()
await party.invite(friend_id)
seat = await playserv.matchmaking.find("daily-tournament")
room = await playserv.rooms.join(seat)
即将推出 —— Go

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

// a party first; the ticket then carries the party
Client->Matchmaking->Parties->Create(FPSIdempotencyKey(PartyId),
    TPSOnResult<FPSParty*>::CreateWeakLambda(this, [this](const TPSResult<FPSParty*>& PartyResult)
    {
        if (!PartyResult.HasValue()) { return; }
        FPSParty* Party = PartyResult.Value();
        Party->Invitations->Create(FriendId);

        Client->Matchmaking->Of<FDailyTournament>()->Tickets->Create(FPSTicketClaim{ .Party = Party },
            TPSOnResult<FPSTicket*>::CreateWeakLambda(this, [this](const TPSResult<FPSTicket*>& TicketResult)
            {
                if (!TicketResult.HasValue()) { return; }
                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; }
                            EnterTournament(JoinResult.Value());
                        }));
                });
            }));
    }));
// 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 party = await playserv.Matchmaking.Party.Create();
await party.Invite(friendId);
var seat = await playserv.Matchmaking.Find("daily-tournament");
var room = await playserv.Rooms.Join(seat);

第 1 步的上限属于榜单,而不属于匹配器:队列只负责分配小队,拦下超员参赛者或次数用尽的玩家的是榜单。上限 64 人时,第 65 名参赛者会以冲突被拒,已有参赛者不受影响;同一周期内的第四次提交会得到“attempts exhausted”,同样是冲突,要等每日重置才会解除,申请权限也没用。

由 on-dispose hook 提交成绩

房间(room)不会自己把胜者报给排行榜,两者靠一个 hook 连接,遵循与其他地方相同的 Extensibility 约定。房间的 on dispose hook(Rooms)是最后一个拿着比赛最终状态运行的环节,成绩就从这里提交。

房间销毁时的 [After] hook 提交该分组的最终成绩
[After(Rooms.Disposed, room: "daily-tournament")]
public static Task SubmitScore(RoomDisposed e) =>
    PlayServ.Leaderboards.Submit("daily-tournament", e.State.Winner, score: e.State.FinalScore);
export const submitScore = after(Rooms.disposed, { room: 'daily-tournament' }, (e: RoomDisposed) =>
  PlayServ.leaderboards.submit('daily-tournament', e.state.winner, { score: e.state.finalScore }));
@after(rooms.disposed, room="daily-tournament")
async def submit_score(e: RoomDisposed):
    await playserv.leaderboards.submit("daily-tournament", e.state.winner, score=e.state.final_score)
即将推出 —— Go

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

在引擎之外运行

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

在引擎之外运行

hook 是云函数,在平台上执行,不在引擎里执行。用 C#、TypeScript 或 Python 编写,Unity 代码订阅由此产生的 rank changed 事件。

Winner 和 FinalScore 是这款游戏自己的房间模板(room template)在状态中声明的字段,平台不会往快照里添加任何内容(模板状态在 Rooms 中声明)。销毁 hook(Rooms.Disposed)直接交出最终快照,所以比赛永远不必重新计算。Leaderboards 中的提交前 hook 仍然先运行:分组成绩和其他提交一样,要么修正、封顶,要么拒绝。

周期关闭:发放奖励,通知玩家

第 1 步设定的每日重置和其他排行榜重置一样关闭周期(cycle),并触发携带已关闭周期标签的 CycleClosed。hook 按这个标签读取榜单,所以拿到的是刚关闭的那张,而不是刚开启的空榜。

剩下的事一个 hook 就够了:它通过 Catalog & Commerce 的权益(entitlement)流程发放奖品,再通过 Messaging 推送结果,无需另外运行发奖任务。一条通知只发给一个主体(actor),所以前 8 名就是循环八次,每次把各自的 rank 参数传入模板。

周期关闭 hook 发放权益,并逐一通知前 8 名
[After(Leaderboards.CycleClosed, board: "daily-tournament")]
public static async Task RewardAndNotify(CycleClosed closed)
{
    var final = await PlayServ.Leaderboards.Top("daily-tournament", 8, cycle: closed.Cycle);
    foreach (var row in final)
    {
        await PlayServ.Commerce.Grant(row.PlayerId, entitlement: "trophy.daily", origin: Grant.Reward);
        await PlayServ.Messaging.Notify(row.PlayerId, Template.Named("daily-tournament-won"),
                                        args: new { rank = row.Rank });
    }
}
export const rewardAndNotify = after(Leaderboards.cycleClosed, { board: 'daily-tournament' },
  async (closed: CycleClosed) => {
    const final = await PlayServ.leaderboards.top('daily-tournament', 8, { cycle: closed.cycle });
    for (const row of final) {
      await PlayServ.commerce.grant(row.playerId, { entitlement: 'trophy.daily', origin: Grant.Reward });
      await PlayServ.messaging.notify(row.playerId, Template.named('daily-tournament-won'),
        { args: { rank: row.rank } });
    }
  });
@after(leaderboards.cycle_closed, board="daily-tournament")
async def reward_and_notify(closed: CycleClosed):
    final = await playserv.leaderboards.top("daily-tournament", 8, cycle=closed.cycle)
    for row in final:
        await playserv.commerce.grant(row.player_id, entitlement="trophy.daily", origin=Grant.REWARD)
        await playserv.messaging.notify(row.player_id, Template.named("daily-tournament-won"),
                                        args={"rank": row.rank})
即将推出 —— Go

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

在引擎之外运行

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

在引擎之外运行

hook 是云函数,在平台上执行,不在引擎里执行。用 C#、TypeScript 或 Python 编写,Unity 订阅周期关闭事件和通知事件。

第 1 步中的数值只是初始值

live-ops 团队在面板里调整窗口、参赛人数上限和尝试次数后,下一次部署不会覆盖这些修改:重复推送不会改动 seed 值,所以之后在代码里改这些值,也不会影响已部署的榜单(Schema 即代码)。

要改成每周锦标赛,只需改一处:把 Reset.Daily() 换成 Reset.Weekly(DayOfWeek.Monday),第 2 到第 4 步保持不变。

后续阅读