Перейти к основному содержимому
Примеры

Эта страница ещё не переведена — показана английская версия.

Ежедневный турнир

Ежедневный турнир с окном регистрации, рассадкой участников по комнатам и выплатой призов. Он собран из объявлений (declaration) и хуков на модулях, у которых уже есть свои страницы: таблицы лидеров, подбор игроков, комнаты, коммерция и сообщения.

Объявите таблицу с окном регистрации и лимитом попыток

Турнир — это обычное объявление таблицы лидеров (leaderboard) плюс ограничения на участие: окно регистрации, лимит участников и число попыток за цикл. Подсчёт очков не меняется: ключ сортировки, агрегация и сброс — такие же, как у любой таблицы.

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.

Окно открылось: пати записывается, участников рассаживают по комнатам

Когда окно регистрации открывается, игроки (player) встают в очередь так же, как на любой матч: создают пати или присоединяются к ней, а затем делают один вызов Find. Матчмейкер помещает пати в турнирную сетку, а модуль Rooms готовит матч. Путь тот же, что и у любого матча, — размещение и место (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 принадлежат таблице, а не матчмейкеру. Очередь только размещает пати, а участника сверх лимита или игрока, у которого кончились попытки, останавливает таблица. 65-й участник при лимите 64 получает отказ с конфликтом, и никого ради него не вытесняют. Четвёртая отправка за цикл получает ответ «attempts exhausted» — это тоже конфликт. Снимает его ежедневный сброс, а не запрос разрешения (permission).

Результат отправляет хук закрытия комнаты

Комната (room) сама не сообщает победителя в таблицу лидеров. Эту связь создаёт хук, и правила для него те же, что и везде (Extensibility). Хук комнаты on dispose (Rooms) выполняется последним, пока итоговое состояние матча ещё под рукой, — оттуда он и отправляет результат.

Хук [After] на закрытие комнаты отправляет итоговый счёт в турнирную сетку
[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). Примеры появятся, когда выберут способ записи.

Выполняется вне движка

Хук — это облачная функция: он выполняется на платформе, а не в движке. Напишите его на C#, TypeScript или Python, а ваш код на Unreal подпишется на событие rank changed, которое он порождает. В планах — функции, написанные прямо в движке: графы Blueprint и C++, который будет транслироваться в скрипт, проверяемый платформой. Их будут анализировать и загружать так же, как любые объявления, а выполняться они по-прежнему будут на платформе.

Выполняется вне движка

Хук — это облачная функция: он выполняется на платформе, а не в движке. Напишите его на C#, TypeScript или Python, а ваш код на Unity подпишется на событие rank changed, которое он порождает.

Winner и FinalScore — поля, которые шаблон комнаты (room template) этой игры объявляет в своём состоянии. Сама платформа в снапшот ничего не добавляет, а как объявляется состояние шаблона, описано на странице Rooms. Хук закрытия (Rooms.Disposed) получает итоговый снапшот, поэтому матч никогда не пересчитывается. Хук перед отправкой из Leaderboards по-прежнему срабатывает первым: к результату турнира применяются те же правила, что и к любой другой отправке, — его либо исправляют и урезают до лимита, либо отклоняют.

Цикл закрыт: награды выданы, игроки оповещены

Ежедневный сброс из шага 1 закрывает цикл так же, как любой сброс таблицы лидеров, и порождает событие CycleClosed с меткой закрытого цикла. Хук читает таблицу по этой метке и получает ту, что только что закрылась, а не пустую, которая только что открылась.

Остальное делает один хук. Он выдаёт приз как право владения (entitlement) через модуль Catalog & Commerce и отправляет результат через Messaging, так что отдельной задачи на выплату нет. Уведомление адресуется одному актору, поэтому топ-8 оповещается в цикле по одному, и каждое уведомление передаёт в шаблон свой аргумент rank.

Хук закрытия цикла выдаёт право владения и уведомляет каждого из топ-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). Примеры появятся, когда выберут способ записи.

Выполняется вне движка

Хук — это облачная функция: он выполняется на платформе, а не в движке. Напишите его на C#, TypeScript или Python, а Unreal подпишется на события закрытия цикла и уведомлений. В планах — функции, написанные прямо в движке: графы Blueprint и C++, который будет транслироваться в скрипт, проверяемый платформой. Их будут анализировать и загружать так же, как любые объявления, а выполняться они по-прежнему будут на платформе.

Выполняется вне движка

Хук — это облачная функция: он выполняется на платформе, а не в движке. Напишите его на C#, TypeScript или Python, а Unity подпишется на события закрытия цикла и уведомлений.

Числа из шага 1 — только начальные значения

Команда live-ops перенастраивает в панели окно, лимит участников и число попыток, и следующий деплой её правки не затрёт. Повторная загрузка не трогает значения seed, поэтому если позже поменять их в коде, до развёрнутой таблицы это не дойдёт (Схема в коде).

Чтобы турнир стал еженедельным, достаточно одной правки: заменить Reset.Daily() на Reset.Weekly(DayOfWeek.Monday). Шаги 2–4 остаются как есть.

Что дальше