בנה לוחות הישגים עם Firestore

1. הקדמה

עדכון אחרון: 2023-01-27

מה צריך כדי לבנות לוח הישגים?

בבסיסם, לוחות ההישגים הם רק טבלאות של ציונים עם גורם אחד מסבך: קריאת דירוג עבור כל ציון נתון דורשת ידע על כל הציונים האחרים בסדר כלשהו. כמו כן, אם המשחק שלך ימריא, לוחות ה-Leaderboard שלך יגדלו ויקראו ויכתבו אליהם לעתים קרובות. כדי לבנות לוח הישגים מוצלח, הוא צריך להיות מסוגל להתמודד עם פעולת הדירוג הזו במהירות.

מה שתבנה

ב-Codelab זה, תטמיע לוחות הישגים שונים, כל אחד מתאים לתרחיש אחר.

מה תלמד

תלמד כיצד ליישם ארבעה לוחות הישגים שונים:

  • יישום נאיבי באמצעות ספירת רשומות פשוטה לקביעת דירוג
  • Leaderboard זול המתעדכן מעת לעת
  • לוח הישגים בזמן אמת עם כמה שטויות עצים
  • לוח הישגים סטוכסטי (הסתברותי) לדירוג משוער של בסיסי שחקנים גדולים מאוד

מה אתה צריך

  • גרסה עדכנית של Chrome (107 ואילך)
  • Node.js 16 ומעלה (הפעל nvm --version כדי לראות את מספר הגרסה שלך אם אתה משתמש ב-nvm)
  • תוכנית Firebase Blaze בתשלום (אופציונלי)
  • Firebase CLI v11.16.0 ומעלה
    כדי להתקין את ה-CLI, אתה יכול להפעיל npm install -g firebase-tools או לעיין בתיעוד CLI לאפשרויות התקנה נוספות.
  • ידע ב-JavaScript, Cloud Firestore, Cloud Functions ו- Chrome DevTools

2. מתחילים להתקין

קבל את הקוד

שמנו את כל מה שאתה צריך עבור הפרויקט הזה ב- Git repo. כדי להתחיל, תצטרך לתפוס את הקוד ולפתוח אותו בסביבת הפיתוח המועדפת עליך. עבור מעבד הקוד הזה, השתמשנו ב- VS Code, אבל כל עורך טקסט יצליח.

ופרק את קובץ ה-zip שהורדת.

לחלופין, לשכפל לתוך ספריית הבחירה שלך:

git clone https://github.com/FirebaseExtended/firestore-leaderboards-codelab.git

מהי נקודת המוצא שלנו?

הפרויקט שלנו הוא כרגע לוח ריק עם כמה פונקציות ריקות:

  • index.html מכיל כמה סקריפטים דבק המאפשרים לנו להפעיל פונקציות ממסוף ה-dev ולראות את הפלטים שלהם. נשתמש בזה כדי להתממשק עם הקצה העורפי שלנו ולראות את התוצאות של הפעלות לפונקציות שלנו. בתרחיש של העולם האמיתי, היית מבצע את השיחות האחוריות האלה מהמשחק שלך ישירות - אנחנו לא משתמשים במשחק במעבדת הקוד הזה כי זה ייקח יותר מדי זמן לשחק משחק בכל פעם שאתה רוצה להוסיף ניקוד ל-Leaderboard .
  • functions/index.js מכיל את כל פונקציות הענן שלנו. תראה כמה פונקציות שירות, כמו addScores ו- deleteScores , כמו גם את הפונקציות שנטמיע במעבדת הקוד הזה, שקוראות לפונקציות עוזרות בקובץ אחר.
  • functions/functions-helpers.js מכיל את הפונקציות הריקות שנטמיע. עבור כל Leaderboard, ניישם פונקציות קריאה, יצירה ועדכנו, ותראה כיצד בחירת היישום שלנו משפיעה הן על המורכבות של היישום שלנו והן על ביצועי קנה המידה שלו.
  • functions/utils.js מכיל יותר פונקציות שירות. לא ניגע בקובץ הזה במעבדת הקוד הזה.

