源码学习网 首页 编程学园 Unity3d教程 查看内容

我们来用Unity做一个局域网游戏(上)

2019-7-14 01:36| 发布者: opiye| 查看: 71| 评论: 0

摘要: 本篇难度:★★★★☆(请注意,本文非常长,而且为了照顾到对网络不太熟悉的童鞋,用了较大篇幅作相关铺垫,食用前请做好心理准备)前言大家好。由纯单机到联网游戏,是游戏开发的一个质的突破。无论是从涉及技术的 ...
腾讯云服务器秒杀

本篇难度:★★★★☆

(请注意,本文非常长,而且为了照顾到对网络不太熟悉的童鞋,用了较大篇幅作相关铺垫,食用前请做好心理准备)

前言

大家好。

由纯单机到联网游戏,是游戏开发的一个质的突破。无论是从涉及技术的广度,还是从当今的市场需求出发,都是极其有意义的。

完全不涉及网络的游戏越来越少了,或多或少都会带一些联网要素

这次就来向大家介绍如何利用Unity制作一款局域网(LAN)游戏——五子棋。

麻雀虽小,五脏俱全。

很多看起来很牛逼的单机,耐玩性还不如一些有趣的联机游戏。

由于项目代码过于臃肿庞大,不会放出所有源码,只会专注于讲述实现思路。对细节感兴趣的同学可以在文章结尾下载本项目服务器与客户端的源码。技术有限,仅供参考。

先上一波效果图:

以下是游戏的服务器(使用传说中的大黑框,方便调试)

以下是游戏的客户端(UI像不像电视遥控器?)

当然服务器与客户端互相独立,客户端使用Unity开发,服务器使用控制台开发。

在同一台电脑上只能运行一个服务器,但是可以运行多个客户端。


项目分析

联机游戏肯定缺不了服务器跟客户端,采用的同步方式为状态同步,以下功能可供参考:

客户端的功能:接受用户输入,把用户输入的结果封装成消息发送给服务器。

服务器的功能:接受客户端的消息,处理游戏逻辑后把结果以消息形式反馈给特定的客户端。


所谓的消息(Message)就是根据双方制定的协议(Protocol)来封装与解析的数据。把消息封装与解析的过程叫做序列化(Serialize)与反序列化(Deserialize)。

中国人和中国人说话,要遵循汉语的的语法结构,使用汉语的发音。当我们和外国人交流时,就要适用外国的语言了,遵循外国的语法结构和发音。其实这就是一种协议,只不过我们称之为语言。

开发准备

网络协议

网络通讯部分用到了TCP/IP网络协议,客户端与服务器所有操作都基于这个协议在不同的计算机之间进行数据传输。

Transmission Control Protocol/Internet Protocol的简写,中译名为传输控制协议/因特网互联协议,又名网络通讯协议,是Internet最基本的协议、Internet国际互联网络的基础,由网络层的IP协议和传输层的TCP协议组成。TCP/IP 定义了电子设备如何连入因特网,以及数据如何在它们之间传输的标准。

序列化工具

要实现在不同计算机进行数据(二进制数据)传输,首先需要想清楚的是:如何把客户端的信息(例如:角色坐标,角色状态)序列化为能够在不同计算机之间传输的二进制数据。接着把二进制数据传输给服务器,服务器再把这堆数据反序列化为对象进行逻辑处理。

通用的做法是双方先制定特定的协议并提供序列化工具。客户端与服务器均按照这个协议中制定的类型来创建特定的对象。

然后用序列化工具把对象序列化为传输数据,或者把传输数据反序列化为对象。

下图是一张TCP/IP通信数据流程图:TCP/IP协议中,数据在不同计算机之间的传输流程。

现在常用的网络通信协议有Protocol Buffer,Json,Xml等。在这里为了方便易懂,直接利用C#制定通信协议并且利用C#实现序列化工具类,以下是序列化工具代码:

