Unity Fish Game 教程 #

Fish Game 是一个使用 Unity 游戏引擎开发的开源游戏项目。此为一个灵感来自 Duck Game 的快节奏 2D 竞技射击游戏,供多至 4 个玩家玩。

此游戏和教程集中体现了 Nakama 强大的身份验证匹配实时多人游戏功能。

查看本教程附带的博客文章视频系列,了解更多内容。

前提条件 #

为轻松学习本教程,请执行以下操作后再继续:

项目层次结构 #

在 Unity 中打开 Fish Game 项目,并参阅项目层次结构:

Fish Game 项目层次结构
Fish Game 项目层次结构

以下游戏对象已列出:

  • GameManager:控制比赛的所有具体游戏逻辑,包括处理匹配。
  • AudioManager:处理游戏音频。
  • UI
    • AudioMenu:一个简单的静音开关。
    • MainMenu:此菜单可让玩家查找比赛、取消匹配请求或查看制作人员表。
    • InGameMenu:玩家可以通过按 ESC 退出比赛来在游戏中调用的菜单。
    • Canvas:UI 画布,只包含获胜玩家通知标签。
  • DefaultMap:包括生成点和平铺图的游戏级别。
  • Main Camera:游戏 2D 相机和背景精灵。
  • EventSystem:处理 UI 互动的默认 Unity 事件系统。

资产层次结构 #

Fish Game 资产层次结构
Fish Game 资产层次结构

Nakama 功能的相关文件夹:

  • 实体 > 玩家:包含总共三个预制玩家,以及与之相关的所有 MonoBehaviour 组件。
  • 管理器:包含负责处理全局游戏元素(例如 GameManager)的组件和各种菜单脚本。
  • Nakama:包含 NakamaConnection 类(这是 ScriptableObject)和 LocalNakamaConnection 资产。
  • 武器:包含负责处理武器和射弹的组件。

连接到 Nakama #

从上方的资产层次结构可以看到我们的 Fish Game 项目已经包含 NakamaConnection 类和 LocalNakamaConnection 资产。 NakamaConnection 从 Unity 的 ScriptableObject 类继承,意味着我们可以在项目中创建 NakamaConnection 类的实例,并将其保存为资产,可将其作为游戏中其他脚本的依赖项传递。

为了解如何连接到 Nakama 服务器,让我们打开 NakamaConnection.cs 文件。在声明 NakamaConnection 类后,我们定义了一些公用变量,因此我们能够指明该连接对象应当连接到哪个 Nakama 服务器:

NakamaConnection.cs

1
2
3
4
public string Scheme = "http";
public string Host = "localhost";
public int Port = 7350;
public string ServerKey = "defaultkey";

然后我们讨论 Connect 函数如何工作。我们首先创建 Nakama.Client 对象实例,并传入我们的连接值:

NakamaConnection.cs

1
Client = new Nakama.Client(Scheme, Host, Port, ServerKey, UnityWebRequestAdapter.Instance);

然后用 Client 对象来与 Nakama 交互。

接下来,我们尝试恢复一个现有的用户会话,例如,如果用户之前启动了游戏并连接到了 Nakama 服务器,如果该会话尚未过期,我们希望恢复该会话:

NakamaConnection.cs

1
2
3
4
5
6
7
8
9
var authToken = PlayerPrefs.GetString(SessionPrefName);
if (!string.IsNullOrEmpty(authToken))
{
    var session = Nakama.Session.Restore(authToken);
    if (!session.IsExpired)
    {
        Session = session;
    }
}

在此 我们尝试从 PlayerPrefs 获取身份验证令牌。如找到,我们调用 Nakama.Session.Restore 函数,并传入身份验证令牌,以检索 Session 对象。我们还执行检查,确保会话还没有过期。如果会话未过期,则将此会话对象分配给私密 Session 变量。否则,我们需要重新进行身份验证,创建全新的会话。

这在 if 语句内发生,此语句检查 Session 变量是否为空:

