Firestore ile skor tabloları oluşturun

1. Giriş

Son güncelleme tarihi: 27.01.2023

Skor tablosu oluşturmak için ne gerekir?

Skor tabloları, temelde karmaşık bir faktör içeren skor tablolarıdır: Herhangi bir skor için bir sıralamayı okumak, diğer tüm puanları bir şekilde belirli bir sırayla bilmek gerektirir. Ayrıca, oyununuz yükselirse skor tablolarınız da büyür ve sık sık okunup yazılır. Başarılı bir skor tablosu oluşturmak için bu sıralama işlemini hızlı bir şekilde yapabilmesi gerekir.

Neler oluşturacaksınız?

Bu codelab'de her biri farklı bir senaryo için uygun olan farklı skor tabloları uygulayacaksınız.

Neler öğreneceksiniz?

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

  • Sıralamayı belirlemek için basit kayıt sayımı kullanan naif bir uygulama
  • Düzenli olarak güncellenen ucuz bir leaderboard
  • Eğlenceli ağaçlar içeren gerçek zamanlı skor tablosu
  • Çok büyük oyuncu tabanlarının yaklaşık sıralaması için olasılıksal (olasılık) bir skor tablosu

Gerekenler

  • Chrome'un yeni bir sürümü (107 veya sonraki sürümler)
  • Node.js 16 veya sonraki sürümleri (nvm kullanıyorsanız sürüm numaranızı görmek için nvm --version komutunu çalıştırın)
  • Ücretli bir Firebase Blaze planı (isteğe bağlı)
  • Firebase CLI v11.16.0 veya sonraki sürümü
    CLI'yı yüklemek için npm install -g firebase-tools komutunu çalıştırabilir veya daha fazla yükleme seçeneği için KSA dokümanlarına bakabilirsiniz.
  • JavaScript, Cloud Firestore, Cloud Functions ve Chrome Geliştirici Araçları bilgisi

2. Kurulum

Kodu edinin

Bu proje için ihtiyacınız olan her şeyi bir Git deposuna yerleştirdik. Başlamak için kodu alıp favori geliştirme ortamınızda açmanız gerekecek. Bu codelab için VS Code'u kullandık ancak tüm metin düzenleyici kullanabilirsiniz.

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

Alternatif olarak, istediğiniz dizine klonlayabilirsiniz:

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 liste:

  • index.html, işlevleri geliştirici konsolundan çağırmamızı ve çıkışlarını görmemizi sağlayan bazı yapışkan 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 hayattan bir senaryoda, bu arka uç çağrılarını doğrudan oyununuzdan yaparsınız. Bu codelab'de bir oyun kullanmıyoruz çünkü skor tablosuna her skor eklemek istediğinizde oyun oynamak çok uzun sürecektir.
  • functions/index.js, tüm Cloud Functions işlevlerimizi içerir. addScores ve deleteScores gibi bazı yardımcı program işlevlerinin yanı sıra, bu codelab'de uygulayacağımız ve başka bir dosyadaki yardımcı işlevlere atıfta bulunan işlevler de göreceksiniz.
  • functions/functions-helpers.js, uygulayacağımız boş işlevleri içeriyor. Her bir skor tablosu için okuma, oluşturma ve güncelleme işlevleri 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ı program işlevi içeriyor. Bu codelab'de bu dosyaya değinmeyeceğiz.

