(二)PUN 2基本教程

一、介绍

PUN 基础教程是一个基于 Unity 的教程。我们将使用 Photon Cloud 开发第一个简单的 PUN 2 多人游戏。目标是同步每个玩家的动画角色、健康值和基本光线投射。

(二)PUN 2基本教程_第1张图片

1.概述

本教程将从一个空项目开始,逐步指导您完成整个创建过程。在此过程中,将解释概念,以及网络游戏的常见陷阱和设计注意事项。
我们将实现一个简单的 UI 来输入昵称,我们将向用户显示连接进度。
该游戏最多可容纳 4 名玩家,并根据房间内的玩家数量自定义大小的竞技场。这主要是为了展示有关同步场景的几个概念:加载不同场景时如何处理玩家以及这样做时可能出现的问题:)
为了不只是让玩家四处走动而无所事事,我们将实施一个基本的射击系统以及玩家的健康管理。到那时,我们将学习如何跨网络同步变量。
当你的健康为 0 时,游戏结束,你离开竞技场。然后您会再次看到介绍屏幕,如果需要,您可以开始新游戏。

2.你需要知道的

本教程仅假定使用 Unity 编辑器和编程的基础知识。但是,最好具备良好的游戏创建知识和一些经验,以便专注于 Photon Unity Networking 引入的新概念。
示例代码是用 C# 编写的。

3.导入 PUN 和设置

确保您使用等于或高于 2017.4 的 Unity 版本(不推荐测试版)。创建一个新项目,一般在处理教程时建议这样做。
打开资产商店并找到 PUN 2 资产并下载/安装它。导入所有资产后,让 Unity 重新编译。
PUN 设置向导可以帮助您进行网络设置,并提供一种方便的方式来开始我们的多人游戏:光子云!
云?是的,云。这是我们可以在游戏中使用的一组 Photon 服务器。我们稍后会解释。
通过“免费计划”使用云是免费的,没有义务,所以现在我们只需输入我们的邮件地址,向导就会发挥它的魔力。

(二)PUN 2基本教程_第2张图片

新帐户立即获得“AppId”。如果您的邮件地址已经注册,系统会要求您打开仪表板。登录并获取“Ap​​pId”以将其粘贴到输入字段中。
保存 AppId 后,我们就完成了这一步。

 那么,这个“光子云”到底有什么作用呢?!
基本上,它是一堆机器,上面运行着 Photon 服务器。这个“云”服务器由 Exit Games 维护,并为您的多人游戏提供无忧服务。服务器按需添加,因此可以处理任意数量的玩家。
尽管 Photon Cloud 并非完全免费,但成本很低,尤其是与常规托管相比。
Photon Unity Networking 将为您处理 Photon Cloud,但简而言之,这是内部发生的事情:
每个人都首先连接到“名称服务器”。它检查客户端想要使用哪个应用程序(带有 AppId)和哪个区域。然后它将客户端转发到主服务器。
主服务器是一堆区域服务器的枢纽。它知道该区域的所有房间。任何时候创建或加入房间(比赛/游戏)时,客户端都会被转发到另一台称为“游戏服务器”的机器。
PUN 中的设置非常简单,您不必关心托管成本、性能或维护。不止一次。

4.应用程序 ID 和游戏版本

由于每个人都连接到相同的服务器,因此必须有一种方法将您的玩家与其他人的玩家分开。
每个标题(如在游戏、应用程序中)在云中都有自己的“AppId”。玩家只会遇到具有相同“AppId”的其他玩家。
还有一个“游戏版”。这是一个您可以编辑的字符串,它将把拥有老客户的玩家与拥有新客户的玩家区分开来。

5.地区

Photon Cloud 在全球不同的区域组织,以实现玩家之间的最佳连接。
每个区域都与所有其他区域分开,在与分布在不同区域的远程团队合作时记住这一点很重要。确保您最终位于同一地区。
PUN 2 通过确定一个“开发区域”来帮助您,该区域用于所有开发构建。

6.房间

Photon Cloud 是为“基于房间的游戏”而构建的,这意味着每场比赛的玩家数量有限(比如说:最多 16 人),与其他人分开。在一个房间里,每个人都会收到其他人发送的任何信息(除非您向特定玩家发送消息)。在房间外,玩家无法交流,所以我们总是希望他们尽快进入房间。
进入房间的最佳方式是使用随机匹配​​:只需向服务器询问任何房间或指定玩家期望的一些属性。
所有房间都有一个名称作为标识符。除非房间已满或关闭,否则我们可以通过名字加入。方便的是,主服务器可以为我们的应用程序提供房间列表。

7.大厅

您的应用程序的大厅存在于主服务器上,用于列出您的游戏的房间。它不能让玩家相互交流!
在我们的示例中,我们不会使用大厅,而是简单地加入一个随机房间(如果有可用房间),或者如果没有现有房间可以加入则创建一个新房间(房间可以有最大容量,因此它们可能是全部满的)。

 8.开发

本教程的每个部分都涵盖了项目开发阶段的特定部分。对脚本和 Photon 知识的假设水平逐渐增加。在最好的情况下,按顺序完成它们。

  1. 创建基本的大厅场景。
  2. 使用用户界面 (ui) 改善大厅场景。
  3. 创建游戏场景。
  4. 实施关卡加载。
  5. 创建基本的播放器预制件。
  6. 让相机跟随你的玩家。
  7. 修改播放器预制件以添加网络功能。
  8. 播放器实例化和场景切换。
  9. 播放器用户界面 (ui)。

9.教程之外

当然,要创建一个完整的游戏还有很多工作要做,但这只是建立在我们在这里介绍的基础之上。
请务必阅读“入门”部分。
保持好奇,浏览文档和 API 参考只是为了全面了解可用的内容。您可能不会立即需要所有内容,但是当您需要它或实现新功能时,它会重新出现在您的记忆中。您会记得某些方法或属性是相关的,因此是时候正确了解它们了。
利用论坛,不要犹豫,分享您的问题、问题,甚至挫败感 :) 重要的是您不要被问题困住。通过写下来让其他人理解你的问题,你会在你的大脑之外制定它,这有助于解决问题。没有愚蠢的问题,这完全取决于您的专业水平以及您对 Unity 和 PUN 的学习/掌握程度。

二、大厅

1.连接到服务器、房间访问和创建

让我们首先解决本教程的核心问题,能够连接到 Photon Cloud 服务器并加入一个房间或在必要时创建一个房间。

  1. 创建一个新场景,并将其保存为 Launcher.unity。
  2. 创建一个新的 C# 脚本启动器。
  3. 在层次结构中创建一个名为 Launcher 的空游戏对象。
  4. 将 C# 脚本启动器附加到游戏对象启动器。
  5. 编辑 C# 脚本启动器使其内容如下:
  6. 保存 C# 脚本启动器。
using UnityEngine;
using Photon.Pun;

namespace Com.MyCompany.MyGame
{
    public class Launcher : MonoBehaviour
    {
        #region Private Serializable Fields

        #endregion

        #region Private Fields

        /// 
        /// This client's version number. Users are separated from each other by gameVersion (which allows you to make breaking changes).
        /// 
        string gameVersion = "1";

        #endregion

        #region MonoBehaviour CallBacks

        /// 
        /// MonoBehaviour method called on GameObject by Unity during early initialization phase.
        /// 
        void Awake()
        {
            // #Critical
            // this makes sure we can use PhotonNetwork.LoadLevel() on the master client and all clients in the same room sync their level automatically
            PhotonNetwork.AutomaticallySyncScene = true;
        }

        /// 
        /// MonoBehaviour method called on GameObject by Unity during initialization phase.
        /// 
        void Start()
        {
            Connect();
        }

        #endregion


        #region Public Methods

        /// 
        /// Start the connection process.
        /// - If already connected, we attempt joining a random room
        /// - if not yet connected, Connect this application instance to Photon Cloud Network
        /// 
        public void Connect()
        {
            // we check if we are connected or not, we join if we are , else we initiate the connection to the server.
            if (PhotonNetwork.IsConnected)
            {
                // #Critical we need at this point to attempt joining a Random Room. If it fails, we'll get notified in OnJoinRandomFailed() and we'll create one.
                PhotonNetwork.JoinRandomRoom();
            }
            else
            {
                // #Critical, we must first and foremost connect to Photon Online Server.
                PhotonNetwork.ConnectUsingSettings();
                PhotonNetwork.GameVersion = gameVersion;
            }
        }

    #endregion

    }
}

编码提示:不要复制/粘贴代码,您应该自己输入所有内容,因为您可能会更好地记住它。编写注释非常简单,在方法或属性上方的行中键入 ///,您将让脚本编辑器自动创建结构化注释,例如带有

标记。

让我们回顾一下到目前为止该脚本中的内容,首先是从一般的 Unity 角度,然后是我们进行的 PUN 特定调用。

(1)命名空间

虽然不是强制性的,但为您的脚本提供适当的命名空间可以防止与其他资产和开发人员发生冲突。如果另一个开发人员也创建了一个类 Launcher 怎么办? Unity 会抱怨,您或那个开发人员将不得不为 Unity 重命名该类以允许执行项目。如果冲突来自您从资产商店下载的资产,这可能会很棘手。现在,Launcher 类实际上是 Com.MyCompany.MyGame.Launcher 实际上,其他人不太可能拥有完全相同的命名空间,因为您拥有该域,因此使用反向域约定作为命名空间可以使您的工作安全且井井有条。 Com.MyCompany.MyGame 应该替换为您自己的反向域名和游戏名称,这是一个很好的惯例。

(2)MonoBehaviour 类

请注意,我们从 MonoBehaviour 派生我们的类,这实质上将我们的类变成了一个 Unity 组件,然后我们可以将其放到 GameObject 或 Prefab 上。扩展 MonoBehaviour 的类可以访问许多非常重要的方法和属性。在您的例子中,我们将使用两种回调方法,Awake() 和 Start()。

(3)PhotonNetwork.GameVersion

请注意代表您的游戏版本的 gameVersion 变量。您应该将其保留为“1”,直到您需要对已经上线的项目进行重大更改。

(4)PhotonNetwork.ConnectUsingSettings()

在 Start() 期间,我们调用此方法的公共函数 Connect()。这里要记住的重要信息是,此方法是连接到 Photon Cloud 的起点。

(5)PhotonNetwork.AutomaticallySyncScene

我们的游戏将有一个可根据玩家数量调整大小的竞技场,并确保加载的场景对于每个连接的玩家都是相同的,我们将利用 Photon 提供的非常方便的功能:PhotonNetwork.AutomaticallySyncScene 当这是是的,masterclient 可以调用 PhotonNetwork.LoadLevel() 并且所有连接的玩家将自动加载相同的级别。

此时,可以保存Launcher Scene,打开PhotonServerSettings(在Unity菜单Window/Photon Unity Networking/Highlight Photon Server Settings中选择),我们需要将PUN Logging设置为“Full”:

(二)PUN 2基本教程_第3张图片

 编码时要养成的一个好习惯是始终测试潜在的失败。这里我们假设电脑是联网的,但是如果电脑没有联网会怎样呢?让我们找出来。关闭计算机上的互联网并播放场景。您应该会在 Unity 控制台中看到此错误:

Connect() to 'ns.exitgames.com' failed: System.Net.Sockets.SocketException: No such host is known.

 理想情况下,我们的脚本应该意识到这个问题,并对这些情况作出优雅的反应,并提出响应式体验,无论出现什么情况或问题。
现在让我们处理这两种情况,并在我们的 Launcher 脚本中获知我们确实连接或未连接到 Photon Cloud。这将是对 PUN 回调的完美介绍。

2.PUN 回调

PUN 在回调方面非常灵活,并提供两种不同的实现。为了学习,让我们涵盖所有方法,我们将根据情况选择最适合的方法。

3.实现回调接口

PUN 提供了您可以在类中实现的 C# 接口:

IConnectionCallbacks:连接相关的回调。
IInRoomCallbacks:房间内发生的回调。
ILobbyCallbacks:大厅相关回调。
IMatchmakingCallbacks:匹配相关回调。
IOnEventCallback:任何接收到的事件的单一回调。这等效于 C# 事件 OnEventReceived。
IWebRpcCallback:接收WebRPC操作响应的单一回调。
IPunInstantiateMagicCallback:实例化 PUN 预制件的单个回调。
IPunObservable:PhotonView 序列化回调。
IPunOwnershipCallbacks:PUN 所有权转移回调。

回调接口必须注册和注销。调用 PhotonNetwork.AddCallbackTarget(this) 和 PhotonNetwork.RemoveCallbackTarget(this)(可能分别在 OnEnable() 和 OnDisable() 中)

这是确保类符合所有接口但强制开发人员实现所有接口声明的一种非常安全的方法。大多数优秀的 IDE 将使这项任务变得非常容易。然而,脚本最终可能会包含许多可能什么都不做的方法,但必须实现所有方法才能使 Unity 编译器满意。因此,这确实是您的脚本将大量使用所有或大部分 PUN 功能的时候。

我们确实要使用 IPunObservable,在本教程的后面进行数据序列化。

4.扩展 MonoBehaviourPunCallbacks

我们将经常使用的另一种技术是最方便的。我们将从 MonoBehaviourPunCallbacks 派生类,而不是创建派生自 MonoBehaviour 的类,因为它公开了特定的属性和虚方法,供我们在方便时使用和覆盖。这非常实用,因为我们可以确定我们没有任何错别字,我们不需要实现所有方法。
注意:覆盖时,大多数 IDE 默认会执行一个碱基调用并自动为您填充。在我们的例子中,我们不需要,因此作为 MonoBehaviourPunCallbacks 的一般规则,永远不要调用基本方法,除非您覆盖 OnEnable() 或 OnDisable()。如果您覆盖 OnEnable() 和 OnDisable(),请始终调用基类方法。

因此,让我们通过 OnConnectedToMaster() 和 OnDisconnected() PUN 回调将其付诸实践 :

(1)编辑 C# 脚本启动器

(2)将基类从 MonoBehaviour 修改为 MonoBehaviourPunCallbacks

