客户端中继多人游戏 #

实时多人游戏,又称客户端中继多人游戏,有利于比赛中的玩家相互迅速传送和接收数据。客户端连接到比赛时,可将消息发送到服务器。Nakama 的实时多人游戏引擎会自动将这些消息发送给比赛中的其他所有人。收到消息后,每个客户端就可以处理它们并提示执行操作、更新或对游戏状态的其他更改。

Nakama 还支持服务器权威多人游戏,在这种游戏中服务器是每场比赛的中心。所有客户端都直接向服务器发送数据和从其接收数据,然后服务器处理数据并将其路由到其他客户端(如有必要)。服务器权威多人游戏在处理数据方面提供了更大的灵活性,但其开发和维护工作可能更复杂。

对于 Pirate Panic,我们将使用实时多人游戏。

加入比赛 #

提交门票后,服务器将处理匹配并为所有匹配的玩家分配新比赛(如果可以找到)。

然后,我们可以在客户端注册一个回调函数,以便在发生这种情况时运行,该函数可用于切换场景或者准备游戏以加入比赛。

为此我们使用 ReceivedMatchmakerMatched,这是一个 Register 挂钩,在服务器找到对手时自动触发。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
_connection.Socket.ReceivedMatchmakerMatched += OnMatchmakerMatched;
...
private void OnMatchmakerMatched(IMatchmakerMatched matched)
{
    ...
    _connection.Socket.ReceivedMatchmakerMatched -= OnMatchmakerMatched; // Unregister callback function

    _connection.BattleConnection = new BattleConnection(matched); // Save Matched object to BattleConnection for later use

    SceneManager.LoadScene(GameConfigurationManager.Instance.GameConfiguration.SceneNameBattle); // Switch scene to battle scene
}

我们还需要保存 IMatchmakerMatched 对象,因为在加载战斗场景后,需要它才能加入比赛。在 PiratePanic 中,我们创建了 BattleConnection 对象,以存储此信息,这样我们可以从多个类访问它。

BattleConnection.cs

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
public class BattleConnection
{
    public string MatchId { get; set; }
    public string HostId { get; set; }
    public string OpponentId { get; set; }
    public IMatchmakerMatched Matched { get; set; }
    // More properties can be stored here!

    public BattleConnection(IMatchmakerMatched matched)
    {
        Matched = matched;
    }
}

一旦我们加载到战斗场景中,我们需要在开始时通过传入匹配程序令牌来调用 JoinMatchAsync。匹配程序令牌是匹配程序提供的身份和预订令牌。

这样服务器能够将此客户端添加到正确的比赛中,并确保发送给此比赛中其他玩家和来自其他玩家的所有消息也将发送到此客户端:

1
2
3
4
5
protected async void Start() {
    IMatch match = await _connection.Socket.JoinMatchAsync(_connection.BattleConnection.Matched);
    _connection.BattleConnection.MatchId = match.Id;
    ...
}

玩家可以通过调用 _connection.Socket.LeaveMatchAsync(matchId) 退出比赛,其中 matchIdJoinMatchAsync 返回的 ID。

发送实时数据 #

既然有玩家加入了比赛,让我们向其他玩家发送一些信息。这通过 socket.SendMatchStateAsync() 调用来完成。

例如,当一个玩家在游戏中施放法术时,其他玩家应该收到信息(例如法术类型、施放地点等),以便他们的游戏客户端可以渲染法术。

MatchMessageSpellActivated.cs

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
using Nakama.TinyJson;
...
public class MatchMessageSpellActivated {
    public readonly string OwnerId;
    ... // More message data goes here

    public MatchMessageSpellActivated(string ownerId, ...) {
        OwnerId = ownerId;
        ...
    }
}

然后,在别处的函数内:

1
2
3
4
long opCode = 5; // custom opcode for spells
MatchMessageSpellActivated message = new MatchMessageSpellActivated(playerId, ...);
string json = JsonWriter.ToJson(message); // Converts C# object to JSON object
_connection.Socket.SendMatchStateAsync(_connection.BattleConnection.MatchId, opCode, json);

此处我们创建一个名叫 MatchMessageSpellActivated 的新对象,其中保留必要信息。我们需要确保对对象进行标准化,以便它可以在另一端轻松转换,在本例中为 JSON。创建自定义类并将其转换为 JSON 是发送任何类型数据的常见模式。您可以向类中添加不同的属性以发送您想要的任何内容。

opCode 可让接收方客户端根据其类别以不同方式处理消息,而无需调查有效负载本身的结构。操作码可以是任何正整数,Nakama 未预定义什么固有含义,因此您可以创建自己的方案。

