1.总览。
多人游戏基本结构:Clent/Server,分为Authoritative Server和Non-Authoritative Server两种,前者客户端发送消息,服务器端反馈结果,好处是有效防止客户端作弊,并统一不同客户端之间的物理表现和互动状况,缺陷是存在网络延时,很有可能每发出一个命令要过一段时间才能接收到反馈。解决方法是client-side prediction客户端预测,客户端预测服务器的反馈,但不能做出关键性事件的预测,比如某个怪物的死亡。关于client-side prediction技术可以到网上查阅相关资料,这里不赘述。
Non-Authoritative Sever有客户端自己处理用户输入和对象逻辑,不需要要client-side prediction技术。这种情况服务器端要处理的数据量相对比较小。
网络通信的方法:Remote Procedure Calls(RPC,远程进程调用)和State Synchronization(状态同步)。
NAT punchthrough:网络连接可能是复杂的,比如中间要通过一个带公共IP的NAT路由器,怎样把NAT的内部私有IP和外部计算机连接?可以利用一个叫做Facilitator的服务器和private IP接触并获知其所用的IP地址和端口号。另外,内部防火墙玩家可以更改设置,外部防火墙也可以使用这种NAT punchthrough技术穿透。
Minimizing Network Bandwidth:最小化网络开支,只传输绝对重要的数据,比如客户端完全可以独立处理角色模型而不需要服务器发送数据来同步。另外记住,你可以使用一个自带关卡标示的RPC调用来让所有客户端同时装载这个关卡。
2.基础函数。
建立服务器:Network.InitializeServer().
函数原型:static function InitializeServer (connections : int, listenPort : int, useNat : boolean) : NetworkConnectionError
其中connections是允许连接数,useNat是否允许NAT punchthrough连接。
一个初始化例子:
public class example : MonoBehaviour { void LaunchServer() { Network.incomingPassword = "HolyMoly"; bool useNat = !Network.HavePublicAddress(); Network.InitializeServer(32, 25000, useNat); } }
其中Network.HavePublicAddress()查看是否拥有IPV4公共IP.这是简单那的测试方法,另外一个更值得研究的方法是Network.TestConnction().
建立连接到服务器:Network.Connect().
熟悉一下Network类总是有好处。
使用Network View组件进行通信,这一点很重要,它让你可以使用RPC调用和State Synchronization.每个组件有一个唯一的NetworkViewID,每个数据包都对应发送到一个由NetworkViewID指定的Network View所链接的对象上。当使用Network.Instantiate()创建Network对象时,会自动分配Network View,不用开发者操心。当然你也通过RPG调用在每个客户机上实例化一个Network View对象,然后使用Network.AllocateViewID()函数手动分配NetworkViewID。
当你向一个对象添加NetworkView组件,并指定状态同步,需要选择NetworkView所监视的组件,这可以是该对象的transform,animation,script,或是rigidbody.如果不使用状态同步而只是用PRC调用,则state Synchronization设为off即可,只要有一个Network View(不论是否开启state Synchronization)你都可以使用RPC.
设置state Synchronization为Reliable Delta Compression,为可靠传输,数据按序发送接收。
同步监视对象是script时,需要使用OnSerializeNetworkView显式化序列数据:
function OnSerializeNetworkView (stream : BitStream, info : NetworkMessageInfo) { var horizontalInput : float = Input.GetAxis ("Horizontal"); stream.Serialize (horizontalInput); }
此代码当你本地更新或收到stream的更改时总是写horizontalInput的值。有时候你在本地更新和收到更新时,需要做不同的事情,此时敲入如下代码:
function OnSerializeNetworkView (stream : BitStream, info : NetworkMessageInfo) { var horizontalInput : float = 0.0; if (stream.isWriting) { // Sending horizontalInput = Input.GetAxis ("Horizontal"); stream.Serialize (horizontalInput); } else { // Receiving stream.Serialize (horizontalInput); // ... do something meaningful with the received variable } }
在C#Script中Serialize()的参数是ref类型,在参数horizontalInput的前面加上引用符号ref即可。
OnSerializeNetworkView的调用频率在Network Manager的SendRate里设置,默认是15/s.
3.RPC和状态同步。
如果要调用RPC,所调用函数必须带有@RPC属性,如下:
var playerBullet : GameObject; function Update () { if (Input.GetButtonDown ("Fire1")) { networkView.RPC ("PlayerFire", RPCMode.All); } } @RPC function PlayerFire () { Instantiate (playerBullet, playerBullet.transform.position, playerBullet.transform.rotation); }
RPC调用是可靠的有序的通信。
以下是使用Network.AllocateViewID()手动分配ViewID的实例,其中使用RPC调用实例化cubePrefab的远程调用,ViewID作为该远程调用的参数传递:
public class example : MonoBehaviour { public Transform cubePrefab; void OnGUI() { if (GUILayout.Button("SpawnBox")) { NetworkViewID viewID = Network.AllocateViewID(); networkView.RPC("SpawnBox", RPCMode.AllBuffered, viewID, transform.position); } } [RPC] void SpawnBox(NetworkViewID viewID, Vector3 location) { Transform clone; clone = Instantiate(cubePrefab, location, Quaternion.identity) as Transform as Transform; NetworkView nView; nView = clone.GetComponent(); nView.viewID = viewID; } }
那么,RPC(Remote Procedure Call)的规则和定义到底是什么?
RPC是某对象的脚本组件中包含的声明为[RPC]的(JS为@RPC)函数,这个对象必须包含一个Network View组件,并且监视对象是该脚本组件。这样,其他脚本就可以从远程通过这个对象调用该RPC.
RPC函数的参数个数任意,但是为了减少网络消耗提高网络性能,个数最好优化到最少。
RPC调用(Network.RPC())的参数还应加上RPC函数名称(string)和RPCMode(接收对象,包括All,Allbuffered,Others,Server等等),接下来才是RPC函数的参数。
RPC调用可以用来在所有客户端上执行某个事件,或者在两个特定的部分之间传递事件信息,你也可以创造性的发挥。比如,一个游戏服务器只有当4个客户端连接上才开始游戏,当第四个客户端连上以后,服务器可以向所有客户端同时发送RPC从而开始游戏。一个客户端可以向所有客户端发送RPC告知他拿到了某个物品。服务器可以在客户端连接时发送RPC让客户端执行初始化,比如给它一个player number,出生地点,队伍颜色,等等。客户端可以发送RPC定制它的启动设置,比如颜色偏好,已购物品等等。
NetworkMessageInfo结构体中包含timestamp时间戳和sender发送者两个数据成员。在RPC函数的参数列表中加入一项NetworkMessageInfo,可自动获取该信息,而在RPC调用中不用显示列出该参数。
@RPC function PrintText (text : String, info : NetworkMessageInfo) { Debug.Log(text + " from " + info.sender); }
RPC Buffer:使用buffer模式可以让你之前发送的RPC被新连接的客户端接受。比如向当前玩家发送装载某个关卡的RPC,过段时间新玩家连接到游戏,需要装载当前其他玩家正在进行的关卡。这时新连接的玩家不会错过装载该关卡的RPC,因为它被缓存了起来。另外还可以刷新buffer,比如开始新的关卡后新连接的玩家不如从之前的关卡开始一个一个装载。
State Synchronization的一些细节:
如前所述,状态同步的对象只能是以下四种类型之一:transform,animation,rigidbody和monobehaviour(也就是script),其中script的需要用onSerializeNetworkView()函数显示指定监视的变量数据。BitStream.Serialize()可接受的参数可以是bool,char,short,int,float,Quaternion,Vector3和NetworkPlayer.详情参考官方script手册中的BitStream.Serialize().
关于状态同步两种方式的选择:
Reliable Delta Compressed:在属性级别自动对比当前状态(比如position和rotation是transform的两个不同属性,如果其中之一发生变动,只发送该变动数据),如果无变动则不发送。Unity会利用ACK和NACK来保证UDP包的发送可靠性,丢失的包会等待重传(是的,Unity传输层使用的是UDP协议)。缺陷是网络不好会导致明显延时。
Unreliable:不保证可靠性,因此它不是仅仅发送更改的部分数据保留未更改的部分,而是每次更新都发送所有的数据,不管某部分改动还是未改动。这种模式用于游戏每帧都在变动的情况,比如赛车游戏。
综上,游戏变化频繁且迅速,而丢一两个包无关紧要,这时候选择Unreliable,如果不是频繁变化,为了节省带宽,建议选择Reliable Delta Compressed.
4.Network.Instantiate.
此方法在每个客户机上实例化一个prefab,并且是buffered型RPC,这样当一个新的客户机连接到服务器后,会自动实例化一个prefab.
函数原型:static function Instantiate (prefab : Object, position : Vector3, rotation : Quaternion, group : int) : Object
其中group(组)可以用来设置消息的过滤,如果不需要这个功能则设置为0.
调用此RPC的例子:
public class example : MonoBehaviour { public Transform playerPrefab; void OnConnectedToServer() { Network.Instantiate(playerPrefab, transform.position, transform.rotation, 0); } }
可以用Network.RemoveRPCs()函数来去掉buffer区的Network.Instantiate()函数。你可以去掉特定组的RPC(如果Instantiate指定了组的话),也可以去掉第一个NetworkView ID的RPC,因为当实例化发生的时候RPC消息是与第一个Network View连接的。
函数原型:static function RemoveRPCs (playerID : NetworkPlayer, group : int) : void
从RPC buffer里去掉Instantiation的例子:
public class example : MonoBehaviour { void OnNetworkInstantiate(NetworkMessageInfo info) { Debug.Log(networkView.viewID + " spawned"); if (Network.isServer) { Network.RemoveRPCs(networkView.viewID); Network.Destroy(gameObject); } } }
组的作用:让特定(组)的客户端只能收到特定的消息。比如,如果两个玩家分隔在物理上互不影响的两个区域,但是可以进行聊天。这时候他们之间游戏状态数据的交换就很有限,但是要保留聊天功能。物理上的游戏对象的实例化就应该和提供聊天功能的对象的实例化分开,设定为不同的组。
5.Master Server:
在Master Server中可以显示所有的服务器,这样客户端可以在这里选择适合的服务器,另外Master Server隐藏端口和IP,解决连接问题,比如防火墙和NAT的punchthrough.
每个游戏需要向Master Server提交一个Game Type(注意避免和别的游戏重复),当客户端选择好Game Type,Master Server会提供一个包含游戏人数、可能设置密码的运行中游戏列表。
这两个方法分别是:MasterServer.RegisterHost()(Host端)和MasterServer.RequestHostList()(客户端).
函数原型:static function RegisterHost (gameTypeName : String, gameName : String, comment : String = "") : void
RegisterHost的例子:
public class example : MonoBehaviour { void OnGUI() { if (GUILayout.Button("Start Server")) { bool useNat = !Network.HavePublicAddress(); Network.InitializeServer(32, 25002, useNat); MasterServer.RegisterHost("MyUniqueGameType", "JohnDoes game", "l33t game for all"); } } }
函数原型:static function RequestHostList (gameTypeName : String) : void
RequestHostList的例子:
public class example : MonoBehaviour { void Awake() { MasterServer.ClearHostList(); MasterServer.RequestHostList("LarusTest"); } void Update() { if (MasterServer.PollHostList().Length != 0) { HostData[] hostData = MasterServer.PollHostList(); int i = 0; while (i < hostData.Length) { Debug.Log("Game name: " + hostData[i].gameName); i++; } MasterServer.ClearHostList(); } } }
上面的代码使用ClearHostList()清除掉由PollHostList()获取的HostList,然后Request新的HostList,把新的HostList通过PollHostList()存储在HostData数组中,其中HostData是保存HostList中数据的数据结构,其成员变量如下图:
以下是连接MasterServer的进程实例:
function Awake() { MasterServer.RequestHostList("MadBubbleSmashGame"); } function OnGUI() { var data : HostData[] = MasterServer.PollHostList(); // Go through all the hosts in the host list for (var element in data) { GUILayout.BeginHorizontal(); var name = element.gameName + " " + element.connectedPlayers + " / " + element.playerLimit; GUILayout.Label(name); GUILayout.Space(5); var hostInfo; hostInfo = "["; for (var host in element.ip) hostInfo = hostInfo + host + ":" + element.port + " "; hostInfo = hostInfo + "]"; GUILayout.Label(hostInfo); GUILayout.Space(5); GUILayout.Label(element.comment); GUILayout.Space(5); GUILayout.FlexibleSpace(); if (GUILayout.Button("Connect")) { // Connect to HostData struct, internally the correct method is used (GUID when using NAT). Network.Connect(element); } GUILayout.EndHorizontal(); } }
可以继续向其中添加ping值和地理位置等信息,这里不再赘述。
关于NAT punchthrough,如果某个客户端设备在NAT中且没有NAT punchthrough技术,可以依赖Facilitator,如果做服务器的客户端和其他客户端都能连接到这个Facilitator,那么服务器和客户端就能进行通信,前提是Facilitator使用外部IP和端口。Master Server可以提供这个外部IP和端口,所以一般Master Sever同时担当Faciliator的角色,此时默认两者拥有共同的IP地址。使用MasterServer.ipAddress,MasterServer.port,Network.natFacilitatorIP,Network.natFacilitatorPort修改IP地址和端口号。
Unity提供默认的Master Server服务,但是你也可以自己组建一个Mater Server.方法详见官网。
6.Debug.
在Network Manager中可以设置debug级别为看到所有进出的交通信息。
在Inspector和Hierarchy里观察游戏对象的建立以及ViewID等。
Unity可以双开,你可以让它运行不同的project.在windows环境只需要再打开一个Unity并打开一个新的project即可。在Mac OS X环境需要在终端输入以下命令:
/Applications/Unity/Unity.app/Contents/MacOS/Unity -projectPath "/Users/MyUser/MyProjectFolder/"
/Applications/Unity/Unity.app/Contents/MacOS/Unity -projectPath "/Users/MyUser/MyOtherProjectFolder/"
在对Network进行debug的时候,要选中Run In Background选项,因为当你运行两个实例并在其中一个上debug,另一个会失去焦点,你要确保在后台它也能运行。在Project Setting>Player里面设置,也可以直接使用Apllication.runInBackground = true语句打开该功能。
基础部分学习到此结束,下篇文章学习多人游戏中的关卡装载,带宽优化以及连接测试等。