# 好友

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

---


# 好友

Nakama 的一个核心功能是“好友”系统，有利于在社交网络中建立个人联系。

在邀请系统的支持下，再加上玩家可以保存自己的好友列表，好友可以通过聊天、在排行榜上竞争或一起加入游戏，轻松地进行互动。

在此我们将学习如何使用 Nakama 的“好友”功能：

- 在玩家数据库中搜索某个人
- 添加新好友和管理邀请
- 直接给好友发消息
- 即使离线也从好友获取消息

在 Pirate Panic，其形如：

![好友面板]({{< fingerprint_image "/images/pages/nakama/tutorials/unity/pirate-panic/friends-panel.png" >}})

本示例中的代码常常分为**客户端代码**（直接与 Unity 交互）和**服务器端代码**（存在于 `ServerModules` 文件夹中，用 Typescript 编写）。

这种分离允许我们在中央位置管理和存储敏感信息，并控制玩家可以看到哪些部分。例如，我们可能需要在服务器上存储所有玩家及其连接的数据库，但玩家自己应该只能看到自己的好友。

## 查找好友

为了在客户端和服务器之间发送和接收数据，我们可以编写 RPC（远程过程调用）。

RPC 代码的一种常见结构是：需要使用 `JSON.parse` 读取 JSON 字符串，对输入进行操作，然后使用 `JSON.stringify` 将其转换回字符串，以便返回。JSON 的使用实现了对数据格式的标准化，从而促进多个引擎和多种语言之间的通信。

我们需要使用 RPC 的一个例子是，如何让玩家搜索好友的用户名，以便他们能够添加好友。由于我们不希望客户端直接访问其他玩家的信息，因此我们进行如下处理：

1. 玩家输入要搜索的短语
2. 客户端使用 PRC 将此短语发送到服务器
3. 服务器直接查询数据库，看看是否有匹配
4. 服务器将匹配结果返回客户端，以便显示

### 在服务器上注册 RPC

我们首先在服务器端实现 RPC，以便客户端可以调用：

**main.ts**

```typescript
const rpcSearchUsernameFn: nkruntime.RpcFunction = function(
  ctx: nkruntime.Context,
  logger: nkruntime.Logger,
  nk: nkruntime.Nakama,
  payload: string): string {

  const input: any = JSON.parse(payload)

  // NOTE: Must be very careful with custom SQL queries to performance check them.
  const query = `
  SELECT id, username FROM users WHERE username ILIKE concat($1, '%')
  `
  const result = nk.sqlQuery(query, [input.username]);

  return JSON.stringify(result);
}
```