צור והגדר פרויקט Firebase

  1. במסוף Firebase , לחץ על הוסף פרויקט .
  2. כדי ליצור פרויקט חדש, הזן את שם הפרויקט הרצוי.
    זה גם יקבע את מזהה הפרויקט (המוצג מתחת לשם הפרויקט) למשהו המבוסס על שם הפרויקט. באפשרותך ללחוץ על סמל העריכה במזהה הפרויקט כדי להתאים אותו עוד יותר.
  3. אם תתבקש, עיין בתנאי Firebase וקבל אותם.
  4. לחץ על המשך .
  5. בחר באפשרות הפעל את Google Analytics עבור פרויקט זה ולאחר מכן לחץ על המשך .
  6. בחר חשבון Google Analytics קיים לשימוש או בחר צור חשבון חדש כדי ליצור חשבון חדש.
  7. לחץ על צור פרויקט .
  8. לאחר יצירת הפרויקט, לחץ על המשך .
  9. מתפריט בנייה , לחץ על פונקציות , ואם תתבקש, שדרג את הפרויקט שלך לשימוש בתוכנית החיוב של Blaze.
  10. מתפריט בנייה , לחץ על מסד נתונים של Firestore .
  11. בתיבת הדו-שיח צור מסד נתונים שמופיעה, בחר התחל במצב בדיקה ולאחר מכן לחץ על הבא .
  12. בחר אזור מהתפריט הנפתח מיקום Cloud Firestore ולאחר מכן לחץ על הפעל .

הגדר והפעל את ה-Leaderboard שלך

  1. במסוף, נווט לשורש הפרויקט והפעל את firebase use --add . בחר את פרויקט Firebase שיצרת זה עתה.
  2. בשורש הפרויקט, הפעל את firebase emulators:start --only hosting .
  3. בדפדפן שלך, נווט אל localhost:5000 .
  4. פתח את מסוף ה-JavaScript של Chrome DevTools וייבא את leaderboard.js :
    const leaderboard = await import("http://localhost:5000/scripts/leaderboard.js");
    
  5. הפעל leaderboard.codelab(); בקונסולה. אם אתה רואה הודעת קבלת פנים, אתה מוכן! אם לא, כבה את האמולטור והפעל מחדש את שלבים 2-4.

בואו נקפוץ ליישום Leaderboard הראשון.

3. ליישם טבלת הישגים פשוטה

עד סוף הסעיף הזה, נוכל להוסיף ניקוד ל-Leaderboard ולבקש ממנו לומר לנו את הדרגה שלנו.

לפני שנקפוץ פנימה, בואו נסביר איך יישום Leaderboard זה עובד: כל השחקנים מאוחסנים באוסף בודד, והבאת דרג של שחקן מתבצעת על ידי שליפת האוסף וספירה של כמה שחקנים מקדימים אותם. זה מקל על הוספת ועדכון ניקוד. כדי להוסיף ניקוד חדש, אנו פשוט מצרפים אותו לאוסף, וכדי לעדכן אותו, אנו מסננים עבור המשתמש הנוכחי שלנו ולאחר מכן מעדכנים את המסמך שנוצר. בוא נראה איך זה נראה בקוד.

ב- 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

ואז, במסוף JS של Chrome, הוסף עוד כמה ציונים כדי שנוכל לראות את הדירוג שלנו בין שחקנים אחרים.

leaderboard.addScores(); // Results may take some time to appear.

כעת אנו יכולים להוסיף ציון משלנו לתערובת:

leaderboard.addScore(999, 11); // You can make up a score (second argument) here.

בסיום הכתיבה, אתה אמור לראות תגובה בקונסולה האומרת "ציון נוצר". רואה שגיאה במקום? פתח את יומני הפונקציות דרך מסוף Firebase כדי לראות מה השתבש.

ולבסוף, אנחנו יכולים להביא ולעדכן את הציון שלנו.

leaderboard.getRank(999);
leaderboard.updateScore(999, 0);
leaderboard.getRank(999); // we should be last place now (11)