public class Launcher : MonoBehaviourPunCallbacks
{

(3)使用 Photon.Realtime 添加;在类定义之前的文件顶部。

(4)为清楚起见,在类的末尾添加以下两个方法,在 MonoBehaviourPunCallbacks 回调区域内。

#region MonoBehaviourPunCallbacks Callbacks

public override void OnConnectedToMaster()
{
    Debug.Log("PUN Basics Tutorial/Launcher: OnConnectedToMaster() was called by PUN");
}

public override void OnDisconnected(DisconnectCause cause)
{
    Debug.LogWarningFormat("PUN Basics Tutorial/Launcher: OnDisconnected() was called by PUN with reason {0}", cause);
}

#endregion

(5)保存启动器脚本。

现在,如果我们在有或没有互联网的情况下播放这个场景,我们可以采取适当的步骤来通知播放器和/或进一步处理逻辑。当我们开始构建 UI 时,我们将在下一节中处理这个问题。现在我们将处理成功的连接:

因此,我们将以下调用附加到 OnConnectedToMaster() 方法:

// #Critical: The first we try to do is to join a potential existing room. If there is, good, else, we'll be called back with OnJoinRandomFailed()
PhotonNetwork.JoinRandomRoom();

正如评论所说,如果尝试加入随机房间失败,我们需要得到通知,在这种情况下我们需要实际创建一个房间,因此我们在脚本中实现 OnJoinRandomFailed() PUN 回调并使用 PhotonNetwork.CreateRoom 创建一个房间() 并且,您已经猜到了,相关的 PUN 回调 OnJoinedRoom() 将在我们有效加入房间时通知您的脚本:

public override void OnJoinRandomFailed(short returnCode, string message)
{
    Debug.Log("PUN Basics Tutorial/Launcher:OnJoinRandomFailed() was called by PUN. No random room available, so we create one.\nCalling: PhotonNetwork.CreateRoom");

    // #Critical: we failed to join a random room, maybe none exists or they are all full. No worries, we create a new room.
    PhotonNetwork.CreateRoom(null, new RoomOptions());
}

public override void OnJoinedRoom()
{
    Debug.Log("PUN Basics Tutorial/Launcher: OnJoinedRoom() called by PUN. Now this client is in a room.");
}

现在,如果您运行该场景,您应该按照连接到 PUN 的逻辑顺序结束,尝试加入现有房间,否则创建一个房间并加入新创建的房间。
在本教程的这一点上,由于我们现在已经涵盖了连接和加入房间的关键方面,所以有一些事情不是很方便,但他们需要尽快解决。这些与学习PUN并无太大关系,但从整体角度来看却很重要。

5.在 Unity Inspector 中公开字段

您可能已经知道这一点,但如果您不知道,MonoBehaviours 可以自动将字段公开给 Unity Inspector。默认情况下,所有公共字段都是公开的,除非它们被标记为 [HideInInspector]。如果我们想公开非公共字段,我们可以使用属性 [SerializeField]。这是 Unity 中一个非常重要的概念,在我们的例子中,我们将修改每个房间的最大玩家数量并将其显示在检查器中,以便我们可以在不触及代码本身的情况下进行设置。

我们将对每个房间的最大玩家数量做同样的事情。在代码中对此进行硬编码并不是最佳做法,相反,让我们将其作为公共变量,以便我们稍后可以决定并使用该数字,而无需重新编译。

在类声明的开头,在 Private Serializable Fields 区域内让我们添加:

/// 
/// The maximum number of players per room. When a room is full, it can't be joined by new players, and so new room will be created.
/// 
[Tooltip("The maximum number of players per room. When a room is full, it can't be joined by new players, and so new room will be created")]
[SerializeField]
private byte maxPlayersPerRoom = 4;

然后我们修改 PhotonNetwork.CreateRoom() 调用并使用这个新字段而不是我们之前使用的硬编码数字。

// #Critical: we failed to join a random room, maybe none exists or they are all full. No worries, we create a new room.
PhotonNetwork.CreateRoom(null, new RoomOptions { MaxPlayers = maxPlayersPerRoom });

(二)PUN 2基本教程_第4张图片

 所以,现在我们不强制脚本使用静态 MaxPlayers 值,我们只需要在 Unity 检查器中设置它,然后点击运行,不需要打开脚本,编辑它,保存它,等待 Unity 重新编译最后运行。这种方式的效率和灵活性要高得多。

 三、大厅UI

本部分将重点介绍为大厅创建用户界面 (UI)。它将保持非常基础,因为它与网络本身并没有真正的关系。

1.Play按钮

目前我们的大厅自动将我们连接到一个房间,这对早期测试很有帮助,但实际上我们想让用户选择是否以及何时开始游戏。因此,我们只需为此提供一个按钮。

  1. 打开场景启动器。
  2. 使用 Unity 菜单“GameObject/UI/Button”创建一个 UI Button,将该按钮命名为 Play Button,请注意,它在场景层次结构中创建了一个 Canvas 和一个 EventSystem GameObject,所以我们不必这样做,很好 :)
  3. 将播放按钮的子文本值编辑为“播放”
  4. 选择 Play Button 并找到 Button 组件内的 On Click () 部分
  5. 单击小“+”以添加条目
  6. 将 Launcher GameObject 从 Hierarchy 拖到 Field 中
  7. 在下拉菜单中选择 Launcher.Connect() 我们现在已将 Button 与我们的 Launcher Script 连接起来,这样当用户按下该 Button 时,它将从我们的 Launcher Script 调用方法“Connect()”。
  8. 打开脚本启动器。
  9. 在 Start() 中删除我们调用 Connect() 的行
  10. 保存脚本启动器并保存场景。

 如果您现在点击播放,请注意在您点击按钮之前您不会连接。

2.玩家名称

典型游戏的另一个重要的最低要求是让用户输入他们的名字,这样其他玩家就知道他们在和谁玩。我们将通过使用 PlayerPrefs 来记住名称的值,从而为这个简单的任务添加一个转折点,以便当用户再次打开游戏时,我们可以恢复名称。这是一个非常方便且非常重要的功能,可以在您的游戏的许多区域实现,以获得出色的用户体验。
让我们首先创建将管理和记住玩家名称的脚本,然后创建相关的 UI。

3.创建 PlayerNameInputField

(1)创建一个新的 C# 脚本,将其命名为 PlayerNameInputField

(2)这是它的全部内容。相应地编辑并保存 PlayerNameInputField 脚本

using UnityEngine;
using UnityEngine.UI;

using Photon.Pun;
using Photon.Realtime;

using System.Collections;

namespace Com.MyCompany.MyGame
{
    /// 
    /// Player name input field. Let the user input his name, will appear above the player in the game.
    /// 
    [RequireComponent(typeof(InputField))]
    public class PlayerNameInputField : MonoBehaviour
    {
        #region Private Constants

        // Store the PlayerPref Key to avoid typos
        const string playerNamePrefKey = "PlayerName";

        #endregion

        #region MonoBehaviour CallBacks

        /// 
        /// MonoBehaviour method called on GameObject by Unity during initialization phase.
        /// 
        void Start () {

            string defaultName = string.Empty;
            InputField _inputField = this.GetComponent();
            if (_inputField!=null)
            {
                if (PlayerPrefs.HasKey(playerNamePrefKey))
                {
                    defaultName = PlayerPrefs.GetString(playerNamePrefKey);
                    _inputField.text = defaultName;
                }
            }

            PhotonNetwork.NickName =  defaultName;
        }

        #endregion

        #region Public Methods

        /// 
        /// Sets the name of the player, and save it in the PlayerPrefs for future sessions.
        /// 
        /// The name of the Player
        public void SetPlayerName(string value)
        {
            // #Important
            if (string.IsNullOrEmpty(value))
            {
                Debug.LogError("Player Name is null or empty");
                return;
            }
            PhotonNetwork.NickName = value;

            PlayerPrefs.SetString(playerNamePrefKey,value);
        }

        #endregion
    }
}

让我们分析一下这个脚本:

RequireComponent(typeof(InputField)):
我们首先确保此脚本强制执行 InputField,因为我们需要它,这是保证此脚本无故障使用的非常方便快捷的方法。

PlayerPrefs.HasKey(), PlayerPrefs.GetString() and PlayerPrefs.SetString():

