存储 #

对于大多数游戏,有必要跟踪重要的玩家数据(例如库存或游戏统计)。在单人游戏中,这可以通过在客户端本地存储信息来实现,但对于多人游戏,通常需要将数据同时发送到多个客户端,或防止篡改。

Nakama 存储引擎有利于在服务器上安全地存储和访问每个用户信息

在此部分,我们将探讨如何:

  • 从服务器数据库读取和向其写入
  • 创建保留虚拟货币的钱包
  • 使用钱包分类账管理交易
  • 在钱包中加入自定义元数据

集合 #

在 Nakama 中,将自定义用户数据组织成 collections 形式,其中包含保留特定用户的一些数据的 objects 群组。可以借助这种结构将相似的信息存储在一起,以便访问。

在 Pirate Panic 中,每个玩家都持有一副牌以投入战斗:

牌组浏览器
牌组浏览器

我们希望将此牌组的状态存储在服务器上,以便每个用户都有自己的牌组,即使他们关闭游戏并稍后重新加入,这些牌组也会得到保存。

从服务器读取和向其写入 #

首先,让我们设置一种方法,以给予每个玩家一些牌,以便开始:

deck.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
let deck: any = {};

const DefaultDeckCards = [
  {
    type: 1,
    level: 1,
  },
  ... // more cards here
]

DefaultDeckCards.forEach(c => {
  deck[nk.uuidv4()] = c;
});

此处我们的原始牌组数据以对象文字数组表示。每个对象都包含两个数值属性:它是什么类型的牌以及它已经升级到的级别。

类型和级别表示的详细信息是 Pirate Panic 特有的,因此您的游戏可能会有需要存储的自己的自定义信息。例如,每个对象还可以包括获得的日期、特殊变量或玩家可能想知道的任何其他信息。

然后我们需要用 uuidv4() 函数,为每个对象分配一个唯一的 ID,将数据转换为存储对象。这对于跟踪可能完全相同的牌是必要的。

有此牌组后,我们可以用 storageWrite 函数将其存储:

deck.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
nk.storageWrite([
  {
    key: "card_collection",
    collection: "user_cards",
    userId: userId,
    value: deck,
    permissionRead: 2,
    permissionWrite: 0,
  }
]);

这样将创建一个具有以下结构的集合:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
card_collection
 user_cards: {
    userId: some-user-id-3fb1d6, // some arbitrary ID
    permissionRead: 2,
    permissionWrite: 2,
    ... // some other properties
    value: {
      some-random-id: { // another arbitrary ID
        type: 1,
        level: 1,
      }
      ... // more cards
    }
 }

现在,如果我们想在以后访问这些牌,我们可以用请求对象调用 storageRead

deck.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
function loadUserCards(nk: nkruntime.Nakama, logger: nkruntime.Logger, userId: string): CardCollection {
  let storageReadReq: nkruntime.StorageReadRequest = {
    key: DeckCollectionKey,
    collection: DeckCollectionName,
    userId: userId,
  }

  let objects: nkruntime.StorageObject[] = nk.storageRead([storageReadReq]);

  // Get the original collection for processing
  let storedCardCollection = objects[0].value;
  ...
}

将多个 StorageReadRequest 附加到 storageRead 中的数组,可以使用单一 storageRead 调用,一次发出多个请求。

从客户端读取和向其写入 #

为与 Unity 客户端的集合联系,我们可以使用 RPC,就像我们 查找好友 时的操作一样。这有助于我们控制用户可以发出的请求类型,并严格地在服务器上完成所有敏感操作。

本地玩家也可以直接使用函数 ReadStorageObjectsAsync 读取其数据。

ProfilePanel.cs

1
2
3
4
5
6
StorageObjectId personalStorageId = new StorageObjectId();
personalStorageId.Collection = "personal";
personalStorageId.UserId = _connection.Session.UserId;
personalStorageId.Key = "player_data";

IApiStorageObjects personalStorageObjects = await _connection.Client.ReadStorageObjectsAsync(_connection.Session, personalStorageId);

此处我们将 CollectionUserIdKey 属性传入 StorageObjectId struct 中,它用于识别我们查找的对象。

添加权限 #

Nakama 支持处理数据库的读写权限,以防止对敏感数据的未经授权的访问。

可在三个不同 permissionRead 级别上对对象进行读取保护:

  • 0:无人可读取对象(服务器除外)
  • 1:仅有拥有此对象(userId 比赛)的用户可以读取它
  • 2:无人可读取此对象

写入有两个级别:无写入的 0,或允许所有者写入的 1。就像对读取一样,服务器绕过这些权限,可以写入任何对象。级别 0 经常用于实现在服务器上检查写请求,而不是让用户自己编辑数据。

例如,我们希望每个人都能看到用户统计数据,但除了服务器之外,谁都不能更改这些数据:

1
2
3
4
5
6
7
8
const writeStats: nkruntime.StorageWriteRequest = {
  collection: "stats",
  key: "public",
  permissionRead: 2,
  permissionWrite: 0,
  value: initialState,
  userId: ctx.userId,
}

条件读写 #

可能会有多个用户需要访问(而且可能写入)同一对象。

