《Unity3D网络游戏实战》学习与实践--制作一款大乱斗游戏

角色类

基类Base Human是基础的角色类,它处理“操控角色”和“同步角色”的一些共有功能;CtrlHuman类代表“操控角色”​,它在BaseHuman类的基础上处理鼠标操控功能;SyncHuman类是“同步角色”类,它也继承自BaseHuman,并处理网络同步(如果有必要)​。

BaseHuman

        using System.Collections;
        using System.Collections.Generic;
        using UnityEngine;

        public class BaseHuman : MonoBehaviour {
            //是否正在移动
            protected bool isMoving = false;
            //移动目标点
            private Vector3 targetPosition;
            //移动速度
            public float speed = 1.2f;
            //动画组件
            private Animator animator;
            //描述
            public string desc = "";

            //移动到某处
            public void MoveTo(Vector3 pos){
                targetPosition = pos;
                isMoving = true;
                animator.SetBool("isMoving", true);
            }

            //移动Update
            public void MoveUpdate(){
                if(isMoving == false) {
                    return;
                }

                Vector3 pos = transform.position;
                transform.position = Vector3.MoveTowards(pos, targetPosition, speed*Time.
                                      deltaTime);
                transform.LookAt(targetPosition);
                if(Vector3.Distance(pos, targetPosition) < 0.05f){
                    isMoving = false;
                    animator.SetBool("isMoving", false);
                }
            }

            // Use this for initialization
            protected void Start () {
                animator = GetComponent<Animator>();
            }

            // Update is called once per frame
            protected void Update () {
                MoveUpdate();
            }
        }

CtrlHuman

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class CtrlHuman : BaseHuman
{
    new void Start()
    {
        base.Start();
    }

    // Update is called once per frame
    new void Update()
    {
        base.Update();
        if(Input.GetMouseButtonDown(0)) {
            Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
            RaycastHit hit;
            Physics.Raycast(ray,out hit);
            if(hit.collider.tag == "Terrain") {
                MoveTo(hit.point);
            }
        }
    }
}

如何使用网络模块

在实际的网络游戏开发中,网络模块往往是作为一个底层模块用的,它应该和具体的游戏逻辑分开,而不应该把处理逻辑的代码写到 ReceiveCallback 里面去。因为ReceiveCallback应当只处理网络数据,不应该去处理游戏功能

一个可行的做法是,给网络管理类添加回调方法,当收到某种消息时就自动调用某个函数,这样便能够将游戏逻辑和底层模块分开。制作网络管理类前,需要先了解委托、协议和消息队列这三个概念。

通信协议

通信协议是通信双方对数据传送控制的一种约定,通信双方必须共同遵守,方能“知道对方在说什么”和“让对方听懂我的话”​。

使用一种最简单的字符串协议来实现。协议格式如下所示,消息名和消息体用“|”隔开,消息体中各个参数用“, ”隔开。

消息名|参数1, 参数2, 参数3, ...

Move|127.0.0.1:1234, 10, 0, 8,

处理数据:

        string str = "Move|127.0.0.1:1234, 10, 0,8, ";

        string[] args = str.Split('|');
        string msgName = args[0]; //协议名:Move
        string msgBody = args[1]; //协议体:127.0.0.1:1234, 10, 0,8,

        string[] bodyArgs = msgBody.Split(', ');
        string desc = bodyArgs [0];               //玩家描述:127.0.0.1:1234
        float x = float.Parse(bodyArgs [1]);     //x坐标:10
        float y = float.Parse(bodyArgs [2]);     //y坐标:0
        float z = float.Parse(bodyArgs [3]);     //z坐标:8

消息队列

多线程消息处理虽然效率较高,但非主线程不能设置Unity3D组件,而且容易造成各种莫名其妙的混乱。由于单线程消息处理足以满足游戏客户端的需要,因此大部分游戏会使用消息队列让主线程去处理异步Socket接收到的消息。