PlayerPrefs 是一个简单的配对条目查找列表(就像一个有两列的 excel 表),一列是键,一列是值。 Key 是一个字符串,完全是任意的,你决定如何命名,你需要在整个开发过程中坚持使用它。因此,始终将 PlayerPrefs Key 存储在一个地方是有意义的,一种方便的方法是使用 [Static|变量声明,因为它在游戏过程中不会随时间改变,每次都是一样的。可以一直将其声明为 const,但随着您使用 C# 获得越来越多的经验,您将会了解这一点,这只是在此处戏弄 C# 的可能性范围。

因此,逻辑非常简单。如果 PlayerPrefs 有一个给定的键,我们可以在启动该功能时获取它并直接注入该值,在我们的例子中,我们在启动时用这个填充 InputField,在编辑期间,我们将 PlayerPref Key 设置为当前InputField 的值,然后我们确定它已本地存储在用户设备上以供以后检索(用户下次打开该游戏时)。 

PhotonNetwork.NickName:

这是此脚本的要点,通过网络设置播放器的名称。该脚本在两个地方使用它,一次是在检查名称是否存储在 PlayerPrefs 之后的 Start() 期间,一次是在公共方法 SetPlayerName() 中。现在,没有任何东西调用这个方法,我们需要绑定 InputField OnValueChange() 来调用 SetPlayerName() 以便每次用户编辑 InputField 时,我们都会记录它。我们只能在用户按下播放键时执行此操作,这取决于您,但是这更多涉及脚本方面的知识,因此为了清楚起见,让我们保持简单。这也意味着无论用户将做什么,输入都会被记住,这通常是期望的行为。

4.为玩家的名字创建 UI

  1. 确保您仍在场景启动器中。
  2. 使用 Unity 菜单“GameObject/UI/InputField”创建一个 UI InputField,将其命名为 GameObject Name InputField
  3. 将 RectTransform 中的 PosY 值设置为 35,使其位于播放按钮上方
  4. 找到 Name InputField 的 PlaceHolder 子项并将其文本值设置为“输入您的姓名...”
  5. 选择名称 InputField GameObject
  6. 添加我们刚刚创建的 PlayerNameInputField 脚本
  7. 在 InputField 组件内找到 On Value Change (String) 部分
  8. 单击小“+”以添加条目
  9. 将附加到同一 GameObject 的 PlayerNameInputField 组件拖到该字段中
  10. 在下拉菜单中选择 Dynamic String 部分下的 PlayerNameInputField.SetPlayerName
  11. 保存场景。

现在你可以点击播放,输入你的名字,然后停止播放,再次点击播放,你输入的内容就会出现。
我们正在取得进展,但就用户体验而言,我们缺少有关连接进度的反馈,以及连接和加入房间时出现问题的反馈。

 5.连接进度

我们将在这里保持简单,隐藏名称字段和播放按钮,并在连接期间将其替换为简单的文本“正在连接...”,并在需要时将其切换回来。
为此,我们将对播放按钮进行分组并命名为 Field,这样我们就可以简单地激活和停用该组。稍后可以将更多功能添加到组中,这不会影响我们的逻辑。

  1. 确保您仍在场景启动器中。
  2. 使用 Unity 菜单“GameObject/UI/Panel”创建一个 UI 面板,将其命名为 GameObject Control Panel
  3. 从控制面板中删除图像和画布渲染器组件,我们不需要这个面板的任何视觉效果,我们只关心它的内容。
  4. 将播放按钮和名称输入字段拖放到控制面板上
  5. 使用 Unity 菜单“GameObject/UI/Text”创建一个 UI Text,命名为 GameObject Progress Label 不要担心它会干扰视觉,我们将在运行时相应地激活/停用它们。
  6. 选择进度标签的文本组件
  7. 设置Alignment为居中对齐和中间对齐
  8. 将文本值设置为“正在连接...”
  9. 将颜色设置为白色或任何从背景中脱颖而出的颜色。
  10. 保存场景

 此时,为了进行测试,您可以简单地启用/禁用控制面板和进度标签,以查看各个连接阶段的情况。现在让我们编辑脚本来控制这两个游戏对象的激活。

(1)编辑脚本Launcher

(2)在 Public Fields 区域内添加以下两个属性

[Tooltip("The Ui Panel to let the user enter name, connect and play")]
[SerializeField]
private GameObject controlPanel;
[Tooltip("The UI Label to inform the user that the connection is in progress")]
[SerializeField]
private GameObject progressLabel;

(3)在 Start() 方法中添加以下内容

progressLabel.SetActive(false);
controlPanel.SetActive(true);

(4)在 Connect() 方法的开头添加以下内容

progressLabel.SetActive(true);
controlPanel.SetActive(false);

(5)将以下内容添加到 OnDisconnected() 方法的开头

progressLabel.SetActive(false);
controlPanel.SetActive(true);

(6)保存 Script Launcher 并等待 Unity 完成编译

(7)确保您仍在场景Launcher中。

(8)在层次结构中选择 GameObject Launcher

(9)从层次结构Control Panel和Progress Label拖放到Launcher组件中的相应字段

(10)保存场景

现在,如果您播放场景,您将只看到控制面板,可见并且只要您单击播放,就会显示进度标签。
现在,我们对大厅部分很好。为了进一步向大厅添加功能,我们需要切换到游戏本身,并创建各种场景,以便我们最终可以在加入房间时加载正确的级别。我们将在下一节中完成,之后,我们将最终完成大厅系统。

四、游戏场景

本节介绍玩家将要玩的各种场景的创建。
每个场景都将专供特定数量的玩家使用,场景会越来越大以适应所有玩家,并为他们提供足够的移动空间。
在本教程的后续部分,我们将实现根据玩家数量加载正确关卡的逻辑,为此我们将使用一个约定,即每个关卡将使用以下格式命名:“Room for X”,其中 X将代表玩家的数量。

1.第一个房间创建

  1. 创建一个新场景,保存并命名为 Room for 1。
  2. 创建一个立方体并将其命名为 floor。
  3. 将其定位在 0,0,0。这很重要,因为我们的逻辑系统会在中心 (0,x,​​0) 上方生成玩家。
  4. 将地板缩放到 20、1、20。

这对于一个可玩的关卡来说已经足够了,但是一些墙会让玩家保持在地板区域内。只需创建更多立方体并定位、旋转和缩放它们即可充当墙壁。这是所有四面墙的位置和比例,以匹配物体floor

(二)PUN 2基本教程_第5张图片

(二)PUN 2基本教程_第6张图片

(二)PUN 2基本教程_第7张图片

 此时不要忘记保存 Room For 1 Scene。

2.游戏管理器预制件

在所有情况下,用户界面的最低要求是能够退出房间。为此,我们需要一个 UI 按钮,但我们还需要一个脚本来调用 Photon 让本地玩家离开房间,所以让我们从创建我们称之为游戏管理器预制件开始,第一个它将处理退出本地玩家当前所在房间的任务。

  1.  创建一个新的 c# 脚本 GameManager
  2. 在场景中创建一个空的 GameObject,将其命名为 Game Manager
  3. 将 GameManager 脚本放到 GameObject 游戏管理器上
  4. 通过将游戏管理器从场景层次结构拖到资产浏览器,将游戏管理器变成预制件,它将在层次结构中变为蓝色。
  5. 编辑 GameManager 脚本
  6. 替换为以下内容:
  7. 保存场景
using System;
using System.Collections;

using UnityEngine;
using UnityEngine.SceneManagement;

using Photon.Pun;
using Photon.Realtime;

namespace Com.MyCompany.MyGame
{
    public class GameManager : MonoBehaviourPunCallbacks
    {

        #region Photon Callbacks

        /// 
        /// Called when the local player left the room. We need to load the launcher scene.
        /// 
        public override void OnLeftRoom()
        {
            SceneManager.LoadScene(0);
        }

        #endregion

        #region Public Methods

        public void LeaveRoom()
        {
            PhotonNetwork.LeaveRoom();
        }

        #endregion
    }
}

因此,我们创建了一个公共方法 LeaveRoom()。它所做的是明确让本地玩家离开 Photon Network 房间。我们将其包装在我们自己的公共抽象方法周围。我们可能希望在稍后阶段实现更多功能,例如保存数据,或插入用户将离开游戏的确认步骤等。
根据我们的游戏要求,如果我们不在房间里,我们需要显示 Launcher 场景,所以我们将监听 OnLeftRoom() Photon Callback 并加载大厅场景 Launcher,它在 Build settings 场景的列表中索引为 0 ,我们将在此页面的“构建设置场景列表”部分中进行设置。
但是为什么我们要用这个做一个预制件呢?因为我们的游戏需求意味着同一个游戏有多个场景,所以我们需要重用这个游戏管理器。在 Unity 中,重用游戏对象的最佳方式是将它们变成预制件。
接下来,让我们创建将调用 GameManager 的 LeaveRoom() 方法的 UI 按钮。

3.退出房间按钮预制体

同样,就像游戏管理器一样,从我们将有许多不同场景需要此功能的角度来看,提前计划并将按钮制作成预制件是有意义的,这样我们就可以重用它并仅在一个地方修改它我们需要在未来。

  1. 确保您在 Scene Room 中 1
  2. 使用 Unity 菜单“GameObject/UI/Panel”创建一个 UI 面板,将该面板命名为 Top Panel。
  3. 移除 Image 和 Canvas Renderer 组件以清除此面板。如果您觉得它更好,请保留它,这很美观。
  4. 将“锚定预设”设置为顶部并将锚定预设设置为在按住 Shift 和 Alt 的同时拉伸。 RectTransform Anchors 需要一些经验才能习惯,但这是值得的。
  5. 将 RectTransform 高度设置为 50。
  6. 右键单击 Panel GameObject Top Panel 并添加一个 UI/Button,将其命名为 Leave Button
  7. 选择 Leave Button 的 Text Child,并将其文本设置为 Leave Game。
  8. 将 OnClick 按钮的事件连接到层次结构中的游戏管理器实例以调用 LeaveRoom()。
  9. 将“离开按钮”从场景层次结构拖到资源浏览器中,将其变成预制件,它将在层次结构中变为蓝色。
  10. 保存场景,保存Project

4.其他房间创建

现在我们已经正确地完成了一个房间,让我们将其复制 3 次,并适当地命名它们(当您复制它们时,它们应该已经由 Unity 命名):

  • Room for 2
  • Room for 3
  • Room for 4

在下面找到位置、旋转和比例的变化以加速这个重复过程。(略)

5.构建Build Settings列表

对于项目在编辑和发布时的良好运行至关重要,我们需要在构建设置中添加所有这些场景,以便 Unity 在构建应用程序时包含它们。

(1)通过菜单“File/Build Settings”打开构建设置
(2)拖放所有场景,Launcher场景必须保持在第一个,因为默认情况下 Unity 将加载并向玩家显示该列表中的第一个场景

(二)PUN 2基本教程_第8张图片

 现在我们已经完成了基本的场景设置,我们终于可以开始连接所有东西了。让我们在下一节中执行此操作。

五、Game Manager & Levels

本节介绍了根据当前在房间中玩游戏的玩家数量来处理各种关卡加载的功能。

1.加载竞技场例程

我们创建了 4 个不同的房间,并且按照约定最后一个字符是玩家人数来命名它们,因此现在可以很容易地绑定房间中当前的玩家人数和相关场景。这是一种非常有效的技术,称为“约定优于配置”。例如,基于“配置”的方法会为房间中给定数量的玩家维护场景名称的查找表列表。然后我们的脚本会查看该列表并返回一个名称根本无关紧要的场景。 “配置”通常需要更多的脚本,这就是为什么我们会在这里选择“约定”,它可以让我们更快地工作代码,而不会用不相关的功能污染我们的代码。

(1)打开 GameManager 脚本
(2)让我们在专用于我们将为该场合创建的私有方法的新区域中添加一个新方法。不要忘记保存 GameManager 脚本。 

#region Private Methods

void LoadArena()
{
    if (!PhotonNetwork.IsMasterClient)
    {
        Debug.LogError("PhotonNetwork : Trying to Load a level but we are not the master Client");
        return;
    }
    Debug.LogFormat("PhotonNetwork : Loading Level : {0}", PhotonNetwork.CurrentRoom.PlayerCount);
    PhotonNetwork.LoadLevel("Room for " + PhotonNetwork.CurrentRoom.PlayerCount);
}

#endregion

当我们调用此方法时,我们将根据我们所在房间的 PlayerCount 属性加载适当的房间。

 这里有两点需要注意,非常重要:

(1)PhotonNetwork.LoadLevel() 只应在我们是 MasterClient 时调用。所以我们首先使用 PhotonNetwork.IsMasterClient 检查我们是 MasterClient。调用者也有责任检查这一点,我们将在本节的下一部分中介绍。

(2)我们使用 PhotonNetwork.LoadLevel() 来加载我们想要的级别,我们不直接使用 Unity,因为我们希望依靠 Photon 在房间中所有连接的客户端上加载这个级别,因为我们已经启用了 PhotonNetwork.AutomaticallySyncScene这个游戏。我们使用 PhotonNetwork.LoadLevel() 来加载我们想要的级别,我们不直接使用 Unity,因为我们希望依靠 Photon 在房间中所有连接的客户端上加载这个级别,因为我们已经启用了 PhotonNetwork.AutomaticallySyncScene这个游戏。

现在我们有了加载正确关卡的函数,让我们将其与连接和断开连接的玩家绑定。

2.观看玩家连接

我们已经在教程的前一部分研究了获取 Photon Callbacks 的各种方法,现在 GameManager 需要监听玩家的连接和断开连接。让我们来实现它。

(1)打开 GameManager 脚本
(2)添加以下 Photon 回调并保存 GameManager 脚本

#region Photon Callbacks

public override void OnPlayerEnteredRoom(Player other)
{
    Debug.LogFormat("OnPlayerEnteredRoom() {0}", other.NickName); // not seen if you're the player connecting

    if (PhotonNetwork.IsMasterClient)
    {
        Debug.LogFormat("OnPlayerEnteredRoom IsMasterClient {0}", PhotonNetwork.IsMasterClient); // called before OnPlayerLeftRoom

        LoadArena();
    }
}

public override void OnPlayerLeftRoom(Player other)
{
    Debug.LogFormat("OnPlayerLeftRoom() {0}", other.NickName); // seen when other disconnects

    if (PhotonNetwork.IsMasterClient)
    {
        Debug.LogFormat("OnPlayerLeftRoom IsMasterClient {0}", PhotonNetwork.IsMasterClient); // called before OnPlayerLeftRoom

        LoadArena();
    }
}

#endregion

现在,我们有一个完整的设置。每次玩家加入或离开房间时,我们都会收到通知,我们会调用之前实现的 LoadArena() 方法。但是,只有当我们是使用 PhotonNetwork.IsMasterClient 的 MasterClient 时,我们才会调用 LoadArena()。
现在让我们回到大厅,最终能够在加入房间时加载正确的场景。

3.从大厅loading Arena

(1)编辑脚本Launcher。
(2)将以下内容附加到 OnJoinedRoom() 方法

// #Critical: We only load if we are the first player, else we rely on `PhotonNetwork.AutomaticallySyncScene` to sync our instance scene.
if (PhotonNetwork.CurrentRoom.PlayerCount == 1)
{
    Debug.Log("We load the 'Room for 1' ");

    // #Critical
    // Load the Room Level.
    PhotonNetwork.LoadLevel("Room for 1");
}

让我们测试一下,打开场景Launcher,然后运行它。单击“播放”,让系统连接并加入房间。就是这样,现在我们的大厅开始工作了。但是如果你离开房间,你会注意到当回到大厅时,它会自动重新加入……哎呀,让我们来解决这个问题。
如果您还不知道为什么会发生这种情况,请“简单地”分析日志。我只是简单地引用一下,因为需要实践和经验才能获得自动性来概述问题并知道在哪里查看以及如何调试它。

现在尝试一下,如果您仍然无法找到问题的根源,让我们一起来解决这个问题。

  1. 运行Launcher场景
  2. 点击“播放”按钮,等待您加入房间并加载“Room for 1”
  3. 清除 Unity 控制台
  4. 点击“离开房间”
  5. 研究 Unity 控制台,注意记录“PUN Basics Tutorial/Launcher: OnConnectedToMaster() was called by PUN”
  6. 停止Launcher场景
  7. 双击日志条目“PUN Basics Tutoria/Launcher:OnConnectedToMaster() was called by PUN”脚本将被加载并指向调试调用行。
  8. 嗯……所以,每次我们被告知我们已连接时,我们都会自动加入一个随机房间,这不是我们想要的。

要解决这个问题,我们需要了解上下文。当用户点击“播放”按钮时,我们应该举起一个标志来知道连接过程是由用户发起的。然后我们可以检查此标志以在各种光子回调中相应地采取行动。

(1)编辑脚本启动器
(2)在 Private Fields 区域内创建新属性

/// 
/// Keep track of the current process. Since connection is asynchronous and is based on several callbacks from Photon,
/// we need to keep track of this to properly adjust the behavior when we receive call back by Photon.
/// Typically this is used for the OnConnectedToMaster() callback.
/// 
bool isConnecting;

(3)在 Connect() 方法内部将 isConnecting 设置为 PhotonNetwork.ConnectUsingSettings() 方法的返回值,如下所示:

// keep track of the will to join a room, because when we come back from the game we will get a callback that we are connected, so we need to know what to do then
isConnecting = PhotonNetwork.ConnectUsingSettings();

结果:

public void Connect()
{
    progressLabel.SetActive(true);
    controlPanel.SetActive(false);
    if (PhotonNetwork.IsConnected)
    {
        PhotonNetwork.JoinRandomRoom();
    }
    else
    {
        isConnecting = PhotonNetwork.ConnectUsingSettings();
        PhotonNetwork.GameVersion = gameVersion;
    }
}

(4)在 OnConnectedToMaster() 方法中,用 if 语句包围 PhotonNetwork.JoinRandomRoom() 如下:

// we don't want to do anything if we are not attempting to join a room.
// this case where isConnecting is false is typically when you lost or quit the game, when this level is loaded, OnConnectedToMaster will be called, in that case
// we don't want to do anything.
if (isConnecting)
{
    // #Critical: The first we try to do is to join a potential existing room. If there is, good, else, we'll be called back with OnJoinRandomFailed()
    PhotonNetwork.JoinRandomRoom();
    isConnecting = false;
}

(5)在 OnDisconnected 方法中,将 isConnecting 设置为 false

现在,如果我们再次测试并运行 Launcher Scene,并在 Lobby 和 Game 之间来回切换,一切都很好 :) 为了测试场景的自动同步,您需要发布应用程序(为桌面发布,它是运行测试最快的),并与 Unity 一起运行,因此您实际上有两个玩家将连接并加入一个房间。如果 Unity 编辑器首先创建房间,它将是 MasterClient,您将能够在 Unity 控制台中验证您在连接时获得“PhotonNetwork:加载级别:1”和后来的“PhotonNetwork:加载级别:2”与已发布的实例。
好的!我们已经介绍了很多,但这只是工作的一半……:) 我们需要自己解决玩家问题,所以让我们在下一节中解决这个问题。不要忘记不时离开计算机休息一下,以便更有效地吸收所解释的各种概念。

六、构建玩家

本节将指导您从头开始创建将在本教程中使用的Player预制件,因此我们将涵盖创建过程的每个步骤。
尝试创建一个可以在没有连接 PUN 的情况下工作的Player预制件始终是一个好方法,这样可以轻松快速测试、调试并确保一切至少在没有任何网络功能的情况下工作。然后,您可以缓慢而稳妥地构建和修改每个功能,使其成为网络兼容的角色。通常,用户输入只能在玩家拥有的实例上激活,而不应在其他玩家的计算机上激活。我们将在下面详细介绍。

1.预制基础知识

要了解 PUN 的第一个也是重要的规则是,应该通过网络实例化的预制件必须位于 Resources 文件夹中。

在 Resources 文件夹中包含预制件的第二个重要副作用是您需要注意它们的名称。您的资产资源路径下不应有两个名称相同的预制件,因为 Unity 会选择它找到的第一个。因此,请始终确保在您的项目资源中,资源文件夹路径中没有两个同名的预制件。我们很快就会谈到这一点。

