Aller au contenu principal
Exemples

Cette page n’est pas encore traduite : la version anglaise est affichée.

Tournoi quotidien

Un tournoi quotidien avec une période d’inscription (entry window), des salles préparées à l’avance et une remise de prix, construit uniquement avec des déclarations et des hooks sur des modules qui ont déjà leur page : classements, matchmaking, salles, commerce et messagerie.

Déclarer le classement avec une période d’inscription et des tentatives limitées

Un tournoi, c’est une déclaration de classement ordinaire, à laquelle s’ajoutent des contraintes de participation : une période d’inscription, un plafond de participants et un nombre de tentatives par cycle. Le calcul du score ne change pas : la clé de tri, l’agrégation et la réinitialisation restent celles de n’importe quel classement (leaderboard).

daily-tournament — score par ordre décroissant, réinitialisation quotidienne, période d’inscription de 2 heures, 64 participants, trois tentatives
[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)
Bientôt — Go

La déclaration par attributs en Go n’est pas encore tranchée (O-1). Les exemples arriveront quand la forme d’écriture sera choisie.

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 — les mêmes attributs C# ; un client Unity lit le tableau du tournoi à l’étape 3 et ne peut rien y soumettre
[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;
}

Publiez la déclaration : le panneau affiche un tableau de tournoi vide, avec sa période d’inscription planifiée. Avec joinRequired: true, les participants sont définis par une appartenance (membership) et non par le simple fait de jouer ; la soumission (submit) d’un joueur non inscrit est donc refusée. Le reste des axes, et le comportement de chaque contrainte à sa limite, sont décrits sur Leaderboards.

La période s’ouvre : un groupe s’inscrit, les salles sont préparées

Dès l’ouverture de la période d’inscription, les joueurs font la queue comme pour n’importe quel match : ils créent ou rejoignent un groupe, puis passent un seul appel Find. Le matchmaker place le groupe de joueurs (party) dans un tableau du tournoi, et Rooms prépare le match. C’est le même chemin de placement et d’attribution de place (seat) que pour tout autre match, limité à la file du tournoi.

Un groupe trouve la file du tournoi et rejoint la salle préparée pour lui
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)
Bientôt — Go

La déclaration par attributs en Go n’est pas encore tranchée (O-1). Les exemples arriveront quand la forme d’écriture sera choisie.

// 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 — fonctionne tel quel dans Unity avec les types générés
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);

Les plafonds de l’étape 1 appartiennent au classement, pas au matchmaker. La file ne fait que placer les groupes ; c’est le classement qui arrête un participant au-delà du plafond ou un joueur qui a épuisé ses tentatives. Le 65ᵉ participant pour 64 places est refusé pour conflit, sans que personne soit évincé. Une quatrième soumission dans le même cycle reçoit « attempts exhausted » : c’est aussi un conflit, que lève la réinitialisation quotidienne, et non une demande de permission.

Soumettre les scores depuis le hook de fermeture

Une salle (room) ne transmet pas d’elle-même son vainqueur à un classement : ce lien passe par un hook, soumis au même contrat que partout ailleurs (Extensibility). Le hook on dispose de la salle (Rooms) est le dernier code à s’exécuter avec l’état final du match en main ; c’est de là qu’il soumet le score.

Un hook [After] sur la fermeture de la salle soumet le score final du tournoi
[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)
Bientôt — Go

La déclaration par attributs en Go n’est pas encore tranchée (O-1). Les exemples arriveront quand la forme d’écriture sera choisie.

S’exécute hors du moteur

Un hook est une fonction cloud : il s’exécute sur la plateforme, pas dans le moteur. Écrivez-le en C#, TypeScript ou Python ; votre code Unreal s’abonne à l’événement rank changed qui en résulte. Des fonctions écrites dans le moteur sont prévues : des graphes Blueprint et du C++ (traduit vers une cible de script validée par la plateforme). Les deux seront analysés et publiés comme n’importe quelle déclaration, et l’exécution restera sur la plateforme.

S’exécute hors du moteur

Un hook est une fonction cloud : il s’exécute sur la plateforme, pas dans le moteur. Écrivez-le en C#, TypeScript ou Python ; votre code Unity s’abonne à l’événement rank changed qui en résulte.

Winner et FinalScore sont des champs que le modèle de salle (room template) de ce jeu déclare dans son état ; la plateforme n’ajoute rien au snapshot (Rooms explique comment déclarer l’état d’un modèle). Le hook de fermeture (Rooms.Disposed) reçoit le snapshot final, si bien que le match n’est jamais recalculé. Le hook de pré-soumission de Leaderboards passe toujours en premier : un score de tournoi obéit au même contrat que toute autre soumission — corriger, plafonner ou rejeter.

Le cycle se clôt : récompenses accordées, joueurs notifiés

La réinitialisation quotidienne de l’étape 1 clôt le cycle comme n’importe quelle réinitialisation de classement, et déclenche CycleClosed avec le libellé du cycle clos. Le hook lit le tableau par ce libellé : il obtient ainsi celui qui vient de se fermer, et non le tableau vide qui vient de s’ouvrir.

Un seul hook fait le reste. Il accorde le prix sous forme de droit d’usage (entitlement) via le module Catalog & Commerce et envoie le résultat par Messaging : aucune tâche de distribution des prix à faire tourner à part. Une notification s’adresse à un seul acteur ; le top 8 donne donc une boucle de huit envois, chacun passant son propre argument rank au modèle de message.

Le hook de clôture de cycle accorde un droit d’usage et notifie chacun des 8 premiers
[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})
Bientôt — Go

La déclaration par attributs en Go n’est pas encore tranchée (O-1). Les exemples arriveront quand la forme d’écriture sera choisie.

S’exécute hors du moteur

Un hook est une fonction cloud : il s’exécute sur la plateforme, pas dans le moteur. Écrivez-le en C#, TypeScript ou Python ; Unreal s’abonne aux événements de clôture de cycle et de notification. Des fonctions écrites dans le moteur sont prévues : des graphes Blueprint et du C++ (traduit vers une cible de script validée par la plateforme). Les deux seront analysés et publiés comme n’importe quelle déclaration, et l’exécution restera sur la plateforme.

S’exécute hors du moteur

Un hook est une fonction cloud : il s’exécute sur la plateforme, pas dans le moteur. Écrivez-le en C#, TypeScript ou Python ; Unity s’abonne aux événements de clôture de cycle et de notification.

Les nombres de l’étape 1 ne sont que des valeurs initiales

L’équipe live-ops ajuste la période d’inscription, le plafond de participants et le nombre de tentatives dans le panneau, et le déploiement suivant n’écrase pas ses réglages. Une nouvelle publication laisse les valeurs seed intactes ; si vous les modifiez plus tard dans le code, le changement n’atteindra pas le classement déployé (Le schéma dans le code).

Pour en faire un tournoi hebdomadaire, une seule modification suffit : Reset.Daily() devient Reset.Weekly(DayOfWeek.Monday), et les étapes 2 à 4 restent telles quelles.

Pour aller plus loin