C#的异步通信由线程池实现,不同的BeginReceive不一定在同一线程中执行。创建一个消息列表,每当收到消息便在列表末端添加数据,这个列表由主线程读取,它可以作为主线程和异步接收线程之间的桥梁。由于MonoBehaviour的Update方法在主线程中执行,可让Update方法每次从消息列表中读取几条信息并处理,处理后便在消息列表中删除它们

NetManager类

网络模块中最核心的地方是一个称为NetManager的静态类,这个类对外提供了三个最主要的接口。

  • Connect方法,调用后发起连接;
  • AddListener方法,消息监听。其他模块可以通过AddListener设置某个消息名对应的处理方法,当网络模块接收到这类消息时,就会回调处理方法;
  • Send方法,发送消息给服务端。

无论内部实现有多么复杂,网络模块对外的接口只有图片展示的这几个:

对内部而言,NetManager使用了异步Socket接收消息,每次接收到一条消息后,NetManager会把消息存入消息队列中​。NetManager有一个供外部调用的Update方法,每当调用它时就会处理消息队列里的第一条消息,然后根据协议名将消息分发给对应的回调函数

    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;
    using System.Net.Sockets;
    using UnityEngine.UI;
    using System;

    public static class NetManager {
        //定义套接字
        static Socket socket;
        //接收缓冲区
        static byte[] readBuff = new byte[1024];
        //委托类型
        public delegate void MsgListener(String str);
        //监听列表
        private static Dictionary<string, MsgListener> listeners =
            new Dictionary<string, MsgListener>();
        //消息列表
        static List<String> msgList = new List<string>();

        //添加监听
        public static void AddListener(string msgName, MsgListener listener){
            listeners[msgName] = listener;
        }

        //获取描述
        public static string GetDesc(){
            if(socket == null) return "";
            if(! socket.Connected) return "";
            return socket.LocalEndPoint.ToString();
        }

        //连接
        public static void Connect(string ip, int port)
        {
            //Socket
            socket = new Socket(AddressFamily.InterNetwork,
                SocketType.Stream, ProtocolType.Tcp);
            //Connect(用同步方式简化代码)
            socket.Connect(ip, port);
            //BeginReceive
            socket.BeginReceive( readBuff, 0, 1024, 0,
                ReceiveCallback, socket);
        }
        //Receive回调
        private static void ReceiveCallback(IAsyncResult ar){
            try {
                Socket socket = (Socket) ar.AsyncState;
                int count = socket.EndReceive(ar);
                string recvStr =
                    System.Text.Encoding.Default.GetString(readBuff, 0, count);
                msgList.Add(recvStr);
                socket.BeginReceive( readBuff, 0, 1024, 0,
                    ReceiveCallback, socket);
            }
            catch (SocketException ex){
                Debug.Log("Socket Receive fail" + ex.ToString());
            }
        }

        //发送
        public static void Send(string sendStr)
        {
            if(socket == null) return;
            if(! socket.Connected)return;

            byte[] sendBytes = System.Text.Encoding.Default.GetBytes(sendStr);
            socket.Send(sendBytes);
        }

        //Update
        public static void Update(){
            if(msgList.Count <= 0)
                return;
            String msgStr = msgList[0];
            msgList.RemoveAt(0);
            string[] split = msgStr.Split('|');
            string msgName = split[0];
            string msgArgs = split[1];
            //监听回调;
            if(listeners.ContainsKey(msgName)){
                listeners[msgName](msgArgs);
            }
        }
    }

漏洞

上述代码没有处理粘包分包、线程冲突等问题

进入游戏:Enter协议

当玩家打开游戏,客户端程序会生成一个操控角色(CtrlHuman)​,并把它放到场景中的一个随机位置。然后发送一条Enter协议给服务端,包含了对玩家的描述、位置等信息。服务端将Enter协议广播出去,其他客户端收到Enter协议后,创建一个同步角色(SyncHuman)

