أنشئ لوحات الصدارة باستخدام Firestore

1 المقدمة

آخر تحديث: 2023-01-27

ما الذي يتطلبه بناء لوحة المتصدرين؟

تعد لوحات المتصدرين في جوهرها مجرد جداول من النتائج مع عامل معقد واحد: قراءة تصنيف لأي نتيجة معينة تتطلب معرفة جميع النتائج الأخرى بنوع من الترتيب. وأيضًا، إذا نجحت لعبتك، فسوف تنمو لوحات المتصدرين الخاصة بك بشكل كبير وسيتم قراءتها وكتابتها بشكل متكرر. لبناء لوحة صدارة ناجحة، يجب أن تكون قادرًا على التعامل مع عملية التصنيف هذه بسرعة.

ما سوف تبنيه

في هذا الدرس التدريبي حول البرمجة، ستقوم بتنفيذ العديد من لوحات المتصدرين المختلفة، كل منها مناسبة لسيناريو مختلف.

ما ستتعلمه

ستتعلم كيفية تنفيذ أربع لوحات صدارة مختلفة:

  • تنفيذ ساذج باستخدام عد السجلات البسيط لتحديد الرتبة
  • لوحة صدارة رخيصة يتم تحديثها دوريًا
  • المتصدرين في الوقت الحقيقي مع بعض هراء الشجرة
  • لوحة صدارة عشوائية (احتمالية) للترتيب التقريبي لقواعد اللاعبين الكبيرة جدًا

ماذا ستحتاج

  • إصدار حديث من Chrome (107 أو أحدث)
  • Node.js 16 أو أعلى (قم بتشغيل nvm --version لرؤية رقم الإصدار الخاص بك إذا كنت تستخدم nvm)
  • خطة Firebase Blaze المدفوعة (اختيارية)
  • Firebase CLI v11.16.0 أو أعلى
    لتثبيت واجهة سطر الأوامر، يمكنك تشغيل npm install -g firebase-tools أو الرجوع إلى وثائق سطر الأوامر لمزيد من خيارات التثبيت.
  • معرفة JavaScript وCloud Firestore والوظائف السحابية وأدوات Chrome DevTools

2. الإعداد

احصل على الرمز

لقد وضعنا كل ما تحتاجه لهذا المشروع في Git repo. للبدء، ستحتاج إلى الحصول على الكود وفتحه في بيئة التطوير المفضلة لديك. في هذا الدرس التطبيقي حول التعليمات البرمجية، استخدمنا VS Code، ولكن أي محرر نصوص سيفي بالغرض.

وفك ضغط الملف المضغوط الذي تم تنزيله.

أو قم بالاستنساخ في الدليل الذي تختاره:

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

ما هي نقطة البداية لدينا؟

مشروعنا حاليًا عبارة عن قائمة فارغة تحتوي على بعض الوظائف الفارغة:

  • يحتوي index.html على بعض البرامج النصية اللاصقة التي تتيح لنا استدعاء الوظائف من وحدة تحكم المطور ورؤية مخرجاتها. سنستخدم هذا للتواصل مع الواجهة الخلفية لدينا ونرى نتائج استدعاءات الوظائف لدينا. في سيناريو العالم الحقيقي، يمكنك إجراء هذه الاستدعاءات الخلفية من لعبتك مباشرة - نحن لا نستخدم لعبة في هذا الدرس التطبيقي للرموز لأن تشغيل لعبة سيستغرق وقتًا طويلاً في كل مرة تريد فيها إضافة نتيجة إلى لوحة المتصدرين .
  • functions/index.js يحتوي على جميع وظائفنا السحابية. ستشاهد بعض الوظائف المساعدة، مثل addScores و deleteScores ، بالإضافة إلى الوظائف التي سننفذها في هذا الدرس التطبيقي حول الترميز، والتي تستدعي وظائف مساعدة في ملف آخر.
  • تحتوي functions/functions-helpers.js على الوظائف الفارغة التي سنقوم بتنفيذها. بالنسبة لكل لوحة صدارة، سنقوم بتنفيذ وظائف القراءة والإنشاء والتحديث، وسترى كيف يؤثر اختيارنا للتنفيذ على كل من تعقيد التنفيذ وأداء التوسع الخاص به.
  • functions/utils.js يحتوي على المزيد من الوظائف المساعدة. لن نتطرق إلى هذا الملف في هذا الدرس التطبيقي حول الترميز.

