1. 簡介
上次更新時間:2023 年 1 月 27 日
建立排行榜時需要注意哪些事項?
排行榜本質上是分數表格,具有一個複雜因素,如要讀取任何特定分數的排名,必須瞭解所有其他分數。而且,如果遊戲成效不彰,排行榜就會擴增,而且經常會讀取及寫入。如要建立成功的排行榜,遊戲必須要能快速處理這項排名作業。
建構內容
在這個程式碼研究室中,您將實作各種排行榜,每個排行榜適用於不同的情境。
課程內容
您將瞭解如何實作四個不同的排行榜:
- 一種簡單的實作,使用簡單的記錄計數來決定排名
- 便宜且定期更新的排行榜
- 有幾棵樹的即時排行榜
- 速成 (機率) 排行榜,玩家族群的概略排名
事前準備
- 最新版本的 Chrome (107 以上版本)
- Node.js 16 以上版本 (如果您使用 nvm,請執行
nvm --version
來查看版本號碼) - 付費 Firebase Blaze 方案 (選用)
- Firebase CLI v11.16.0 以上版本
如要安裝 CLI,請執行npm install -g firebase-tools
或參閱 CLI 說明文件查看更多安裝選項。 - 瞭解 JavaScript、Cloud Firestore、Cloud Functions 和 Chrome 開發人員工具
2. 開始設定
取得程式碼
我們已將本專案所需的資料都放到 Git 存放區中。如要開始使用,請擷取程式碼,然後在慣用的開發環境中開啟。在本程式碼研究室中,我們使用 VS Code,但任何文字編輯器都可以。
或是複製到您選擇的目錄:
git clone https://github.com/FirebaseExtended/firestore-leaderboards-codelab.git
從哪裡開始?
我們的專案目前為空白插入畫面,其中包含一些空白函式:
index.html
包含一些黏合指令碼,可讓我們從開發控制台叫用函式並查看其輸出內容。我們會使用此 ID 來與後端互動,並查看函式叫用的結果。在實際情境中,您會直接從遊戲發出這些後端呼叫。在這個程式碼研究室中,我們不會使用遊戲,因為每次要在排行榜中加入分數時,遊戲時間會過長。functions/index.js
包含所有 Cloud Functions。您會看到一些公用函式 (例如addScores
和deleteScores
),以及在本程式碼研究室中實作的函式,這些函式會在另一個檔案中呼叫輔助函式。functions/functions-helpers.js
包含要實作的空白函式。我們會在各個排行榜中實作讀取、建立與更新函式,讓您瞭解我們選擇的實作方式對於實作的複雜性和資源調度效能會有什麼影響。functions/utils.js
包含更多公用函式。我們在本程式碼研究室中不會接觸這個檔案。
建立及設定 Firebase 專案
- 在 Firebase 控制台,按一下「新增專案」。
- 如要建立新專案,請輸入所需專案名稱。
這麼做也會將專案 ID (顯示在專案名稱下方) 設為根據專案名稱顯示的內容。您也可以在專案 ID 上按一下「編輯」圖示,進一步自訂專案 ID。 - 如果出現提示訊息,請詳閱並接受 Firebase 條款。
- 按一下 [繼續]。
- 選取「為這項專案啟用 Google Analytics」選項,然後按一下「繼續」。
- 選取要使用的現有 Google Analytics 帳戶,或選取「建立新帳戶」來建立新帳戶。
- 按一下 [Create Project]。
- 專案建立完成後,按一下「Continue」。
- 在「建構」選單中按一下「函式」。如果出現提示,請將專案升級為使用 Blaze 計費方案。
- 在「建構」選單中,按一下「Firestore 資料庫」。
- 在隨即顯示的「Create database」(建立資料庫) 對話方塊中,選取「Start in test mode」(以測試模式啟動),然後按一下「Next」(下一步)。
- 從「Cloud Firestore location」下拉式選單中選擇區域,然後按一下「啟用」。
設定並執行排行榜
- 在終端機中,前往專案根目錄並執行
firebase use --add
。選擇剛建立的 Firebase 專案。 - 在專案的根目錄中執行
firebase emulators:start --only hosting
。 - 透過瀏覽器前往
localhost:5000
。 - 開啟 Chrome 開發人員工具的 JavaScript 控制台,然後匯入
leaderboard.js
:const leaderboard = await import("http://localhost:5000/scripts/leaderboard.js");
- 在控制台中執行
leaderboard.codelab();
。如果看到歡迎訊息,表示一切就緒!如果未開啟,請關閉模擬器,然後重新執行步驟 2 到 4。
一起來看看第一個排行榜實作。
3. 實作簡易排行榜
在這部分結束時,我們可以為排行榜新增分數並告訴我們排名。
在開始使用之前,我們先解釋這個排行榜實作的運作方式:所有玩家都儲存在單一集合中,系統會擷取所有玩家的收藏並計算領先的玩家人數,藉此擷取玩家排名。這樣就能輕鬆插入及更新分數。插入新分數時,我們只會將分數附加至集合,如要更新分數,我們會篩選目前的使用者,然後更新產生的文件。我們來看看程式碼看起來的樣子。
在 functions/functions-helper.js
中實作 createScore
函式,就像所得到的一樣簡單:
async function createScore(score, playerID, firestore) {
return firestore.collection("scores").doc().create({
user: playerID,
score: score,
});
}
如果是更新分數,我們只需新增錯誤檢查,確保已更新分數已存在:
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,
});
}
最後,我們來看簡單但擴充性較低的排名函式:
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}`);
}
我們開始測試吧!在終端機中執行下列指令來部署函式:
firebase deploy --only functions
接著,在 Chrome 的 JS 控制台中,加入其他分數,以便我們查看在其他玩家的排名。
leaderboard.addScores(); // Results may take some time to appear.
我們現在可以在合輯中加入自己的分數:
leaderboard.addScore(999, 11); // You can make up a score (second argument) here.
寫入完成後,控制台中會顯示「Score created」(已建立分數) 的回應。改為顯示錯誤訊息?透過 Firebase 控制台開啟函式記錄檔,瞭解問題所在。
最後,我們可以擷取並更新分數。
leaderboard.getRank(999);
leaderboard.updateScore(999, 0);
leaderboard.getRank(999); // we should be last place now (11)
不過,這個實作方式讓我們瞭解擷取指定分數排名時不想要的線性時間和記憶體需求。由於函式執行時間和記憶體兩者都有所限制,這不僅表示擷取速度會越來越慢,但在排行榜加入足夠的分數之後,我們的函式會先逾時或當機,才能傳回結果。顯然,如果我們打算擴展到少數玩家之外,就需要更好的做法。
如果你是 Firestore 愛好者,或許會有 COUNT 項匯總查詢,讓這個排行榜的成效大幅提升。你說的對!使用 COUNT 項查詢時,儘管成效仍是線性變化,因此擴充幅度大幅低於百萬位使用者。
但您可能會想,如果我們還是要列舉集合中的所有文件,我們可以為每份文件指派排名。當需要擷取文件時,擷取作業就會是 O(1) 時間和記憶體!這就開啟了下一個定期更新排行榜的做法。
4. 實作定期更新排行榜
這個方法的關鍵在於將排名儲存在文件本身中,因此擷取該檔案後,我們無需進行額外工作就能排名。為達成此目標,就需要新的函式。
在 index.js
中新增以下內容:
// 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;
});
現在,讀取、更新和寫入作業都是很好而簡單。寫入和更新作業都維持不變,但讀取會變為 (在 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"),
};
}
很抱歉,您必須新增帳單帳戶至專案,才能部署及測試這項作業。如果您有帳單帳戶,請縮短預定函式的間隔時間,並觀察函式如何自動分配排行榜分數。
如果沒有,請刪除排定的函式,然後跳到下一個實作。
請按一下分數集合旁的 3 點圖示,為下一節做好準備,以便刪除 Firestore 資料庫中的分數。
5. 實作即時樹狀排行榜
這個方法的運作原理是將搜尋資料儲存在資料庫集合本身。我們的目標是將所有內容都儲存在樹狀結構中,以便透過移動方式穿越。這可讓我們針對特定分數排名執行二進位 (或 n-ary) 搜尋。會是什麼樣子?
首先,我們要將分數分散到大致上甚至可分屬不同值區,但需瞭解使用者記錄分數值的一些資訊。舉例來說,如果您在競賽活動中建構排行榜,以提陞技能等級,技能評分最終通常會按一般分配的方式分配。我們的隨機分數產生函式使用了 JavaScript 的 Math.random()
,因此會產生近乎平均的分佈,因此我們會將值區平均分配。
在此範例中,為了精簡起見,我們會使用 3 個值區,但如果在實際應用程式中使用這項實作項目,可能會有更多的值區能更快產生結果。淺樹代表平均集合擷取次數越少,鎖定爭用情形就越少。
排序依據是各玩家得分的最高得分之總和,再加上玩家自身排名。scores
底下的每個集合都會儲存三份文件,每個文件各有一個範圍、每個範圍內的文件數量,以及三個對應的子集合。為了閱讀排名,我們會掃遍這個樹狀結構中的分數,並追蹤分數較高的總和。我們找到分數時,也會提供正確的總和。
寫作變得更複雜。首先,我們必須在一個交易中完成所有寫入作業,以避免在同一時間發生多次寫入或讀取時資料不一致的問題。因此,我們也必須維護上述所有條件,同時在樹狀圖中掃遍整個樹狀結構,以便撰寫新文件。最後,由於這個新方法的所有樹狀結構都相當複雜,而且系統需要儲存所有原始文件,因此儲存成本會稍微增加 (但仍是線性的)。
在 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,
});
});
});
}
這比我們上次的實作來得複雜,這是一種方法呼叫和六行程式碼。實作此方法後,請試著在資料庫中新增一些分數,並觀察產生的樹狀結構結構。在 JS 控制台中:
leaderboard.addScores();
產生的資料庫結構應如下所示:清楚可見的樹狀結構,以及代表個別分數的樹狀結構。
scores
- document
range: 0-333.33
count: 2
scores:
- document
exact:
score: 18
user: 1
- document
exact:
score: 22
user: 2
現在我們已解決困難的部分,可以像之前一樣穿越樹狀結構圖,查看分數。
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,
};
}
更新視為額外運動。嘗試使用 leaderboard.addScore(id, score)
和 leaderboard.getRank(id)
方法在 JS 控制台中新增及擷取分數,並在 Firebase 控制台中查看您的排行榜變化。
不過,採用這種實作方式時,為了達到對數成效而添加的複雜性將需要支付費用。
- 首先,這個排行榜實作可能會發生鎖定爭用問題,因為交易需要鎖定讀取和寫入文件以確保一致性。
- 第二,Firestore 設下子集合深度限制為 100,也就是說,在 100 分並列分數後,應避免建立子樹狀結構,而此實作方法則不會。
- 最後,只有在樹狀圖處於平衡狀態的情況下,這個排行榜才會依照對數比例進行縮放。在未平衡的情況下,這個排行榜最糟糕的情況就會再次變成線性。
完成後,請透過 Firebase 控制台刪除 scores
和 players
集合,然後我們就會前往上一次的排行榜實作。
6. 實作隨機 (機率) 排行榜
執行插入程式碼時,您可能會注意到,如果同時執行太多次,函式會開始失敗,並顯示與交易鎖定爭用相關的錯誤訊息。我們在本程式碼研究室中不會進一步探討這類做法,但如果您不需要精確排名,可以捨棄先前做法的所有複雜度,並加快執行速度。讓我們來看看如何傳回玩家的預估排名分數而非確切排名,以及這如何改變資料庫邏輯。
使用這個方法時,我們會將排行榜分為 100 個區塊,每個區塊代表我們預期所接收分數的約 1%。這種做法即使在瞭解分數分佈的情況下仍然有效。在這種情況下,我們無法保證將分數平均分佈於值區,但如果我們知道分數分佈情形的分配情況,將提高我們的近似精確值。
我們的做法如下:和先前一樣,每個值區都會儲存分數範圍內的分數數量。插入新分數時,我們會找出該分數的值區,並遞增計數。擷取排名時,我們只會加總值區,然後將其估算在值區中,而非進一步搜尋。這讓我們能夠持續進行查詢和插入作業,而且需要較少的程式碼。
首先,插入:
// 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();
}
}
}
您會發現此插入程式碼在頂端初始化資料庫狀態的部分邏輯,並附帶警告,在實際工作環境中不會發生這類情況。用於初始化的程式碼完全不受競爭狀況保護,因此如果這麼做,多次並行寫入作業會提供大量重複的值區,導致資料庫損毀。
請直接部署函式,然後執行插入作業,將所有值區的計數初始化。系統會傳回錯誤,您可以放心忽略。
leaderboard.addScore(999, 0); // The params aren't important here.
現在資料庫已正確初始化,我們可以執行 addScores
,並在 Firebase 控制台中查看資料的結構。最終的實作成果遠如我們上次的實作架構,但功能非常類似。
leaderboard.addScores();
現在,如要讀取分數:
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,
};
}
由於我們已讓 addScores
函式產生統一分數分佈,並在值區內使用線性內插法,因此將取得非常準確的結果,排行榜的效能不會因為使用者人數增加而降低,而且在更新計數時,我們不必擔心鎖定爭用情形 (最大)。
7. 附錄:作弊
我想,您可能會想,如果是透過瀏覽器分頁的 JS 控制台,將值寫進程式碼研究室,但有其他玩家都說他們分數根本不算在排行榜上。
可以。如要防止作弊,最可靠的方法是透過安全性規則停用用戶端寫入資料庫、保護 Cloud Functions 存取權。這樣一來,用戶端就無法直接呼叫這些函式,並在將分數更新傳送至排行榜之前,先在伺服器上驗證遊戲動作。
值得注意的一點是,這項策略並不是對抗作弊的大好時機,再加上足夠的獎勵,作家可以設法規避伺服器端驗證。許多成功的大型電玩遊戲會不斷透過貓狗找出新的謎團,阻止他們繼續擴散。如果發生這種狀況,最困難的問題在於每款遊戲的伺服器端驗證自主設定。雖然 Firebase 提供的反濫用工具 (例如 App Check) 可以防止使用者透過具有指令碼的簡易用戶端複製你的遊戲,但 Firebase 並未提供任何可精確抵禦多項服務的服務。
只要遊戲數量夠熱門,或是作弊的障礙程度偏低,只要進行伺服器端驗證,就能製作出排行榜,把排名分到最出色的遊戲都包含在內。
8. 恭喜
恭喜,您成功在 Firebase 中建立四個不同的排行榜!您可以視遊戲對精確性和速度的需求而定,以合理的成本挑選適合自己的遊戲。
接下來,請參考遊戲的學習路徑。