首先要认清一点,Unet是服务器权威的。这在同步问题中很是重要。
状态同步是从服务器向客户端方向上的。本地客户端没有序列化的数据,因为它和服务器共享同一个场景。任何为本地客户端序列化的数据都是多余的。然而,SyncVar钩子函数会被本地客户端调用。注意数据不会从客户端向服务器同步,这个方向上的操作叫做命令(Commands)。
除了可以直接用的network类的同步组件,我们还应该认识几个操作:
同步变量[SyncVar]--
同步变量是NetworkBehaviour脚本中的成员变量,他们会从服务器同步到客户端上。当一个物体被派生出来之后,或者一个新的玩家中途加入游戏后,他会接收到他的视野内所有物体的同步变量。成员变量通过[SyncVar]标签被配置成同步变量:
class Player :NetworkBehaviour
{
[SyncVar]
int health;
public void TakeDamage(int amount)
{
if (!isServer)
return;
health -= amount;
}
}
同步变量的状态在OnStartClient()之前就被应用到物体上了,所以在OnStartClient函数中,物体的状态已经是最新的数据。
同步变量可以是基础类型,如整数,字符串和浮点数。也可以是Unity内置数据类型,如Vector3和用户自定义的结构体,但是对结构体类型的同步变量,如果只有几个字段的数值有变化,整个结构体都会被发送。每个NetworkBehaviour脚本可以有最多32个同步变量,包括同步列表(见下面的解释)。
当同步变量有变化时,服务器会自动发送他们的最新数据。不需要手工为同步变量设置任何的脏数据标志位。
注意在属性设置函数中设置一个同步变量的值不会使他的脏数据标志被设置。如果这样做的话,会得到一个编译期的警告。因为同步变量使用他们自己内部的标识记录脏数据状态,在属性设置函数中设置脏位会引起递归调用问题。
同步变量还可以指定函数,使用hook:
当服务器改变了playerName的值,客户端会调用OnMyName这个函数
[SyncVar(hook = "OnMyName")]
public string playerName = "";
public void OnMyName(string newName)
{
playerName = newName;
nameInput.text = playerName;
}
同步列表(SyncLists)--
同步列表类似于同步变量,但是他们是一些值的列表而不是单个值。同步列表和同步变量都包含在初始的状态更新里。同步列表不需要[SyncVar]属性标识,他们是特殊的类。内建的基础类型属性列表有:
SyncListString
SyncListFloat
SyncListInt
SyncListUInt
SyncListBool
还有个SyncListStruct可以给用户自定义的结构体用。从SyncListStruct派生出的结构体类可以包含基础类型,数组和通用Unity类型的成员变量,但是不能包含复杂的类和通用容器。
同步列表有一个叫做SyncListChanged的回调函数,可以使客户端能接收到列表中的数据改动的通知。这个回调函数被调用时,会被通知到操作类型,和修改的变量索引。
public class MyScript :NetworkBehaviour
{
public struct Buf
{
public int id;
public string name;
public float timer;
};
public class TestBufs : SyncListStruct<Buf> {}
TestBufs m_bufs = new TestBufs();
void BufChanged(Operation op, int itemIndex)
{
Debug.Log("buf changed:" + op);
}
void Start()
{
m_bufs.Callback = BufChanged;
}
}
定制序列化函数--
通常在脚本中使用同步变量就够了,但是有时候也需要更复杂的序列化代码。NetworkBehaviour中的虚函数允许开发者定制自己的序列化函数,这些函数有:
public virtual boolOnSerialize(NetworkWriter writer, bool initialState);
public virtual voidOnDeSerialize(NetworkReader reader, bool initialState);
initalState可以用来标识是第一次序列化数据还是只发送增量的数据。如果是第一次发送给客户端,必须要包含所有状态的数据,后续的更新只需要包含增量的修改,以节省带宽。同步变量的钩子函数在initialState为True的时候不会被调用,而只会在增量更新函数中被调用。
如果一个类里面声明了同步变量,这些函数的实现会自动被加到类里面,因此一个有同步变量的类不能拥有自己的序列化函数。
OnSerialize函数应该返回True来指示有更新需要发送,如果它返回了true,这个类的所有脏标志位都会被清除,如果它返回False,则脏标志位不会被修改。这可以允许将多次改动合并在一起发送,而不需要每一帧都发送。
序列化流程--
具有NetworkIdentity组件的游戏物体可以带有多个从NetworkBehaviour派生出来的脚本,这些物体的序列化流程为:
在服务器上:
- 每个NetworkBehaviour上都有一个脏数据掩码,这个掩码可以在OnSerialize函数中通过syncVarDirtyBits访问到
- NetworkBehavious中的每个同步变量被指定了脏数据掩码中的一位
- 对同步变量的修改会使对应的脏数据位被设置
- 或者可以通过调用SetDirtyBit函数直接修改脏数据标志位
- 服务器的每个Update调用都会检查他的NetworkIdentity组件
- 如果有标记为脏的NetworkBehaviour,就会为那个物体创建一个更新数据包
- 每个NetworkBehaviour组件的OnSerialize函数都被调用,来构建这个更新数据包
- 没有脏数据位设置的NetworkBehaviour在数据包中添加0标志
- 有脏数据位设置的NetworkBehavious写入他们的脏数据和有改动的同步变量的值
- 如果一个NetworkBehavious的OnSerialize函数返回了True,那么他的脏标志位被重置,因此直到下一次数据修改之前不会被再次发送
- 更新数据包被发送到能看见这个物体的所有客户端
在客户端:
- 接收到一个物体的更新数据包
- 每个NetworkBehavious脚本的OnDeserialize函数被调用
- 这个物体上的每个NetworkBehavious脚本读取脏数据标识
- 如果关联到这个NetworkBehaviour脚本的脏数据位是0,OnDeserialize函数直接返回;
- 如果脏数据标志不是0,OnDeserialize函数继续读取后续的同步变量
- 如果有同步变量的钩子函数,调用钩子函数
对下面的代码:
public class data :NetworkBehaviour
{
[SyncVar]
public int int1 = 66;
[SyncVar]
public int int2 = 23487;
[SyncVar]
public string MyString = "esfdsagsdfgsdgdsfg";
}
产生的序列化函数OnSerialize将如下所示:
public override boolOnSerialize(NetworkWriter writer, bool forceAll)
{
if (forceAll)
{
// 第一次发送物体信息给客户端,发送全部数据
writer.WritePackedUInt32((uint)this.int1);
writer.WritePackedUInt32((uint)this.int2);
writer.Write(this.MyString);
return true;
}
bool wroteSyncVar = false;
if ((base.get_syncVarDirtyBits() & 1u) != 0u)
{
if (!wroteSyncVar)
{
// write dirty bits if this is the first SyncVar written
writer.WritePackedUInt32(base.get_syncVarDirtyBits());
wroteSyncVar = true;
}
writer.WritePackedUInt32((uint)this.int1);
}
if ((base.get_syncVarDirtyBits() & 2u) != 0u)
{
if (!wroteSyncVar)
{
// write dirty bits if this is the first SyncVar written
writer.WritePackedUInt32(base.get_syncVarDirtyBits());
wroteSyncVar = true;
}
writer.WritePackedUInt32((uint)this.int2);
}
if ((base.get_syncVarDirtyBits() & 4u) != 0u)
{
if (!wroteSyncVar)
{
// write dirty bits if this is the first SyncVar written
writer.WritePackedUInt32(base.get_syncVarDirtyBits());
wroteSyncVar = true;
}
writer.Write(this.MyString);
}
if (!wroteSyncVar)
{
// write zero dirty bits if no SyncVars were written
writer.WritePackedUInt32(0);
}
return wroteSyncVar;
}
反序列化函数将如下:
public override voidOnDeserialize(NetworkReader reader, bool initialState)
{
if (initialState)
{
this.int1 = (int)reader.ReadPackedUInt32();
this.int2 = (int)reader.ReadPackedUInt32();
this.MyString = reader.ReadString();
return;
}
int num = (int)reader.ReadPackedUInt32();
if ((num & 1) != 0)
{
this.int1 = (int)reader.ReadPackedUInt32();
}
if ((num & 2) != 0)
{
this.int2 = (int)reader.ReadPackedUInt32();
}
if ((num & 4) != 0)
{
this.MyString = reader.ReadString();
}
}
如果这个NetworkBehaviour的基类也有一个序列化函数,基类的序列化函数也将被调用。
注意更新数据包可能会在缓冲区中合并,所以一个传输层数据包可能包含多个物体的更新数据包。
远程动作--
网络系统允许在网络上执行远程的动作。这类动作有时也叫做远程过程调用(RPC)。有两种类型的远程过程调用,命令(Commands) – 由客户端发起,运行在服务器上;和客户端远程过程调用(ClientRpc) - 服务器发起,运行在客户端上。
命令(Commands)--
命令从客户端上的物体发给服务器上的物体。出于安全考虑,命令只能从玩家控制的物体上发出,因此玩家不能控制其他玩家的物体。要把一个函数变成命令,需要给这个函数添加[Command]属性,并且为函数名添加“Cmd”前缀,这样这个函数会在客户端上被调用时在服务器上运行。所有的参数会自动和命令一起发送给服务器。
命令函数的名字必须要有“Cmd”前缀。在阅读代码的时候,这也是个提示 – 这个函数比较特殊,他不像普通函数一样在本地被执行。
class Player :NetworkBehaviour
{
public GameObject bulletPrefab;
[Command]
void CmdDoFire(float lifeTime)
{
GameObject bullet =(GameObject)Instantiate(
bulletPrefab,
transform.position +transform.right,
Quaternion.identity);
var bullet2D =bullet.GetComponent<Rigidbody2D>();
bullet2D.velocity = transform.right *bulletSpeed;
Destroy(bullet, lifeTime);
NetworkServer.Spawn(bullet);
}
void Update()
{
if (!isLocalPlayer)
return;
if (Input.GetKeyDown(KeyCode.Space))
{
CmdDoFire(3.0f);
}
}
}
注意如果每一帧都发送命令消息,会产生很多的网络流量。
默认情况下,命令是通过0号通道(默认的可靠传输通道)进行传输的。所以默认情况下,所有的命令都会被可靠地发送到服务器。可以使用命令的“Channel”参数修改这个配置。参数是一个整数,表示通道号。
1号通道是默认的不可靠传输通道,如果要用这个通道,把这个参数设置为1,示例如下:
[Command(channel=1)]
从Unity5.2开始,可以从拥有客户端授权的非玩家物体发出命令。这些物体必须是使用函数NetworkServer.SpawnWithClientAuthority()派生出来的,或者是使用NetworkIdentity.AssignClientAuthority()授权过的。从物体发送出来的命令会在服务器上运行,而不是在相关玩家物体所在的客户端上。
客户端远程过程调用(ClientRPC Calls)
客户端远程过程调用从服务器的物体发送到客户端的物体上去。他们可以从任何带有NetworkIdentity并被派生出来的物体上发出。因为服务器拥有授权,所以这个过程不存在安全问题。要把一个函数变成客户端远程过程调用,需要给函数添加[ClientRpc]属性,并且为函数名添加“Rpc”前缀。这个函数将在服务端上被调用时,在客户端上执行。所有的参数都将自动传给客户端。
客户端远程调用必须带有“Rpc”前缀。在阅读代码的时候,这将是个提示 – 这个函数比较特殊,不像一般函数那样在本地执行。
class Player :NetworkBehaviour
{
[SyncVar]
int health;
[ClientRpc]
void RpcDamage(int amount)
{
Debug.Log("Took damage:" +amount);
}
public void TakeDamage(int amount)
{
if (!isServer)
return;
health -= amount;
RpcDamage(amount);
}
}
当使用伺服器模式运行游戏的时候,客户端远程调用将在本地客户端执行 – 即使他其实和服务器运行在同一个进程。因此本地客户端和远程客户端对客户端远程过程调用的处理是一样的。
如果想将[ClientRpc]用在点击事件的同步操作上,不能直接绑定点击事件函数,而是应该起一个新的Rpc函数,点击事件去绑定这个Rpc函数,Rpc函数里才是对点击事件的操作:
//点击事件
public void ClickDXView()
{
RpcDXView();
}
[ClientRpc]
public void RpcDXView()
{
readyPN.gameObject.SetActive(false);
startGm();
Camera.main.GetComponent<DOTweenPath>().DOPlay();
}
回调函数--
[ServerCallback]:只执行在服务器端,并使一些特殊函数(eg:Update)不报错(若在此函数中改变了带有syncvar的变量,客户端不同步)
(使用ServerCallback时,将Update中的重要语句摘出来写入Rpc函数中并调用)
[ClientCallback]:只执行在客户端
另:[Server]:只执行在服务器端但是不能标识一些特殊函数(可以在这里调用Rpc类函数)
远程过程的参数
传递给客户端远程过程调用的参数会被序列化并在网络上传送,这些参数可以是:
- 基本数据类型(字节,整数,浮点树,字符串,64位无符号整数等)
- 基本数据类型的数组
- 包含允许的数据类型的结构体
- Unity内建的数学类型(Vector3,Quaternion等)
- NetworkIdentity
- NetworkInstanceId
- NetworkHash128
- 带有NetworkIdentity组件的物体
远程过程的参数不可以是游戏物体的子组件,像脚本对象或Transform,他们也不能是其他不能在网络上被序列化的数据类型。
在使用过程中发现一个问题:带有NetworkIdentity的组件在运行之前不能是隐藏的,否则同步会受影响,在代码Start函数中置为SetActive = false,或者因为网络问题一开始隐藏的物体在后续同步中都没有问题。