使用 Firestore 构建排行榜

1. 简介

上次更新日期:2023 年 1 月 27 日

构建排行榜需要完成哪些操作?

从本质上讲,排行榜只是一个得分表,其中包含一个复杂因素:读取任何给定得分的排名都需要以某种顺序了解所有其他得分。此外,如果您的游戏一炮而红,排行榜就会变大,且读写操作频繁。要构建成功的排行榜,它需要能够快速处理此排名操作。

您将构建的内容

在此 Codelab 中,您将实现各种不同的排行榜,每个排行榜适合不同的场景。

学习内容

您将学习如何实现四种不同的排行榜:

  • 使用简单的记录计数来确定排名的简单实现
  • 经济实惠且定期更新的排行榜
  • 实时排行榜,包含不一样树木
  • 超大型玩家群的近似排名的随机(概率)排行榜

所需条件

  • 最新版本的 Chrome(107 或更高版本)
  • Node.js 16 或更高版本(如果您使用的是 nvm,请运行 nvm --version 来查看版本号)
  • 付费 Firebase Blaze 方案(可选)
  • Firebase CLI 11.16.0 或更高版本
    如需安装 CLI,您可以运行 npm install -g firebase-tools 或参阅 CLI 文档,了解其他安装选项。
  • 了解 JavaScript、Cloud Firestore、Cloud Functions 和 Chrome 开发者工具

2. 准备工作

获取代码

我们已将您完成此项目所需的一切都放入一个 Git 代码库中。首先,您需要获取代码并在您偏好的开发环境中打开它。在此 Codelab 中,我们使用了 VS Code,但任何文本编辑器都可以使用。

并解压缩下载的 ZIP 文件。

或者,克隆到您选择的目录:

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

我们从何处入手?

我们的项目目前是一片空白,其中包含一些空函数:

  • index.html 包含一些粘合脚本,让我们能够从开发者控制台调用函数并查看其输出。我们将使用它与后端进行交互,并查看函数调用的结果。在实际场景中,您可以直接从游戏中进行这些后端调用。我们在此 Codelab 中并未使用游戏,因为每次您想在排行榜中添加得分时玩某款游戏都会花费很长时间。
  • functions/index.js 包含我们的所有 Cloud Functions 函数。您会看到一些实用函数(例如 addScoresdeleteScores),以及我们将在此 Codelab 中实现的函数,这些函数会在另一个文件中调用辅助函数。
  • functions/functions-helpers.js 包含我们要实现的空函数。对于每个排行榜,我们将实现读取、创建和更新功能,您将看到我们的实现选择如何影响实现的复杂性和扩展性能。
  • functions/utils.js 包含更多实用函数。在此 Codelab 中,我们不会修改此文件。

创建和配置 Firebase 项目

  1. Firebase 控制台中,点击添加项目
  2. 如需创建新项目,请输入所需的项目名称。
    此操作还会根据项目名称将项目 ID(显示在项目名称下方)设置为相应的值。您可以选择点击项目 ID 上的修改图标,进一步对其进行自定义。
  3. 如果看到相关提示,请查看并接受 Firebase 条款
  4. 点击继续
  5. 选择为此项目启用 Google Analytics(分析)选项,然后点击继续
  6. 选择要使用的现有 Google Analytics(分析)帐号,或选择创建新帐号以创建新帐号。
  7. 点击 Create project
  8. 创建项目后,点击 Continue(继续)。
  9. Build(构建)菜单中,点击 Functions(函数),如果系统提示,升级项目以使用 Blaze 结算方案。
  10. 构建菜单中,点击 Firestore 数据库
  11. 在随即显示的创建数据库对话框中,选择以测试模式开始,然后点击下一步
  12. Cloud Firestore 位置下拉列表中选择一个区域,然后点击启用

配置并运行排行榜

  1. 在终端中,转到项目根目录并运行 firebase use --add。选择您刚刚创建的 Firebase 项目。
  2. 在项目的根目录中,运行 firebase emulators:start --only hosting
  3. 在浏览器中,前往 localhost:5000
  4. 打开 Chrome 开发者工具的 JavaScript 控制台并导入 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

