# Unity/.Net

**URL:** https://heroiclabs.com/docs/zh/nakama/client-libraries/unity/
**Summary:** 官方Unity客户端实时处理与服务器的所有通信。它实现了服务器中的所有功能。本指南将展示如何开发一项游戏的Nakama特定部分，从而展示如何在Unity中使用Nakama的核心功能。

---


# Nakama Unity客户端指南

本客户端库指南介绍如何开发名为Sagi-shi（“Imposter”的日语名称）游戏的Nakama特定环节（无完整的游戏逻辑或UI），以便您了解如何在**Unity**中使用Nakama的核心功能。Sagi-shi是一款基于[Among Us（外部）](https://www.innersloth.com/games/among-us/)制作的游戏。

<figure>
  <img src="/docs/nakama/client-libraries/images/gameplay.png" alt="Sagi-shi gameplay screen">
  <figcaption>Sagi-shi gameplay</figcaption>
</figure>


## 前提条件

开始之前，确保您已经：

* 安装[Nakama服务器](../../getting-started/install/docker/)
* [下载并安装Unity](https://unity.com/download)
* 安装[Nakama Unity SDK](#installation)

### 完整的API文档

请在[API文档](https://heroiclabs.github.io/nakama-dotnet/)中查看完整的API文档。

### 安装

可通过以下方式下载客户端：

* [Unity资源商店](https://assetstore.unity.com/packages/tools/network/nakama-81338)
* [Heroic Labs GitHub发布页面](https://github.com/heroiclabs/nakama-unity/releases/latest)

`Nakama.unitypackage`包含客户端代码中所需的所有源代码和DLL依赖项。

下载文件后：

* 将其拖拽或导入您的Unity项目
* 将编辑器的脚本运行时间版本设置为.NET 4.6（通过**编辑** -> **项目设置** -> **玩家** -> **配置**菜单）。
* 通过**资源**菜单创建一个新的C#脚本和一个[客户端对象](#nakama-client)

{{< note "important" "Unity 2019.4.1+">}}
或者，您可以通过将以下内容添加到`manifest.json`项目文件夹 `Packages` 中的文件来签出特定版本或提交：

`"com.heroiclabs.nakama-unity": "https://github.com/heroiclabs/nakama-unity.git?path=/Packages/Nakama#<commit | tag>"`
{{< / note >}}

#### 更新

Nakama Unity客户端的新版本和相应的改进都记录在 [更改日志](https://github.com/heroiclabs/nakama-unity/blob/master/Packages/Nakama/CHANGELOG.md)中。

### Unity / .NET SDK区别

通常，本客户端指南还可以与[Nakama .NET SDK](https://github.com/heroiclabs/nakama-dotnet)一起使用，但存在以下微小差异。

#### 套接字的创建

`Client.NewSocket`扩展名在.NET SDK中不可用。而是使用：

```csharp
var socket = Socket.From(client);
```

#### 日志

在本指南中，将日志存入控制台使用特定于Unity的`Debug.LogFormat`方法。在.NET SDK中使用：

```csharp
Console.WriteLine("Hello, {0}!", "Nakama");
```

#### 保存/加载数据

本指南利用Unity的内置功能`PlayerPrefs`存储和加载数据，如设备ID、授权令牌、可缓存游标等。使用.NET SDK时，您可以自由使用您选择的任何数据存储/检索方法；例如直接保存到磁盘。

### 异步编程

许多Nakama API采用异步、非阻塞操作方式，在Unity SDK中可以作为异步方法使用。

Sagi-shi使用`await`运算符调用异步方法，不去阻止调用线程，从而提高游戏响应能力和效率。

```csharp
await client.AuthenticateDeviceAsync("<DeviceId>");
```

在官方.NET C#参考中阅读 [`async`关键词](https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/async)和[`await`运算符](https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/operators/await)。

或者，您可以使用任务的`ContinueWith`方法进行回调：

```csharp
var task = Nakama.Client.GetAccountAsync(null);

task.ContinueWith(t =>
{
    Debug.LogFormat("Found account for user: {0}", t.Result.User.Username);
}, TaskContinuationOptions.OnlyOnRanToCompletion);

task.ContinueWith(t => {
    Debug.LogFormat("Error: {0}", t.Exception);
}, TaskContinuationOptions.NotOnRanToCompletion);
```

使用`ContinueWith`时，需要运行有`TaskContinuationOptions.NotOnRanToCompletion`参数的第二个`ContinueWith`来捕获异常。

### 处理异常

网络编程需要额外的保护措施，防止出现连接和有效负载方面的问题。

Sagi-shi中的API调用被一个try块和catch语句包围，其中包含一个`Nakama.ApiResponseException`对象，可以得当处理错误：

```csharp
try
{
    await client.AuthenticateDeviceAsync("<DeviceId>");
}
catch (Nakama.ApiResponseException ex)
{
    Debug.LogFormat("Error authenticating device: {0}:{1}", ex.StatusCode, ex.Message);
}
```


### 处理重试

Nakama拥有全局对象或每个请求的`RetryConfiguration`对象控制如何重试失败的API调用。重试模式对于身份验证或后台服务相关任务非常实用。

Sagi-shi使用全局重试配置最多尝试五次失败的API调用，然后将错误输出到控制台。

向单个请求传递一个`RetryConfiguration` 对象将优先于任何全局设置配置。

```csharp
var retryConfiguration = new Nakama.RetryConfiguration(baseDelay: 1, maxRetries: 5, delegate { System.Console.Writeline("about to retry."); });

// Configure the retry configuration globally.
client.GlobalRetryConfiguration = retryConfiguration;
var account = await client.GetAccountAsync(session);

// Alternatively, pass the retry configuration to an individual request.
var account = await client.GetAccountAsync(session, retryConfiguration);
```

某些情况下，错开一定时间发送重试请求可能有效。为此，您可以在创建新`RetryConfiguration`时将委托传递给`jitter`参数。针对完全随机的重试抖动，您可以使用SDK中的`RetryJitter.FullJitter`委托。

```csharp
// Retry configuration with random jitter intervals
var retryConfiguration = new RetryConfiguration(500, 5, RetryListener, RetryJitter.FullJitter);
```

如果需要对请求之间的延迟有更多的控制，您可以提供自己的抖动委托。

```csharp
// Retry configuration with custom retry jitter
var retryConfigurationCustomJitter = new RetryConfiguration(500, 5, RetryListener, (history, baseDelay, random) =>
    {
        // Use the "Decorrelated Jitter" algorithm (https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/)
        const int delayCap = 20000;
        var lastAttempt = history.Last();
        var jitter = Mathf.Min(delayCap, random.Next(baseDelay, lastAttempt.JitterBackoff * 3));
        return jitter;
    });
```

Nakama API可以使用可选`CancellationTokenSource`对象来取消请求：

```csharp
// Part of System.Threading namespace
var canceller = new CancellationTokenSource();
var account = await client.GetAccountAsync(session, retryConfiguration: null, canceller);

canceller.Cancel();
```

### 对数据进行序列化和反序列化处理

通过网络发送和接收数据时，需要对数据进行适当的序列化和反序列化处理。使用JSON和二进制数据是两种最常见的方式。

两个示例都将展示如何对以下`Dictionary`进行序列化和反序列化处理，但也可以用于任何可序列化处理的对象。

```csharp
var data = new Dictionary<string, string>
{
    { "Key", "Value" },
    { "AnotherKey", "AnotherValue" }
};
```


#### JSON

使用`Nakama.TinyJson`命名空间。

```csharp
// Serialize
var serialized = data.ToJson();

// Deserialize
var deserialized = serialized.FromJson<Dictionary<string, string>>();
```


#### 二进制

使用`System.Runtime.Serialization.Formatters.Binary`命名空间。仅当您想将序列化处理后的数据作为`string`发送和接收时，才有必要转换为`Base64`和从其转换；否则，您可以使用`byte[]`阵列进行序列化和反序列化处理。

```csharp
var formatter = new BinaryFormatter();
string serialized;

using (var ms = new MemoryStream())
{
    formatter.Serialize(ms, data);
    serialized = Convert.ToBase64String(ms.GetBuffer());
}

using (var ms = new MemoryStream(Convert.FromBase64String(serialized)))
{
    var deserialized = (Dictionary<string, string>) formatter.Deserialize(ms);
    Debug.Log(deserialized["AnotherKey"]);
}
```


## 新手入门

新手入门需要使用Nakama客户端和套接字对象开始创建Sagi-shi和您自己的游戏。


### Nakama客户端

Nakama客户端与Nakama服务器连接，是访问Nakama功能的入口。建议为每个游戏的每个服务器创建一个客户端。

要为Sagi-shi创建一个客户端，请将以下详细连接信息输入您的服务器：

```csharp
var client = new Nakama.Client("http", "127.0.0.1", 7350, "defaultkey");
```


### 配置请求超时时长

客户端对Nakama发出的每个请求必须在一定时间内完成，超过这个时间段会被认为超时。您可以通过在客户端设置`Timeout`值来配置这个时间段的长度（以秒为单位）：

```csharp
client.Timeout = 10;
```


### 配置自定义记录器

使用Nakama时，您可以将客户端配置为使用自定义记录器。然后，这个记录器将被传递到HTTP适配器，用来记录从API调用中收到的错误。

如需使用自定义记录器，您必须首先创建一个实现`Nakama.ILogger`接口的类别，然后将其布置到客户端`Logger`的属性中：

```csharp
var logger = new MyCustomLogger(); // Implements Nakama.ILogger
client.Logger = logger;
```

您也可以使用提供的`Nakama.UnityLogger`，它将记录到Unity控制台。


### Nakama套接字

Nakama套接字用于玩法和实时延迟敏感功能，如聊天、派对、比赛和RPC。

从客户端创建套接字：

```csharp
var socket = client.NewSocket();

bool appearOnline = true;
int connectionTimeout = 30;
await socket.ConnectAsync(Session, appearOnline, connectionTimeout);
```

### WebGL兼容性

如果您计划为WebGL构建游戏，您将需要关闭Socket对象用于`UnityWebRequestAdapter`的`IHttpAdapter`，然后使用客户端对象上的`NewSocket()`扩展来创建套接字。

```csharp
var client = new Client("defaultkey", UnityWebRequestAdapter.Instance);
var socket = client.NewSocket();

// or
#if UNITY_WEBGL && !UNITY_EDITOR
    ISocketAdapter adapter = new JsWebSocketAdapter();
#else
    ISocketAdapter adapter = new WebSocketAdapter();
#endif
var socket = Socket.From(client, adapter);
```

## 身份验证

Nakama支持多种[身份验证方法](../../concepts/authentication/)，还支持在服务器上创建[自定义身份验证](../../concepts/authentication/#custom)。

Sagi-shi将通过链接到同一个用户账户的设备和Facebook进行身份验证，以便玩家可以使用不同的设备玩游戏。

<figure>
  <img src="/docs/nakama/client-libraries/images/login.png" alt="Sagi-shi login screen">
  <figcaption>Login screen and Authentication options</figcaption>
</figure>


### 设备身份验证

Nakama[设备身份验证](../../concepts/authentication/#device)使用物理设备的唯一标识符轻松验证用户，也可以为没有账户的设备创建账户。

仅使用设备身份验证时，您无需使用登录UI，因为游戏启动时会自动验证玩家身份。

身份验证是从Nakama客户端实例访问Nakama功能的示例。

```csharp
public async void AuthenticateWithDevice()
{
    // If the user's device ID is already stored, grab that - alternatively get the System's unique device identifier.
    var deviceId = PlayerPrefs.GetString("deviceId", SystemInfo.deviceUniqueIdentifier);

    // If the device identifier is invalid then let's generate a unique one.
    if (deviceId == SystemInfo.unsupportedIdentifier)
    {
        deviceId = System.Guid.NewGuid().ToString();
    }

    // Save the user's device ID to PlayerPrefs so it can be retrieved during a later play session for re-authenticating.
    PlayerPrefs.SetString("deviceId", deviceId);

    // Authenticate with the Nakama server using Device Authentication.
    try
    {
        Session = await client.AuthenticateDeviceAsync(deviceId);
        Debug.Log("Authenticated with Device ID");
    }
    catch(ApiResponseException ex)
    {
        Debug.LogFormat("Error authenticating with Device ID: {0}", ex.Message);
    }
}
```


### Facebook身份验证

Nakama [Facebook身份验证](../../concepts/authentication/#facebook)操作简便，您可以有选择性地导入玩家的Facebook好友，并将这些好友添加到玩家的Nakama好友列表。

要使用Nakama Facebook身份验证，[为Unity（外部）安装Facebook SDK](https://developers.facebook.com/docs/unity/)。

```csharp
public void AuthenticateWithFacebook()
{
    FB.LogInWithReadPermissions(new[] { "public_profile", "email" }, async result =>
    {
        if (FB.IsLoggedIn)
        {
            try
            {
                var importFriends = true;
                Session = await client.AuthenticateFacebookAsync(AccessToken.CurrentAccessToken.TokenString, importFriends);
                Debug.Log("Authenticated with Facebook");
            }
            catch(ApiResponseException ex)
            {
                Debug.LogFormat("Error authenticating with Facebook: {0}", ex.Message);
            }
        }
    });
}
```


### 自定义身份验证

Nakama支持[自定义身份验证](../../concepts/authentication/#custom)方法，以便与其他身份服务相集成。

示例请见[Itch.io自定义身份验证](../snippets/custom-authentication/)配方。

以下代码显示了如何从客户端进行身份验证：

```csharp
public async void AuthenticationWithCustom()
{
    // Authenticate using Custom ID (using itch.io authentication).
    try
    {
        var itchioApiKey = Environment.GetEnvironmentVariable("ITCHIO_API_KEY");
        Session = await Client.AuthenticateCustomAsync(itchioApiKey);
        Debug.Log("Authenticated with Custom ID");
    }
    catch (ApiResponseException ex)
    {
        Debug.LogFormat("Failed authentication: {0}", ex.Message);
    }
}
```

### 链接身份验证

Nakama允许玩家在进行身份验证后通过[链接身份验证](../../concepts/authentication/#link-or-unlink) 方法前往玩家账户。


**链接设备ID身份验证**

```csharp
public async void LinkDeviceAuthentication()
{
    // Acquiring the unique device ID has been shortened for brevity, see previous example.
    var deviceId = "<UniqueDeviceId>";

    // Link Device Authentication to existing player account.
    try
    {
        await client.LinkDeviceAsync(Session, deviceId);
        Debug.Log("Successfully linked Device ID authentication to existing player account");
    }
    catch(ApiResponseException ex)
    {
        Debug.LogFormat("Error linking Device ID: {0}", ex.Message);
    }
}
```


**链接Facebook身份验证**

```csharp
public void LinkFacebookAuthentication(bool importFriends = true)
{
    FB.LogInWithReadPermissions(new[] { "public_profile", "email" }, async result =>
    {
        if (FB.IsLoggedIn)
        {
            try
            {
                var importFriends = true;
                await client.LinkFacebookAsync(Session, AccessToken.CurrentAccessToken.TokenString, importFriends);
                Debug.Log("Successfully linked Facebook authentication to existing player account");
            }
            catch(ApiResponseException ex)
            {
                Debug.LogFormat("Error authenticating with Facebook: {0}", ex.Message);
            }
        }
    });
}
```


### 会话变量

在身份验证时可以存储Nakama [会话变量](../../concepts/session/#session-variables)，并且只要会话处于活动状态，就可以在客户端和服务器上使用Nakama会话变量。

Sagi-shi使用会话变量执行分析、推荐和奖励计划等。

在进行身份验证时将会话变量作为参数传递即可存储会话变量：

```csharp
var vars = new Dictionary<string, string>();
vars["DeviceOS"] = SystemInfo.operatingSystem;
vars["DeviceModel"] = SystemInfo.deviceModel;
vars["GameVersion"] = Application.version;
vars["InviterUserId"] = "<SomeUserId>";

/// ...

var session = await client.AuthenticateDeviceAsync("<DeviceId>", null, true, vars);

```
要访问客户端的会话变量，请使用`Session`对象上的`Vars`属性：

```csharp
var deviceOs = session.Vars["DeviceOS"];
```


### 会话生命周期

Nakama [会话](../../concepts/session/)在您的服务器[配置](../../getting-started/configuration/#session)中设定的时间之后过期。使不活动会话过期是良好的安全做法。

Nakama提供了多种恢复会话的方法，例如Sagi-shi玩家重新启动游戏，或在玩游戏的过程中刷新令牌保持会话处于活动状态。

使用身份验证并刷新会话对象上的令牌可以恢复或刷新会话。

Sagi-shi将这些令牌存储在Unity的玩家偏好中：

```csharp
PlayerPrefs.SetString("nakama.authToken", session.AuthToken);
PlayerPrefs.SetString("nakama.refreshToken", session.RefreshToken);
```

恢复会话而不必重新进行身份验证：

```csharp
var authToken = PlayerPrefs.GetString("nakama.authToken", null);
var refreshToken = PlayerPrefs.GetString("nakama.refreshToken", null);
session = Session.Restore(authToken, refreshToken);
```

检查会话是否已过期或即将过期，刷新会话使其保持活动状态：

```csharp
// Check whether a session has expired or is close to expiry.
if (session.IsExpired || session.HasExpired(DateTime.UtcNow.AddDays(1))) {
    try {
        // Attempt to refresh the existing session.
        session = await client.SessionRefreshAsync(session);
    } catch (ApiResponseException) {
        // Couldn't refresh the session so reauthenticate.
        session = await client.AuthenticateDeviceAsync(deviceId);
        PlayerPrefs.SetString("nakama.refreshToken", session.RefreshToken);
    }

    PlayerPrefs.SetString("nakama.authToken", session.AuthToken);
}
```

#### 自动刷新会话

Unity客户端库具有一个自动刷新即将到期的会话的功能。

默认启用这个功能，但可以在首次创建 [Nakama客户端](#nakama-client)时使用以下参数进行配置：

* `AutoRefreshSession` - 指示该功能是否启用的布尔值，默认为`true`
* `DefaultExpiredTimespan` - 会话到期前自动刷新的时间，默认设置为5分钟

### 结束会话

退出登录并结束当前会话：

```csharp
await client.SessionLogoutAsync(session);
```


## 用户账户

Nakama [用户账户](../../concepts/user-accounts/)存储Nakama定义的用户信息和自定义的开发者元数据。

Sagi-shi允许玩家编辑他们的账户，并存储游戏进展和游戏内商品等内容的元数据。

<figure>
  <img src="/docs/nakama/client-libraries/images/profile.png" alt="Sagi-shi player profile screen">
  <figcaption>Player profile</figcaption>
</figure>


### 获取用户账户

通过经身份验证的会话可以访问Nakama的许多功能，比如[获取用户帐户](../../concepts/user-accounts/#fetch-account)。

获取Sagi-shi玩家的完整用户账户，包括他们的基本[用户信息](../../concepts/user-accounts/#fetch-account) 和用户ID：

```csharp
var account = await client.GetAccountAsync(session);
var username = account.User.Username;
var avatarUrl = account.User.AvatarUrl;
var userId = account.User.Id;
```


### 更新用户账户

Nakama为更新服务器存储的资源（如用户账户）提供了简单的方法。

Sagi-shi玩家需要能够更新他们的公开资料：

```csharp
var newUsername = "NotTheImp0ster";
var newDisplayName = "Innocent Dave";
var newAvatarUrl = "https://example.com/imposter.png";
var newLangTag = "en";
var newLocation = "Edinburgh";
var newTimezone = "BST";
await client.UpdateAccountAsync(session, newUsername, newDisplayName, newAvatarUrl, newLangTag, newLocation, newTimezone);
```


### 获取用户

除了获取玩家当前经身份验证的用户账户外，Nakama还可以方便地从其他玩家的ID或用户名中获取其他玩家公开资料列表。

Sagi-shi在结合Nakama的其他功能时，通过这种方法显示用户的资料：

```csharp
var users = await client.GetUsersAsync(session, new string[] { "<AnotherUserId>" });
```


### 存储元数据

Nakama[用户元数据](../../concepts/user-accounts/#user-metadata)允许开发人员使用公开的用户字段扩展用户账户。

仅可在服务器上更新用户元数据。示例请见[更新用户元数据](../snippets/user-metadata/)配方。

Sagi-shi将使用元数据存储玩家装备的游戏内商品：


### 读取元数据

定义描述元数据的类，并对JSON元数据进行语法解析：

```csharp
public class Metadata
{
    public string Title;
    public string Hat;
    public string Skin;
}

// Get the updated account object.
var account = await client.GetAccountAsync(session);

// Parse the account user metadata.
var metadata = Nakama.TinyJson.JsonParser.FromJson<Metadata>(account.User.Metadata);

Debug.LogFormat("Title: {0}", metadata.Title);
Debug.LogFormat("Hat: {0}", metadata.Hat);
Debug.LogFormat("Skin: {0}", metadata.Skin);
```


### 钱包

Nakama[用户钱包](../../concepts/user-accounts/#virtual-wallet)可以将多种数字货币存储为字符串/整数的键/值对。

Sagi-shi玩家可以使用游戏内的虚拟货币解锁或购买头衔、皮肤和帽子。


#### 访问钱包

对用户账户中的JSON钱包数据进行语法解析：

```csharp
var account = await client.GetAccountAsync(session);
var wallet = JsonParser.FromJson<Dictionary<string, int>>(account.Wallet);

foreach (var currency in wallet.Keys)
{
    Debug.LogFormat("{0}: {1}", currency, wallet[currency].ToString());
}
```


#### 更新钱包

仅可在服务器上更新钱包。示例请见[用户账户虚拟钱包](../../concepts/user-accounts/#virtual-wallet)文档。


#### 验证应用内的购买行为

Sagi-shi玩家可以通过应用程序内的购买行为购买游戏内的虚拟货币，这些购买行为应经服务器授权并验证为合法。

示例请见[应用程序内购买行为验证](../../concepts/iap-validation/)文档。


## 存储引擎

Nakama[存储引擎](../../concepts/storage/collections/)是为您的游戏而设的分布式、可扩展的基于文件的存储解决方案。

通过存储引擎，您可以更好地控制数据在集合中的[访问方式](../../concepts/storage/permissions/#object-permissions)和[结构](../../concepts/storage/collections/#collections)。

这些集合会被命名，并将JSON数据存储在唯一的键和用户ID下。

默认玩家拥有创建、读取、更新和删除自己的存储对象的全部权限。

Sagi-shi玩家可以解锁或购买存储引擎中存储的许多商品。

<figure>
  <img src="/docs/nakama/client-libraries/images/player-items.png" alt="Sagi-shi player items screen">
  <figcaption>Player items</figcaption>
</figure>


### 读取存储对象

定义描述存储对象的类别，并使用集合名称、键和用户id创建新的存储对象id。最后，读取存储对象并对JSON数据进行语法解析：

```csharp
public class HatsStorageObject
{
    public string[] Hats;
}

var readObjectId = new StorageObjectId
{
    Collection = "Unlocks",
    Key = "Hats",
    UserId = session.UserId
};

var result = await client.ReadStorageObjectsAsync(session, new [] { readObjectId });

if (result.Objects.Any())
{
    var storageObject = result.Objects.First();
    var unlockedHats = JsonParser.FromJson<HatsStorageObject>(storageObject.Value);
    Debug.LogFormat("Unlocked hats: {0}", string.Join(",", unlockedHats.Hats));
}
```

要读取其他玩家的公开存储对象利用他们的`UserId`。注意，玩家仅可读取自己拥有的存储对象或公开的存储对象（`PermissionRead`值为`2`）。

### 写入存储对象

Nakama允许开发人员从客户端和服务器写入存储引擎。

在决定写入逻辑的存放位置时，要考虑恶意用户会对您的游戏和财物产生何种不利影响，例如仅允许经过授权后写入数据（即游戏解锁或进度）。

Sagi-shi允许玩家收藏商品，以便在UI界面轻松查看这些商品，可以安全地从客户端写入这些数据。

使用集合名称、键和JSON编码的数据创建写入存储对象。最后，将存储对象写入存储引擎：

```csharp
var favoriteHats = new HatsStorageObject
{
    Hats = new string[] { "cowboy", "alien"}
};

var writeObject = new WriteStorageObject
{
    Collection = "favorites",
    Key = "Hats",
    Value = JsonWriter.ToJson(favoriteHats),
    PermissionRead = 1, // Only the server and owner can read
    PermissionWrite = 1, // The server and owner can write
};

await client.WriteStorageObjectsAsync(session, new[] { writeObject });
```

您也可以将多个对象传递到`WriteStorageObjectsAsync`方法：

```csharp
var writeObjects = new[] {
    new WriteStorageObject {
        //...
    },
    new WriteStorageObject
    {
        // ...
    }
};

await client.WriteStorageObjectsAsync(session, writeObjects);
```


### 条件写入

存储引擎[条件写入](../../concepts/storage/collections/#conditional-writes)确保仅当对象在您访问后未改变时才会发生写入操作。

这样可以保护您的数据不被覆盖，例如，Sagi-shi服务器可能在玩家上次访问对象后更新对象。

要执行有条件写入，要用最新的对象版本向写入存储对象添加版本：

```csharp
// Assuming we already have a storage object (storageObject)
var writeObject = new WriteStorageObject
{
    Collection = storageObject.Collection,
    Key = storageObject.Key,
    Value = "<NewJSONValue>",
    PermissionWrite = 0,
    PermissionRead = 1,
    Version = storageObject.Version
};

try
{
    await client.WriteStorageObjectsAsync(session, writeObjects);
}
catch (ApiResponseException ex)
{
    Debug.Log(ex.Message);
}
```


### 列出存储对象

您可以在一个集合中列出玩家可以查看的所有存储对象，而不是通过单独的键发出多次读取请求。

Sagi-shi列出玩家未解锁或已购买的所有头衔、帽子和皮肤：

```csharp
var limit = 3;
var unlocksObjectList = await client.ListUsersStorageObjectsAsync(session, "Unlocks", session.UserId, limit, cursor: null);

foreach (var unlockStorageObject in unlocksObjectList.Objects)
{
    switch(unlockStorageObject.Key)
    {
        case "Titles":
            var unlockedTitles = JsonParser.FromJson<TitlesStorageObject>(unlockStorageObject.Value);
            // Display the unlocked titles
            break;
        case "Hats":
            var unlockedHats = JsonParser.FromJson<HatsStorageObject>(unlockStorageObject.Value);
            // Display the unlocked hats
            break;
        case "Skins":
            var unlockedSkins = JsonParser.FromJson<SkinsStorageObject>(unlockStorageObject.Value);
            // Display the unlocked skins
            break;
    }
}
```


### 分页结果

Nakama列出结果的方法会退回游标，将其传递给Nakama的后续调用，指示在集合中检索对象的起始点。

例如：
- 如果游标的值为5，您将从第五个对象开始获取结果。
- 如果游标的值为`null`，您将从第一个对象开始获取结果。

```csharp
objectList = await client.ListStorageObjectsAsync(session, "<CollectionName>", limit, objectList.Cursor);
```

### 保护服务器上的存储操作

可以在服务器上保护Nakama存储引擎操作，从而保护不应被玩家修改的数据（即 游戏解锁或进度）。参见[经授权写入存储引擎](../snippets/authoritative-write/)配方。

## 远程程序调用

Nakama[服务器](../../server-framework/)允许开发人员写入自定义逻辑，并将其作为[RPC](../../server-framework/introduction/#functionality)向客户端公开。

Sagi-shi包含各种需要在服务器上保护的逻辑，比如在装备设备之前检查玩家是否拥有设备。

### 创建服务器逻辑

关于创建远程过程检查玩家在装备设备之前是否拥有设备的示例，请参见[经授权处理玩家设备](../snippets/authoritative-read/)配方。


### 客户端RPC

可以从客户端调用Nakama远程过程，并获取可选的JSON负载。

Sagi-shi客户端允许RPC安全地装备帽子：

```csharp
try
{
    var payload = new Dictionary<string, string> {{ "item", "cowboy" }};
    var response = await client.RpcAsync(session, "EquipHat", payload.ToJson());
    Debug.Log("New hat equipped successfully", response);
}
catch (ApiResponseException ex)
{
    Debug.LogFormat("Error: {0}", ex.Message);
}
```


### 套接字RPC

您需要与Nakama实时功能交互时，也可以通过套接字调用Nakama远程过程。

```csharp
var response = await socket.RpcAsync("<RpcId>", "<PayloadString>");
```


## 好友

Nakama[好友](../../concepts/friends/)提供完整的社交图谱系统来管理玩家之间的好友关系。

Sagi-shi允许玩家添加好友，管理好友关系，组队玩游戏。

<figure>
  <img src="/docs/nakama/client-libraries/images/friends.png" alt="Sagi-shi Friends screen">
  <figcaption>Friends screen</figcaption>
</figure>


### 添加好友

在Nakama中添加好友不会立即添加共同好友。它会向每个用户发送好友请求，需要用户接受请求。

Sagi-shi允许玩家按用户名或用户id添加好友：


```csharp
// Add friends by Username.
await client.AddFriendsAsync(session, null, new[] { "AlwaysTheImposter21", "SneakyBoi" });

// Add friends by User ID.
await client.AddFriendsAsync(session, new[] { "<SomeUserId>", "<AnotherUserId>" });
```


### 好友关系的状态

在Nakama中，好友关系可以有以下几种[状态](../../concepts/friends/#friend-state)：

| 值 | 状态 |
| ----- | ----- |
| 0 | 共同好友 |
| 1 | 发出的好友请求等待接受 |
| 2 | 收到的好友请求等待接受 |
| 3 | 被用户屏蔽 |


### 列出好友

Nakama允许开发人员按好友关系的状态列出玩家的好友。

Sagi-shi列出最近20位共同好友：

```csharp
var limit = 20; // Limit is capped at 1000
var frienshipState = 0;
var result = await client.ListFriendsAsync(session, frienshipState, limit, cursor: null);

foreach (var friend in result.Friends)
{
    Debug.LogFormat("ID: {0}", friend.User.Id);
}
```


### 接受好友请求

在Nakama中接受好友请求时，玩家会添加[双向好友关系](../../concepts/friends/best-practices/#modeling-relationships)。

Nakama会将两个玩家的好友状态从待接受改为共同好友。

在完整的游戏中，您可以允许玩家单独接受某些请求。

Sagi-shi仅获取并接受收到的所有好友请求：

```csharp
var limit = 1000;
var result = await client.ListFriendsAsync(session, 2, limit, cursor: null);

foreach (var friend in result.Friends)
{
    await client.AddFriendsAsync(session, new[] { friend.User.Id });
}
```


### 删除好友

Sagi-shi玩家可以按用户名或用户id删除好友：

```csharp
// Delete friends by User ID.
await client.DeleteFriendsAsync(session, new[] { "<SomeUserId>", "<AnotherUserId>" });

// Delete friends by Username.
await client.DeleteFriendsAsync(session, null, new[] { "<SomeUsername>", "<AnotherUsername>" });
```


### 屏蔽用户

Sagi-shi玩家可以按用户名或用户id屏蔽用户：


```csharp
// Block friends by User ID.
await client.BlockFriendsAsync(session, new[] { "<SomeUserId>", "<AnotherUserId>" });

// Block friends by Username.
await client.BlockFriendsAsync(session, null, new[] { "<SomeUsername>", "<AnotherUsername>" });
```


## 状态与显示

Nakama[状态](../../concepts/status/)是一种实时状态与显示的服务，允许用户设置显示的在线状态，更新状态消息并关注其他用户的更新。

玩家不必与别人成为好友也可关注别人。

Sagi-shi使用状态消息和显示的在线状态，当好友在线时通知玩家并分享比赛。

<figure>
  <img src="/docs/nakama/client-libraries/images/status.png" alt="Sagi-shi status update screen">
  <figcaption>Updating player status</figcaption>
</figure>


### 关注用户

Nakama实时API允许开发人员订阅套接字上的事件并实时接收这些事件，如状态显示变更。

关注用户的方法也会返回至当前的在线用户（即显示的在线状态）以及他们的状态。

Sagi-shi关注玩家的好友，并当好友在线时通知玩家：

```csharp
// Subscribe to the Status event.
socket.ReceivedStatusPresence += e =>
{
    foreach (var presence in e.Joins)
    {
        Debug.LogFormat("{0} is online with status: '{1}'", presence.Username, presence.Status);
    }

    foreach (var presence in e.Leaves)
    {
        Debug.LogFormat("{0} went offline", presence.Username);
    }
};

// Follow mutual friends and get the initial Status of any that are currently online.
var friendsResult = await client.ListFriendsAsync(session, 0);
var friendIds = friendsResult.Friends.Select(f => f.User.Id);
var result = await socket.FollowUsersAsync(friendIds);

foreach (var presence in result.Presences)
{
    Debug.LogFormat("{0} is online with status: {1}", presence.Username, presence.Status);
}
```


### 取消关注用户

Sagi-shi玩家可以取消关注其他人：

```csharp
await socket.UnfollowUsersAsync(new[] { "<UserId>" });
```


### 更新玩家状态

Sagi-shi玩家可以更改状态并向关注自己的人发布状态：

```csharp
await socket.UpdateStatusAsync("Viewing the Main Menu");
```


## 群组

Nakama[群组](../../concepts/groups/)是指一个公开/私密的群组或家族体系，拥有用户成员资格和权限、元数据和群组聊天功能。

Sagi-shi允许玩家创建和加入群组，可以社交或竞赛。

<figure>
  <img src="/docs/nakama/client-libraries/images/groups-list.png" alt="Sagi-shi groups screen">
  <figcaption>Groups list screen</figcaption>
</figure>


### 创建群组

群组可以为公开或私密“开放”。每个人都可以加入公开的群组，但如想加入私密群组，必须要请求加入，得到超级管理员/管理员批准。

Sagi-shi玩家可以围绕共同的兴趣创建群组：

```csharp
var name = "Imposters R Us";
var description = "A group for people who love playing the imposter.";
var open = true; // public group
var maxSize = 100;

var group = await client.CreateGroupAsync(session, name, description, avatarUrl: null, langTag: null, open, maxSize);
```


### 更新群组的可见性

Nakama允许群组的超级管理员或管理员成员从客户端更新某些属性，如开放可见性：

```csharp
var open = false;
await client.UpdateGroupAsync(session, "<GroupId>", name: null, open);
```


### 更新群组规模

其他属性只能在服务器上更改，例如群组成员的最大数量。

示例请见[更新群组规模](../../concepts/groups/#updating-group-size)配方和[群组服务器功能参考](../../server-framework/typescript-runtime/function-reference/#groups)，进一步了解在服务器上更新群组的信息。

<figure>
  <img src="/docs/nakama/client-libraries/images/group-edit.png" alt="Sagi-shi group edit screen">
  <figcaption>Sagi-shi group edit</figcaption>
</figure>


### 列出和过滤群组

群组可以像Nakama的其他资源一样列出，也可以使用通配符群组名称[过滤](../../concepts/groups/#list-and-filter-groups)。

Sagi-shi玩家通过列出和过滤群组搜索加入现有的群组：


```csharp
var limit = 20;
var result = await client.ListGroupsAsync(session, "imposter%", limit);

foreach (var group in result.Groups)
{
    Debug.LogFormat("{0} [{1}]", group.Name, group.Open ? "Public" : "Private")
}

// Get the next page of results.
var nextResults = await client.ListGroupsAsync(session, name: "imposter%", limit, result.Cursor);
```


### 删除群组

Nakama允许群组的超级管理员删除群组。

开发人员可以完全禁用此功能，请在[Guarding API指南](../../guides/server-framework/guarding-apis/)中查看如何保护Nakama各种API的示例。

Sagi-shi玩家可以删除自己在其中作为超级管理员的群组：

```csharp
await client.DeleteGroupAsync(session, "<GroupId>");
```


### 群组元数据

与用户账户一样，群组可以拥有公开元数据。

Sagi-shi使用群组元数据存储群组的兴趣、玩家的活跃时间和使用的语言。

仅可在服务器上更新群组元数据。示例请见[更新群组元数据](../snippets/group-metadata/)配方。

Sagi-shi客户端使用群组元数据负载进行RPC：

```csharp
var payload = new UpdateGroupMetadataPayload
{
    GroupId = "<GroupId>",
    Interests = new[] { "Deception", "Sabotage", "Cute Furry Bunnies" },
    ActiveTimes = new[] { "9am-2pm Weekdays", "9am-10pm Weekends" },
    Languages = new[] { "English", "German" }
};

try
{
    var result = await client.RpcAsync(session, "UpdateGroupMetadata", JsonWriter.ToJson(payload));
    Debug.Log("Successfully updated group metadata");
}
catch (ApiResponseException ex)
{
    Debug.LogFormat("Error: {0}", ex.Message);
}
```


### 群组成员资格状态

在Nakama中，成员资格可以有以下几种[状态](../../concepts/groups/#groups-and-clans)：

| 代码 | 用途 |
| ---- | ------- | - |
|    0 | 超级管理员 | 任何群组都必须拥有至少一位超级管理员。超级管理员拥有管理员的所有权限，另外还可以删除群组和升级管理员成员。 |
|    1 | 管理员 | 可以有一个或多个管理员。管理员可以更新群组，也可以接受、踢出、升级、降级、封禁或添加成员。 |
|    2 | 成员 | 群组常规成员。他们无法接受新用户的加入请求。 |
|    3 | 加入请求 | 新用户发来的新的加入请求。这不会被计入群组成员的最大数量。 |


### 加入群组

如果用户加入公开群组，可立即成为群组成员，但如果尝试加入私密群组，必须等待群组管理员接受请求。

Sagi-shi玩家可以加入群组：

```csharp
await client.JoinGroupAsync(session, "<GroupId>");
```


### 列出用户的群组

Sagi-shi玩家可以列出自己所在的群组：

```csharp
var results = await client.ListUserGroupsAsync(session);

foreach (var userGroup in results.UserGroups)
{
    Debug.LogFormat("{0}: {1}", userGroup.Group.Name, userGroup.State);
}
```


### 列出成员

Sagi-shi玩家可以列出群组的成员：

```csharp
var result = await client.ListGroupUsersAsync(session, "<GroupId>");

foreach (var groupUser in result.GroupUsers)
{
    Debug.LogFormat("{0}: {1}", groupUser.User.Id, groupUser.State);
}
```


### 接受加入请求

私密群组管理员或超级管理员可以通过将用户重新添加到群组来接受加入请求。

Sagi-shi首先列出处于请求加入状态的用户，然后遍历将这些用户添加到群组：


```csharp
var result = await client.ListGroupUsersAsync(session, "<GroupId>", 3);

foreach (var groupUser in result.GroupUsers)
{
    await client.AddGroupUsersAsync(session, "<GroupId>", new[] { groupUser.User.Id });
}
```


### 升级成员

Nakama群组成员可以升级为管理员或超级管理员角色，帮助管理规模不断扩大的群组，或在成员离开时接任。

管理员可以将其他成员升级为管理员，超级管理员可以将其他成员升级为超级管理员。

成员将被提升一级。例如：

- 成员可以升级为管理员
- 管理员可以升级为超级管理员

```csharp
await client.PromoteGroupUsersAsync(session, "<GroupId>", new[] { "UserId" });
```


### 降级成员

Sagi-shi群组管理员和超级管理员可以降级成员：

```csharp
await client.DemoteGroupUsersAsync(session, "<GroupId>", new[] { "UserId" });
```


### 踢出成员

Sagi-shi群组管理员和超级管理员可以移除群组成员：

```csharp
await client.KickGroupUsersAsync(session, "<GroupId>", new[] { "UserId" });
```


### 封禁成员

当降级用户或踢出用户不够严重时，Sagi-shi群组管理员和超级管理员可以封禁成员：

```csharp
await client.BanGroupUsersAsync(session, "<GroupId>", new[] { "UserId" });
```

### 退出群组

Sagi-shi玩家可以退出群组：

```csharp
await client.LeaveGroupAsync(session, "<GroupId>");
```


## 聊天

Nakama聊天是针对群组、私密/直接消息以及动态聊天室的实时聊天系统。

Sagi-shi在比赛期间使用动态聊天，玩家可以相互误导，讨论谁是内鬼，群组聊天和私密/直接消息。

<figure>
  <img src="/docs/nakama/client-libraries/images/chat.png" alt="Sagi-shi chat screen">
  <figcaption>Sagi-shi Chat</figcaption>
</figure>


### 加入动态聊天室

Sagi-shi比赛设有非永久聊天室，供玩家交流：

```csharp
var roomName = "<MatchId>";
var persistence = false;
var hidden = false;
var channel = await socket.JoinChatAsync(roomName, ChannelType.Room, persistence, hidden);

Debug.LogFormat("Connected to dynamic room channel: {0}", channel.Id);
```


### 加入群组聊天

Sagi-shi群组成员可以在持久的群组聊天频道中跨越游戏会话进行交流：

```csharp
var groupId = "<GroupId>";
var persistence = true;
var hidden = false;
var channel = await socket.JoinChatAsync(groupId, ChannelType.Group, persistence, hidden);

Debug.LogFormat("Connected to group channel: {0}", channel.Id);
```


### 加入直接聊天

Sagi-shi玩家也可以在比赛中或比赛后一对一私下交流，可以查看消息历史记录：

```csharp
var userId = "<UserId>";
var persistence = true;
var hidden = false;
var channel = await socket.JoinChatAsync(userId, ChannelType.DirectMessage, persistence, hidden);

Debug.LogFormat("Connected to direct message channel: {0}", channel.Id);
```


### 发送消息

在每种聊天频道中，消息的发送都是一样的。消息包含聊天文本和表情，以JSON序列化数据的形式发送：

```csharp
var channelId = "<ChannelId>"

var messageContent = new Dictionary<string, string> {
    { "message", "I think Red is the imposter!" }
};

var messageSendAck = await socket.WriteChatMessageAsync(channelId, JsonWriter.ToJson(messageContent));

var emoteContent = new Dictionary<string, string> {
    { "emote", "point" },
    { "emoteTarget", "<RedPlayerUserId>" }
};

var emoteSendAck = await socket.WriteChatMessageAsync(channelId, JsonWriter.ToJson(emoteContent));
```


### 列出消息历史

消息列表需要一个参数，指示接收的消息是从最早到最新（向前）还是从最新到最早排列。

Sagi-shi玩家可以列出群组的消息历史：

```csharp
var limit = 100;
var forward = true;
var groupId = "<GroupId>";
var result = await client.ListChannelMessagesAsync(session, groupId, limit, forward, cursor: null);

foreach (var message in result.Messages)
{
    Debug.LogFormat("{0}:{1}", message.Username, message.Content);
}
```

聊天还有可缓存游标，获取最新消息，您可以存储到`PlayerPrefs`。


```csharp
PlayerPrefs.SetString(string.Format("nakama.groupMessagesCacheableCursor_{0}", groupId), result.CacheableCursor);

var cacheableCursor = PlayerPrefs.GetString(string.Format("nakama.groupMessagesCacheableCursor_{0}", groupId), null);
var nextResults = await client.ListChannelMessagesAsync(session, groupId, limit, forward, cacheableCursor);
```


### 更新消息

Nakama还支持更新消息。是否使用此功能取决于您，但在类似Sagi-shi的欺骗性游戏中，它可以增加额外的欺骗元素。

例如玩家发送以下消息：

```csharp
var channelId = "<ChannelId>"
var messageContent = new Dictionary<string, string> {
    { "message", "I think Red is the imposter!" }
};
var messageSendAck = await socket.WriteChatMessageAsync(channelId, JsonWriter.ToJson(messageContent));
```

然后他们迅速编辑他们的消息，迷惑他人：

```csharp
var newMessageContent = new Dictionary<string, string> {
    { "message", "I think BLUE is the imposter!" }
};
var messageUpdateAck = await socket.UpdateChatMessageAsync(channelId, messageSendAck.MessageId, JsonWriter.ToJson(newMessageContent));
```


## 比赛

Nakama支持[服务器授权](../../concepts/multiplayer/authoritative/)和[服务器中继](../../concepts/multiplayer/relayed/)的多人比赛。

在服务器授权比赛中，服务器控制玩法循环，并且必须使所有的客户端与游戏的当前状态保持同步。

在服务器中继比赛中，客户端处于控制地位，服务器仅将信息中继到其他连接的客户端。

在竞争性游戏中，如Sagi-shi，服务器授权比赛可防止客户端以未经授权的方式与您的游戏交互。

在本指南中，为简化起见，采用了服务器中继模式。


### 创建比赛

Sagi-shi玩家可以自行创建比赛并邀请在线好友加入：

```csharp
var match = await socket.CreateMatchAsync();
var friendsList = await client.ListFriendsAsync(session, 0, 100);
var onlineFriends = friendsList.Friends.Where(f => f.User.Online).Select(f => f.User);

foreach (var friend in onlineFriends)
{
    var content = new
    {
        message = string.Format("Hey {0}, join me for a match!", friend.Username),
        matchId = match.Id
    };

    var channel = await socket.JoinChatAsync(friend.Id, ChannelType.DirectMessage);
    var messageAck = await socket.WriteChatMessageAsync(channel, JsonWriter.ToJson(content));
}
```

**使用比赛名称创建比赛**

Sagi-shi玩家还可以使用特定的比赛名称创建比赛，这样就可以通过将比赛名称告知好友来邀请好友加入。需要注意，使用比赛名称创建比赛时（任意名称，而不是与经授权的比赛处理程序绑定的名称），比赛将始终为中继比赛而非授权比赛。

```csharp
var matchName = "NoImpostersAllowed";
var match = await socket.CreateMatchAsync(matchName);
```

### 加入比赛

如果知道id，Sagi-shi玩家可以尝试加入已有比赛：
```csharp
var matchId = "<MatchId>";
var match = await socket.JoinMatchAsync(matchId);
```

或者设置实时配对监听器，将自己加入到配对：

```csharp
socket.ReceivedMatchmakerMatched += async matchmakerMatched => {
    var match = await socket.JoinMatchAsync(matchmakerMatched);
};

var minPlayers = 2;
var maxPlayers = 10;
var query = "";

var matchmakingTicket = await socket.AddMatchmakerAsync(query, minPlayers, maxPlayers);
```

**按比赛名称加入比赛**

Sagi-shi玩家可以按好友分享的比赛名称加入比赛。

```csharp
var matchName = "NoImpostersAllowed";

// When joining by match name, you use the CreateMatchAsync function instead of the JoinMatchAsync function
var match = await socket.CreateMatchAsync(matchName);
```

**按玩家状态加入比赛**

Sagi-shi玩家可以在加入新比赛时更新状态：

```csharp
var status = new Dictionary<string, string>
{
    { "Status", "Playing a match" },
    { "MatchId", "<MatchId>" }
};

await socket.UpdateStatusAsync(JsonWriter.ToJson(status));
```

关注玩家的用户可以接收实时状态事件，并尝试加入比赛：

```csharp
socket.ReceivedStatusPresence += async e =>
{
    // Join the first match found in a friend's status
    foreach (var presence in e.Joins)
    {
        var status = JsonParser.FromJson<Dictionary<string, string>>(presence.Status);
        if (status.ContainsKey("MatchId"))
        {
            await socket.JoinMatchAsync(status["MatchId"]);
            break;
        }
    }
};
```

### 列出比赛

[比赛列表](../../concepts/multiplayer/match-listing/)需要一些标准来过滤比赛，包括玩家人数、匹配标签和可以进行更复杂的[搜索查询](../../concepts/multiplayer/query-syntax/)的选项。

在大厅状态时可以开始Sagi-shi比赛。比赛会存在于服务器，但只有当加入的玩家人数足够时才会开始比赛。

然后Sagi-shi可以列出正在等待更多玩家的比赛：

```csharp
var minPlayers = 2;
var maxPlayers = 10;
var limit = 10;
var authoritative = true;
var label = "";
var query = "";
var result = await client.ListMatchesAsync(session, minPlayers, maxPlayers, limit, authoritative, label, query);

foreach (var match in result.Matches)
{
    Debug.LogFormat("{0}: {1}/10 players", match.MatchId, match.Size);
}
```

找到标签为`"AnExactMatchLabel"`的比赛：

```csharp
var label = "AnExactMatchLabel";
```

**高级：**

为了使用更复杂的结构化查询，匹配标签必须为JSON格式。

要查找预期玩家技能级别为`>100`且可选游戏模式为`"sabotage"`的比赛：

```csharp
var query = "+label.skill:>100 label.mode:sabotage"
```


### 生成玩家

比赛对象有一个当前在线用户列表，称为显示在线的用户。

Sagi-shi使用比赛显示的在线状态在客户端上生成玩家：

```csharp
var match = await socket.JoinMatchAsync(matchId);

var players = new Dictionary<string, GameObject>();

foreach (var presence in match.Presences)
{
    // Spawn a player for this presence and store it in a dictionary by session id.
    var go = Instantiate(playerPrefab);
    players.Add(presence.SessionId, go);
}
```

Sagi-shi使用接收到的比赛显示的在线状态事件，使生成的玩家在离开和加入比赛时保持最新状态：

```csharp
socket.ReceivedMatchPresence += matchPresenceEvent =>
{
    // For each player that has joined in this event...
    foreach (var presence in matchPresenceEvent.Joins)
    {
        // Spawn a player for this presence and store it in a dictionary by session id.
        var go = Instantiate(playerPrefab);
        players.Add(presence.SessionId, go);
    }

    // For each player that has left in this event...
    foreach (var presence in matchPresenceEvent.Leaves)
    {
        // Remove the player from the game if they've been spawned
        if (players.ContainsKey(presence.SessionId))
        {
            Destroy(players[presence.SessionId]);
            players.Remove(presence.SessionId);
        }

    }
};
```


### 发送比赛状态

Nakama拥有实时网络，可以在玩家移动和与游戏世界互动时[发送](../../concepts/multiplayer/relayed/#send-data-messages)和[接收](../../concepts/multiplayer/relayed/#receive-data-messages)比赛状态。

比赛过程中，Sagi-shi的每个客户端都会将比赛状态发送到服务器，从而中继给其他客户端。

匹配状态包含一个操作代码，让接收者知道正在接收什么数据，以便对数据进行反序列化处理并更新他们的游戏视图。

Sagi-shi使用的操作代码示例：
- 1：玩家位置
- 2：玩家调用投票


**发送玩家位置**

定义一个类别来代表Sagi-shi玩家的位置状态。

```csharp
[Serializable]
public class PositionState
{
    public float X;
    public float Y;
    public float Z;
}
```

在玩家的转换中创建一个实例，设置操作代码并发送JSON编码的状态：

```csharp
var state = new PositionState
{
    X = transform.position.x,
    Y = transform.position.y,
    Z = transform.position.z
};

var opCode = 1;

await socket.SendMatchStateAsync(match.Id, opCode, JsonWriter.ToJson(state));
```


**作为静态类操作代码**

Sagi-shi有许多联网游戏动作。操作代码使用静态常量类将更容易遵循和维护：

```csharp
public static class OpCodes
{
    public const long Position = 1;
    public const long Vote = 2;
}

await socket.SendMatchStateAsync(match.Id, OpCodes.Position, JsonWriter.ToJson(state));
```


### 接收比赛状态

Sagi-shi玩家可以通过订阅比赛状态接收事件，从其他连接的客户端接收比赛数据：

```csharp
socket.ReceivedMatchState += matchState =>
{
    switch (matchState.OpCode)
    {
        case OpCodes.Position:
            // Get the updated position data
            var stateJson = Encoding.UTF8.GetString(matchState.State);
            var positionState = JsonParser.FromJson<PositionState>(stateJson);

            // Update the GameObject associated with that player
            if (players.ContainsKey(matchState.UserPresence.SessionId))
            {
                // Here we would normally do something like smoothly interpolate to the new position, but for this example let's just set the position directly.
                players[matchState.UserPresence.SessionId].transform.position = new Vector3(positionState.X, positionState.Y, positionState.Z);
            }
            break;
        default:
            Debug.Log("Unsupported op code");
            break;
    }
};
```


## 配对程序

开发人员可以使用比赛列表或Nakama[配对程序](../../concepts/multiplayer/matchmaker/)为玩家寻找匹配，使玩家能够加入实时匹配池，并在匹配到其他符合指定标准的玩家时收到通知。

配对可以帮助玩家找到一起玩游戏的伙伴，但不会创建比赛。这种解耦的设计允许您将配对用于寻找游戏匹配之外的其他目的。例如，如果您想社交，您可以使用配对匹配别人进行聊天。


### 添加配对程序

匹配标准可以很简单，即找到2名玩家，或者更复杂，即找到2-10名对特定游戏模式感兴趣的拥有最低技能水平的玩家。

Sagi-shi允许玩家加入匹配池，让服务器将这些玩家与其他玩家匹配：

```csharp
var minPlayers = 2;
var maxPlayers = 10;
var query = "+skill:>100 mode:sabotage";
var stringProperties = new Dictionary<string, string> { { "mode", "sabotage" }};
var numericProperties = new Dictionary<string, double> { { "skill", 125 }};
var matchmakerTicket = await socket.AddMatchmakerAsync(query, minPlayers, maxPlayers, stringProperties, numericProperties);
```

## 派对

Nakama[派对](../../concepts/parties/)是一个实时系统，允许玩家组成短暂的派对，这些派对在所有玩家断开连接后不会持续存在。

Sagi-shi允许好友组成派对并一起配对。


### 创建派对

创建派对的玩家是派对的领导者。派对玩家的数量有上限，派对既可以为开放式，即自动接受玩家，也可以为封闭式，即等待派对领导者接受玩家发来的加入请求。

Sagi-shi使用封闭式派对，最多可以有四名玩家：

```csharp
var open = false;
var hidden = false;
var maxPlayers = 4;
var party = await socket.CreatePartyAsync(open, hidden, maxPlayers);
```

Sagi-shi通过私密/直接消息将派对id分享给好友：

```csharp
var friendsList = await client.ListFriendsAsync(session, 0, 100);
var onlineFriends = friendsList.Friends.Where(f => f.User.Online).Select(f => f.User);

foreach (var friend in onlineFriends)
{
    var content = new
    {
        message = string.Format("Hey {0}, wanna join the party?!", friend.Username),
        partyId = party.Id
    };

    var channel = await socket.JoinChatAsync(friend.Id, ChannelType.DirectMessage);
    var messageAck = await socket.WriteChatMessageAsync(channel, JsonWriter.ToJson(content));
}
```


### 加入派对

Safi-shi玩家可以通过查看聊天消息中的派对id来加入派对：

```csharp
socket.ReceivedChannelMessage += async m =>
{
    var content = JsonParser.FromJson<Dictionary<string, string>>(m.Content);
    if (content.ContainsKey("partyId"))
    {
        await socket.JoinPartyAsync(content["partyId"]);
    }
};
```


### 升级成员

Sagi-shi派对成员可以升级为派对领导者：

```csharp
var newLeader = party.Presences.Where(p => p.SessionId != party.Leader.SessionId).First();
await socket.PromotePartyMemberAsync(party.Id, newLeader);
```

### 退出派对

Sagi-shi玩家可以退出派对：

```csharp
await socket.LeavePartyAsync(party.Id);
```


### 派对配对

加入派对的一个主要好处是，所有玩家都可以同时加入匹配池。

Sagi-shi玩家可以收听配对程序匹配事件，并在找到匹配人时加入比赛：

```csharp
socket.ReceivedMatchmakerMatched += async matchmakerMatched =>
{
    await socket.JoinMatchAsync(matchmakerMatched.MatchId);
};
```

派对领导者将开始为派对进行匹配：

```csharp
var minPlayers = 2;
var maxPlayers = 10;
var query = "";
var matchmakerTicket = await socket.AddMatchmakerPartyAsync("<PartyId>", query, minPlayers, maxPlayers);
```


## 排行榜

Nakama[排行榜](../../concepts/leaderboards/)为您的游戏引入竞争因素，提高了玩家的参与度和保留率。

Sagi-shi有一个内鬼获胜的周排行榜，玩家每次获胜都会增加得分，同样，也有船员获胜的周排行榜。

<figure>
  <img src="/docs/nakama/client-libraries/images/leaderboard.png" alt="Sagi-shi leaderboard screen">
  <figcaption>Sagi-shi Leaderboard</figcaption>
</figure>


### 创建排行榜

必须在服务器上创建排行榜，请在[排行榜](../../concepts/leaderboards/#create-a-leaderboard)文档中查看有关创建排行榜的详细信息。


### 提交分数

玩家提交分数时，Nakama将会把提交的分数值加到玩家的现有分数中。

除了分数值外，Nakama还有一个子分数，当分数值相同时可使用子分数进行排序。

Sagi-shi玩家可以向排行榜提交带有情境元数据的分数，例如取得分数的地图：

```csharp
var score = 1;
var subscore = 0;
var metadata = new Dictionary<string, string> {{ "map", "space_station" }};
await client.WriteLeaderboardRecordAsync(session, "weekly_imposter_wins", score, subscore, JsonWriter.ToJson(metadata));
```

### 列出最高记录

Sagi-shi玩家可以列出排行榜的最高记录：

```csharp
var limit = 20;
var leaderboardName = "weekly_imposter_wins";
var result = await client.ListLeaderboardRecordsAsync(session, leaderboardName, ownerIds: null, expiry: null, limit, cursor: null);

foreach (var record in result.Records)
{
    Debug.LogFormat("{0}:{1}", record.OwnerId, record.Score);
}
```


**列出用户周围的记录**

Nakama允许开发人员列出玩家周围的排行榜记录。

Sagi-shi向玩家简要介绍其与周围玩家的对抗情况：

```csharp
var limit = 20;
var leaderboardName = "weekly_imposter_wins";
var result = await client.ListLeaderboardRecordsAroundOwnerAsync(session, leaderboardName, session.UserId, expiry: null, limit);

foreach (var record in result.Records)
{
    Debug.LogFormat("{0}:{1}", record.OwnerId, record.Score);
}
```


**列出一系列用户的记录**

Sagi-shi玩家可以通过向所有者id参数提供他们的用户id来获取好友分数：

```csharp
var friendsList = await client.ListFriendsAsync(session, 0, 100, cursor: null);
var userIds = friendsList.Friends.Select(f => f.User.Id);
var recordList = await client.ListLeaderboardRecordsAsync(session, "weekly_imposter_wins", userIds, expiry: null, 100, cursor: null);

foreach (var record in recordList.Records)
{
    Debug.LogFormat("{0} scored {1}", record.Username, record.Score);
}
```

同样也可以通过向所有者id参数提供他们的用户id来获取群组成员的分数：

```csharp
var groupUserList = await client.ListGroupUsersAsync(session, "<GroupId>", state: null, 100, cursor: null);
var userIds = groupUserList.GroupUsers.Where(x => x.State < 3).Select(g => g.User.Id);
var recordList = await client.ListLeaderboardRecordsAsync(session, "weekly_imposter_wins", userIds, expiry: null, 100, cursor: null);
foreach (var record in recordList.Records)
{
    Debug.LogFormat("{0} scored {1}", record.Username, record.Score);
}
```


### 删除记录

Sagi-shi玩家可以删除自己的排行榜记录：

```csharp
await client.DeleteLeaderboardRecordAsync(session, "<LeaderboardId>");
```


## 锦标赛

Nakama[锦标赛](../../concepts/tournaments/)是玩家争夺奖品的短暂比赛。

Sagi-shi玩家可以查看、过滤和加入正在进行的锦标赛。

<figure>
  <img src="/docs/nakama/client-libraries/images/tournaments.png" alt="Sagi-shi tournaments screen">
  <figcaption>Sagi-shi Tournaments</figcaption>
</figure>


### 创建锦标赛

必须在服务器上创建锦标赛，请在[锦标赛](../../concepts/tournaments/#create-tournament)文档中查看有关创建锦标赛的详细信息。

Sagi-shi每周都会举行锦标赛，玩家需要投票给最准确的内鬼。本周结束时，排名靠前的玩家将获得游戏货币奖励。


### 加入锦标赛

默认Nakama玩家不必加入锦标赛也可提交分数，但Sagi-shi强制要求加入锦标赛后方可提交分数：

```csharp
await client.JoinTournamentAsync(session, "<TournamentId>");
```

### 列出锦标赛

Sagi-shi玩家可以根据各种标准列出和筛选锦标赛：

```csharp
var categoryStart = 1;
var categoryEnd = 2;
int? startTime = null;
int? endTime = null;
var limit = 100;
var result = await client.ListTournamentsAsync(session, categoryStart, categoryEnd, startTime, endTime, limit, cursor: null);

foreach (var tournament in result.Tournaments)
{
    Debug.LogFormat("{0}:{1}", tournament.Id, tournament.Title);
}
```

出于性能方面的考虑，通过范围而不是单个数字来过滤类别。您可以利用这一点构建您的类别（例如，所有的PVE锦标赛属于1XX范围，所有的PVP锦标赛属于2XX范围）。

### 列出记录

Sagi-shi玩家可以列出锦标赛记录：

```csharp
var limit = 20;
var tournamentName = "weekly_top_detective";
var result = await client.ListTournamentRecordsAsync(session, tournamentName, ownerIds: null, expiry: null, limit, cursor: null);

foreach (var record in result.Records)
{
    Debug.LogFormat("{0}:{1}", record.OwnerId, record.Score);
}
```


**列出用户周围的记录**

与排行榜类似，Sagi-shi玩家可以获取周围其他玩家的记录：

```csharp
var limit = 20;
var tournamentName = "weekly_top_detective";
var result = await client.ListTournamentRecordsAroundOwnerAsync(session, tournamentName, session.UserId, expiry: null, limit);

foreach (var record in result.Records)
{
    Debug.LogFormat("{0}:{1}", record.OwnerId, record.Score);
}
```


### 提交分数

Sagi-shi玩家可以向锦标赛提交分数、子分数和元数据：

```csharp
var score = 1;
var subscore = 0;
var metadata = new Dictionary<string, string> {{ "map", "space_station" }};
await client.WriteTournamentRecordAsync(session, "weekly_top_detective", score, subscore, JsonWriter.ToJson(metadata));
```


## 通知

游戏服务器可利用Nakama[通知](../../concepts/notifications/)向玩家广播实时消息。

通知可以是持续性（在玩家查看之前一直保留）或暂时性的（仅在玩家当前在线的情况下接收）。

Sagi-shi使用通知将获得的奖金告知锦标赛获奖者。

<figure>
  <img src="/docs/nakama/client-libraries/images/notifications.png" alt="Sagi-shi notification screen">
  <figcaption>Sagi-shi notifications</figcaption>
</figure>


### 接收通知

必须要从服务器发出通知。

Nakama使用代码区分通知。`0` 及以下的代码是为Nakama内部构件保留的[系统代码](../../concepts/notifications/#notification-codes)。

Sagi-shi玩家可以订阅收到的通知事件。Sagi-shi使用代码`100`表示赢得锦标赛：

```csharp
socket.ReceivedNotification += notification => {
    const int rewardCode = 100;
    switch (notification.Code)
    {
        case rewardCode:
            Debug.LogFormat("Congratulations, you won the tournament!\n{0}\n{1}", notification.Subject, notification.Content);
            break;
        default:
            Debug.LogFormat("Other notification: {0}:{1}\n{2}", notification.Code, notification.Subject, notification.Content);
            break;
    }
};
```


### 列出通知

Sagi-shi玩家可以列出离线时收到的通知：

```csharp
var limit = 100;
var result = await client.ListNotificationsAsync(session, limit, cacheableCursor: null);
foreach (var notification in result.Notifications)
{
    Debug.LogFormat("Notification: {0}:{1}\n{2}", notification.Code, notification.Subject, notification.Content);
}
```


**分页及可缓存游标**

与其他列出方法一样，可以使用游标或可缓存游标将通知结果分页。

```csharp
PlayerPrefs.SetString("nakama.notificationsCacheableCursor", result.CacheableCursor);
```

玩家下次登录时，可使用可缓存游标列出未读通知。

```csharp
var cacheableCursor = PlayerPrefs.GetString("nakama.notificationsCacheableCursor", null);
var nextResults = await client.ListNotificationsAsync(session, limit, cacheableCursor);
```


### 删除通知

Sagi-shi玩家可以在阅读后删除通知：

```csharp
await client.DeleteNotificationsAsync(session, new[] { "<NotificationId>" });
```