创建角色

    void Start()
    {
        //网络模块
        NetManager.AddListener("Enter", OnEnter);
        NetManager.AddListener("Move", OnMove);
        NetManager.AddListener("Leave", OnLeave);
        NetManager.Connect("127.0.0.1", 8888);
        //添加一个新角色
        GameObject obj = (GameObject)Instantiate(humanPrefab);
        float x = Random.Range(-5, 5);
        float z = Random.Range(-5, 5);
        obj.transform.position = new Vector3(x, 0, z);
        myHuman = obj.AddComponent<CtrlHuman>();
        myHuman.desc = NetManager.GetDesc();

        //发送协议
        Vector3 pos = myHuman.transform.position;
        Vector3 eul = myHuman.transform.eulerAngles;
        string sendStr = "Enter|";
        sendStr += NetManager.GetDesc() + ",";
        sendStr += pos.x + ",";
        sendStr += pos.y + ",";
        sendStr += pos.z + ",";
        sendStr += eul.y;
        NetManager.Send(sendStr);
    }

服务端如何处理消息

反射机制

如果网络模块能在解析协议名后,自动调用名为“Msg+协议名”的方法,那便大功告成,而这其中,C#的反射机制是实现该功能的关键

修改服务端的代码,完成消息处理函数的自动调用

        using System.Reflection;
        using System.Linq;

        //读取Clientfd
        public static bool ReadClientfd(Socket clientfd){
            ClientState state = clients[clientfd];
            //接收消息
            ……
            //客户端关闭(count==0)
            ……
            //消息处理
            string recvStr =
                System.Text.Encoding.Default.GetString(state.readBuff, 0, count);
            string[] split = recvStr.Split('|');
            Console.WriteLine("Recv" + recvStr);
            string msgName = split[0];
            string msgArgs = split[1];
            string funName = "Msg" + msgName;
            MethodInfo mi =  typeof(MsgHandler).GetMethod(funName);
            object[] o = {state, msgArgs};
            mi.Invoke(null, o);
            return true;
        }

MethodInfo类对象mi包含它所指代的方法的所有信息,通过这个类可以得到方法的名称、参数、返回值等,并且可以调用它。假设所有的消息处理方法都定义在MsgHandler类中,且都是静态方法,通过typeof(MsgHandler).GetMethod(funName)便能够获取MsgHandler类中名为funName的静态方法。

mi.Invoke(null, o)代表调用mi所包含的方法。第一个参数null代表this指针,由于消息处理方法都是静态方法,因此此处要填null。第二个参数o代表的是参数列表。这里定义的消息处理函数都有两个参数,第一个参数是客户端状态state,第二个参数是消息的内容msgArgs。

消息处理函数

MsgHandler.cs的文件,用它来定义存放所有消息处理函数的Msg-Handler类

        using System;
        using System.Collections.Generic;

        class MsgHandler
        {
            public static void MsgEnter(ClientState c, string msgArgs){
                Console.WriteLine("MsgEnter" + msgArgs);
            }

            public static void MsgList(ClientState c, string msgArgs){
                Console.WriteLine ("MsgList" + msgArgs);
            }
        }

时间处理

        using System;

        public class EventHandler
        {
            public static void OnDisconnect(ClientState c){
                Console.WriteLine ("OnDisconnect");
            }
        }

修改服务端接收消息的代码ReadClientfd,当玩家下线时,调用EventHandler.OnDis-connect,代码如下所示。同理可以在Accept处添加接受客户端连接的事件。

        //读取Clientfd
        public static bool ReadClientfd(Socket clientfd){
            ClientState state = clients[clientfd];
            //接收
            int count = 0;
            try{
                count = clientfd.Receive(state.readBuff);
            }catch(SocketException ex){
                MethodInfo mei =  typeof(EventHandler).GetMethod("OnDisconnect");
                object[] ob = {state};
                mei.Invoke(null, ob);

                clientfd.Close();
                clients.Remove(clientfd);
                Console.WriteLine("Receive SocketException" + ex.ToString());
                return false;
            }
            //客户端关闭
            if(count <= 0){
                MethodInfo mei =  typeof(EventHandler).GetMethod("OnDisconnect");
                object[] ob = {state};
                mei.Invoke(null, ob);

                clientfd.Close();
                clients.Remove(clientfd);
                Console.WriteLine("Socket Close");
                return false;
            }
            //消息处理
            ……
        }