Firebase projesi oluşturma ve yapılandırma

  1. Firebase konsolunda Proje ekle'yi tıklayın.
  2. Yeni proje oluşturmak için istediğiniz proje adını girin.
    Bu işlem ayrıca proje kimliğini (proje adının altında gösterilir) proje adına bağlı olarak bir değere ayarlar. İsteğe bağlı olarak proje kimliğindeki düzenle simgesini tıklayarak kimliği daha fazla özelleştirebilirsiniz.
  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 belirleyip 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. Create project (Proje oluştur) seçeneğini tıklayın.
  8. Proje oluşturulduktan sonra Continue (Devam) seçeneğini tıklayın.
  9. Build (Derleme) menüsünden Functions'ı (İşlevler) tıklayın. Sizden istenirse projenizi yükselterek Blaze faturalandırma planını kullanın.
  10. Build (Derleme) menüsünde, Firestore database'i (Firestore veritabanı) tıklayın.
  11. Açılan Veritabanı oluştur iletişim kutusunda Test modunda başlat'ı seçin ve ardından İleri'yi tıklayın.
  12. Cloud Firestore konumu açılır menüsünden bir bölge seçip Etkinleştir'i tıklayın.

Leaderboard'unuzu yapılandırın ve çalıştırın

  1. Terminalde proje köküne gidip firebase use --add komutunu çalıştırın. Yeni oluşturduğunuz Firebase projesini seçin.
  2. Projenin kök dizininde firebase emulators:start --only hosting komutunu çalıştırın.
  3. Tarayıcınızda localhost:5000 adresine gidin.
  4. Chrome Geliştirici Araçları'nın JavaScript konsolunu açın ve leaderboard.js dosyasını içe aktarın:
    const leaderboard = await import("http://localhost:5000/scripts/leaderboard.js");
    
  5. Konsolda leaderboard.codelab(); komutunu çalıştırın. Bir karşılama mesajı görüyorsanız hazırsınız demektir. Değilse emülatörü kapatın ve 2-4 arasındaki adımları yeniden uygulayın.

İlk skor tablosu uygulamasını inceleyelim.

3. Basit bir leaderboard uygulama

Bu bölümün sonunda skor tablosuna bir skor ekleyebilir ve bu skorun sıralamamızı bilmesini sağlayabiliriz.

Başlamadan önce, bu skor tablosu uygulamasının nasıl çalıştığını açıklayalım: Tüm oyuncular tek bir koleksiyonda depolanır ve bir oyuncunun sıralaması alınırken koleksiyon kaç oyuncunun önde olduğu sayılarak yapılır. Böylece puan ekleme ve güncelleme işlemleri kolaylaşır. Yeni puan eklemek için bunu koleksiyona ekleriz ve güncellemek için mevcut kullanıcımıza göre filtre uygularız ve ardından elde edilen dokümanı güncelleriz. Kodda bunun nasıl göründüğüne bakalım.

functions/functions-helper.js ürününde createScore işlevini uygulayın. Bu işlev son derece basittir:

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

Puanları güncellerken puanın zaten mevcut olup olmadığını kontrol etmek 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,
  });
}

Son olarak, basit ancak daha az ölçeklenebilir sıralama işlevimiz:

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 yapalım! Terminalde aşağıdaki komutu çalıştırarak işlevlerinizi dağıtın:

firebase deploy --only functions

Sonra, Chrome'un JS konsolunda diğer oyuncular arasındaki sıralamamızı görebilmek için başka skorlar da ekleyin.

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

Şimdi kendi puanımızı da kendimize ekleyebiliriz:

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

Yazma işlemi tamamlandığında, konsolda "Puan oluşturuldu" ifadesini içeren bir yanıt görürsünüz. Bunun yerine bir hata mı görüyorsunuz? Sorunu görmek için Firebase konsolu üzerinden Functions günlüklerini açın.

Son olarak, puanımızı getirip güncelleyebiliriz.

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

Ancak, bu uygulama, belirli bir puanın sıralamasını getirmek için istenmeyen doğrusal zaman ve bellek gereksinimleri uygular. İşlev yürütme süresi ve bellek hem sınırlı olduğundan hem getirme işlemlerimiz giderek yavaşlar hem de skor tablosuna yeterli puan eklendikten sonra işlevlerimiz sonuç döndürmeden zaman aşımına uğrar veya çöker. Açıkçası, bir dizi oyuncuyu aşacak şekilde ölçeklenirsek daha iyi şeylere ihtiyacımız olacak.

