深入Unity Mirror的ClientRpc机制

·  阅读 115

携手创作,共同成长!这是我参与「掘金日新计划 · 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就可以了。

分类:
前端
标签:
收藏成功!
已添加到「」, 点击更改