我们将使用 Unity 作为免费资产提供的 Kyle 机器人。它以 Fbx 文件的形式出现,该文件是使用 3ds Max、Maya、cinema 4D 等 3d 软件创建的。使用这些软件创建网格和动画超出了本教程的范围。这个机器人 Kyle.fbx 位于“\Assets\Photon\PhotonUnityNetworking\Demos\Shared Assets\Models”。

这是开始为您的Player使用“Kyle Robot.fbx”的一种方法:

(1)在你的“项目浏览器”中,在某处创建一个名为“Resources”的文件夹,通常建议你组织你的内容,所以可以有类似“PunBasics_tutorial\Resources”的东西

(2)创建一个新的空场景,并将其保存为“PunBasics_tutorial\Scenes”中的 Kyle Test。 “Kyle Test”场景的目的仅仅是创建预制件并进行设置。一旦完成,您就可以摆脱现场。

(3)将 Robot Kyle 拖放到“Scene Hierarchy”上。

(4)将刚刚在层次结构中创建的 GameObject 重命名为 My Robot Kyle

(5)将我的机器人 Kyle 拖放到“PunBasics_tutorial\Resources”

(6)我们现在已经创建了一个基于 Kyle Robot Fbx 资产的预制件,并且我们在场景 Kyle Test 的层次结构中有一个它的实例。现在我们可以开始使用它了。

2.CharacterController

(1)这个Component是Unity提供的一个非常方便的Standard Asset,可以让我们使用Animator更快地制作出典型的角色,所以让我们利用这些Unity的强大功能吧。

(2)双击 My Kyle Robot 放大场景视图。注意以脚为中心的“Capsule Collider”;我们实际上需要“Capsule Collider”来正确匹配角色。

(3)在 CharacterController 组件中,将 Center.y 属性更改为 1(其 Height 属性的一半)。

 (4)点击“Apply”以影响我们所做的更改。这是非常重要的一步,因为我们已经编辑了预制件 My Kyle Robot 的一个实例,但我们希望这些更改适用于每个实例,而不仅仅是这个实例,所以我们点击“Apply”。

3.分配Animator Controller

Kyle Robot Fbx 资产需要由 Animator Graph 控制。我们不会在本教程中介绍此图的创建,因此我们为此提供了一个控制器,位于 \Assets\Photon\PhotonUnityNetworking/Demos/PunBasics-Tutorial/Animator/ 下的项目资产中,名为 Kyle Robot

(二)PUN 2基本教程_第9张图片

 要将此 Kyle 机器人控制器分配给我们的预制件,只需将 Animator 组件的属性 Controller 设置为指向 Kyle Robot

(二)PUN 2基本教程_第10张图片

 不要忘记,如果您在 My Kyle Robot 的实例上执行此操作,则需要点击预制件本身的“Apply”以合并这些更改。

4.使用控制器参数

理解动画控制器的关键特征是动画参数。我们正在使用这些,通过脚本控制我们的动画。在我们的例子中,我们有 Speed、Direction、Jump、Hi 等参数。

Animator 组件的一大特色是能够根据动画实际移动角色。此功能称为 Root Motion,Animator Component 上有一个 Apply Root Motion 属性,默认情况下为 true,所以我们可以开始了。
因此,实际上,要让角色行走,我们只需将速度动画参数设置为正值,它就会开始行走并向前移动。我们开工吧!

5.Animator 管理脚本

让我们创建一个新脚本,我们将在其中根据用户的输入控制角色。

  1. 创建一个名为 PlayerAnimatorManager 的新 C# 脚本。
  2. 将此脚本附加到 Prefab My Robot Kyle。
  3. 用您的命名空间 Com.MyCompany.MyGame 包围类,如下所示。
  4. 为了清晰起见,用区域 MonoBehaviour CallBacks 包围 Start() 和 Update()。
using UnityEngine;
using System.Collections;

namespace Com.MyCompany.MyGame
{
    public class PlayerAnimatorManager : MonoBehaviour
    {
        #region MonoBehaviour Callbacks

        // Use this for initialization
        void Start()
        {
        }

        // Update is called once per frame
        void Update()
        {
        }

        #endregion
    }
}

 6.动画管理:速度控制

我们需要编码的第一件事是获取 Animator 组件,以便我们可以控制它。

(1)确保您正在编辑脚本 PlayerAnimatorManager

(2)创建类型为 Animator 的私有变量动画器

(3)将 Animator 组件存储在 Start() 方法中的这个变量中

private Animator animator;
// Use this for initialization
void Start()
{
    animator = GetComponent();
    if (!animator)
    {
        Debug.LogError("PlayerAnimatorManager is Missing Animator Component", this);
    }
}

请注意,由于我们需要一个 Animator 组件,如果我们没有得到一个,我们会记录一个错误,这样它就不会被忽视并立即被开发人员解决。您应该始终编写代码,就好像它会被其他人使用 :) 这很乏味,但从长远来看是值得的。

(4)现在让我们听取用户输入并控制速度动画参数。然后保存脚本PlayerAnimatorManager。

// Update is called once per frame
void Update()
{
    if (!animator)
    {
        return;
    }
    float h = Input.GetAxis("Horizontal");
    float v = Input.GetAxis("Vertical");
    if (v < 0)
    {
        v = 0;
    }
    animator.SetFloat("Speed", h * h + v * v);
}

让我们研究一下这个脚本在做什么:
由于我们的游戏不允许后退,因此我们确保 v 小于 0。如果用户按下“向下箭头”或“s”键(垂直轴的默认设置),我们不允许这样做并强制值为 0。

您还会注意到我们对两个输入进行了平方。为什么?所以它总是一个正的绝对值以及添加一些缓和。不错的技巧就在这里。您也可以使用 Mathf.Abs() ,那会很好。
我们还添加了两个输入来控制速度,这样当只按下左或右输入时,我们仍然会在转弯时获得一些速度。
当然,所有这些都非常针对我们的角色设计,根据您的游戏逻辑,您可能希望角色原地转弯,或者能够向后退。动画参数的控制总是非常特定于游戏。

7. 测试,测试,1 2 3 ...

让我们验证一下我们到目前为止所做的。确保您已打开 Kyle Test 场景。目前,在这个场景中,我们只有一个摄像头和 Kyle 机器人实例,场景中缺少机器人站立的地面,如果你现在跑到场景中,Kyle 机器人就会倒下。此外,我们不会关心场景中的灯光或任何花哨的东西,我们想测试和验证我们的角色和脚本是否正常工作。

  1.  在场景中添加一个“Cube”。因为立方体默认有一个“Box Collider”,所以我们最好将它用作地板。
  2. 将它定位在 0,-0.5,0 因为立方体的高度是 1。我们希望Cube的顶面是地板。
  3. 将立方体缩放到 30、1、30,以便我们有空间进行实验
  4. 选择相机并将其移开以获得良好的概览。一个不错的技巧是在“SceneView”中获得您喜欢的视图,选择相机并转到菜单“GameObject/Align With View”,相机将匹配场景视图。
  5. 最后一步,将 My Robot Kyle 向上移动 0.1,否则在开始时会错过碰撞并且角色会穿过地板,因此始终在碰撞器之间留出一些物理空间以便模拟创建接触。
  6. 播放场景,然后按“向上箭头”或“a”键,角色正在行走!您可以使用所有键进行测试以进行验证。

这很好,但我们还有很多工作要做,相机需要跟上,我们还不能转向......

如果您现在想在相机上工作,请转到专用部分,此页面的其余部分将完成 Animator 控件并实现旋转。

8.Animator Manager 脚本:方向控制

控制旋转会稍微复杂一些;我们不希望我们的角色在按下左右键时突然旋转。我们想要柔和平滑的旋转。幸运的是,可以使用一些阻尼来设置动画参数

(1)确保您正在编辑 Script PlayerAnimatorManager

(2)在新区域“Private Fields”区域内创建一个公共浮动变量 directionDampTime。

#region Private Fields

[SerializeField]
private float directionDampTime = 0.25f;

#endregion

 (3)在 Update() 函数的末尾,添加

animator.SetFloat("Direction", h, directionDampTime, Time.deltaTime);

所以我们马上注意到 animator.SetFloat() 有不同的签名。我们用来控制 Speed 的参数很简单,但是这个参数需要两个参数,一个是阻尼时间,一个是 deltaTime。阻尼时间是有道理的:它需要多长时间才能达到所需的值,但是 deltaTime?。它本质上允许您编写与帧速率无关的代码,因为 Update() 取决于帧速率,我们需要使用 deltaTime 来解决这个问题。尽可能多地阅读有关该主题的内容以及在网络上搜索此内容时您会找到的内容。理解这个概念后,您将能够充分利用 Unity 的许多功能,包括动画和随时间推移对值的一致控制。

 (4)播放您的场景,并使用所有箭头查看您的角色行走和转身的情况

(5)测试directionDampTime的效果:比如1,然后5,看看达到最大转弯能力需要多长时间。您会看到转弯半径随着 directionDampTime 的增加而增加。

9.Animator Manager脚本:跳跃

对于跳跃,由于两个因素,我们需要做更多的工作。第一,我们不希望玩家在不跑的情况下跳跃,第二,我们不希望跳跃是循环的。

确保您正在编辑 Script PlayerAnimatorManager
在我们在 Update() 方法中捕获用户输入之前插入它

// deal with Jumping
AnimatorStateInfo stateInfo = animator.GetCurrentAnimatorStateInfo(0);
// only allow jumping if we are running.
if (stateInfo.IsName("Base Layer.Run"))
{
    // When using trigger parameter
    if (Input.GetButtonDown("Fire2"))
    {
        animator.SetTrigger("Jump");
    }
}

测试。开始奔跑并按下“alt”键或鼠标右键,Kyle 就会跳起来。

好的,那么首先要了解我们如何知道Animator是否正在运行。我们使用 stateInfo.IsName("Base Layer.Run") 执行此操作。我们只是询问 Animator 的当前活动状态是否为运行。我们必须附加 Base Layer,因为 Run 状态在 Base Layer 中。

如果我们处于 Run 状态,那么我们将监听 Fire2 Input 并在必要时触发 Jump 触发器。
所以,这是到目前为止完整的 PlayerAnimatorManager 脚本:

using UnityEngine;
using System.Collections;

namespace Com.MyCompany.MyGame
{
    public class PlayerAnimatorManager : MonoBehaviour
    {
        #region Private Fields

        [SerializeField]
        private float directionDampTime = .25f;
        private Animator animator;

        #endregion

        #region MonoBehaviour CallBacks

        // Use this for initialization
        void Start()
        {
            animator = GetComponent();
            if (!animator)
            {
                Debug.LogError("PlayerAnimatorManager is Missing Animator Component", this);
            }

        }

        // Update is called once per frame
        void Update()
        {
            if (!animator)
            {
                return;
            }
            // deal with Jumping
            AnimatorStateInfo stateInfo = animator.GetCurrentAnimatorStateInfo(0);
            // only allow jumping if we are running.
            if (stateInfo.IsName("Base Layer.Run"))
            {
                // When using trigger parameter
                if (Input.GetButtonDown("Fire2"))
                {
                    animator.SetTrigger("Jump");
                }
            }
            float h = Input.GetAxis("Horizontal");
            float v = Input.GetAxis("Vertical");
            if (v < 0)
            {
                v = 0;
            }
            animator.SetFloat("Speed", h * h + v * v);
            animator.SetFloat("Direction", h, directionDampTime, Time.deltaTime);
        }

        #endregion
    }
}

当您考虑它在场景中实现的目标时,对于几行代码来说还不错。现在让我们处理相机工作,因为我们能够在我们的世界中进化,我们需要适当的相机行为来跟进。

 10.相机设置

在本节中,我们将使用 CameraWork 脚本。如果您想从头开始编写 CameraWork,请转到下一部分,完成后返回此处。

  1. 将组件 CameraWork 添加到 My Kyle Robot 预制件
  2. 启用属性 Follow on Start,这实际上使相机立即跟随角色。当我们开始网络实施时,我们将关闭它。
  3. 将属性 Center Offset 设置为 0,4,0,这会使相机看起来更高,从而提供比相机直视玩家更好的环境视角,我们会白白看到太多地面。
  4. 播放场景 Kyle Test,并四处移动角色以验证相机是否正确跟随角色。

11.PhotonView 组件

我们需要将一个 PhotonView 组件附加到我们的预制件上。 PhotonView 将每台计算机上的各种实例连接在一起,并定义要观察的组件以及如何观察这些组件。

  1. 将 PhotonView 组件添加到我的机器人 Kyle
  2. 将 Observe 选项设置为 Unreliable On Change
  3. 注意 PhotonView 警告您需要观察一些东西才能产生任何效果。您现在可以忽略它,因为这些观察到的组件将在本教程的后面设置。 

12.Beams设置

我们的机器人角色仍然缺少他的武器,让我们创建一些从他的眼睛中射出的激光束。

13.添加Beams模型

为了简单起见,我们将使用简单的立方体并将它们缩放到非常细和长。有一些技巧可以快速完成此操作:不要直接将立方体添加为头部的子项,而是创建它移动它并自行放大,然后将其附加到头部,这将防止猜测正确的旋转值让你的光束与眼睛对齐。
另一个重要的技巧是对两个光束只使用一个对撞机。这是为了让物理引擎更好地工作,薄对撞机从来都不是一个好主意,它不可靠,所以我们将制作一个大盒子对撞机,以便我们确保可靠地击中目标。

(1)打开 Kyle 测试场景

(2)在场景中添加一个立方体,将其命名为 Beam Left

(3)修改它看起来像一根长光束,并正确定位在左眼上

(4)在层次结构中选择 My Kyle Robot 实例

(5)找到Head child

(二)PUN 2基本教程_第11张图片

 (6)添加一个空游戏对象作为“Head”游戏对象的子对象,将其命名为“Beams” 7. 将“Beam Left”拖放到“Beams”内 8.复制“Beams Left”,将其命名为“Beams Right” 9.将其定位使其与右眼对齐 10. 从 `Beams Right` 中移除 Box Collider 组件 11. 调整 `Beams Left` 的“Box Collider”中心和大小以封装两个光束 12. 转动 `Beams Left` 的“Box Collider” `IsTrigger` 属性设置为 `True`,我们只想知道光束接触玩家,而不是碰撞。 13. 创建一个新材料,将其命名为“Red Beam” 14. 将“Red Beam”材料分配给两个梁 15. 将更改应用回预制件
