携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第44天,点击查看活动详情
关于ClientRpc
继续深挖Mirror中,本篇研究一下ClientRpc。ClientRpc是在服务器调用客户端的方法,这是Mirror的高层API的封装。一般用法如下:
public class Player : NetworkBehaviour
{
int health;
public void TakeDamage(int amount)
{
if (!isServer) return;
health -= amount;
RpcDamage(amount);
}
[ClientRpc]
public void RpcDamage(int amount)
{
Debug.Log("Took damage:" + amount);
}
}
复制代码
即服务器代码中调用Rpc方法,然后在所有客户端上该方法被调用。 另外ClientRpc经常出现在Command方法中,比如Tanks例子中:
public class Tank : NetworkBehaviour
{
[Command]
void CmdFire()
{
GameObject projectile = Instantiate(projectilePrefab, projectileMount.position, projectileMount.rotation);
NetworkServer.Spawn(projectile);
RpcOnFire();
}
// this is called on the tank that fired for all observers
[ClientRpc]
void RpcOnFire()
{
animator.SetTrigger("Shoot");
}
}
复制代码
Command是客户端上调用的方法,但是在服务器上执行,因此也是服务器代码,其内部自然可以调用Rpc。 这两种情况,其实都是服务器上的NetworkBehaviour对象上调用Rpc方法,其实是这个服务器对象发送Rpc消息给所有客户端。
思考一个问题
上面说,Rpc本质是NetworkBehaviour发送Rpc消息给所有客户端。如果服务器上并非NetworkBehaviour的代码想要执行Rpc应该怎么办?比如服务器上有一个World类,负责世界状态的更新,比如我们有个状态是世界时间,当天黑时要调用Player对象的Rpc方法RpcOpenHeadLight
让客户端的Player自动打开头灯,伪代码如下:
public class Player: NetworkBehaviour
{
Light headLight;
[Command]
public void CmdOpenHeadLight()
{
RpcOpenHeadLight();
}
[ClientRpc]
public void RpcOpenHeadLight()
{
headLight.enabled = true;
}
}
复制代码
Player具有一个Rpc方法,该方法可以通过一个Command方法调用。你可能觉得为啥要这么折腾?直接在客户端调用一个普通的开头灯方法不行吗?不行,因为这个操作需要同步给其他客户端。这儿的Command方法经过服务器的广播可以让所有客户端执行。好了,下面是World的代码,天黑了需要让玩家的头灯自动打开:
pubic class World
{
int hour;
void Update()
{
UpdateTime();
if(hour >= 20)
{
//天黑了
???.RpcOpenLight();
}
}
}
复制代码
这不同于上面Player这样的NetworkBehaviour对象中的服务器函数调用Rpc。上面的情况由于是在Player内部很好理解,直接调用即可。而这儿是在服务器的一个普通对象World上调用,这个World不是一个NetworkBehaviour,因为它仅仅存在于服务器上,不需要同步状态到客户端。那么在World的方法中想要执行Player的Rpc方法是否可行呢?
谁在执行Rpc?
这种情况下,我们首先要搞清楚一个问题,谁在执行Rpc。由于不是在Player脚本内部,因此你不知道让哪个Player执行Rpc方法。由于ClientRpc是让所有玩家的客户端都去执行一个方法,因此看上去哪个Player触发都可以。那么是不是可以理解为服务器上的任意一个Player去执行都可以?当然首先服务器上至少有一个Player。看上去是可以的,但是我们先深挖一下ClientRpc的实现看看。
ClientRpc机制解析
RpcMessage
ClientRpc本质上是服务器发送消息给客户端(其实所有的远程Action都是发送消息)。这个消息是RpcMessage
:
public struct RpcMessage : NetworkMessage
{
public uint netId;
public byte componentIndex;
public int functionHash;
// the parameters for the Cmd function
// -> ArraySegment to avoid unnecessary allocations
public ArraySegment<byte> payload;
}
复制代码
注意ClientRpc和TargetRpc都是使用这个消息类型,区别只是发送的对象不同。
发送Client RpcMessage
在NetworkBehaviour.SendRPCInternal
方法中发送RpcMessage
,代码如下:
protected void SendRPCInternal(string functionFullName, NetworkWriter writer, int channelId, bool includeOwner)
{
// this was in Weaver before
if (!NetworkServer.active)
{
Debug.LogError($"RPC Function {functionFullName} called on Client.");
return;
}
// This cannot use NetworkServer.active, as that is not specific to this object.
if (!isServer)
{
Debug.LogWarning($"ClientRpc {functionFullName} called on un-spawned object: {name}");
return;
}
// construct the message
RpcMessage message = new RpcMessage
{
netId = netId,
componentIndex = (byte)ComponentIndex,
// type+func so Inventory.RpcUse != Equipment.RpcUse
functionHash = functionFullName.GetStableHashCode(),
// segment to avoid reader allocations
payload = writer.ToArraySegment()
};
NetworkServer.SendToReadyObservers(netIdentity, message, includeOwner, channelId);
}
复制代码
首先,RpcMessage的netId被赋值为当前这NetworkBehaviour也就是Player的netId。
最后这个SendToReadyObservers
顾名思义,就是发送消息给该Player对象所有的观察者,即所有客户端上该对象的镜像。主要实现如下:
foreach (NetworkConnection conn in identity.observers.Values)
{
bool isOwner = conn == identity.connectionToClient;
if ((!isOwner || includeOwner) && conn.isReady)
{
count++;
conn.Send(segment, channelId);
}
}
复制代码
可以看到,ClientRpc消息是发送给定义这个ClientRpc方法的服务器Player对象在所有客户端上的观察者(镜像)。如果不使用aoi系统,那么就是所有客户端上的该player的镜像收到该消息。
接收RpcMessage
在NetworkClient.OnRPCMessage
中接收:
static void OnRPCMessage(RpcMessage message)
{
if (spawned.TryGetValue(message.netId, out NetworkIdentity identity))
{
using (NetworkReaderPooled networkReader = NetworkReaderPool.Get(message.payload))
identity.HandleRemoteCall(message.componentIndex, message.functionHash, RemoteCallType.ClientRpc, networkReader);
}
}
复制代码
这儿使用message.netId,也就是发送方的Player的netId,在客户端spawned字典中查找客户端上的player对象,获取该对象的identity后,在这个identity上面执行functionHash对应的方法。
小结
ClientRpc消息中包含了发送方的netId,通过该netId在所有的客户端上查找到该player的观察者(也就是他在各客户端上对应的镜像对象),然后调用观察者对象的此ClientRpc方法。重点是观察者,如果使用aoi,观察者就由aoi系统决定,否则观察者就是该服务器Player在所有客户端上的镜像。
回到world调用Player ClientRpc的问题
world状态更新(天黑了),希望所有客户端上的所有player执行一个Rpc方法。通过上面的分析,如果不考虑aoi,那么想要所有客户端上的所有Player都执行,就需要在服务器上所有player上调用Rpc。假设有N个客户端,那么服务器上就有N个player,需要对于这个N个player分别调用Rpc,总计调用N次,而客户端上就会执行N平方次Rpc方法。
需求是什么?
所以怎么做取决于需求是什么?如果是客户端Local Player主动打开头灯,就会调用CmdOpenHeadLight
,然后所有客户端上的该Player镜像都会打开头灯。而其他Player镜像则不会。如果是天黑了,所有人都自动打开头灯,那么显然,World需要调用所有服务器Player对象的RpcOpenHeadLight
对象,这样所有客户端上的所有Player镜像都会打开头灯。因此上面说任意一个服务器Player去调用Rpc在这种需求下并不对,虽然任意一个Player去调用可以让所有客户端都执行该方法,但也只是在所有该Player的镜像上执行。
另一种需求
上面的例子其实是修改了Player的客户端状态。如果World只是在天黑时调整场景的光照呢?假设我们也是通过Player上的一个Rpc方法实现:
public class Player: NetworkBehaviour
{
[ClientRpc]
public void RpcChangeSceneLight()
{
}
}
复制代码
此时,我们只需要每个客户端执行一次该方法即可,因此在服务器上任意一个Player对象上调用该Rpc就可以了。虽然,这个方法放在Player类中有点别扭,但确实可行。当然,从代码可读性上来说,更好的方法是使用自定义网络消息,然后客户端上一个普通对象去注册该消息的Handler,统一处理该消息。或者在场景中加一个唯一的NetworkBehaviour对象,比如叫SceneHandler
,然后在该对象上添加RpcChangeSceneLight
。这样服务器上的World对象只要获取到SceneHandler的唯一实例,然后调用他的RpcChangeSceneLight
就可以了。