עם זאת, יישום זה נותן לנו דרישות זמן ליניאריות וזיכרון בלתי רצויות להשגת דירוג של ניקוד נתון. מכיוון שזמן ביצוע הפונקציות והזיכרון שניהם מוגבלים, לא רק שזה אומר שהשליפות שלנו יהפכו איטיות יותר, אלא שלאחר הוספת מספיק ציונים ללוח התוצאות, הפונקציות שלנו יפסקו זמן קצר או יקרסו לפני שיוכלו להחזיר תוצאה. ברור שנצטרך משהו טוב יותר אם אנחנו מתכוונים להרחיב מעבר לקומץ שחקנים.

אם אתה חובב Firestore, ייתכן שאתה מודע ל- COUNT שאילתות צבירה , שיהפכו את ה-Leaderboard הזה להרבה יותר ביצועי. ואתה צודק! עם 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"),
  };
}

למרבה הצער, לא תוכל לפרוס ולבדוק זאת מבלי להוסיף חשבון חיוב לפרויקט שלך. אם יש לך חשבון חיוב, קצר את המרווח בפונקציה המתוכננת וצפה בפונקציה שלך מקצה דרגות בצורה קסומה לציוני ה-Leaderboard שלך.

אם לא, מחק את הפונקציה המתוזמנת ודלג קדימה ליישום הבא.

קדימה, מחק את הציונים במסד הנתונים של Firestore שלך ​​על ידי לחיצה על 3 הנקודות שליד אוסף הציונים כדי להתכונן לקטע הבא.

Firestore scores document page with\nDelete Collection activated

5. הטמעת לוח עץ בזמן אמת

גישה זו פועלת על ידי אחסון נתוני חיפוש באוסף מסד הנתונים עצמו. במקום אוסף אחיד, המטרה שלנו היא לאחסן כל דבר בעץ שאנו יכולים לעבור על ידי מעבר בין מסמכים. זה מאפשר לנו לבצע חיפוש בינארי (או n-ארי) עבור דירוג של ציון נתון. איך זה יכול להיראות?

כדי להתחיל, נרצה להיות מסוגלים לחלק את הציונים שלנו לדליים אחידים בערך, מה שידרוש ידע מסוים לגבי הערכים של הציונים שהמשתמשים שלנו רושמים; לדוגמה, אם אתה בונה טבלת הישגים לדירוג מיומנויות במשחק תחרותי, דירוגי המיומנות של המשתמשים שלך כמעט תמיד יתפזרו בצורה נורמלית. פונקציית יצירת הניקוד האקראי שלנו משתמשת ב- Math.random() של JavaScript, מה שמביא להתפלגות שווה בערך, אז נחלק את הדליים שלנו באופן שווה.

בדוגמה זו נשתמש ב-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,
  };
}

עדכונים נשארים כתרגיל נוסף. נסה להוסיף ולהביא ציונים במסוף ה-JS שלך עם השיטות leaderboard.addScore(id, score) ו- leaderboard.getRank(id) וראה כיצד ה-leaderboard שלך משתנה במסוף Firebase.

עם זאת, עם היישום הזה, המורכבות שהוספנו כדי להשיג ביצועים לוגריתמיים גובה מחיר.

  • ראשית, יישום Leaderboard זה יכול להיתקל בבעיות מחלוקת נעילה, מכיוון שעסקאות דורשות נעילת קריאה וכתיבה למסמכים כדי לוודא שהן נשארות עקביות.
  • שנית, Firestore מטילה מגבלת עומק של תת-אוסף של 100 , כלומר תצטרך להימנע מיצירת תתי-עצים לאחר 100 ציונים שווים, מה שיישום זה אינו עושה.
  • ולבסוף, ה-Leaderboard הזה משתנה לוגריתמית רק במקרה האידיאלי שבו העץ מאוזן - אם הוא לא מאוזן, הביצועים במקרה הגרוע ביותר של Leaderboard זה שוב ליניאריים.

לאחר שתסיים, מחק את scores ואת אוספי players דרך קונסולת Firebase ונעבור ליישום Leaderboard האחרון שלנו.

6. ליישם לוח סטוכסטי (הסתברותי).