玩家数据

让后进来的玩家也可以接收到其他已经存在的玩家信息,当玩家进入场景时,向服务端请求List协议,服务端收到后,将场景中的人物信息返回给客户端。要达成这个功能,服务端必须要记录各个玩家的坐标信息

      public class ClientState
      {
          public Socket socket;
          public byte[] readBuff = new byte[1024];
          public int hp = -100;
          public float x = 0;
          public float y = 0;
          public float z = 0;
          public float eulY = 0;
      }

处理Enter协议

服务端接收到Enter协议(以及后续的Move协议)后,需要把玩家的坐标信息记录下来,再广播出去。可通过修改处理消息的MsgHandler.MsgEnter方法来实现。它先解析客户端发来的协议参数,然后给代表该客户端的ClientState赋值,最后将协议广播给所有的客户端。代码如下:

        public static void MsgEnter(ClientState c, string msgArgs)
        {
            //解析参数
            string[] split = msgArgs.Split(', ');
            string desc = split[0];
            float x = float.Parse(split[1]);
            float y = float.Parse(split[2]);
            float z = float.Parse(split[3]);
            float eulY = float.Parse(split[4]);
            //赋值
            c.hp = 100;
            c.x = x;
            c.y = y;
            c.z = z;
            c.eulY = eulY;
            //广播
            string sendStr = "Enter|" + msgArgs;
            foreach (ClientState cs in MainClass.clients.Values){
                MainClass.Send(cs, sendStr);
            }
        }

玩家列表:List协议

当玩家进入场景后,调用NetManager.Send发送List协议。服务端收到后回应各个客户端的信息

        public class Main : MonoBehaviour {

            ……

            void Start () {
                //网络模块
                NetManager.AddListener("Enter", OnEnter);
                NetManager.AddListener("List", OnList);
                NetManager.AddListener("Move", OnMove);
                NetManager.AddListener("Leave", OnLeave);
                NetManager.Connect("127.0.0.1", 8888);
                //添加角色,发送Enter协议
                ……
                //请求玩家列表
                NetManager.Send("List|");
            }

            void OnList (string msgArgs) {
                Debug.Log("OnList" + msgArgs);
                //解析参数
                string[] split = msgArgs.Split(', ');
                int count = (split.Length-1)/6;
                for(int i = 0; i < count; i++){
                    string desc = split[i*6+0];
                    float x = float.Parse(split[i*6+1]);
                    float y = float.Parse(split[i*6+2]);
                    float z = float.Parse(split[i*6+3]);
                    float eulY = float.Parse(split[i*6+4]);
                    int hp = int.Parse(split[i*6+5]);
                    //是自己
                    if(desc == NetManager.GetDesc())
                        continue;
                    //添加一个角色
                    GameObject obj = (GameObject)Instantiate(humanPrefab);
                    obj.transform.position = new Vector3(x, y, z);
                    obj.transform.eulerAngles = new Vector3(0, eulY, 0);
                    BaseHuman h = obj.AddComponent<SyncHuman>();
                    h.desc = desc;
                    otherHumans.Add(desc, h);
                }
            }
            ……
        }

服务端处理

        public static void MsgList(ClientState c, string msgArgs){
            string sendStr = "List|";
            foreach (ClientState cs in MainClass.clients.Values){
                sendStr+=cs.socket.RemoteEndPoint.ToString() + ", ";
                sendStr+=cs.x.ToString() + ", ";
                sendStr+=cs.y.ToString() + ", ";
                sendStr+=cs.z.ToString() + ", ";
                sendStr+=cs.eulY.ToString() + ", ";
                sendStr+=cs.hp.ToString() + ", ";
            }
            MainClass.Send(c, sendStr);
        }

