1. Introduction
Dernière mise à jour : 27/01/2023
Quelles sont les étapes à suivre pour créer un classement ?
En substance, les classements ne sont que des tableaux de scores avec un facteur de complication : pour lire un rang pour un score donné, il faut connaître tous les autres scores dans un certain ordre. De plus, si votre jeu décolle, vos classements deviendront volumineux et seront fréquemment lus et écrits. Pour qu'un classement soit efficace, il doit pouvoir gérer cette opération de classement rapidement.
Objectifs de l'atelier
Dans cet atelier de programmation, vous allez implémenter différents classements, chacun adapté à un scénario différent.
Points abordés
Vous apprendrez à implémenter quatre classements différents :
- Implémentation naïve utilisant un simple décompte des enregistrements pour déterminer le classement
- Un classement peu coûteux et mis à jour régulièrement
- Un classement en temps réel avec quelques bêtises d'arbres
- Classement stochastique (probabiliste) pour le classement approximatif de très grandes bases de joueurs
Prérequis
- Une version récente de Chrome (107 ou ultérieure)
- Node.js 16 ou version ultérieure (exécutez
nvm --version
pour afficher votre numéro de version si vous utilisez nvm) - Un forfait Blaze Firebase payant (facultatif)
- CLI Firebase version 11.16.0 ou ultérieure
Pour installer la CLI, vous pouvez exécuternpm install -g firebase-tools
ou consulter la documentation de la CLI pour découvrir d'autres options d'installation. - Connaissance de JavaScript, Cloud Firestore, Cloud Functions et des outils pour les développeurs Chrome
2. Configuration
Obtenir le code
Pour ce projet, nous avons regroupé tout ce dont vous avez besoin dans un dépôt Git. Pour commencer, vous devez récupérer le code et l'ouvrir dans l'environnement de développement de votre choix. Pour cet atelier de programmation, nous avons utilisé VS Code, mais n'importe quel éditeur de texte fera l'affaire.
et décompressez le fichier ZIP téléchargé.
Vous pouvez également cloner le dépôt dans le répertoire de votre choix :
git clone https://github.com/FirebaseExtended/firestore-leaderboards-codelab.git
Quel est notre point de départ ?
Notre projet est actuellement une page blanche avec des fonctions vides :
index.html
contient des scripts de colle qui nous permettent d'appeler des fonctions depuis la console de développement et d'afficher leurs sorties. Nous l'utiliserons pour interagir avec notre backend et afficher les résultats de nos appels de fonctions. Dans un scénario réel, vous effectuerez ces appels de backend directement depuis votre jeu. Nous n'utilisons pas de jeu dans cet atelier de programmation, car il faudrait trop de temps pour jouer à un jeu chaque fois que vous souhaitez ajouter un score au classement.functions/index.js
contient toutes nos fonctions Cloud Functions. Vous verrez des fonctions utilitaires, commeaddScores
etdeleteScores
, ainsi que les fonctions que nous allons implémenter dans cet atelier de programmation, qui appellent des fonctions d'assistance dans un autre fichier.functions/functions-helpers.js
contient les fonctions vides que nous allons implémenter. Pour chaque classement, nous allons implémenter des fonctions de lecture, de création et de mise à jour. Vous verrez comment notre choix d'implémentation affecte à la fois la complexité de notre implémentation et ses performances de scaling.functions/utils.js
contient davantage de fonctions utilitaires. Nous ne toucherons pas à ce fichier dans cet atelier de programmation.
Créer et configurer un projet Firebase
Créer un projet Firebase
- Connectez-vous à la console Firebase à l'aide de votre compte Google.
- Cliquez sur le bouton pour créer un projet, puis saisissez un nom de projet (par exemple,
Leaderboards Codelab
).
- Cliquez sur Continuer.
- Si vous y êtes invité, lisez et acceptez les Conditions d'utilisation de Firebase, puis cliquez sur Continuer.
- (Facultatif) Activez l'assistance IA dans la console Firebase (appelée "Gemini dans Firebase").
- Pour cet atelier de programmation, vous n'avez pas besoin de Google Analytics. Désactivez donc l'option Google Analytics.
- Cliquez sur Créer un projet, attendez que votre projet soit provisionné, puis cliquez sur Continuer.
Configurer les produits Firebase
- Dans le menu Compilation, cliquez sur Fonctions, puis, si vous y êtes invité, passez au forfait Blaze pour votre projet.
- Dans le menu Build (Créer), cliquez sur Firestore database (Base de données Firestore).
- Dans la boîte de dialogue Créer une base de données qui s'affiche, sélectionnez Commencer en mode test, puis cliquez sur Suivant.
- Choisissez une région dans le menu déroulant Emplacement Cloud Firestore, puis cliquez sur Activer.
Configurer et exécuter votre classement
- Dans un terminal, accédez à la racine du projet et exécutez
firebase use --add
. Sélectionnez le projet Firebase que vous venez de créer. - À la racine du projet, exécutez
firebase emulators:start --only hosting
. - Dans votre navigateur, accédez à
localhost:5000
. - Ouvrez la console JavaScript des outils pour les développeurs Chrome et importez
leaderboard.js
:const leaderboard = await import("http://localhost:5000/scripts/leaderboard.js");
- Exécutez
leaderboard.codelab();
dans la console. Si un message de bienvenue s'affiche, vous pouvez continuer. Si ce n'est pas le cas, arrêtez l'émulateur et répétez les étapes 2 à 4.
Commençons par implémenter le premier classement.
3. Implémenter un classement simple
À la fin de cette section, nous pourrons ajouter un score au classement et connaître notre position.
Avant de commencer, expliquons comment fonctionne cette implémentation du classement : tous les joueurs sont stockés dans une même collection. Pour récupérer le rang d'un joueur, il faut récupérer la collection et compter le nombre de joueurs qui le précèdent. Cela facilite l'insertion et la mise à jour d'un score. Pour insérer un nouveau score, il suffit de l'ajouter à la collection. Pour le mettre à jour, nous filtrons l'utilisateur actuel, puis nous mettons à jour le document obtenu. Voyons à quoi cela ressemble dans le code.
Dans functions/functions-helper.js
, implémentez la fonction createScore
, qui est aussi simple que possible :
async function createScore(score, playerID, firestore) {
return firestore.collection("scores").doc().create({
user: playerID,
score: score,
});
}
Pour mettre à jour les scores, il suffit d'ajouter une vérification des erreurs pour s'assurer que le score à mettre à jour existe déjà :
async function updateScore(playerID, newScore, firestore) {
const playerSnapshot = await firestore.collection("scores")
.where("user", "==", playerID).get();
if (playerSnapshot.size !== 1) {
throw Error(`User not found in leaderboard: ${playerID}`);
}
const player = playerSnapshot.docs[0];
const doc = firestore.doc(player.id);
return doc.update({
score: newScore,
});
}
Enfin, notre fonction de classement simple, mais moins évolutive :
async function readRank(playerID, firestore) {
const scores = await firestore.collection("scores")
.orderBy("score", "desc").get();
const player = `${playerID}`;
let rank = 1;
for (const doc of scores.docs) {
const user = `${doc.get("user")}`;
if (user === player) {
return {
user: player,
rank: rank,
score: doc.get("score"),
};
}
rank++;
}
// No user found
throw Error(`User not found in leaderboard: ${playerID}`);
}
Testons-la ! Déployez vos fonctions en exécutant la commande suivante dans le terminal :
firebase deploy --only functions
Ensuite, dans la console JS de Chrome, ajoutez d'autres scores pour que nous puissions voir votre classement par rapport aux autres joueurs.
leaderboard.addScores(); // Results may take some time to appear.
Nous pouvons maintenant ajouter notre propre score :
leaderboard.addScore(999, 11); // You can make up a score (second argument) here.
Une fois l'écriture terminée, vous devriez voir une réponse dans la console indiquant "Score créé". Un message d'erreur s'affiche ? Ouvrez les journaux des fonctions dans la console Firebase pour identifier le problème.
Enfin, nous pouvons récupérer et mettre à jour notre score.
leaderboard.getRank(999);
leaderboard.updateScore(999, 0);
leaderboard.getRank(999); // we should be last place now (11)
Toutefois, cette implémentation nous impose des exigences de temps et de mémoire linéaires indésirables pour récupérer le classement d'un score donné. Étant donné que le temps d'exécution et la mémoire des fonctions sont limités, non seulement nos récupérations deviendront de plus en plus lentes, mais une fois qu'un nombre suffisant de scores aura été ajouté au classement, nos fonctions expireront ou planteront avant de pouvoir renvoyer un résultat. Il est évident que nous aurons besoin d'une meilleure solution si nous voulons dépasser une poignée de joueurs.
Si vous êtes un passionné de Firestore, vous connaissez peut-être les requêtes d'agrégation COUNT, qui rendraient ce classement beaucoup plus performant. Et vous auriez raison ! Avec les requêtes COUNT, cette mise à l'échelle fonctionne bien pour un million d'utilisateurs environ, même si ses performances restent linéaires.
Mais attendez, vous vous dites peut-être que si nous allons de toute façon énumérer tous les documents de la collection, nous pouvons attribuer un rang à chaque document, puis, lorsque nous devrons le récupérer, nos récupérations seront en temps et en mémoire O(1) ! Cela nous amène à notre prochaine approche, le classement mis à jour périodiquement.
4. Implémenter un classement mis à jour périodiquement
La clé de cette approche est de stocker le rang dans le document lui-même. Ainsi, la récupération du rang ne nécessite aucun travail supplémentaire. Pour ce faire, nous aurons besoin d'un nouveau type de fonction.
Dans index.js
, ajoutez les éléments suivants :
// Also add this to the top of your file
const admin = require("firebase-admin");
exports.scheduledFunctionCrontab = functions.pubsub.schedule("0 2 * * *")
// Schedule this when most of your users are offline to avoid
// database spikiness.
.timeZone("America/Los_Angeles")
.onRun((context) => {
const scores = admin.firestore().collection("scores");
scores.orderBy("score", "desc").get().then((snapshot) => {
let rank = 1;
const writes = [];
for (const docSnapshot of snapshot.docs) {
const docReference = scores.doc(docSnapshot.id);
writes.push(docReference.set({rank: rank}, admin.firestore.SetOptions.merge()));
rank++;
}
Promise.all(writes).then((result) => {
console.log(`Writes completed with results: ${result}`);
});
});
return null;
});
Nos opérations de lecture, de mise à jour et d'écriture sont désormais simples et efficaces. Les autorisations d'écriture et de mise à jour restent inchangées, mais l'autorisation de lecture devient (dans functions-helpers.js
) :
async function readRank(playerID, firestore) {
const scores = firestore.collection("scores");
const playerSnapshot = await scores
.where("user", "==", playerID).get();
if (playerSnapshot.size === 0) {
throw Error(`User not found in leaderboard: ${playerID}`);
}
const player = playerSnapshot.docs[0];
if (player.get("rank") === undefined) {
// This score was added before our scheduled function could run,
// but this shouldn't be treated as an error
return {
user: playerID,
rank: null,
score: player.get("score"),
};
}
return {
user: playerID,
rank: player.get("rank"),
score: player.get("score"),
};
}
Malheureusement, vous ne pourrez pas déployer ni tester cela sans ajouter de compte de facturation à votre projet. Si vous disposez d'un compte de facturation, raccourcissez l'intervalle de la fonction planifiée et regardez votre fonction attribuer comme par magie des rangs aux scores de votre classement.
Si ce n'est pas le cas, supprimez la fonction planifiée et passez à l'implémentation suivante.
Pour préparer la section suivante, supprimez les scores de votre base de données Firestore en cliquant sur les trois points à côté de la collection de scores.
5. Implémenter un classement d'arbres en temps réel
Cette approche consiste à stocker les données de recherche dans la collection de base de données elle-même. Au lieu d'avoir une collection uniforme, notre objectif est de tout stocker dans une arborescence que nous pouvons parcourir en passant d'un document à l'autre. Cela nous permet d'effectuer une recherche binaire (ou n-aire) pour le classement d'un score donné. À quoi cela pourrait-il ressembler ?
Pour commencer, nous devons pouvoir répartir nos scores dans des buckets à peu près égaux, ce qui nécessite de connaître les valeurs des scores enregistrés par nos utilisateurs. Par exemple, si vous créez un classement pour le niveau de compétence dans un jeu compétitif, les niveaux de compétence de vos utilisateurs seront presque toujours distribués normalement. Notre fonction de génération de scores aléatoires utilise Math.random()
de JavaScript, ce qui donne une distribution à peu près uniforme. Nous allons donc diviser nos buckets de manière égale.
Dans cet exemple, nous utiliserons trois buckets pour plus de simplicité. Toutefois, vous constaterez probablement que si vous utilisez cette implémentation dans une application réelle, un plus grand nombre de buckets donnera des résultats plus rapides. En effet, un arbre moins profond signifie en moyenne moins de récupérations de collections et moins de conflits de verrouillage.
Le classement d'un joueur est donné par la somme du nombre de joueurs ayant obtenu un score plus élevé, plus un pour le joueur lui-même. Chaque collection sous scores
stocke trois documents, chacun avec une plage, le nombre de documents sous chaque plage, puis trois sous-collections correspondantes. Pour lire un classement, nous parcourons cet arbre à la recherche d'un score et nous gardons une trace de la somme des scores les plus élevés. Une fois que nous avons trouvé notre score, nous avons également la somme correcte.
L'écriture est beaucoup plus complexe. Tout d'abord, nous devrons effectuer toutes nos écritures dans une transaction pour éviter les incohérences de données lorsque plusieurs écritures ou lectures se produisent en même temps. Nous devrons également respecter toutes les conditions décrites ci-dessus lorsque nous parcourrons l'arborescence pour écrire nos nouveaux documents. Enfin, comme nous avons toute la complexité arborescente de cette nouvelle approche combinée à la nécessité de stocker tous nos documents d'origine, nos coûts de stockage augmenteront légèrement (mais ils resteront linéaires).
Dans functions-helpers.js
:
async function createScore(playerID, score, firestore) {
/**
* This function assumes a minimum score of 0 and that value
* is between min and max.
* Returns the expected size of a bucket for a given score
* so that bucket sizes stay constant, to avoid expensive
* re-bucketing.
* @param {number} value The new score.
* @param {number} min The min of the previous range.
* @param {number} max The max of the previous range. Must be greater than
* min.
* @return {Object<string, number>} Returns an object containing the new min
* and max.
*/
function bucket(value, min, max) {
const bucketSize = (max - min) / 3;
const bucketMin = Math.floor(value / bucketSize) * bucketSize;
const bucketMax = bucketMin + bucketSize;
return {min: bucketMin, max: bucketMax};
}
/**
* A function used to store pending writes until all reads within a
* transaction have completed.
*
* @callback PendingWrite
* @param {admin.firestore.Transaction} transaction The transaction
* to be used for writes.
* @return {void}
*/
/**
* Recursively searches for the node to write the score to,
* then writes the score and updates any counters along the way.
* @param {number} id The user associated with the score.
* @param {number} value The new score.
* @param {admin.firestore.CollectionReference} coll The collection this
* value should be written to.
* @param {Object<string, number>} range An object with properties min and
* max defining the range this score should be in. Ranges cannot overlap
* without causing problems. Use the bucket function above to determine a
* root range from constant values to ensure consistency.
* @param {admin.firestore.Transaction} transaction The transaction used to
* ensure consistency during tree updates.
* @param {Array<PendingWrite>} pendingWrites A series of writes that should
* occur once all reads within a transaction have completed.
* @return {void} Write error/success is handled via the transaction object.
*/
async function writeScoreToCollection(
id, value, coll, range, transaction, pendingWrites) {
const snapshot = await transaction.get(coll);
if (snapshot.empty) {
// This is the first score to be inserted into this node.
for (const write of pendingWrites) {
write(transaction);
}
const docRef = coll.doc();
transaction.create(docRef, {exact: {score: value, user: id}});
return;
}
const min = range.min;
const max = range.max;
for (const node of snapshot.docs) {
const data = node.data();
if (data.exact !== undefined) {
// This node held an exact score.
const newRange = bucket(value, min, max);
const tempRange = bucket(data.exact.score, min, max);
if (newRange.min === tempRange.min &&
newRange.max === tempRange.max) {
// The scores belong in the same range, so we need to "demote" both
// to a lower level of the tree and convert this node to a range.
const rangeData = {
range: newRange,
count: 2,
};
for (const write of pendingWrites) {
write(transaction);
}
const docReference = node.ref;
transaction.set(docReference, rangeData);
transaction.create(docReference.collection("scores").doc(), data);
transaction.create(
docReference.collection("scores").doc(),
{exact: {score: value, user: id}},
);
return;
} else {
// The scores are in different ranges. Continue and try to find a
// range that fits this score.
continue;
}
}
if (data.range.min <= value && data.range.max > value) {
// The score belongs to this range that may have subvalues.
// Increment the range's count in pendingWrites, since
// subsequent recursion may incur more reads.
const docReference = node.ref;
const newCount = node.get("count") + 1;
pendingWrites.push((t) => {
t.update(docReference, {count: newCount});
});
const newRange = bucket(value, min, max);
return writeScoreToCollection(
id,
value,
docReference.collection("scores"),
newRange,
transaction,
pendingWrites,
);
}
}
// No appropriate range was found, create an `exact` value.
transaction.create(coll.doc(), {exact: {score: value, user: id}});
}
const scores = firestore.collection("scores");
const players = firestore.collection("players");
return firestore.runTransaction((transaction) => {
return writeScoreToCollection(
playerID, score, scores, {min: 0, max: 1000}, transaction, [],
).then(() => {
transaction.create(players.doc(), {
user: playerID,
score: score,
});
});
});
}
C'est certainement plus compliqué que notre dernière implémentation, qui ne comportait qu'un seul appel de méthode et six lignes de code. Une fois cette méthode implémentée, essayez d'ajouter quelques scores à la base de données et d'observer la structure de l'arborescence résultante. Dans votre console JS :
leaderboard.addScores();
La structure de base de données obtenue doit ressembler à ceci, avec la structure arborescente clairement visible et les feuilles de l'arbre représentant les scores individuels.
scores
- document
range: 0-333.33
count: 2
scores:
- document
exact:
score: 18
user: 1
- document
exact:
score: 22
user: 2
Maintenant que nous avons surmonté la partie difficile, nous pouvons lire les scores en parcourant l'arborescence comme décrit précédemment.
async function readRank(playerID, firestore) {
const players = await firestore.collection("players")
.where("user", "==", playerID).get();
if (players.empty) {
throw Error(`Player not found in leaderboard: ${playerID}`);
}
if (players.size > 1) {
console.info(`Multiple scores with player ${playerID}, fetching first`);
}
const player = players.docs[0].data();
const score = player.score;
const scores = firestore.collection("scores");
/**
* Recursively finds a player score in a collection.
* @param {string} id The player's ID, since some players may be tied.
* @param {number} value The player's score.
* @param {admin.firestore.CollectionReference} coll The collection to
* search.
* @param {number} currentCount The current count of players ahead of the
* player.
* @return {Promise<number>} The rank of the player (the number of players
* ahead of them plus one).
*/
async function findPlayerScoreInCollection(id, value, coll, currentCount) {
const snapshot = await coll.get();
for (const doc of snapshot.docs) {
if (doc.get("exact") !== undefined) {
// This is an exact score. If it matches the score we're looking
// for, return. Otherwise, check if it should be counted.
const exact = doc.data().exact;
if (exact.score === value) {
if (exact.user === id) {
// Score found.
return currentCount + 1;
} else {
// The player is tied with another. In this case, don't increment
// the count.
continue;
}
} else if (exact.score > value) {
// Increment count
currentCount++;
continue;
} else {
// Do nothing
continue;
}
} else {
// This is a range. If it matches the score we're looking for,
// search the range recursively, otherwise, check if it should be
// counted.
const range = doc.data().range;
const count = doc.get("count");
if (range.min > value) {
// The range is greater than the score, so add it to the rank
// count.
currentCount += count;
continue;
} else if (range.max <= value) {
// do nothing
continue;
} else {
const subcollection = doc.ref.collection("scores");
return findPlayerScoreInCollection(
id,
value,
subcollection,
currentCount,
);
}
}
}
// There was no range containing the score.
throw Error(`Range not found for score: ${value}`);
}
const rank = await findPlayerScoreInCollection(playerID, score, scores, 0);
return {
user: playerID,
rank: rank,
score: score,
};
}
Les mises à jour sont traitées dans un exercice supplémentaire. Essayez d'ajouter et de récupérer des scores dans votre console JS avec les méthodes leaderboard.addScore(id, score)
et leaderboard.getRank(id)
, et voyez comment votre classement change dans la console Firebase.
Toutefois, avec cette implémentation, la complexité que nous avons ajoutée pour obtenir des performances logarithmiques a un coût.
- Tout d'abord, cette implémentation du classement peut rencontrer des problèmes de contention de verrouillage, car les transactions nécessitent de verrouiller les lectures et les écritures dans les documents pour s'assurer qu'ils restent cohérents.
- Deuxièmement, Firestore impose une limite de profondeur de sous-collections de 100. Cela signifie que vous devrez éviter de créer des sous-arbres après 100 scores à égalité, ce que cette implémentation ne fait pas.
- Enfin, ce tableau de classement est mis à l'échelle de manière logarithmique uniquement dans le cas idéal où l'arbre est équilibré. S'il ne l'est pas, les performances les plus médiocres de ce tableau de classement sont à nouveau linéaires.
Une fois que vous avez terminé, supprimez les collections scores
et players
via la console Firebase. Nous passerons ensuite à la dernière implémentation du classement.
6. Implémenter un classement stochastique (probabiliste)
Lorsque vous exécutez le code d'insertion, vous pouvez remarquer que si vous l'exécutez trop de fois en parallèle, vos fonctions commenceront à échouer avec un message d'erreur lié à la contention de verrouillage des transactions. Il existe des moyens de contourner ce problème que nous n'explorerons pas dans cet atelier de programmation. Toutefois, si vous n'avez pas besoin d'un classement exact, vous pouvez abandonner toute la complexité de l'approche précédente pour quelque chose de plus simple et de plus rapide. Voyons comment renvoyer un classement estimé pour les scores de nos joueurs au lieu d'un classement exact, et comment cela modifie la logique de notre base de données.
Pour cette approche, nous allons diviser notre classement en 100 buckets, chacun représentant environ un pour cent des scores que nous prévoyons de recevoir. Cette approche fonctionne même sans connaître la distribution de nos scores. Dans ce cas, nous ne pouvons pas garantir une distribution à peu près uniforme des scores dans le bucket. Toutefois, nous obtiendrons des approximations plus précises si nous savons comment nos scores seront distribués.
Notre approche est la suivante : comme précédemment, chaque bucket stocke le nombre de scores qu'il contient et la plage de scores. Lorsque vous insérez un nouveau score, nous trouvons le bucket correspondant et incrémentons son nombre. Lorsque nous récupérons un classement, nous nous contentons d'additionner les buckets qui le précèdent, puis nous effectuons une approximation dans notre bucket au lieu de poursuivre la recherche. Cela nous permet d'effectuer des recherches et des insertions en temps constant, et nécessite beaucoup moins de code.
Tout d'abord, l'insertion :
// Add this line to the top of your file.
const admin = require("firebase-admin");
// Implement this method (again).
async function createScore(playerID, score, firestore) {
const scores = await firestore.collection("scores").get();
if (scores.empty) {
// Create the buckets since they don't exist yet.
// In a real app, don't do this in your write function. Do it once
// manually and then keep the buckets in your database forever.
for (let i = 0; i < 10; i++) {
const min = i * 100;
const max = (i + 1) * 100;
const data = {
range: {
min: min,
max: max,
},
count: 0,
};
await firestore.collection("scores").doc().create(data);
}
throw Error("Database not initialized");
}
const buckets = await firestore.collection("scores")
.where("range.min", "<=", score).get();
for (const bucket of buckets.docs) {
const range = bucket.get("range");
if (score < range.max) {
const writeBatch = firestore.batch();
const playerDoc = firestore.collection("players").doc();
writeBatch.create(playerDoc, {
user: playerID,
score: score,
});
writeBatch.update(
bucket.ref,
{count: admin.firestore.FieldValue.increment(1)},
);
const scoreDoc = bucket.ref.collection("scores").doc();
writeBatch.create(scoreDoc, {
user: playerID,
score: score,
});
return writeBatch.commit();
}
}
}
Vous remarquerez que ce code d'insertion contient une logique pour initialiser l'état de votre base de données en haut, avec un avertissement indiquant de ne pas faire cela en production. Le code d'initialisation n'est pas du tout protégé contre les conditions de concurrence. Si vous le faisiez, plusieurs écritures simultanées corrompraient votre base de données en vous donnant un grand nombre de buckets en double.
Déployez vos fonctions, puis exécutez une insertion pour initialiser tous les buckets avec un nombre nul. Une erreur s'affichera, mais vous pourrez l'ignorer sans problème.
leaderboard.addScore(999, 0); // The params aren't important here.
Maintenant que la base de données est correctement initialisée, nous pouvons exécuter addScores
et voir la structure de nos données dans la console Firebase. La structure obtenue est beaucoup plus plate que notre dernière implémentation, bien qu'elles soient superficiellement similaires.
leaderboard.addScores();
Pour lire les scores :
async function readRank(playerID, firestore) {
const players = await firestore.collection("players")
.where("user", "==", playerID).get();
if (players.empty) {
throw Error(`Player not found in leaderboard: ${playerID}`);
}
if (players.size > 1) {
console.info(`Multiple scores with player ${playerID}, fetching first`);
}
const player = players.docs[0].data();
const score = player.score;
const scores = await firestore.collection("scores").get();
let currentCount = 1; // Player is rank 1 if there's 0 better players.
let interp = -1;
for (const bucket of scores.docs) {
const range = bucket.get("range");
const count = bucket.get("count");
if (score < range.min) {
currentCount += count;
} else if (score >= range.max) {
// do nothing
} else {
// interpolate where the user is in this bucket based on their score.
const relativePosition = (score - range.min) / (range.max - range.min);
interp = Math.round(count - (count * relativePosition));
}
}
if (interp === -1) {
// Didn't find a correct bucket
throw Error(`Score out of bounds: ${score}`);
}
return {
user: playerID,
rank: currentCount + interp,
score: score,
};
}
Étant donné que nous avons fait en sorte que la fonction addScores
génère une distribution uniforme des scores et que nous utilisons l'interpolation linéaire dans les buckets, nous obtiendrons des résultats très précis. Les performances de notre classement ne se dégraderont pas à mesure que nous augmenterons le nombre d'utilisateurs, et nous n'aurons pas à nous soucier (autant) de la contention de verrouillage lors de la mise à jour des nombres.
7. Avenant : Tricherie
Vous vous demandez peut-être si un joueur peut mentir au classement et dire qu'il a obtenu un score élevé qu'il n'a pas atteint de manière équitable, si vous écrivez des valeurs dans votre atelier de programmation via la console JS d'un onglet de navigateur.
Oui, c'est possible. Si vous souhaitez empêcher la triche, le moyen le plus efficace consiste à désactiver l'accès en écriture des clients à votre base de données à l'aide des règles de sécurité, à sécuriser l'accès à vos fonctions Cloud afin que les clients ne puissent pas les appeler directement, puis à valider les actions en jeu sur votre serveur avant d'envoyer les mises à jour des scores au classement.
Il est important de noter que cette stratégie n'est pas une panacée contre la triche. Avec une incitation suffisamment importante, les tricheurs peuvent trouver des moyens de contourner les validations côté serveur. De nombreux jeux vidéo à succès jouent constamment au chat et à la souris avec leurs tricheurs pour identifier de nouvelles triches et les empêcher de se propager. Une conséquence difficile de ce phénomène est que la validation côté serveur pour chaque jeu est intrinsèquement personnalisée. Bien que Firebase fournisse des outils anti-abus tels qu'App Check, qui empêchent un utilisateur de copier votre jeu via un simple client scripté, Firebase ne fournit aucun service qui équivaut à une solution anti-triche holistique.
Si la validation côté serveur n'est pas utilisée, les classements des jeux suffisamment populaires ou dont le niveau de difficulté est suffisamment bas pour que les joueurs puissent tricher seront remplis de tricheurs.
8. Félicitations
Félicitations, vous avez créé quatre classements différents sur Firebase ! En fonction des besoins de votre jeu en termes de précision et de vitesse, vous pourrez choisir celui qui vous convient à un coût raisonnable.
Ensuite, consultez les parcours de formation pour les jeux.