# 存储

**URL:** https://heroiclabs.com/docs/zh/nakama/tutorials/unity/pirate-panic/storage/
**Summary:** 学习如何在Pirate Panic教程游戏中使用Nakama存储引擎。

---


# 存储

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

Nakama [存储引擎](../../../../concepts/storage/collections/)有利于在服务器上安全地存储和访问每个用户信息

在此部分，我们将探讨如何：

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

## 集合

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

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

![牌组浏览器]({{< fingerprint_image "/images/pages/nakama/tutorials/unity/pirate-panic/storage-deck.png" >}})

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

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

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

**deck.ts**
```typescript
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**
```typescript
nk.storageWrite([
  {
    key: "card_collection",
    collection: "user_cards",
    userId: userId,
    value: deck,
    permissionRead: 2,
    permissionWrite: 0,
  }
]);
```

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

```typescript
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**
```typescript
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，就像我们 [ 查找好友 ](../friends/#finding-friends) 时的操作一样。这有助于我们控制用户可以发出的请求类型，并严格地在服务器上完成所有敏感操作。

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

**ProfilePanel.cs**
```csharp
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);
```

此处我们将 `Collection`、`UserId` 和 `Key` 属性传入 `StorageObjectId` `struct` 中，它用于识别我们查找的对象。

### 添加权限

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

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

* `0`：无人可读取对象（服务器除外）
* `1`：仅有拥有此对象（`userId` 比赛）的用户可以读取它
* `2`：无人可读取此对象

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

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

```typescript
const writeStats: nkruntime.StorageWriteRequest = {
  collection: "stats",
  key: "public",
  permissionRead: 2,
  permissionWrite: 0,
  value: initialState,
  userId: ctx.userId,
}
```

### 条件读写

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

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

在 Pirate Panic 中未展示此功能，但您可以进一步了解[条件存储](../../../../concepts/storage/collections/#conditional-writes)，并在自己的游戏中实现此功能。

## 钱包

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

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

![宝石！]({{< fingerprint_image "/images/pages/nakama/tutorials/unity/pirate-panic/storage-gems.png" >}})

### 更新钱包金额

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

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

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

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

**economy.ts**
```typescript
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 客户端在某些情况下添加宝石。例如，假设我们很慷慨，想要制作一个免费提供宝石的按钮：

![]({{< fingerprint_image "/images/pages/nakama/tutorials/unity/pirate-panic/storage-freegems.png" >}})

RPC 将形如：
```typescript
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 调用它：

```csharp
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 标记每项奖励，以便确保它们都来自同一个地方，并打印出总的奖励，供玩家查看：

```typescript
let metadata = {
  source: "match_reward",
  match_id: request.matchId,
};

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

### 获取钱包金额

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

获取此 `Wallet` 变量（包含字符串化的 JSON 对象）后，我们可以使用以下代码提取钱包中的宝石数：

```csharp
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` ：

```csharp
GetGems(_connection.Account.Wallet);
```

### 交易分类账

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

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

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

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

**match.ts**
```typescript
let items = nk.walletLedgerList(ctx.userId, 100);
while (items.cursor) {
  items = nk.walletLedgerList(ctx.userId, 100, items.cursor);
}
```

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

## 下一主题

[匹配](../matchmaking/)
