Эта страница ещё не переведена — показана английская версия.
Ежедневный турнир
Ежедневный турнир с окном регистрации, рассадкой участн иков по комнатам и выплатой призов. Он собран из объявлений (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, пока не решено (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[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, пока не решено (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.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, пока не решено (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.
[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, пока не решено (O-1). Примеры появятся, когда выберут способ записи.
Хук — это облачная функция: он выполняется на платформе, а не в движке. Напишите его на C#, TypeScript или Python, а Unreal подпишется на события закрытия цикла и уведомлений. В планах — функции, написанные прямо в движке: графы Blueprint и C++, который будет транслироваться в скрипт, проверяемый платформой. Их будут анализировать и загружать так же, как любые объявления, а выполняться они по-прежнему будут на платформе.
Хук — это облачная функция: он выполняется на платформе, а не в движке. Напишите его на C#, TypeScript или Python, а Unity подпишется на события закрытия цикла и уведомлений.
Числа из шага 1 — только начальные значения
Команда live-ops перенастраивает в панели окно, лимит участников и число попыток, и следующий деплой её правки не затрёт. Повторная загрузка не трогает значения seed, поэтому если позже поменять их в коде, до развёрнутой таблицы это не дойдёт (Схема в коде).
Чтобы турнир стал ежене дельным, достаточно одной правки: заменить Reset.Daily() на Reset.Weekly(DayOfWeek.Monday). Шаги 2–4 остаются как есть.
Что дальше
- Leaderboards — оси турнира (окно регистрации, лимит участников, попытки).
- Matchmaking → Rooms — пати, размещение и рассадка по комнатам.
- Extensibility → Catalog & Commerce → Messaging — цепочка хуков, которая выплачивает призы.
- Основные понятия — словарь, на который опирается каждая страница модуля.