בעת הפעלת קוד ההכנסה, ייתכן שתבחין שאם תפעיל אותו יותר מדי פעמים במקביל, הפונקציות שלך יתחילו להיכשל עם הודעת שגיאה הקשורה לטענה של נעילת עסקה. יש דרכים לעקוף את זה שלא נחקור במעבדת הקוד הזה, אבל אם אתה לא צריך דירוג מדויק, אתה יכול להוריד את כל המורכבות של הגישה הקודמת למשהו פשוט ומהיר יותר. בואו נסתכל כיצד אנו עשויים להחזיר דירוג משוער עבור ציוני השחקנים שלנו במקום דירוג מדויק, וכיצד זה משנה את הלוגיקה של מסד הנתונים שלנו.

עבור גישה זו, נחלק את ה-leaderboard שלנו ל-100 דליים, שכל אחד מהם מייצג כאחוז אחד מהציונים שאנו מצפים לקבל. גישה זו פועלת גם ללא ידיעה על התפלגות הציונים שלנו, ובמקרה זה אין לנו דרך להבטיח התפלגות שווה בערך של הציונים לאורך הדלי, אך נשיג דיוק רב יותר בקירוב שלנו אם נדע כיצד הציונים שלנו יחולקו .

הגישה שלנו היא כדלקמן: כמו בעבר, כל דלי מאחסן את ספירת מספר הציונים בתוך וטווח הציונים. בעת הכנסת ניקוד חדש, נמצא את הדלי של הציון ונגדיל את הספירה שלו. בעת הבאת דרגה, פשוט נסכם את הדליים שלפניה ולאחר מכן נתקרב בתוך הדלי שלנו במקום לחפש יותר. זה נותן לנו חיפושים והכנסות בזמן קבוע מאוד נחמד, ודורש הרבה פחות קוד.

ראשית, הוספה:

// 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 לייצר חלוקה אחידה של ציונים ואנחנו משתמשים באינטרפולציה ליניארית בתוך הדליים, נקבל תוצאות מדויקות מאוד, הביצועים של ה-Leaderboard שלנו לא ידרדרו ככל שנגדיל את מספר המשתמשים, ואנחנו לא צריכים לדאוג לגבי מחלוקת מנעולים (כמו הרבה) בעת עדכון ספירות.

7. תוספת: רמאות

חכה רגע, אולי אתם חושבים, אם אני כותב ערכים ל-codelab שלי דרך קונסולת JS של כרטיסיית דפדפן, האם אף אחד מהשחקנים שלי לא יכול פשוט לשקר ל-Leaderboard ולומר שהם קיבלו ציון גבוה שלא קיבל. להשיג בצורה הוגנת?

כן הם יכולים. אם אתה רוצה למנוע רמאות, הדרך החזקה ביותר לעשות זאת היא להשבית את הכתיבה של הלקוח למסד הנתונים שלך באמצעות כללי אבטחה , גישה מאובטחת לפונקציות הענן שלך כך שלקוחות לא יוכלו להתקשר אליהם ישירות, ולאחר מכן לאמת פעולות במשחק בשרת שלך לפני שליחת עדכוני ניקוד ל-Leaderboard.

חשוב לציין שהאסטרטגיה הזו אינה תרופת פלא נגד רמאות - עם תמריץ גדול מספיק, רמאים יכולים למצוא דרכים לעקוף אימותים בצד השרת, ומשחקי וידאו גדולים ומצליחים רבים כל הזמן משחקים בחתול ועכבר עם הרמאים שלהם כדי לזהות צ'יטים חדשים ומונעים מהם להתרבות. תוצאה קשה של תופעה זו היא שאימות צד השרת עבור כל משחק מותאם מטבעו; למרות ש-Firebase מספקת כלים נגד שימוש לרעה כמו App Check שימנעו ממשתמש להעתיק את המשחק שלך דרך לקוח פשוט עם סקריפט, Firebase לא מספקת שום שירות שמסתכם באנטי-צ'יט הוליסטי.

כל דבר פחות מאימות בצד השרת יביא, עבור משחק פופולרי מספיק או מחסום נמוך מספיק בפני רמאות, ללוח הישגים שבו הערכים העליונים הם כולם רמאים.

8. מזל טוב

מזל טוב, בנית בהצלחה ארבעה לוחות הישגים שונים ב-Firebase! בהתאם לצרכי המשחק שלך לדיוק ומהירות, תוכל לבחור אחד שעובד בשבילך במחיר סביר.

בשלב הבא, בדוק את מסלולי הלמידה של משחקים.