Firestore tutkunuysanız bu skor tablosunun çok daha yüksek performans göstermesini sağlayacak COUNT toplama sorgusu hakkında bilgi sahibi olabilirsiniz. Elbette haklısınız! COUNT sorgularında, performansı doğrusal olsa da bu, yaklaşık bir milyon kullanıcının altında ölçeklendirilir.

Ama durup düşünün: Zaten koleksiyondaki tüm belgeleri numaralandıracaksak her belgeye bir rütbe atayabiliriz. Sonra da getirmemiz gerektiğinde getirmelerimiz O(1) zaman ve hafıza olacaktır! Bu da bizi bir sonraki yaklaşımımız olan düzenli olarak güncellenen skor tablosuna yönlendiriyor.

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

Bu yaklaşımın anahtarı, sıralamayı belgenin kendisinde depolamaktır. Bu nedenle, onu getirmek, herhangi bir ek işlem yapmadan sıralamayı elde etmemizi sağlar. Bunun için yeni bir işlev türüne ihtiyacımız olacak.

index.js alanına 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 işlemlerimiz gayet iyi ve basit. Yazma ve güncelleme işlemleri değişmez ancak okuma olur (functions-helpers.js içinde):

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 faturalandırma hesabı eklemeden bu hizmeti dağıtıp test edemezsiniz. Faturalandırma hesabınız varsa, planlanmış işlevdeki aralığı kısaltın ve işlevinizin, skor tablosu puanlarınıza sıraları otomatik olarak atadığını izleyin.

Aksi halde, planlanmış işlevi silin ve bir sonraki uygulamaya geçin.

Bir sonraki bölüme hazırlanmak için puan koleksiyonunun yanındaki 3 noktayı tıklayarak Firestore veritabanınızdaki puanları silin.

Firestore puan dokümanı sayfası\nKoleksiyonu Sil etkin

5. Gerçek zamanlı ağaç skor tablosu uygulama

Bu yaklaşım, arama verilerini veritabanı koleksiyonunda depolayarak çalışır. Tek tip bir koleksiyona sahip olmak yerine, hedefimiz belgeler arasında gezinerek içinde bulunabileceğimiz her şeyi bir ağaçta depolamaktır. Bu, belirli bir puanın sıralaması için ikili (veya n öğeli) arama gerçekleştirmemize olanak tanır. Bu nasıl görünebilir?

Başlangıç olarak, puanlarımızı yaklaşık olarak eşit gruplara dağıtabilmek isteriz. Bu yöntem, kullanıcılarımızın kaydettiği puanların değerleri hakkında biraz bilgi sahibi olmayı gerektirir; Örneğin, rekabetçi bir oyunda beceri derecelendirmesi için bir skor tablosu oluşturuyorsanız kullanıcılarınızın beceri derecelendirmeleri neredeyse her zaman normal şekilde dağılır. Rastgele puan oluşturma işlevimiz JavaScript'in Math.random() işlevini kullanır. Bu da yaklaşık olarak eşit bir dağılımla sonuçlanır. Böylece paketlerimizi eşit bir şekilde böleriz.

Bu örnekte, basitlik için 3 paket kullanacağız ancak bu uygulamayı gerçek bir uygulamada kullanırsanız daha fazla paketin daha hızlı sonuç vereceğini görebilirsiniz. Daha sığ bir ağaç, ortalama olarak daha az koleksiyon getirmesi ve daha az kilit anlaşmazlığı anlamına gelir.

Bir oyuncunun sıralaması, daha yüksek skora sahip oyuncuların sayısının toplamı ile oyuncunun kendisi için bir toplamıyla belirlenir. scores altındaki her koleksiyonda üç doküman depolanır. Her koleksiyonda bir aralık, her bir aralıktaki doküman sayısı ve ardından bunlara karşılık gelen üç alt koleksiyon bulunur. Bir sıralamayı okumak için, skoru arayarak ve en yüksek skorların toplamını izleyerek bu ağacı katlayacağız. Puanımızı bulduğumuzda, doğru toplamı da buluruz.