为确保通知不发生冲突,可以将 version 属性传入任何写请求。为此使用服务器上的 writeStorageObjects 或客户端上的 WriteStorageObjectsAsync,且只有输入的比赛与数据库中存储的比赛匹配时,才会接受写入。

在 Pirate Panic 中未展示此功能,但您可以进一步了解条件存储,并在自己的游戏中实现此功能。

钱包 #

除了普通的集合外,Nakama 还有一个钱包功能,专用于支持使用游戏中的货币进行存储和交易。

在 Pirate Panic 中,这种货币采取宝石的形式,可以用来购买新的牌。

宝石!
宝石!

更新钱包金额 #

与集合不同,钱包只能从服务器直接更新,以进一步防止意外交易或被人利用。

可以使用 walletUpdate 更改钱包服务器端的值,这需要四个参数:

  • 要更改的钱包的用户 ID
  • 一个包含键值对的对象,该键值对就每个货币名称和要更新的金额进行匹配
  • 元数据对象
  • 布尔值,告知服务器是否应更新分类帐

由于 Pirate Panic 中只有一种货币,所以编写一个帮助函数很方便,这样我们就可以只传递一个数值,而不是在每次我们想要添加一些宝石时重新构建 changeset

economy.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
const currencyKeyName = "gems" // Can be changed to whatever you want!
...
function updateWallet(nk: nkruntime.Nakama, userId: string, amount: number, metadata: {[key: string]: any}): nkruntime.WalletUpdateResult {
  const changeset = {
    [currencyKeyName]: amount,
  }
  let result = nk.walletUpdate(userId, changeset, metadata, true);

  return result;
}

注意,amount 是我们想要对当前钱包余额所作改变的量,并不完全取代当前余额。例如,如果我们的钱包有 900 个宝石,且以 100 的金额调用 updateWallet,则我们预计新的余额将是 1000(而不是 100)。

然后,我们可以将其封装在 RPC 中,让 Unity 客户端在某些情况下添加宝石。例如,假设我们很慷慨,想要制作一个免费提供宝石的按钮:

RPC 将形如:

1
2
3
4
5
6
7
8
const rpcAddUserGems: nkruntime.RpcFunction = function(ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama): string {
  let walletUpdateResult = updateWallet(nk, ctx.userId, 100, {});
  let updateString = JSON.stringify(walletUpdateResult);

  logger.debug("Added 100 gems to user %s wallet: %s", ctx.userId, updateString);

  return updateString;
}

从 Unity 调用它:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
private void Awake() {
    ...
    _getFreeGemsButton.onClick.AddListener(HandleAddFreeGems); // Bind function to button
}
...
private async void HandleAddFreeGems() {
    // Call the RPC we just made!
    IApiRpc newGems = await _connection.Client.RpcAsync(_connection.Session, "add_user_gems");
    // IMPORTANT: Update the account instance to get the latest results!
    _connection.Account = await _connection.Client.GetAccountAsync(_connection.Session);
    ...
}

我们也可以通过传入一个负数来从玩家的钱包中减去宝石。例如,如果我们想要使用 100 块宝石购买一张牌,我们将调用 updateWallet(nk, ctx.userId, -100, {});

自定义元数据 #

元数据对象可用于在每次钱包交易中输入任何自定义信息。然后 walletLedgerList 中的每个项目都将带有这些信息,因此以后可以应用它。

例如,我们可能希望用比赛 ID 标记每项奖励,以便确保它们都来自同一个地方,并打印出总的奖励,供玩家查看:

1
2
3
4
5
6
let metadata = {
  source: "match_reward",
  match_id: request.matchId,
};

updateWallet(nk, ctx.userId, score, metadata);

获取钱包金额 #

在客户端上,钱包信息存储在 connection.Account.Wallet 中,其中 connection.AccountGameConnection 类的 IApiAccount 实例,该类保留了在设置身份验证时建立的连接信息。

获取此 Wallet 变量(包含字符串化的 JSON 对象)后,我们可以使用以下代码提取钱包中的宝石数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
private int GetGems(string wallet)
{
    Dictionary<string, int> currency = wallet.FromJson<Dictionary<string, int>>();

    if (currency.ContainsKey("gems"))
    {
        return currency["gems"];
    }

    return 0;
}

在 Pirate Panic 中,因为我们创建了 GameConnection,我们可以使用下项来调用 GetGems

1
GetGems(_connection.Account.Wallet);

交易分类账 #

最后有一个参数 updateLedger,我们在上方将其设置为真。

每次通过设置为 trueupdateLedger 调用 walletUpdate ,即创建一个新的分类账条目。这样我们就能翻阅以前的交易,查看其详细信息。

为此调用 walletLedgerList,默认情况下这会抓取按时间由远到近排序的所有交易的列表。

然后用 cursor 跳过所需的任何条目。例如,如果想要获取最新的游标更新:

match.ts

1
2
3
4
let items = nk.walletLedgerList(ctx.userId, 100);
while (items.cursor) {
  items = nk.walletLedgerList(ctx.userId, 100, items.cursor);
}

这在要跳到当前比赛奖励等情况下很使用。如果我们将初始奖励设置为有一个游标更新,并在没有游标更新的情况下继续列出增量更新(如占领哨塔),那么我们可以使用此策略获得最新比赛中所有交易的列表。

下一主题 #

匹配

Related Pages