移动同步:Move协议

客户端处理

修改Ctrlhuman类中控制角色移动的代码,当角色移动时,将目的地信息发送给服务端。 

        new void Update () {
            base.Update();

            if(Input.GetMouseButtonDown(0)){
                Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
                RaycastHit hit;
                Physics.Raycast(ray, out hit);
                if (hit.collider.tag == "Terrain"){
                    MoveTo(hit.point);
                    //发送协议
                    string sendStr = "Move|";
                    sendStr += NetManager.GetDesc()+ ", ";
                    sendStr += hit.point.x + ", ";
                    sendStr += hit.point.y + ", ";
                    sendStr += hit.point.z + ", ";
                    NetManager.Send(sendStr);
                }
            }
        }

修改Main的协议处理函数OnMove(记得添加对该协议的监听)​,解析协议参数,然后找到对应的同步角色,调用MoveTo方法让同步角色走到目的地。

        void OnMove (string msgArgs) {
            Debug.Log("OnMove" + msgArgs);
            //解析参数
            string[] split = msgArgs.Split(', ');
            string desc = split[0];
            float x = float.Parse(split[1]);
            float y = float.Parse(split[2]);
            float z = float.Parse(split[3]);
            //移动
            if(! otherHumans.ContainsKey(desc))
                return;
            BaseHuman h = otherHumans[desc];
            Vector3 targetPos = new Vector3(x, y, z);
            h.MoveTo(targetPos);
        }

服务端处理

服务端收到Move协议后,解析参数,记录坐标信息,然后广播Move协议 

        public static void MsgMove(ClientState c, string msgArgs){
            //解析参数
            string[] split = msgArgs.Split(', ');
            string desc = split[0];
            float x = float.Parse(split[1]);
            float y = float.Parse(split[2]);
            float z = float.Parse(split[3]);
            //赋值
            c.x = x;
            c.y = y;
            c.z = z;
            //广播
            string sendStr = "Move|" + msgArgs;
            foreach (ClientState cs in MainClass.clients.Values){
                MainClass.Send(cs, sendStr);
            }
        }

玩家离开:Leave协议

当某个客户端掉线,服务端会广播Leave协议,客户端收到后删除对应的角色

客户端处理

当客户端收到Leave协议后,调用监听函数OnLeave,删除对应的同步角色,同时把它从同步角色列表otherHumans中删掉

        void OnLeave (string msgArgs) {
            Debug.Log("OnLeave" + msgArgs);
            //解析参数
            string[] split = msgArgs.Split(', ');
            string desc = split[0];
            //删除
            if(! otherHumans.ContainsKey(desc))
                return;
            BaseHuman h = otherHumans[desc];
            Destroy(h.gameObject);
            otherHumans.Remove(desc);
        }

服务端处理

当客户端掉线时,会触发服务端的Disconnect事件,只要在Disconnect事件的处理函数OnDisconnect中编写发送Leave协议的代码即可。

        using System;

        public class EventHandler
        {
            public static void OnDisconnect(ClientState c){
                string desc = c.socket.RemoteEndPoint.ToString();
                string sendStr = "Leave|" + desc + ", ";
                foreach (ClientState cs in MainClass.clients.Values){
                    MainClass.Send(cs, sendStr);
                }
            }
        }

攻击动作:Attack协议

第一个参数为角色描述,第二个参数为攻击的方向。在CtrlHuman发起攻击动作后,将Attack协议发送给服务端 

客户端处理

        if(Input.GetMouseButtonDown(1)){
            if(isAttacking) return;
            if(isMoving) return;
            ……
            //发送协议
            string sendStr = "Attack|";
            sendStr += NetManager.GetDesc()+ ", ";
            sendStr += transform.eulerAngles.y + ", ";
            NetManager.Send(sendStr);
        }