إنشاء وتكوين مشروع Firebase

  1. في وحدة تحكم Firebase ، انقر فوق إضافة مشروع .
  2. لإنشاء مشروع جديد، أدخل اسم المشروع المطلوب.
    سيؤدي هذا أيضًا إلى تعيين معرف المشروع (المعروض أسفل اسم المشروع) على شيء يعتمد على اسم المشروع. يمكنك اختياريًا النقر فوق أيقونة التحرير الموجودة على معرف المشروع لتخصيصه بشكل أكبر.
  3. إذا طُلب منك، راجع شروط Firebase واقبلها.
  4. انقر فوق "متابعة" .
  5. حدد خيار تمكين Google Analytics لهذا المشروع ، ثم انقر فوق متابعة .
  6. حدد حساب Google Analytics موجودًا لاستخدامه أو حدد إنشاء حساب جديد لإنشاء حساب جديد.
  7. انقر فوق إنشاء مشروع .
  8. عندما يتم إنشاء المشروع، انقر فوق "متابعة" .
  9. من قائمة Build ، انقر فوق Functions ، وإذا طُلب منك ذلك، قم بترقية مشروعك لاستخدام خطة فوترة Blaze.
  10. من قائمة Build ، انقر فوق قاعدة بيانات Firestore .
  11. في مربع الحوار "إنشاء قاعدة بيانات" الذي يظهر، حدد "البدء في وضع الاختبار " ثم انقر فوق "التالي ".
  12. اختر منطقة من القائمة المنسدلة لموقع Cloud Firestore ، ثم انقر فوق "تمكين" .

تكوين وتشغيل المتصدرين الخاص بك

  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.

دعونا ننتقل إلى تنفيذ المتصدرين الأول.

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

وبعد ذلك، في وحدة تحكم 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 من استعلامات التجميع ، مما يجعل لوحة المتصدرين هذه أكثر أداءً. وستكون على حق! مع 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"),
  };
}

ولسوء الحظ، لن تتمكن من نشر هذا واختباره دون إضافة حساب فوترة إلى مشروعك. إذا كان لديك حساب فوترة، فقم بتقصير الفاصل الزمني للوظيفة المجدولة وشاهد وظيفتك وهي تقوم بطريقة سحرية بتعيين الرتب لنتائج لوحة المتصدرين الخاصة بك.

إذا لم يكن الأمر كذلك، فاحذف الوظيفة المجدولة وانتقل إلى التنفيذ التالي.

تابع واحذف النتائج الموجودة في قاعدة بيانات Firestore الخاصة بك عن طريق النقر على النقاط الثلاث بجوار مجموعة النتائج للتحضير للقسم التالي.

Firestore scores document page with\nDelete Collection activated

5. تنفيذ لوحة المتصدرين الشجرة في الوقت الحقيقي

يعمل هذا الأسلوب عن طريق تخزين بيانات البحث في مجموعة قاعدة البيانات نفسها. بدلاً من الحصول على مجموعة موحدة، هدفنا هو تخزين كل شيء في شجرة يمكننا اجتيازها من خلال التنقل عبر المستندات. يتيح لنا ذلك إجراء بحث ثنائي (أو n-ary) عن تصنيف درجة معينة. ما يمكن أن تبدو وكأنها؟

للبدء، نريد أن نكون قادرين على توزيع درجاتنا إلى مجموعات متساوية تقريبًا، الأمر الذي سيتطلب بعض المعرفة بقيم النتائج التي يسجلها مستخدمونا؛ على سبيل المثال، إذا كنت تقوم بإنشاء لوحة صدارة لتقييم المهارات في لعبة تنافسية، فسيتم دائمًا توزيع تقييمات مهارات المستخدمين بشكل طبيعي. تستخدم وظيفة توليد النتائج العشوائية الخاصة بنا 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,
  };
}

يتم ترك التحديثات كتمرين إضافي. حاول إضافة النتائج وجلبها في وحدة تحكم JS الخاصة بك باستخدام طريقتي leaderboard.addScore(id, score) و leaderboard.getRank(id) وشاهد كيف تتغير لوحة الصدارة الخاصة بك في وحدة تحكم Firebase.

ومع ذلك، مع هذا التنفيذ، فإن التعقيد الذي أضفناه لتحقيق الأداء اللوغاريتمي له تكلفة.

  • أولاً، يمكن أن يواجه تطبيق لوحة المتصدرين مشكلات تنافس القفل، نظرًا لأن المعاملات تتطلب قفل عمليات القراءة والكتابة للمستندات للتأكد من بقائها متسقة.
  • ثانيًا، يفرض Firestore حدًا لعمق المجموعة الفرعية يبلغ 100 ، مما يعني أنك ستحتاج إلى تجنب إنشاء أشجار فرعية بعد 100 نتيجة مرتبطة، وهو ما لا يفعله هذا التنفيذ.
  • وأخيرًا، يتم قياس لوحة المتصدرين هذه لوغاريتميًا فقط في الحالة المثالية حيث تكون الشجرة متوازنة - إذا كانت غير متوازنة، فإن أسوأ أداء للوحة المتصدرين هذه يكون خطيًا مرة أخرى.

