Firestore ile skor tabloları oluşturun

1. Giriş

Son Güncelleme: 2023-01-27

Liderlik tablosu oluşturmak için ne gerekir?

Özünde skor tabloları, karmaşık bir faktöre sahip puan tablolarından ibarettir: herhangi bir puan için bir sıralamayı okumak, diğer tüm puanların bir tür sırayla bilinmesini gerektirir. Ayrıca, oyununuz başarılı olursa skor tablolarınız büyüyecek ve sık sık okunacak ve yazılacaktır. Başarılı bir liderlik tablosu oluşturmak için bu sıralama işlemini hızlı bir şekilde gerçekleştirebilmesi gerekir.

Ne inşa edeceksin

Bu codelab'de her biri farklı bir senaryoya uygun olan çeşitli farklı liderlik tablolarını uygulayacaksınız.

Ne öğreneceksin

Dört farklı skor tablosunu nasıl uygulayacağınızı öğreneceksiniz:

  • Sıralamayı belirlemek için basit kayıt saymayı kullanan saf bir uygulama
  • Ucuz, periyodik olarak güncellenen bir skor tablosu
  • Bazı ağaç saçmalıklarıyla gerçek zamanlı bir liderlik tablosu
  • Çok büyük oyuncu tabanlarının yaklaşık sıralaması için stokastik (olasılıksal) bir liderlik tablosu

İhtiyacınız olan şey

  • Chrome'un yeni bir sürümü (107 veya üzeri)
  • Node.js 16 veya üzeri (nvm kullanıyorsanız sürüm numaranızı görmek için nvm --version çalıştırın)
  • Ücretli bir Firebase Blaze planı (isteğe bağlı)
  • Firebase CLI v11.16.0 veya üzeri
    CLI'yi yüklemek için npm install -g firebase-tools çalıştırabilir veya daha fazla kurulum seçeneği için CLI belgelerine bakabilirsiniz.
  • JavaScript, Cloud Firestore, Cloud Functions ve Chrome DevTools bilgisi

2. Kurulum

Kodu al

Bu proje için ihtiyacınız olan her şeyi Git deposuna koyduk. Başlamak için kodu alıp favori geliştirme ortamınızda açmanız gerekir. Bu codelab için VS Code'u kullandık, ancak herhangi bir metin düzenleyici de işe yarayacaktır.

ve indirilen zip dosyasını açın.

Veya seçtiğiniz dizine kopyalayın:

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

Başlangıç ​​noktamız nedir?

Projemiz şu anda bazı boş işlevler içeren boş bir sayfadır:

  • index.html geliştirici konsolundan işlevleri çağırmamıza ve çıktılarını görmemize olanak tanıyan bazı yapıştırıcı komut dosyaları içerir. Bunu arka ucumuzla arayüz oluşturmak ve işlev çağrılarımızın sonuçlarını görmek için kullanacağız. Gerçek dünya senaryosunda, bu arka uç çağrılarını doğrudan oyununuzdan yaparsınız; bu codelab'de bir oyun kullanmıyoruz çünkü liderlik tablosuna her puan eklemek istediğinizde oyun oynamak çok uzun sürecektir. .
  • functions/index.js tüm Bulut İşlevlerimizi içerir. addScores ve deleteScores gibi bazı yardımcı işlevlerin yanı sıra, bu codelab'de uygulayacağımız ve başka bir dosyadaki yardımcı işlevleri çağıran işlevleri göreceksiniz.
  • functions/functions-helpers.js uygulayacağımız boş fonksiyonları içerir. Her liderlik tablosu için okuma, oluşturma ve güncelleme işlevlerini uygulayacağız ve uygulama seçimimizin hem uygulamamızın karmaşıklığını hem de ölçeklendirme performansını nasıl etkilediğini göreceksiniz.
  • functions/utils.js daha fazla yardımcı işlev içerir. Bu codelab'de bu dosyaya dokunmayacağız.