然后,在 Chrome 的 JS 控制台中添加一些其他得分,以便了解我们的排名在其他玩家中的排名。

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

现在,我们可以将自己的分数添加到组合中:

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

写入完成后,您应该会在控制台中看到一条内容为“Score created”的响应。却看到了错误?通过 Firebase 控制台打开 Functions 日志,查看问题所在。

最后,我们可以获取并更新得分。

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

很抱歉,您必须先为项目添加结算账号,然后才能部署和测试此 API。如果您有结算账号,请缩短安排函数的时间间隔,然后观察函数就会神奇地为排行榜得分分配排名。

如果没有,请删除预定函数并直接跳到下一个实现。

接下来,点击得分集合旁边的三个点,删除 Firestore 数据库中的得分,为下一部分做好准备。

Firestore 得分文档页面,其中\n 已启用“删除集合”

5. 实现实时树状排行榜

此方法的工作原理是将搜索数据存储在数据库集合本身中。我们的目标是将所有内容存储在可以通过文档浏览时遍历的树中,而不是采用统一的集合。这使我们能够对给定得分的排名执行二元(或 N 元)搜索。这会是什么样的?

首先,我们希望能够将得分分布到大致均等的分桶,这需要了解用户记录的得分值;例如,如果您要在竞争性游戏中构建技能评分排行榜,用户的技能评分几乎总是呈正态分布。我们的随机得分生成函数使用 JavaScript 的 Math.random(),这会使分布情况大致均匀,因此我们会平均分配分桶。

为简单起见,在此示例中,我们将使用 3 个分桶,但您可能会发现,如果在真实应用中使用此实现,越多分桶的产生速度更快 - 较浅的树意味着平均较少的集合提取和锁争用。

玩家的排名由得分更高的玩家数量加上 1 本身计算得出。scores 下的每个集合将存储三个文档,每个文档都有一个范围、每个范围下的文档数量,以及三个相应的子集合。为了读取排名,我们将遍历此树,搜索得分并跟踪较高得分的总和。当我们找到分数时,也会得到正确的总和。

编写过程要复杂得多。首先,我们需要在事务内进行所有写入,以防止在同时发生多个写入或读取操作时出现数据不一致。此外,在遍历树以写入新文档时,我们还需要保持上述所有条件。最后,由于我们拥有这种新方法的所有树复杂性,同时需要存储所有原始文档,因此存储成本将会略有增加(但仍然是线性的)。

functions-helpers.js 中:

async function createScore(playerID, score, firestore) {
  /**
   * This function assumes a minimum score of 0 and that value
   * is between min and max.
   * Returns the expected size of a bucket for a given score
   * so that bucket sizes stay constant, to avoid expensive
   * re-bucketing.
   * @param {number} value The new score.
   * @param {number} min The min of the previous range.
   * @param {number} max The max of the previous range. Must be greater than
   *     min.
   * @return {Object<string, number>} Returns an object containing the new min
   *     and max.
   */
  function bucket(value, min, max) {
    const bucketSize = (max - min) / 3;
    const bucketMin = Math.floor(value / bucketSize) * bucketSize;
    const bucketMax = bucketMin + bucketSize;
    return {min: bucketMin, max: bucketMax};
  }

  /**
   * A function used to store pending writes until all reads within a
   * transaction have completed.
   *
   * @callback PendingWrite
   * @param {admin.firestore.Transaction} transaction The transaction
   *     to be used for writes.
   * @return {void}
   */

  /**
   * Recursively searches for the node to write the score to,
   * then writes the score and updates any counters along the way.
   * @param {number} id The user associated with the score.
   * @param {number} value The new score.
   * @param {admin.firestore.CollectionReference} coll The collection this
   *     value should be written to.
   * @param {Object<string, number>} range An object with properties min and
   *     max defining the range this score should be in. Ranges cannot overlap
   *     without causing problems. Use the bucket function above to determine a
   *     root range from constant values to ensure consistency.
   * @param {admin.firestore.Transaction} transaction The transaction used to
   *     ensure consistency during tree updates.
   * @param {Array<PendingWrite>} pendingWrites A series of writes that should
   *     occur once all reads within a transaction have completed.
   * @return {void} Write error/success is handled via the transaction object.
   */
  async function writeScoreToCollection(
      id, value, coll, range, transaction, pendingWrites) {
    const snapshot = await transaction.get(coll);
    if (snapshot.empty) {
      // This is the first score to be inserted into this node.
      for (const write of pendingWrites) {
        write(transaction);
      }
      const docRef = coll.doc();
      transaction.create(docRef, {exact: {score: value, user: id}});
      return;
    }

    const min = range.min;
    const max = range.max;

    for (const node of snapshot.docs) {
      const data = node.data();
      if (data.exact !== undefined) {
        // This node held an exact score.
        const newRange = bucket(value, min, max);
        const tempRange = bucket(data.exact.score, min, max);

        if (newRange.min === tempRange.min &&
          newRange.max === tempRange.max) {
          // The scores belong in the same range, so we need to "demote" both
          // to a lower level of the tree and convert this node to a range.
          const rangeData = {
            range: newRange,
            count: 2,
          };
          for (const write of pendingWrites) {
            write(transaction);
          }
          const docReference = node.ref;
          transaction.set(docReference, rangeData);
          transaction.create(docReference.collection("scores").doc(), data);
          transaction.create(
              docReference.collection("scores").doc(),
              {exact: {score: value, user: id}},
          );
          return;
        } else {
          // The scores are in different ranges. Continue and try to find a
          // range that fits this score.
          continue;
        }
      }

      if (data.range.min <= value && data.range.max > value) {
        // The score belongs to this range that may have subvalues.
        // Increment the range's count in pendingWrites, since
        // subsequent recursion may incur more reads.
        const docReference = node.ref;
        const newCount = node.get("count") + 1;
        pendingWrites.push((t) => {
          t.update(docReference, {count: newCount});
        });
        const newRange = bucket(value, min, max);
        return writeScoreToCollection(
            id,
            value,
            docReference.collection("scores"),
            newRange,
            transaction,
            pendingWrites,
        );
      }
    }

    // No appropriate range was found, create an `exact` value.
    transaction.create(coll.doc(), {exact: {score: value, user: id}});
  }

  const scores = firestore.collection("scores");
  const players = firestore.collection("players");
  return firestore.runTransaction((transaction) => {
    return writeScoreToCollection(
        playerID, score, scores, {min: 0, max: 1000}, transaction, [],
    ).then(() => {
      transaction.create(players.doc(), {
        user: playerID,
        score: score,
      });
    });
  });
}

这当然比上一种实现方式更复杂,后者是单次方法调用和六行代码。实现此方法后,请尝试向数据库添加一些分数并观察所生成树的结构。在您的 JS 控制台中:

leaderboard.addScores();

生成的数据库结构应如下所示,其中树形结构清晰可见,树的叶子表示各个分数。

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

现在,我们解决了困难部分,接下来就可以如前所述通过遍历树来读取分数了。