Yazmak çok daha karmaşıktır. Öncelikle, aynı anda birden fazla yazma veya okuma işlemi gerçekleştiğinde veri tutarsızlıklarını önlemek için tüm yazma işlemlerimizi işlem içinde yapmamız gerekir. Ayrıca, yeni dokümanlarımızı yazmak için ağacın dışına giderken yukarıda açıkladığımız tüm koşulları korumamız gerekir. Son olarak, bu yeni yaklaşımın tüm ağaç karmaşıklığını tüm orijinal belgelerimizi depolama ihtiyacıyla bir araya getirdiğimizden, depolama maliyetimiz biraz artacaktır (ancak yine de doğrusal olacaktır).

functions-helpers.js ürününde:

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, tek bir yöntem çağrısı ve yalnızca altı kod satırı olan son uygulamamızdan kesinlikle daha karmaşık. 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();

Elde edilen veritabanı yapısı aşağıdaki gibi görünmelidir: Ağaç yapısı net bir şekilde görünür ve ağacın yaprakları bireysel puanları temsil eder.

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

Artık işin zor kısmını hallettiğimize göre, önceden açıklandığı gibi ağacın üzerinden 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 ek bir alıştırma olarak bırakılmıştır. leaderboard.addScore(id, score) ve leaderboard.getRank(id) yöntemlerini kullanarak JS konsolunuzda skorları ekleyip getirmeyi deneyin ve skor tablosunun nasıl değiştiğini Firebase konsolunda görün.

Ancak bu uygulama ile, logaritmik performans elde etmek amacıyla eklediğimiz karmaşıklığın bir maliyeti vardır.

  • Öncelikle, bu skor tablosu uygulaması, tutarlılık sağlamak için belgelere okuma ve yazma işlemlerinin kilitlenmesini gerektirdiğinden kilitlenme çakışması sorunlarıyla karşılaşabilir.
  • İkinci olarak, Firestore 100 alt koleksiyon derinliği sınırı uygular. Bu durumda, 100 berabere puandan sonra alt ağaçlar oluşturmaktan kaçınmanız gerekir. Bu durumda alt ağaçlar oluşturmaktan kaçınmanız gerekir.
  • Son olarak, bu skor tablosu yalnızca ağacın dengeli olduğu ideal durumda logaritmik olarak ölçeklenir. Dengeli değilse bu skor tablosunun en kötü performansı yine doğrusal olur.

İşlem tamamlandığında scores ve players koleksiyonlarını Firebase konsolu üzerinden sildiğinizde son skor tablosu uygulamamıza geçeriz.

6. Olasılıksal (olasılık) bir skor tablosu uygulama

Ekleme kodunu çalıştırırken, paralel olarak çok fazla kez çalıştırırsanız işlevlerinizin işlem kilidi anlaşmazlığıyla ilgili bir hata mesajıyla başarısız olmaya başlayacağını fark edebilirsiniz. Bu konuyla ilgili, bu codelab'de ele almayacağımız bazı yöntemler vardır. Ancak tam sıralamaya ihtiyacınız yoksa önceki yaklaşımın tüm karmaşıklığını hem daha basit hem de daha hızlı hale getirebilirsiniz. Oyuncularımızın tahmini sıralamasını nasıl döndürebileceğimize bakalım. puanlarını ve bunun veritabanı mantığımızı nasıl değiştirdiğini anlamaya çalışırız.

Bu yaklaşımda, skor tablosunu her biri almasını beklediğimiz skorların yaklaşık yüzde birini temsil eden 100 gruba ayıracağız. Bu yaklaşım, puan dağılımımız hakkında bilgi sahibi olmadan da işe yarar. Bu durumda, puanların grup genelinde kabaca eşit bir şekilde dağıtılmasını garanti edemeyiz ancak puanlarımızın nasıl dağıtılacağını bilirsek tahminlerimizde daha kesin bilgi elde ederiz.