بمجرد الانتهاء، احذف scores ومجموعات players عبر وحدة تحكم Firebase وسننتقل إلى آخر تنفيذ للوحة المتصدرين.

6. تنفيذ المتصدرين العشوائية (الاحتمالية).

عند تشغيل رمز الإدراج، قد تلاحظ أنه إذا قمت بتشغيله عدة مرات بالتوازي، فستبدأ وظائفك بالفشل مع ظهور رسالة خطأ تتعلق بالتنافس على قفل المعاملة. هناك طرق لحل هذه المشكلة لن نستكشفها في هذا الدرس التطبيقي حول التعليمات البرمجية، ولكن إذا لم تكن بحاجة إلى تصنيف دقيق، فيمكنك التخلص من كل التعقيدات الموجودة في الطريقة السابقة للحصول على شيء أبسط وأسرع. دعونا نلقي نظرة على كيفية إرجاع تصنيف تقديري لنتائج لاعبينا بدلاً من الترتيب الدقيق، وكيف يغير ذلك منطق قاعدة البيانات لدينا.

بالنسبة لهذا الأسلوب، سنقوم بتقسيم لوحة المتصدرين الخاصة بنا إلى 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 تولد توزيعًا موحدًا للنتائج ونستخدم الاستيفاء الخطي داخل المجموعات، فسنحصل على نتائج دقيقة للغاية، ولن يتدهور أداء لوحة المتصدرين لدينا مع زيادة عدد المستخدمين، ولا داعي للقلق بشأن تنافس القفل (بنفس القدر) عند تحديث الأعداد.

7. الإضافة: الغش

انتظر، ربما تفكر، إذا كنت أكتب قيمًا إلى مختبر التعليمات البرمجية الخاص بي عبر وحدة تحكم JS في علامة تبويب المتصفح، ألا يمكن لأي من اللاعبين أن يكذب على لوحة المتصدرين ويقول إنهم حصلوا على درجة عالية لم يحصلوا عليها تحقيق إلى حد ما؟

نعم يستطيعون. إذا كنت تريد منع الغش، فإن أقوى طريقة للقيام بذلك هي تعطيل كتابة العميل إلى قاعدة البيانات الخاصة بك عبر قواعد الأمان ، والوصول الآمن إلى وظائف السحابة الخاصة بك حتى لا يتمكن العملاء من الاتصال بهم مباشرة، ثم التحقق من صحة الإجراءات داخل اللعبة على الخادم الخاص بك قبل إرسال تحديثات النتيجة إلى المتصدرين.

من المهم ملاحظة أن هذه الإستراتيجية ليست علاجًا سحريًا ضد الغش - فمع وجود حافز كبير بما فيه الكفاية، يمكن للغشاشين إيجاد طرق للتحايل على عمليات التحقق من جانب الخادم، والعديد من ألعاب الفيديو الكبيرة والناجحة تلعب باستمرار لعبة القط والفأر مع الغشاشين للتعرف عليهم. عمليات غش جديدة ومنعها من الانتشار. والنتيجة الصعبة لهذه الظاهرة هي أن التحقق من جانب الخادم لكل لعبة يتم بطبيعته حسب الطلب؛ على الرغم من أن Firebase يوفر أدوات لمكافحة إساءة الاستخدام مثل App Check والتي ستمنع المستخدم من نسخ لعبتك عبر عميل نصي بسيط، إلا أن Firebase لا يقدم أي خدمة ترقى إلى مستوى مكافحة الغش الشاملة.

أي شيء أقل من التحقق من جانب الخادم، بالنسبة للعبة مشهورة بدرجة كافية أو حاجز منخفض بما يكفي للغش، سيؤدي إلى لوحة صدارة حيث تكون القيم العليا جميعها غشاشين.

8. تهانينا

تهانينا، لقد نجحت في إنشاء أربع لوحات صدارة مختلفة على Firebase! اعتمادًا على احتياجات لعبتك من الدقة والسرعة، ستتمكن من اختيار اللعبة التي تناسبك وبتكلفة معقولة.

بعد ذلك، تحقق من مسارات التعلم للألعاب.