注意:激光束应该在角色的碰撞器之外,以免伤到自己。
你现在应该有这样的东西:

(二)PUN 2基本教程_第12张图片

 (二)PUN 2基本教程_第13张图片

 14.使用用户输入控制光束

好的,现在我们有了光束,让我们插入 Fire1 输入来触发它们。
让我们创建一个新的 C# 脚本,称为 PlayerManager。下面是让光束工作的第一个版本的完整代码。

using UnityEngine;
using UnityEngine.EventSystems;

using Photon.Pun;

using System.Collections;

namespace Com.MyCompany.MyGame
{
    /// 
    /// Player manager.
    /// Handles fire Input and Beams.
    /// 
    public class PlayerManager : MonoBehaviourPunCallbacks
    {
        #region Private Fields

        [Tooltip("The Beams GameObject to control")]
        [SerializeField]
        private GameObject beams;
        //True, when the user is firing
        bool IsFiring;
        #endregion

        #region MonoBehaviour CallBacks

        /// 
        /// MonoBehaviour method called on GameObject by Unity during early initialization phase.
        /// 
        void Awake()
        {
            if (beams == null)
            {
                Debug.LogError("Missing Beams Reference.", this);
            }
            else
            {
                beams.SetActive(false);
            }
        }

        /// 
        /// MonoBehaviour method called on GameObject by Unity on every frame.
        /// 
        void Update()
        {

            ProcessInputs();

            // trigger Beams active state
            if (beams != null && IsFiring != beams.activeInHierarchy)
            {
                beams.SetActive(IsFiring);
            }
        }

        #endregion

        #region Custom

        /// 
        /// Processes the inputs. Maintain a flag representing when the user is pressing Fire.
        /// 
        void ProcessInputs()
        {
            if (Input.GetButtonDown("Fire1"))
            {
                if (!IsFiring)
                {
                    IsFiring = true;
                }
            }
            if (Input.GetButtonUp("Fire1"))
            {
                if (IsFiring)
                {
                    IsFiring = false;
                }
            }
        }

        #endregion
    }
}

此脚本在此阶段的要点是激活或停用光束。激活后,光束将在与其他模型发生碰撞时有效触发。我们稍后会抓住这些触发器来影响每个角色的健康。
我们还公开了一个公共属性 Beams,它可以让我们在 My Kyle Robot Prefab 的层次结构中引用确切的游戏对象。让我们看看我们需要如何工作才能连接梁,因为这在预制件中很棘手,因为在资产浏览器中,预制件只公开第一个孩子,而不是子孩子,而且我们的梁确实埋在预制件层次结构中,所以我们需要从场景中的一个实例中做到这一点,然后将其应用回预制件本身。

  1.  打开 Kyle 测试场景
  2. 在场景层次结构中选择 My Kyle Robot
  3. 将 PlayerManager 组件添加到我的 Kyle 机器人
  4. 将 My Kyle Robot/Root/Ribs/Neck/Head/Beams 拖放到检查器中的 PlayerManager Beams 属性中
  5. 将实例中的更改应用回预制件

如果你点击播放,然后按下 Fire1 输入(默认情况下是鼠标左键或左 ctrl 键),光束将出现,并在你松开时立即隐藏。

15.健康值设置

让我们实现一个非常简单的健康系统,当光束击中玩家时,它会减少。由于它不是子弹,而是源源不断的能量流,我们需要以两种方式考虑健康损害,当我们被光束击中时,以及在光束击中我们的整个过程中。

(1)打开 PlayerManager 脚本
(2)在 Public Fields 区域内添加Public Fields属性

[Tooltip("The current Health of our player")]
public float Health = 1f;

(3)将以下两个方法添加到 MonoBehaviour 回调区域。然后保存 PlayerManager 脚本。

/// 
/// MonoBehaviour method called when the Collider 'other' enters the trigger.
/// Affect Health of the Player if the collider is a beam
/// Note: when jumping and firing at the same, you'll find that the player's own beam intersects with itself
/// One could move the collider further away to prevent this or check if the beam belongs to the player.
/// 
void OnTriggerEnter(Collider other)
{
    if (!photonView.IsMine)
    {
        return;
    }
    // We are only interested in Beamers
    // we should be using tags but for the sake of distribution, let's simply check by name.
    if (!other.name.Contains("Beam"))
    {
        return;
    }
    Health -= 0.1f;
}
/// 
/// MonoBehaviour method called once per frame for every Collider 'other' that is touching the trigger.
/// We're going to affect health while the beams are touching the player
/// 
/// Other.
void OnTriggerStay(Collider other)
{
    // we dont' do anything if we are not the local player.
    if (!photonView.IsMine)
    {
        return;
    }
    // We are only interested in Beamers
    // we should be using tags but for the sake of distribution, let's simply check by name.
    if (!other.name.Contains("Beam"))
    {
        return;
    }
    // we slowly affect health when beam is constantly hitting us, so player has to move to prevent death.
    Health -= 0.1f * Time.deltaTime;
}

PlayerManager 扩展了 MonoBehaviourPunCallbacks。 MonoBehaviourPunCallbacks 扩展了 MonoBehaviourPun。 MonoBehaviourPun 有一个带有“延迟初始化”的 photonView 属性。这就是 photonView 最终出现在 PlayerManager 中的方式。

首先,这两种方法几乎相同,唯一的区别是我们在 TriggerStay 期间使用 Time.deltaTime 减少健康,减少的速度不依赖于帧率。这是一个通常适用于动画的重要概念,但在这里,我们也需要这个,我们希望所有设备的健康状况以可预测的方式下降,如果在更快的计算机上,你的健康状况下降得更快是不公平的:) Time.deltaTime 是为了保证一致性。如果您有任何问题,请回复我们并通过搜索 Unity 社区了解 Time.deltaTime,直到您完全理解这个概念,这是必不可少的。
第二个重要方面,现在应该明白的是,我们只影响本地玩家的健康,这就是为什么如果 PhotonView 不是我的,我们会提前退出该方法。
最后,如果击中我们的物体是光束,我们只想影响健康。
为了便于调试,我们将 Health 浮点数设置为公共浮点数,以便在等待构建 UI 时轻松检查其值。
好的,看起来一切都完成了吗?嗯...如果不考虑玩家的游戏结束状态,健康系统是不完整的,当健康达到 0 时发生,让我们现在开始吧。

16.健康值检查游戏结束

为简单起见,当玩家的生命值达到 0 时,我们只需离开房间,如果您还记得的话,我们已经在 GameManager 脚本中创建了一个离开房间的方法。如果我们可以重用此方法而不是对相同的功能进行两次编码,那就太好了。您应该不惜一切代价避免针对相同结果的重复代码。这也是介绍一个非常方便的编程概念“Singleton”的好时机。虽然这个主题本身可以填满几个教程,但我们只会做“单例”的最小实现。了解单例、它们在 Unity 上下文中的变体以及它们如何帮助创建强大的功能非常重要,并且会为您省去很多麻烦。因此,请不要犹豫,抽出本教程的时间来了解更多信息。

(1)打开 GameManager 脚本
(2)在 Public Fields 区域添加这个变量

public static GameManager Instance;

(3)添加 Start() 方法,如下所示

void Start()
{
    Instance = this;
}

请注意,我们用 [static] 关键字修饰了 Instance 变量,这意味着该变量无需保存指向 GameManager 实例的指针即可使用,因此您可以在代码的任何位置简单地执行 GameManager.Instance.xxx() .真的很实用!让我们看看它如何适合我们的逻辑管理游戏

(1)打开 PlayerManager 脚本
(2)在 Update() 方法中,在我们检查 photonView.IsMine 的 if 语句中,添加它并保存 PlayerManager 脚本

if (photonView.IsMine)
{
    ProcessInputs();
    if (Health <= 0f)
    {
        GameManager.Instance.LeaveRoom();
    }
}

注意:我们考虑到健康可能是负值,因为激光束造成的损害强度不同。
注意:我们到达 GameManager 实例的 LeaveRoom() 公共方法而无需实际获取 Component 或任何东西,我们只依赖于假设 GameManager 组件位于当前场景中的 GameObject 上这一事实。

 好的,现在我们进入网络!

七、构建玩家相机

本节将指导您创建 CameraWork 脚本,以便在您玩此游戏时跟随您的玩家。
本节与网络无关,因此将保持简短。

1.创建 CameraWork 脚本

(1)创建一个名为 CameraWork 的新 C# 脚本
(2)将 CameraWork 的内容替换为以下内容:

// --------------------------------------------------------------------------------------------------------------------
// 
//   Part of: Photon Unity Networking Demos
// 
// 
//  Used in PUN Basics Tutorial to deal with the Camera work to follow the player
// 
// [email protected]
// --------------------------------------------------------------------------------------------------------------------

using UnityEngine;

namespace Photon.Pun.Demo.PunBasics
{
    /// 
    /// Camera work. Follow a target
    /// 
    public class CameraWork : MonoBehaviour
    {
        #region Private Fields

        [Tooltip("The distance in the local x-z plane to the target")]
        [SerializeField]
        private float distance = 7.0f;

        [Tooltip("The height we want the camera to be above the target")]
        [SerializeField]
        private float height = 3.0f;

        [Tooltip("Allow the camera to be offseted vertically from the target, for example giving more view of the sceneray and less ground.")]
        [SerializeField]
        private Vector3 centerOffset = Vector3.zero;

        [Tooltip("Set this as false if a component of a prefab being instanciated by Photon Network, and manually call OnStartFollowing() when and if needed.")]
        [SerializeField]
        private bool followOnStart = false;

        [Tooltip("The Smoothing for the camera to follow the target")]
        [SerializeField]
        private float smoothSpeed = 0.125f;

        // cached transform of the target
        Transform cameraTransform;

        // maintain a flag internally to reconnect if target is lost or camera is switched
        bool isFollowing;

        // Cache for camera offset
        Vector3 cameraOffset = Vector3.zero;

        #endregion

        #region MonoBehaviour Callbacks

        /// 
        /// MonoBehaviour method called on GameObject by Unity during initialization phase
        /// 
        void Start()
        {
            // Start following the target if wanted.
            if (followOnStart)
            {
                OnStartFollowing();
            }
        }


        void LateUpdate()
        {
            // The transform target may not destroy on level load,
            // so we need to cover corner cases where the Main Camera is different everytime we load a new scene, and reconnect when that happens
            if (cameraTransform == null && isFollowing)
            {
                OnStartFollowing();
            }

            // only follow is explicitly declared
            if (isFollowing) {
                Follow ();
            }
        }

        #endregion

        #region Public Methods

        /// 
        /// Raises the start following event.
        /// Use this when you don't know at the time of editing what to follow, typically instances managed by the photon network.
        /// 
        public void OnStartFollowing()
        {
            cameraTransform = Camera.main.transform;
            isFollowing = true;
            // we don't smooth anything, we go straight to the right camera shot
            Cut();
        }

        #endregion

        #region Private Methods

        /// 
        /// Follow the target smoothly
        /// 
        void Follow()
        {
            cameraOffset.z = -distance;
            cameraOffset.y = height;

            cameraTransform.position = Vector3.Lerp(cameraTransform.position, this.transform.position +this.transform.TransformVector(cameraOffset), smoothSpeed*Time.deltaTime);

            cameraTransform.LookAt(this.transform.position + centerOffset);
        }


        void Cut()
        {
            cameraOffset.z = -distance;
            cameraOffset.y = height;

            cameraTransform.position = this.transform.position + this.transform.TransformVector(cameraOffset);

            cameraTransform.LookAt(this.transform.position + centerOffset);
        }
        #endregion
    }
}

跟随玩家背后的逻辑很简单。我们使用距离计算所需的相机位置,并添加偏移量以落后于使用高度。然后它使用 Lerping 来平滑运动以赶上所需的位置,最后,一个简单的 LookAt 让相机始终指向玩家。
除了 Camera 工作本身,还设置了一些重要的东西;控制行为何时应该主动跟随玩家的能力。理解这一点很重要:我们什么时候想让摄像机跟随玩家?

通常,让我们想象一下如果它始终跟随玩家会发生什么。当你连接到一个满是玩家的房间时,其他玩家实例上的每个 CameraWork 脚本都会争先恐后地控制“主摄像机”,以便它看着它的玩家......好吧,我们不想那样,我们只想跟随代表计算机后面的用户的本地Player。
一旦我们定义了我们只有一个相机但有多个玩家实例的问题,我们就可以轻松找到多种方法来解决这个问题。

(1)仅在本地Player上附加 CameraWork 脚本。
(2)通过关闭和打开 CameraWork 行为来控制它,具体取决于它必须跟随的玩家是否是本地玩家。
(3)将 CameraWork 连接到相机并注意场景中何时有本地Player实例并仅关注该Player实例。

这 3 个选项并不详尽,可以找到更多方法,但在这 3 个中,我们将任意选择第二个。以上选项都没有好坏之分,但这是可能需要最少编码量并且最灵活的选项......“有趣......”我听到你说:)

(1)我们暴露了一个字段 followOnStart 如果我们想在非网络环境中使用它,我们可以将其设置为 true,例如在我们的测试场景中,或者在完全不同的场景中

(2)在基于网络的游戏中运行时,当我们检测到玩家是本地玩家时,我们将调用公共方法 OnStartFollowing()。这将在播放器预制网络一章中创建和解释的脚本 PlayerManager 中完成

 八、Player Networking

本节将指导您修改“Player”预制件。我们首先创建了一个按原样工作的Player,但现在我们将对其进行修改,以便在我们在 PUN 环境中使用它时它可以工作并符合要求。修改非常轻,但概念很关键。所以这个部分确实非常重要。

1.Transform同步