Yaklaşımımız şu şekildedir: daha önce olduğu gibi, her kovada puan sayısı ve puanlar aralığı depolanır. Yeni bir puan eklerken, puanın grubunu bulur ve puan sayısını artırırız. Bir sıralama getirirken, yalnızca öndeki grupları toplarız ve daha fazla arama yapmak yerine grubumuzda yaklaşık bir tahmin yaparız. Bu yöntem, çok iyi ve düzenli arama ve eklemeler yapmamızı sağlarken çok daha az kod gerektiriyor.

Birincisi, araya 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 üst kısmında veritabanı durumunuzu başlatmak için bir mantığa sahip olduğunu ve üretimde böyle bir işlem yapılmaması gerektiğini belirten bir uyarı olduğunu fark edeceksiniz. Başlatma kodu, yarış koşullarına karşı hiç korunmaz. Bu nedenle, bunu yaparsanız birden fazla eşzamanlı yazma işlemi, size bir sürü kopya paket sağlayarak veritabanınızı bozar.

Devam edin ve işlevlerinizi dağıtın. Ardından tüm paketleri sıfır olarak saymak üzere bir ekleme işlemi çalıştırın. Bu işlem bir hata döndürür. Bu hatayı güvenle yok sayabilirsiniz.

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

Artık veritabanı doğru şekilde ilk kullanıma hazırlandığına göre addScores komutunu çalıştırarak Firebase konsolunda verilerimizin yapısını görebiliriz. Ortaya çıkan yapı, yüzeysel açıdan benzer olmalarına rağmen, son uygulamamızdan çok daha düzdür.

leaderboard.addScores();

Şimdi de skorları 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şlevini puanların tek tip bir dağılımı oluşturmasını sağladığımızdan ve gruplarda doğrusal interpolasyon kullandığımızdan çok doğru sonuçlar elde ediyoruz, kullanıcı sayısı arttıkça skor tablosunun performansı düşmeyecek ve sayıları güncellerken kilitlenme çakışması konusunda (çok fazla) endişelenmemiz gerekmiyor.

7. Ek: Hile

Biraz daha bekleyin. Codelab'ime bir tarayıcı sekmesinin JS konsolu üzerinden değer yazıyorsam, oyuncularımın hiçbiri skor tablosuna yalan söyleyip yüksek bir puan almadıklarını söyleyemez mi?

Evet, alabilirler. Hile yapılmasını önlemek istiyorsanız bunun en etkili yolu, güvenlik kuralları aracılığıyla veritabanınıza yazma işlemlerini devre dışı bırakmak, istemcilerin doğrudan çağrı yapamaması için Cloud Functions'ınıza erişimi güvenli hale getirmek ve ardından skor tablosuna güncelleme göndermeden önce sunucunuzdaki oyun içi işlemleri doğrulamaktır.

Bu stratejinin hileye karşı çare olmadığını unutmayın. Aldatıcılar, yeterince teşvik eden bu unsurlar sayesinde sunucu tarafında yapılan doğrulamaları atlatacak yollar bulabilir. Büyük ve başarılı video oyunlarının çoğu, yeni hileler bulup bunların çoğalmasını önlemek için aldatanlarla sürekli olarak kedi-fare oynar. 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, kullanıcıların basit bir komut dosyası içeren istemci aracılığıyla oyununuzu kopyalamasını engelleyen Uygulama Kontrolü gibi kötüye kullanım karşıtı araçlar sunar. Ancak Firebase, hileli bütüncül bir önlem almanızı sağlayacak hiçbir hizmet sunmaz.

Sunucu tarafında doğrulamanın yeterli olmadığı her şey, yeterince popüler bir oyunda veya hile yapmanın önünde yeterince engel olmadığı düşünüldüğünde en yüksek değerlerin tümünün hileciler olduğu bir skor tablosunun ortaya çıkmasına neden olur.

8. Tebrikler

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

Şimdi de oyunlara yönelik öğrenme rotalarına göz atın.