unity 网络游戏架构设计(第11课:角色同步解决方案)之美

第11课:角色同步解决方案

在上一章介绍了关于服务器的部署,本章利用部署好的服务器进行角色同步,在这里我们先介绍什么是角色实时同步,网络游戏角色实时同步一直是一个技术热点,市面上已有的角色同步解决方案主要分为两种:帧同步和状态同步,其实使用哪种同步不重要,重要的是它能帮助我们解决项目中的问题。其实帧同步很久以前就有了,并不是一个新技术,只是没有被重视而已,技术也并不是越新越好,而是根据实际项目需要去选择技术方案。

先介绍一下帧同步实现,客户端发送游戏动作到服务器,服务器广播转发所有客户端的动作(或者客户端直接通过 P2P 技术发送),客户端根据收到的所有游戏动作来做游戏运算和显示。帧同步这种同步方式,主要依靠客户端的能力,服务器仅仅是做一个转发,甚至客户端可以无需服务器,仅仅通过 P2P 方式来转发数据,比如我们以前玩的 CS 游戏。网上关于帧同步技术文章很多,给读者推荐一篇比较好的,详见这里

帧同步适用的产品主要是针对竞技类游戏游戏,或者开房间这样的游戏。

再介绍状态同步,顾名思义它是指将其他玩家的状态行为同步的方式,一般情况下 AI 逻辑、技能逻辑、战斗计算都由服务器运算,只是将运算的结果同步给客户端,客户端只需要接受服务器传过来的状态变化,然后更新自己本地的动作状态、Buff 状态,位置等就可以了,但是为了给玩家好的体验,减少同步的数据量,客户端也会做很多的本地运算,减少服务器同步的频率以及数据量。比如插值运算,如果玩家状态不改变的情况下,比如一直跑,服务器是不处理的,那他跑过的路程就是用插值去处理,否则就会出现瞬移的情况,用户体验非常不好。这样处理也可以减少服务器与客户端之间通信的次数,也就是减少服务器的压力。

我们的 Photon Server 服务器使用的就是状态同步,所以本篇主要介绍的也是状态同步。关于帧同步和状态同步,在这里给读者简单的总结一下:

(1)对于回合制战斗来讲,其实选用哪种方式实现不是特别重要了,因为本身实现难度不是很高,采用状态同步也能实现离线战斗验证,所以采用帧同步的必要性不是很大。

(2)对于单位比较多的 RTS 游戏一定是帧同步,对于 COC 来讲,他虽然是离线游戏,但是他在一样输入的情况下是能得到一样结果的,所以也可以认为他是用帧同步方式实现的战斗系统。

(3)对于对操作要求比较高的,例如 MOBA 类游戏有碰撞(玩家、怪物可以互相卡位)、物理逻辑,纯物理类即时可玩休闲游戏,帧同步实现起来比较顺畅,(有开源的 Dphysics 2D 物理系统可用 它是 Determisti 的)。

(4)对于战斗时大地图 MMORPG 的,一个地图内会有成千上百的玩家,不是小房间性质的游戏,只能使用状态同步,只同步自己视野的状态。    下面介绍常用游戏服务器是如何实现同步的,后面再结合 Photon Server 代码实现给读者讲解,其实 Photon Server 已经为我们实现好了,直接拿过来使用就可以了。

做同步时,首先要知道玩家周围的其他玩家或者怪物,以及 NPC 是如何刷出来的,在这里涉及到服务器的实现,服务器会模拟客户端的场景,也就是在服务器的 GameServer 里面生成一个跟客户端同样大小的地图,在服务器中同步时会有一个区域,这就涉及到九屏的概念,什么是九屏,九屏是服务器以玩家为中心生成的九个格子,每个格子都有一定的大小,而玩家就是在中间的格子中,凡是位于玩家的九屏之内的对象都会被服务器刷出来,如图所示:

enter image description here

服务器中的九屏如上图所示,每个玩家都有自己的九屏,这个九屏是随着玩家移动而移动的,在九屏之外就不会被刷出,玩家是看不到的,这也是为什么有时在客户端把可视距离设大了还是看不到,就是这个道理,服务器会发消息给客户端将其隐藏掉,角色的显示和隐藏是以服务器为准,我们在客户端实现的时候主要分如下几步:

第一步是刷玩家自己;

第二步是刷玩家九屏的对象;

第三步是要随时刷走进玩家九屏或者远离玩家九屏的对象。

这是传统的服务器做同步时的实现思路,接下来我们学习 Photon Server 的同步思路,先看看客户端与服务器之间是如何通信的,如下图所示: 