当客户端接收到服务端转发的Attack协议时,它会解析协议参数,然后调用对应同步角色的SyncAttack方法。修改的Main代码如下

        NetManager.AddListener("Attack", OnAttack);
        void OnAttack (string msgArgs) {

            Debug.Log("OnAttack" + msgArgs);
            //解析参数
            string[] split = msgArgs.Split(', ');
            string desc = split[0];
            float eulY = float.Parse(split[1]);
            //攻击动作
            if(! otherHumans.ContainsKey(desc))
                return;
            SyncHuman h = (SyncHuman)otherHumans[desc];
            h.SyncAttack(eulY);
        }

服务端处理

服务端只需转发Attack协议

        public static void MsgAttack(ClientState c, string msgArgs){
            //广播
            string sendStr = "Attack|" + msgArgs;
            foreach (ClientState cs in MainClass.clients.Values){
                MainClass.Send(cs, sendStr);
            }
        }

攻击伤害:Hit协议

 

客户端处理

修改CtrlHuman,添加攻击判断的代码,如下所示。先做一条有向线段,如果线段碰到了带有SyncHuman组件的角色,表示角色被击中,客户端发送Hit协议通知服务端谁被击中了

        //攻击
        if(Input.GetMouseButtonDown(1)){
            ……
            //发送协议
            ……
            //攻击判定
            Vector3 lineEnd = transform.position + 0.5f*Vector3.up;
            Vector3 lineStart = lineEnd + 20*transform.forward;
            if(Physics.Linecast(lineStart, lineEnd, out hit)){
                GameObject hitObj = hit.collider.gameObject;
                if(hitObj == gameObject)
                    return;
                SyncHuman h = hitObj.GetComponent<SyncHuman>();
                if(h == null)
                    return;
                sendStr = "Hit|";
                sendStr += NetManager.GetDesc()+ ", ";
                sendStr += h.desc + ", ";
                NetManager.Send(sendStr);
            }

        }

服务端处理

当服务端收到Hit协议后,它会找出受到攻击的角色,然后扣血(此处固定扣除25滴血)​。当被攻击的角色血量小于0,代表角色死亡,服务端会广播Die协议,通知客户端删除该角色。

        public static void MsgHit(ClientState c, string msgArgs){
            //解析参数
            string[] split = msgArgs.Split(', ');
            string attDesc = split[0];
            string hitDesc = split[1];
            //找出被攻击的角色
            ClientState hitCS = null;
            foreach (ClientState cs in MainClass.clients.Values){
                if(cs.socket.RemoteEndPoint.ToString() == hitDesc)
                    hitCS = cs;
            }
            if(hitCS == null)
                return;
            //扣血
            hitCS.hp -= 25;
            //死亡
            if(hitCS.hp <= 0){
                string sendStr = "Die|" + hitCS.socket.RemoteEndPoint.ToString();
                foreach (ClientState cs in MainClass.clients.Values){
                    MainClass.Send(cs, sendStr);
                }
            }
        }

角色死亡:Die协议

客户端处理函数如下,如果是玩家操控的角色死亡,打印出“GameOver”​,如果是其他玩家死亡,删掉他[通过SetActive(false)实现

        void OnDie (string msgArgs) {
            Debug.Log("OnDie" + msgArgs);
            //解析参数
            string[] split = msgArgs.Split(', ');
            string attDesc = split[0];
            string hitDesc = split[0];
            //自己死了
            if(hitDesc == myHuman.desc){
                Debug.Log("Game Over");
                return;
            }
            //死了
            if(! otherHumans.ContainsKey(hitDesc))
                return;
            SyncHuman h = (SyncHuman)otherHumans[hitDesc];
            h.gameObject.SetActive(false);
        }

参考书籍:《Unity3D网络游戏实战(第2版)》 (豆瓣) (douban.com)

  • 10
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值