此 RPC 是一个函数，将 JSON 对象输出回客户端。[上下文对象](../../../../server-framework/introduction/#runtime-context)包含许多实用的属性，让我们知道此调用源自何处。Nakama 实例可让我们与 Nakama 运行时 API 联系，
 有效载荷是一个 JSON 对象，包含客户端发送的任何自定义信息。

例如，如果我们在寻找名为“BobNorris101”的好友，有效载荷会形如 `{username: "BobNorris101"}`。

一旦我们解析了 JSON，我们就可以使用该信息通过 SQL 查询直接与数据库交互。对 `nk.sqlQuery` 的调用将返回可能形如 `{{id: '3ea5608a-43c3-11e7-90f9-7b9397165f34', username: 'BobNorris101'} ...}` 的一个对象。

我们不需要在任何地方显式调用此函数，我们只需将 RPC 注册到事件：

```typescript
initializer.registerRpc('search_username', rpcSearchUsernameFn);
```

因此只要客户端调用 `search_username`，我们的函数就会运行，自动将结果返回客户端。

### 在客户端上调用 RPC

在 Unity 客户端上调用上方定义的 RPC：

**UsernameSearcher.cs**

```csharp
private struct SearchResult {
    public string username;
}
...
private async void SearchUsers(string text)
{
    ... // Do some error handling (see source for examples)

    // Construct a payload
    var searchRequest = new Dictionary<string, string>() { { "username", text } };

    // Convert the payload to JSON for delivery
    string payload = searchRequest.ToJson();
    //RPC method ID from server
    string rpcid = "search_username";

    Task<IApiRpc> searchTask; // Can also put this in a try/catch to handle errors

    //creating search task - sending request for running "search_username" method on server with text parameter
    searchTask = _connection.Client.RpcAsync(_connection.Session, rpcid, payload);

    //awaiting for server returning value
    IApiRpc searchResult = await searchTask;

    //unpacking results to SearchResult struct object
    SearchResult[] usernames = searchResult.Payload.FromJson<SearchResult[]>();

    ...
}
```

此处的关键字函数是 `RpcAsync`，其会获取 Nakama 客户端会话、RPC 标识符 `search_username`和有效载荷。由于这是一个异步网络请求，我们必须确保使用 `await searchTask` 异步处理请求，以便在输出到达时处理输出。

RPC 将数据库查询转换为 JSON 字符串并返回，因此我们需要将此信息转换为 C# 对象才能使用它。
 为此我们制作一个自定义 `SearchResult` `struct`，其中有字段 `username`，因为我们期望 JSON 处于这种格式。

## 添加和邀请好友

现在服务器返回了经过验证的用户名，可以用 `AddFriendsAsync` 函数添加它们：

**FriendsMenuUI.cs**

```csharp
public async void AddFriend()
{
    // This is from the UsernameSearcher we made earlier
    string[] usernames = new[] { _usernameSearcher.InputFieldValue };
    await _connection.Client.AddFriendsAsync(_connection.Session, new string[] { }, usernames);
    var friends = await _connection.Client.ListFriendsAsync(_connection.Session);
    // Do stuff with friends list (friends[i].State...)
    ...
}
...
```

在这里，我们可以使用 ID 或用户名添加好友列表。由于我们在本例中使用用户名，ID 列表为空。

添加好友后，然后我们更新 UI，用 `ListFriendsAsync` 显示新状态。此函数异步返回一个 `IApiFriend` 对象列表。每个 `IApiFriend` 都包含一个 `User` 属性和一个 `State` 属性，可用于显示每个好友的信息。

`User` 属性包含用户的所有常见信息（ID、用户名等）。

`State` 属性描述玩家与好友的关系：

- 状态 `0` 表示他们彼此是好友
- 状态 `1` 表示 A 发送了邀请，但 B 尚未接受
- 状态 `2` 与 `1` 相反：B 向 A 发送了邀请
- 状态 `3` 表示 A 阻止了 B

调用 `AddFriendsAsync` 会将玩家置于状态 `1`。另一玩家需要在其一端调用 `AddFriendsAsync`，将两种状态转变为 `0`。

最后，我们需要创建一个界面，让玩家查看他们的邀请，理想情况下按下“接受”按钮为他们调用 `AddFriendsAsync`。 

此操作代码与添加好友的代码非常相似，[FriendPanel.cs](https://github.com/heroiclabs/unity-sampleproject/blob/master/PiratePanic/Assets/PiratePanic/Scripts/Menus/Friends/FriendPanel.cs) 中有此代码可供参考。

## 阻止和取消阻止好友

玩家也可以决定阻止任何好友。此操作按以下方式处理：

**FriendsPanel.cs**

```csharp
private async void BlockOrUnblockThisFriend()
{
    //Checking if player status is blocked.
    if (!_blocked)
    {
      //if is not blocked, then block this friend
        string[] ids = new[] { _friend.User.Id };

        await _connection.Client.BlockFriendsAsync(_connection.Session, ids);
        OnDataChanged();
    }
    else
    {
        //unblock this friend
        await _connection.Client.DeleteFriendsAsync(_connection.Session, new[] { _friend.User.Id });
    }
}
```

此处的 `_friend` 是一个 `IApiFriend` 对象，在创建后被传送到 `FriendPanel` 类，`_blocked` 是每当此好友被阻止或解除阻止时翻转的布尔值。
重要的部分是 `BlockFriendsAsync` 函数，其取得一个 ID 列表并将所有好友状态设置为 `3`。

我们调用 `DeleteFriendsAsync` 来解除阻止的原因可能不直观。另外一个办法是将“好友状态”更改为邀请（`1` 或 `2`）或友谊（`0`）。我们不想这么做，因此彻底删除 Friend 对象。然后您就可以重新邀请好友了。

`DeleteFriendsAsync` 也可以同样用于删除未阻止好友。

{{< note "important" >}}
阻止和禁止某用户有区别。禁止在服务器端进行，彻底防止玩家加入游戏。进一步了解[禁止](../../../../concepts/friends/#ban-a-user)。
{{< / note >}}

## 与好友聊天

Nakama 为实现好友聊天提供方便。

在服务器内的聊天分为不同频道，每个都有一个特别的 ID。如果我们想要私密频道，我们传入我们想要与之聊天的玩家的 ID（因为这些 ID 是唯一的）。

要加入聊天频道，我们可以使用 `JoinChatAsync`：

**FriendsMenuUI.cs:**

```csharp
private async void StartChatWithUser(string userId, string username)
{
    IChannel chatChannel;

    ...
    chatChannel = await _connection.Socket.JoinChatAsync(userId, ChannelType.DirectMessage, persistence: true, hidden: true);
    ...

    _chatChannelUI.SetChatChannel(chatChannel);
    _chatChannelUI.gameObject.SetActive(true);
}
```

让我们检查 `JoinChatAsync` 函数的参数：

聊天频道有三种主要类型：

- `DirectMessage`：用于创建两个玩家之间的私密聊天室
- `Group`：用于支持有多个同一部落的人参与的私密聊天
- `Room`：创建任何人均可参与的动态公共聊天。每个人都退出聊天室后，频道将不再存在，直到有人重新加入。
  - [在此](../../../../concepts/chat/#join-chat)可进一步了解这三种频道的区别。

`Persistent` 表示消息会被存储到数据库中，以后某个日期可在消息历史列表中列出。`hidden` 成员不出现在成员列表中。

现在玩家已加入频道，他们可以发送聊天消息：

**ChatChannelUI.cs**

```csharp
var content = new Dictionary<string, string>(){{"content", _chatInputField.text}}.ToJson();
_connection.Socket.WriteChatMessageAsync(_chatChannel.Id, content);
```

此处，`_chatChannel` 是我们用上一函数中的 `JoinChatAsync` 创建的同一频道。我们传入的 `content` 的格式来自一个字典，其中密钥为一个具体的自定义字段（在本例中为 `content`），值为我们想要传送的信息。

为接收发送给群组的消息，我们将函数添加到 `ReceivedChannelMessage` 处理程序，形如：

```csharp
_connection.Socket.ReceivedChannelMessage += AddMessage;
```

此处 `AddMessage` 是我们想要用接收到的消息来做的操作。例如：

**ChatChannelUI.cs**

```csharp
private void AddMessage(IApiChannelMessage message)
{
    // Manage message prefabs, etc. (not shown in this snippet)
    ...

    // If the message is from the same user as newest we should hide his username on this message
    bool hideUsername = (message.Username == _lastMessageUsername) && !message.Persistent;

    // Initializing message with ID, username, message content, time of creation, and whether or not we should hide it
    messageUI.InitMessage(message.MessageId, message.Username,
    message.Content.FromJson<Dictionary<string, string>>()["content"], message.CreateTime, hideUsername);

    ...

    //If message is historical change order in hierarchy, the latest historical message is the oldest
    if (message.Persistent)
    {
        messageUI.transform.SetSiblingIndex(1);
    }
}
```

`MessagePrefab` 或 `messageUI` 等结构是本教程特有的，可根据需求进一步自定义。上述代码举例说明我们如何使用 `ReceivedChannelMessage` 处理程序传入的 `IApiChannelMessage` 对象。

可以看到 `message` 对象有多个属性：

- `SenderId`：发送此消息的客户端
- `Username`：发送它的玩家的名称
- `Persistent`：显示是否保存了消息的布尔值
- `CreateTime`：发送消息的时间
- `Content`：可转换成为其实际使用内容的 JSON 对象 `message.Content.FromJson<Dictionary<string, string>>()["content"]`

## 订阅频道

默认情况下，当客户端断开并重新连接到服务器时（例如，如果玩家退出游戏），他们将不会自动重新订阅任何聊天频道。

如果这是您在游戏中想要的功能（例如，您希望玩家在收到消息时收到通知），您需要确保客户端在开始时重新加入聊天频道。

订阅所有好友频道：

**Scene01MainMenuController.cs**

```csharp
private async void Start() {
    var friends = await _connection.Client.ListFriendsAsync(_connection.Session);

    foreach(IApiFriend friend in friends) {
        var userId = friend.User.Id;
        await _connection.Socket.JoinChatAsync(userId, ChannelType.DirectMessage, persistence: true, hidden: true);
    }
  ...
}
```

此代码在应用程序启动时循环浏览好友列表中的每个好友，并加入每个好友的直接消息频道，以便自动接收任何好友发送的任何聊天。

## 下一主题

[部落](../clans/)