using System.Net;using System.Net.Sockets;using System.IO;using System.Runtime.Serialization.Formatters.Binary;using System.Text;/// <summary>/// 网络工具类 <see langword="static"/>/// </summary>public static class NetworkUtils{    //序列化:obj -> byte[]    public static byte[] Serialize(object obj)    {        //对象必须被标记为Serializable        if (obj == null || !obj.GetType().IsSerializable)            return null;        BinaryFormatter formatter = new BinaryFormatter();        using (MemoryStream stream = new MemoryStream())        {            formatter.Serialize(stream, obj);            byte[] data = stream.ToArray();            return data;        }    }    //反序列化:byte[] -> obj    public static T Deserialize<T>(byte[] data) where T : class    {        //T必须是可序列化的类型        if (data == null || !typeof(T).IsSerializable)            return null;        BinaryFormatter formatter = new BinaryFormatter();        using (MemoryStream stream = new MemoryStream(data))        {            object obj = formatter.Deserialize(stream);            return obj as T;        }    }}

消息协议

实现了序列化工具,接下来就是根据具体游戏设计不同的协议:所谓协议,就是客户端跟服务器都要遵守的规范。比如:为了记录角色位置,在Unity客户端可以直接使用Vector3类保存角色的位置信息,但是服务器一般不会写在Unity客户端上,甚至不会用C#编写。这时服务器跟客户端必须制定一个双方都可以使用的类型进行数据交互。

对于五子棋游戏来说:服务器采用房间机制比较合理,同一个服务器上运行多个房间,每个房间拥有自己的状态,棋盘与玩家,互不干涉。根据这种情况设计了一套简单的消息类型枚举。

首先是棋子类型,棋子类型既可以用于表示棋子本身,也可以表示胜利的一方:

    /// <summary>    /// 棋子类型    /// </summary>    public enum Chess    {        //棋子类型        None, //空棋        Black,//黑棋        White,//白棋        //以下用于胜利判断结果和操作结果        Draw, //平局        Null, //表示无结果(用于用户操作失败情况下的返回值)    }

下图为服务器与客户端的消息类型协议:

    /// <summary>    /// 消息类型    /// </summary>    public enum MessageType    {        None,         //空类型        HeartBeat,    //心跳包验证                //以下为玩家操作请求类型        Enroll,       //注册        CreatRoom,    //创建房间        EnterRoom,    //进入房间        ExitRoom,     //退出房间        StartGame,    //开始游戏        PlayChess,    //下棋    }

每种玩家操作请求类型都对应一个类(Class),这个类包含客户端向服务器发送的属性,也包含服务器向客户端回应的属性。

以创建房间为例:

    [Serializable]         //加上C#自带的可序列化特性就可以把该类型序列化了    public class CreatRoom    {        public int RoomId; //房间号码,客户端向服务器发送的属性        public bool Suc;   //是否成功,服务器向客户端发送的属性    }

由于协议类型过多,这里只展示一种协议的序列化与反序列化。

byte[] data = NetworkUtils.Serialize(new CreatRoom(){ RoomId = 8848 }); //序列化CreatRoom room =  NetworkUtils.Deserialize<CreatRoom>(data);            //反序列化

明白了协议与序列化,接下来就是构建我们局域网游戏的架构。

由于游戏是采用状态同步:客户端不会把玩家的操作直接发给服务器,例如:用户在客户端的棋盘上下了一步棋,客户端会先把用户的输入转化为棋盘上坐标,然后再把用户当前的棋子类型跟这一步棋的坐标发给服务器,由服务器去做游戏逻辑再把结果返回给客户端,最后客户端执行生成棋子的操作。

接下来就是介绍实现这些部分的思路与大概的实现流程:协议,客户端与服务器。


开发过程

协议

首先我们用VisualStudio创建一个消息协议的C#类库项目。

然后在项目属性里面把输出类型改为类库(DLL)。

对于unity版本低于2017的,应该把目标框架改为.Net Framework 3.5及以下

然后建立一个命名空间:Multiplay,并把上面的协议类型枚举放在命名空间中即可。