接收实时数据 #

现在我们已经向比赛发送了一条消息,比赛中的其他玩家需要能够接收到它。为了做到这一点,Nakama 有一个 Register(寄存器)挂钩 ReceivedMatchState,每当客户端收到任何消息时,它都会触发。

对于我们的施放法术示例,我们需要接收该消息以在客户端上显示法术,并处理它造成的任何伤害:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
void Start() {
    _connection.Socket.ReceivedMatchState += ReceiveMatchStateMessage; // Bind function to hook
}
...
private void ReceiveMatchStateMessage(IMatchState matchState) {
    string messageJson = System.Text.Encoding.UTF8.GetString(matchState.State);

    if (matchState.opCode == 5) {
        MatchMessageSpellActivated spell = JsonParser.FromJson<MatchMessageSpellActivated>(messageJson);
        // Do stuff with the spell (instantiate object, destroy towers, etc.)
    }
    ...
    // Handle more opcodes, or alternatively create a switch statement
}

此处我们创建自定义函数 ReceiveMatchStateMessage,并将其绑定到 ReceivedMatchState,因此在每次接收到消息时都调用它。消息存储在 matchState.State 中,因为我们使用操作码 5 来指定消息是 MatchMessageSpellActivated 类型,我们可以将 JSON 对象转换回 C#,以读取它的属性。

ReceiveMatchStateMessage 函数可用于处理其各自的 opcodes 指定的不同消息类型。

比赛状态 #

比赛中的每个玩家都有在比赛中的 presence。Nakama 在内部将该状态信息作为比赛名册进行跟踪。每当玩家加入或退出比赛时,都会向客户端发送更新的增量,以便客户端可以更新自己的玩家列表视图或执行游戏特有的操作。

当客户端刚加入比赛时,Nakama 将自动向他们提供当前加入的对手的列表。此列表可通过 match.Presences 对象访问。注意,matchJoinMatchAsync 返回的 IMatch 对象。

此外,我们可以使用 ReceivedMatchPresence 挂钩,在每次有玩家加入或退出时运行函数。加入列表可以 match.Joins 访问,退出列表可以 match.Leaves 访问。

例如,如果一名玩家退出游戏,但仍有人在,下面的代码会打印一条消息:

1
2
3
4
5
6
7
8
9
void Start() {
    _connection.Socket.ReceivedMatchPresence += OnMatchPresence;
}
...
private void OnMatchPresence(IMatchPresenceEvent e) {
    if (e.Leaves.Count() > 0) {
        Debug.LogWarning($"OnMatchPresence() User(s) left the game");
    }
}

我们还可以使用状态信息来指定一名主机玩家来负责处理只应执行一次的事件。例如,在游戏开始时,我们可能希望将初始牌型分配给每个玩家。

可以通过将第一个加入比赛的玩家指定为主机来分配主机。我们做到这一步的方法可以是检查游戏中的状态数是否为 0(状态数并不包含当前客户端):

1
2
3
4
5
6
7
8
9
if (match.Presences.Count() == 0) {
    _connection.BattleConnection.HostId = _connection.Session.UserId;
    ...
} else {
    string opponentId = match.Presences.First().UserId;
    _connection.BattleConnection.OpponentId = opponentId;
    _connection.BattleConnection.HostId = opponentId;
    ...
}

然后,如果我们想执行仅限主机的操作,我们可以检查主机 ID 是否等于当前用户的 ID:

1
2
3
if (_connection.BattleConnection.HostId == _connection.Session.UserId) {
    // Handle host-only behaviors
}

使用 RPC #

您可能希望将敏感计算卸载到服务器,而不是委托主机执行这些计算。例如,比赛结束后,我们想用宝石奖励获胜者。

在 Pirate Panic 中,一旦玩家的主堡垒被摧毁,我们希望向服务器发送消息以结束比赛:

1
2
3
4
private async void OnAfterMainFortDestroyed() {
    ...
    response = await _connection.Client.RpcAsync(_connection.Session, "handle_match_end", matchEndRequest.ToJson());
}

此处 matchEndRequest 为包含要发送到服务器的信息的任意对象。我们需要将其转换为 JSON 以通过 RPC 发送,以便在服务器端将其解码为 Typescript:

1
2
3
4
const rpcHandleMatchEnd: nkruntime.RpcFunction = function(ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payload: string): string {
    let request : MatchEndRequest = JSON.parse(payload);
    ...
}

请参阅 排行榜 部分中这类 RPC 的行为示例。

下一主题 #

排行榜

Related Pages