enter image description here   Unity 要与 Photon Server 做链接,我们也要去 Photon 官方网站去下载 Unity SDK,下载方式与下载 Photon Server 处理方式是一样的,我们会在第12课中具体实现案例中会讲解,本篇我们先介绍客户端几个重点函数。

这几个函数非常重要,首先我们在 Unity 的 Plugins 文件夹中要将 Photon3Unity3D.dll 库放进去,其实它是 C# 实现的,我们可以通过 ILSpy 工具将其反编译出来,反编译的代码如下所示:

enter image description here

除了这个库外,Photon 还提供了已经为我们封装好的 C# 脚本代码, 提供给我们的代码是写逻辑用的,也就是网络通信使用的,Photon 已经做好了,如下图所示是 Phonton Unity 提供的,需要将其放到 Unity 中,如下所示:

enter image description here

我们逐步给读者介绍其底层代码的实现方式,官方也提供了 Demo,逻辑方面在最后一课讲解,在这里先把底层核心的技术点给读者介绍一下,首先客户端链接服务器,调用的函数是 Connect 链接服务器,Photon Sever 为我们提供了 NameServer、GameServer、MasterServer,我们通常使用的是 GameServer 和 MasterServer,Master 和 GameServer 是一对多的关系,客户端首先通过 IP 地址和端口号登录到 MasterServer,因为 NameServer 用处不是很大,在这里我们就直接略过去了,然后 MasterServer 将登录的用户分配到 Game Server 中,其实这么做的目的也是提供了负载均衡。

我们先看一下 Connect 客户端链接服务器函数,该函数在 NetWorkingPeer.cs 文件中,代码实现如下:

 public bool Connect(string serverAddress, ServerConnection type)
{
    if (PhotonHandler.AppQuits)
    {
        Debug.LogWarning("Ignoring Connect() because app gets closed. If this is an error, check PhotonHandler.AppQuits.");
        return false;
    }

    if (this.State == ClientState.Disconnecting)
    {
        Debug.LogError("Connect() failed. Can't connect while disconnecting (still). Current state: " + PhotonNetwork.connectionStateDetailed);
        return false;
    }

    cachedProtocolType = type;
    cachedServerAddress = serverAddress;
    cachedApplicationName = string.Empty;

    this.SetupProtocol(type);

    // connect might fail, if the DNS name can't be resolved or if no network connection is available
    bool connecting = base.Connect(serverAddress, "", this.TokenForInit);

    if (connecting)
    {
        switch (type)
        {
            case ServerConnection.NameServer:
                State = ClientState.ConnectingToNameServer;
                break;
            case ServerConnection.MasterServer:
                State = ClientState.ConnectingToMasterserver;
                break;
            case ServerConnection.GameServer:
                State = ClientState.ConnectingToGameserver;
                break;
        }
    }

    return connecting;
}

该函数提供服务器地址和服务器连接类型,Photon Server 为我们提供了三种链接方式:TCP、UDP、Web,我们采用的是 UDP 协议,链接到服务器后,下一步使用方法 OpCustom 发送请求到服务器,我们会从服务器接收到消息,接收消息这部分模块是放在库中 Photon3Unity3D.dll 中的,现在我们已经将其反编译出来了,先看看接口类:IPhotonPeerListener.cs 这个文件提供了处理服务器消息的接口。其具体实现实在其子类中,客户端也就是围绕这几个函数进行的,再回到 OpCustom 函数中,它的实现是在类 PhotonPeer.cs 文件中的,实现方式如下:

        public virtual bool OpCustom(byte customOpCode, Dictionary<byte, object> customOpParameters, bool sendReliable, byte channelId)
    {
        object enqueueLock = this.EnqueueLock;
        bool result;
        lock (enqueueLock)
        {
            result = this.peerBase.EnqueueOperation(customOpParameters, customOpCode, sendReliable, channelId, false);
        }
        return result;
    }

这个函数具体实现在我们的脚本 LoadBalancingPeer.cs 文件中,该类实现了一系列请求函数,我们以具体案例为例给读者介绍,比如首先实现创建房间,在该类实现了函数如下:

public virtual bool OpCreateRoom(EnterRoomParams opParams)
    {
        if (this.DebugOut >= DebugLevel.INFO)
        {
            this.Listener.DebugReturn(DebugLevel.INFO, "OpCreateRoom()");
        }

        Dictionary<byte, object> op = new Dictionary<byte, object>();

        if (!string.IsNullOrEmpty(opParams.RoomName))
        {
            op[ParameterCode.RoomName] = opParams.RoomName;
        }
        if (opParams.Lobby != null && !string.IsNullOrEmpty(opParams.Lobby.Name))
        {
            op[ParameterCode.LobbyName] = opParams.Lobby.Name;
            op[ParameterCode.LobbyType] = (byte)opParams.Lobby.Type;
        }

        if (opParams.ExpectedUsers != null && opParams.ExpectedUsers.Length > 0)
        {
            op[ParameterCode.Add] = opParams.ExpectedUsers;
        }
        if (opParams.OnGameServer)
        {
            if (opParams.PlayerProperties != null && opParams.PlayerProperties.Count > 0)
            {
                op[ParameterCode.PlayerProperties] = opParams.PlayerProperties;
                op[ParameterCode.Broadcast] = true; // TODO: check if this also makes sense when creating a room?! // broadcast actor properties
            }

            this.RoomOptionsToOpParameters(op, opParams.RoomOptions);
        }
        return this.OpCustom(OperationCode.CreateGame, op, true);
    }

调用了 OpCustom 发送给服务器请求房间,我们再看看代码是在哪里以及如何调用函数 OpCreateRoom?继续查找我们可以看到在 PhotonNetWork.cs 脚本中实现了函数创建房间:

   public static bool CreateRoom(string roomName, RoomOptions roomOptions, TypedLobby typedLobby, string[] expectedUsers)
{
    if (offlineMode)
    {
        if (offlineModeRoom != null)
        {
            Debug.LogError("CreateRoom failed. In offline mode you still have to leave a room to enter another.");
            return false;
        }
        EnterOfflineRoom(roomName, roomOptions, true);
        return true;
    }
    if (networkingPeer.Server != ServerConnection.MasterServer || !connectedAndReady)
    {
        Debug.LogError("CreateRoom failed. Client is not on Master Server or not yet ready to call operations. Wait for callback: OnJoinedLobby or OnConnectedToMaster.");
        return false;
    }

    typedLobby = typedLobby ?? ((networkingPeer.insideLobby) ? networkingPeer.lobby : null);  // use given lobby, or active lobby (if any active) or none

    EnterRoomParams opParams = new EnterRoomParams();
    opParams.RoomName = roomName;
    opParams.RoomOptions = roomOptions;
    opParams.Lobby = typedLobby;
    opParams.ExpectedUsers = expectedUsers;

    return networkingPeer.OpCreateGame(opParams);
}

以上函数在最后调用了 OpCreateGame 函数实现了房间的创建,这样写逻辑时,我们只需要调用函数 CreateRoom 就可以在 GameServer 中创建房间了,发送请求就完成了,服务器会传消息给客户端,这就需要客户端处理消息。接下来介绍接收到服务器后响应函数,它是在 IPhotonPeerListener 类中提供的接口 OnOperationResponse 方法里处理服务器返回的响应信息。具体实现该函数也是在子类中,读者可以在 NetworkingPeer.cs 文件中查看到,由于实现代码比较长,这里就不给读者列举了,在此提醒一下,该函数在 Photon Unity 的逻辑代码中并没有提供调用函数,其实它是在库 Photon3Unity3D.dll 中被调用的,在这里把联网的流程给读者展示一下,也就是帮读者理顺一下思路,方便读者根据流程学习,先说第一步,我们链接网络调用的是类 PhotonNetwork.cs 文件中的函数:

PhotonNetwork.ConnectUsingSettings("0.9");

该 PhotonNetwork 的构造函数:static PhotonNetwork() 里面增加了 PhotonHandler 组件,代码段如下:

 GameObject photonGO = new GameObject();
    photonMono = (PhotonHandler)photonGO.AddComponent<PhotonHandler>();
    photonGO.name = "PhotonMono";
    photonGO.hideFlags = HideFlags.HideInHierarchy;

上述代码实现了增加组件,并将其隐藏掉,所以在项目中是看不到的。PhotonHandler.cs 文件中的 Update 函数就是处理服务器返回消息,再移步到 Update 函数中,在该函数中调用了另一个函数,先看代码:

        bool doDispatch = true;
    while (PhotonNetwork.isMessageQueueRunning && doDispatch)
    {
        // DispatchIncomingCommands() returns true of it found any command to dispatch (event, result or state change)
        Profiler.BeginSample("DispatchIncomingCommands");
        doDispatch = PhotonNetwork.networkingPeer.DispatchIncomingCommands();
        Profiler.EndSample();
    }

通过一个 while 循环不停地处理消息,它调用的函数是 DispatchIncomingCommands(),字面意思就是分发输入命令,再继续深入到库库 Photon3Unity3D.dll 进去查找,因为我们已经将其反编译出来了,直接在工程中查找就行了,PhotonPeer.cs 文件中可看到函数:

public virtual bool DispatchIncomingCommands()
    {
        bool flag = this.TrafficStatsEnabled;
        if (flag)
        {
            this.TrafficStatsGameLevel.DispatchIncomingCommandsCalled();
        }
        object dispatchLockObject = this.DispatchLockObject;
        bool result;
        lock (dispatchLockObject)
        {
            this.peerBase.ByteCountCurrentDispatch = 0;
            result = this.peerBase.DispatchIncomingCommands();
        }
        return result;
    }

再移步到 PeerBase.cs 文件中查看 DispatchIncomingCommands 函数,我们找到 EnetPeer 类,它是 PeerBase 的子类,在 DispatchIncomingCommands 函数中调用了一个重要的函数 DeserializeMessageAndCallback,继续进去看,它调用了函数 this.Listener.OnOperationResponse(operationResponse);这样我们就找到执行了,流程完成。

这个是介绍正常的一个流程,如果出现通信异常,因为网络问题是经常遇到的,Photon Server 为我们提供了 OnStatusChanged 方法里对网络状态异常进行处理,另外我们一个房间中会有多个角色,这些角色之间的同步消息是广播处理的,OnEvent 方法为我们处理服务器广播的事件。

最后使用了 Disconnect 函数断开服务器与客户端的网络连接。

另外,Photon Server 还提供了 TCP 和 UDP 协议,UDP 协议使用了多线程处理,有兴趣的读者可以学习一下。以上是关于客户端与服务器通信时,在客户端的逻辑处理。

下面再介绍一下 Photon Server 服务器端的逻辑编写,因为 PhotonServer 的底层是用 C++ 写的,对我们来说是黑盒子,我们只要知道如何使用就可以了,在官方提供的代码中,我们的 Photon Server 使用 OnOperationRequest 函数处理客户端的请求,它是继承自 IOperationHandler 类,我们写逻辑只要重写这个方法即可。另外我们处理客户端后应该将服务器端处理结果返回给客户端调用函数 SendOperationResponse 即可完成任务。Photon Server 服务器逻辑已经为我们写好了代码逻辑,比如 MmoInitialOperationHandler 类以及 MmoActorOperationHandler 类等等。另外,如果遇到消息广播咋处理,Photon Server 服务器端为我们都考虑到了,它提供了一个函数 SendEvent 进行消息的广播处理。

以上就是把我们客户端与服务器通信流程就解释完了。下面开始具体应用,比如有多个角色在一个场景中需要进行实时同步,在此给读者解释一下,其实在服务器端也会模拟一个客户端游戏场景,在这个场景中它划分了一个区域用于同步使用的,类似 MMO 服务器的九屏。我们使用的是状态同步,状态同步肯定需要同步状态,这个状态比如 Idle、walk、run、attack1、attack2 等等,另外角色在场景中肯定会移动以及会转向,这些是状态同步需要的,因为玩家做动作或者移动,这些都需要同步的,我们需要自己编写一个类用于同步,自己编写的类需要继承自 Photon.MonoBehaviour,另外,在该类中要实现一个用于同步的函数如下:

 void OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info)
    {
        if (stream.isWriting)
        {
            //We own this player: send the others our data
            stream.SendNext((int)controllerScript._characterState);
            stream.SendNext(transform.position);
            stream.SendNext(transform.rotation); 
        }
        else
        {
            //Network player, receive data
            controllerScript._characterState = (CharacterState)(int)stream.ReceiveNext();
            correctPlayerPos = (Vector3)stream.ReceiveNext();
            correctPlayerRot = (Quaternion)stream.ReceiveNext();

            // avoids lerping the character from "center" to the "current" position when this client joins
            if (firstTake)
            {
                firstTake = false;
                this.transform.position = correctPlayerPos;
                transform.rotation = correctPlayerRot;
            }

        }
    }

该函数主要是序列化数据,一方面将角色的信息发送出去,另一方面接收数据,这些数据包含:状态、位置、方向。我们在前面介绍过了 OnEvent 函数用于处理广播的数据,它会将用户的信息在 OnEvent 函数中再发送给服务器,通过 Photon Server 自己封装的函数 SendMonoMessage 执行的,因为我们只是在状态改变时发送数据的,这里要注意一下,如果用户状态不变,比如一直在 run,然后再切换到 idle,这中间的距离,我们会使用插值的方式,代码如下:

                transform.position = Vector3.Lerp(transform.position, correctPlayerPos, Time.deltaTime * 10);
            transform.rotation = Quaternion.Lerp(transform.rotation, correctPlayerRot, Time.deltaTime * 10);

这样就完成了角色的同步,其实我们自己编写的代码非常少,本章只是把流程给读者介绍了一下,下章开始案例的实现。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值