async function readRank(playerID, firestore) {
  const players = await firestore.collection("players")
      .where("user", "==", playerID).get();
  if (players.empty) {
    throw Error(`Player not found in leaderboard: ${playerID}`);
  }
  if (players.size > 1) {
    console.info(`Multiple scores with player ${playerID}, fetching first`);
  }
  const player = players.docs[0].data();
  const score = player.score;

  const scores = firestore.collection("scores");

  /**
   * Recursively finds a player score in a collection.
   * @param {string} id The player's ID, since some players may be tied.
   * @param {number} value The player's score.
   * @param {admin.firestore.CollectionReference} coll The collection to
   *     search.
   * @param {number} currentCount The current count of players ahead of the
   *     player.
   * @return {Promise<number>} The rank of the player (the number of players
   *     ahead of them plus one).
   */
  async function findPlayerScoreInCollection(id, value, coll, currentCount) {
    const snapshot = await coll.get();
    for (const doc of snapshot.docs) {
      if (doc.get("exact") !== undefined) {
        // This is an exact score. If it matches the score we're looking
        // for, return. Otherwise, check if it should be counted.
        const exact = doc.data().exact;
        if (exact.score === value) {
          if (exact.user === id) {
            // Score found.
            return currentCount + 1;
          } else {
            // The player is tied with another. In this case, don't increment
            // the count.
            continue;
          }
        } else if (exact.score > value) {
          // Increment count
          currentCount++;
          continue;
        } else {
          // Do nothing
          continue;
        }
      } else {
        // This is a range. If it matches the score we're looking for,
        // search the range recursively, otherwise, check if it should be
        // counted.
        const range = doc.data().range;
        const count = doc.get("count");
        if (range.min > value) {
          // The range is greater than the score, so add it to the rank
          // count.
          currentCount += count;
          continue;
        } else if (range.max <= value) {
          // do nothing
          continue;
        } else {
          const subcollection = doc.ref.collection("scores");
          return findPlayerScoreInCollection(
              id,
              value,
              subcollection,
              currentCount,
          );
        }
      }
    }
    // There was no range containing the score.
    throw Error(`Range not found for score: ${value}`);
  }

  const rank = await findPlayerScoreInCollection(playerID, score, scores, 0);
  return {
    user: playerID,
    rank: rank,
    score: score,
  };
}

更新是一项额外的练习。尝试使用 leaderboard.addScore(id, score)leaderboard.getRank(id) 方法在 JS 控制台中添加和提取得分,并在 Firebase 控制台中查看排行榜的变化情况。

不过,这种实现方式为实现对数性能而增加的复杂性是代价的。

  • 首先,此排行榜实现可能会遇到锁争用问题,因为事务需要锁定对文档进行读写操作以确保它们保持一致。
  • 其次,Firestore 强制将子集合深度限制为 100,这意味着您需要避免在达到 100 并列分数后创建子树,此实现没有这样做。
  • 最后,此排行榜仅在理想情况下(树为平衡)进行对数扩展,如果不平衡,则此排行榜最糟糕的性能将再次变为线性。

完成上述操作后,通过 Firebase 控制台删除 scoresplayers 集合,然后继续完成最后一个排行榜实现。

6. 实现随机(概率)排行榜

运行插入代码时,您可能会注意到,如果并行运行它的次数过多,您的函数将开始失败,并显示与事务锁争用相关的错误消息。还有一些方法不在此 Codelab 中探索,但如果您并不需要精确排名,可以放弃前一种方法的所有复杂性,这样既更简单,又更快速。让我们来看看如何返回玩家得分的估算排名而不是确切排名,以及这会如何改变我们的数据库逻辑。

对于这种方法,我们将排行榜分为 100 个桶,每个桶约占我们预期收到的分数的 1%。此方法即使在不了解得分分布的情况下也可工作,在这种情况下,我们无法保证整个存储分区内得分大致均匀分布,但如果我们知道得分的分布方式,则会在近似值中实现更高的精确率。

我们的方法如下所示:与之前一样,每个存储分区都会存储相应得分内得分的计数和得分范围。插入新得分时,我们将找到得分桶并递增其计数。获取排名时,我们只对前面的存储分区求和,然后在存储分区内进行近似计算,而不是进一步搜索。这为我们提供了非常棒的常量时间查找和插入操作,并且需要的代码要少得多。

首先,插入:

// Add this line to the top of your file.
const admin = require("firebase-admin");

// Implement this method (again).
async function createScore(playerID, score, firestore) {
  const scores = await firestore.collection("scores").get();
  if (scores.empty) {
    // Create the buckets since they don't exist yet.
    // In a real app, don't do this in your write function. Do it once
    // manually and then keep the buckets in your database forever.
    for (let i = 0; i < 10; i++) {
      const min = i * 100;
      const max = (i + 1) * 100;
      const data = {
        range: {
          min: min,
          max: max,
        },
        count: 0,
      };
      await firestore.collection("scores").doc().create(data);
    }
    throw Error("Database not initialized");
  }

  const buckets = await firestore.collection("scores")
      .where("range.min", "<=", score).get();
  for (const bucket of buckets.docs) {
    const range = bucket.get("range");
    if (score < range.max) {
      const writeBatch = firestore.batch();
      const playerDoc = firestore.collection("players").doc();
      writeBatch.create(playerDoc, {
        user: playerID,
        score: score,
      });
      writeBatch.update(
          bucket.ref,
          {count: admin.firestore.FieldValue.increment(1)},
      );
      const scoreDoc = bucket.ref.collection("scores").doc();
      writeBatch.create(scoreDoc, {
        user: playerID,
        score: score,
      });
      return writeBatch.commit();
    }
  }
}