NakamaConnection.cs

1
2
3
4
5
6
// If we weren't able to restore an existing session, authenticate to create a new user session.
if (Session == null)
{
    string deviceId;

// ...

这意味着我们有两条可能的路径,要么用户以前玩过游戏,我们已经存储了他们设备的唯一标识符;要么这是他们第一次玩游戏,我们需要获取他们设备的唯一标识符,以便与 Nakama 服务器进行身份验证。

对于玩过游戏的玩家,我们检查 PlayerPrefs 中是否有 DeviceIdentifierPrefName 键的值,如果有,则抓取此值,将其存储在 deviceId 变量中,以便以后在函数中使用:

NakamaConnection.cs

1
2
3
4
5
// If we've already stored a device identifier in PlayerPrefs then use that.
if (PlayerPrefs.HasKey(DeviceIdentifierPrefName))
{
    deviceId = PlayerPrefs.GetString(DeviceIdentifierPrefName);
}

对于新玩家,我们尝试从 SystemInfo.deviceUniqueIdentifier 抓取其设备的唯一标识符。如果此操作未返回一个合适的标识符值,则将返回 SystemInfo.unsupportedIdentifier 的值。

我们对此进行检查,如果我们接收到此值,则用 Guid 生成我们自己的唯一标识符,将其存储到 PlayerPrefs 中,供以后使用:

NakamaConnection.cs

1
2
3
4
5
6
7
if (deviceId == SystemInfo.unsupportedIdentifier)
{
    deviceId = System.Guid.NewGuid().ToString();
}

// Store the device identifier to ensure we use the same one each time from now on.
PlayerPrefs.SetString(DeviceIdentifierPrefName, deviceId);

我们现在与 Nakama 服务器进行身份验证,将身份验证令牌存储到 PlayerPrefs 中:

NakamaConnection.cs

1
2
3
4
5
// Use Nakama Device authentication to create a new session using the device identifier.
Session = await Client.AuthenticateDeviceAsync(deviceId);

// Store the auth token that comes back so that we can restore the session later if necessary.
PlayerPrefs.SetString(SessionPrefName, Session.AuthToken);

现在我们有一个有效的 Nakama 会话,我们继续并打开 Socket

NakamaConnection.cs

1
2
3
// Open a new Socket for real-time communication.
Socket = Client.NewSocket(true);
await Socket.ConnectAsync(Session, true);

这样我们就可以开始与 Nakama 服务器以及其他连接到我们游戏的玩家进行通信。在 Fish Game 中调用此连接是通过 GameManager 对象完成:

Fish Game GameManager 对象
Fish Game GameManager 对象

创建连接 #

使用 CreateAssetMenu 属性和定义连接的公用变量,我们就能在 Unity Inspector 中修改每个 NakamaConnection 资产。

这简化了在需要时(例如您有本地开发和生产服务器时)连接到 Nakama 服务器的方法。

为创建新的 Nakama 连接资产:

  1. 资产菜单,选择创建 > Nakama 连接

  2. Inspector(检查器) 窗格中,编辑连接属性:

    • 方案:连接方案,httphttps
    • 主机:服务器的主机模型或 IP。
    • 端口:使用的端口,默认为 7350
    • 服务器密钥:连接到服务器的密钥,默认为 defaultkey

    本地 Nakama 连接
    本地 Nakama 连接

对于生产资产,确保将 NakamaConnection 资产添加到 .gitignore 文件,因此您不向源控制提交它们,不暴露生产服务器密钥。例如: # Heroic Cloud Nakama Connection asset HeroicCloudNakamaConnection.asset HeroicCloudNakamaConnection.asset.meta

匹配 #

为回顾在 Fish Game 中匹配如何工作,我们深入探讨 GameManagerNakamaConnectionMainMenu 类,并讨论相关函数。参阅匹配程序文档,详细了解 Nakama 中的匹配。

您在此创建新的 Dictionary 来存储连接的玩家并订阅以下事件:

  • ReceivedMatchmakerMatched:让您能在 Nakama 找到匹配时做出反应。
  • ReceivedMatchPresence:让您能在玩家加入或退出比赛时做出反应。
  • ReceivedMatchState:让您能在从 Nakama 服务器接收到消息时做出反应。

GameManager.cs

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
/// <summary>
/// Called by Unity when the GameObject starts.
/// </summary>
private async void Start()
{
// Create an empty dictionary to hold references to the currently connected players.
players = new Dictionary<string, GameObject>();

// ...

// Setup network event handlers.
NakamaConnection.Socket.ReceivedMatchmakerMatched += OnReceivedMatchmakerMatched;
NakamaConnection.Socket.ReceivedMatchPresence += OnReceivedMatchPresence;
NakamaConnection.Socket.ReceivedMatchState += async m => await OnReceivedMatchState(m);

// ...
}

MainMenu 类,可看到匹配请求是如何通过 FindMatch 函数触发的。 在此您通过 GameManager 上的参考,调用 NakamaConnection 类上的 FindMatch 函数,并将玩家数(通过下拉选择)传送到比赛,此比赛具有:

MainMenu.cs

1
2
3
4
5
6
7
8
9
/// <summary>
/// Begins the matchmaking process.
/// </summary>
public async void FindMatch()
{
// ...

await gameManager.NakamaConnection.FindMatch(int.Parse(PlayersDropdown.options[PlayersDropdown.value].text));
}

NakamaConnection 类的 FindMatch 函数中,对 Dictionary 声明单一 key,value 对,指明我们使用的是 Unity 引擎;并声明一个函数,根据此过滤器和任何其他指定参数,将此请求添加到匹配池:

NakamaConnection.cs

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
/// <summary>
/// Starts looking for a match with a given number of minimum players.
/// </summary>
public async Task FindMatch(int minPlayers = 2)
{
// Set some matchmaking properties to ensure we only look for games that are using the Unity client.
// This is not a required when using the Unity Nakama SDK,
// however in this instance we are using it to differentiate different matchmaking requests across multiple platforms using the same Nakama server.
var matchmakingProperties = new Dictionary<string, string>
{
    { "engine", "unity" }
};

// Add this client to the matchmaking pool and get a ticket.
var matchmakerTicket = await Socket.AddMatchmakerAsync("+properties.engine:unity", minPlayers, minPlayers, matchmakingProperties);
currentMatchmakingTicket = matchmakerTicket.Ticket;
}

Nakama 然后将响应,给予您 MatchmakingTicket。正是此门票让您加入 Nakama 找到的比赛,或取消现有比赛请求:

NakamaConnection.cs

1
2
3
4
5
6
7
/// <summary>
/// Cancels the current matchmaking request.
/// </summary>
public async Task CancelMatchmaking()
{
    await Socket.RemoveMatchmakerAsync(currentMatchmakingTicket);
}

现在,您已被添加到配对池中,Nakama 将搜索匹配项,并在找到匹配项时通过 ReceivedMatchmakerMatched 事件侦听器通知您:

GameManager.cs

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/// <summary>
/// Called when a MatchmakerMatched event is received from the Nakama server.
/// </summary>
/// <param name="matched">The MatchmakerMatched data.</param>
private async void OnReceivedMatchmakerMatched(IMatchmakerMatched matched)
{
    // Cache a reference to the local user.
    localUser = matched.Self.Presence;

    // Join the match.
    var match = await NakamaConnection.Socket.JoinMatchAsync(matched);

    // ...

    // Spawn a player instance for each connected user.
    foreach (var user in match.Presences)
    {
        SpawnPlayer(match.Id, user);
    }

    // Cache a reference to the current match.
    currentMatch = match;
}

存储的对 Self.Presence 值的引用让您可以快速方便地访问本地玩家的 Nakama 状态,对于获取他们的会话 ID、用户 ID 和用户名等信息非常实用。

存储的对 JoinMatchAsync() 返回值的引用能让您访问当前匹配 ID 和连接的用户等信息

接下来,我们通过 match.Presences 属性遍历所有连接的用户,并为每个用户生成一个玩家

配对最后要考虑的是当用户连接和断开连接时该做什么。这通过 OnReceivedMatchPresence 事件处理程序完成:

GameManager.cs

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
/// <summary>
/// Called when a player/s joins or leaves the match.
/// </summary>
/// <param name="matchPresenceEvent">The MatchPresenceEvent data.</param>
private void OnReceivedMatchPresence(IMatchPresenceEvent matchPresenceEvent)
{
    // For each new user that joins, spawn a player for them.
    foreach (var user in matchPresenceEvent.Joins)
    {
        SpawnPlayer(matchPresenceEvent.MatchId, user);
    }

    // For each player that leaves, despawn their player.
    foreach (var user in matchPresenceEvent.Leaves)
    {
        if (players.ContainsKey(user.SessionId))
        {
            Destroy(players[user.SessionId]);
            players.Remove(user.SessionId);
        }
    }
}

IMatchPresenceEvent 在此给出两个列表:加入比赛的用户的列表和退出比赛的用户的列表。

对于加入的用户,我们就像在 OnReceivedMatchmakerMatched 事件处理程序中的操作一样,为他们生成玩家对象。

对于退出的玩家,我们根据他们的会话 ID,在 players 字典中检查我们是否当前引用了该用户。如有,我们首先销毁该玩家的 GameObject,然后从我们的玩家 Dictionary 中删除他们。

生成玩家 #

在此我们响应匹配或用户状态事件,回顾 GameManager 调用的 SpawnPlayer 函数。

首先,我们执行检查,根据玩家的会话 ID,看看我们是否在玩家字典中引用了该用户。如果是,我们会尽早返回,因为我们已经生成了这个玩家:

GameManager.cs

1
2
3
4
5
// If the player has already been spawned, return early.
if (players.ContainsKey(user.SessionId))
{
    return;
}

接下来我们确定在何处生成此玩家。如果未传递 spawnIndex (spawnIndex == -1),则我们为此玩家选择一个随机生成点。如已传递生成索引,则我们将使用此索引:

GameManager.cs

1
2
3
4
// If the spawnIndex is -1 then pick a spawn point at random, otherwise spawn the player at the specified spawn point.
var spawnPoint = spawnIndex == -1 ?
    SpawnPoints.transform.GetChild(Random.Range(0, SpawnPoints.transform.childCount - 1)) :
    SpawnPoints.transform.GetChild(spawnIndex);

接下来我们查看此玩家是本地玩家还是远程玩家。基于此,我们决定生成哪个预制玩家,然后在游戏中的相关生成点实例化预制玩家的实例:

GameManager.cs

1
2
3
4
5
6
7
8
// Set a variable to check if the player is the local player or not based on session ID.
var isLocal = user.SessionId == localUser.SessionId;

// Choose the appropriate player prefab based on if it's the local player or not.
var playerPrefab = isLocal ? NetworkLocalPlayerPrefab : NetworkRemotePlayerPrefab;

// Spawn the new player.
var player = Instantiate(playerPrefab, spawnPoint.transform.position, Quaternion.identity);

接下来查看玩家是否远程连接,如是,则抓取对 PlayerNetworkRemoteSync 组件的引用,设置合适的网络数据。我们将当前比赛 ID 传递给它,并引用此用户的 IUserPresence 对象:

GameManager.cs

1
2
3
4
5
6
7
8
9
// Setup the appropriate network data values if this is a remote player.
if (!isLocal)
{
    player.GetComponent<PlayerNetworkRemoteSync>().NetworkData = new RemotePlayerNetworkData
    {
        MatchId = matchId,
        User = user
    };
}

这用于在接收到数据时,核实此数据是否预定传送给该具体玩家。

然后我们将玩家会话 ID 用作密钥,将其添加到 players 字典,并将玩家 GameObject 作为此值传入:

GameManager.cs

1
2
// Add the player to the players array.
players.Add(user.SessionId, player);

接下来检查此玩家是否在本地,如是,则设置其 PlayerDied 事件的事件侦听器:

GameManager.cs

1
2
3
4
5
6
// If this is our local player, add a listener for the PlayerDied event.
if (isLocal)
{
    localPlayer = player;
    player.GetComponent<PlayerHealthController>().PlayerDied.AddListener(OnLocalPlayerDied);
}

预制玩家 #

然后在实体 > 玩家文件夹中找到 Fish Game 的两个预制玩家:NetworkLocalPlayerNetworkRemotePlayer

NetworkLocalPlayer #

Fish Game NetworkLocalPlayer 对象
Fish Game NetworkLocalPlayer 对象

这是一个包裹了网络不可知预制玩家的简单包装,它有处理移动、物理、武器和颜色的组件。此预制玩家的 GameObject 根有一些值得注意的组件:

  • PlayerNetworkLocalSync:在特定的间隔向整个网络发送状态更新。
  • PlayerInputController:处理玩家输入,并将其传送给底层移动控制器。
  • PlayerHealthController 处理玩家健康状况并负责触发 PlayerDied 事件。
  • PlayerCameraController 一个简单组件,确保相机跟随此玩家。

NetworkRemotePlayer #

Fish Game NetworkRemotePlayer 对象
Fish Game NetworkRemotePlayer 对象

也是一个包裹预置玩家的包装器,这里我们在根上只有一个组件: PlayerNetworkRemoteSync

该组件从网络接收状态更新,并处理其当前状态到新的正确状态的平滑插值。例如,如果此对象在位置 { 100, 100 },而我们接收到的消息表明正确的位置应为 { 120, 100 },此组件将在 LerpTime 秒内平滑纠正此对象位置。

发送玩家位置 #

为回顾如何从 NetworkLocalPlayer 向全网络发送位置和输入更新,请查看 Entities > Player > PlayerNetworkLocalSync.cs

首先,我们有一个公用变量,确定发送网络状态同步更新的频率:

PlayerNetworkLocalSync.cs

1
2
3
4
/// <summary>
/// How often to send the player's velocity and position across the network, in seconds.
/// </summary>
public float StateFrequency = 0.1f;

更改此处的值将增加或减少向整个网络发送状态更新的频率。此时很好地达成平衡是创建一个流畅的多人游戏的关键部分。

这是 0.1f 的默认值,或每秒 10 次。在我们的 Fish Game 示例中,让我们将此设置为 0.05f,将我们的速率增加一倍至每秒 20 次:

1
public float StateFrequency = 0.1f;

更快的速率将更频繁地发送更新,这将为其他玩家带来更流畅的体验,而较低的更新发送频率将导致玩家位置在其他连接的客户端上变得不同步,并且插值/校正会更加不一致。

接下来我们来关注所用的 LateUpdate 函数,之所以使用是因为我们仅想在更新玩家位置后才发送更新。首先是检查状态同步计时器是否已过,如果已过,则需要发送新的更新:

PlayerNetworkLocalSync.cs

1
2
3
4
// Send a network packet containing the player's velocity and position.
gameManager.SendMatchState(
    OpCodes.VelocityAndPosition,
    MatchDataJson.VelocityAndPosition(playerRigidbody.velocity, playerTransform.position));

接下来,我们检查本地玩家的输入自上次运行此函数以来是否发生了变化,如果没有,则提前返回:

PlayerNetworkLocalSync.cs

1
2
3
4
5
// If the players input hasn't changed, return early.
if (!playerInputController.InputChanged)
{
    return;
}

最后,我们向整个网络发送玩家的当前输入:

PlayerNetworkLocalSync.cs

1
2
3
4
5
// Send network packet with the player's current input.
gameManager.SendMatchState(
    OpCodes.Input,
    MatchDataJson.Input(playerInputController.HorizontalInput, playerInputController.Jump, playerInputController.JumpHeld, playerInputController.Attack)
);

操作代码 #

在上方函数中使用的操作代码 (OpCodes) 是游戏中发生的特定网络操作的数值表示。

查看 Entities > Player> OpCodes.cs,可以查看 Fish Game 中的所有 OpCodes。这些包括:

  • VelocityAndPosition:参见上方。发送包含玩家速度和位置的网络消息。
  • Input:参见上方。指示玩家的输入自上次更新以来已更改,并使用当前输入发送消息。
  • Died:指示玩家死亡,之后其 GameObject(游戏对象)被销毁,从 players 字典中删除他们。
  • Respawned:向网络发送消息,通知其他玩家您在哪里生成。
  • NewRound:向网络发送消息,通知其他玩家您赢了,并启动新一轮。

接收玩家位置 #

为回顾如何从 NetworkRemotePlayer 从全网络接收位置和输入更新,请查看 Entities > Player > PlayerNetworkRemoteSync.cs

首先,我们有两个公共变量:一个存储对当前比赛 ID 和玩家的 IUserPresence 对象的引用,另一个确定我们插入玩家位置的速度:

PlayerNetworkRemoteSync.cs

1
2
3
4
5
6
public RemotePlayerNetworkData NetworkData;

/// <summary>
/// The speed (in seconds) in which to smoothly interpolate to the player's actual position when receiving corrected data.
/// </summary>
public float LerpTime = 0.05f;

Start 函数内,我们设置一个我们从网络接收数据的侦听器:

PlayerNetworkRemoteSync.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
30
31
32
33
// Add an event listener to handle incoming match state data.
gameManager.NakamaConnection.Socket.ReceivedMatchState += OnReceivedMatchState;

// ...

/// <summary>
/// Called when receiving match data from the Nakama server.
/// </summary>
/// <param name="matchState">The incoming match state data.</param>
private void OnReceivedMatchState(IMatchState matchState)
{
    // If the incoming data is not related to this remote player, ignore it and return early.
    if (matchState.UserPresence.SessionId != NetworkData.User.SessionId)
    {
        return;
    }

    // Decide what to do based on the Operation Code of the incoming state data as defined in OpCodes.
    switch (matchState.OpCode)
    {
        case OpCodes.VelocityAndPosition:
            UpdateVelocityAndPositionFromState(matchState.State);
            break;
        case OpCodes.Input:
            SetInputFromState(matchState.State);
            break;
        case OpCodes.Died:
            playerMovementController.PlayDeathAnimation();
            break;
        default:
            break;
    }
}

这里,我们首先通过将此对象的 NetworkData.User.SessionIdmatchState.UserPresence.SessionId 值进行比较,确定我们接收的消息是否针对此对象:

  • 如果它们不匹配,我们不需要处理此消息并尽早从该函数返回。
  • 如果它们匹配,我们执行 matchState.OpCode 切换。我们依据 OpCode 将状态传送到要处理的三个函数之一,用于:

接收位置和速度 #

对此,我们查看 UpdateVelocityAndPositionFromState 函数:

PlayerNetworkRemoteSync.cs

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
/// <summary>
/// Updates the player's velocity and position based on incoming state data.
/// </summary>
/// <param name="state">The incoming state byte array.</param>
private void UpdateVelocityAndPositionFromState(byte[] state)
{
    var stateDictionary = GetStateAsDictionary(state);

    playerRigidbody.velocity = new Vector2(float.Parse(stateDictionary["velocity.x"]), float.Parse(stateDictionary["velocity.y"]));

    var position = new Vector3(
        float.Parse(stateDictionary["position.x"]),
        float.Parse(stateDictionary["position.y"]),
        0);

    // Begin lerping to the corrected position.
    lerpFromPosition = playerTransform.position;
    lerpToPosition = position;
    lerpTimer = 0;
    lerpPosition = true;
}

在此,我们使用辅助函数 GetStateAsDictionary,以 Dictionary<string, string> 的格式抓取状态,然后用传入的 velocity.xvelocity.y 值更新玩家的刚体速度特性。

接下来我们用传入的 position.xposition.y 值创建新的 Vector3。然后我们将玩家当前位置设置为 lerpFromPosition,将新位置设置为 lerpToPosition,开始插值过程。我们设置 lerp 计时器,将 lerpPosition 值设置为 true,这将确保驻留在 LateUpdate 中的插入代码将运行。

接收输入 #

对此,我们查看 SetInputFromState 函数:

PlayerNetworkRemoteSync.cs

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
/// <summary>
/// Sets the appropriate input values on the PlayerMovementController and PlayerWeaponController based on incoming state data.
/// </summary>
/// <param name="state">The incoming state Dictionary.</param>
private void SetInputFromState(byte[] state)
{
    var stateDictionary = GetStateAsDictionary(state);

    playerMovementController.SetHorizontalMovement(float.Parse(stateDictionary["horizontalInput"]));
    playerMovementController.SetJump(bool.Parse(stateDictionary["jump"]));
    playerMovementController.SetJumpHeld(bool.Parse(stateDictionary["jumpHeld"]));

    if (bool.Parse(stateDictionary["attack"]))
    {
        playerWeaponController.Attack();
    }
}

我们再次以 Dictionary<string, string> 格式抓取状态,然后从传入的状态数据设置合适的 PlayerMovementController 值。如果 stateDictionary["attack"] 值是 true,我们也调用 Attack 函数,更新 PlayerWeaponController

此处的最终考虑是 OnDestroy 函数。不需要时删除事件侦听器始终是一个好办法,例如销毁此对象时,此处的 OnReceivedMatchState 处理程序。

PlayerNetworkRemoteSync.cs

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
/// <summary>
/// Called when this GameObject is being destroyed.
/// </summary>
private void OnDestroy()
{
    if (gameManager != null)
    {
        gameManager.NakamaConnection.Socket.ReceivedMatchState -= OnReceivedMatchState;
    }
}

在玩家死亡时 #

为回顾我们如何处理玩家死亡,请查看 Entities > Player > PlayerHealthController。声明了 PlayerDied 事件,当玩家的生命值小于 1 时,在 TakeDamage 函数中将其调用:

PlayerHealthController.cs

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
public PlayerDiedEvent PlayerDied;

// ...

/// <summary>
/// Reduces the players health by damage, triggers the PlayerDied event if health is 0 or below.
/// </summary>
/// <param name="damage">The amount of damage the player should receive.</param>
public void TakeDamage(int damage = 1)
{
    // ...

    // If health falls to 0 or below, disable player input controls, play the death animation and fire the PlayerDied event.
    if (health <= 0)
    {
        // ...
        PlayerDied.Invoke(gameObject);
    }
}

本地玩家的死亡通过 GameManager 类中的 OnLocalPlayerDied 函数处理。

GameManager.cs

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
/// <summary>
/// Called when the local player dies.
/// </summary>
/// <param name="player">The local player.</param>
private async void OnLocalPlayerDied(GameObject player)
{
    // Send a network message telling everyone that we died.
    await SendMatchStateAsync(OpCodes.Died, MatchDataJson.Died(player.transform.position));

    // Remove ourself from the players array and destroy our GameObject after 0.5 seconds.
    players.Remove(localUser.SessionId);
    Destroy(player, 0.5f);
}

在这里,我们向网络发送消息,让其他玩家知道我们死了,以及我们的位置。然后我们被从 players 字典中删除并被销毁。

然后所有剩余的玩家都接收到 OpCodes.Died 网络操作,然后通过 OnReceivedMatchState 函数处理响应:

GameManager.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
30
31
32
33
34
35
36
37
38
/// <summary>
/// Called when new match state is received.
/// </summary>
/// <param name="matchState">The MatchState data.</param>
private async Task OnReceivedMatchState(IMatchState matchState)
{
    // Get the local user's session ID.
    var userSessionId = matchState.UserPresence.SessionId;

    // If the matchState object has any state length, decode it as a Dictionary.
    var state = matchState.State.Length > 0 ? System.Text.Encoding.UTF8.GetString(matchState.State).FromJson<Dictionary<string, string>>() : null;

    // Decide what to do based on the Operation Code as defined in OpCodes.
    switch(matchState.OpCode)
    {
        case OpCodes.Died:
            // Get a reference to the player who died and destroy their GameObject after 0.5 seconds and remove them from our players array.
            var playerToDestroy = players[userSessionId];
            Destroy(playerToDestroy, 0.5f);
            players.Remove(userSessionId);

            // If there is only one player left and that us, announce the winner and start a new round.
            if (players.Count == 1 && players.First().Key == localUser.SessionId) {
                AnnounceWinnerAndStartNewRound();
            }
            break;
        case OpCodes.Respawned:
            // Spawn the player at the chosen spawn index.
            SpawnPlayer(currentMatch.Id, matchState.UserPresence, int.Parse(state["spawnIndex"]));
            break;
        case OpCodes.NewRound:
            // Display the winning player's name and begin a new round.
            await AnnounceWinnerAndRespawn(state["winningPlayerName"]);
            break;
        default:
            break;
    }
}

此处我们获得发送消息的连接用户的会话 ID 引用,将其存储在 userSessionId 中,然后检查 matchState.State.Length 属性,查看网络消息内是否有任何状态数据。

最后,我们在 matchState.OpCode 上切换,根据需要逐一处理。

宣布赢家 #

每个玩家死亡时,我们销毁其 GameObject,并根据其会话 ID 将其从 players 字典删除。

接下来我们检查,看他们是否是仅有的剩余玩家。如果是,则调用 AnnounceWinnerAndStartNewRound 函数:

GameManager.cs

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
/// <summary>
/// Sends a network message that indicates a player has won and a new round is being started.
/// </summary>
/// <returns></returns>
public async void AnnounceWinnerAndStartNewRound()
{
    // ...
    var winningPlayerName = localDisplayName;

    // Send a network message telling everyone else that we won.
    await SendMatchStateAsync(OpCodes.NewRound, MatchDataJson.StartNewRound(winningPlayerName));

    // Display the winning player message and respawn our player.
    await AnnounceWinnerAndRespawn(winningPlayerName);
}

在这里,我们抓取获胜玩家的姓名,并发送一条包含 OpCodes.NewRound 操作的网络消息,告诉每个人比赛胜负已决,获胜玩家是谁。然后我们调用 AnnounceWinnerAndRespawn 函数:

GameManager.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
/// <summary>
/// Displays the winning player message and respawns the player.
/// </summary>
/// <param name="winningPlayerName">The name of the winning player.</param>
private async Task AnnounceWinnerAndRespawn(string winningPlayerName)
{
    // Set the winning player text label.
    WinningPlayerText.text = string.Format("{0} won this round!", winningPlayerName);

    // Wait for 2 seconds.
    await Task.Delay(2000);

    // Reset the winner player text label.
    WinningPlayerText.text = "";

    // Remove ourself from the players array and destroy our player.
    players.Remove(localUser.SessionId);
    Destroy(localPlayer);

    // Choose a new spawn position and spawn our local player.
    var spawnIndex = Random.Range(0, SpawnPoints.transform.childCount - 1);
    SpawnPlayer(currentMatch.Id, localUser, spawnIndex);

    // Tell everyone where we respawned.
    SendMatchState(OpCodes.Respawned, MatchDataJson.Respawned(spawnIndex));
}

这将显示获胜玩家消息,并在短暂延迟(此处为 2 秒)后删除此消息。然后我们销毁本地玩家 GameObject,并从 players 字典中删除它们,然后在随机位置重新生成它们。

Related Pages