好友 #

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

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

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

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

在 Pirate Panic,其形如:

好友面板
好友面板

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

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

查找好友 #

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

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

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

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

在服务器上注册 RPC #

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

main.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
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 对象输出回客户端。上下文对象包含许多实用的属性,让我们知道此调用源自何处。Nakama 实例可让我们与 Nakama 运行时 API 联系, 有效载荷是一个 JSON 对象,包含客户端发送的任何自定义信息。

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

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

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

1
initializer.registerRpc('search_username', rpcSearchUsernameFn);

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

在客户端上调用 RPC #

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

UsernameSearcher.cs

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
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 尚未接受
  • 状态 21 相反:B 向 A 发送了邀请
  • 状态 3 表示 A 阻止了 B

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

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

此操作代码与添加好友的代码非常相似,FriendPanel.cs 中有此代码可供参考。

阻止和取消阻止好友 #

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

FriendsPanel.cs

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
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 来解除阻止的原因可能不直观。另外一个办法是将“好友状态”更改为邀请(12)或友谊(0)。我们不想这么做,因此彻底删除 Friend 对象。然后您就可以重新邀请好友了。

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

阻止和禁止某用户有区别。禁止在服务器端进行,彻底防止玩家加入游戏。进一步了解禁止

与好友聊天 #

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

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

要加入聊天频道,我们可以使用 JoinChatAsync

FriendsMenuUI.cs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
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:创建任何人均可参与的动态公共聊天。每个人都退出聊天室后,频道将不再存在,直到有人重新加入。
    • 在此可进一步了解这三种频道的区别。

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

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

ChatChannelUI.cs

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

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

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

1
_connection.Socket.ReceivedChannelMessage += AddMessage;

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

ChatChannelUI.cs

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
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);
    }
}

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

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

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

订阅频道 #

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

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

订阅所有好友频道:

Scene01MainMenuController.cs

1
2
3
4
5
6
7
8
9
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);
    }
  ...
}

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

下一主题 #

部落

Related Pages