您会注意到,此插入代码在顶部有一些用于初始化数据库状态的逻辑,并警告不要在生产环境中执行此类操作。初始化代码完全不受竞态条件的保护,因此如果您这样做,多次并发写入会给您的数据库提供大量重复的存储分区,从而损坏数据库。

接下来部署您的函数,然后运行插入操作,将计数为零的所有存储分区初始化。它会返回一个错误,您可以放心地忽略该错误。

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

现在数据库已正确初始化,接下来可以运行 addScores 并在 Firebase 控制台中查看数据的结构。最终得到的结构比我们上一次的实现要扁平得多,尽管它们表面上类似。

leaderboard.addScores();

现在,读取分数:

async function readRank(playerID, firestore) {
  const players = await firestore.collection("players")
      .where("user", "==", playerID).get();
  if (players.empty) {
    throw Error(`Player not found in leaderboard: ${playerID}`);
  }
  if (players.size > 1) {
    console.info(`Multiple scores with player ${playerID}, fetching first`);
  }
  const player = players.docs[0].data();
  const score = player.score;

  const scores = await firestore.collection("scores").get();
  let currentCount = 1; // Player is rank 1 if there's 0 better players.
  let interp = -1;
  for (const bucket of scores.docs) {
    const range = bucket.get("range");
    const count = bucket.get("count");
    if (score < range.min) {
      currentCount += count;
    } else if (score >= range.max) {
      // do nothing
    } else {
      // interpolate where the user is in this bucket based on their score.
      const relativePosition = (score - range.min) / (range.max - range.min);
      interp = Math.round(count - (count * relativePosition));
    }
  }

  if (interp === -1) {
    // Didn't find a correct bucket
    throw Error(`Score out of bounds: ${score}`);
  }

  return {
    user: playerID,
    rank: currentCount + interp,
    score: score,
  };
}

由于我们让 addScores 函数生成了得分的均匀分布,并且在存储分区内使用了线性插值,我们会获得非常准确的结果,排行榜的性能不会随着用户数量的增加而下降,而且我们在更新计数时也不用担心锁争用。

7. 附录:作弊

稍等,您可能会想,如果我通过浏览器标签页的 JS 控制台向我的 Codelab 写入值,难道我的任何玩家就在排行榜上撒谎并说他们获得了高分,而他们并没有得到公正的打分?

可以。如果您想防止作弊,最可靠的方法是停用客户端通过安全规则向数据库写入数据,保护对 Cloud Functions 函数的访问权限,使客户端无法直接调用它们,然后在服务器上验证游戏内操作,然后再将得分更新发送到排行榜。

请务必注意,这种策略并不是抵御欺诈的万能药 - 在激励足够大的情况下,作弊者可以找到方法来规避服务器端验证,许多成功的大型视频游戏不断与作弊者玩猫鼠,以发现新的作弊,并阻止它们扩散。这种现象导致的严重后果是,每个游戏的服务器端验证本质上都是定制的;尽管 Firebase 提供了像 App Check 这样的反滥用工具,可以防止用户通过简单的脚本客户端复制您的游戏,但 Firebase 并不能提供任何可全面反作弊的服务。

对于一款足够受欢迎的游戏或欺诈的门槛足够低,任何缺少服务器端验证的结果都会导致排行榜中的排名值全部为欺诈者。

8. 恭喜

恭喜,您已在 Firebase 上成功构建了四个不同的排行榜!您可以根据游戏对精确性和速度的要求来选择价格合理的游戏。

接下来,请查看有关游戏的开发者在线学习课程