这里是个人学习photon的一些总结,以及学习photon官方文档和对其的部分翻译和整理,都是些个人觉得基础和常用的部分,有什么错误谢谢指出~
pun2官方文档链接https://doc.photonengine.com/zh-cn/pun/current/getting-started/pun-intro
文章目录
连接和鉴权
区域
Photon提供全球范围内的低延迟游戏,client会初始化连接到photon的name server服务器上以获得一个可用的region列表,每个region彼此完全分离,一个region由若干MasterServer 【用于匹配】和 GameServers组成
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-SsKVlFCl-1645156427976)(data:image/gif;base64,R0lGODlhAQABAPABAP///wAAACH5BAEKAAAALAAAAAABAAEAAAICRAEAOw==)]
-
RealTimeApi能够筛选最佳的region并提供连接。当client每次连接时会从Name Server处获取可用区域列表,这用于获取最新区域列表时检查最佳区域是否有变化,在ping通一个servers后,结果会以字符串的形式返回,并进行存储,这个字符串包括当前最佳区域、ping值、当前可用区域列表。当没有旧区域列表结果字符串时会去ping所有region,此操作十分耗时。如果有之前的字符串记录,则会去检查:a.是否区域列表发生变化 b.某个区域ping值是否已无法接受(大于以前ping值的1.5倍);如果这两个条件任一满足,则会去ping所有region并保存一个新结果。可以结合区域筛选器去改变玩家获取的区域列表。
-
PUN会使用unity的playerprefs去自动保存最佳区域的信息,并在下次连接时去再次ping这个region。 为了方便和调试,当前的“最佳区域”及其 ping 显示在 Unity 编辑器的 PhotonServerSettings 中。但是,这仅对 Unity Editor 的播放模式有效。
PhotonNetwork.ConnectUsingSettings() //使用时默认连接最佳区域
- 最佳区域 这个选项不是确定的,有时可能因为相同或者差不多ping值导致随机。可能出现以下情况:1.一个设备对多个region具有相同的ping 2.连接到同一网络上的不同设备对同一region具有不同ping值;从而导致随机。可以通过在线区域白名单去筛选,或者显式选择region
- 从 PUN v2.17 开始,添加了一个名为“Dev Region”的新功能。这是 PhotonServerSettings 中提供的新设置。使用此设置,所有开发版本都将使用相同的区域,避免“最佳区域”选择的初始匹配问题。创建 PhotonServerSettings 并在 Unity 编辑器的第一次运行 (PlayMode) 期间设置“开发区域”时,会自动启用“ Development build”。当您使用连接PhotonNetwork.ConnectUsingSettings()时,“Dev Region”仅在 Unity 编辑器和“ Development”构建中使用。还可以通过简单地删除值来禁用 Unity Editor 和“Development Build”中的“Dev Region”。因此,为避免在开发阶段出现最佳区域选择问题,请确保更新到最新的 PUN 2 版本。在 Unity Editor 中运行一次(进入 PlayMode 并连接)。Unity Editor 的第一个连接将设置“Dev Region”,这可以从 PhotonServerSettings 检查器中看到。 这样,所有客户端(Unity Editor 和builds)都将连接到同一个“开发区域”。
- 每个photon region都有一个令牌,为了使用PhotonNetwork.ConnectToRegion连接到指定region,需要手动设置中的AppId和App Versions等配置(PhotonServerSettings在这种情况下不生效)
PhotonNetwork.ConnectToRegion
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-sZ2lbexs-1645156427977)(data:image/gif;base64,R0lGODlhAQABAPABAP///wAAACH5BAEKAAAALAAAAAABAAEAAAICRAEAOw==)]
Region | Hosted in | Token |
---|---|---|
Asia | Singapore | asia |
Australia | Melbourne | au |
Canada, East | Montreal | cae |
Chinese Mainland1 【需要单独申请,要给photon发送邮件解锁appid 】 | Shanghai | cn |
Europe | Amsterdam | eu |
India | Chennai | in |
Japan | Tokyo | jp |
Russia | Moscow | ru |
Russia, East | Khabarovsk | rue |
South Africa | Johannesburg | za |
South America | Sao Paulo | sa |
South Korea | Seoul | kr |
Turkey | Istanbul | tr |
USA, East | Washington D.C. | us |
USA, West | San José | usw |
- 可以为每个app设置各自的region filter,dashboard-》Manage-》Edit
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3GAxNMNv-1645156427978)(data:image/gif;base64,R0lGODlhAQABAPABAP///wAAACH5BAEKAAAALAAAAAABAAEAAAICRAEAOw==)]
- 中国区Setting设置,可以选择使用PhotonServerSettings或者Code
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-wphjrYfG-1645156427978)(data:image/gif;base64,R0lGODlhAQABAPABAP///wAAACH5BAEKAAAALAAAAAABAAEAAAICRAEAOw==)]
void ConnectToChina(){
AppSettings chinaSettings = new AppSettings();
chinaSettings.UseNameServer = true;
chinaSettings.ServerAddress = "ns.photonengine.cn";
chinaSettings.AppIdRealtime = "ChinaPUNAppId"; // TODO: replace with your own PUN AppId unlocked for China region
chinaSettings.AppVersion = "ChinaAppVersion"; // optional
PhotonNetwork.ConnectUsingSettings(chinaSettings);
}
TCP和UDP
根据 API 的不同,连接到 Photon Cloud 可能需要根据应用程序的协议和目的传递适当的端口。在大多数情况下,不必担心端口号。下表包含每个协议和每个服务器连接要与 Photon Cloud 一起使用的默认端口。
Port Number | Protocol | Purpose |
---|---|---|
5058 or 27000 | UDP | Client to Nameserver (UDP) |
5055 or 27001 | UDP | Client to Master Server (UDP) |
5056 or 27002 | UDP | Client to Game Server (UDP) |
4533 | TCP | Client to Nameserver (TCP) |
4530 | TCP | Client to Master Server (TCP) |
4531 | TCP | Client to Game Server (TCP) |
9090 | TCP | Client to Master Server (WebSockets) |
9091 | TCP | Client to Game Server (WebSockets) |
9093 | TCP | Client to Nameserver (WebSockets) |
19090 | TCP | Client to Master Server (Secure WebSockets) |
19091 | TCP | Client to Game Server (Secure WebSockets) |
19093 | TCP | Client to Nameserver (Secure WebSockets) |
鉴权
STEAM
转到DashBoard Multiplayer Game Development Made Easy | Photon Engine DashBoard -》MANAGE-》Authentication-》STEAM
-
apiKeySecret : Steam 发布者 Web API 密钥。
-
appid : Steam 游戏的 ID。
-
verifyOwnership: 验证用户是否真正拥有(购买了游戏并将其保存在他的库中)该游戏
-
verifyVacBan、 **verifyPubBan:**是否验证用户被ban
-
客户端必须使用Valve’s Steamworks API获取会话ticket用以证明是有效的Steam用户
-
在项目中支持SteamWork.NET Steamworks.NET - Installation
-
获取ticket以及发送
//获取session ticket并转换成UTF8
// hAuthTicket should be saved so you can use it to cancel the ticket as soon as you are done with itpublic string GetSteamAuthTicket(out HAuthTicket hAuthTicket){
byte[] ticketByteArray = new byte[1024];
uint ticketSize;
hAuthTicket = SteamUser.GetAuthSessionTicket(ticketByteArray, ticketByteArray.Length, out ticketSize);
System.Array.Resize(ref ticketByteArray, (int)ticketSize);
StringBuilder sb = new StringBuilder();
for(int i=0; i < ticketSize; i++)
{
sb.AppendFormat("{0:x2}", ticketByteArray[i]);
}
return sb.ToString();}
//发送ticket进行鉴权
PhotonNetwork.AuthValues = new AuthenticationValues();
PhotonNetwork.AuthValues.UserId = SteamUser.GetSteamID().ToString();
PhotonNetwork.AuthValues.AuthType = CustomAuthenticationType.Steam;
PhotonNetwork.AuthValues.AddAuthParameter("ticket", SteamAuthSessionTicket);// connect
- 在鉴权完毕后建议撤销ticket,建议在IConnectionCallbacks.OnConnected、IConnectionCallbacks.OnConnectedToMaster、IConnectionCallbacks.OnConnectedToMaster、IConnectionCallbacks.OnDisconnected任一中进行
private void OnConnected(){
SteamUser.CancelAuthTicket(hAuthTicket);}
- 完整代码
using Photon.Pun;using Photon.Realtime;using Steamworks;
public class MinimalSteamworksPunAuth : MonoBehaviourPunCallbacks{
private HAuthTicket hAuthTicket;
private void Start()
{
if (SteamManager.Initialized)
{
Connect();
}
}
public void Connect()
{
string SteamAuthSessionTicket = GetSteamAuthTicket(out hAuthTicket);
PhotonNetwork.AuthValues = new AuthenticationValues();
PhotonNetwork.AuthValues.UserId = SteamUser.GetSteamID().ToString();
PhotonNetwork.AuthValues.AuthType = CustomAuthenticationType.Steam;
PhotonNetwork.AuthValues.AddAuthParameter("ticket", SteamAuthSessionTicket);
PhotonNetwork.ConnectUsingSettings();
}
public override void OnConnectedToMaster()
{
CancelAuthTicket(hAuthTicket);
}
public override void OnCustomAuthenticationFailed(string errorMessage)
{
CancelAuthTicket(hAuthTicket);
}
// ticket should be saved so you can use it to cancel the ticket as soon as you are done with it
private string GetSteamAuthTicket(out HAuthTicket ticket)
{
byte[] ticketByteArray = new byte[1024];
uint ticketSize;
ticket = SteamUser.GetAuthSessionTicket(ticketByteArray, ticketByteArray.Length, out ticketSize);
System.Array.Resize(ref ticketByteArray, (int)ticketSize);
StringBuilder sb = new StringBuilder();
for(int i=0; i < ticketSize; i++)
{
sb.AppendFormat("{0:x2}", ticketByteArray[i]);
}
return sb.ToString();
}
private void CancelAuthTicket(HAuthTicket ticket)
{
if (ticket != null)
{
SteamUser.CancelAuthTicket(ticket);
}
}}
- 使用 Facepunch.Steamworks
using Photon.Pun;using Photon.Realtime;using Steamworks;using UnityEngine;
public class MinimalSteamworksPunAuth : MonoBehaviourPunCallbacks{
[SerializeField]
private int steamAppId;
private AuthTicket hAuthTicket;
private void Awake()
{
try
{
SteamClient.Init(steamAppId);
}
catch (System.Exception e)
{
// Couldn't init for some reason - it's one of these:
// Steam is closed?
// Can't find steam_api dll?
// Don't have permission to play app?
}
}
private void Start()
{
Connect();
}
public void Connect()
{
string steamAuthSessionTicket = GetSteamAuthTicket(out hAuthTicket);
PhotonNetwork.AuthValues = new AuthenticationValues();
PhotonNetwork.AuthValues.UserId = SteamClient.SteamId.ToString();
PhotonNetwork.AuthValues.AuthType = CustomAuthenticationType.Steam;
PhotonNetwork.AuthValues.AddAuthParameter("ticket", steamAuthSessionTicket);
PhotonNetwork.ConnectUsingSettings();
}
public override void OnConnectedToMaster()
{
CancelAuthTicket(hAuthTicket);
}
public override void OnCustomAuthenticationFailed(string errorMessage)
{
CancelAuthTicket(hAuthTicket);
}
// ticket should be saved so you can use it to cancel the ticket as soon as you are done with it
private string GetSteamAuthTicket(out AuthTicket ticket)
{
ticket = SteamUser.GetAuthSessionTicket();
StringBuilder ticketString = new StringBuilder();
for (int i = 0; i < ticket.Data.Length; i++)
{
ticketString.AppendFormat("{0:x2}", ticket.Data[i]);
}
return ticketString.ToString();
}
private void CancelAuthTicket(AuthTicket ticket)
{
if (ticket != null)
{
ticket.Cancel();
}
}
private void Update()
{
SteamClient.RunCallbacks();
}
private void OnApplicationQuit()
{
SteamClient.Shutdown();
}}
OCULUS
转到DashBoard Multiplayer Game Development Made Easy | Photon Engine DashBoard -》MANAGE-》Authentication-》OCULUS
Oculus通过Oculus ID和客户端生成的nonce值去验证身份,nonce是一个只能使用一次的随机数字。 客户端需要登录 Oculus 然后生成一个随机数。此 nonce 证明客户端是有效的 Oculus 用户。
下载 Oculus Platform SDK for Unity 并将其导入您的项目。从编辑器的菜单栏中,转到“Oculus 平台”->“编辑设置”并输入Oculus AppId。使用以下代码获取登录用户的 Oculus ID 并生成随机数:
using UnityEngine;using Oculus.Platform;using Oculus.Platform.Models;
public class OculusAuth : MonoBehaviour{
private string oculusId;
private void Start()
{
Core.AsyncInitialize().OnComplete(OnInitializationCallback);
}
private void OnInitializationCallback(Message<PlatformInitialize> msg)
{
if (msg.IsError)
{
Debug.LogErrorFormat("Oculus: Error during initialization. Error Message: {0}",
msg.GetError().Message);
}
else
{
Entitlements.IsUserEntitledToApplication().OnComplete(OnIsEntitledCallback);
}
}
private void OnIsEntitledCallback(Message msg)
{
if (msg.IsError)
{
Debug.LogErrorFormat("Oculus: Error verifying the user is entitled to the application. Error Message: {0}",
msg.GetError().Message);
}
else
{
GetLoggedInUser();
}
}
private void GetLoggedInUser()
{
Users.GetLoggedInUser().OnComplete(OnLoggedInUserCallback);
}
private void OnLoggedInUserCallback(Message<User> msg)
{
if (msg.IsError)
{
Debug.LogErrorFormat("Oculus: Error getting logged in user. Error Message: {0}",
msg.GetError().Message);
}
else
{
oculusId = msg.Data.ID.ToString(); // do not use msg.Data.OculusID;
GetUserProof();
}
}
private void GetUserProof()
{
Users.GetUserProof().OnComplete(OnUserProofCallback);
}
private void OnUserProofCallback(Message<UserProof> msg)
{
if (msg.IsError)
{
Debug.LogErrorFormat("Oculus: Error getting user proof. Error Message: {0}",
msg.GetError().Message);
}
else
{
string oculusNonce = msg.Data.Value;
// Photon Authentication can be done here
}
}}
客户端需要发送 Oculus ID 和生成的 nonce 作为查询字符串参数,分别带有“userid”和“nonce”键:
PhotonNetwork.AuthValues = new AuthenticationValues();
PhotonNetwork.AuthValues.UserId = oculusId;
PhotonNetwork.AuthValues.AuthType = CustomAuthenticationType.Oculus;
PhotonNetwork.AuthValues.AddAuthParameter("userid", oculusId);
PhotonNetwork.AuthValues.AddAuthParameter("nonce", oculusNonce);// do not set AuthValues.Token or authentication will fail// connect
大厅和房间
用户id以及好友
-
在 Photon 中,玩家使用唯一的 UserID 来识别。UserID区分大小写。具有相同 UserID 的 Photon 客户端可以连接到同一台服务器,但不能从使用相同 UserID 的两个单独客户端加入同一个 Photon 房间。房间内的每个actor都应该有一个唯一的用户 ID。
-
使用唯一UID的优点:1.可以跨设备保存数据,以及断线重连等;2.便于构建社交系统;3.可以使用另一服务的id(Facebook等)绑定到PhotonUserID上;4.可以通过保留UserID黑名单并使用身份验证来禁止恶意用户的连接;
-
在验证后,photon client会持有相同的UID直到disconnected;可以通过以下三个方式进行UID设置:1.在client connecting之前使用AuthenticationValues.UserId去设置;2.或者在验证成功后会返回一个UID;3.匿名用户将返回被分配一个随机GUID
-
玩家可以在房间内相互分享他们的 UserID。在 C# SDK 中,要启用此功能并使 UserID 对所有人可见,请在创建房间时设置RoomOptions.PublishUserId为true然后,服务器将在每次新加入时广播此信息,可以使用 Player.UserId 访问每个玩家的用户 ID。
-
使用 expectedUsers参数为好友保留房间空位slot( Slot Reservation),可以通过 Room.ExpectedUsers去调整房间的预期玩家列表,要启用Slot Reservation需要开启 房间内UID分享
// create room example
PhotonNetwork.CreateRoom(roomName, roomOptions, typedLobby, expectedUsers);// join room example
PhotonNetwork.JoinRoom(roomName, expectedUsers);// join or create room example
PhotonNetwork.JoinOrCreateRoom(roomName, roomOptions, typedLobby, expectedUsers);// join random room example
PhotonNetwork.JoinRandomRoom(expectedProperties, maxPlayers, expectedUsers, matchmakingType, typedLobby, sqlLobbyFilter, expectedUsers);
- 可以用以下实例进行一个队伍的匹配,队伍的房主进行实际匹配,并为所有成员预留位置
//房主
//尝试找到一个随机房间
PhotonNetwork.JoinRandomRoom(expectedProperties, maxPlayers, expectedUsers, matchmakingType, typedLobby, sqlLobbyFilter, expectedUsers);
//如果没有找到,则创建一个新的
PhotonNetwork.CreateRoom(roomName, roomOptions, typedLobby, teamMembersUserIds);
//成员
//成员不必进行任何匹配,而是轮询房主的房间名字
roomNameWhereTheLeaderIs = PhotonNetwork.FindFriends(new string[1]{
leaderUserId });
//当房主加入一个房间后成员也加入
PhotonNetwork.JoinRoom(roomNameWhereTheLeaderIs);
- 好友也通过UID判别,只有连接同一个 AppID、同一个 Photon Cloud 区域、同一个 Photon AppVersion 的朋友,无论使用什么设备或平台,都能找到彼此。通过以下代码更新好友列表。 Photon 不会保留朋友列表。 游戏中的任何非现有用户都将被视为离线。只有当他/她在进行 FindFriends 查询并 连接到 Photon 时,他/她才被认为是在线的。如果用户在线并加入同名房间,则每个用户将返回一个房间名称。
using System.Collections.Generic;using UnityEngine;using Photon.Realtime;using Photon.Pun;
public class FindFriendsExample : MonoBehaviourPunCallbacks{
public bool FindFriends(string[] friendsUserIds)
{
return PhotonNetwork.FindFriends(friendsUserIds);
}
public override void OnFriendListUpdate(List<FriendInfo> friendsInfo)
{
for(int i=0; i < friendsInfo.Count; i++)
{
FriendInfo friend = friendsInfo[i];
Debug.LogFormat("{0}", friend);
}
}}
匹配指导
使用 Photon 进入房间与某人一起玩(或对抗)非常容易。基本上有三种方法:告诉服务器找到匹配的房间,跟随朋友进入她的房间,或者获取房间列表让用户选择一个。对于大多数游戏来说,最好使用快速简单的匹配,建议使用随机匹配,并可能使用技能、等级等过滤器。
常见问题:
- 验证
AppId
在所有客户端中使用相同。 - 验证客户端是否连接到相同的
Region
. 无论他们使用什么设备或平台,只有连接到同一区域的玩家才能互相玩。 - 确认所有客户端中使用相同的AppVersion。
- 验证玩家是否有不同的唯一用户UID。具有相同 UserID 的玩家不能加入同一个房间。
- 在尝试按名称加入房间之前,请确保已创建此房间。或者使用
JoinOrCreateRoom
. - 如果您尝试加入随机房间,请确保选择创建时使用的相同大厅(名称和类型)。
- 如果您使用房间属性作为过滤器条件进行随机匹配,请确保在创建房间时将这些属性的键设置为从大厅可见。
- 如果您使用 SQL 过滤器进行随机匹配,请确保将保留的过滤属性键设置为从大厅可见。每次随机匹配尝试时放松 SQL 过滤器或使用链式过滤器或在多次尝试失败后的某个时间点创建新房间也很重要。
- 如果您正在实现异步匹配,请确保使用配置正确的 webhook(启用“AsyncJoin”)或使用 AsyncRandomLobby。
快速匹配
只需调用JoinRandomOrCreateRoom
如果找到一个房间,它将被加入,否则将创建一个新房间。
using Photon.Pun;
using Photon.Realtime;
public class QuickMatchExample : MonoBehaviourPunCallbacks
{
private void QuickMatch()
{
PhotonNetwork.JoinRandomOrCreateRoom();
}
public override void OnJoinedRoom()
{
// joined a room successfully
}
}
如果JoinRandomRoom
不能立即找到房间,则创建一个。如果从不显示房间名称(以及为什么要显示),请不要自定义名字。让服务器执行此操作。创建房间时将空字符串设置为“房间名称”。房间有一个独特的 GUID。为“最大玩家数”应用一个值。这样,当房间已满时,服务器最终会停止添加玩家。开始游戏后为阻止新玩家加入,请关闭房间,服务器会停止玩家加入。
using Photon.Pun;
using Photon.Realtime;
public class QuickMatchExample : MonoBehaviourPunCallbacks
{
[SerializeField]
private maxPlayers = 4;
private void CreateRoom()
{
RoomOptions roomOptions = new RoomOptions();
roomOptions.MaxPlayers = maxPlayers;
PhotonNetwork.CreateRoom(null, roomOptions, null);
}
private void QuickMatch()
{
PhotonNetwork.JoinRandomRoom();
}
public override void OnJoinRandomFailed(short returnCode, string message)
{
CreateRoom();
}
public override void OnJoinedRoom()
{
// joined a room successfully
}
}
筛选随机匹配
可以设置任意“自定义房间属性”并将它们用作JoinRandomRoom
请求中的过滤器。
自定义房间属性与房间中的所有玩家同步,可用于跟踪当前地图、游戏模式、难度、回合、回合、开始时间等。它们作为带有字符串键的哈希表处理。
默认情况下,为了保持精简,这些属性只能在房间内访问,不会发送到主服务器(存在大厅)。您可以选择一些自定义房间属性在大厅中公开。这些属性将用作随机匹配的过滤器,并且它们将在大厅中可见。
示例:
using UnityEngine;
using Photon.Pun;
using Photon.Realtime;
using Hashtable = ExitGames.Client.Photon.Hashtable;
public class CreateRoomWithLobbyPropertiesExample : MonoBehaviourPunCallbacks
{
public const string MAP_PROP_KEY = "map";
public const string GAME_MODE_PROP_KEY = "gm";
public const string AI_PROP_KEY = "ai";
private void CreateRoom()
{
RoomOptions roomOptions = new RoomOptions();
roomOptions.CustomRoomPropertiesForLobby = {
MAP_PROP_KEY, GAME_MODE_PROP_KEY, AI_PROP_KEY };
roomOptions.CustomRoomProperties = new Hashtable {
{
MAP_PROP_KEY, 1 }, {
GAME_MODE_PROP_KEY, 0 } };
PhotonNetwork.CreateRoom(null, roomOptions, null);
}
public override void OnCreateRoomFailed(short returnCode, string message)
{
Debug.LogErrorFormat("Room creation failed with error code {0} and error message {1}", returnCode, message);
}
}
请注意,“ai”最初没有任何值。在设置到房间之前,它不会出现在大厅中(通过Room.SetCustomProperties
完成)。当您更改“map”或“gm”或“ai”的值时,它们也将在短暂的延迟大厅中更新,并有。
稍后(创建房间后),你还可以更改大厅可见的房间属性键(添加或删除)(在 C# SDK 中,这是通过 完成的Room.PropertiesListedInLobby
)提示:保持大厅属性列表简短,以确保您的客户端性能不在加入房间或加入大厅时不会加载它们(只有默认类型的大厅会发送房间列表)。
using UnityEngine;
using Photon.Pun;
using Photon.Realtime;
using Hashtable = ExitGames.Client.Photon.Hashtable;
public class CreateRoomWithLobbyPropertiesExample : MonoBehaviourPunCallbacks
{
public const string MAP_PROP_KEY = "map";
public const string GAME_MODE_PROP_KEY = "gm";
public const string AI_PROP_KEY = "ai";
private void CreateRoom()
{
RoomOptions roomOptions = new RoomOptions();
roomOptions.CustomRoomPropertiesForLobby = {
MAP_PROP_KEY, GAME_MODE_PROP_KEY, AI_PROP_KEY };
roomOptions.CustomRoomProperties = new Hashtable {
{
MAP_PROP_KEY, 1 }, {
GAME_MODE_PROP_KEY, 0 } };
PhotonNetwork.CreateRoom(null, roomOptions, null);
}
public override void OnCreateRoomFailed(short returnCode, string message)
{
Debug.LogErrorFormat("Room creation failed with error code {0} and error message {1}", returnCode, message);
}
public override void OnCreatedRoom()
{
}
public override void OnJoinedRoom()
{
// joined a room successfully, CreateRoom leads here on success
}
}
请注意,“ai”最初没有任何值。在设置到房间属性之前,它不会出现在大厅中(在 C# SDK 中,这是通过Room.SetCustomProperties
完成的)。当更改“map”或“gm”或“ai”的值时,它们也将在短暂的延迟后在大厅中更新。
稍后(创建房间后),还可以更改大厅可见的房间属性键(添加或删除)(在 C# SDK 中,这是通过Room.PropertiesListedInLobby
完成的)提示:保持大厅属性列表简短,以确保客户端未加入房间时的性能或加入大厅时不会加载它们(只有默认类型的大厅会发送房间列表)。
当尝试找到一个随机房间时,可以选择选择预期的房间属性或预期的最大玩家数。服务器用这些属性作未过滤器为你选择“合适的”房间。
using UnityEngine;
using Photon.Pun;
using Photon.Realtime;
using Hashtable = ExitGames.Client.Photon.Hashtable;
public class RandomMatchmakingExample : MonoBehaviourPunCallbacks
{
public const string MAP_PROP_KEY = "map";
private void JoinRandomRoom(byte mapCode, byte expectedMaxPlayers)
{
Hashtable expectedCustomRoomProperties = new Hashtable {
{
MAP_PROP_KEY, mapCode } };
PhotonNetwork.JoinRandomRoom(expectedCustomRoomProperties, expectedMaxPlayers);
}
public override void OnJoinRandomFailed(short returnCode, string message)
{
Debug.LogErrorFormat("Join Random Failed with error code {0} and error message {1}", returnCode, message);
// here usually you create a new room
}
public override void OnJoinedRoom()
{
// joined a room successfully, JoinRandomRoom leads here on success
}
}
如果传递更多过滤器属性,则房间匹配它们的机会会更低。可以更好地限制可用选项。
想要邀请好友,可以自定义一个房间名称,然后每个人只需使用JoinOrCreateRoom
进入该房间。
唯一的房间名称可以组成(例如):“friendName1 +friendName2 + randomInteger”。为避免其他人加入,请像这样创建不可见的房间:
using UnityEngine;
using Photon.Pun;
using Photon.Realtime;
public class PrivateRoomExample : MonoBehaviourPunCallbacks
{
public void JoinOrCreatePrivateRoom(string nameEveryFriendKnows)
{
RoomOptions roomOptions = new RoomOptions();
roomOptions.IsVisible = false;
PhotonNetwork.JoinOrCreateRoom(nameEveryFriendKnows, roomOptions, null);
}
public override void OnJoinRoomFailed(short returnCode, string message)
{
Debug.LogErrorFormat("Room creation failed with error code {0} and error message {1}", returnCode, message);
}
public override void OnJoinedRoom()
{
// joined a room successfully, JoinOrCreateRoom leads here on success
}
}
大厅Lobby
Photon 在所谓的“大厅”中组织所有房间。所以所有房间都属于大厅。大厅使用它们的name和type来标识。名称可以是任何字符串,但只有 3 种类型的大厅:Default、SQL、Async
。每个都有适合特定用例的独特功能。
所有应用程序都开始于一个预先存在大厅:Default Lobby。大多数应用程序不需要其他大厅。但是,客户可以即时创建其他大厅。当在操作请求JoinLobby
、CreateRoom
或JoinOrCreateRoom
中定义新的大厅时,大厅开始创建存在。
像房间一样,大厅可以加入和离开。在大厅中,客户端仅在适用时才获得该大厅的房间列表。
当客户加入大厅后并尝试创建(或JoinOrCreate
)房间而没有明确设置大厅时,如果创建成功/发生,则房间将添加到当前加入的大厅。当客户端未加入大厅并尝试创建(或JoinOrCreate
)房间而未明确设置大厅时,如果创建成功/发生,则房间将添加到Default Lobby
。当客户端加入大厅并尝试通过JoinOrCreate
显式设置大厅来创建(或)房间时,如果创建成功/发生:
- 如果大厅name为null或为空:房间将被添加到当前加入的大厅。这意味着当加入自定义大厅时,无法在默认大厅中创建房间。
- 如果大厅name不为null也不为空:房间将被添加到房间创建请求中指定的大厅。
当客户端加入大厅后并尝试加入随机房间而没有明确设置大厅时,服务器将在当前加入的大厅中查找房间。当客户端未加入大厅并尝试在未明确设置大厅的情况下加入随机房间时,服务器将在默认大厅中查找房间。当客户端加入大厅并尝试通过显式设置大厅参数加入随机房间时:
- 如果大厅名称为null或为空:服务器将在当前加入的大厅中查找房间。这意味着当加入自定义大厅时,无法在默认大厅中随机加入房间。
- 如果大厅名称不为null也不为空:服务器将在房间创建请求中指定的大厅中查找房间。
当客户加入大厅并想要切换到另一个大厅时,可以直接调用 JoinLobby
,无需通过显式调用 LeaveLobby
离开第一个。
Default Lobby Type
最适合 随机匹配 的情况,不复杂同时最常见。加入默认大厅类型时,客户端将收到定期的房间列表更新。
当客户端加入默认类型的大厅时,它会立即获得可用房间的初始列表。之后,客户端将收到定期的房间列表更新。
该列表使用两个标准进行排序:打开或关闭、已满或未满。所以列表由三组组成,按以下顺序:
- 第一组:开放且未满(可加入)。
- 第二组:已满但未关闭(不可加入)。
- 第三组:已关闭(不可加入,可能已满或未满)。
在每个组中,每一项排序是完全随机的。
using Photon.Pun;
using System.Collections.Generic;
public class RoomListCachingExample : MonoBehaviourPunCallbacks
{
private TypedLobby customLobby = new TypedLobby("customLobby", LobbyType.Default);
private Dictionary<string, RoomInfo> cachedRoomList = new Dictionary<string, RoomInfo>();
public void JoinLobby()
{
PhotonNetwork.JoinLobby(customLobby);
}
private void UpdateCachedRoomList(List<RoomInfo> roomList)
{
for(int i=0; i<roomList.Count; i++)
{
RoomInfo info = roomList[i];
if (info.RemovedFromList)
{
cachedRoomList.Remove(info.Name);
}
else
{
cachedRoomList[info.Name] = info;
}
}
}
public override void OnJoinedLobby()
{
cachedRoomList.Clear();
}
public override void OnRoomListUpdate(List<RoomInfo> roomList)
{
UpdateCachedRoomList(roomList);
}
public override void OnLeftLobby()
{
cachedRoomList.Clear();
}
public override void OnDisconnected(DisconnectCause cause)
{
cachedRoomList.Clear();
}
}
默认大厅具有一个null
name,它的类型是Default Lobby Type
。在 C# SDK 中,它定义在TypedLobby.Default
中。 默认大厅的name是保留的:只有默认大厅才可以有null
name,所有其他大厅都需要有一个不为null也不为空的name字符串。如果使用字符串为空或 null 作为大厅name,它将指向指定类型的默认大厅。
除非绝对必要,Photon 建议玩家跳过join lobby。如果需要,当希望将房间添加到特定或自定义大厅时,客户端可以在创建新房间时指定大厅。
加入默认类型的大厅将为客户端提供房间列表,但在大多数情况下它没有用:
- 列表条目之间的 ping 没有区别
- 通常玩家都在寻找快速匹配
- 接收房间列表增加了额外的延迟并消耗了流量
- 包含太多信息的长列表可能会对用户体验产生不良影响
相反,为了让玩家更好地控制匹配,请使用过滤器进行随机匹配。多个大厅仍然有效,因为它们也用于(服务器端)随机匹配。可以利用大厅统计信息。
SQL Lobby Type
在 SQL 大厅类型中,字符串过滤器JoinRandomRoom
替换了默认的预设大厅属性。此外,在 SQL 大厅类型中,仅支持一种匹配模式:(FillRoom
默认值,0)。此外,”自定义房间列表“取代了仅存在于默认大厅类型中的自动定期房间列表。
这种大厅类型添加了更精细的匹配过滤,可用于完全由客户端驱动的服务器侧基于技术取向的匹配 skill-based matchmaking。
在内部,SQL 大厅将房间保存在具有多达 10 个特殊“SQL 过滤属性”的 SQLite 表中。这些 SQL 属性的命名固定为:“C0”、“C1”到“C9”。仅允许使用整数类型和字符串类型的值,并且一旦将值分配给特定大厅中的任何列,该列就会锁定为该类型的值。尽管是静态命名,但客户端必须定义大厅中需要哪些值。请注意,当你将 SQL 属性定义为大厅属性或设置它们的值但在 SQL 过滤器中不区分大小写时,这些SQL 属性仍是区分大小写。
在房间创建期间或加入房间后,仍然可以使用 SQL 属性以外的自定义房间属性,对大厅可见或不可见。但是,这些不会用于匹配。
查询可以在JoinRandomRoom
操作中发送。过滤查询基本上是基于 “C0” … “C9” 值的 SQL WHERE 条件。
using UnityEngine;
using Photon.Pun;
using Photon.Realtime;
using Hashtable = ExitGames.Client.Photon.Hashtable;
public class RandomMatchmakingExample : MonoBehaviourPunCallbacks
{
public const string ELO_PROP_KEY = "C0";
public const string MAP_PROP_KEY = "C1";
private TypedLobby sqlLobby = new TypedLobby("customSqlLobby", LobbyType.SqlLobby);
private void CreateRoom()
{
RoomOptions roomOptions = new RoomOptions();
roomOptions.CustomRoomProperties = new Hashtable {
{
ELO_PROP_KEY, 400 }, {
MAP_PROP_KEY, "Map3" } };
roomOptions.CustomRoomPropertiesForLobby = {
ELO_PROP_KEY, MAP_PROP_KEY }; // makes "C0" and "C3" available in the lobby
PhotonNetwork.CreateRoom(null, roomOptions, sqlLobby);
}
private void JoinRandomRoom()
{
string sqlLobbyFilter = "C0 BETWEEN 345 AND 475 AND C3 = 'Map2'";
//string sqlLobbyFilter = "C0 > 345 AND C0 < 475 AND (C3 = 'Map2' OR C3 = \"Map3\")";
//string sqlLobbyFilter = "C0 >= 345 AND C0 <= 475 AND C3 IN ('Map1', 'Map2', 'Map3')";
PhotonNetwork.JoinRandomRoom(null, 0, MatchmakingMode.FillRoom, sqlLobby, sqlLobbyFilter);
}
public override void OnJoinRandomFailed(short returnCode, string message)
{
CreateRoom();
}
public override void OnCreateRoomFailed(short returnCode, string message)
{
Debug.LogErrorFormat("Room creation failed with error code {0} and error message {1}", returnCode, message);
}
public override void OnJoinedRoom()
{
// joined a room successfully, both JoinRandomRoom or CreateRoom lead here on success
}
}
链式过滤器
可以在一次JoinRandomRoom
操作中一次发送最多 3 个由逗号分隔的过滤器。其被称为链式过滤器。Photon服务器将尝试按顺序使用过滤器。如果任何过滤器与房间匹配,将加入房间。否则,将向客户端返回 NoMatchFound 错误。
链式过滤器可以帮助节省配对请求并加快其流程。这将用于skill-based matchmaking中,同时需要在尝试失败后“放宽”过滤器。
可能的过滤器字符串格式:
- 1(最小)过滤器值:(
{filter1}
或{filter1};
) - 2 个过滤器值:(
{filter1};{filter2}
或{filter1};{filter2};
) - 3(最大)过滤器值:(
{filter1};{filter2};{filter3}
或{filter1};{filter2};{filter3};
)
例子:
C0 BETWEEN 345 AND 475
C0 BETWEEN 345 AND 475;C0 BETWEEN 475 AND 575
C0 BETWEEN 345 AND 475;C0 BETWEEN 475 AND 575;C0 >= 575
自定义房间列表
客户端还可以使用类似 SQL 的查询从 SqlLobby 请求自定义房间列表。此方法将返回最多 100 个符合条件的房间。返回的房间是可连接的(即打开而不是满的)和可见的。
using Photon.Pun;
using System.Collections.Generic;
public class GetCustomRoomListExample : MonoBehaviourPunCallbacks
{
private TypedLobby sqlLobby = new TypedLobby("customSqlLobby", LobbyType.SqlLobby);
private void GetCustomRoomList(string sqlLobbyFilter)
{
PhotonNetwork.GetCustomRoomList(sqlLobby, sqlLobbyFilter);
}
public override void OnRoomListUpdate(List<RoomInfo> roomList)
{
// here you get the response, empty list if no rooms found
}
}
基于技能取向的匹配 skill-based matchmaking
可以使用 SQL 类型的大厅来实现自己的基于技能取向的匹配。
首先,每个房间都有一个固定的skill。这个值不应该改变,否则它基本上会使之前匹配的玩家无效。
通常,玩家通过JoinRandomRoom
加入房间,过滤器基于用户的skill。客户端可以轻松过滤“skill +/- X”的房间。
JoinRandomRoom
会像往常一样得到响应,但如果没有立即找到匹配项,客户端应等待几秒钟,然后重试。可以根据需要执行任意数量的请求。如果使用 SQL 大厅类型,则可以使用链式过滤器。最重要的是:随着时间的推移,客户端应该放宽过滤规则。
一段时间之后放宽过滤器很重要。而且:一个房间可能会被一个skill不太合适的玩家加入,但显然没有其他房间更合适。
还可以定义最大偏差和超时。如果没有找到房间,该客户端必须使用该用户的skill打开一个新房间。然后它必须等待其他人做同样的事情。
显然,当可用房间很少时,此工作流程可能需要一些时间。可以通过查看“应用程序统计信息”来进行优化,该信息会告诉你有多少房间可用。可以调整“少于 100 个房间”的过滤器和时间,并为“100 到 1000 个房间”使用不同的设置,以此类推。
排除的SQL关键字
SQL 过滤器不接受以下关键字:ALTER、CREATE、DELETE、DROP、EXEC、EXECUTE、INSERT、INSERT INTO、MERGE、SELECT、UPDATE、UNION、UNION ALL
如果在 SQL 过滤器字符串中使用这些词中的任何一个,相应的操作将失败。
Asynchronous Random Lobby Type
此大厅与默认大厅类型相似,但有两个主要区别:
- 房间项在从游戏服务器中删除后会在大厅列表中保留一小时(可用于匹配)。房间需要可见且开放,才能在异步匹配中考虑。
- 房间列表不会发送给客户端。
大厅类型对比
大堂类型 | 定期房间列表更新 | SQL 过滤器 | 最大玩家数过滤器 | 自定义房间属性过滤器 | 配对模式 | 删除的房间条目 TTL(分钟) |
---|---|---|---|---|---|---|
默认 | √ | × | √ | √ | √ | 0 |
SQL | × | √ | × | × | × | 0 |
异步 | × | × | √ | √ | √ | 60 |
低CCU大厅
对于真正好的匹配,一个游戏需要几百名在线玩家。随着在线玩家的减少,找到一个有价值的对手将变得更加困难,并且在某些时候接受几乎任何match都是有意义的。
当在客户端构建更精细的匹配时,你必须考虑到这一点。为此,Photon Master Server 提供连接的用户、房间和玩家(在一个房间内)的计数
,因此你可以在运行时调整客户端驱动的匹配策略。
房间数量应该是游戏当前活跃程度的一个很好的通用指标。显然,你还可以根据房间内有多少玩家来微调匹配。不在房间里的人可能正在寻找一个。
例如,你可以将低 CCU 情况定义为少于 20 个房间。因此,如果房间数低于 20,你的客户将不使用过滤,而是运行快速匹配例程。
大厅早期测试
如果在开发阶段早期测试匹配并尝试同时从两个客户端加入随机房间,则两个客户端有可能最终进入不同的房间:发生这种情况是因为加入随机房间时不会为两者返回同一个房间而是每个人都可能因为没有找到而创建一个新房间。为避免这种情况,请使用 JoinRandomOrCreateRoom
而不是 JoinRandomRoom
然后 CreateRoom
。否则,可能的解决方法(仅用于开发目的)是在尝试加入房间或重试之前(或之后)添加随机延迟。你还可以侦听应用程序或大厅统计信息,以确保至少存在或已创建房间。
其它匹配选项
如果你想开发自己的匹配系统,请确保其中大部分是在服务器端完成的。由于从服务器到客户端的房间列表更新频率很低(~1…2 秒),客户端没有关于房间满员情况的完美信息。
当你有成千上万的玩家时,有几个玩家会同时发送他们的“加入”请求。如果一个房间很快就满了,你的玩家将经常无法加入房间,匹配会花费越来越长的时间。
另一方面,服务器可以完美地分配玩家,更喜欢几乎满员的房间并采用您的过滤。
你可以做的是通过基于 HTTP 的 Web 服务在 Photon 外部进行配对,然后使用 Photon 创建和加入房间(或一次调用JoinOrCreate
)。这种匹配服务可以使用(结合)Photon 的“本地 HTTP 模块”(自定义身份验证/WebRPC/WebHooks),甚至可以使用自定义插件来向你的 Web 服务报告房间可用性。匹配中需要考虑的事项(关键字):Region、AppVersion、AppId、UserId、RoomName/GameId、Auth Cookie(自定义身份验证)、URL 标签(WebHooks)等。
另一种选择是修改 LoadBalancing 服务器应用程序,特别是 MasterServer,即匹配部分。当然,此选项仅适用于自托管。
也就是说,在实际游戏开始之前将房间用作“大厅”或“配对场所”,在大多数情况下对于流行游戏来说并不是一个好主意。
大厅限制
Photon 具有以下与大厅相关的默认限制:
- 每个应用程序的最大大厅数量:10000。
- GameList 事件中房间列表条目的最大数量(加入默认类型大厅时的初始列表):500。
- GameListUpdate 事件中更新房间条目的最大数量(当加入默认类型的大厅时):500。此限制不考虑已删除的房间条目(对应于不再可见或完全消失的房间)。
- GetGameList 操作响应(SQL Lobby)中房间列表条目的最大数量:100。
提示:
在Lobbies v2 中,仅发送给加入相同默认类型大厅的客户端的初始/更新房间条目数,在 GameList 或 GameListUpdate 事件中限制为 500。对于 GameList 事件,客户端接收到的数组的长度将不超过 500。对于 GameListUpdate 事件,客户端接收到的数组的长度可能超过 500,因为我们不限制删除的房间条目的数量(也在这里发送),只限制更新的条目。
Lobbies v2 中的新限制不会影响服务器端的任何其他内容:房间仍然存在于大厅中。出于带宽原因,仅限制其向加入大厅的客户广播的内容,并且不会影响可能在规格上受到限制的客户。而且 500 是一个很大的数字,没有玩家会滚动查看完整列表。此外,理论上,在大厅停留足够长的客户端最终可能会在服务器上获得可用房间的完整列表,因为服务器会将所有更新添加到队列中并以最大长度 为500 批量发送它们。当然,如果大厅里有一个房间并且一段时间内没有改变,客户可能会错过它。
所以在服务器上,每个大厅的房间数量没有限制。FindFriends、CreateRoom、JoinRoom 或 JoinOrCreateRoom 不受迁移到 Lobbies v2 的影响并且不受限制,这意味着客户可以无限制地创建房间或加入或在大厅列表更新中未发送给客户端的房间中寻找朋友。
应用程序和大厅数据统计
Photon 服务器可以向客户端广播应用程序和大厅统计数据。你可以利用这些数据来实现复杂的自定义匹配系统。
应用统计
当连接到 Photon 主服务器时,Photon 客户端会接收应用程序统计信息。无论客户端是否加入大厅,它都会收到 AppStats 事件。
应用统计数据如下:
- 活跃游戏房间数量:
PhotonNetwork.CountOfRooms
- 未加入房间的玩家数量:
PhotonNetwork.CountOfPlayersOnMaster
- 房间内玩家人数:
PhotonNetwork.CountOfPlayersInRooms
- 已连接玩家总数:
PhotonNetwork.CountOfPlayers
AppStats 事件每五秒发送一次给客户端。
大厅统计
如果游戏使用多个大厅并且您想要显示活跃情况,则大厅统计信息会很有用。大厅统计数据按地区计算。
每个类型的大厅(name + type),你可以获得以下信息:
- 活跃游戏房间数量
- 加入大厅或加入大厅房间的玩家总数
自动获取大厅统计信息
一旦客户端通过主服务器的身份验证,就会发送大厅统计事件。然后每分钟发送一次。默认情况下禁用大厅统计事件。
在 PhotonServerSettings 中,选中“Enable Lobby Stats”以从服务器获取大厅统计信息。
如果想在连接之前从代码中执行此操作: PhotonNetwork.PhotonServerSettings.EnableLobbySatistic
这将在你使用PhotonNetwork.ConnectUsingSettings()时生效,因为这是实际使用 ScriptableObject ServerSettings 的唯一连接方法。如果需要使用其他连接方法,那么应该在连接之前使用PhotonNetwork.NetworkingClient.EnableLobbyStatistics = true;
。
从ILobbyCallbacks.OnLobbyStatisticsUpdate
回调中获取对更新 UI 有用的统计信息。
GamePlay
实例化Instantiate
大多数多人游戏需要创建和同步一些 GameObjects。也许它是应该出现在房间内的所有客户端的一个角色、一些单位或怪物。PUN 提供了一种方便的方法来做到这一点。
与 Unity 中的往常一样,Instantiate
和Destroy
用于管理 GameObjects 的生命周期。PUN 2 可以使用一个池来创建(和返回)它们。每个联网的游戏对象都必须有一个 PhotonView
组件(和一个 ViewID
)作为网络标识符。
PhotonNetwork.Instantiate
要创建网络游戏对象,请使用PhotonNetwork.Instantiate
而不是Unity 的Object.Instantiate
房间中的任何客户端都可以调用它来创建对象。
PhotonNetwork.Instantiate("MyPrefabName", new Vector3(0, 0, 0), Quaternion.identity, 0);
函数的第一个参数PhotonNetwork.Instantiate
是一个字符串,它定义了要实例化的“预制件”。在内部,PUN 将从PhotonNetwork.PrefabPool
中获取 GameObject ,设置并启用它。必须设置创建对象的位置和旋转。稍后加入的玩家将首先在此位置实例化对象,即使它已经移动。
任何预制件都必须有一个PhotonView
组件。这包含一个 ViewID
(网络消息的标识符),谁拥有该对象,哪些脚本将写入和读取网络更新(“observed”列表)以及这些更新如何发送(“Observe option”)。检查inspector以通过编辑器设置 PhotonView。一个网络对象可能包含多个 PhotonView,但出于性能原因,建议只使用一个。
默认情况下,PUN 实例化使用 DefaultPool,它从“Resources”文件夹加载预制件并在稍后销毁游戏对象。更复杂的IPunPrefabPool
实现可以在Destroy
中将对象返回到池中并在Instantiate
中重用. 在这种情况下,GameObjects 并没有真正在 Instantiate 中创建,这意味着在这种情况下 Unity 不会调用Start()
。因此,网络游戏对象上的脚本应该只实现OnEnable()
和OnDisable()
.
要在实例化游戏对象时设置它们,你还可以通过IPunInstantiateMagicCallback
在脚本中实现。PUN 将检查接口是否在组件上实现,并在实例开始使用时调用OnPhotonInstantiate(PhotonMessageInfo info)
。该PhotonMessageInfo
参数包含谁实例化了 GameObject 以及何时实例化等信息。
注意:查找IPunInstantiateMagicCallback
接口实现是一项代价高昂的操作,因此 PUN 缓存哪些预制件没有使用该接口,并在再次使用此预制件时跳过此查找。
例如,可以将实例化的 GameObject 设置为玩家的Tag
对象:
void OnPhotonInstantiate(PhotonMessageInfo info)
{
// e.g. store this gameobject as this player's charater in Player.TagObject
info.sender.TagObject = this.GameObject;
}
在内部,PhotonNetwork.Instantiate
在服务器上存储一个事件,供以后加入的玩家使用。
Networked Objects生命周期
默认情况下,只要创建者在房间里,PhotonNetwork.Instantiate
创建的游戏对象就会存在。当你交换房间时,对象不会转移,就像在 Unity 中切换场景时一样。
当客户端离开房间时,其余玩家将销毁离开玩家创建的游戏对象。如果这不符合你的游戏逻辑,你可以禁用它:在创建房间时将RoomOptions.CleanupCacheOnLeave
设置为 false。
主客户端可以使用PhotonNetwork.InstantiateRoomObject()
创建生命周期与房间一样的对象。注意:该对象不与主客户端相关联,而是与房间相关联。默认情况下,主客户端控制这些对象,但你可以使用photonView.TransferOwnership()
传递控制。
还可以手动销毁网络对象。
Networked Scene Objects网络场景对象
将 PhotonViews 放置在scene中的对象上是非常好的。默认情况下,它们将由主客户端控制,并且对于有一个“中性”对象来发送与房间相关的 RPC 很有用。
重要提示:当你在进入房间之前加载带有网络对象的场景时,某些 PhotonView 值还没有用。例如:当你不在房间时,无法在Awake()
中check IsMine
。
切换场景
加载场景时,Unity 通常会销毁当前层次结构中的所有游戏对象。这包括网络对象。
示例:在菜单场景中,你加入一个房间并加载另一个房间。你实际上可能有点太早到达房间并收到房间的初始消息。PUN 开始实例化联网对象,但你的逻辑加载了另一个场景并且它们消失了。
为避免加载场景出现问题,您可以设置PhotonNetwork.AutomaticallySyncScene
为 true 并使用PhotonNetwork.LoadLevel()
来切换场景。
自定义实例化数据
可以在实例化调用时发送一些初始自定义数据。只需使用方法PhotonNetwork.Instantiate*
中的最后一个参数即可。
这有两个主要优点:
- 通过避免额外消息来节省流量:我们不必使用单独的 RPC 或 RaiseEvent 调用来同步此类信息
- 时间:在预制实例化时数据交互可用,这可能有助于进行一些初始化
实例化数据是 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;
// ...
}
PhotonNetwork.Destroy
通常,当你离开房间时,游戏对象会自动销毁。但是,如果你已连接并加入一个房间,并且你想“通过网络破坏”使用 PhotonNetwork.Instantiate
调用创建的游戏对象,请使用 PhotonNetwork.Destroy
。这包括:
- 从服务器上的房间中删除缓存的实例化事件。
- 删除为要销毁的 GameObject 层次结构中的 PhotonView 缓冲的 RPC。
- 向其他客户端发送消息以删除游戏对象(受网络延迟的影响)。
要成功销毁 GameObject 其必须具备以下条件:
- GameObject 在运行时使用
PhotonNetwork.Instantiate*
方法调用进行实例化。 - 如果客户端加入在线房间,则游戏对象的 PhotonView 必须由同一客户端拥有或控制。
PhotonNetwork.InstantiateRoomObject
创建的游戏对象只能由主客户端销毁。 - 如果客户端未加入房间或加入离线房间,则游戏对象可以在本地销毁。
使用预制件池
默认情况下,PUN 使用简单的DefaultPool
去实例化和销毁游戏对象。这使用Resources 文件夹来加载预制件,它不会收集被销毁的对象(以简化)。如果其中任何一个对你的游戏性能产生负面影响,那么是时候设置自定义 PrefabPool 了。
自定义池类必须实现IPunPrefabPool
接口的两个方法:
GameObject Instantiate(string prefabId, Vector3 position, Quaternion rotation)
获取预制件的实例。它必须返回一个valid、disabled而且带有 PhotonView 的游戏对象。
Destroy(GameObject gameObject)
被调用以销毁(或仅返回)预制件的实例。这个GameObject 已经被禁用,并且池可能会重置并缓存它以供以后在 Instantiate 中使用。
注意:当使用自定义IPunPrefabPool
时,PhotonNetwork.Instantiate
可能不会创建 GameObject 并且(例如)Start()
不会调用。使用OnEnable()
并OnDisable()
相应地禁用任何可能仍以其他方式运行的物理或其他组件。
手动实例化
如果你不想使用 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一节。在此示例中,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
代码替换为您的资产包中的预制件。
ViewID 限制
大多数游戏永远不会需要每个玩家拥有超过几个 PhotonView;一两个角色,通常就是这样。如果你需要更多,你可能做错了什么。例如,网络实例化并为你的武器发射的每个子弹分配一个 PhotonView 是非常低效的,应该使用玩家或武器的 PhotonView 通过 RPC 跟踪发射的子弹。
默认情况下,PUN 最多支持每个玩家 999 ( PhotonNetwork.MAX_VIEW_IDS - 1
) 个 PhotonViews,玩家的最大理论数量等于 2,147,483。你可以轻松地为每个玩家提供更多 PhotonView。
示例:
PhotonViews 为每条网络消息发送一个 viewID。这个viewID是一个整数,由actor number和玩家的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 之间。
- actor编号 1 的对象的 viewID 介于 1001 和 1999 之间。
- actor x 的对象的 viewID 介于
x * MAX_VIEW_IDS + 1
和之间(x + 1) * MAX_VIEW_IDS - 1
。 - actpr编号 2,147,483 的对象的 viewID 介于 2,147,483,001 和 2,147,483,647 之间。自从我们达到
int.MaxValue
限制以来,只有 647 个可能的值。
ViewID 在分配后被保留,在释放后被回收。这意味着在网络销毁其对应的网络游戏对象后,可以重用 viewID。
所有权和控制权Ownership & Control
在 PUN 中,网络对象是使用PhotonView
组件建立的。每个PhotonView
都有一个创建者(实例化者)、所有者和控制器。在本文档中,我们将发现关于PhotonView
的控制权和所有权的定义和概念。我们还将列出不同情况下的预期行为以及如何显式更改PhotonView
的所有权。
定义
Actor
Actor 代表房间里的客户端。加入房间时,每个Actor 都会被逐步分配一个新编号。第一个加入房间的客户端是 1 号Actor ,第二个是 2 号Actor ,以此类推。Actor 可以是消息的目标,每个房间有一个 Actor 被分配为主客户端。Creator、Owner 和 Controller 都是对Actor 的引用。
PhotonView Creator
一个PhotonView
或“实例化器”(或“生成器”)的创建者是调用PhotonNetwork.Instantiate
的Actor. 该Actor编号是PhotonView.ViewID
的一部分,并由 ViewID / PhotonNetwork.MAX VIEW IDS 确定。PhotonView
的 ViewID不会改变,因此创建者 ID 也不会改变。在“网络房间对象room object”的情况下,没有创建者(null/0),因此网络对象不是由Actor创建的,而是与房间相关联。
PhotonView Owner
PhotonView
的Owner是PhotonView
的默认控制器。
在“联网房间对象room object”的情况下,没有所有者(空),因为该对象由房间拥有,而不是由Actor拥有。如果有Owner,如果后者是active的,那它也是控制器。否则,主客户端拥有控制权。
PhotonView Controller
控制PhotonView
的Actor。
Owner始终是Controller,除非:
- Owner为空;那么主客户端就是控制器。
- Owner与房间断开连接;如果 PlayerTTL 大于 0,则Actor有可能暂时离开房间并稍后重新加入。当Owner软断开时,主客户端成为控制器,当Owner重新加入时,Owner恢复控制权。
photonView.IsMine
验证PhotonNetwork.LocalPlayer
是否是 photonView
的当前Controller。
Networked Object网络对象
网络对象是具有PhotonView
组件的游戏对象,包括其子对象。所以一个网络对象由它的根PhotonView
组件表示,即PhotonView
附加到的最上层(根)游戏对象。
Nested Networked Object嵌套网络对象
如果网络对象在其层次结构中有子 GameObject 并且其中一个或多个具有PhotonView
附加对象,则它被视为嵌套网络对象。因此,嵌套网络对象是一个网络对象,它是另一个网络对象的层次结构的一部分。通常,当你实例化具有嵌套网络对象的网络对象时,所有PhotonView
将共享相同的实例化 ID(最上层PhotonView
的 ViewID ),除非所有者或控制器不同,或者重新设置父对象,否则它们将具有相同的生命周期。
Room Object房间对象
联网房间对象是不属于Actor的联网对象,但它是属于房间的“全局联网对象”。它没有所有者(null),也没有控制器(null),它不是PhotonNetwork.Instantiate
调用的结果(但可能是PhotonNetwork.InstantiateRoomObject
的结果)。
Scene Object场景对象
场景对象是未在运行时使用PhotonNetwork.InstantiateRoomObject
的Room Object. 它是Unity 场景编译时的一部分。
Soft Disconnect软断开
软断开连接是指Actor在房间中变得不活跃。如果 PlayerTTL 不等于 0 并且:
- 客户端断开连接
- 客户端暂时离开房间,并计划回来
在 PlayerTTL 到期之前,Actor 保持非活动状态。如果PlayerTTL < 0
或PlayerTTL == int.MaxValue
Actor 可以永远保持不活动状态。
你可以使用PhotonNetwork.ReconnectAndRejoin()
恢复意料之外的软断开,或者使用PhotonNetwork.RejoinRoom
重新加入相同的房间. 在这两种情况下,客户端都需要保持相同的 UserId,并且Actor 在重新加入时回收相同的Actor 编号和所有先前的Actor 属性。
硬断开
硬断开连接是指从房间的Actor 列表中完全删除Actor 。
如果PhotonNetwork.CurrentRoom.PlayerTtl == 0
:
- 客户端与 Photon Server 断开连接
- 客户端离开房间
如果PhotonNetwork.CurrentRoom.PlayerTtl != 0
:
- 客户端永远离开房间
- 非活跃Actor 的 PlayerTTL 过期
自动控制转换
当软断开时
在软断开的本地客户端上:
- 如果
PhotonNetwork.CurrentRoom.AutoCleanUp == true
:- unparent所有来自运行时实例化的场景对象(以防止它们被破坏)。
- 任何不是由该Actor创建的嵌套网络对象在销毁之前都与父/根对象unparent。
- 断开连接的Actor创建的所有运行时实例化的网络对象都被销毁。
- 其他联网对象被重置为默认值。
- 如果
PhotonNetwork.CurrentRoom.AutoCleanUp == false
:- 没有什么变化。你需要进行手动清理。
在远程客户端上——如果有的话——:
- Actor软断开连接的之前拥有的
PhotonView
的所有权不变。 - 主客户端成为软断开连接的Actor所拥有的
PhotonView
的控制器。
当硬断开连接时
在硬断开的本地客户端上:
- 如果
PhotonNetwork.CurrentRoom.AutoCleanUp == true
:- 从断连Actor实例化的网络对象中unparent所有嵌套网络场景对象(以防止它们被破坏)。
- 断开连接的Actor创建的运行时实例化网络对象将被销毁。
- 从中继服务器中删除缓存的实例化事件和缓冲的 RPC。
- 场景对象被重置。
- 如果
PhotonNetwork.CurrentRoom.AutoCleanUp == false
:- 没有什么变化。您需要进行手动清理。
在远程客户端上——如果有的话——:
- 从断连Actor实例化的网络对象中unparent所有嵌套网络场景对象(以防止它们被破坏)。
- 断开连接的Actor创建的运行时实例化网络对象将被销毁。
- 重置先前硬断开的Actor拥有的剩余
PhotonView
的所有权(所有者变为空) 。那些成为“孤儿”网络对象。 - 主客户端成为这些“孤儿”
PhotonView
的控制器,因为当 owner == null 时主客户端始终是控制器。
On Rejoin重新加入时
Player.HasRejoined == true
如果客户端重新加入一个非空房间:
- 重新获得对同一Actor拥有的联网对象的控制权。
- [可选] 主客户端将为所有owner!=creator的网络对象重新发送 OwnershipUpdate。
如果客户端重新加入仍在服务器上但为空的房间(EmptyRoomTTL 尚未过期):
- 重新获得对同一Actor拥有的联网对象的控制权。
- 由于它是主客户端,它还控制所有其他联网对象。
如果客户端通过“resurrecting it”(从外部源重新加载其状态)重新加入房间:
- 重新获得对同一Actor拥有的联网对象的控制权。
- 由于它是主客户端,它还控制所有其他联网对象。
新加入时
Player.HasRejoined == false
如果客户端加入一个非空房间:
- 没什么特别的。
如果客户端加入的房间仍在服务器上但为空(EmptyRoomTTL 尚未过期):
- 重新获得对同一Actor拥有的联网对象的控制权。
- 由于它是主客户端,它还控制所有其他联网对象。
异步加入:如果客户端通过“resurrecting it”(从外部源重新加载其状态)加入房间:
- 重新获得对同一Actor拥有的联网对象的控制权。
- 由于它是主客户端,它还控制所有其他联网对象。
On Master Client Change主客户端变更
新主客户端成为以下对象的控制器:
- “孤儿”网络对象:没有所有者的网络对象。
- 具有非活跃Owner的网络对象。
注意:如果前一个主客户端软断开连接,它会保留它声明拥有的任何联网房间对象room object的所有权。
显式所有权转让
你可以通过其各自的 root PhotonView
显式更改网络对象的所有者。默认情况下,联网对象具有固定所有权,但你可以更改此设置以允许直接或通过请求进行所有权转移。所有权Ownership 更改通常意味着控制器controller 更改,除非新所有者处于非活跃状态,否则它将控制联网对象。
所有权转让选项
PhotonView
的所有权转移行为是通过设置PhotonView.OwnershipTransfer
的OwnershipOption
实现的。 PhotonView.OwnershipTransfer
不通过网络同步,一旦PhotonView
实例化就不应更改。共有三种所有权转让选项Fixed
:Request
和Takeover
。让我们分别了解更多关于每一个的信息。
Fixed
所有权是固定的。对于房间对象,没有所有者,但主客户端是控制器。对于玩家对象,创建者始终是所有者。这是默认值。
Request
当OwnershipTransfer
相应选项设置为Request
时,任何Actor都可以从其当前所有者(或控制器)请求PhotonView
的所有权。
在这种情况下,这是一个两步过程:首先,Actor向PhotonView
的所有者发送请求,然后,如果后者接受所有者的所有权转移请求,发出请求请求Actor将成为新所有者。请求通过PhotonView.RequestOwnership()
完成。
这会触发当前所有者的回调IPunOwnershipCallbacks.OnOwnershipRequest(PhotonView targetView, Player requestingPlayer)
,开发人员必须调用该方法targetView.TransferOwnership(requestingPlayer)
来执行实际的所有权更改。这允许开发人员在代码中确定是否应接受该请求。
仅允许联网对象的当前所有者owner 或当前控制者controller 接受所有权转移请求。
Takeover接管
任何Actor都可以更改任何OwnershipTransfer
设置Takeover
的PhotonView
的所有权。
此选项旨在在未经当前所有者同意的情况下直接声明对PhotonView
的所有权,甚至将PhotonView
归于其他人。在这种情况下,要接管所有权,只需调用PhotonView.TransferOwnership(Player newOwner)
. 请注意,你还可以将所有权更改为不同的Actor,这意味着Actor X 可以将设置为Takeover
的PhotonView
集合的所有者从Actor Y 更改为Actor Z。
如果在OwnershipTransfer
被设置Takeover
的PhotonView
上调用PhotonView.RequestOwnership()
,则该请求将被自动接受(除非之前有其他人接管),而无需任何回调处理。但是,在所有权选项为Takeover
的情况下,建议直接调用PhotonView.TransferOwnership(Player newOwner)
Renouncing Ownership放弃所有权
除非PhotonView
’的所有权是Fixed并且不打算更改,否则任何Actor都可以将自己的PhotonView
的所有权转让给任何其他活跃的Actor。使用PhotonView.TransferOwnership(Player newOwner)
.
PhotonView 回调
所有权变更回调
每当PhotonView
的所有者发生更改时,IOnPhotonViewOwnerChange.OnOwnerChange(Player newOwner, Player previousOwner)
都会在实现它并在相同的PhotonView
注册的类上触发。
实现IOnPhotonViewOwnerChange
接口的类需要使用注册PhotonView.AddCallbackTarget
和注销PhotonView.RemoveCallbackTarget
。
显式所有权转移回调
IPunOwnershipCallbacks
接口中有两个所有权变更回调:
OnOwnershipRequest(PhotonView targetView, Player requestingPlayer)
当有人向 targetView 请求所有权时。OnOwnershipTransfered(PhotonView targetView, Player previousOwner)
每当所有者更改 targetView 时。
实现IPunOwnershipCallbacks
接口的类需要使用注册PhotonNetwork.AddCallbackTarget
和注销PhotonNetwork.RemoveCallbackTarget
。
控制变更回调
每当PhotonView
的控制器发生更改时,IOnPhotonViewControllerChange.OnControllerChange(Player newController, Player newController)
都会在实现它并在相同的PhotonView
注册的类上触发。
实现IOnPhotonViewControllerChange
接口的类需要使用注册PhotonView.AddCallbackTarget
和注销PhotonView.RemoveCallbackTarget
。
网络销毁回调
有时你希望在网络对象即将被销毁时得到通知。对此有一个回调,它也会在同一网络对象的所有 PhotonView
上触发:根PhotonView
及其所有嵌套对象(如果有)。
无论何时PhotonNetwork.Destroy
被调用,并且在完成网络销毁之前,IOnPhotonViewPreNetDestroy.OnPreNetDestroy(PhotonView rootView)
都会在实现它并在相同的PhotonView
注册的类上触发。
实现IOnPhotonViewPreNetDestroy
接口的类需要使用注册PhotonView.AddCallbackTarget
和注销PhotonView.RemoveCallbackTarget
。
同步和状态
游戏都是关于更新其他玩家并保持相同的状态。你想知道其他玩家是谁、他们做什么、他们在哪里以及他们的游戏世界是什么样的。
PUN(和一般的 Photon)提供了几种用于更新和保持状态的工具。此页面将解释选项以及何时使用每个选项。
对象同步Object Synchronization
使用 PUN,你可以轻松地使某些游戏对象被“网络感知”。为一个可以将位置、旋转和其他值与其远程副本同步的对象分配一个 PhotonView 组件。PhotonView 必须设置为“observe”一个组件,如 Transform 或(更常见的)它的一个脚本。
同步数据有四种不同的选项:
- Off : 不发生同步,不发送或接收任何内容
- Reliable Delta Compressed:保证通过内部优化机制接收数据,如果数据没有变化,则发送 null。为此,请确保使用不同的
SendNext
调用填充流。 - Unreliable:按顺序接收数据,但可能会丢失一些更新。这意味着在丢失的情况下不会延迟。
- Unreliable OnChange:按顺序接收数据,但可能会丢失一些更新。如果更新重复了上一条信息,PhotonView 将暂停发送更新,直到下一次更改。
一些脚本实现OnPhotonSerializeView()
并成为 PhotonView 的观察组件。在OnPhotonSerializeView()
中,位置和其他值被写入流并被从中读取。要使用此功能,脚本必须实现IPunObservable
接口。
public void OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info)
{
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();
}
}
远程过程调用 (RPC)
你可以将你的方法标记为可供房间中的任何客户端调用。如果你使用属性[PunRPC]
实现 ‘ChangeColorToRed()’ ,远程玩家可以: 通过调用 photonView.RPC("ChangeColorToRed", RpcTarget.All);
将游戏对象的颜色更改为红色。
调用始终以 GameObject 上的特定 PhotonView 为目标。因此,当调用“ChangeColorToRed()”时,它只会在具有该 PhotonView 的 GameObject 上执行。当想要影响特定对象时,这很有用。
当然,对于没有真正目标的方法,可以将一个空的 GameObject 作为“虚拟”放入场景中。例如,您可以通过RPC实现聊天功能,但这与特定的 GameObject 无关。
RPC 可以被“缓冲”。服务器将记住该调用并将其发送给在调用 RPC 后加入的任何人。这使您能够存储一些操作并实现PhotonNetwork.Instantiate()
。一个缺点是,缓冲区可能会不断增长。
自定义属性
Photon 的自定义属性由一个键值哈希表组成,你可以按需填写。这些值在客户端上同步和缓存,因此你不必在使用前获取它们。更改由SetCustomProperties()
推送给其他人。
这有什么用?通常,房间和玩家有一些与游戏对象无关的属性:当前地图或玩家角色的颜色(如:2d 跳跃和奔跑)。这些可以通过对象同步或 RPC 发送,但使用自定义属性通常更方便。
要为Player设置自定义属性,请使用Player.SetCustomProperties(Hashtable propsToSet)
并包含要添加或更新的键值。本地玩家对象的快捷访问方式是:PhotonNetwork.LocalPlayer
. 同样,用PhotonNetwork.CurrentRoom.SetCustomProperties(Hashtable propsToSet)
去更新您所在的房间属性。
所有更新都需要一点时间来分发,但所有客户端都会相应地更新Player.CustomProperties
和CurrentRoom.CustomProperties
。作为属性更改时的回调,PUN 分别调用OnRoomPropertiesUpdate(Hashtable propertiesThatChanged)
或OnPlayerPropertiesUpdate(Player targetPlayer, Hashtable changedProps)
。
你还可以在创建新房间时设置属性。这特别有用,因为房间属性可用于匹配。有一个JoinRandomRoom()
重载,它使用属性哈希表来过滤可接受的加入房间。创建房间时,请确保通过相应设置RoomOptions.CustomRoomPropertiesForLobby
来定义大厅中可用于过滤的房间属性。详细见Lobby部分。
检查和交换属性 (CAS)
当使用SetCustomProperties
时,服务器通常会接受来自任何客户端的新值,这在某些情况下可能会很棘手。
例如,一个属性可以用来存储谁在房间里拾取了一件独特的物品。因此,该属性的键将是这个item,而值定义了谁捡起它。任何客户端都可以随时将属性值设置为他的 ActorNumber。如果所有人几乎同时进行,则最后一次 SetCustomProperties
调用将赢得该item(设置最终值)。这是违反直觉的,可能不是你想要的。
SetCustomProperties
有一个可选expectedProperties
参数,可以用作条件。使用expectedProperties
,服务器仅更新当前键值与expectedProperties
中值匹配的属性。过期expectedProperties
的更新将被忽略(客户端会因此收到错误,其他人不会注意到更新失败)。
在我们的示例中,expectedProperties
可能包含你获取的unique item的当前所有者。即使每个人都尝试获取该item,也只有第一个会成功,因为每个其他更新请求在expectedProperties
中使用的都是无效的owner。
将expectedProperties
用作SetCustomProperties
中的条件称为检查和交换 (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
}
}
属性同步
默认情况下,这是“通过服务器”完成的,这意味着:
默认情况下,在加入在线房间时,为 Actor 或房间属性设置 Actor属性不会立即在设置者客户端(设置属性的 Actor )上生效。相反,发送者/设置者客户端(设置属性的Actor)将等待服务器事件PropertiesChanged
以在本地应用/设置更改。所以你需要等到OnPlayerPropertiesUpdate
或OnRoomPropertiesUpdate
在本地客户端上触发后去访问它们。这背后的原因是,如果我们先在本地设置属性,然后在服务器上和房间中的其他Actor发送请求,那么属性很容易不同步。后者可能会失败,我们最终可能会得到与服务器或其他客户端上的本地不同的客户端属性。如果你想设置旧行为(在将请求发送到服务器以同步它们之前在本地设置属性):在创建房间之前设置roomOptions.BroadcastPropsChangeToAll
为false
。但强烈建议不要这样做。
客户端仍然可以在房间外缓存本地玩家的属性。这些属性将在进入房间时发送。此外,在离线模式下设置属性的行为会立即发生。
此外,默认情况下,房间之间不会清除本地Actor的属性,你应该自己手动清除。
充分利用同步、RPC 和属性
要确定哪种同步方法最适合某个值,通常最好检查它需要更新的频率以及是否需要值的“历史值”。
频繁更新(职位、角色状态)
对于频繁更新,请使用Object Synchronization
对象同步(见对象同步一节). 毫无疑问,你自己的脚本可以通过不将任何内容写入流中以进行任意数量的更新来跳过更新。
角色的位置经常变化。每次更新都很有用,但很可能很快就会被新的更新所取代。可以将 PhotonView 设置为发送“不可靠”或“更改时不可靠”。第一个将以固定频率发送更新 - 即使角色没有移动。后者将在游戏对象(角色、单位)静止时停止发送更新。
不经常更新(玩家的行动)
更换角色的装备、使用工具或结束游戏回合都是不常见的动作。它们基于用户输入,可能最好作为 RPC 发送。
如果你无论如何都进行对象同步,那么将一些操作与更频繁的更新“内联”会很有意义。例如:如果你频繁发送角色的位置,你可以在其中 轻松添加一个值以发送“跳跃”状态。这不必是单独的 RPC!
与对象同步不同,RPC 可能会被缓冲。任何缓冲的 RPC 都将发送给稍后加入的玩家,如果必须一个接一个地重播动作,这将很有用。例如,加入的客户端可以重播某人如何在场景中放置工具以及其他人如何升级它。后者取决于第一个动作。
将缓冲的 RPC 发送给新玩家需要一些流量,这意味着你的客户端必须在进入“实时”游戏之前回放并应用每个动作。这可能会有问题,过多的缓冲可能会破坏弱客户端,因此请谨慎使用缓冲。
RPC详细见RPC章
低频率更新和状态(打开/关闭门、地图、角色装备)
非常低频的更改通常最好存储在Custom Properties
.
与缓冲 RPC 不同,属性 Hashtable 仅包含当前键值。这对于门的“打开”(或不打开)状态非常有用。玩家并不关心门之前是如何打开和关闭的。
在上面的 RPC 示例中,有人在场景中放置了一个工具并对其进行了升级。将 RPC 用于一些操作就可以了。对于大量修改,将当前状态聚合到一个属性的单个值中可能更容易。多个“+10 防御”升级可以轻松存储在单个值中,而不是大量 RPC。
同样,使用自定义属性和使用 RPC 之间的界限并不准确。
自定义属性的另一个很好的用例是存储房间的“开始时间”。游戏开始时,将PhotonNetwork.Time
作为属性储存。对于房间中的所有客户端,该值(大约)相同,并且随着开始时间,任何客户端都可以计算游戏已经运行了多长时间以及它在哪个回合。当然,你也可以存储每个回合的开始时间。如果可以暂停游戏,这会更好。
滞后补偿Lag Compensation
物理对象的滞后补偿
当你的游戏中有物理对象时,你可能已经注意到这些对象可能会稍微不同步——尤其是当有两个或更多游戏窗口彼此相邻时,更易发现。这可能会导致游戏出现一些严重的问题,最终也可能会降低玩家的体验。
这种同步问题是由消息从一个客户端“传播”到另一个客户端所花费的时间引起的。一个例子:客户端 A 将他的角色向前移动并发送他当前的位置。客户端 B 在客户端 A 发送此消息后仅 100 毫秒就收到此消息。客户端 B 使用此信息将客户端 A 的角色放置在正确的位置上,以使他的游戏保持最新。由于客户端 A 在最后 100 毫秒内没有停止移动他的角色,他的角色在世界上达到了一个新的位置。此时对象不再完全同步,因为它在客户端 A 和客户端 B 的游戏中的位置不同。根据角色的移动速度,两个位置之间的差异会有所不同:如果移动速度相当慢,则差异可能根本不明显 - 但是,如果移动速度非常高,则两个游戏窗口上的差异都清晰可见。由于我们无法完全摆脱这个问题(除非我们使用像光子量子这样的另一种技术),我们正试图尽可能减少这个问题的出现,并引入一种我们称之为“滞后补偿’。
滞后补偿是什么意思,它是如何工作的?
将滞后补偿应用于我们的物理对象时,我们要求对象的所有者owner发送除了对象的位置和旋转之外的其他数据:在这种情况下,我们会寻找对象的速度。我们不是简单地将接收到的信息应用于远程客户端上的对象,而是使用它们来计算对象的更新和更准确的行为。因此,我们还需要发送和接收消息之间经过的确切时间。由于我们的示例使用自定义 OnPhotonSerializeView 解决方案(见下文),我们将展示如何基于此函数计算经过的时间。
首先我们需要一个空的 OnPhotonSerializeView 实现:
public void OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info) {
}
注意:要使用此功能,脚本必须实现IPunObservable
接口。
由于这个函数的发送端实现比较简单,后面会展示,所以我们先看看接收端,因为大部分工作都在这里完成。接收客户端必须做的一件事是计算前面提到的在发送和接收当前消息之间经过的时间。因此,我们使用 PhotonMessageInfo,其中包含描述消息发送时刻的时间戳。此外,我们PhotonNetwork.Time
用于计算当前时间与前面提到的时间戳之间的差异。结果是其间经过的时间。
float lag = Mathf.Abs((float) (PhotonNetwork.Time - info.timestamp));
有了这个值,我们可以根据我们从所有者那里收到的信息来计算对象可能是如何移动的。为此,我们有两个不同的选项,如下所述。
使用 OnPhotonSerializeView 更新对象
第一个选项只是使用 OnPhotonSerializeView 函数来更新对象。基于我们空的 OnPhotonSerializeView 函数,发送者与其他客户端共享所有必要的信息。在这种情况下,我们发送刚体的位置、旋转以及它的速度。接收器在计算已经过去的时间之前,将接收到的信息直接存储在对象的刚体组件中,如上所述。之后,他将速度乘以他之前计算的发送经过时间。然后将该计算的结果添加到刚体组件的位置。现在,我们的远程客户端上有一个描述更准确的对象。为了更好地理解 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;
}
}
使用 OnPhotonSerializeView 和 FixedUpdate 更新对象
除了 OnPhotonSerializeView 实现之外,第二个选项还使用 Unity 的 FixedUpdate 函数。我们再次从空的 OnPhotonSerializeView 函数开始。在这种方法中,发送者有相同的任务:共享刚体的位置、旋转以及速度的信息。接收方的任务与之前的方法不同。这次他只将接收到的速度信息存储到对象的刚体组件中,然后计算发送和接收当前消息之间经过的时间。其他信息——位置和旋转——此时存储在局部变量中。对于此示例,局部变量称为 networkPosition(Vector3 类型)和 networkRotation(Quaternion 类型)。之后,接收器将刚体的速度乘以经过的时间,并将计算结果加到本地存储的 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);
}
}
非物理对象的滞后补偿
有时你的游戏中有对象,但它们没有附加刚体组件。在这种情况下,将使用它们的 Transform 组件同步它们。此时可能还注意到,两个(或更多)不同屏幕上的同一对象之间存在一些延迟和轻微偏移。可以将前面学到的方法也用于那些经过一些调整的对象。
首先,我们需要一个选项来描述对象的运动。由于我们没有刚体组件的速度属性,我们必须使用自定义解决方案。这里一个简单的方法是使用对象最后两个位置之间的差异。
Vector3 oldPosition = transform.position;
// Handling position updates related to the given input
movement = transform.position - oldPosition;
首先,我们将当前位置存储在一个名为 oldPosition 的临时变量中。之后我们将处理所有输入并据此更新对象的位置。最后,我们计算本地存储位置和更新位置之间的差异,它描述了对象的运动(类型 Vector3),并且是我们对刚体组件速度属性的“替换”。此代码片段是 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
远程过程调用
将 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()
方法并提供要调用的方法的名称。
方法签名
PhotonView 就像 RPC 的“目标”:所有客户端仅在具有特定 PhotonView 的联网游戏对象上执行该方法。如果你射击击中特定对象并调用“ApplyDamage”RPC,则接收客户端会将伤害应用于同一对象。
你可以添加多个参数,前提是 PUN 可以对其进行序列化(“参见Photon的序列化一节”)。这样做时,方法和调用必须具有相同的参数。如果接收客户端找不到匹配的方法,它将记录一个错误。
这条规则有一个例外:RPC 方法的最后一个参数可以是 type PhotonMessageInfo
,它将为每个调用提供上下文。你可以不在调用中设置PhotonMessageInfo
参数。
[PunRPC]
void ChatMessage(string a, string b, PhotonMessageInfo info)
{
// the photonView.RPC() call is the same as without the info parameter.
// the info.Sender is the player who called the RPC.
Debug.LogFormat("Info: {0} {1} {2}", info.Sender, info.photonView, info.SentServerTime);
}
使用PhotonMessageInfo
,你可以在没有额外参数的情况下为“射击”实现 RPC。你知道谁开枪,什么被击中,什么时候被击中。
注意事项
-
按照设计,具有 RPC 方法的脚本需要附加到与 PhotonView 完全相同的 GameObject,而不是其父级或子级。
-
RPC 方法不能是静态的。
-
不支持将通用方法作为 PUN RPC。
-
可以调用具有非
void
返回值但不会使用返回值的 RPC 方法。除非在对同一方法的其他显式直接调用中需要返回值,否则请始终用void
作返回值。 -
如果 RPC 方法被override,不要忘记给它添加属性。否则,会使用基类上具有
PunRPC
属性的未被重写的方法。 -
不要在与 PhotonView 相同的 GameObject 上附加多个相同类型且具有 RPC 方法的组件。如果你有一个调用的类
MyClass
实现了标记为PunRPC
的方法MyRPC
,则不应将多个实例附加到同一个 GameObject。可以使用[DisallowMultipleComponent]
类属性。 -
每个 RPC 方法使用唯一的名字。
-
不要创建这种重载overload RPC 方法:一种带有特殊的
PhotonMessageInfo
为最后一个参数,另一种没有。只选择任一选项。 -
不建议在 RPC 方法中使用可选参数。如有必要,在 RPC 调用期间传递所有参数,包括可选参数。否则,接收客户端将无法找到并处理传入的 RPC。
-
如果要将对象数组作为 RPC 方法的参数发送,则需要先将其转换为对象类型。
示例:
object[] objectArray = GetDataToSend(); photonView.RPC("RpcWithObjectArray", target, objectArray as object); // ... [PunRPC] void RpcWithObjectArray(object[] objectArray) { // ... }
目标、缓冲和顺序
你可以定义哪些客户端执行 RPC。为此,请使用 的值RpcTarget
。最常见的是,你希望All
客户端调用 RPC。有时只是Others
。
RpcTarget
有一些以 Buffered
结尾的值。服务器会记住这些 RPC,当有新玩家加入时,它会获取 RPC,即使这个RPC发生得更早。小心使用它,因为一个长的缓冲区列表会导致更长的连接时间。
RpcTarget
具有以 ViaServer
结尾的值。通常,当发送客户端必须执行 RPC 时,它会立即执行 - 无需通过服务器发送 RPC。但是,这会影响事件的顺序,因为在本地调用方法时没有延迟。
ViaServer
禁用“全部”快捷方式。这在 RPC 应该按顺序执行时特别有趣:通过服务器发送的 RPC 由所有接收客户端以相同的顺序执行。这是到达服务器的顺序。
示例:在赛车游戏中,可以将“完成”的 RPC 发送为AllViaServer
. 第一个“完成”的 RPC 调用会告诉你谁赢了。接下来“完成”的调用顺序会告诉你排名。
或者,可以为房间中的特定玩家调用 RPC。将目标Player作为重载的第二个参数。如果你直接应用于本地玩家,那么这将在本地执行并且不会通过服务器。
RPC 名字的快捷优化
因为字符串不是通过网络发送的最有效率的数据,PUN 使用了一个技巧来缩短它们。PUN 在编辑器中检测 RPC 并编译一个列表。每个方法名称都通过该列表获得一个 ID,当按名称调用 RPC 时,PUN 实际上会发送 ID。
由于这个快捷优化,不同版本的游戏可能不会对 RPC 使用相同的 ID。如果这是一个问题,你可以禁用快捷优化。如果匹配相同构建的客户端,这不会有问题。
RPC 列表通过PhotonServerSettings
进行储存和管理.
如果项目的不同构建之间的 RPC 调用出错,请检查此列表。***Get HashCode***按钮计算一个哈希码,便于在项目之间进行比较。
如果需要,可以清除列表(清除 RPC按钮)并通过单击(刷新 RPC )列表按钮手动刷新列表。
RPC 和加载关卡的时间安排
RPC 在特定的 PhotonViews 上被调用,并且总是以相匹配的接收客户端为目标。如果远程客户端尚未加载或创建匹配的 PhotonView,则 RPC 将丢失。
因此,丢失 RPC 的典型原因是客户端加载新场景时。它只需要一个客户端已经加载了一个带有新游戏对象的场景,而其他客户端无法理解这个(直到他们也加载了相同的场景)。
PUN 可以解决这个问题。只需在你连接前设置PhotonNetwork.AutomaticallySyncScene = true
,然后在房间的主客户端上使用调用PhotonNetwork.LoadLevel()
。这样,一个客户端定义了所有客户端必须在房间/游戏中加载的关卡。
为了防止丢失 RPC,客户端可以停止执行传入消息(这就是 LoadLevel 为你所做的)。当你得到一个 RPC 来加载某个场景时,立即设置IsMessageQueueRunning = false
直到场景内容被初始化。禁用消息队列将延迟传入和传出消息,直到队列解锁。显然,当你准备好继续时**,解锁队列非常重要。**
示例:
private IEnumerator MoveToGameScene()
{
// Temporary disable processing of futher network messages
PhotonNetwork.IsMessageQueueRunning = false;
LoadNewScene(newSceneName); // custom method to load the new scene by name
while(newSceneDidNotFinishLoading)
{
yield return null;
}
PhotonNetwork.IsMessageQueueRunning = true;
}
RaiseEvent
在某些情况下,RPC 并不是你所需要的。而是需要一个 PhotonView 和一些被调用的方法。
使用PhotonNetwork.RaiseEvent
,你可以自定义自己的事件并发送它们,而与网络对象无关。
事件是通过使用唯一标识符,即event code来描述的。在 Photon 中,这个事件代码被描述为一个字节值,它允许多达 256 个不同的事件。但是其中一些已被 Photon 本身使用,因此你不能将它们全部用于自定义事件。排除所有内置事件后,你仍然可以使用多达 200 个自定义事件代码 [0…199]。如果你要使用高级事件缓存操作,则应避免使用事件代码 0(根据事件代码删除缓存事件时,它是过滤器中的通配符)。在以下示例中,我们有一个事件,它告诉一堆单位移动到某个位置。
using ExitGames.Client.Photon;
using Photon.Realtime;
using Photon.Pun;
public class SendEventExample
{
// If you have multiple custom events, it is recommended to define them in the used class
public const byte MoveUnitsToTargetPositionEventCode = 1;
private void SendMoveUnitsToTargetPositionEvent()
{
object[] content = new object[] {
new Vector3(10.0f, 2.0f, 5.0f), 1, 2, 5, 10 }; // Array contains the target position and the IDs of the selected units
RaiseEventOptions raiseEventOptions = new RaiseEventOptions {
Receivers = ReceiverGroup.All }; // You would have to set the Receivers to All in order to receive this event on the local client as well
PhotonNetwork.RaiseEvent(MoveUnitsToTargetPositionEventCode, content, raiseEventOptions, SendOptions.SendReliable);
}
}
内容可以是 PUN 可以序列化的任何内容。在这种情况下,我们使用了一个对象数组,因为在这个例子中我们有不同的类型。
第三个参数描述了 RaiseEventOptions。通过使用这些选项,可以选择是否应将此事件缓存在服务器上,选择哪些客户端应接收此事件或选择应将此事件转发到哪个目标组。除了自己定义这些选项,你还可以使用null
以应用默认 RaiseEventOptions。由于我们希望发送者也接收此事件,因此我们将 Receivers 设置为ReceiverGroup.All
.
最后一个参数描述SendOptions
. 例如,通过使用此选项,你可以选择发送此事件是可靠还是不可靠,或者选择是否应加密消息。在我们的示例中,我们只想确保我们的事件发送可靠。
IOnEventCallback 回调
要接收自定义事件,我们有两种不同的可能性。第一个是实现IOnEventCallback
接口。实现这一点时,你必须添加OnEvent
回调处理程序。此处理程序的方法如下所示:
public void OnEvent(EventData photonEvent)
{
// Do something
}
要正确注册此处理程序,我们可以使用 Unity 的OnEnable
和OnDisable
方法。这样我们还可以确保我们正确地添加和删除回调处理程序,并且不会遇到与此特定处理程序相关的任何问题。
private void OnEnable()
{
PhotonNetwork.AddCallbackTarget(this);
}
private void OnDisable()
{
PhotonNetwork.RemoveCallbackTarget(this);
}
为了对接收到的信息做一些事情,我们的OnEvent
方法可能类似于这个:
public void OnEvent(EventData photonEvent)
{
byte eventCode = photonEvent.Code;
if (eventCode == MoveUnitsToTargetPositionEvent)
{
object[] data = (object[])photonEvent.CustomData;
Vector3 targetPosition = (Vector3)data[0];
for (int index = 1; index < data.Length; ++index)
{
int unitId = (int)data[index];
UnitList[unitId].TargetPosition = targetPosition;
}
}
}
首先,我们正在检查接收到的事件代码是否与我们之前设置的代码匹配。如果是这样,我们将事件的内容转换为我们之前发送的格式,在我们的示例中是一个对象数组。之后我们从该数组中获取目标位置,这是我们之前添加到内容中的第一个对象。由于我们知道该数组中只剩下int值,因此我们可以使用 for 循环遍历其余数据。
最终代码应如下所示:
using ExitGames.Client.Photon;
using Photon.Realtime;
using Photon.Pun;
public class ReceiveEventExample : MonoBehaviour, IOnEventCallback
{
private void OnEnable()
{
PhotonNetwork.AddCallbackTarget(this);
}
private void OnDisable()
{
PhotonNetwork.RemoveCallbackTarget(this);
}
public void OnEvent(EventData photonEvent)
{
byte eventCode = photonEvent.Code;
if (eventCode == MoveUnitsToTargetPositionEvent)
{
object[] data = (object[])photonEvent.CustomData;
Vector3 targetPosition = (Vector3)data[0];
for (int index = 1; index < data.Length; ++index)
{
int unitId = (int)data[index];
UnitList[unitId].TargetPosition = targetPosition;
}
}
}
}
LoadBalancingClient.EventReceived
接收自定义事件的第二种方法是注册一个在接收到事件时调用的方法。为了正确地做到这一点,我们可以像以前一样使用 Unity 的OnEnable
和OnDisable
方法。
public void OnEnable()
{
PhotonNetwork.NetworkingClient.EventReceived += OnEvent;
}
public void OnDisable()
{
PhotonNetwork.NetworkingClient.EventReceived -= OnEvent;
}
最终代码应如下所示:
using ExitGames.Client.Photon;
using Photon.Realtime;
using Photon.Pun;
public class ReceiveEventExample : MonoBehaviour
{
private void OnEnable()
{
PhotonNetwork.NetworkingClient.EventReceived += OnEvent;
}
private void OnDisable()
{
PhotonNetwork.NetworkingClient.EventReceived -= OnEvent;
}
private void OnEvent(EventData photonEvent)
{
byte eventCode = photonEvent.Code;
if (eventCode == MoveUnitsToTargetPositionEvent)
{
object[] data = (object[])photonEvent.CustomData;
Vector3 targetPosition = (Vector3)data[0];
for (int index = 1; index < data.Length; ++index)
{
int unitId = (int)data[index];
UnitList[unitId].TargetPosition = targetPosition;
}
}
}
}
在底层,PUN 还使用 RaiseEvent 进行几乎所有的通信。
Raise Event Options
你可以使用RaiseEventOptions
参数定义哪些客户端获取事件,如果它被缓冲等等。
Receiver Groups
“Receiver Groups”是定义谁接收事件的一种方式。此选项可通过RaiseEventOptions
获得。
定义了三个组:
Others
:所有其他活跃的Actor都加入了同一个房间。All
:所有活跃的演员都加入了同一个房间,包括发送者Actor。MasterClient
:当前指定房间内的主客户端。
Interest Groups
“兴趣组”是定义谁接收事件的另一种方法。你可以使用全局组 0,为所有客户端引发事件。你还可以使用不为 0 的特定组来仅为特定组引发事件。要接收发送到某个组的事件,客户端必须订阅该组。详见Interest Groups章
Target Actors
“Target Actors”是定义谁接收事件的第三种方式。使用这种方式,你可以向一个或多个特定客户端发起事件,你可以使用他们唯一的Actor编号来选择这些客户端。
Caching Options缓存选项
最有趣的选项可能是事件缓存选项。PUN 在两个地方使用事件缓存:详见缓存事件章
- 所有
PhotonNetwork.Instantiate*
调用都被缓存。 - 所有使用
RpcTarget.*Buffered
发送的 RPC 都会被缓存。
Send Options发送选项
使用SendOptions
来设置事件是可靠发送还是加密发送。
Reliability可靠性
该Reliability
选项描述了事件是可靠发送还是不可靠发送。
Encryption加密
使用Encrypt
可以将事件在发送之前进行加密。默认情况下,事件未加密。
缓存事件Cached Events
Photon event在房间内的游戏逻辑和玩家交流中起着核心作用。客户端调用RaiseEvent
向房间中的一个或多个玩家发送数据。所有加入的玩家都将能够立即收到这些事件。但是,除非你使用缓存选项,否则后来的玩家将错过这些事件。
当你缓存一个事件时,Photon 服务器会将其保存在房间状态中,以供以后加入房间的玩家使用。在转发任何最终的“实时”事件之前,Photon 服务器将首先将缓存事件发送给加入的玩家。
用例 1: 你可以将包含回合创建时间戳的“开始时间”事件缓存为数据。这样,任何加入游戏的人都会知道该回合何时开始(以及可能何时结束)。
保证时序
加入的玩家按照这些事件到达服务器的顺序获得缓存的事件。客户端首先读取房间和玩家属性(因此每个玩家都是已知的),然后获取缓存的事件。自加入以来已发送的所有事件将在之后交付。
因此,你可以认为缓存事件的接收顺序与它们的传输顺序相同。只有在使用 UDP 并且发送的事件不可靠时,才会发生异常。
了解如何缓存事件
每个缓存的事件都可以由它的代码、它的数据和发送者的Actor编号来定义。
事件缓存也可以分为两个“逻辑分区”:
- “全局缓存”:与房间关联的缓存(ActorNr == 0)。它包含所有已使用
EventCaching.AddToRoomCacheGlobal
标志发送且尚未明确删除的缓存事件。这些缓存的事件不能再追溯到它们的原始发送者。这就是为什么这些事件会发送给任何加入的Actor。 - “Actor缓存”:与特定Actor编号关联的缓存(ActorNr!= 0)。它包含所有已由该Actor发送的缓存事件,这些事件尚未显式添加到全局缓存或从缓存中删除。这些事件被发送给除了它们各自的原始发送者之外的任何加入的Actor。
控制事件缓存
在 C# LoadBalancing API 和 PUN 中,用户需要将RaiseEventOptions
对象传递给任何RaiseEvent
调用。此参数包括一个CachingOption
属性。让我们发现每个可能的EventCaching
值如何影响事件缓存:
DoNotCache
: 这是默认值。它表示要发送的事件不会被添加到房间的缓存中。AddToRoomCache
:这表示要发送的事件将被添加到发送者Actor的房间缓存中。它将由发送Actor的编号标记。AddToRoomCacheGlobal
:这表示要发送的事件将被添加到“全局缓存”中。使用此值时要小心,因为此代码仅在你明确请求将其删除时才会删除该事件。否则,它将具有与房间相同的生命周期。RemoveFromRoomCache
:这表示所有与指定“过滤模式”匹配的先前缓存的事件都将从缓存中删除。“过滤模式”是三个参数的组合:事件代码、发送者编号和事件数据。你可以使用一个、两个或所有三个过滤器。- 等于 0 的事件代码是事件代码的通配符。
- 在这种情况下,使用目标Actor选项(在 C# 客户端 SDK 中,这是使用
RaiseEventOptions.TargetActors
数组完成的)来指定发送者编号。发送者编号可以为 0,这意味着你可以从“全局缓存”中删除。因此,要从全局缓存中删除,你可以指定 0 作为发送者编号。由于目标 actor 是一个数组,你可以按多个 actor 进行过滤,甚至可以组合全局 (ActorNr == 0) 和非全局缓存事件 (ActorNr != 0)。 - 此外,如果你按事件数据过滤,可以只发送部分数据。例如,如果你使用 一个
Hashtable
作为事件数据,则只能通过指定一个或多个键/值对来删除事件。
RemoveFromRoomCacheForActorsLeft
:这表明被移除的actors发送的缓存事件也应该被移除。创建房间时RoomOptions.CleanupCacheOnLeave
默认为true。
如果满足以下任一条件,则不会将事件添加到缓存中:
RaiseEventOptions.Receivers == ReceiverGroups.MasterClient
.RaiseEventOptions.TargetActors != null
.RaiseEventOptions.InterestGroups != 0
.
用例 2: 如果使用 一个Hashtable
作为事件内容,则可以使用“oid”键(“ObjectID”的缩写形式)和某个值来标记属于某个对象的所有事件。当你想清理与特定对象相关的缓存时,你可以发送一个Hashtable(){"oid", <objectId>}
作为事件数据过滤器然后调用EventCaching.RemoveFromRoomCache
.
缓存清理
当玩家退出游戏时,通常他/她的行为与新玩家不再相关。为了避免加入时出现拥塞,Photon 服务器默认会自动清理玩家缓存的事件。
如果你想手动清理房间的事件缓存,你可以创建RoomOptions.CleanupCacheOnLeave
设置为 false 的房间。
特别注意事项
- 只有当 Actor 完全加入时,Photon 客户端才能在房间内开始调用操作(RaiseEvent、SetProperties 等)。只有当服务器完成发送所有缓存的事件时,才会认为Actor已经完全加入。在首先接收所有缓存事件之前尝试在房间内调用操作时,客户端将收到错误 (
OperationNotAllowedInCurrentState (-3)
)。 - 缓存的事件也会延迟Actor加入房间后发送的实时事件。
- Photon 限制了每个房间的缓存事件总数(Actor缓存和全局缓存相结合)。如果你达到限制,服务器将广播一个 ErrorInfo 事件并关闭房间,因此没有新玩家可以加入。已经加入的Actor可以保留,而不活跃的Actor将无法重新加入。
兴趣组Interest Groups
你可以将 Photon 的“兴趣组”想象为房间内对话的子频道:客户只收到他们订阅的兴趣组(和组 0)的消息。他们可以将任何事件发送到他们想要的任何组。
这个简单的功能可用于基本的兴趣管理
Available Groups可用组
客户端不需要明确创建兴趣组。兴趣组是按需创建的;当Actor订阅一个新的组号时,服务器将创建它。
Photon 提供多达 256 个兴趣组。组号 0 是保留的,用于广播:房间内的所有Actor都订阅了组 0,并且不能取消订阅。其他 255 个组可供开发人员免费使用。当服务器发送事件时,分配给组号 > 0 的任何事件只会传输给对该组和房间感兴趣的客户端。
重要提示:只能缓存发送到兴趣组 0 的事件!其他组没有事件缓存。
设置兴趣组
SetInterestGroups
告诉服务器,你的客户端对哪些组感兴趣。默认情况下,这是组 0,但没有其他组,因此如果你想使用它,你必须订阅一些组。
全局过滤客户端从哪些兴趣组接收事件:
void PhotonNetwork.SetInterestGroups(byte[] disableGroups, byte[] enableGroups)
你应该更喜欢使用批量更新,但你也可以切换单个兴趣组的接收:
void PhotonNetwork.SetInterestGroups(byte group, bool enabled)
设置发送启用
使用SetSendingEnabled
时,客户端可能会在本地丢弃发往特定组的更新——它们根本不会到达服务器(没有服务器端的逻辑)。
要全局定义客户端应向哪些兴趣组发送事件,请使用:
void PhotonNetwork.SetSendingEnabled(byte[] disableGroups, byte[] enableGroups)
要切换单个发送兴趣组:
void PhotonNetwork.SetSendingEnabled(byte group, bool enabled)
默认情况下,客户端会向所有组发送更新。
提示:
- 优先级始终是添加组而不是删除:如果将相同的组号添加到数组
enableGroups
和disableGroups
数组中,则将添加组。 null
数组充当“无组”,空数组 (new byte[0]
)充当“所有组”。PhotonNetwork.SetInterestGroups(new byte[0], enableGroups)
将删除除enableGroups
外的所有组PhotonNetwork.SetInterestGroups(disableGroups, new byte[0])
将添加所有当前存在的(使用的)组,无论disableGroups
的值是什么。
使用指南
实例化
PhotonNetwork.Instantiate
方法接受一个group
参数,该参数将设置附加到预制件的 PhotonView 的兴趣组。
**重要提示:**如果你将group
默认值 0 以外的值传递给PhotonNetwork.Instantiate
,请确保在这之前启用从该组接收事件。否则预制件将不会在本地实例化。
PhotonView
附加到预制件的 PhotonViews 自动将兴趣组传递给PhotonNetwork.Instantiate
调用。默认为 0。如果不为 0,新加入客户端可能不会为!= 0的组执行 Instantiate
你可以通过以下代码设置或更新 PhotonView 的兴趣组:
photonView.Group = interestGroupCode;
仅在本地设置photon view的组 - 远程实例保留在实例化 GameObject 时设置的组。
PunRPCs和PhotonView序列化/同步事件将仅发送给有相应兴趣组配置的PhotonView,除非使用SetSendingEnabled禁用它。因此,通常可以使用接收客户端的SetInterestGroup以在服务器端对更新进行过滤。
RaiseEvent
PhotonNetwork.RaiseEvent
调用时使用RaiseEventOptions.InterestGroup
来设置目标兴趣组。
示例用例
每个客户端订阅的兴趣组可以在运行时动态定义或在编译时静态定义。兴趣组设置可以在所有客户端上相同,也可以在每个客户端上不同。
兴趣组有助于降低房间内每秒的消息数量。通过降低流量,你可以保持在消息/秒限制以下,降低成本,有时它可以帮助你增加每个房间的最大玩家数量。
但是你可以想出其他巧妙的方法在你的游戏中使用兴趣组。
网络剔除Network Culling
兴趣组最常见的用例是网络剔除。兴趣组可以映射到你游戏中感兴趣的领域。例如,如果你有一个“大世界”,你可以将其分成更小的块,让我们称它们为“areas”并为每个“area”分配一个兴趣组。
Team Events
如果你的游戏中有团队并且想要实施团队专属活动,你可以为每个团队分配一个兴趣组。所有团队成员都应订阅团队的兴趣组。
优化提示
性能优化
用于实例化的对象池
PUN 有一个内置选项可以从对象池中实例化对象,而不是从加载的资源(它保存在内存中以加快速度)。详见预制体pool节。
缓存 RPC 目标
在某些情况下,你可能会在游戏中使用大量 RPC。目标 GameObject 上的任何 Component 都可能实现 RPC,因此 PUN 将使用反射来寻找合适的方法。当然,如果组件不改变,性能消耗会很高而且很浪费。
默认情况下,PUN 缓存每个脚本的 MethodInfo 列表。这个列表不会将 GameObject 上的 MonoBehaviours 做为潜在的缓存目标。
你可以设置PhotonNetwork.UseRpcMonoBehaviourCache = true
, 为 RPC 缓存每个 PhotonView 的 MonoBehaviours。这加快了查找要调用的组件的速度。如果游戏对象上的脚本发生更改,请根据需要调用photonView.RefreshRpcMonoBehaviourCache()
更新。
网络流量优化
紧凑序列化Compact Serialization
你可以通过仔细检查发送的内容以及发送方式来优化流量。这可以在开发的后期完成,没有太多额外的负担。像往常一样进行优化,首先从最常发送的消息开始。
通常,发送的值和对象的值多于共享状态所需的值。如果你的角色无法缩放,请不要同步scale!如果你的角色从不偏向一边,rotation可能是一个浮点值。实际上,一个字节可能足以进行旋转而不会造成太大的精度损失。这对于非物理对象来说很好。
一般来说,看看你在OnPhotonSerializeView
和RPC 中做了什么。你可以为每个OnPhotonSerializeView
发送可变数量的值。发送紧凑字节数组通常比注册和发送大量自定义类型更精简。
对此NetStack工具可以帮助到你。
网络剔除和兴趣组
另一种节省相当多带宽的技术是“网络剔除”。见兴趣组一章。
使用Pooled ByteArraySlice
默认情况下,C# SDK 中的 Photon 客户端将byte[]
和ArraySegment<byte>
序列化为byte[]
. 在接收端,分配了一个相同长度的新byte[]
,它被传递给OnEvent
回调。ByteArraySlice 是这些选项的non-alloc / non-boxing替代方案。
ByteArraySlice
是一个与ArraySegment<byte>
非常相似的byte[]
包装类,除了它是一个可回收类。作为一个类,它可以被转换为对象(所有 Photon 消息都被转换为对象),而无需从系统中创建内存分配。
ByteArraySlice
的字段/属性是:
Buffer
: 包装的byte[]
数组。Offset
:传输时将从中读取的起始字节。Count
:Offset起写入的字节数。
可以使用自定义#if PUN_2_19_OR_NEWER
来检测ByteArraySlice
可用性。
序列化用法
这可以通过两种方式完成:
从 ByteArraySlicePool 获取
void Serialization()
{
// Get a pooled Slice.
var pool = PhotonNetwork.NetworkingClient.LoadBalancingPeer.ByteArraySlicePool;
var slice = pool.Acquire(256);
// Write your serialization to the byte[] Buffer.
// Set Count to the number of bytes written.
slice.Count = MySerialization(slice.Buffer);
// Send (works for RPCs as well)
PhotonNetwork.RaiseEvent(MSG_ID, slice, opts, sendOpts);
// The ByteArraySlice that was Acquired is automatically returned to the pool
// inside of the RaiseEvent
}
维护自己的 ByteArraySlice
private ByteArraySlice slice = new ByteArraySlice(new byte[1024]);
void Serialization()
{
// Write your serialization to the byte[] Buffer.
// Set Count to the number of bytes written.
slice.Count = MySerialization(slice.Buffer);
// Send (works for RPCs as well)
PhotonNetwork.RaiseEvent(MSG_ID, slice, opts, sendOpts);
}
反序列化使用
默认情况下,byte[]
数据被反序列化为new byte[x]
. 我们必须设置LoadBalancingPeer.UseByteArraySlicePoolForEvents = true
启用非分配管道。一旦启用,我们将传入的对象转换为ByteArraySlice
而不是byte[]
.
// By default byte arrays arrive as byte[]
// UseByateArraySlicePoolForEvents must be enabled to use this feature
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
private static void EnableByteArraySlicePooling()
{
PhotonNetwork.NetworkingClient.LoadBalancingPeer.UseByteArraySlicePoolForEvents = true;
}
private void OnEvent(EventData photonEvent)
{
// Rather than casting to byte[], we now cast to ByteArraySlice
ByteArraySlice slice = photonEvent.CustomData as ByteArraySlice;
// Read in the contents of the byte[] Buffer
// Your custom deserialization code for byte[] will go here.
Deserialize(slice.Buffer, slice.Count);
// Be sure to release the slice back to the pool
slice.Release();
}
Reusing EventData 重用事件数据
C# 客户端通过OnEvent(EventData ev)
接收事件. 默认情况下,每个 EventData 都是一个新实例,这会给垃圾收集器带来一些额外的工作。
在许多情况下,很容易重用 EventData 并避免开销。这可以通过PhotonPeer.ReuseEventInstance
设置启用。
在 PUN 2 中,设置PhotonNetwork.NetworkingClient.LoadBalancingPeer.ReuseEventInstance
为真。
其他提示
立即发送
RPC 和触发的事件不会在photonView.RPC
或PhotonNetwork.RaiseEvent
中同时发送. 这同样适用于其他操作请求,如PhotonNetwork.LeaveRoom
、PhotonNetwork.SetMasterClient
或SetProperties
。所有这些消息都会排队,直到PhotonHandler
(使用PhotonNetwork.SendRate
设置频率)调用定期例程。这会将消息聚合到更少的包中以避免流量开销,但会引入一些可变延迟。为了避免这种滞后,你可能希望PhotonNetwork.SendAllOutgoingCommands
在下一行立即发送 RPC 或事件调用。
当你的游戏依赖于这些消息的时间时,这是有道理的,例如:
- 有时限竞技或问答游戏,越快越好
- 对手在等待你
但是,还有其他用例,例如发送消息:
- 在断开之前
- 离开房间之前
- 在退出应用程序之前(内部
OnApplicationQuit
) - 在应用程序移至后台或失去焦点之前(内部
OnApplicationPause
或内部OnApplicationFocus
)
对于这些情况,你应该知道:
OnApplicationQuit
不会在所有平台上调用,例如在 Android 上,当应用程序被系统终止时不会调用。你可以改用OnApplicationPause
。- 平均丢包率通常为 1.2%。即使发送可靠,也不能保证你发送的内容会到达目的地,因为客户端将无响应或断开连接或不再加入房间,因此需要重试尝试。
- 在这些情况下发送的消息应该相对较小以压缩为一个包,因为客户端可能没有足够的时间发送多个包。
不要在Time.timeScale == 0时暂停PUN
要在Time.timeScale
是零或低的情况下允许在 PUN 中接收到消息,要设置PhotonNetwork.MinimalTimeScaleToDispatchInFixedUpdate
为等于或高于Time.timeScale
的值。 默认情况下,如果Time.timeScale
为0,则PhotonNetwork.MinimalTimeScaleToDispatchInFixedUpdate
为-1,因而不会处理接收到的事件(包括 RPC)或操作响应(没有回调,也没有操作可以完成执行) 。
主客户端和主机迁移Master Client and Host Migration
“主机迁移”是在线多人游戏中的一个已知概念。它涵盖了有关如何进行“主机”角色的平滑与无缝过渡的问题。“host”是对游戏拥有更多控制权且游戏最依赖的角色。通常,“host”是开始游戏的客户端,其他玩家需要连接到他或加入他才能玩。在Photon的概念中,我们本身并没有真正的“host”。相反,我们每个房间都有一个“特殊”客户端,称为“主客户端”。默认情况下,它和所有其他客户端一样是普通客户端。在你决定让他比其他人完成更多任务之前,它会一直保持这种状态。
Photon 中没有针对“主机迁移”的通用解决方案。
Photon Server 检测主客户端何时断开连接并将房间中的另一个Actor分配为新的主客户端。默认情况下,选择具有最小Actor编号的Actor。
每当主客户端发生变化时,都会调用以下回调:
void IInRoomCallbacks.OnMasterClientSwitched(Player newMasterClient)
Photon 还提供了一种从客户端、服务器和插件 SDK 显式更改主客户端的方法。从客户端你可以使用以下方法执行此操作:
PhotonNetwork.SetMasterClient(photonPlayer);
如果可以发送操作,该方法将返回true。
你需要做什么
Photon不会将前主客户端关于房间状态的所有信息交给新的主客户端。这不是一件显而易见的事情,有时时机太晚了,因为主客户端已经不在了(断开连接、没有响应等)。Photon不会将玩家属性或缓存事件从一个主客户端移动到另一个主客户端。此外,Photon不会将旧主客户端的事件重新发送到新选择的主客户端。这就是为什么开发者有责任确保在切换发生时不会丢失房间状态。
建议
-
主客户端概念并不适合所有类型的游戏。例如,对于快节奏的游戏,你应该改用自定义服务器端代码(自托管服务器应用程序或插件)。
-
你在游戏中拥有的玩家越多,你就越不需要依赖单个客户端来完成额外的任务。
-
应该避免仅向主客户端发送事件。复制数据总比丢失数据好。当发送给所有Actor时,你可以更轻松地进行任何新的主客户端切换。由于所有Actor都会跟踪房间的状态,因此新分配的主客户端将拥有完整的房间状态。
-
如果想在主客户端切换发生时保留游戏数据,自定义房间属性是最可靠的选择。
-
如果你的主客户端不断在房间中发送事件,你可以通过保存从主客户端接收到的最后一个事件的时间戳并在游戏循环中不断检查该值来实现主客户端超时检测机制。当检测到主客户端未按预期发送事件时,你可以显式切换主客户端。棘手的部分是选择超时值。较低的值可能会产生误报并使主客户端切换过于频繁。还需要选择是否需要从单个Actor(可能是下一个主客户端候选者)或所有Actor进行此检查。
-
在移动平台上,应用程序可能会失去焦点。在后台时,PUN 仅使用专用线程进行确认。该线程使客户端保持活动状态一分钟,但不执行任何其他操作。开发者需要决定如何处理进入后台的主客户端的场景:
- 应用程序进入后台后立即断开与 Photon 的连接。
- 应用程序进入后台后立即离开房间。
- 明确设置主客户端。
- 禁用keep-alive ACKs-only后台线程。
在实施选项 2 或 3 时,阅读优化提示-立即发送 一节。
离线模式Offline Mode
离线模式是一项能够在单人游戏模式中重复使用多人游戏代码的功能。
你希望能够在单人游戏中使用的最常见功能是发送 RPC 和使用
PhotonNetwork.Instantiate
. 离线模式的主要目标是在未连接时使用 PhotonNetwork 功能时禁用空引用和其他错误。你仍然需要跟踪正在运行的单人游戏、游戏设置等。但是,在运行游戏时,所有代码都应该是可重用的。你需要手动启用离线模式,因为 PhotonNetwork 需要能够区分错误行为和预期行为。启用此功能非常简单:
PhotonNetwork.OfflineMode = true;
一旦设置为 true,Photon 将通过调用OnConnectedToMaster()进行回调,然后你可以创建一个房间,这个房间当然也会离线。
你现在可以重复使用某些多人游戏方法而不会产生任何连接和错误。此外,没有明显的开销。下面列出了 PhotonNetwork 函数和变量及其在离线模式下的结果:
PhotonNetwork.LocalPlayer
:Actor编号始终为-1。PhotonNetwork.NickName
: 按预期工作。PhotonNetwork.PlayerList
:仅包含本地Player。PhotonNetwork.PlayerListOthers
总是空的。PhotonNetwork.Time
: 返回Time.time
。PhotonNetwork.IsMasterClient
: 永远正确。PhotonNetwork.AllocateViewID()
: 按预期工作。PhotonNetwork.Instantiate
: 按预期工作。PhotonNetwork.Destroy
: 按预期工作。PhotonNetwork.RemoveRPCs/RemoveRPCsInGroup/SetInterestGroups/SetSendingEnabled/SetLevelPrefix
:虽然这些在单人游戏没有意义,但也不会有影响。PhotonView.RPC
: 按预期工作。
请注意,使用上述以外的属性或方法可能会产生意想不到的结果,有些甚至什么都不做。如果你打算在单人游戏中开始游戏,但稍后将其转移到多人游戏,你可能需要考虑改为托管单人游戏;这将保留缓冲的 RPC 和实例化调用,而离线模式实例化后再连接后不会自动继续。
设置
PhotonNetwork.OfflineMode = false;
或简单地调用Connect()
以停止离线模式。
攻击和作弊保护
任何游戏都容易被作弊。
在服务器上运行所有逻辑听起来像是解决方案,但价格昂贵,并且不能保证无法作弊。
保护选项
作弊保护或多或少有四种方法:
- 纯客户端。
- 客户端通过后端报告和ban作弊者。
- 正在运行的游戏的服务器端分析。
- 完整的服务器端权限。
每个选项都更加有效,但也更昂贵,因为你必须在服务器端运行更多逻辑和系统。
通常,我们建议:让所有客户端检查作弊并报告这些作弊。然后在游戏中ban掉被举报次数过多的用户。
你将需要使用某些帐户或每个设备来识别用户。此外,你需要使用一些后端服务进行(如 player.io、Playfab、Steam 或其他)存储和评估报告。而且,你必须设置你的游戏只有身份验证之后才能访问服务器。
客户端
PUN和Unity
PUN 是一个没有服务器逻辑的纯客户端实现,所以它很经济实惠的,但当然它不是防作弊的。
Unity 构建非常容易破解,除非你使用 IL2CPP 并在客户端采取一些额外的步骤以确保你的客户端在某种程度上是防作弊的。
查看 Asset Store 中的反作弊工具。反作弊工具包只是一个例子。
还可以查看以下提示:破解(和保护)Unity 游戏的实用教程。
Photon Bolt
Bolt 有一些内置功能可以降低作弊的可能性,即使没有服务器也是如此。它定义了状态和所有消息,这使得任意信息不太可能被注入。
任何托管 Bolt 游戏的客户端都可以获得对状态的权威性权限。这意味着随机客户端(希望没有被黑)可以检查每个人的状态。
此外,你可以在无头模式下运行 Bolt,这会打开在专用机器上运行它的选项。为此,你必须协调 PC 以运行这些实例,并且你必须确保玩家使用这些实例。
有一些服务将为您部署和管理游戏服务器。Unity 的 Multiplay 服务就是一个例子。
服务器端监控
你可以使用我们的Photon Server Plugin SDK来实现“正在运行的游戏的服务器端分析”。你可以修改游戏的核心操作和事件,因此你可以对服务器进行任意数量的控制。
没有内置支持来运行你的游戏引擎(或你的完整游戏),因此服务器无法访问场景/关卡或任何其他数据。这使得该解决方案更适合“抽象”且不依赖场景的游戏。
在最好的情况下,你在服务器上运行与在客户端上相同的游戏逻辑,并让服务器为客户端创建状态更新。
您可以自己托管自定义逻辑,也可以让我们在Photon Enterprise Cloud中处理它。
完全服务器权限
同样,如果你的游戏是“抽象的”并且不需要关卡数据,那么在 Photon Server 中实现完整的服务器权限相对容易。
如果你需要在各种场景中运行物理,直接在 Photon 中执行此操作可能有许多问题。
在这种情况下,使用 Photon Bolt 或其他引擎内托管解决方案最有意义。
Bolt 具有支持 FPS 和 TPS 的内置功能,具有服务器授权模式,并且可以托管在你的计算机上。
序列化和二进制协议
序列化
Photon 及其客户端使用高度优化的二进制协议进行通信。更加紧凑,但易于解析。
Photon 必须将所有数据转换成这种二进制协议才能发送。对于一系列常用数据类型,这是自动完成的。在大多数客户端 API 上,你还可以为你可能需要的其他类注册自定义序列化方法。
Photon支持的类型
每个 Photon 支持的类型都需要一些为 type_info 保留的字节。
- 原始类型需要 1 个字节用于 type_info。
- 大多数集合需要 1 个额外字节来表示集合的类型。这不是指 Hashtable、Object 数组和 Byte 数组的情况,因为集合类型是 type_info 的一部分。
- 强类型集合只发送一次元素类型,而不是为每个元素发送 type_info。
- 所有集合都需要 2 个字节来存储它们的长度。这是因为长度的类型是
short
。字节数组是这个规则的例外。它的长度是类型的int
,它需要4个字节来存储长度。 - string[] 不能包含空值。改用empty。
Photon 的二进制协议通常支持和已知以下类型。由于某些语言并未提供所有列出的类型,因此某些 SDK 支持的类型较少。
Type (C#) | Size [bytes] (photon_sizeof) | Description |
---|---|---|
byte | 2 | 8 bit unsigned 2 = type_info(byte) + sizeof(byte) |
bool (boolean) | 2 | true or false 2 = type_info(bool) + sizeof(bool) |
short | 3 | 16 bit 3 = type_info(short) + sizeof(short) |
int (integer) | 5 | 32 bit 5 = type_info(int) + sizeof(int) |
long | 9 | 64 bit 9 = type_info(long) + sizeof(long) |
float | 5 | 32 bit 5 = type_info(float) + sizeof(float) |
double | 9 | 64 bit 9 = type_info(double) + sizeof(double) |
String | 3 + sizeof( UTF8.GetBytes(string_value) ) | length ≤ short.MaxValue 3 = type_info(String) + length_size; length_size = sizeof(short) |
Object[] (Object-array) | 3 + photon_sizeof(elements) | length ≤ short.MaxValue 3 = type_info(Object[]) + length_size; length_size = sizeof(short) |
byte[] (byte-array) | 5 + length | length ≤ int.MaxValue 5 = type_info(byte[]) + length_size; length_size = sizeof(int) |
array (array of type T, T[]) | 4 + photon_sizeof(elements) - length * type_info(T) | length ≤ short.MaxValue T-type can be any of the types listed in this table except byte. 4 = type_info(array) + type_info(T) + length_size; length_size = sizeof(short) |
Hashtable | 3 + photon_sizeof(keys) + photon_sizeof(values) | pairs count ≤ short.MaxValue 3 = type_info(Hashtable) + length_size; length_size = sizeof(short) |
Dictionary<Object,Object> | 5 + photon_sizeof(keys) + photon_sizeof(values) | pairs count ≤ short.MaxValue 5 = type_info(Dictionary) + 2 * type_info(Object) + length_size; length_size = sizeof(short) Dictionary keys should not be of type Dictionary. |
Dictionary<Object,V> | 5 + photon_sizeof(keys) + photon_sizeof(values) - count(keys) * type_info(V) | pairs count ≤ short.MaxValue V-type can be any of the types listed in this table. 5 = type_info(Dictionary) + type_info(Object) + type_info(V) + length_size; length_size = sizeof(short) Dictionary keys should not be of type Dictionary. |
Dictionary<K,Object> | 5 + photon_sizeof(keys) + photon_sizeof(values) - count(keys) * type_info(K) | pairs count ≤ short.MaxValue K-type can be any of the types listed in this table. 5 = type_info(Dictionary) + type_info(K) + type_info(Object) + length_size; length_size = sizeof(short) Dictionary keys should not be of type Dictionary. |
Dictionary<K,V> | 5 + photon_sizeof(keys) + photon_sizeof(values) - count(keys) * (type_info(K) + type_info(V)) | pairs count ≤ short.MaxValue K- and V-types can be any of the types listed in this table. 5 = type_info(Dictionary) + type_info(K) + type_info(V) + length_size; length_size = sizeof(short) Dictionary keys should not be of type Dictionary. |
Photon Unity 网络中的其他类型
Photon Unity Networking (PUN) 支持一些其他类型。它们被实现为“自定义类型”,如下所述。
你可能会注意到 Transform 和 PhotonView 不在此列表中。Transforms 不能“作为整体”发送,因此你必须单独发送位置、旋转和缩放。PhotonViews 通常由它们的viewId 引用,每个id在网络游戏中都是独一无二的,而且发送成本很低。
type (C#) | sizeof [bytes] | code | description |
---|---|---|---|
Vector2 | 12 | 23 (W) | 2 floats |
Vector3 | 16 | 22 (V) | 3 floats |
Quaternion | 20 | 17 (Q) | 4 floats |
Photon.Realtime.Player | 8 | 16 § | integer (Player.ActorNumber) |
自定义类型
对于上面未列出的任何类型,Photon 将需要你的帮助来反序列化/序列化重要值。
基本思想是编写两个方法将类转换为字节数组并返回,然后将它们注册到 Photon API。完成后,你可以在发送的任何消息中包含该类型的实例。
自定义类型有 2 个字节用于 type_info:一个字节表示它是自定义类型,再加一个字节用于自定义类型代码。Photon 支持多达 256 种自定义类型。我们建议从 255 及以下选择自定义类型代码。在为自定义类型选择代码时,请考虑到 PUN 默认注册 4 种类型,即上面表格里的四种类型。
Photon 将调用已注册类型的序列化方法,并自动为创建的字节数组添加 4 个字节的前缀:2 个字节用于必要的类型信息,2 个字节用于有效负载长度。由于 4 字节的开销,最好避免注册只有几个字节数据的类型。
Photon 服务器能够“原封不动的”转发未知的自定义类型。这就是为什么你不需要在 Photon Cloud 中注册你的类型。
确保在所有通信客户端上注册你的自定义类型。需要时在服务器端或插件上注册自定义类型。
RegisterType
方法返回一个布尔结果,它告诉你是否可以注册该类型。如果在自定义类型注册期间发生任何错误,该方法将返回false
并且不会更改任何内容。否则注册应该成功,返回值为true
. 如果自定义代码已被使用,则注册将失败并且该方法将返回 false。为相同的自定义类型覆盖已注册的序列化和反序列化方法将失败,并且仍将使用旧的。
C# 中的自定义类型
我们所有基于 C# 的 API(.NET、Unity、Xamarin 等)都提供了注册类的相同方式。有两种方法可以做到这一点,这取决于你是使用我们的自定义Steam
类还是仅使用“普通”字节数组。
字节数组法
要调用的静态方法是:
PhotonPeer.RegisterType(Type customType, byte code, SerializeMethod serializeMethod, DeserializeMethod deserializeMethod)
SerializeMethod
和DeserializeMethod
:
public delegate byte[] SerializeMethod(object customObject);
public delegate object DeserializeMethod(byte[] serializedCustomObject);
例如,我们实现了一个简单的基本MyCustomType
:
public class MyCustomType
{
public byte Id {
get; set; }
public static object Deserialize(byte[] data)
{
var result = new MyCustomType();
result.Id = data[0];
return result;
}
public static byte[] Serialize(object customType)
{
var c = (MyCustomType)customType;
return new byte[] {
c.Id };
}
}
并注册它:
PhotonPeer.RegisterType(typeof(MyCustomType), myCustomTypeCode, MyCustomType.Serialize, MyCustomType.Deserialize);
流缓冲方法
StreamBuffer
是我们自定义的Stream
类实现。它为你提供了字节数组包装器的所有优点,并支持 Photon 的内置可序列化类型。
要调用的静态方法是:
RegisterType(Type customType, byte code, SerializeStreamMethod serializeMethod, DeserializeStreamMethod deserializeMethod)
SerializeStreamMethod
和DeserializeStreamMethod
接口:
public delegate short SerializeStreamMethod(StreamBuffer outStream, object customobject);
public delegate object DeserializeStreamMethod(StreamBuffer inStream, short length);
让我们看看 PUN 如何实现对 Unity 的Vector2
支持。
你可以在“Assets\Photon\PhotonUnityNetworking\Code\CustomTypes.cs”下找到所有 PUN 的自定义类型实现。
一个Vector2
有 2 个浮点数:Vector2.x
和Vector2.y
. 虽然 Photon 支持浮点数,但Vector2
不支持。
基本上,使用任何 C# 方式来获取类似于浮点数的 4 个字节都可以。Photon 的 Protocol 类有一些 Serialize 方法,可以有效地将几种类型写入字节数组。
public static readonly byte[] memVector2 = new byte[2 * 4];
private static short SerializeVector2(StreamBuffer outStream, object customobject)
{
Vector2 vo = (Vector2)customobject;
lock (memVector2)
{
byte[] bytes = memVector2;
int index = 0;
Protocol.Serialize(vo.x, bytes, ref index);
Protocol.Serialize(vo.y, bytes, ref index);
outStream.Write(bytes, 0, 2 * 4);
}
return 2 * 4;
}
请注意, SerializeVector2 获取一个对象,并且必须首先将其转换为预设的 Vector2 类型。
相反,我们在 DeserializeVector2 中也只返回一个对象:
private static object DeserializeVector2(StreamBuffer inStream, short length)
{
Vector2 vo = new Vector2();
lock (memVector2)
{
inStream.Read(memVector2, 0, 2 * 4);
int index = 0;
Protocol.Deserialize(out vo.x, memVector2, ref index);
Protocol.Deserialize(out vo.y, memVector2, ref index);
}
return vo;
}
最后,我们必须注册 Vector2:
PhotonPeer.RegisterType(typeof(Vector2), (byte)'W', SerializeVector2, DeserializeVector2);
二进制协议
通信层
Photon 二进制协议分为几层。在最底层,UDP 用于承载要发送的消息。在这些标准数据中,几个标头反映了你期望 Photon 的属性:可选的可靠性、排序、消息聚合、时间同步和其他。
下图显示了各个层及其嵌套:
Photon服务器:二进制协议 UDP 层
换句话说:任何 UDP 包都包含一个 eNet 标头和至少一个 eNet 命令。每个 eNet 命令都携带我们的信息:操作、结果或事件。这些又包括操作标头和莫提供的数据。
示例:加入“somegame”
让我们看一下JoinRoom
操作(由 Lite 或 LoadBalancing 应用程序实现)。没有属性,这是一个带有单个参数的操作。
“操作参数”需要:2 + count(parameter-keys) + size(parameter-values) 字节。字符串“somegame”在 UTF8 编码中使用 8 个字节,而字符串需要另外 3 个字节(长度和类型信息)。总和:14 个字节。
参数被包装到一个操作头中,它是 8 个字节。 总和:22 个字节。
操作码不编码为参数。相反,它在操作头中。
该操作及其标头被包装到 eNet 的“发送可靠”命令中作为有效负载。该命令为 12 个字节。总和:34 字节。
让我们假设当没有其他命令排队时发送命令。eNet 包头为 12 个字节。Sum:46 个字节,用于可靠的有序操作。
最后但同样重要的是,eNet 包被放入一个 UDP/IP 数据报中,它添加了 28 字节的报头。与目前的 46 个字节相比这相当多。遗憾的是,这无法完全避免,但我们的命令聚合可以为你节省大量流量,共享这些标头。
完整的操作占用 74 个字节。
服务器必须确认此命令。ACK 命令为 20 个字节。如果这是单独在一个包中发送,它将返回 40 个字节的返回值。
Enet Channels
Enet Channels 允许你使用多个独立的命令序列。在单个通道中,所有命令都按顺序发送和分派。如果缺少可靠的命令,接收方将无法继续发送更新并且事件会延迟。
当某些事件(和操作)彼此独立时,你可以将它们放在单独的通道中。示例:聊天室消息应该是可靠的,但如果消息迟到,玩家的位置更新也不应因(暂时)丢失聊天消息而延迟。
默认情况下,Photon 有两个通道,通道 0 是默认发送操作。加入和离开操作总是在通道 0 中发送(为简单起见)。有一个“内部”通道 255 在内部用于连接和断开消息,在通道计数将被忽略。
通道优先级:最低的通道号放在第一位。当 UDP 包已满时,可能会稍后发送更高通道中的数据。
操作内容——可序列化类型
在客户端,操作参数及其值在哈希表中聚合。由于每个参数都类似于字节键,因此操作请求得到了简化,并且比任何“常规”哈希表使用的字节更少。操作和事件的结果以与操作参数相同的方式编码。一个操作结果总是包含:“操作码”、“返回码”和一个“调试字符串”。事件总是包含:“事件代码”和“事件数据”哈希表。
Enet 命令
name | size | sent by | description |
---|---|---|---|
connect | 44 | client | reliable, per connection |
verify connect | 44 | server | reliable, per connect |
init message | 57 | client | reliable, per connection (choses the application) |
init response | 19 | server | reliable, per init |
ping | 12 | both | reliable, called in intervals (if nothing else was reliable) |
fetch timestamp | 12 | client | a ping which is immediately answered |
ack | 20 | both | unreliable, per reliable command |
disconnect | 12 | both | reliable, might be unreliable in case of timeout |
send reliable | 12 + payload | both | reliable, carries a operation, response or event |
send unreliable | 16 + payload | both | unreliable, carries a operation, response or event |
fragment | 32 + payload | both | reliable, used if the payload does not fit into a single datagram |
UDP 数据包内容 - 标头
name | size [bytes] | description |
---|---|---|
udp/ip | 28 + size(optional headers) | IP + UDP Header. Optional headers are less than 40 bytes. |
enet packet header | 12 | contains up to byte.MaxValue commands (see above) |
operation header | 8 | in a command (reliable, unreliable or fragment) contains operation parameters (see above) |
调试
Photon Lag Simulation Gui
PUN 包含一个简单的 GUI 组件来控制 Photon 客户端的内置网络和延迟模拟。如果你的网络条件相对较好,你可以添加延迟和丢包来测试你的游戏在较差条件下的运行情况。
用法
将组件 PhotonNetSimSettingsGui 添加到场景中启用的游戏对象。在运行时,屏幕左上角显示当RTT和网络仿真控件:
- RTT:往返时间是服务器确认消息之前的平均毫秒数。方差值(在 +/- 后面)显示了 rtt 的稳定性(值越低越好)。
- Sim:启用和禁用模拟。网络条件的突然、重大变化可能会导致断开连接。
- Lag:为所有传出和传入消息添加固定延迟。以毫秒为单位。
- Jit:为每条消息添加“最多 X 毫秒”的随机延迟。
- Loss:设置丢包率。。
Photon Stats Gui
PhotonStatsGui 是一个简单的 GUI 组件,用于在运行时显示跟踪的网络指标。它可以从“UtilityScripts”文件夹中的 PUN 和 PUN+ 包中获得。
用法
只需将 PhotonStatsGui 组件添加到任何活动的游戏对象即可。在运行时,一个窗口会显示消息计数。
确保检查器中的“Traffic Stats On”复选框被选中。它控制是否收集流量统计信息。GUI 中的“stats on”切换是相同的值。
一些控件可让您配置窗口:
- buttons:显示“stats on”、“reset stats”和“to log”按钮
- traffic:显示较低级别的网络流量(每个方向的字节数)
- health:显示发送、调度的时间和最长的间隔
消息统计
显示的最上方是“msg”的计数器。任何操作、响应和事件都会被计算在内。显示的是这些消息的总传出、传入和总和,以及跟踪的时间跨度的平均值。传入的消息将根据每个房间的玩家数成倍增加。如果每个单独的客户端发送大量消息,则消息计数会迅速增加。
流量统计
这些是字节和数据包计数器。通过网络离开或到达的任何东西都在这里计算。即使消息很少,它们也可能会意外地很大,并且仍然会导致功能较弱的客户端断开连接。你还可以看到,当不发送消息时,也会发送一些包,以保持活跃的连接。
网络状况统计
以“最长间隔longest delta between”开头的块是关于客户端的性能。我们测量发送和调度的连续调用之间经过了多少时间。它们应该每秒被调用十次。如果这些值超过一秒,请检查更新调用延迟的原因。加载资产时,PhotonNetwork.IsMessageQueueRunning
应设置为false。
“Stats On”按钮(启用流量统计)
Photon 库可以跟踪各种网络统计信息,但通常会关闭此功能。PhotonStatsGui 将启用跟踪并显示这些值。
“Reset”按钮
这会重置统计信息,但会继续跟踪它们。跟踪不同情况下的消息计数很有用。
“To Log”按钮
按下它会记录当前的统计值。
其他
常见问题
Photon Realtime 和 PUN 有什么区别?
Photon Realtime 包含了 Photon 负载平衡所需的所有通用功能。Photon Realtime(又名 LoadBalancing)是许多使用 Photon 的游戏的基础。
虽然 Photon Realtime 独立于 Unity,但 PUN 为 Unity 添加了许多舒适的功能,并使 Realtime(底层)更易于使用。
两种产品共享相同的后端、相同的服务器应用程序、相同的底层内容、相同的核心概念。起初,PUN 旨在成为更好的 UNet(旧的 Unity Networking):保留类似的 API,具有更可靠的后端和丰富的功能。然后它慢慢分化并成为 Unity 多人游戏的第一大解决方案。
虽然我们确实有 Photon Realtime Unity SDK,但 PUN 具有更多高级别的开箱即用功能,例如:
- Magic Unity 回调
- 序列化和同步的额外 Unity 组件。 PhotonView
- PunRPC
- 离线模式
- …
LoadBalancing API 和 Photon Realtime 有什么区别?
LoadBalancing API 和 Photon Realtime 是同一事物的两个不同名称。LoadBalancing API 或 LoadBalancing Client API 是我们为 Photon Realtime 产品提供的客户端 SDK 中可用的编程接口。
什么是Default Photon Region?
只要至少有一个region可用,客户端就应该能够连接到 Photon Cloud。因此,为了保证这一点,当开发人员没有明确设置或选择“最佳区域”选项时,配置或使用默认值。默认值可能因客户端 SDK 而异。在原生 SDK 中,它是服务器返回OpGetRegions
的区域列表索引 0 处的值。在 Unity 和 DotNet SDK 上,默认区域应为“EU。
负载均衡
Photon Room 支持的最大玩家数量是多少?
大多数 Photon 多人游戏有 2-16 名玩家,但每个房间的玩家/同伴的理论限制可能相当高。有 32 甚至 64 名玩家的 Photon 游戏,在虚拟会议场景中可能有数百人。但是,每秒发送太多消息(每个房间的 msg/s)可能会导致性能问题,具体取决于客户端处理数据的能力。虽然在回合制游戏中玩家人数较多是完全可以的,但在快节奏的动作游戏中超过 8 名玩家可能需要你进行兴趣组设置。这样,并不是每个玩家都会收到来自所有其他玩家的每条消息。见兴趣组一节。
每个房间的玩家数量是增加游戏房间内数据流量的主要因素:这就是为什么我们建议将每个房间的 msg/s 数保持在 500 以下。Photon 不强制执行此限制。
Photon Strings限制吗?
Photon 使用字符串有很多用途:房间名称、大厅名称、用户 ID、昵称、自定义属性键等。
Photon 二进制协议可以序列化最多 32767 个单字节字符的字符串。对于name和用户 ID,36 个字符就足够了(例如,一个 GUID 是 36 个字符)。但是,对于自定义属性键,你应该使用较短的字符串来最小化它们的开销。这对于大厅中可见的属性尤其重要,因为这些属性是房间列表的一部分,并且会发送给大厅中的每个人,而不仅仅是房间中的几个客户端。
自定义属性的数量是否有限制?
不会。但是请注意,设置的自定义属性越多,客户端加载时间就越长,因为在加入房间时,客户端也会收到所有属性。如果它们太多并且客户端的加载时间超过一定时间,这可能会导致它们断开连接。
可以使用 Photon 发送巨大的数据吗?
我们不建议使用 Photon 传输大数据(即文件)。
Photon Cloud 对客户端缓冲区的服务器端限制为 500KB。因此有以下情况:
- 对于Photon Cloud 上的每个客户端缓冲区大小> 500KB 来说太大。如果客户端在短时间内达到此限制,它将被服务器断开连接。
- “太大”无法使用 UDP 发送,而不将其拆分为多个大于 100KB 的 片段。
- “太大”无法发送而不将其拆分为多个大于 1.2KB 的 UDP 数据包(包括协议开销)。
对于定期发送的消息(每秒 10 次或更频繁),我们建议将其大小保持在 1KB 以下。
如果一条消息很少被发送(例如在比赛开始时发送一次),那么多 KB 的大小仍然可以,但我们仍然建议将其保持在 10KB 以下。
哪些数据应该可靠发送,哪些数据应该不可靠发送?
首先,你应该知道,只有当使用的协议是 UDP 时,可靠性才是一个选项。TCP 有它自己的“可靠性”机制,这里没有涉及。
发送可靠的东西意味着我们应该确保它到达目标。因此,如果我们在等待足够长的时间后没有收到确认,我们会重复发送,直到收到确认或超过重试次数。此外,重复可靠事件可能会导致额外的延迟并使后续事件延迟。
不使用可靠性的示例:
- 实时游戏中的玩家位置更新
- 语音或视频聊天
使用可靠性的示例:
- 回合制游戏中的回合事件
为什么我的游戏中有这么多断线?
详见断线分析一节。
如何计算每个房间每秒的消息数?
Photon 服务器每秒计算接收和发送消息的总数,并将其除以房间总数(在同一主服务器上)。
任何操作请求或操作响应或事件都被视为消息。Photon操作返回一个可选的操作响应并触发零个或多个事件。缓存的事件也算作消息。
Operation | Success: Best Case | Success: Average Case | Success: Worst Case |
---|---|---|---|
Create | 2 (SuppressRoomEvents=true) | 3 + Join event (SuppressRoomEvents=false, default) | 3 |
Join | 2 + k (SuppressRoomEvents=true) + k * cached custom event | 2 + n + k + n * Join event (SuppressRoomEvents=false, default) | 2 + 2 * n + k + n * ErroInfo event (HasErrorInfo=true) |
Leave | 2 (SuppressRoomEvents=true) | 1 + n + (n - 1) * Leave event (SuppressRoomEvents=false, default) | 2 + (n - 1) * 2 + (n - 1) * ErroInfo event (HasErrorInfo=true) |
RaiseEvent | 1 (no operation response) (target: interest group with no subscribers) | 1 + n + n * custom event (target: all/broadcast) | 2 + 2 * n + n * ErroInfo event (HasErrorInfo=true) + Auth event (token refresh) |
SetProperties | 2 Broadcast=false | 2 + n + n * PropertiesChanged event (default: Broadcast=true, BroadcastPropertiesChangeToAll=true) | 2 + 2 * n + n * ErrorInfo event (HasErrorInfo=true) + 1 in case of CAS or BroadcastPropsChangeToAll |
如何计算用户消耗的流量?
这是一个复杂的话题。首先,你需要知道所做的任何计算都只是理论上的估计,可能无法反映现实。我们建议构建概念验证并使用它来收集真实数据。
这里要说的是如何估计房间内单个用户产生的流量:
让我们假设以下内容:
- 一个房间有 N 个玩家。
- 玩家每秒发送 F 条消息(消息发送速率,单位为 Hz)
- 平均消息大小为 X(以字节为单位,有效负载 § + 协议开销 (O))
- 平均每个玩家每个月在你的游戏上花费 H 小时
如果我们不考虑 ACK,连接处理(建立、保持活动等)命令并重新发送。然后我们说,平均而言,CCU 在您的游戏中消耗 C(以字节/月为单位),如下所示:
C = X * F * N * H * 60(分钟/小时)* 60(秒/分钟)
断线后如何快速重新加入房间?
要在加入房间时从意外断开连接中恢复,客户端可以尝试重新连接并重新加入房间。我们称之为“快速重新加入”。只有在以下情况下,快速重新加入才会成功:
- 房间仍然存在于同一服务器上或可以加载:如果玩家离开房间,如果仍有其他玩家,则后者仍可以在 Photon 服务器上保持活跃。如果玩家是最后一个离开的人并且房间变空了,那么 EmptyRoomTTL 就是它会在等待玩家加入或重新加入时保持存活的时间。如果在 EmptyRoomTTL 之后房间仍然是空的并且没有人加入,那么它将从 Photon 服务器中删除。
- Actor在内部被标记为非活跃:具有相同 UserId 的Actor存在于Actor列表中,但当前未加入房间。这要求 PlayerTTL 不为 0。
- 非活跃Actor的 PlayerTTL 没有过期:当Actor离开房间并选择返回时,我们保存他的停用时间戳。当 Actor尝试重新加入时,如果重新加入尝试的时间和 停用时间戳 时间之间的毫秒差超过 PlayerTTL,则从 Actor的列表中删除该 Actor,并且重新加入失败。因此,活跃Actor只能在停用时间戳后的 PlayerTTL 毫秒内重新加入房间。
在PUN中,你可以调用PhotonNetwork.ReconnectAndRejoin()
.
为什么我的游戏有这么多延迟?
如果你遇到不常见的 ping (RTT) 问题,则很难判断在这些情况下实际发生了什么。除了游戏和 PUN 本身,也可能与网络问题有关。基础功能通常非常复杂且难以控制。PUN 中的一些消息是可靠的(例如 RPC),当这些消息丢失时,需要重新发送。如果这种情况不止一次发生,那么 ping 会迅速上升,并且由于缺乏更新,游戏中的逻辑会停止。
PUN 具有抗重发延迟的功能,但它不能完全避免它。
滞后可能导致断开连接。详见其他-断连一节。
我使用的是什么 PUN 版本?
PUN 版本可以通过 3 种不同的方式获得:
- 在 Unity 编辑器中,PhotonServerSettings 面板的顶部。
- 从
PhotonNetwork.PunVersion
字符串字段。 - 从“Assets\Photon\PhotonUnityNetworking\changelog.txt”,检查顶部的第一个条目。
断连分析
断开连接的原因
有一些客户端根本无法连接的情况(例如,服务器无法访问、服务器地址错误、没有可用的 DNS、自托管服务器未启动等)。在这种情况下,这些不被视为断开连接,而是“(初始)连接失败”。
客户端 SDK 提供断开连接回调和断开连接原因。使用这些来调查遇到的意外断开连接。在这里,我们列出了主要的断开连接原因以及它们是在客户端还是服务器端引起的。
客户端断开连接
- 客户端超时:来自服务器的没有/太迟的 ACK。见下面超时断连小节。
- 客户端套接字异常(连接丢失)。
- 接收时客户端连接失败(缓冲区已满,连接丢失)。见下面流量问题和缓冲区已满小节。
- 发送时客户端连接失败(缓冲区已满,连接丢失)。见下面流量问题和缓冲区已满小节。
服务器断开连接
- 服务器端超时:来自客户端的没有/太迟的 ACK。有关详细信息,见下面超时断连小节。
- 服务器发送缓冲区已满(消息太多)。见下面流量问题和缓冲区已满小节。
- 达到订阅的CCU 限制。
超时断连
与普通的 UDP 不同,Photon 的可靠 UDP 协议在服务器和客户端之间建立了连接:UDP 包中的命令具有序列号和一个标志(如果它们是可靠的)。如果是这样,接收端必须确认该命令。可靠的命令在很短的时间间隔内重复,直到确认到达。如果没有到达,则连接超时。
双方都从他们的角度独立地监控这种联系。双方都有自己的规则来决定对方是否仍然可用。
如果检测到超时,则在连接的那一侧发生断开连接。一旦一方认为另一方不再响应,就不会向其发送任何消息。这就是为什么超时断开单方的而不是同步的。
超时断开连接是最常见的问题。
当你遇到频繁的超时时,没有单点故障,但有一些常见的情况会导致问题以及一些解决方法。。
这是一个快速清单:
- 检查发送的数据量。如果出现峰值或者消息/秒速率非常高,这可能会影响连接质量。见下面的性能调整-少发小节。
- 检查你是否可以在其他硬件和其他网络上重现该问题。见下面的修复-尝试新的连接小节。
- 你可以调整重新发送的次数和时间。见下面的性能调整-调整重新发送小节。
- 如果你正在制作移动应用程序,请阅读移动后台应用程序。
- 如果你想使用断点调试你的游戏,见调试小节。
流量问题和缓冲区已满
Photon 服务器和客户端通常会在将某些命令实际放入包中并通过 Internet 发送之前对其进行缓冲。这允许我们将多个命令聚合到(更少的)包中。
如果某方产生大量命令(例如通过发送大量大事件),则缓冲区可能会用完。
填充缓冲区也会导致额外的延迟:你会注意到事件需要更长的时间才能到达另一端。操作响应不像往常那么快。
见下面的修复-少发小节。
修复
检查日志
这是你需要做的第一个检查。
所有客户端都有一些回调来提供有关内部状态更改和问题的日志消息。你应该记录这些消息并在出现问题时访问它们。
如果没有出现任何有用的信息,你通常可以在一定程度上增加日志记录。检查 API 参考如何执行此操作。
如果你自定义了服务器,请检查那里的日志。
启用 SupportLogger
该SupportLogger
工具记录了调试 Photon 问题时最常用的信息,例如(缩写)AppId、版本、区域、服务器 IP 和一些回调。
在 PUN 2 中,可以使用 PhotonServerSettings 中的复选框启用 SupportLogger。控制台或日志文件将包含新的日志条目。不要手动添加 SupportLogger 作为组件。
尝试另一个项目
Photon 的所有客户端 SDK 都包含一些demo。在你的目标平台上使用其中之一。如果demo也失败,则更有可能是连接问题。
尝试其他服务器或区域
使用Photon Cloud,可以轻松使用其他区域。
自己托管?首选物理机而不是虚拟机。使用靠近服务器的客户端(但不在同一台机器或网络上)测试最小延迟(往返时间)。考虑在你的客户端附近添加服务器。
尝试其他连接
在某些情况下,特定硬件可能会导致连接失败。尝试其他 WiFi、路由器等。检查其他设备是否运行正常。
尝试替代端口
自 2018 年初以来,我们在所有 Photon Cloud 部署中支持新的端口范围:端口从 27000 开始,而不是使用 5055 到 5058。
在 PUN 中,可以通过PhotonNetwork.UseAlternativeUdpPorts = true
在连接之前进行设置。
启用 CRC 检查
有时,包在客户端和服务器的传输途中被损坏。当路由器或网络特别繁忙时,这种情况更有可能发生。
Photon 每个包都有一个可选的 CRC Check。由于这需要一些性能,因此默认情况下我们没有激活它。
在客户端中启用 CRC 检查,但服务器也会在你这样做时发送 CRC。
连接前设置PhotonNetwork.CrcCheckEnabled = true
Photon 客户端跟踪由于启用的 CRC 检查而丢弃了多少包。
你可以监控PhotonNetwork.PacketLossByCrcCheck
。
性能调整
检查流量统计
在某些客户端平台上,你可以直接在 Photon 中启用Traffic Statistics
。这会跟踪各种重要的性能指标,并且可以轻松记录。
在 C# 中,流量统计信息在 LoadBalancingPeer 类中作为TrafficStatsGameLevel
属性提供。
例如,TrafficStatsGameLevel.LongestDeltaBetweenDispatching
用于检查连续调用DispatchIncomginCommands
之间的最长时间。如果这个时间超过几毫秒,你可能会有一些延迟。检查LongestDeltaBetweenSending
以确保你的客户端有发送。
TrafficStatsIncoming
和TrafficStatsOutgoing
属性为输入和输出字节、命令和包提供更多统计信息
调整重新发送
PUN 有两个属性允许你调整重新发送时间:
快速重新发送
PhotonNetwork.QuickResends
加快接收端未确认的可靠命令的重复次数。如果某些消息被丢弃,结果会产生更多的流量以缩短延迟。
MaxResendsBeforeDisconnect
PhotonNetwork.MaxResendsBeforeDisconnect
定义客户端重复单个可靠消息的频率。
在某些情况下,PhotonNetwork.QuickResends
设置为 3 同时PhotonNetwork.MaxResendsBeforeDisconnect
设置为7时你会看到很好的效果。
虽然更多的重复并不能保证更稳定的连接,但肯定会导致更长的延迟。
检查重新发送可靠的命令
你应该开始监控ResentReliableCommands
。每次重新发送可靠命令时,此计数器都会增加(因为来自服务器的确认没有及时到达)。
PhotonNetwork.ResentReliableCommands
如果此值超出上限,则连接不稳定且 UDP 数据包无法正常通过(在任一方向)。
少发
你通常可以减少发送以避免流量问题。这样做有很多不同的方法:
不要发送超过所需的内容
只交换完全必要的东西。只发送相关值并尽可能多地基于它们进行操作。根据上下文优化发送的内容。非关键数据应该在接收端根据同步的数据或游戏中发生的情况重新计算,而不是通过同步强制。
例子:
- 在 RTS 中,你可以为一堆单位发送一个数据。这比每秒十次发送每个单元的位置、旋转和速度要精简得多。
- 在射击游戏中,将射击目标作为位置和方向。子弹通常沿直线飞行,因此不必每 100 毫秒发送一次单独的位置。你可以在子弹击中任何东西或在它移动一定的”单位后清理它。无需实例化和销毁每个子弹。
- 不要发送动画。通常你可以从玩家的输入和动作中获得所有的动画。发送的动画很有可能会延迟,并且播放得太晚通常看起来有问题。
- 使用增量压缩。仅在自上次发送后发生更改时发送值。使用数据插值来平滑接收端的值。它比强行同步更可取,并且可以节省流量。
不要发送太多
优化交换类型和数据结构。
例子:
- 对小整数使用字节而不是整数,尽可能使用整数而不是浮点数。
- 要避免交换字符串,而是更喜欢枚举/字节。
- 只在明确的情况下交换自定义类型。
- 通常避免用
Vector3
去控制角色的旋转,因为它很可能只围绕垂直轴旋转。所以编写额外的代码来提取垂直旋转并且只发送这个值,那你将为那个参数节省 2/3 的大小。 - 不要试图将数据组合成数组。使用
stream.SendNext()
将每个变量所有内容拆分为单独的。 - 小心过度使用自定义属性。如果你在一个长期运行的游戏中设置了很多自定义属性,加入玩家将有很多事件要接受。当客户端在加入房间时掉线频繁,请检查此选项。
使用其他服务下载静态或更大的数据(例如地图)。Photon 不是作为内容交付系统构建的。使用基于 HTTP 的内容系统通常更便宜且更易于维护。任何大于最大传输单元 (MTU) 的东西都将被分割并作为多个可靠的包发送(它们必须到达才能再次组装成完整的消息)。
不要发送太频繁
- 降低发送速率,如果可能的话,应该低于 10。这当然取决于你的游戏玩法。这对流量有很大影响。你还可以根据用户的活跃度或交换的数据使用自适应或动态发送速率,这也很有帮助。
- 尽可能发送不可靠的数据。如果必须尽快发送另一个更新,可以在大多数情况下使用不可靠的消息。不可靠的消息永远不会导致重复。示例:在 FPS 中,玩家位置通常可以不可靠地发送。
尝试降低 MTU
通过客户端的设置,你可以强制服务器和客户端使用比平常更小的最大包大小。降低 MTU 意味着你需要更多的包来发送一些消息。
设置PhotonNetwork.NetworkingClient.LoadBalancingPeer.MaximumTransferUnit = 520;
。
工具
Wireshark
这个网络协议分析器和记录器对于找出游戏网络层实际发生的事情非常有用。使用此工具,我们可以查看网络方面的事数据。
安装并启动。第一个工具栏图标将打开(网络)接口列表。
Wireshark 工具栏
你可以选中有流量的接口旁边的框。如有疑问,请记录多个接口。接下来,单击“选项”。
Wireshark - 捕获接口
我们不想要所有的网络流量,因此你必须为每个检查的接口设置一个过滤器。在下一个对话框(“捕获选项”)中,找到选中的界面并双击它。这将打开另一个对话框“接口设置”。在这里你可以设置过滤器。
Wireshark - 接口设置
记录任何与 Photon 相关信息的过滤器如下所示:
(udp || tcp) && (port 5055 || port 5056 || port 5057 || port 5058 || port 843 || port 943 || port 4530 || port 4531 || port 4532 || port 4533 || port 9090 || port 9091 || port 9092 || port 9093 || port 19090 || port 19091 || port 19093 || port 27000 || port 27001 || port 27002)
当按下“开始”时,将在你连接时开始记录。重现问题后,停止记录(第三个工具栏按钮)并保存。
平台信息
Unity
PUN 会每隔一段事件执行Service
调用。
但是,Unity在加载场景和资产或拖动独立窗口时不会调用Update
。
要在加载场景时保持连接,你应该设置PhotonNetwork.IsMessageQueueRunning = false
.
暂停消息队列有两个效果:
- 底层线程将在没有调用
Update
时调用SendOutgoingCommands
。这使连接保持活动状态,仅发送确认但不发送事件或操作(RPC 或同步更新)。此线程不执行传入数据。 - 所有传入的更新都进入队列。既不调用 RPC,也不更新观察对象。当更改Level时,这可以避免调用前一个关卡中的 RPC。
如果你使用我们的 Photon Unity SDK,你可能会在某些 MonoBehaviourUpdate
方法中进行Service
调用。
为了确保SendOutgoingCommands
在加载场景时调用 Photon 客户端,请实现background thread。此线程应在每次调用之间暂停 100 或 200 毫秒,因此它对性能不会有太大影响。
从意外断开中恢复
断开连接会发生,可以减少,但无法避免。因此,当那些意外断开连接发生时,尤其是在游戏中,最好实施恢复操作。
何时重新连接
首先,你需要确定断开连接的原因。一些断开连接可能是由于无法通过简单的重新连接解决或绕过的问题。相反,这些案件应分开处理,逐案处理。
快速重新加入 (ReconnectAndRejoin)
Photon 客户端 SDK 提供了一种在加入房间时断开连接后尽快重新加入房间的方法。这称为“快速重新加入”。Photon 客户端在本地缓存身份验证令牌、房间名称和游戏服务器地址。因此,当游戏中途断开连接时,客户端可以做一个捷径:直接连接到游戏服务器,使用保存的令牌进行身份验证,然后重新加入房间。
在 PUN 中,使用PhotonNetwork.ReconnectAndRejoin()
. 检查此方法的返回值以确保启动快速重新加入操作。
为了使重新连接和重新加入成功,房间需要有 PlayerTTL != 0。但这并不能保证重新加入会起作用。如果重新连接和身份验证成功,重新加入可能会失败并出现以下错误之一:
- GameDoesNotExist (32758):房间在断开连接时从服务器中删除。这可能意味着你是断开连接时离开房间的最后一个Actor,并且 0 <= EmptyRoomTTL < PlayerTTL 或 PlayerTTL < 0 <= EmptyRoomTTL。
- JoinFailedWithRejoinerNotFound (32748):Actor在断开连接时被从房间中移除。这可能意味着 PlayerTTL 太短且已过期,我们建议至少为 12000 毫秒以允许快速重新加入。
- PluginReportedError (32752):这可能意味着你使用 webhook 并且 PathCreate 返回的 ResultCode 不是 0。
- JoinFailedFoundActiveJoiner (32746):这不太可能发生,但它可能发生。这意味着另一个使用相同 UserId 的客户端在你断开连接时设法重新加入房间。
可以在回调OnJoinRoomFailed
中捕获这些错误。
重新连接
如果客户端在房间外断开连接或快速重新加入失败(ReconnectAndRejoin
返回 false),你仍然可以只进行重新连接。客户端将重新连接到主服务器并在那里重用缓存的身份验证令牌。
在 PUN 中,这是使用PhotonNetwork.Reconnect()
. 检查此方法的返回值以确保启动快速重新加入过程。
在某些情况下添加以下内容可能很有用:
- 检查连接是否按预期工作(互联网连接可用、服务器/网络可达、服务状态)
- 重新连接尝试计数器: max. retries
- 重试之间的回退计时器
示例 (C#)
using Photon.Pun;
using UnityEngine;
using Photon.Realtime;
public class RecoverFromUnexpectedDisconnectSample : MonoBehaviourPunCallbacks
{
public override void OnDisconnected(DisconnectCause cause)
{
if (this.CanRecoverFromDisconnect(cause))
{
this.Recover();
}
}
private bool CanRecoverFromDisconnect(DisconnectCause cause)
{
switch (cause)
{
// the list here may be non exhaustive and is subject to review
case DisconnectCause.Exception:
case DisconnectCause.ServerTimeout:
case DisconnectCause.ClientTimeout:
case DisconnectCause.DisconnectByServerLogic:
case DisconnectCause.DisconnectByServerReasonUnknown:
return true;
}
return false;
}
private void Recover()
{
if (!PhotonNetwork.ReconnectAndRejoin())
{
Debug.LogError("ReconnectAndRejoin failed, trying Reconnect");
if (!PhotonNetwork.Reconnect())
{
Debug.LogError("Reconnect failed, trying ConnectUsingSettings");
if (!PhotonNetwork.ConnectUsingSettings())
{
Debug.LogError("ConnectUsingSettings failed");
}
}
}
}
}
补充
-
UNet是p2p的,ip直连,需要nat网络穿透,服务商提供的nat服务有限,性能不好,同时unet性能负载有限。局域网的话推荐UNet
-
PUN C/S架构 与UNet类似,Realtime则是底层架构
-
MonoBehaviourPunCallbacks继承自MonoBehaviour和IPunCallbacks,含有mb的所有功能以及pun的回调,推荐继承这个
-
通过PhotonView组件进行角色的网络同步,这里viewID为角色ID+后三位标识,意味着是1号玩家的001号角色。Ownership Transfer一般是Fixed。Synchronization同步方式,UnreliableOnChange:值改变时通过不可靠传输进行同步
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7V4Bk6Wo-1645156427987)(data:image/gif;base64,R0lGODlhAQABAPABAP///wAAACH5BAEKAAAALAAAAAABAAEAAAICRAEAOw==)][外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-F2e2ovxO-1645156427987)(data:image/gif;base64,R0lGODlhAQABAPABAP///wAAACH5BAEKAAAALAAAAAABAAEAAAICRAEAOw==)]
针对特定的可同步组件,会有相应的一个photonview组件进行同步。可以在photonAnimatorView中选择同步方式Discrete是离散的,每秒十次,而Continuous是连续的,每帧同步,顺滑但是网络负载高,当同步的parameters是trigger时【只会在一帧触发】,要注意放这个组件放在栈的最后,否则可能会同步失效。
if (photonView.IsMine == false && PhotonNetwork.IsConnected == true) //最基本鉴权操作 这里isconnect判断是用于离线调试,只有在链接状态下才进行鉴权
{
return;
}
通过继承IPunObservable接口去同步变量,注意同步发送和接收的顺序要一致,同时要在
photonview中添加观察的组件
public void OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info)
{
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();
}
}
未完待续。。。后续再看有什么补充的
这里是个人学习photon的一些总结,以及学习photon官方文档和对其的部分翻译和整理,都是些个人觉得基础和常用的部分,有什么错误谢谢指出~
this.Recover();
}
}
private bool CanRecoverFromDisconnect(DisconnectCause cause)
{
switch (cause)
{
// the list here may be non exhaustive and is subject to review
case DisconnectCause.Exception:
case DisconnectCause.ServerTimeout:
case DisconnectCause.ClientTimeout:
case DisconnectCause.DisconnectByServerLogic:
case DisconnectCause.DisconnectByServerReasonUnknown:
return true;
}
return false;
}
private void Recover()
{
if (!PhotonNetwork.ReconnectAndRejoin())
{
Debug.LogError("ReconnectAndRejoin failed, trying Reconnect");
if (!PhotonNetwork.Reconnect())
{
Debug.LogError("Reconnect failed, trying ConnectUsingSettings");
if (!PhotonNetwork.ConnectUsingSettings())
{
Debug.LogError("ConnectUsingSettings failed");
}
}
}
}
}
# 补充
- UNet是p2p的,ip直连,需要nat网络穿透,服务商提供的nat服务有限,性能不好,同时unet性能负载有限。局域网的话推荐UNet
- PUN C/S架构 与UNet类似,Realtime则是底层架构
- MonoBehaviourPunCallbacks继承自MonoBehaviour和IPunCallbacks,含有mb的所有功能以及pun的回调,推荐继承这个
- 通过PhotonView组件进行角色的网络同步,这里viewID为角色ID+后三位标识,意味着是1号玩家的001号角色。Ownership Transfer一般是Fixed。Synchronization同步方式,UnreliableOnChange:值改变时通过不可靠传输进行同步
![img](https://img-blog.csdnimg.cn/01844e6012464e3ca0e65e009c0f7c19.png?x-oss-process=image/watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBA5LuA5LmI5pe25YCZ5omN6IO95Z2a5oyB5YGa5aW95LiA5Lu25LqL5ZWK,size_7,color_FFFFFF,t_70,g_se,x_16)[外链图片转存中...(img-7V4Bk6Wo-1645156427987)]![img](https://img-blog.csdnimg.cn/c2569a8c445543f280e6c8473efb5112.png?x-oss-process=image/watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBA5LuA5LmI5pe25YCZ5omN6IO95Z2a5oyB5YGa5aW95LiA5Lu25LqL5ZWK,size_7,color_FFFFFF,t_70,g_se,x_16)[外链图片转存中...(img-F2e2ovxO-1645156427987)]
针对特定的可同步组件,会有相应的一个photonview组件进行同步。可以在photonAnimatorView中选择同步方式Discrete是离散的,每秒十次,而Continuous是连续的,每帧同步,顺滑但是网络负载高,当同步的parameters是trigger时【只会在一帧触发】,要注意放这个组件放在栈的最后,否则可能会同步失效。
```cs
if (photonView.IsMine == false && PhotonNetwork.IsConnected == true) //最基本鉴权操作 这里isconnect判断是用于离线调试,只有在链接状态下才进行鉴权
{
return;
}
通过继承IPunObservable接口去同步变量,注意同步发送和接收的顺序要一致,同时要在
photonview中添加观察的组件
public void OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info)
{
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();
}
}
未完待续。。。后续再看有什么补充的
这里是个人学习photon的一些总结,以及学习photon官方文档和对其的部分翻译和整理,都是些个人觉得基础和常用的部分,有什么错误谢谢指出~
pun2官方文档链接https://doc.photonengine.com/zh-cn/pun/current/getting-started/pun-intro