Firebase projesi oluşturma ve yapılandırma

  1. Firebase konsolunda Proje ekle'yi tıklayın.
  2. Yeni bir proje oluşturmak için istediğiniz proje adını girin.
    Bu aynı zamanda proje kimliğini (proje adının altında görüntülenen) proje adına dayalı bir şeye ayarlayacaktır. İsteğe bağlı olarak proje kimliğini daha da özelleştirmek için düzenleme simgesine tıklayabilirsiniz.
  3. İstenirse Firebase şartlarını inceleyip kabul edin.
  4. Devam'ı tıklayın.
  5. Bu proje için Google Analytics'i etkinleştir seçeneğini seçin ve ardından Devam'ı tıklayın.
  6. Kullanmak için mevcut bir Google Analytics hesabını seçin veya yeni bir hesap oluşturmak için Yeni hesap oluştur'u seçin.
  7. Proje oluştur'u tıklayın.
  8. Proje oluşturulduğunda Devam'a tıklayın.
  9. Oluştur menüsünden İşlevler'e tıklayın ve istenirse projenizi Blaze faturalandırma planını kullanacak şekilde yükseltin.
  10. Oluştur menüsünden Firestore veritabanı öğesine tıklayın.
  11. Görüntülenen Veritabanı oluştur iletişim kutusunda Test modunda başlat öğesini seçin ve ardından İleri öğesine tıklayın.
  12. Cloud Firestore konumu açılır menüsünden bir bölge seçin ve ardından Etkinleştir'e tıklayın.

Skor tablonuzu yapılandırın ve çalıştırın

  1. Bir terminalde proje köküne gidin ve firebase use --add çalıştırın. Yeni oluşturduğunuz Firebase projesini seçin.
  2. Projenin kökünde firebase emulators:start --only hosting çalıştırın.
  3. Tarayıcınızda localhost:5000 adresine gidin.
  4. Chrome DevTools'un JavaScript konsolunu açın ve leaderboard.js içe aktarın:
    const leaderboard = await import("http://localhost:5000/scripts/leaderboard.js");
    
  5. leaderboard.codelab(); konsolda. Bir hoş geldiniz mesajı görürseniz her şey hazır demektir! Değilse öykünücüyü kapatın ve 2-4. adımları yeniden çalıştırın.

İlk liderlik tablosu uygulamasına geçelim.

3. Basit bir skor tablosu uygulayın

Bu bölümün sonunda liderlik tablosuna bir puan ekleyebileceğiz ve sıralamamızın bize bildirilmesini sağlayabileceğiz.

Başlamadan önce, bu liderlik tablosu uygulamasının nasıl çalıştığını açıklayalım: Tüm oyuncular tek bir koleksiyonda saklanır ve bir oyuncunun sıralaması, koleksiyonun alınması ve önlerinde kaç oyuncunun olduğu sayılarak yapılır. Bu, puan eklemeyi ve güncellemeyi kolaylaştırır. Yeni bir puan eklemek için onu koleksiyona ekleriz ve güncellemek için mevcut kullanıcımıza göre filtreler ve ardından ortaya çıkan belgeyi güncelleriz. Bunun kodda neye benzediğini görelim.

functions/functions-helper.js dosyasında, oldukça basit olan createScore işlevini uygulayın:

async function createScore(score, playerID, firestore) {
  return firestore.collection("scores").doc().create({
    user: playerID,
    score: score,
  });
}

Puanları güncellemek için, güncellenen puanın zaten mevcut olduğundan emin olmak için bir hata kontrolü eklememiz yeterlidir:

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,
  });
}