我们想要同步的明显特征是角色的位置和旋转,这样当玩家四处移动时,角色在其他玩家的游戏实例中的行为方式相似。
您可以在自己的 Script 中手动观察 Transform 组件,但是由于网络延迟和同步数据的有效性,您会遇到很多麻烦。幸运的是,为了简化这项常见任务,我们将使用 PhotonTransformView 组件。基本上,此组件已为您完成所有艰苦的工作。

  1. 将 PhotonTransformView 添加到“My Robot Kyle”预制件
  2. 将 PhotonTransformView 从其标题标题拖到 PhotonView 组件上的第一个可观察组件条目上
  3. 现在,检查 PhotonTransformView 中的Synchronize Position
  4. 检查Synchronize Rotation

2.Animator同步

PhotonAnimatorView 还使网络设置变得轻而易举,将为您节省大量时间和麻烦。它允许您定义要同步的图层权重和参数。只有当层权重在游戏过程中发生变化时才需要同步,并且可能根本不同步它们就可以逃脱。参数也是如此。有时可以从其他因素中得出动画值。速度值就是一个很好的例子,您不一定需要完全同步该值,但您可以使用同步位置更新来估计其值。如果可能,请尝试同步尽可能少的参数。

  1. 将 PhotonAnimatorView 添加到我的机器人 Kyle 预制件
  2. 将 PhotonAnimatorView 从其标题标题拖到 PhotonView 组件中的新可观察组件条目上
  3. 现在,在同步参数中,将速度Speed设置为Discrete(离散)
  4. 将方向Direction设置为Discrete离散
  5. 将跳转Jump设置为Discrete离散
  6. 将 Hi 设置为Disabled禁用

每个值都可以被Disabled禁用,或者Discrete离散地或Continuous连续地同步。在我们的例子中,因为我们没有使用 Hi 参数,所以我们将禁用它并节省流量。

Discrete离散同步意味着默认情况下每秒发送 10 次值(在 OnPhotonSerializeView 中)。接收客户端将值传递给他们本地的 Animator。 

Continuous连续同步意味着 PhotonAnimatorView 运行每一帧。当调用 OnPhotonSerializeView 时(默认情况下每秒 10 次),自上次调用以来记录的值将一起发送。接收客户端然后按顺序应用这些值以保持平滑过渡。这种模式虽然更流畅,但也会发送更多的数据来达到这种效果。

 3.用户输入管理

用户对网络的控制的一个关键方面是相同的预制件将为所有玩家实例化,但其中只有一个代表用户实际在计算机前玩,所有其他实例代表其他用户,在其他计算机上玩。因此,考虑到这一点的第一个障碍是"Input Management”。我们如何在一个实例上启用输入而不在其他实例上启用输入以及如何知道哪个是正确的?输入 IsMine 概念。

让我们编辑之前创建的 PlayerAnimatorManager 脚本。在当前形式下,此脚本不知道这种区别,让我们来实现它。

  1. 打开脚本PlayerAnimatorManager
  2. 将 PlayerAnimatorManager 类从 MonoBehaviour 转换为 MonoBehaviourPun,这样可以方便地公开 PhotonView 组件。
  3. 在 Update() 调用中,在最开始插入
if (photonView.IsMine == false && PhotonNetwork.IsConnected == true)
{
    return;
}

好的,如果实例由“客户端”应用程序控制,则 photonView.IsMine 将为真,这意味着该实例代表在此应用程序中在此计算机上玩游戏的自然人。因此,如果它为 false,我们不想做任何事情,只依赖 PhotonView 组件来同步我们之前设置的Transform和Animator组件。
但是,为什么还要在我们的 if 语句中强制执行 PhotonNetwork.IsConnected == true 呢?嗯嗯:) 因为在开发过程中,我们可能想在没有连接的情况下测试这个预制件。例如,在虚拟场景中,仅创建和验证与网络功能本身无关的代码。因此,通过这个附加表达式,我们将允许在未连接时使用输入。这是一个非常简单的技巧,将大大改善您在开发过程中的工作流程。

4.相机控制

它与输入相同,玩家只有一个游戏视图,因此我们需要 CameraWork 脚本只跟随本地玩家,而不是其他玩家。这就是 CameraWork 脚本具有定义何时跟随的能力的原因。
让我们修改 PlayerManager 脚本来控制 CameraWork 组件。

在 Awake() 和 Update() 方法之间插入下面的代码:

/// 
/// MonoBehaviour method called on GameObject by Unity during initialization phase.
/// 
void Start()
{
    CameraWork _cameraWork = this.gameObject.GetComponent();

    if (_cameraWork != null)
    {
        if (photonView.IsMine)
        {
            _cameraWork.OnStartFollowing();
        }
    }
    else
    {
        Debug.LogError("Missing CameraWork Component on playerPrefab.", this);
    }
}

首先,它获取 CameraWork 组件,这是我们所期望的,所以如果我们没有找到它,我们会记录一个错误。然后,如果 photonView.IsMine 为真,则意味着我们需要跟随此实例,因此我们调用 _cameraWork.OnStartFollowing(),这会有效地使相机跟随场景中的那个实例。
所有其他Player实例都将其 photonView.IsMine 设置为 false,因此它们各自的 _cameraWork 将不执行任何操作。
使这项工作有效的最后一项更改:

在预制件 My Robot Kyle 的 CameraWork 组件上禁用 Follow on Start

现在,这实际上将跟随玩家的逻辑移交给了将调用 _cameraWork.OnStartFollowing() 的脚本 PlayerManager,如上所述。

 5.Beams Fire 控制

触发也遵循上面暴露的输入原则,它只需要在 photonView.IsMine 为 true 时工作
打开脚本PlayerManager
用 if 语句包围输入处理调用:

if (photonView.IsMine)
{
    ProcessInputs ();
}

然而,在测试时,我们只看到本地玩家开火。我们还需要查看另一个实例何时触发。我们需要一种在网络上同步触发的机制。为此,我们将手动同步 IsFiring 布尔值,到目前为止,我们使用 PhotonTransformView 和 PhotonAnimatorView 为我们完成变量的所有内部同步,我们只需要调整通过 Unity 方便地暴露给我们的内容Inspector,但是这里我们需要的是非常针对您的游戏的,因此我们需要手动执行此操作。

打开脚本PlayerManager
实施 IPunObservable:

public class PlayerManager : MonoBehaviourPunCallbacks, IPunObservable
{
    #region IPunObservable implementation

    public void OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info)
    {
    }

    #endregion
}

在 IPunObservable.OnPhotonSerializeView 中添加以下代码:

if (stream.IsWriting)
{
    // We own this player: send the others our data
    stream.SendNext(IsFiring);
}
else
{
    // Network player, receive data
    this.IsFiring = (bool)stream.ReceiveNext();
}

回到 Unity 编辑器,在你的资源中选择 My Robot Kyle prefab,在 PhotonView 组件上添加一个 observe entry 并将 PlayerManager 组件拖到它上面

(二)PUN 2基本教程_第14张图片

 如果没有这最后一步,IPunObservable.OnPhotonSerializeView 永远不会被调用,因为它不会被 PhotonView 观察到。
在这个 IPunObservable.OnPhotonSerializeView 方法中,我们得到一个变量流,这是将要通过网络发送的内容,这个调用是我们读取和写入数据的机会。我们只能在我们是本地玩家时写入(photonView.IsMine == true),否则我们读取。
由于 stream 类有 helpers 知道该做什么,我们简单地依赖 stream.isWriting 来知道在当前实例情况下预期的是什么。
如果我们需要写入数据,我们会使用 stream.SendNext() 将 IsFiring 值附加到数据流中,这是一种非常方便的方法,可以隐藏数据序列化的所有艰苦工作。如果我们需要读取,我们使用 stream.ReceiveNext()。

6.生命值同步

好的,为了完成网络更新Player功能,我们将同步生命值,以便Player的每个实例都具有正确的生命值。这与我们刚才介绍的 IsFiring 值的原理完全相同。

打开脚本播放器管理器
在 SendNext 和 ReceiveNext IsFiring 变量之后,在 IPunObservable.OnPhotonSerializeView 中,对 Health 做同样的事情:

if (stream.IsWriting)
{
    // We own this player: send the others our data
    stream.SendNext(IsFiring);
    stream.SendNext(Health);
}
else
{
    // Network player, receive data
    this.IsFiring = (bool)stream.ReceiveNext();
    this.Health = (float)stream.ReceiveNext();
}

这就是在这个场景中同步 Health 变量所需要的全部内容。

九、Player Instantiation

本节将介绍网络上的“Player”预制实例化,并实现播放时适应自动场景切换所需的各种功能。

1.实例化Player

实例化我们的“Player”预制件实际上非常容易。我们需要在刚进入房间时实例化它,我们可以依赖 GameManager Script Start() 消息,这将指示我们已经加载了Arena,这意味着我们的设计表明我们在房间中。

打开 GameManager 脚本
在 Public Fields 区域添加以下变量:

[Tooltip("The prefab to use for representing the player")]
public GameObject playerPrefab;

在 Start() 方法中,添加以下内容

if (playerPrefab == null)
{
    Debug.LogError("Missing playerPrefab Reference. Please set it up in GameObject 'Game Manager'",this);
}
else
{
    Debug.LogFormat("We are Instantiating LocalPlayer from {0}", Application.loadedLevelName);
    // we're in a room. spawn a character for the local player. it gets synced by using PhotonNetwork.Instantiate
    PhotonNetwork.Instantiate(this.playerPrefab.name, new Vector3(0f,5f,0f), Quaternion.identity, 0);
}

这会公开一个公共字段供您引用“Player”预制件。这很方便,因为在这种特殊情况下,我们可以直接拖放到“GameManager”预制件中,而不是在每个场景中,因为“Player”预制件是一种资产,因此引用将保持完整(而不是引用层次结构中的游戏对象,预制件只能在同一场景中实例化时执行)。

 警告:始终确保应该通过网络实例化的预制件位于 Resources 文件夹中,这是 Photon 的要求。

 

然后,在 Start() 中,我们实例化它(在检查我们是否正确引用了“Player”预制件之后)。
请注意,我们在地板上方实例化(5 个单位以上,而玩家只有 2 个单位高)。这是在新玩家加入房间时防止碰撞的众多方法之一,玩家可能已经在竞技场中心移动,因此它避免了突然碰撞。 “掉落”的玩家也是游戏中一个新实体的清晰指示和介绍。
然而,这对我们的情况来说还不够,我们有一个转折:)当其他玩家加入时,将加载不同的场景,我们希望保持一致性,而不是仅仅因为其中一个玩家离开而破坏现有玩家。所以我们需要告诉 Unity 不要销毁我们创建的实例,这反过来意味着我们现在需要检查在加载场景时是否需要实例化。

2.跟踪玩家实例

打开 PlayerManager 脚本
在“Public Fields”区域中,添加以下内容:

[Tooltip("The local player instance. Use this to know if the local player is represented in the Scene")]
public static GameObject LocalPlayerInstance;

 在 Awake() 方法中,添加以下内容:

// #Important
// used in GameManager.cs: we keep track of the localPlayer instance to prevent instantiation when levels are synchronized
if (photonView.IsMine)
{
    PlayerManager.LocalPlayerInstance = this.gameObject;
}
// #Critical
// we flag as don't destroy on load so that instance survives level synchronization, thus giving a seamless experience when levels load.
DontDestroyOnLoad(this.gameObject);

通过这些修改,我们可以在 GameManager 脚本中执行检查以仅在必要时实例化。

打开 GameManager 脚本
用 if 条件包围实例化调用:

if (PlayerManager.LocalPlayerInstance == null)
{
    Debug.LogFormat("We are Instantiating LocalPlayer from {0}", SceneManagerHelper.ActiveSceneName);
    // we're in a room. spawn a character for the local player. it gets synced by using PhotonNetwork.Instantiate
    PhotonNetwork.Instantiate(this.playerPrefab.name, new Vector3(0f, 5f, 0f), Quaternion.identity, 0);
}
else
{
    Debug.LogFormat("Ignoring scene load for {0}", SceneManagerHelper.ActiveSceneName);
}

有了这个,我们现在只在 PlayerManager 没有对 localPlayer 的现有实例的引用时实例化。

3.在竞技场外管理Player位置 

我们还有一件事需要注意。竞技场的大小根据玩家的数量而变化,这意味着如果一个玩家离开而其他玩家在当前竞技场大小的边界附近,他们会发现自己在较小的竞技场之外它会加载,我们需要考虑到这一点,在这种情况下只需将玩家重新定位回竞技场的中心。这是您的游戏玩法和关卡设计中的一个问题。
目前增加了复杂性,因为 Unity 改进了“场景管理”并且 Unity 5.4 弃用了一些回调,这使得创建适用于所有 Unity 版本(从 Unity 5.3.7 到最新版本)的代码稍微复杂一些。所以我们需要基于 Unity 版本的不同代码。它与 Photon Unity Networking 无关,但掌握它对于您的项目在更新中生存很重要。

打开 PlayerManager 脚本
在“私有方法”区域中添加一个新方法:

#if UNITY_5_4_OR_NEWER
void OnSceneLoaded(UnityEngine.SceneManagement.Scene scene, UnityEngine.SceneManagement.LoadSceneMode loadingMode)
{
    this.CalledOnLevelWasLoaded(scene.buildIndex);
}
#endif

在 Start() 方法的末尾,添加以下代码

#if UNITY_5_4_OR_NEWER
// Unity 5.4 has a new scene management. register a method to call CalledOnLevelWasLoaded.
UnityEngine.SceneManagement.SceneManager.sceneLoaded += OnSceneLoaded;
#endif

在“MonoBehaviour Callbacks”区域中添加以下两个方法

#if !UNITY_5_4_OR_NEWER
/// See CalledOnLevelWasLoaded. Outdated in Unity 5.4.
void OnLevelWasLoaded(int level)
{
    this.CalledOnLevelWasLoaded(level);
}
#endif

void CalledOnLevelWasLoaded(int level)
{
    // check if we are outside the Arena and if it's the case, spawn around the center of the arena in a safe zone
    if (!Physics.Raycast(transform.position, -Vector3.up, 5f))
    {
        transform.position = new Vector3(0f, 5f, 0f);
    }
}

重写 OnDisable 方法如下:

