一、实例化
大多数多人游戏需要创建和同步一些游戏对象。也许它是一个角色、一些单位或怪物,应该出现在房间内的所有客户端上。 PUN 提供了一种方便的方法来做到这一点。
与 Unity 中一样,Instantiate 和 Destroy 用于管理 GameObjects 的生命周期。 PUN 2 可以使用池来创建(并返回)它们。每个联网的游戏对象都必须有一个 PhotonView 组件(和一个 ViewID)作为网络标识符。
此页面和以下页面解释了如何创建、同步。并使用联网的游戏对象。
1.PhotonNetwork.Instantiate
要创建网络游戏对象,请使用 PhotonNetwork.Instantiate 而不是 Unity 的 Object.Instantiate。房间中的任何客户端都可以调用它来创建它将控制的对象。
PhotonNetwork.Instantiate("MyPrefabName", new Vector3(0, 0, 0), Quaternion.identity, 0);
PhotonNetwork.Instantiate 的第一个参数是一个字符串,它定义了要实例化的“预制件”。在内部,PUN 将从 PhotonNetwork.PrefabPool 获取游戏对象,为网络设置并启用它。必须设置创建对象的位置和旋转。稍后加入的玩家最初会在此位置实例化该对象,即使它已经移动了。
任何预制件都必须有一个 PhotonView 组件。这包含一个 ViewID(网络消息的标识符)、谁拥有该对象、哪些脚本将写入和读取网络更新(“观察”列表)以及如何发送这些更新(“观察选项”)。检查检查器以通过编辑器设置 PhotonView。一个联网对象可能包含多个 PhotonView,但出于性能原因,我们建议只使用一个。
默认情况下,PUN 实例化使用 DefaultPool,它从“资源”文件夹加载预制件并稍后销毁游戏对象。更复杂的 IPunPrefabPool 实现可以在 Destroy 中将对象返回到池中,并在 Instantiate 中重新使用它们。在这种情况下,GameObjects 并不是在 Instantiate 中真正创建的,这意味着 Start() 在这种情况下不会被 Unity 调用。因此,网络游戏对象上的脚本应该只实现 OnEnable() 和 OnDisable()。
要在实例化时设置游戏对象,您还可以在脚本中实现 IPunInstantiateMagicCallback。 PUN 将检查接口是否在组件上实现,并在实例开始使用时调用 OnPhotonInstantiate(PhotonMessageInfo info)。 PhotonMessageInfo 参数包含实例化游戏对象的人员和时间。
注意:查找 IPunInstantiateMagicCallback 实现是一项代价高昂的操作,因此 PUN 会缓存哪些预制件未使用该接口,并在再次使用该预制件时跳过此查找。
例如,您可以将实例化的 GameObject 设置为玩家的 Tag 对象:
在幕后,PhotonNetwork.Instantiate 在服务器上为稍后加入的玩家存储一个事件。
2.网络对象的生命周期
默认情况下,只要创建者在房间内,使用 PhotonNetwork.Instantiate 创建的游戏对象就会存在。交换房间时,对象不会转移,就像在 Unity 中切换场景一样。
当客户离开房间时,其余玩家将销毁离开玩家创建的游戏对象。如果这不符合您的游戏逻辑,您可以禁用它:在创建房间时将 RoomOptions.CleanupCacheOnLeave 设置为 false。
主客户端可以使用 PhotonNetwork.InstantiateRoomObject() 创建具有房间生命周期的游戏对象。注意:该对象不与主客户端相关联,而是与房间相关联。默认情况下,主客户端控制这些对象,但您可以使用 photonView.TransferOwnership() 传递控制权。
您还可以手动和显式销毁联网对象,请参阅“PhotonNetwork.Destroy”。
3.联网场景对象
将 PhotonView 放置在场景中的对象上是完全没问题的。默认情况下,它们将由主客户端控制,并且可以用一个“中立”对象来发送与房间相关的 RPC。
重要提示:当您在进入房间之前加载带有联网对象的场景时,某些 PhotonView 值尚无用。例如:当您不在房间时,您无法在 Awake() 中检查 IsMine。
4.切换场景
加载场景时,Unity 通常会销毁层次结构中当前的所有游戏对象。这包括有时会造成混淆的联网对象。
示例:在菜单场景中,您加入一个房间并加载另一个房间。您实际上可能过早到达房间并收到房间的初始消息。 PUN 开始实例化联网对象,但您的逻辑加载了另一个场景并且它们消失了。
为避免加载场景出现问题,您可以将 PhotonNetwork.AutomaticallySyncScene 设置为 true 并使用 PhotonNetwork.LoadLevel() 切换场景。
读取 rpc 和加载级别的时间
5.自定义实例化数据
您可以在实例化调用时发送一些初始自定义数据。只需使用 PhotonNetwork.Instantiate* 方法中的最后一个参数。
这有两个主要优点:
(1)通过避免额外的消息来节省流量:我们不必使用单独的 RPC 或 RaiseEvent 调用来同步此类信息;
(2)时间:交换的数据在预制实例化时可用,这对于进行一些初始化很有用;
实例化数据是任何 Photon 可以序列化的对象数组 (object[])。
例子:
使用自定义数据实例化:
object[] myCustomInitData = GetInitData();
PhotonNetwork.Instantiate("MyPrefabName", new Vector3(0, 0, 0), Quaternion.identity, 0, myCustomInitData);
接收自定义数据:
public void OnPhotonInstantiate(PhotonMessageInfo info)
{
object[] instantiationData = info.photonView.InstantiationData;
// ...
}
6.PhotonNetwork.Destroy
通常,当您离开房间时,游戏对象会自动销毁。请参阅“联网对象的生命周期”。但是,如果您已连接并加入房间,并且想要“网络销毁”使用 PhotonNetwork.Instantiate 调用创建的游戏对象,请使用 PhotonNetwork.Destroy。这包括:
(1)从服务器上的房间中删除缓存的 Instantiate 事件。
(2)删除为要销毁的游戏对象层次结构中的 PhotonViews 缓冲的 RPC。
(3)向其他客户端发送消息以删除游戏对象(受网络延迟影响)。
为了成功,要销毁的游戏对象必须满足以下条件:
(1)GameObject 在运行时使用 PhotonNetwork.Instantiate* 方法调用进行实例化。
(2)如果客户端加入在线房间,GameObject 的 PhotonView 必须由同一客户端拥有或控制。使用 PhotonNetwork.InstantiateRoomObject 创建的游戏对象只能由主客户端销毁。
(3)如果客户端未加入房间或加入离线房间,则可以在本地销毁游戏对象。
7.使用PrefabPool
默认情况下,PUN 使用一个简单的 DefaultPool 来实例化和销毁 GameObjects。这使用 Resources 文件夹加载预制件,它不会合并被破坏的对象(以简化使用)。如果其中任何一个对您的游戏性能产生负面影响,那么是时候设置一个自定义的 PrefabPool 了。
自定义池类必须仅使用两种方法实现 IPunPrefabPool 接口:
GameObject Instantiate(string prefabId, Vector3 position, Quaternion rotation)
获取预制件的实例。它必须返回一个有效的、禁用的带有 PhotonView 的游戏对象。
Destroy(GameObject gameObject) 被调用以销毁(或仅返回)预制件的实例。 GameObject 已经被禁用,池可能会重置并缓存它以供以后在 Instantiate 中使用。
注意:当使用自定义 IPunPrefabPool 时,PhotonNetwork.Instantiate 可能不会创建游戏对象并且(例如)不会调用 Start()。相应地使用 OnEnable() 和 OnDisable() 并禁用任何可能仍以其他方式运行的物理或其他组件。
8.手动实例化
如果您不想使用 PUN 的内置实例化和池,您可以使用 RPC 或 RaiseEvent 重新实现行为,如以下示例所示。
您需要告诉远程客户端要实例化哪个对象(预制件名称)以及如何识别它(ViewID)。
PhotonView.ViewID 是将网络消息路由到正确的游戏对象/脚本的关键。如果您手动实例化,则必须使用 PhotonNetwork.AllocateViewID() 分配一个新的 ViewID 并将其一起发送。房间里的每个人都必须为新对象设置相同的 ID。
请记住,需要缓冲手动实例化事件:稍后连接的客户端也必须接收生成指令。
public void SpawnPlayer()
{
GameObject player = Instantiate(PlayerPrefab);
PhotonView photonView = player.GetComponent<PhotonView>();
if (PhotonNetwork.AllocateViewID(photonView))
{
object[] data = new object[]
{
player.transform.position, player.transform.rotation, photonView.ViewID
};
RaiseEventOptions raiseEventOptions = new RaiseEventOptions
{
Receivers = ReceiverGroup.Others,
CachingOption = EventCaching.AddToRoomCache
};
SendOptions sendOptions = new SendOptions
{
Reliability = true
};
PhotonNetwork.RaiseEvent(CustomManualInstantiationEventCode, data, raiseEventOptions, sendOptions);
}
else
{
Debug.LogError("Failed to allocate a ViewId.");
Destroy(player);
}
}
我们首先在本地实例化播放器预制件。这是必要的,因为我们需要引用对象的 PhotonView 组件。如果我们已经成功地为 PhotonView 分配了一个 ID,我们就会收集我们想要发送给其他客户端的所有数据,并将它们存储在一个对象数组中。在此示例中,我们发送实例化对象的位置和旋转,以及最重要的是分配的 ViewID。之后我们创建 RaiseEventOptions 和 SendOptions。通过 RaiseEventOptions,我们确保该事件被添加到房间的缓存中并且只发送给其他客户端,因为我们已经在本地实例化了我们的对象。使用我们刚刚定义的 SendOptions,可以可靠地发送此事件。最后,我们使用 PhotonNetwork.RaiseEvent(...) 将我们的自定义事件发送到服务器。在这种情况下,我们使用 CustomManualInstantiationEventCode,它只是表示此特定事件的字节值。如果为 PhotonView 分配 ID 失败,我们会记录一条错误消息并销毁先前实例化的对象。
由于我们使用的是 PhotonNetwork.RaiseEvent,因此我们必须使用 OnEvent 回调处理程序。别忘了,要正确注册它。要了解其工作原理,您可以查看 RPC 和 RaiseEvent 文档页面。在此示例中,OnEvent 处理程序如下所示:
public void OnEvent(EventData photonEvent)
{
if (photonEvent.Code == CustomManualInstantiationEventCode)
{
object[] data = (object[]) photonEvent.CustomData;
GameObject player = (GameObject) Instantiate(PlayerPrefab, (Vector3) data[0], (Quaternion) data[1]);
PhotonView photonView = player.GetComponent<PhotonView>();
photonView.ViewID = (int) data[2];
}
}
这里我们简单地检查一下,接收到的事件是否是我们自定义的手动实例化事件。如果是这样,我们用收到的位置和旋转信息实例化玩家预制件。之后,我们获得对象的 PhotonView 组件的引用,并分配我们收到的 ViewID。
如果你想使用资产包来加载你的网络对象,你所要做的就是添加你自己的资产包加载代码,并将示例中的 PlayerPrefab 替换为你的资产包中的预制件。
9.ViewID限制
大多数游戏永远不会需要每个玩家超过几个 PhotonView;一两个角色,通常就是这样。如果你需要更多,你可能做错了什么。例如,网络实例化并为你的武器发射的每颗子弹分配一个 PhotonView 是非常低效的,而不是通过玩家或武器的 PhotonView 通过 RPC 跟踪你发射的子弹。
默认情况下,PUN 支持每个玩家最多 999 (PhotonNetwork.MAX_VIEW_IDS - 1) 个 PhotonViews,玩家的最大理论数量为 2,147,483。您可以轻松地允许每个玩家有更多的 PhotonView。
其工作方式如下:
PhotonViews 为每个网络消息发送一个 viewID。这个viewID是一个整数,由actor编号和player的viewID组成。 int 的最大大小为 2,147,483,647 (int.MaxValue),除以我们的 PhotonNetwork.MAX_VIEW_IDS (1000),这允许超过 200 万玩家,每个玩家有 1000 个View ID。
因此对于 MAX_VIEW_IDS 等于 1000 的默认情况:
- 不使用 MAX_VIEW_IDS 的 ViewID 倍数:0、1000、2000 等,直到 2,147,483,000。
- ViewID 0 被保留。这意味着尚未分配 viewID。
- 场景对象的 viewID 介于 1 和 999 之间。
- 1 号actor的对象的 viewID 介于 1001 和 1999 之间。
- actor编号 x 的对象具有 x * MAX_VIEW_IDS + 1 和 (x + 1) * MAX_VIEW_IDS - 1 之间的 viewID。
- actor编号 2,147,483 的对象具有介于 2,147,483,001 和 2,147,483,647 之间的 viewID。自从我们达到 int.MaxValue 限制后,只有 647 个可能的值。
ViewID 分配后保留,释放后回收。这意味着 viewID 可以在网络销毁其对应的联网 GameObject 后重新使用。
二、Ownership和Control(所有权和控制权)
在 PUN 中,网络对象是使用 PhotonView 组件建立的。每个 PhotonView 都有一个创建者(实例化者)、所有者和控制器。在本文档中,我们将发现有关 PhotonView 的控制权和所有权的定义和概念。我们还将列出不同情况下的预期行为以及如何显式更改 PhotonView 的所有权。
1.定义
(1)Actor
Actor代表房间里的客户。每个Actor在加入房间时都会逐渐分配一个新号码。第一个加入房间的客户将是 1 号Actor,第二个将是 2 号Actor,依此类推。Actor可以是消息的目标,每个房间的一个Actor被指定为主客户端。 Creator、Owner 和 Controller 都是对 actor 的引用。
(2)PhotonView Creator
PhotonView 或“实例化器”(或“生成器”)的创建者是调用 PhotonNetwork.Instantiate 的参与者。这个Actor编号是 PhotonView.ViewID 的一部分,并使用 ViewID / PhotonNetwork.MAX_VIEW_IDS 确定。 PhotonView 的 ViewID 不会改变,因此创建者 ID 也不会改变。在“联网房间对象”的情况下,没有创建者 (null/0),因此联网对象不是由参与者创建的,而是与房间相关联的。
(3)PhotonView Owner
PhotonView 的Owner表示 PhotonView 的默认控制器。
在“联网房间对象”的情况下,没有Owner(null),因为该对象归room所有,而不是Actor所有。如果有Owner,如果后者处于活动状态,则它也是controller。否则,主客户端具有控制权。
(4)PhotonView Controller
控制 PhotonView 的参与者(具有状态权限)。
Owner始终是Controller,除非:
- Owner为空;那么主客户端就是Controller。
- Owner与room断开连接;如果 PlayerTTL 大于 0,则Actor可能会暂时离开房间并稍后重新加入。当Owner软断开连接时,主客户端成为控制器,当Owner重新加入时,所有者恢复控制。
photonView.IsMine 指示 PhotonNetwork.LocalPlayer 是否是此 photonView 的当前控制器。
(5)网络对象
联网对象是具有 PhotonView 组件(包括其子组件)的游戏对象。因此,网络对象由其根 PhotonView 组件表示,即附加到顶级(root)GameObject 的 PhotonView。
(6)嵌套网络对象
如果网络对象在其层次结构中有子物体,并且一个或多个附加了 PhotonView,则它被视为嵌套网络对象。因此,嵌套网络对象是一个网络对象,它是另一个网络对象层次结构的一部分。通常,当您实例化一个具有网络对象的网络对象时,所有 PhotonView 将共享相同的实例化 ID(根 PhotonView 的 ViewID),除非Owner或Controller不同,或者重新设置父级,否则它们将具有相同的生命周期。
(7)房间对象
联网房间对象是不属于Actor的联网对象,但它是属于room的“全局联网对象”。它没有Owner(null),也没有Controller(null),它不是 PhotonNetwork.Instantiate 调用的结果(但可能是 PhotonNetwork.InstantiateRoomObject 的结果)。
(8)场景对象
场景对象是未使用 PhotonNetwork.InstantiateRoomObject 在运行时实例化的房间对象。它是编译时 Unity 场景的一部分。
(9)Soft Disconnect
软断开是指Actor在room中变得不活跃。如果 PlayerTTL 不同于 0 并且:
- 客户端断开连接
- 客户暂时离开房间,打算回来
在 PlayerTTL 过期之前,actor 保持不活动状态。如果 PlayerTTL < 0 或 PlayerTTL == int.MaxValue actor 可以永远保持不活动状态。
您可以使用 PhotonNetwork.ReconnectAndRejoin() 从意外的软断开中恢复,或者在发生软断开后使用 PhotonNetwork.RejoinRoom 重新加入同一个房间。在这两种情况下,客户端需要保持相同的 UserId,并且Actor在重新加入时回收相同的Actor编号和所有先前的Actor属性。
(10)Hard Disconnect
硬断开是指Actor从房间的actor列表中完全删除。
如果 PhotonNetwork.CurrentRoom.PlayerTtl == 0:
- 客户端与 Photon 服务器断开连接
- 客户离开房间
如果 PhotonNetwork.CurrentRoom.PlayerTtl != 0:
- 客户永远离开房间
- PlayerTTL 为不活跃的 actor 过期
2.自动控制转换
(1)On Soft Disconnect
在软断开连接的本地客户端上:
如果 PhotonNetwork.CurrentRoom.AutoCleanUp == true:
- 从运行时实例化的网络对象中取消父级场景对象(以防止它们被破坏)。
- 任何不是由该Actor创建的嵌套网络对象在销毁之前都从父/根对象中分离出来。
- 断开连接的actor创建的所有运行时实例化网络对象都将被销毁。
- 其他联网对象重置为默认值。
如果 PhotonNetwork.CurrentRoom.AutoCleanUp == false:
- 没有什么变化。您需要进行手动清理。
在远程客户端上——如果有的话——:
先前由软断开连接的Actor拥有的 PhotonView 的所有权不变。
主客户端成为软断开连接的Actor拥有的 PhotonView 的Controller。
(2)On Hard Disconnect
在软断开连接的本地客户端上:
如果 PhotonNetwork.CurrentRoom.AutoCleanUp == true:
- 取消父级嵌套网络场景对象断开 actor 的实例化网络对象(以防止它们被破坏)。
- 由断开连接的Actor创建的运行时实例化网络对象将被销毁。
- 从中继服务器中删除缓存的实例化事件和缓冲的 RPC。
- 场景对象被重置。
如果 PhotonNetwork.CurrentRoom.AutoCleanUp == false:
没有什么变化。您需要进行手动清理。
- 在远程客户端上——如果有的话——:
- 取消父级嵌套网络场景对象断开 actor 的实例化网络对象(以防止它们被破坏)。
- 由断开连接的Actor创建的运行时实例化网络对象将被销毁。
- 重置先前由硬断开连接的 actor 拥有的剩余 PhotonView 的所有权(所有者变为空)。那些成为“Orphan”网络对象。
- 主客户端成为这些“Orphan”PhotonView 的控制器,因为当Owner == null 时,主客户端始终是Controller。
(3)OnRejoin(重新加入)
Player.HasRejoined == true
如果Client重新加入一个非空房间:
- 重新获得对同一Actor拥有的网络对象的控制。
- [可选] 主客户端将为所有Owner重新发送 OwnershipUpdate != creator。
如果客户端重新加入一个仍在服务器上但为空的房间(EmptyRoomTTL 尚未过期):
- 重新获得对同一Actor拥有的网络对象的控制。
- 因为它是主客户端,所以它还控制所有其他联网对象。
如果客户端通过“复活”(从外部源重新加载其状态)重新加入房间:
- 重新获得对同一Actor拥有的网络对象的控制。
- 因为它是主客户端,所以它还控制所有其他联网对象。
(4)On New Join(新加入)
Player.HasRejoined == false
如果Client加入的房间不是空的:
- 没什么特别的。
如果Client加入了一个仍在服务器上但为空的房间(EmptyRoomTTL 尚未过期):
- 重新获得对同一Actor拥有的网络对象的控制。
- 因为它是主客户端,所以它还控制所有其他联网对象。
异步加入:如果客户端通过“复活”加入房间(从外部源重新加载其状态):
- 重新获得对同一Actor拥有的网络对象的控制。
- 因为它是主客户端,所以它还控制所有其他联网对象。
(5)主客户端变更
新master成为Controller:
“孤儿”联网对象:没有Owner的联网对象。
具有非活动Owner的联网对象。
注意:如果之前的master软断开连接,它会保留它声称拥有所有权的任何联网房间对象的所有权。
3.明确的所有权转让
您可以通过其各自的根 PhotonView 显式更改联网对象的所有者。默认情况下,联网对象具有固定的所有权,但您可以更改它以允许直接或通过请求转移所有权。所有权的改变通常意味着控制者的改变,除非新的Owner不活跃,否则它将控制联网对象。
(1)所有权转让选项
PhotonView 的所有权转移行为是通过 PhotonView.OwnershipTransfer 中设置的 OwnershipOption 定义的。 PhotonView.OwnershipTransfer 未通过网络同步,一旦 PhotonView 实例化后不应更改。共有三种类型的所有权转让选项:Fixed、Request和Takeover。让我们分别了解更多关于每一个的信息。
(2)Fixed
所有权是固定的。对于房间对象,没有所有者,但主客户端是Controller。对于Player对象,创建者始终是所有者。这是默认值。
(3)Request
当相应的 OwnershipTransfer 选项设置为 Request 时,任何参与者都可以向其当前所有者(或控制器)请求 PhotonView 的所有权。
在这种情况下,这是一个两个步骤的过程:首先,actor 向 PhotonView 的所有者发送请求,然后,如果后者接受,则所有权转移由所有者明确完成,请求 actor 成为新的所有者。请求通过:PhotonView.RequestOwnership() 完成。
这会触发当前所有者的 IPunOwnershipCallbacks.OnOwnershipRequest(PhotonView targetView, Player requestingPlayer) 回调,开发人员必须调用 targetView.TransferOwnership(requestingPlayer) 来执行实际的所有权更改。这允许开发人员在代码中确定是否应接受该请求。
仅允许联网对象的当前所有者或当前控制者接受所有权转移请求。
(4)Takeover
任何Actor都可以更改任何具有 Takeover 作为 OwnershipTransfer 选项的 PhotonView 的所有权。
此选项旨在直接声明 PhotonView 的所有权,而无需其当前所有者的同意,甚至将 PhotonView 归于其他人。在这种情况下要接管所有权,只需调用 PhotonView.TransferOwnership(Player newOwner)。请注意,您还可以将所有权更改为不同的Actor,这意味着Actor X 可以将 PhotonView 的所有者更改为从Actor Y 到Actor Z 的接管。
如果您在具有 Takeover as OwnershipTransfer 选项的 PhotonView 上调用 PhotonView.RequestOwnership(),则该请求会自动被接受(除非之前有人接管),而无需任何回调处理。但是,在 Takeover ownership 选项的情况下,建议直接调用 PhotonView.TransferOwnership(Player newOwner)。
(5)放弃所有权
除非 PhotonView 的所有权是Fixed固定的且不打算更改,否则任何Actor都可以将他自己的 PhotonView 的所有权转让给任何其他活动Actor。这是使用 PhotonView.TransferOwnership(Player newOwner) 完成的。
4.PhotonView 回调
(1)所有权变更回调
每当 PhotonView 的所有者发生变化时,IOnPhotonViewOwnerChange.OnOwnerChange(Player newOwner, Player previousOwner) 将在实现它的类上触发,并且由相同的相应 PhotonView 注册。
实现 IOnPhotonViewOwnerChange 接口的类需要使用 PhotonView.AddCallbackTarget 注册并使用 PhotonView.RemoveCallbackTarget 注销。
(2)显式所有权转移回调
在同一个 IPunOwnershipCallbacks 接口中有两个所有权更改回调:
OnOwnershipRequest(PhotonView targetView, Player requestingPlayer) 当有人从 targetView 请求所有权时。
OnOwnershipTransfered(PhotonView targetView, Player previousOwner) 只要 targetView 的所有者发生变化。
实现 IPunOwnershipCallbacks 接口的类需要使用 PhotonNetwork.AddCallbackTarget 注册并使用 PhotonNetwork.RemoveCallbackTarget 注销。
(3)控制变更回调
每当 PhotonView 的控制器发生变化时,IOnPhotonViewControllerChange.OnControllerChange(Player newController, Player newController) 将在实现它的类上触发,并且由相同的相应 PhotonView 注册。
实现 IOnPhotonViewControllerChange 接口的类需要使用 PhotonView.AddCallbackTarget 注册并使用 PhotonView.RemoveCallbackTarget 注销。
(4)网络销毁回调
有时您希望在网络对象即将被销毁时收到通知。对此有一个回调,它也会在同一网络对象的所有 PhotonView 上触发:主/根 PhotonView 及其所有嵌套的(如果有的话)。
每当调用 PhotonNetwork.Destroy 时,就在完成网络销毁之前,IOnPhotonViewPreNetDestroy.OnPreNetDestroy(PhotonView rootView) 会在实现它的类上触发,并由相同的相应 PhotonView 注册。
实现 IOnPhotonViewPreNetDestroy 接口的类需要使用 PhotonView.AddCallbackTarget 注册并使用 PhotonView.RemoveCallbackTarget 注销。
三、同步和状态
游戏都是关于更新其他玩家并保持相同状态的。你想知道其他玩家是谁,他们做什么,他们在哪里以及他们的游戏世界是什么样子的。
PUN(和一般的 Photon)提供了几种用于更新和保持状态的工具。此页面将解释选项以及何时使用每个选项。
1.对象同步
使用 PUN,您可以轻松地使某些游戏对象“网络感知”。分配一个 PhotonView 组件,一个对象可以将位置、旋转和其他值与其远程副本同步。必须将 PhotonView 设置为“observe”组件,例如 Transform 或(更常见的)其脚本之一。
同步数据有四种不同的选项:
Off:不发生同步,没有发送或接收任何内容;
Reliable Delta Compressed:保证通过内部优化机制接收数据,如果数据未更改,该机制将发送 null。为此,请确保使用不同的 SendNext 调用填充流。
Unreliable:数据按顺序接收,但可能会丢失一些更新。这意味着在丢失的情况下不会延迟。
Unreliable OnChange:数据按顺序接收,但可能会丢失一些更新。如果更新重复上次的信息,PhotonView 将暂停发送更新,直到下一次更改。
我们的大多数Demos都使用对象同步。一些脚本实现 OnPhotonSerializeView() 并成为 PhotonView 的observed组件。在 OnPhotonSerializeView() 中,位置和其他值被写入流并从中读取。要使用此功能,脚本必须实现 IPunObservable 接口。
2.远程过程调用 (RPC)
您可以将您的方法标记为可由房间中的任何客户端调用。如果您使用 [PunRPC] 属性实现“ChangeColorToRed()”,远程玩家可以: 通过调用 photonView.RPC("ChangeColorToRed", RpcTarget.All); 将游戏对象的颜色更改为红色。
调用始终以游戏对象上的特定 PhotonView 为目标。因此,当调用“ChangeColorToRed()”时,它仅在具有该 PhotonView 的游戏对象上执行。当您想要影响特定对象时,这很有用。
当然,对于实际上没有目标的方法,可以将一个空的 GameObject 作为“虚拟”对象放入场景中。例如,您可以使用 RPC 实现聊天,但这与特定的游戏对象无关。
RPC 可以被“缓冲”。服务器将记住该调用并将其发送给调用 RPC 后加入的任何人。这使您能够存储一些操作并实现 PhotonNetwork.Instantiate() 的替代方法。请参见手动实例化。一个缺点是,如果您不小心,缓冲区会越来越大。
3.自定义属性
Photon 的自定义属性由一个键值哈希表组成,您可以根据需要填写该哈希表。这些值已同步并缓存在客户端上,因此您不必在使用前获取它们。 SetCustomProperties() 将更改推送给其他人。
这有什么用?通常,房间和玩家有一些与游戏对象无关的属性:当前地图或玩家角色的颜色(想想:2d 跳跃和奔跑)。这些可以通过对象同步或 RPC 发送,但使用自定义属性通常更方便。
要为Player设置自定义属性,请使用 Player.SetCustomProperties(Hashtable propsToSet) 并包含要添加或更新的键值。本地Player对象的快捷方式是:PhotonNetwork.LocalPlayer。同样,使用 PhotonNetwork.CurrentRoom.SetCustomProperties(Hashtable propsToSet) 更新您所在的房间。
所有更新都需要一些时间来分发,但所有客户端都会相应地更新 CurrentRoom.CustomProperties 和 Player.CustomProperties。作为属性更改时的回调,PUN 分别调用 OnRoomPropertiesUpdate(Hashtable propertiesThatChanged) 或 OnPlayerPropertiesUpdate(Player targetPlayer, Hashtable changedProps)。
您还可以在创建新房间时设置属性。这特别有用,因为房间属性可用于配对。有一个 JoinRandomRoom() 重载,它使用属性哈希表来过滤可接受的加入房间。创建房间时,请确保通过相应地设置 RoomOptions.CustomRoomPropertiesForLobby 来定义哪些房间属性可用于在大厅中进行过滤。
(1)检查并交换属性 (CAS)
当您使用 SetCustomProperties 时,服务器通常会接受来自任何客户端的新值,这在某些情况下可能会很棘手。
例如,一个属性可以用来存储谁在一个房间里拿起了一件独特的物品。因此,该属性的键是物品,而值定义了拾取它的人。任何客户端都可以随时将属性设置为他的 actorNumber。如果所有人大约同时执行此操作,则最后一次调用 SetCustomProperties 将赢得该项目(设置最终值)。这是违反直觉的,可能不是您想要的。
SetCustomProperties 有一个可选的 expectedProperties 参数,可以用作条件。使用 expectedProperties,如果服务器的当前键值与 expectedProperties 中的键值匹配,服务器将仅更新属性。具有过时 expectedProperties 的更新将被忽略(客户端因此收到错误,其他人不会注意到失败的更新)。
在我们的示例中,expectedProperties 可以包含您从中获取唯一项的当前所有者。即使每个人都尝试拿走该项目,也只有第一个会成功,因为每个其他更新请求都将在 expectedProperties 中包含一个过时的所有者。
在 SetCustomProperties 中使用 expectedProperties 作为条件称为检查和交换 (CAS)。它有助于避免并发问题,但也可以用于其他创造性的方式。
注意:由于 SetCustomProperties 可能因 CAS 而失败,因此所有客户端仅通过服务器发送的事件更新其自定义属性。这包括尝试设置新值的客户端。与没有 CAS 的设置值相比,这是一个不同的时间。
您应该知道不支持使用 CAS 进行初始化(即第一次创建新属性)。此外,目前,对于 CAS 的 SetProperties 失败没有回调。如果你想得到有关 CAS 失败的通知,这里有一个示例代码可以添加到你的 MonoBehaviour 中:
这不会替换属性更新回调(IInRoomCallbacks.OnPlayerPropertiesUpdate 和 IInRoomCallbacks.OnRoomPropertiesUpdate),只有在成功的情况下才会触发,无论是否使用 CAS。
private void OnEnable()
{
PhotonNetwork.NetworkingClient.OpResponseReceived += NetworkingClientOnOpResponseReceived;
}
private void OnDisable()
{
PhotonNetwork.NetworkingClient.OpResponseReceived -= NetworkingClientOnOpResponseReceived;
}
private void NetworkingClientOnOpResponseReceived(OperationResponse opResponse)
{
if (opResponse.OperationCode == OperationCode.SetProperties &&
opResponse.ReturnCode == ErrorCode.InvalidOperation)
{
// CAS failure
}
}
(2)属性同步
这是默认“via server”完成的,意思是:
默认情况下,为Actor或Room属性设置Actor属性不会在加入在线房间时立即对发送者/设置者客户端(设置属性的Actor)生效。相反,发送者/设置者客户端(设置属性的Actor)将等待服务器事件 PropertiesChanged 以在本地应用/设置更改。因此,您需要等待本地客户端触发 OnPlayerPropertiesUpdate 或 OnRoomPropertiesUpdate 回调才能访问它们。这背后的原因是,如果我们先在本地设置属性,然后在服务器上和房间中的其他Actor发送请求,属性很容易失去同步。后者可能会失败,我们最终可能会发现发送者/设置者客户端(设置属性的参与者)的属性在本地与服务器或其他客户端上的不同。如果您想要旧行为(在将请求发送到服务器以同步它们之前在本地设置属性),请在创建房间之前将 roomOptions.BroadcastPropsChangeToAll 设置为 false。但我们强烈建议不要这样做。
客户端仍然可以在房间外缓存本地Player的属性。这些属性将在进入房间时发送。此外,在离线模式下设置属性会立即发生。
此外,默认情况下,不会在房间之间清除本地Actor的属性,你应该自己做。
4.充分利用同步、RPC 和属性
要确定哪种同步方法最适合某个值,通常最好检查它需要更新的频率以及它是否需要值的“历史记录”。
(1)频繁更新(位置、角色状态)
对于频繁更新,请使用对象同步。毫无疑问,您自己的脚本可以通过不将任何内容写入流中来跳过更新以进行任意数量的更新。
角色的位置经常变化。每次更新都很有用,但很可能很快就会被更新的替换。 PhotonView 可以设置为发送“Unreliable”或“Unreliable On Change”。第一个将以固定频率发送更新 - 即使角色没有移动。后者将在 GameObject(角色、单位)休息时停止发送更新。
(2)不经常更新(玩家的行为)
更换角色的装备、使用工具或结束游戏回合都是不常见的动作。它们基于用户输入,可能最好作为 RPC 发送。
使用对象同步的界限不是很清楚。如果您无论如何都进行对象同步,那么通过更频繁的更新“内嵌”一些操作会很有意义。例如:如果您无论如何都发送一个角色的位置,您可以轻松地添加一个值来发送“跳跃”状态。这不必是一个单独的 RPC!
与对象同步不同,RPC 可能会被缓冲。任何缓冲的 RPC 都将发送给稍后加入的玩家,如果必须一个接一个地重放动作,这将很有用。例如,加入的客户可以重播某人如何在场景中放置工具以及其他人如何升级它。后者取决于第一个动作。
向新玩家发送缓冲 RPC 需要一些流量,这意味着您的客户必须在进入“实时”游戏之前回放并应用每个操作。这可能很烦人,过多的缓冲可能会破坏弱客户端,因此请谨慎使用缓冲。
注意:RPC 不会立即发送。在这里。
(3)稀有更新和状态(开/关门、地图、角色装备)
非常不频繁的更改通常最好存储在自定义属性中。
与缓冲 RPC 不同,Hashtable 属性仅包含当前键值。这对于门处于“打开”(或未打开)状态非常有用。玩家不关心门之前是如何打开和关闭的。
在上面的 RPC 示例中,有人在场景中放置了一个工具并对其进行了升级。将 RPC 用于一些操作很好。对于大量修改,将当前状态聚合到属性的单个值中可能更容易。多个“+10 防御”升级可以很容易地存储在单个值中,而不是大量的 RPC。
同样,使用自定义属性和使用 RPC 之间的界线并不准确。
自定义属性的另一个好用例是存储房间的“开始时间”。游戏开始时,将 PhotonNetwork.Time 存储为属性。该值对于房间中的所有客户端(大约)是相同的,并且有了开始时间,任何客户端都可以计算出游戏已经运行了多长时间以及现在是第几轮。当然,您也可以存储每个回合的开始时间。如果可以暂停游戏,效果会更好。
四、滞后补偿
1.物理对象的滞后补偿
当您的游戏中有物理对象时,您可能已经注意到这些对象可能会稍微不同步运行 - 特别是当您有两个或多个游戏窗口彼此相邻时。这可能会导致游戏出现一些严重的问题,最终还可能会降低玩家的体验。
这种同步问题是由消息从一个客户端“传输”到另一个客户端所花费的时间引起的。一个例子:客户端 A 将他的角色向前移动并发送他的当前位置。此消息在客户端 A 发送后仅 100 毫秒就被客户端 B 接收到。客户端 B 使用此信息将客户端 A 的角色放置在正确的位置,以使其游戏保持最新状态。由于客户端 A 在最后 100 毫秒内没有停止移动他的角色,他的角色在世界中到达了一个新的位置。在这一刻,对象不再完全同步,因为它在客户端 A 和客户端 B 的游戏中有不同的位置。根据角色的移动速度,两个位置之间的差异会有所不同:如果移动速度相当慢,则差异可能根本不会引人注意 - 但是,如果移动速度非常高,则两个游戏窗口中的差异都清晰可见。由于我们无法完全摆脱这个问题(除非我们使用另一种技术,如 Photon Quantum),我们正试图尽可能地减少这个问题的出现,并引入了一种我们称之为“滞后补偿”的技术。
2.滞后补偿是什么意思,它是如何工作的?
当对我们的物理对象应用滞后补偿时,我们要求对象的所有者发送除对象的位置和旋转之外的其他数据:在这种情况下,我们正在寻找对象的速度。我们不是简单地将接收到的信息应用于远程客户端上的对象,而是使用它们来计算对象的更新和更准确的行为。因此,我们还需要发送和接收消息之间经过的确切时间。由于我们的示例使用自定义 OnPhotonSerializeView 解决方案(见下文),我们将展示如何根据此函数计算经过的时间。
首先我们需要一个空的 OnPhotonSerializeView 实现:
public void OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info) { }
注意:要使用此功能,脚本必须实现 IPunObservable 接口。
由于此函数实现的发送端相对简单,稍后会展示,因此我们将首先看一下接收端,因为大部分工作都将在这里完成。接收客户端必须做的一件事是计算前面提到的发送和接收当前消息之间经过的时间。因此,我们正在使用 PhotonMessageInfo,它包含一个时间戳,它描述了消息发送的时刻。此外,我们使用 PhotonNetwork.Time 来计算当前时间和前面提到的时间戳之间的差异。结果是中间经过的时间。
float lag = Mathf.Abs((float) (PhotonNetwork.Time - info.timestamp));
有了这个值,我们就可以根据从所有者那里收到的信息来计算对象可能是如何移动的。为此,我们有两个不同的选项,如下所述。
3.使用 OnPhotonSerializeView 更新对象
第一个选项只是使用 OnPhotonSerializeView 函数来更新对象。基于我们空的 OnPhotonSerializeView 函数,发送方与其他客户端共享所有必要的信息。在这种情况下,我们发送刚体的位置、旋转及其速度。接收者在计算已经过去的时间之前,将接收到的信息直接存储在对象的刚体组件中,如上所述。然后他将速度乘以他之前计算的经过时间。然后将此计算的结果添加到 Rigidbody 组件的位置。我们现在在远程客户端上有一个更准确的描述对象。为了更好地理解 OnPhotonSerializeView 函数的整个实现如下所示。
public void OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info)
{
if (stream.IsWriting)
{
stream.SendNext(rigidbody.position);
stream.SendNext(rigidbody.rotation);
stream.SendNext(rigidbody.velocity);
}
else
{
rigidbody.position = (Vector3) stream.ReceiveNext();
rigidbody.rotation = (Quaternion) stream.ReceiveNext();
rigidbody.velocity = (Vector3) stream.ReceiveNext();
float lag = Mathf.Abs((float) (PhotonNetwork.Time - info.timestamp));
rigidbody.position += rigidbody.velocity * lag;
}
}
4.使用 OnPhotonSerializeView 和 FixedUpdate 更新对象
除了 OnPhotonSerializeView 实现之外,第二个选项还使用 Unity 的 FixedUpdate 函数。我们再次从空的 OnPhotonSerializeView 函数开始。在这种方法中,发送方具有相同的任务:共享有关刚体位置、旋转及其速度的信息。与以前的方法相比,接收器的任务不同。这次他只将接收到的速度信息存储到对象的 Rigidbody 组件中,然后再计算发送和接收当前消息之间经过的时间。其他信息 - 位置和旋转 - 此时存储在局部变量中。对于此示例,局部变量称为 networkPosition(类型 Vector3)和 networkRotation(类型 Quaternion)。之后,接收器将 Rigidbody 的速度乘以经过的时间,并将此计算结果加到本地存储的 networkPosition 变量中。 OnPhotonSerializeView 函数的完整实现如下所示。
public void OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info)
{
if (stream.IsWriting)
{
stream.SendNext(this.m_Body.position);
stream.SendNext(this.m_Body.rotation);
stream.SendNext(this.m_Body.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 += (this.m_Body.velocity * lag);
}
}
您肯定已经注意到,到目前为止我们还没有对对象应用任何位置或旋转更新。当我们将对象移动到其目标位置并将其旋转到其目标旋转时,这将在下一步中完成。我们在 Unity 的 FixedUpdate 函数中逐步执行此操作,如下所示。
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);
}
}
5.非物理对象的滞后补偿
有时你的游戏中有一些对象没有附加的 Rigidbody 组件,因为它不是必需的。在这种情况下,您将使用它们的 Transform 组件来同步它们。此时您可能还注意到,在两个(或更多)不同屏幕上的同一对象之间存在一些延迟和轻微偏移,尤其是当游戏窗口彼此相邻时。好消息是,我们可以将之前学到的知识用于那些经过一些调整的对象。
首先我们需要一个选项来描述物体的运动。由于我们没有 Rigidbody 组件的速度属性,我们必须使用自定义解决方案。这里一个简单的方法是使用对象的最后两个位置之间的差异。
Vector3 oldPosition = transform.position;
// Handling position updates related to the given input
movement = transform.position - oldPosition;
首先,我们将当前位置存储在一个名为 oldPosition 的临时变量中。之后我们正在处理我们所有的输入并根据此更新对象的位置。最后,我们计算本地存储位置和更新位置之间的差异,它描述了我们对象的移动(类型 Vector3),而且是我们对 Rigidbody 组件的速度属性的“替代”。此代码片段是 Update 函数的一部分。
其余部分与前面的方法基本相同,但这次我们使用的是 Update 函数,而不是 FixedUpdate 函数,我们可以在开头添加以下代码片段。
if (!pView.IsMine)
{
transform.position = Vector3.MoveTowards(transform.position, networkPosition, Time.deltaTime * movementSpeed);
transform.rotation = Quaternion.RotateTowards(transform.rotation, networkRotation, Time.deltaTime * 100);
return;
}
这样,即使我们的对象没有刚体组件,我们也可以使用滞后补偿。
总结:滞后补偿不会帮助您摆脱游戏中可能存在的所有类型的同步问题,但它会帮助您获得更稳定的游戏和更稳定的模拟,总体上同步问题要少得多,这会严重影响玩家的体验。
五、RPCs and RaiseEvent
1.Remote Procedure Calls 远程过程调用
PUN 有别于其他 Photon 软件包的一项功能是支持“远程过程调用”(RPC)。
远程过程调用顾名思义:在同一个房间的远程客户端上进行方法调用。
要为某些方法启用远程调用,您必须应用 [PunRPC] 属性。
[PunRPC]
void ChatMessage(string a, string b)
{
Debug.Log(string.Format("ChatMessage {0} {1}", a, b));
}
要调用标记为 PunRPC 的方法,您需要一个 PhotonView 组件。示例调用:
PhotonView photonView = PhotonView.Get(this);
photonView.RPC("ChatMessage", RpcTarget.All, "jup", "and jup.");
专业提示:如果您的脚本是 MonoBehaviourPun,您可以使用:this.photonView.RPC()。
因此,不是直接调用目标方法,而是在 PhotonView 组件上调用 RPC() 并提供要调用的方法的名称。
(1)方法签名
PhotonView 就像 RPC 的“目标”:所有客户端仅在具有该特定 PhotonView 的联网游戏对象上执行该方法。如果您击中特定对象并调用“ApplyDamage”RPC,则接收客户端会将损坏应用到同一对象。
您可以添加多个参数,前提是 PUN 可以序列化它们(阅读“光子序列化”)。当您这样做时,方法和调用必须具有相同的参数。如果接收客户端找不到匹配的方法,它将记录一个错误。
此规则有一个例外:RPC 方法的最后一个参数可以是 PhotonMessageInfo 类型,它将为每次调用提供上下文。您没有在调用中设置 PhotonMessageInfo。
(2)目标、缓冲和秩序
(3)RPC 名称的快捷方式
(4)RPC 的时间和加载级别
2.RaiseEvent 引发事件
(1)IOnEventCallback 回调
(2)LoadBalancingClient.EventReceived 事件
(3)Raise Event 选项
(4)发送选项