Ve son olarak basit ama daha az ölçeklenebilir sıralama fonksiyonumuz:

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}`);
}

Hadi test edelim! Terminalde aşağıdakileri çalıştırarak işlevlerinizi dağıtın:

firebase deploy --only functions

Daha sonra Chrome'un JS konsoluna başka puanlar ekleyin, böylece diğer oyuncular arasındaki sıralamamızı görebiliriz.

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

Artık karışıma kendi puanımızı ekleyebiliriz:

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

Yazma tamamlandığında konsolda "Puan oluşturuldu" diyen bir yanıt görmelisiniz. Bunun yerine bir hata mı görüyorsunuz? Neyin yanlış gittiğini görmek için Firebase konsolu aracılığıyla İşlev günlüklerini açın.

Ve son olarak puanımızı alıp güncelleyebiliriz.

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

Ancak bu uygulama bize belirli bir puanın sıralamasını almak için istenmeyen doğrusal zaman ve bellek gereksinimleri verir. İşlev yürütme süresi ve belleğin her ikisi de sınırlı olduğundan, bu yalnızca getirmelerimizin giderek yavaşlaması anlamına gelmekle kalmayacak, aynı zamanda liderlik tablosuna yeterli puan eklendikten sonra işlevlerimizin bir sonuç döndürmeden önce zaman aşımına uğraması veya çökmesi anlamına gelecektir. Açıkçası, eğer bir avuç oyuncunun ötesine geçeceksek daha iyi bir şeye ihtiyacımız olacak.

Firestore meraklısıysanız COUNT toplama sorgusunun farkında olabilirsiniz; bu da bu skor tablosunu çok daha performanslı hale getirir. Ve haklısın! COUNT sorgularıyla bu, performansı hala doğrusal olmasına rağmen bir milyon kadar kullanıcının oldukça altına ölçeklenir.

Ama durun, kendi kendinize düşünebilirsiniz, eğer koleksiyondaki tüm belgeleri yine de numaralandıracaksak, her belgeye bir derece atayabiliriz ve sonra onu getirmemiz gerektiğinde, getirmelerimiz O(1) olacaktır. zaman ve hafıza! Bu bizi bir sonraki yaklaşımımıza, periyodik olarak güncellenen skor tablosuna götürüyor.

4. Periyodik olarak güncellenen bir skor tablosu uygulayın

Bu yaklaşımın anahtarı, sıralamayı belgenin kendisinde saklamaktır, böylece onu getirmek bize hiçbir ek iş yapmadan sıralamayı verir. Bunu başarmak için yeni bir tür fonksiyona ihtiyacımız olacak.

index.js aşağıdakileri ekleyin:

// 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;
    });

Artık okuma, güncelleme ve yazma operasyonlarımızın hepsi güzel ve basit. Yazma ve güncelleme değişmedi, ancak okuma şu hale geldi ( 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"),
  };
}

Maalesef projenize bir faturalandırma hesabı eklemeden bunu dağıtıp test edemezsiniz. Bir faturalandırma hesabınız varsa, planlanan işlevin aralığını kısaltın ve işlevinizin sihirli bir şekilde skor tablosu puanlarınıza dereceler atamasını izleyin.

Değilse, zamanlanmış işlevi silin ve bir sonraki uygulamaya geçin.

Devam edin ve bir sonraki bölüme hazırlanmak için puan koleksiyonunun yanındaki 3 noktaya tıklayarak Firestore veritabanınızdaki puanları silin.

Firestore scores document page with\nDelete Collection activated

5. Gerçek zamanlı bir ağaç sıralama tablosu uygulayın

Bu yaklaşım, arama verilerini veritabanı koleksiyonunun kendisinde depolayarak çalışır. Amacımız tek tip bir koleksiyona sahip olmak yerine, her şeyi belgeler arasında hareket ederek geçebileceğimiz bir ağaçta depolamaktır. Bu, belirli bir puanın sıralaması için ikili (veya n-ary) arama yapmamızı sağlar. Bu neye benzeyebilir?

Başlangıç ​​olarak, puanlarımızı kabaca eşit gruplara dağıtabilmek isteyeceğiz; bu, kullanıcılarımızın kaydettiği puanların değerleri hakkında biraz bilgi sahibi olmayı gerektirecektir; örneğin, rekabetçi bir oyunda beceri derecelendirmesi için bir liderlik tablosu oluşturuyorsanız, kullanıcılarınızın beceri derecelendirmeleri neredeyse her zaman normal şekilde dağılacaktır. Rastgele puan oluşturma işlevimiz, JavaScript'in Math.random() kullanır, bu da yaklaşık olarak eşit bir dağıtımla sonuçlanır, böylece paketlerimizi eşit olarak böleriz.

Bu örnekte basitlik açısından 3 paket kullanacağız, ancak büyük olasılıkla bu uygulamayı gerçek bir uygulamada kullanırsanız daha fazla grubun daha hızlı sonuçlar vereceğini göreceksiniz; daha sığ bir ağaç, ortalama olarak daha az koleksiyon getirme ve daha az kilit çekişmesi anlamına gelir.

Bir oyuncunun sıralaması, daha yüksek puana sahip oyuncuların sayısı artı oyuncunun kendisi için bir puanın toplamı ile verilir. scores altındaki her koleksiyon, her biri bir aralık içeren üç belgeyi, her aralığın altındaki belge sayısını ve ardından karşılık gelen üç alt koleksiyonu depolayacaktır. Bir sıralamayı okumak için bu ağacı geçerek bir puan arayacağız ve daha büyük puanların toplamını takip edeceğiz. Puanımızı bulduğumuzda doğru toplamı da elde etmiş olacağız.

Yazmak çok daha karmaşıktır. İlk olarak, aynı anda birden fazla yazma veya okuma meydana geldiğinde veri tutarsızlıklarını önlemek için tüm yazmalarımızı bir işlem içinde yapmamız gerekecek. Yeni belgelerimizi yazmak için ağacın içinden geçerken yukarıda tanımladığımız tüm koşulları da korumamız gerekecek. Ve son olarak, bu yeni yaklaşımın tüm ağaç karmaşıklığıyla birlikte tüm orijinal belgelerimizi saklama gereksinimine sahip olduğumuzdan, depolama maliyetimiz biraz artacaktır (ancak yine de doğrusaldır).

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,
      });
    });
  });
}

Bu kesinlikle tek bir yöntem çağrısı ve yalnızca altı satır koddan oluşan son uygulamamızdan daha karmaşıktır. Bu yöntemi uyguladıktan sonra veritabanına birkaç puan eklemeyi ve ortaya çıkan ağacın yapısını gözlemlemeyi deneyin. JS konsolunuzda:

leaderboard.addScores();

Ortaya çıkan veritabanı yapısı, ağaç yapısının açıkça görülebildiği ve ağacın yapraklarının bireysel puanları temsil ettiği şekilde buna benzemelidir.

scores
  - document
    range: 0-333.33
    count: 2
    scores:
      - document
        exact:
          score: 18
          user: 1
      - document
        exact:
          score: 22
          user: 2

Artık zor kısmı aradan çıkardığımıza göre, daha önce açıklandığı gibi ağacı geçerek puanları okuyabiliriz.

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,
  };
}

Güncellemeler ekstra bir egzersiz olarak bırakılmıştır. JS konsolunuzda leaderboard.addScore(id, score) ve leaderboard.getRank(id) yöntemleriyle puanları eklemeyi ve almayı deneyin ve Firebase konsolunda skor tablonuzun nasıl değiştiğini görün.

Ancak bu uygulamayla birlikte logaritmik performansa ulaşmak için eklediğimiz karmaşıklığın bir maliyeti var.

  • İlk olarak, işlemler tutarlı kalmalarını sağlamak için belgelerdeki okuma ve yazma işlemlerinin kilitlenmesini gerektirdiğinden, bu lider tablosu uygulaması kilit çekişme sorunlarıyla karşılaşabilir.
  • İkincisi, Firestore 100'lük bir alt koleksiyon derinliği sınırı uygular; bu, 100 berabere kalan puandan sonra alt ağaçlar oluşturmaktan kaçınmanız gerektiği anlamına gelir; bu uygulamada bunu yapmaz.
  • Ve son olarak, bu skor tablosu yalnızca ağacın dengeli olduğu ideal durumda logaritmik olarak ölçeklenir; dengesizse bu skor tablosunun en kötü durum performansı bir kez daha doğrusaldır.

İşiniz bittiğinde, Firebase konsolu aracılığıyla scores ve players koleksiyonlarını silin; son liderlik tablosu uygulamamıza geçeceğiz.

6. Stokastik (olasılığa dayalı) bir skor tablosu uygulayın

Ekleme kodunu çalıştırırken, paralel olarak çok sayıda çalıştırırsanız işlevlerinizin işlem kilidi çekişmesiyle ilgili bir hata mesajı vererek başarısız olmaya başlayacağını fark edebilirsiniz. Bunu aşmanın bu codelab'de keşfetmeyeceğimiz yolları var, ancak kesin sıralamaya ihtiyacınız yoksa, hem daha basit hem de daha hızlı bir şey için önceki yaklaşımın tüm karmaşıklığından vazgeçebilirsiniz. Oyuncularımızın puanları için kesin bir sıralama yerine nasıl tahmini bir sıralama döndürebileceğimize ve bunun veritabanı mantığımızı nasıl değiştirdiğine bir göz atalım.

Bu yaklaşım için liderlik tablomuzu her biri almayı beklediğimiz puanların yaklaşık yüzde birini temsil eden 100 gruba böleceğiz. Bu yaklaşım, puan dağılımımız hakkında bilgi sahibi olmasak bile işe yarar; bu durumda, puanların grup boyunca kabaca eşit dağılımını garanti etmenin hiçbir yolu yoktur, ancak puanlarımızın nasıl dağıtılacağını bilirsek, tahminlerimizde daha fazla hassasiyet elde ederiz. .

Yaklaşımımız şu şekildedir: Daha önce olduğu gibi, her kova, içindeki puanların sayısını ve puan aralığını saklar. Yeni bir puan eklerken, puana ait kovayı bulacağız ve sayısını artıracağız. Bir sıralamayı getirirken, daha fazla arama yapmak yerine sadece önündeki kümeleri toplayacağız ve ardından kendi grubumuz içinde yaklaşık değerler alacağız. Bu bize çok güzel sabit zamanlı aramalar ve eklemeler sağlar ve çok daha az kod gerektirir.

İlk olarak ekleme:

// 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();
    }
  }
}

Bu ekleme kodunun, üretimde böyle bir şey yapılmaması yönünde bir uyarıyla birlikte veritabanı durumunuzu en üstte başlatmak için bir mantığı olduğunu fark edeceksiniz. Başlatma kodu, yarış koşullarına karşı hiçbir şekilde korunmaz; bu nedenle, bunu yaparsanız, birden fazla eşzamanlı yazma işlemi, size bir sürü yinelenen paket vererek veritabanınızı bozar.

Devam edin ve işlevlerinizi dağıtın ve ardından tüm paketleri sıfır sayısıyla başlatmak için bir ekleme çalıştırın. Güvenle göz ardı edebileceğiniz bir hata döndürecektir.

leaderboard.addScore(999, 0); // The params aren't important here.

Artık veritabanı doğru bir şekilde başlatıldığına göre, addScores çalıştırabilir ve verilerimizin yapısını Firebase konsolunda görebiliriz. Sonuçta ortaya çıkan yapı, yüzeysel olarak benzer olsa da, son uygulamamızdan çok daha düzdür.

leaderboard.addScores();

Ve şimdi puanları okumak için:

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 işlevinin puanların eşit bir dağılımını oluşturmasını sağladığımızdan ve gruplar içinde doğrusal enterpolasyon kullandığımızdan, çok doğru sonuçlar alacağız, kullanıcı sayısını artırdıkça lider tablomuzun performansı düşmeyecektir. ve sayıları güncellerken kilit çekişmesi konusunda (o kadar da) endişelenmemize gerek yok.

7. Ek: Hile

Bir dakika, eğer kod laboratuarıma bir tarayıcı sekmesinin JS konsolu aracılığıyla değerler yazıyorsam, oyuncularımdan herhangi biri skor tablosuna yalan söyleyip yüksek bir puan aldıklarını söyleyemez mi diye düşünüyor olabilirsiniz. adil bir şekilde ulaşmak mı?

Evet yapabilirler. Hile yapmayı önlemek istiyorsanız, bunu yapmanın en sağlam yolu, güvenlik kuralları aracılığıyla istemcinin veritabanınıza yazmasını devre dışı bırakmak, istemcilerin onları doğrudan arayamaması için Bulut İşlevlerinize erişimi güvenli hale getirmek ve ardından sunucunuzdaki oyun içi eylemleri önceden doğrulamaktır. Skor güncellemelerini skor tablosuna gönderme.

Bu stratejinin hileye karşı her derde deva olmadığını belirtmek önemlidir; yeterince büyük bir teşvikle, hile yapanlar sunucu tarafı doğrulamalarını atlatmanın yollarını bulabilir ve birçok büyük, başarılı video oyunu, hile yapanları tespit etmek için sürekli olarak hile yapanlarla kedi-fare oyunu oynar. yeni hileler ve bunların çoğalmasını engelleyin. Bu olgunun zor bir sonucu, her oyun için sunucu tarafı doğrulamasının doğası gereği özel olarak yapılmasıdır; Firebase, bir kullanıcının oyununuzu basit bir komut dosyası içeren istemci aracılığıyla kopyalamasını önleyecek Uygulama Kontrolü gibi kötüye kullanım karşıtı araçlar sunsa da, Firebase bütünsel bir hile önleme anlamına gelen herhangi bir hizmet sağlamaz.

Yeterince popüler bir oyun veya hileye karşı yeterince düşük bir engel için, sunucu tarafı doğrulamasının yetersiz olduğu herhangi bir şey, en yüksek değerlerin hepsinin hileci olduğu bir liderlik tablosuyla sonuçlanacaktır.

8. Tebrikler

Tebrikler, Firebase'de başarıyla dört farklı skor tablosu oluşturdunuz! Oyununuzun kesinlik ve hız ihtiyaçlarına bağlı olarak, sizin için uygun olanı makul bir maliyetle seçebileceksiniz.

Sırada oyunların öğrenme yollarına göz atın.