#if UNITY_5_4_OR_NEWER
public override void OnDisable()
{
    // Always call the base to remove callbacks
    base.OnDisable ();
    UnityEngine.SceneManagement.SceneManager.sceneLoaded -= OnSceneLoaded;
}
#endif

这段新代码所做的是观察正在加载的关卡,并向下投射当前玩家的位置以查看我们是否击中了任何东西。如果我们不这样做,这意味着我们不在竞技场的地面之上,我们需要重新定位回到中心,就像我们第一次进入房间时一样。
如果您使用的 Unity 版本低于 Unity 5.4,我们将使用 Unity 的回调 OnLevelWasLoaded。如果您使用的是 Unity 5.4 或更高版本,则 OnLevelWasLoaded 不再可用,您必须使用新的 SceneManagement 系统。最后,为了避免重复代码,我们只需要一个 CalledOnLevelWasLoaded 方法,该方法将从 OnLevelWasLoaded 或 SceneManager.sceneLoaded 回调中调用。

十、Player UI Prefab

本节将指导您创建 Player UI 系统。我们需要显示玩家的姓名及其当前健康状况。我们还需要管理 UI 位置以跟随周围的玩家。
本节与网络本身无关。但是,它提出了一些非常重要的设计模式,以提供一些围绕网络及其在开发中引入的约束的高级功能。
所以,UI 不会联网,只是因为我们不需要,还有很多其他方法可以解决这个问题并避免占用流量。这总是值得努力的事情,如果你能摆脱一个不联网的功能,那就太好了。
现在的合理问题是:我们如何为每个联网Player提供一个 UI?
我们将拥有一个带有专用 PlayerUI 脚本的 UI Prefab。我们的 PlayerManager 脚本将保存此 UI Prefab 的引用,并在 PlayerManager 启动时简单地实例化此 UI Prefab,并告诉该预制件跟随那个玩家。

1.创建 UI 预制件

  1. 打开任意一个有 UI 画布的场景
  2. 将 Slider UI 游戏对象添加到画布,将其命名为 Player UI
  3. 将 Rect Transform vertical anchor 设置为 Middle,Horizo​​ntal anchor 设置为 center
  4. 将 Rect Transform 宽度设置为 80,将高度设置为 15
  5. 选择背景子项,将其图像组件颜色设置为红色
  6. 选择子“填充区域/填充”,将其图像颜色设置为绿色
  7. 添加一个 Text UI GameObject 作为 Player UI 的子对象,将其命名为 Player Name Text
  8. 将 CanvasGroup 组件添加到播放器 UI
  9. 在该 CanvasGroup 组件上将 Interactable 和 Blocks Raycast 属性设置为 false
  10. 将 Player UI 从层次结构拖到资产中的预制文件夹中,你知道有一个预制件
  11. 删除场景中的实例,我们不再需要它了。 

 2.PlayerUI 脚本基础

创建一个新的 C# 脚本,并将其命名为 PlayerUI
这是基本的脚本结构,相应地编辑和保存 PlayerUI 脚本:

using UnityEngine;
using UnityEngine.UI;

using System.Collections;

namespace Com.MyCompany.MyGame
{
    public class PlayerUI : MonoBehaviour
    {
        #region Private Fields

        [Tooltip("UI Text to display Player's Name")]
        [SerializeField]
        private Text playerNameText;

        [Tooltip("UI Slider to display Player's Health")]
        [SerializeField]
        private Slider playerHealthSlider;

        #endregion

        #region MonoBehaviour Callbacks

        #endregion

        #region Public Methods

        #endregion

    }
}

现在让我们创建预制件本身。

  1. 将 PlayerUI 脚本添加到 Prefab PlayerUI
  2. 将子游戏对象“Player Name Text”拖放到公共字段 PlayerNameText 中
  3. 将滑块组件拖放到公共字段 PlayerHealthSlider

3.PlayerUI 与 Player 绑定

PlayerUI 脚本需要知道它代表哪个玩家,其中一个原因是:能够显示其健康状况和名称,让我们创建一个公共方法来实现此绑定。

打开脚本 PlayerUI
在“私有字段”区域添加私有属性:

private PlayerManager target;

我们需要提前考虑,我们会定期查看健康状况,因此缓存 PlayerManager 的引用以提高效率是有意义的。

 在“公共方法”区域添加这个公共方法:

public void SetTarget(PlayerManager _target)
{
    if (_target == null)
    {
        Debug.LogError("Missing PlayMakerManager target for PlayerUI.SetTarget.", this);
        return;
    }
    // Cache references for efficiency
    target = _target;
    if (playerNameText != null)
    {
        playerNameText.text = target.photonView.Owner.NickName;
    }
}

在“MonoBehaviour Callbacks”区域中添加此方法:

void Update()
{
    // Reflect the Player Health
    if (playerHealthSlider != null)
    {
        playerHealthSlider.value = target.Health;
    }
}

有了这个,我们就有了 UI 来显示目标玩家的名字和生命值。

4.实例化

好的,所以我们已经知道我们想要如何实例化这个预制件,每次我们实例化一个玩家预制件。最好的方法是在初始化期间在 PlayerManager 中执行此操作。

 打开脚本PlayerManager
添加一个公共字段来保存对 Player UI 预制件的引用,如下所示:

[Tooltip("The Player's UI GameObject Prefab")]
[SerializeField]
public GameObject PlayerUiPrefab;

在 Start() 方法中添加此代码:

if (PlayerUiPrefab != null)
{
    GameObject _uiGo =  Instantiate(PlayerUiPrefab);
    _uiGo.SendMessage ("SetTarget", this, SendMessageOptions.RequireReceiver);
}
else
{
    Debug.LogWarning("Missing PlayerUiPrefab reference on player Prefab.", this);
}

所有这些都是标准的 Unity 编码。但是请注意,我们正在向刚刚创建的实例发送一条消息。我们需要一个接收器,这意味着如果 SetTarget 没有找到响应它的组件,我们将收到警报。另一种方法是从实例中获取 PlayerUI 组件,然后直接调用 SetTarget。通常建议直接使用组件,但知道您可以通过多种方式实现相同的目的也很好。
然而这还远远不够,我们需要处理玩家的删除,我们当然不希望场景中到处都是孤立的 UI 实例,所以我们需要在发现目标时销毁 UI 实例它已经被分配了。

打开 PlayerUI 脚本
将此添加到 Update() 函数:

// Destroy itself if the target is null, It's a fail safe when Photon is destroying Instances of a Player over the network
if (target == null)
{
    Destroy(this.gameObject);
    return;
}

保存 PlayerUI 脚本这段代码虽然简单,但实际上非常方便。由于 Photon 删除联网实例的方式,如果目标引用为空,UI 实例更容易销毁自身。这样就避免了很多潜在的问题,而且非常安全,不管是什么原因丢失了一个target,相关的UI也会自动销毁,非常方便快捷。但是等一下……当一个新关卡被加载时,UI 被破坏但我们的播放器仍然存在……所以我们也需要在我们知道一个关卡被加载时实例化它,让我们这样做:

打开脚本PlayerManager
在 CalledOnLevelWasLoaded() 方法中添加此代码:

GameObject _uiGo = Instantiate(this.PlayerUiPrefab);
_uiGo.SendMessage("SetTarget", this, SendMessageOptions.RequireReceiver);

请注意,有更复杂/更强大的方法来处理这个问题,UI 可以用单例来制作,但它很快就会变得复杂,因为其他加入和离开房间的玩家也需要处理他们的 UI。在我们的实现中,这是直截了当的,代价是重复我们实例化 UI 预制件的位置。作为一个简单的练习,您可以创建一个私有方法来实例化并发送“SetTarget”消息,然后从不同的地方调用该方法而不是复制代码。

5.Parenting To UI Canvas

 Unity UI 系统的一个非常重要的约束是任何 UI 元素都必须放置在 Canvas GameObject 中,因此我们需要在实例化 PlayerUI Prefab 时处理这个问题,我们将在 PlayerUI 脚本的初始化期间执行此操作。

打开脚本 PlayerUI
在“MonoBehaviour Callbacks”区域内添加此方法:

void Awake()
{
    this.transform.SetParent(GameObject.Find("Canvas").GetComponent(), false);
}

保存 PlayerUI 脚本 为什么要用蛮力以这种方式找到 Canvas?因为当场景要加载和卸载时,我们的 Prefab 也是如此,而 Canvas 每次都会不同。为了避免更复杂的代码结构,我们将采用最快的方法。但是真的不推荐使用“Find”,因为这是一个缓慢的操作。这超出了本教程的范围,无法实现对此类情况的更复杂处理,但是当您对 Unity 和脚本感到满意时,这是一个很好的练习,可以找到编码更好地管理需要加载的 Canvas 元素引用的方法并考虑卸载。

6.跟随目标玩家 

这是一个有趣的部分,我们需要让玩家 UI 在屏幕上跟随玩家目标。这意味着要解决几个小问题:

UI 是一个 2D 元素,Player是一个 3D 对象。在这种情况下我们如何匹配位置?
我们不希望 UI 稍微高于Player,我们如何在屏幕上从播放器位置偏移?

 打开 PlayerUI 脚本
在“公共字段”区域内添加此公共属性:

[Tooltip("Pixel offset from the player target")]
[SerializeField]
private Vector3 screenOffset = new Vector3(0f,30f,0f);

将这四个字段添加到“私有字段”区域:

float characterControllerHeight = 0f;
Transform targetTransform;
Renderer targetRenderer;
CanvasGroup _canvasGroup;
Vector3 targetPosition;

将其添加到 Awake Method 区域内:

_canvasGroup = this.GetComponent();

在设置 _target 后将以下代码附加到 SetTarget() 方法:

targetTransform = this.target.GetComponent();
targetRenderer = this.target.GetComponent();
CharacterController characterController = _target.GetComponent ();
// Get data from the Player that won't change during the lifetime of this Component
if (characterController != null)
{
    characterControllerHeight = characterController.height;
}

我们知道我们的Player基于具有高度属性的 CharacterController,我们需要它来对Player上方的 UI 元素进行适当的偏移。

在“MonoBehaviour Callbacks”区域添加这个公共方法 :

void LateUpdate()
{
    // Do not show the UI if we are not visible to the camera, thus avoid potential bugs with seeing the UI, but not the player itself.
        if (targetRenderer!=null)
        {
            this._canvasGroup.alpha = targetRenderer.isVisible ? 1f : 0f;
        }

    // #Critical
    // Follow the Target GameObject on screen.
    if (targetTransform != null)
    {
        targetPosition = targetTransform.position;
        targetPosition.y += characterControllerHeight;
        this.transform.position = Camera.main.WorldToScreenPoint (targetPosition) + screenOffset;
    }
}

 因此,将 2D 位置与 3D 位置匹配的技巧是使用摄像机的 WorldToScreenPoint 函数,由于我们的游戏中只有一个,我们可以依赖于访问主摄像机,这是 Unity 场景的默认设置。
请注意我们是如何分几步设置偏移量的:首先我们获取目标的实际位置,然后添加 characterControllerHeight,最后,在推断出 Player 顶部的屏幕位置后,我们添加屏幕偏移量。

 

Package Demos

1.Asteroids

小行星演示是将 Unity 的 NetworkMeteoroid 演示从 uNet 移植到 PUN 2 的结果。在这个演示中,1 到 8 个玩家可以竞争摧毁小行星。得分最多的玩家赢得游戏。
如果您想了解更多关于从 uNet 到 PUN 的移植过程,您可以查看演示的文档页面。
演示位置:/Photon/PhotonUnityNetworking/Demos/DemoAsteroids/

(二)PUN 2基本教程_第15张图片 

(1)从 UNet 移植到 PUN

此页面基于 Unity 的 NetworkMeteoroid 演示描述了从 uNet 到 PUN 的移植过程,您可以通过访问资产商店的链接查看该演示。该页面显示了该过程的某些方面,这些方面非常容易处理,而其他一些方面则处理起来稍微复杂一些,以便最终获得令人信服的结果。因此,该页面被细分为不同的部分,以涵盖所有必要和重要的步骤。这些步骤(或多或少)按难度升序排序。但是,成功移植游戏并不一定要遵循此顺序。

还请记住,以下描述不是您可以用来将现有应用程序从 uNet 移植到 PUN 的通用方法,但它应该让您了解哪些步骤是必要的以及您可能必须处理哪些问题.

(2)重新导入资产,重建预制件和场景

当开始将现有项目从 uNet 移植到 PUN 时,您基本上可以使用现有项目并将所有 uNET 的网络逻辑替换为 PUN 的网络逻辑。由于我们正在处理一个我们不熟悉的演示,因此我们决定从一个新项目开始并重建演示的大部分内容。这样我们也可以从一开始就展示整个移植过程。实际上没有必要这样做,但这样我们也能够看到原始演示和我们移植的演示之间的所有差异 - 至少在源代码中。如果您认为这会导致比仅使用现有的 NetworkMeteoroid 演示更多的工作,那么您可能是对的,但否则我们也必须做很多特别次要的工作。因此,我们可以预计,就此演示而言,两种移植过程的工作量或多或少是相同的。与项目的复杂性相关,这种体验肯定会改变。
首先,我们在 Unity 中创建了一个新项目,并开始(或多或少)重新创建原始演示的文件夹结构,并重新导入必要的资产(模型和纹理)。完成后,我们开始重建其他所需的资产,例如材料和预制件以及场景。在特别重建“大厅”场景时,我们确保没有重新创建以后不需要的部分,例如专用服务器选项。我们还确保它的外观和感觉适合 PUN 的演示中心。

(3)对游戏逻辑进行细微调整