以下客户端向服务器之间协议的具体类型:

    [Serializable]    public class Enroll    {        public string Name;//姓名        public bool Suc;   //是否成功    }    [Serializable]    public class CreatRoom    {        public int RoomId; //房间号码        public bool Suc;   //是否成功    }    [Serializable]    public class EnterRoom    {        public int RoomId;      //房间号码        public Result result;   //结果        public enum Result        {            None,            Player,            Observer,        }    }    [Serializable]    public class ExitRoom    {        public int RoomId;  //房间号码        public bool Suc;    //是否成功    }    [Serializable]    public class StartGame    {        public int RoomId;            //房间号码        public bool Suc;              //是否成功        public bool First;            //是否先手        public bool Watch;            //是否是观察者    }    [Serializable]    public class PlayChess    {        public int RoomId;       //房间号码        public Chess Chess;      //棋子类型        public int X;            //棋子坐标        public int Y;            //棋子坐标        public bool Suc;         //操作结果        public Chess Challenger; //胜利者    }

每一个类型都会有客户端给服务器发送的属性,也会有服务器给客户端响应的属性。

由于五子棋游戏复杂度的原因,这些协议类型已经够用。当我们编写完协议后就可以生成解决方案,在项目的bin目录下把生成的DLL放进Unity客户端并且把控制台服务器添加对该DLL的引用。

要记住,每次更新协议之后一定要保证客户端与服务器使用的协议相同,否则会造成两边序列化与反序列化出现问题。


获取IP地址

在此介绍网络通信之前,我们编写一个方法并放在之前的NetworkUtils类中,可以快速获得本机IPv4地址(用于表示在网络上的位置),不然就得在cmd中手动输入ipconfig指令查看本机IPv4地址。在之后需要IPv4地址的时候,直接调用该方法即可,代码如下:

    /// <summary>    /// 获取本机IPv4,获取失败则返回null    /// </summary>    public static string GetLocalIPv4()    {        string hostName = Dns.GetHostName(); //得到主机名        IPHostEntry iPEntry = Dns.GetHostEntry(hostName);        for (int i = 0; i < iPEntry.AddressList.Length; i++)        {            //从IP地址列表中筛选出IPv4类型的IP地址            if (iPEntry.AddressList[i].AddressFamily == AddressFamily.InterNetwork)                return iPEntry.AddressList[i].ToString();        }        return null;    }

OK,有了这些工具并知道了基础的概念,那么如何实现网络通信?

我们是基于TCP/IP协议在不同计算机之间进行网络通讯,但是我们总得有一个可以用C#调用的接口:就是服务器跟客户端进行网络操作的API方法。在System.Net.Sockets下,C#为我们贴心地封装了一个网络通讯的API类型:Socket(网络套接字)类型。


服务器网络

我们首先创建一个静态类:Server,其中包含服务器的所有网络操作。

在Server提供一个Start方法用于实例化Socket对象:

//实例化Socket类型 参数1:使用ipv4进行寻址 参数2:使用流进行数据传输 参数3:基于TCP协议Socket socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);

创建好socket对象之后需要把socket对象与IP终端对象(包含IP地址与端口号)进行绑定。以此确定服务器在网络空间中的位置与服务器这个程序所用的端口号。

如果把IP地址比作一间房子 ,端口就是出入这间房子的门。
使用端口号,可以找到一台设备上唯一的一个程序。 所以如果需要和某台计算机建立连接的话,只需要知道IP地址或域名即可,但是如果想和该台计算机上的某个程序交换数据的话,还必须知道该程序使用的端口号。
//IP终端IPEndPoint point = new IPEndPoint(IPAddress.Parse(ip), 8848);socket.Bind(point); //套接字绑定IP终端socket.Listen(0);   //开始监听来自其他计算机的连接

通过以上操作,服务器的Socket就已经具备监听其他计算机网络连接的功能了,接下来就准备实现服务器最核心的功能:等待客户端连接,接收客户端数据。

服务器概念

服务器采用开房间(Room)的机制(比如LOL中的一局比赛)进行游戏,并且会有一个集合保存所有玩家(Player)的,有一个字典保存所有房间,有一个回调方法队列。

先上一波Server类的数据结构:

using Multiplay; //使用协议/// <summary>/// <see langword="static"/>/// </summary>public static class Server{    public static Dictionary<int, Room> Rooms;                  //游戏房间集合    public static List<Player> Players;                         //玩家集合    private static Socket _serverSocket;                        //服务器socket}

稍后会介绍并用到这些属性与类型。

虽然服务器直接利用TCP协议与客户端进行长连接,但是服务器除了会保存玩家状态,数据收发逻辑与HTTP相似。在HTTP中,服务器不会保存客户端的状态,也不会主动向客户端发送信息,只有在客户端向服务器请求数据的时候,服务器才会向客户端发送响应数据。

关于HTTP与TCP/IP可参考:HTTP与TCP的区别和联系 - CSDN博客

我们首先制作两个关键的数据类型用于保存关键数据。

服务器数据类型

Player类型,包含客户端Socket与玩家的基本信息:

public class Player{    public Socket Socket; //网络套接字    public string Name;   //玩家名字    public bool InRoom;   //是否在房间中    public int RoomId;    //所处房间号码    public Player(Socket socket)    {        Socket = socket;        Name = "Player Unknown";        InRoom = false;        RoomId = 0;    }    public void EnterRoom(int roomId)    {        InRoom = true;        RoomId = roomId;    }    public void ExitRoom()    {        InRoom = false;    }}

Room类型,包含房间号码,房间状态,容纳人数,玩家与观察者的集合:

public class Room{    public enum RoomState    {        Await,  //等待        Gaming  //对局开始    }    //房间ID    public int RoomId = 0;    //房间棋盘信息    public GamePlay GamePlay;    //房间状态    public RoomState State = RoomState.Await;    //最大玩家数量    public const int MAX_PLAYER_AMOUNT = 2;    //最大观察者数量    public const int MAX_OBSERVER_AMOUNT = 2;    public List<Player> Players = new List<Player>(); //玩家集合    public List<Player> OBs = new List<Player>();     //观察者集合    public Room(int roomId)                           //构造    {        RoomId = roomId;        GamePlay = new GamePlay();    }}

等待客户端连接

服务器的思路就是:开启服务器监听后,先开启一个线程(Thread)不断接受(Accept)客户端Socket的连接。每当有一个客户端连接上服务器,服务器会获取客户端的Socket并且启动一个线程去接收(Receive)这个客户端发送的信息。

服务器上一旦发生异常且没有处理,等于服务器直接挂掉。所有连接上服务器的客户端全部会失去连接。所以对于服务器来说要非常谨慎地编写关键部分的代码。

我们继续在Server类中编写,以下是等待客户端代码:

        //在初始化方法中开启等待玩家线程        Thread thread = new Thread(_Await) { IsBackground = true };        thread.Start();    //等待客户端方法    private static void _Await()    {        Socket client = null;        while (true)        {            try            {                //同步等待,程序会阻塞在这里                client = _serverSocket.Accept();                //获取客户端唯一键                string endPoint = client.RemoteEndPoint.ToString();                //新增玩家                Player player = new Player(client);                Players.Add(player);                Console.WriteLine($"{player.Socket.RemoteEndPoint}连接成功");                //创建特定类型的方法                ParameterizedThreadStart receiveMethod =                    new ParameterizedThreadStart(_Receive);  //Receive方法在后面实现                Thread listener = new Thread(receiveMethod) { IsBackground = true };                                listener.Start(player); //开启一个线程监听该客户端发送的消息            }            catch (Exception ex)            {                Console.WriteLine(ex.Message);            }        }    }

通过这个方法,我们已经实现等待客户端连接。虽然我们还没有开始编写客户端代码。

不急,一步步来,学习开发网络游戏的过程已经足够有趣(十分辛苦)了。
(后面的坑还大的很呢,能从头到尾看完的都是大佬)

封装数据包

在编写收发消息的方法之前,还有一个封装数据包的过程。我们是利用Socket基于TCP网络协议,通过流(Stream)传输数据,网络流中只能传输二进制,并且网络流中的数据像水流一样传输,我们怎么知道接收数据一次该接收多少字节的Byte?

以下是一个简易的封装数据的方法以便于解决以上问题:

    /// <summary>    /// 封装数据    /// </summary>    private static byte[] _Pack(MessageType type, byte[] data = null)    {        List<byte> list = new List<byte>();        if (data != null)        {            list.AddRange(BitConverter.Getbytes((ushort)(4 + data.Length)));//消息长度2字节            list.AddRange(BitConverter.Getbytes((ushort)type));             //消息类型2字节            list.AddRange(data);                                            //消息内容n字节        }        else        {            list.AddRange((ushort)4);                         //消息长度2字节            list.AddRange((ushort)type);                      //消息类型2字节        }        return list.ToArray();    }

我们的消息有一个消息总长度,消息类型,消息本体。

总长度用于确定我们该一次从网络流中接收多少长度的数据,消息类型代表该把消息本体反序列化成什么类型。消息本体本身就是一个byte数组,必须通过反序列化变成一个具体的对象。

简单来说,数据包就是对序列化后消息的封装,通过这层封装后,服务器与客户端可以进行正常的读写操作。


接收客户端数据

接收消息这里有个回调事件机制:在把数据包拆成:消息长度,消息类型之后,我们可以通过不同的消息类型去执行不同的回调方法。

在回调方法中,把消息本体(此时依然为Byte)当做参数传入进去,然后在回调方法内部把消息本体反序列化成对象进行使用。

以下为封装了一个回调方法的委托(函数指针),与一个封装之后的回调类型:

//回调委托public delegate void ServerCallBack(Player client, byte[] data);//回调类型public class CallBack{    public Player Player;    public byte[] Data;    public ServerCallBack ServerCallBack;    public CallBack(Player player, byte[] data, ServerCallBack serverCallBack)    {        Player = player;        Data = data;        ServerCallBack = serverCallBack;    }    public void Execute()    {        ServerCallBack(Player, Data);    }}

在服务器接收客户端消息之前,我们应该把回调方法存入一个字典中。在Server类型中加入一个回调方法队列(线程安全),一个消息类型字典与一个注册回调事件的方法。并且在之前的Start方法中初始化这些属性。

    private static ConcurrentQueue<CallBack> _callBackQueue;          //回调方法队列    private static Dictionary<MessageType, ServerCallBack> _callBacks        = new Dictionary<MessageType, ServerCallBack>();              //消息类型与回调方法    /// <summary>    /// 注册消息回调事件    /// </summary>    public static void Register(MessageType type, ServerCallBack method)    {        if (!_callBacks.ContainsKey(type))            _callBacks.Add(type, method);        else            Console.WriteLine("注册了相同的回调事件");    }

并且在服务器启动之前就把回调方法注册好,以下方法写在一个新的类型:Network中:

    /// <summary>    /// 启动服务器    /// </summary>    /// <param name="ip">IPv4地址</param>    public Network(string ip)    {        //注册        Server.Register(MessageType.HeartBeat, _HeartBeat);        Server.Register(MessageType.Enroll, _Enroll);        Server.Register(MessageType.CreatRoom, _CreatRoom);        Server.Register(MessageType.EnterRoom, _EnterRoom);        Server.Register(MessageType.ExitRoom, _ExitRoom);        Server.Register(MessageType.StartGame, _StartGame);        Server.Register(MessageType.PlayChess, _PlayChess);        //启动服务器        Server.Start(ip);    }

至于以上的回调事件,我们先在Network类中创建好即可,之后再进行填充。

当然,把所有的逻辑操作放在回调事件中执行显然不合适,本项目只是为了方便演示,没有进行更多逻辑数据分离与架构设计。正常的商业项目中,会抽象更多的层,不同的层进行不同的操作,甚至用的语言都不一样。很多要保证高效率或者并发的地方通常会使用C++,而逻辑层可能使用python或者go。

线程安全

因为服务器对每个客户端都会开启线程并接受信息,对于接受信息后触发的回调事件而言,显然不能在多个单独的线程中执行,得有存放入一个队列(线程安全),由一个独立的线程进行执行。

特别是极端情况可能会造成同一个房间中的玩家数据出现异常,此处不采用锁(lock),而是采用一个线程安全的队列:ConcurrentQueue。游戏开始时就开启一个单独的线程专门执行回调事件队列,从而把回调事件放在单一的线程中执行,不同线程只需要往这个队列里添加回调事件即可。

以下就是执行回调事件线程的代码:

        //在开启Await线程后,开启回调方法线程        Thread handle = new Thread(_Callback) { IsBackground = true };        handle.Start();    private static void _Callback()    {        while (true)        {            if (_callBackQueue.Count > 0)            {                //使用TryDequeue保证线程安全                if (_callBackQueue.TryDequeue(out CallBack callBack))                {                    

鲜花

握手

雷人

路过

鸡蛋