对应用程序的源代码进行修改时,您需要一个起点。在这种情况下,我们决定从 NetworkMeteoroid 演示的游戏逻辑开始 - 特别是因为没有那么多事情要做。当然,我们不能一对一地重用整个代码,但我们可以使用演示的源代码作为模式,并简单地对其进行修改。因此,我们可以复制整个代码并在之后修改它,或者从一开始就应用修改重新编写它。对于这个演示,我们结合使用了这两种方式。最后,游戏逻辑本身很可能与原始演示中的相同,除了一些特别适用于网络相关源代码的小改动。
这里的一个例子是小行星在原始演示中是如何产生的。在我们的修改版本中,它基本上以相同的方式工作(使用一个“游戏管理器”和一个只要游戏运行就运行的协程),只是对网络相关代码进行了一些小的调整。在这个具体的例子中,我们只是用 PUN 的房间对象实例化调用 PhotonNetwork.InstantiateRoomObject(...) 替换了 uNet 的实例化调用 NetworkServer.Spawn(...)。使用此调用,我们可以添加 InstantiationData,例如,我们使用它来共享有关小行星刚体的其他详细信息。这还有一个好处,就是我们不必使用单独的 RPC 或 RaiseEvent 调用来同步此类信息。
但也有部分源代码根本没有修改。例如,处理玩家输入的方式与原始演示中的方式完全相同,因为它已经运行良好,根本不需要任何修改。

(4)对网络逻辑进行重大调整

在这部分,事情(终于)变得有趣了,因为我们必须对演示的源代码进行一些重大修改,以便用 PUN 的网络逻辑替换所有 uNet 的网络逻辑。提醒:遗憾的是,在尝试将现有应用程序从 uNet 移植到 PUN 时,没有可以遵循的通用方法。所以你不能一般地说某个 uNET 属性(例如 [ClientRpc])可以一直映射到某个 PUN 属性(在这个例子中是 [PunRPC]),因为在PUN 中根本不存在网络逻辑或属性本身。这意味着您必须考虑对每一行代码的网络相关源代码的哪些段应用了哪些修改。
由于我们不出于此演示的目的使用服务器端逻辑,因此我们还必须就如何处理模拟做出另一个重要决定,因为它由原始演示中的服务器控制。在没有自定义服务器端逻辑的情况下使用 PUN 时,我们唯一的可能是使用所有客户端或仅使用一个客户端来处理模拟。在我们的例子中,我们选择了第二个选项,并决定使用 MasterClient 来运行和控制模拟。这意味着它是唯一允许实例化小行星并处理与玩家宇宙飞船发射的子弹的碰撞检测的客户端。此外,这些小行星被实例化为场景对象,其好处是如果 MasterClient 在游戏运行时断开连接,它们不会被破坏。相反,只要有另一个客户端可以接管这个角色,模拟的控制就会传递给新的 MasterClient。
网络逻辑的另一个重要方面是前面提到的小行星和玩家飞船的同步。为了获得令人信服的结果,我们决定实现一个自定义的 OnPhotonSerializeView 函数,该函数处理所有必要数据的发送和接收。这些包括刚体的位置、旋转和速度。随着对它的进一步修改,这个自定义解决方案后来变成了新的 PhotonRigidbodyView 组件。

(5)通过添加滞后补偿解决同步问题

在设置模拟并在多个客户端上运行它之后,我们很快发现我们有明显的同步问题,当至少有两个游戏窗口彼此相邻运行时,这会导致视觉上令人失望的结果。一个例子是宇宙飞船在两个不同屏幕上的位置位移。这是由滞后引起的,并导致整个同步的进一步问题:在某些情况下,玩家的宇宙飞船在一个客户端的视图中撞上了一颗小行星,但在另一个客户端的视图中却没有。这进一步迫使 MasterClient(记住他控制模拟和碰撞检测)有时会引爆另一个玩家的宇宙飞船,因为他的物理系统检测到碰撞,而这在其他客户端上根本不可见。这些问题对于任何多人游戏的玩法来说都是致命的。
为了摆脱这些同步问题,我们决定为小行星、宇宙飞船和它们发射的子弹添加滞后补偿。在我们的例子中,滞后补偿意味着接收到同步对象信息的客户端试图在先前接收到的信息的帮助下计算出更准确和更新的数据。一个例子:每当客户端收到另一艘宇宙飞船的信息时,他使用接收到的位置和速度值以及消息的时间戳和他当前的时间戳来计算另一艘宇宙飞船的最新位置。计算出这个更准确的位置后,我们使用 Unity 的 FixedUpdate 函数实际将宇宙飞船一步一步地移近它的“真实”位置——至少移动到我们认为这是物体“真实”位置的位置。为清楚起见,您可以查看下面的代码片段,其中显示了上述功能的实现。

public void OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info)
{
   if (stream.IsWriting)
   {
       stream.SendNext(rigidbody.position);
       stream.SendNext(rigidbody.rotation);
       stream.SendNext(rigidbody.velocity);
   }
   else
   {
       networkPosition = (Vector3)stream.ReceiveNext();
       networkRotation = (Quaternion)stream.ReceiveNext();
       rigidbody.velocity = (Vector3)stream.ReceiveNext();

       float lag = Mathf.Abs((float)(PhotonNetwork.Time - info.timestamp));
       networkPosition += (rigidbody.velocity * lag);
   }
}

Owner只发送飞船的位置、旋转和速度等重要信息。接收者使用此信息更新其本地存储的值并对位置应用滞后补偿...... 

public void FixedUpdate()
{
   if (!photonView.IsMine)
   {
       rigidbody.position = Vector3.MoveTowards(rigidbody.position, networkPosition, Time.fixedDeltaTime);
       rigidbody.rotation = Quaternion.RotateTowards(rigidbody.rotation, networkRotation, Time.fixedDeltaTime * 100.0f);
   }
}

 ...在使用 FixedUpdate 函数中本地存储的数据更新远程对象之前。

请注意:上面显示的代码片段并不代表 PUN 包中的实际 PhotonRigidbodyView 组件。

最后,为移植的演示添加滞后补偿使其变得更好。这包括令人信服的视觉表现以及具有改进的网络同步的整体令人满意的游戏玩法。

 2.Procedural

程序演示展示了在使用 Photon Cloud 时如何处理程序生成的世界。因此,该演示的重点是生成世界和在多个客户端上同步应用到它的修改。您可以在演示的文档页面上阅读更多相关信息。
演示位置:/Photon/PhotonUnityNetworking/Demos/DemoProcedural/

 (二)PUN 2基本教程_第16张图片

(1)介绍

在此演示中,我们想演示如何使用 Photon Cloud 处理程序生成的世界和应用于它们的修改。因此 PUN 2 包包含一个演示,我们可以在其中创建一个由立方体/块组成的世界。对于创建过程本身,我们有不同的选项,我们可以选择这些选项来创建不同的世界。
此文档页面描述了演示和世界生成的工作原理以及应用的修改如何在所有客户端之间同步。它还显示了在使用 Photon Cloud 和一般情况下创建程序生成的世界时所犯的一些最常见的错误。

(2)这个演示是如何工作的

当连接到房间时,MasterClient 可以使用控制面板控制确定性世界生成器。下一章将介绍控制面板及其相关选项。当世界生成器运行时,它会创建多个集群,每个集群包含多个构成世界的块。将世界划分为不同的集群,有助于稍后在对其应用修改时对其进行同步。同步的工作原理也是本文档页面描述的一部分。要对世界应用修改,任何客户端都可以左键单击某个块以降低其高度或右键单击以提高其高度。
您可能想知道现在是否降低或提高块的高度。我们决定通过在 y 轴上使用块的比例来描述生成世界的不同高度级别。这有一些优点:首先是我们在场景中没有那么多的游戏对象,这在性能方面是好的——Unity(以及其他引擎)显然无法处理几乎无限数量的对象。所以为了这个演示的目的,我们对我们的实施没有问题。另一方面是我们必须以某种方式存储应用的修改,以便所有客户端都可以使用它们。由于我们在 Photon Cloud 上没有可用的自定义服务器端逻辑(除非我们使用企业云),我们决定使用自定义房间属性来存储应用的修改。最后一点是,实施的解决方案比更复杂且可能“生产就绪”的解决方案更容易理解。然而,这个演示仍然展示了我们在开发程序生成游戏时使用 Photon Cloud 的可能性。

(3)生成一个世界

启动演示时,您可能会注意到游戏窗口左上角的控制面板。该面板只能由 MasterClient 使用,因为它控制着世界生成器。在这个演示中,我们有四个选项可以影响世界生成器。

(二)PUN 2基本教程_第17张图片

其中一个选项是种子,在此演示中用数字表示。种子至少需要一位数字,最多可以有十位数字,结果是从 0 到 9,999,999,999 的区间。另一个选项描述了世界的整体大小。对于这个演示,我们可以创建尺寸从 16 x 16 块到 128 x 128 块的世界。请注意,就此演示而言,生成的世界不是无限的。第三个选项描述了集群的大小。一个簇最多可以包含 64 个块。创建多少集群主要取决于这个值和前面提到的世界大小。拥有包含大量块的集群将加速生成世界(由于我们的实施)。另一个选项描述了对生成关卡的外观有影响的世界类型。该演示包含三个不同的选项,主要影响生成过程中块的最大高度。
每当 MasterClient 单击控制面板上的确认按钮时,所选选项将存储在自定义房间属性中,以便同一房间中的所有客户端都可以使用它们。每当客户端收到这些更新时,他将在本地(重新)运行他的世界生成器以创建新世界。由于这些选项是同步的并且没有使用任何随机功能,因此每个客户端将生成相同的世界。

为了生成一个世界,我们使用 Simplex Noise 算法,这是一个噪声函数。为此,我们将每个实例化块的 x 和 z 坐标传递给它的 CalcPixel2D 函数。除了 2D 功能外,Benjamin Ward 使用的 Simplex Noise 实现也提供了 1D 和 3D 功能。这些功能中的每一个都有两个重要的共同点。第一个是,这些函数总是返回一个介于 -1.0f 和 1.0f 之间的值。我们使用这个值来计算每个块的高度(y 尺度)。高度主要取决于这个值和使用的世界类型。第二个方面是,只要输入保持不变,该函数的输出就始终相同。换句话说:更改输入参数(例如种子)时会得到不同的结果。这主要是我们必须同步使用的种子和世界生成器的其他选项的原因。

 (4)同步修改

如前所述,应用于世界的修改存储在自定义房间属性中。这有一个主要好处:每个客户都将自动收到最新的自定义房间属性,包括所有修改;我们只需要在客户端处理它们。这也让后来加入的客户更容易,因为我们不必考虑如何与他们分享世界的最新状态,它只是“自动”发生。
为了将修改后的世界数据存储在自定义房间属性中,每个集群都会为其添加一个唯一标识符和一个字典。字典本身包含某些块的唯一标识符及其相关高度(y 尺度)。这里的重要方面是,只有修改过的块才会存储在自定义房间属性中。未修改的块仍然由之前解释的世界生成器设置描述,不需要存储在这里。

注意:我们在此演示中使用字典,因为它比更复杂的解决方案更易于使用且更容易理解。当然还有其他可能的表示来描述对世界所做的修改。

就本演示而言,这非常有效。但是,如果您想要创建一个具有“无限”世界的更大规模的游戏,您必须考虑将其托管在企业云或自托管的 Photon 服务器上。这两个选项提供了通过使用插件或通过自己实现服务器应用程序来运行自定义服务器端逻辑的可能性。这对您的游戏来说可能是必不可少的,因为您会绕过一些限制,例如在加入游戏时的最大世界大小或加载时间方面。

(5)最常见的错误

本章介绍了在开发程序生成的网络游戏时可能犯的一些最常见的错误。
你可能犯的第一个错误是试图“网络实例化”一切。假设您想创建一个由几堵墙组成的迷宫。就 PUN 而言,一种简单的方法是为这面墙创建一个预制件,将一个 PhotonView 组件附加到它上面,然后在使用 Unity Editor 时将其直接放置在场景中,或者在运行时使用 PhotonNetwork.Instantiate 或 PhotonNetwork 对其进行实例化。实例化房间对象。这实际上可能适用于一定数量的对象,但是不推荐这样做的一个原因是每个客户端的 ViewID 有限制。此限制适用于用户以及场景对象。由于此限制,生成的迷宫也可能受到其大小或复杂性的限制。
另一个常见错误是在每个客户端上分别使用来自 Unity 或 System 命名空间的 Random 类。 Random 类的问题是,只要不使用相同的种子,就会在不同的机器上得到不同的结果。结果是,不同的客户端会生成不同的世界,这在多人游戏方面确实很糟糕。如果您现在仍然考虑将 Random 类与同步种子一起使用,还有另一个主要缺点:您很可能不会获得视觉上令人满意的结果。正如所描述的,噪声算法创建了某种高度图,它具有 - 取决于生成它的设置 - 在不同高度级别之间的过渡。使用 Random 类时,很可能在不同高度级别之间不会有任何良好的过渡。取而代之的是,会有很多拼凑而成的结果在视觉上令人失望。
由于我们已经看到了一种通过使用自定义房间属性来存储一些数据的方法,您可能会考虑使用它们来存储整个生成的世界。当世界变得太大时,这可能会在一定程度上发挥作用。然而,问题是,加入房间需要很长时间,因为必须将大量数据传输到客户端。在这种情况下的解决方案是添加服务器端逻辑,以便服务器可以决定哪些数据需要发送给客户端。因此,服务器不会立即发送整个世界状态,而是只发送世界的那些部分,客户端当前需要并将在之后按需更新他。

 3.老虎机赛车

 在 Slot Racer 演示中,1 到 4 名玩家可以在赛道上驾驶他们的老虎机。演示没有使用“经典”位置同步,而是使用驱动距离来同步轨道上玩家的老虎机。
演示位置:/Photon/PhotonUnityNetworking/Demos/DemoSlotRacer/

(二)PUN 2基本教程_第18张图片

 4.PUN Cockpit

Cockpit 演示提供了区域 ping、连接过程、房间创建和房间管理的可视化方法,并完美嵌入到 Slot Racer 演示中。要使用它,您必须将 PunCockpit-Scene 和 SlotCar-Scene 添加到构建设置中并启动 Slot Racer 演示。
演示位置:/Photon/PhotonUnityNetworking/Demos/PunCockpit/

 (二)PUN 2基本教程_第19张图片

 5.LoadBalancing

 LoadBalancing 演示展示了如何直接使用实时 API。
演示位置:/Photon/PhotonRealtime/Demos/DemoLoadBalancing/

 6.Chat

从 PUN Classic 接管的聊天演示通过使用聊天 API 显示了一个简单的聊天室。
演示位置:/Photon/PhotonChat/Demos/DemoChat/

你可能感兴趣的:(#,PUN,常用插件,unity,游戏引擎)