我們來用Unity做一個局域網(wǎng)游戲(上)

作者:ProcessCA
本篇難度:★★★★☆
(請注意,本文非常長,而且為了照顧到對網(wǎng)絡(luò)不太熟悉的童鞋,用了較大篇幅作相關(guān)鋪墊,食用前請做好心理準(zhǔn)備)
前言
大家好。
由純單機到聯(lián)網(wǎng)游戲,是游戲開發(fā)的一個質(zhì)的突破。無論是從涉及技術(shù)的廣度,還是從當(dāng)今的市場需求出發(fā),都是極其有意義的。

這次就來向大家介紹如何利用Unity制作一款局域網(wǎng)(LAN)游戲——五子棋。
麻雀雖小,五臟俱全。

由于項目代碼過于臃腫龐大,不會放出所有源碼,只會專注于講述實現(xiàn)思路。對細節(jié)感興趣的同學(xué)可以在文章結(jié)尾下載本項目服務(wù)器與客戶端的源碼。技術(shù)有限,僅供參考。
先上一波效果圖:
以下是游戲的服務(wù)器(使用傳說中的大黑框,方便調(diào)試)

以下是游戲的客戶端(UI像不像電視遙控器?)

當(dāng)然服務(wù)器與客戶端互相獨立,客戶端使用Unity開發(fā),服務(wù)器使用控制臺開發(fā)。
在同一臺電腦上只能運行一個服務(wù)器,但是可以運行多個客戶端。
當(dāng)然服務(wù)器與客戶端互相獨立,客戶端使用Unity開發(fā),服務(wù)器使用控制臺開發(fā)。
在同一臺電腦上只能運行一個服務(wù)器,但是可以運行多個客戶端。
項目分析
聯(lián)機游戲肯定缺不了服務(wù)器跟客戶端,采用的同步方式為狀態(tài)同步,以下功能可供參考:
客戶端的功能:接受用戶輸入,把用戶輸入的結(jié)果封裝成消息發(fā)送給服務(wù)器。
服務(wù)器的功能:接受客戶端的消息,處理游戲邏輯后把結(jié)果以消息形式反饋給特定的客戶端。
所謂的消息(Message)就是根據(jù)雙方制定的協(xié)議(Protocol)來封裝與解析的數(shù)據(jù)。把消息封裝與解析的過程叫做序列化(Serialize)與反序列化(Deserialize)。
中國人和中國人說話,要遵循漢語的的語法結(jié)構(gòu),使用漢語的發(fā)音。當(dāng)我們和外國人交流時,就要適用外國的語言了,遵循外國的語法結(jié)構(gòu)和發(fā)音。其實這就是一種協(xié)議,只不過我們稱之為語言。
開發(fā)準(zhǔn)備
網(wǎng)絡(luò)協(xié)議
網(wǎng)絡(luò)通訊部分用到了TCP/IP網(wǎng)絡(luò)協(xié)議,客戶端與服務(wù)器所有操作都基于這個協(xié)議在不同的計算機之間進行數(shù)據(jù)傳輸。
Transmission Control Protocol/Internet Protocol的簡寫,中譯名為傳輸控制協(xié)議/因特網(wǎng)互聯(lián)協(xié)議,又名網(wǎng)絡(luò)通訊協(xié)議,是Internet最基本的協(xié)議、Internet國際互聯(lián)網(wǎng)絡(luò)的基礎(chǔ),由網(wǎng)絡(luò)層的IP協(xié)議和傳輸層的TCP協(xié)議組成。TCP/IP 定義了電子設(shè)備如何連入因特網(wǎng),以及數(shù)據(jù)如何在它們之間傳輸?shù)臉?biāo)準(zhǔn)。
序列化工具
要實現(xiàn)在不同計算機進行數(shù)據(jù)(二進制數(shù)據(jù))傳輸,首先需要想清楚的是:如何把客戶端的信息(例如:角色坐標(biāo),角色狀態(tài))序列化為能夠在不同計算機之間傳輸?shù)?strong>二進制數(shù)據(jù)。接著把二進制數(shù)據(jù)傳輸給服務(wù)器,服務(wù)器再把這堆數(shù)據(jù)反序列化為對象進行邏輯處理。
通用的做法是雙方先制定特定的協(xié)議并提供序列化工具。客戶端與服務(wù)器均按照這個協(xié)議中制定的類型來創(chuàng)建特定的對象。
然后用序列化工具把對象序列化為傳輸數(shù)據(jù),或者把傳輸數(shù)據(jù)反序列化為對象。
下圖是一張TCP/IP通信數(shù)據(jù)流程圖:TCP/IP協(xié)議中,數(shù)據(jù)在不同計算機之間的傳輸流程。

現(xiàn)在常用的網(wǎng)絡(luò)通信協(xié)議有Protocol Buffer,Json,Xml等。在這里為了方便易懂,直接利用C#制定通信協(xié)議并且利用C#實現(xiàn)序列化工具類,以下是序列化工具代碼:
using System.Net;
using System.Net.Sockets;
using System.IO;
using System.Runtime.Serialization.Formatters.Binary;
using System.Text;
/// <summary>
/// 網(wǎng)絡(luò)工具類 <see langword="static"/>
/// </summary>
public static class NetworkUtils
{
??? //序列化:obj -> byte[]
??? public static byte[] Serialize(object obj)
??? {
??????? //對象必須被標(biāo)記為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;
??????? }
??? }
}
?
消息協(xié)議
實現(xiàn)了序列化工具,接下來就是根據(jù)具體游戲設(shè)計不同的協(xié)議:所謂協(xié)議,就是客戶端跟服務(wù)器都要遵守的規(guī)范。比如:為了記錄角色位置,在Unity客戶端可以直接使用Vector3類保存角色的位置信息,但是服務(wù)器一般不會寫在Unity客戶端上,甚至不會用C#編寫。這時服務(wù)器跟客戶端必須制定一個雙方都可以使用的類型進行數(shù)據(jù)交互。
對于五子棋游戲來說:服務(wù)器采用房間機制比較合理,同一個服務(wù)器上運行多個房間,每個房間擁有自己的狀態(tài),棋盤與玩家,互不干涉。根據(jù)這種情況設(shè)計了一套簡單的消息類型枚舉。
首先是棋子類型,棋子類型既可以用于表示棋子本身,也可以表示勝利的一方:
/// <summary>
??? /// 棋子類型
??? /// </summary>
??? public enum Chess
??? {
??????? //棋子類型
??????? None, //空棋
??????? Black,//黑棋
??????? White,//白棋
?
??????? //以下用于勝利判斷結(jié)果和操作結(jié)果
??????? Draw, //平局
??????? Null, //表示無結(jié)果(用于用戶操作失敗情況下的返回值)
??? }
?
下圖為服務(wù)器與客戶端的消息類型協(xié)議:
/// <summary>
??? /// 消息類型
??? /// </summary>
??? public enum MessageType
??? {
??????? None,???????? //空類型
??????? HeartBeat,??? //心跳包驗證
???????
??????? //以下為玩家操作請求類型
??? ????Enroll,?????? //注冊
??????? CreatRoom,??? //創(chuàng)建房間
??????? EnterRoom,??? //進入房間
??????? ExitRoom,???? //退出房間
??????? StartGame,??? //開始游戲
??????? PlayChess,??? //下棋
??? }
?
每種玩家操作請求類型都對應(yīng)一個類(Class),這個類包含客戶端向服務(wù)器發(fā)送的屬性,也包含服務(wù)器向客戶端回應(yīng)的屬性。
以創(chuàng)建房間為例:
[Serializable]???????? //加上C#自帶的可序列化特性就可以把該類型序列化了
??? public class CreatRoom
??? {
??????? public int RoomId; //房間號碼,客戶端向服務(wù)器發(fā)送的屬性
??????? public bool Suc;?? //是否成功,服務(wù)器向客戶端發(fā)送的屬性
??? }
?
由于協(xié)議類型過多,這里只展示一種協(xié)議的序列化與反序列化。
byte[] data = NetworkUtils.Serialize(new CreatRoom(){ RoomId = 8848 }); //序列化
CreatRoom room =? NetworkUtils.Deserialize<CreatRoom>(data);??????????? //反序列化
?
明白了協(xié)議與序列化,接下來就是構(gòu)建我們局域網(wǎng)游戲的架構(gòu)。
由于游戲是采用狀態(tài)同步:客戶端不會把玩家的操作直接發(fā)給服務(wù)器,例如:用戶在客戶端的棋盤上下了一步棋,客戶端會先把用戶的輸入轉(zhuǎn)化為棋盤上坐標(biāo),然后再把用戶當(dāng)前的棋子類型跟這一步棋的坐標(biāo)發(fā)給服務(wù)器,由服務(wù)器去做游戲邏輯再把結(jié)果返回給客戶端,最后客戶端執(zhí)行生成棋子的操作。
接下來就是介紹實現(xiàn)這些部分的思路與大概的實現(xiàn)流程:協(xié)議,客戶端與服務(wù)器。
開發(fā)過程
協(xié)議
首先我們用VisualStudio創(chuàng)建一個消息協(xié)議的C#類庫項目。

然后在項目屬性里面把輸出類型改為類庫(DLL)。

然后建立一個命名空間:Multiplay,并把上面的協(xié)議類型枚舉放在命名空間中即可。
以下客戶端向服務(wù)器之間協(xié)議的具體類型:
[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;?? //結(jié)果
??????? 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;??????????? //棋子坐標(biāo)
??????? public int Y;??????????? //棋子坐標(biāo)
?
??????? public bool Suc;???????? //操作結(jié)果
??????? public Chess Challenger; //勝利者
??? }
?
每一個類型都會有客戶端給服務(wù)器發(fā)送的屬性,也會有服務(wù)器給客戶端響應(yīng)的屬性。
由于五子棋游戲復(fù)雜度的原因,這些協(xié)議類型已經(jīng)夠用。當(dāng)我們編寫完協(xié)議后就可以生成解決方案,在項目的bin目錄下把生成的DLL放進Unity客戶端并且把控制臺服務(wù)器添加對該DLL的引用。
要記住,每次更新協(xié)議之后一定要保證客戶端與服務(wù)器使用的協(xié)議相同,否則會造成兩邊序列化與反序列化出現(xiàn)問題。
獲取IP地址
在此介紹網(wǎng)絡(luò)通信之前,我們編寫一個方法并放在之前的NetworkUtils類中,可以快速獲得本機IPv4地址(用于表示在網(wǎng)絡(luò)上的位置),不然就得在cmd中手動輸入ipconfig指令查看本機IPv4地址。在之后需要IPv4地址的時候,直接調(diào)用該方法即可,代碼如下:
/// <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,有了這些工具并知道了基礎(chǔ)的概念,那么如何實現(xiàn)網(wǎng)絡(luò)通信?
我們是基于TCP/IP協(xié)議在不同計算機之間進行網(wǎng)絡(luò)通訊,但是我們總得有一個可以用C#調(diào)用的接口:就是服務(wù)器跟客戶端進行網(wǎng)絡(luò)操作的API方法。在System.Net.Sockets下,C#為我們貼心地封裝了一個網(wǎng)絡(luò)通訊的API類型:Socket(網(wǎng)絡(luò)套接字)類型。
服務(wù)器網(wǎng)絡(luò)
我們首先創(chuàng)建一個靜態(tài)類:Server,其中包含服務(wù)器的所有網(wǎng)絡(luò)操作。

在Server提供一個Start方法用于實例化Socket對象:
//實例化Socket類型 參數(shù)1:使用ipv4進行尋址 參數(shù)2:使用流進行數(shù)據(jù)傳輸 參數(shù)3:基于TCP協(xié)議
Socket socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
?
創(chuàng)建好socket對象之后需要把socket對象與IP終端對象(包含IP地址與端口號)進行綁定。以此確定服務(wù)器在網(wǎng)絡(luò)空間中的位置與服務(wù)器這個程序所用的端口號。
如果把IP地址比作一間房子 ,端口就是出入這間房子的門。
使用端口號,可以找到一臺設(shè)備上唯一的一個程序。 所以如果需要和某臺計算機建立連接的話,只需要知道IP地址或域名即可,但是如果想和該臺計算機上的某個程序交換數(shù)據(jù)的話,還必須知道該程序使用的端口號。
//IP終端
IPEndPoint point = new IPEndPoint(IPAddress.Parse(ip), 8848);
socket.Bind(point); //套接字綁定IP終端
socket.Listen(0);?? //開始監(jiān)聽來自其他計算機的連接
?
通過以上操作,服務(wù)器的Socket就已經(jīng)具備監(jiān)聽其他計算機網(wǎng)絡(luò)連接的功能了,接下來就準(zhǔn)備實現(xiàn)服務(wù)器最核心的功能:等待客戶端連接,接收客戶端數(shù)據(jù)。
服務(wù)器概念
服務(wù)器采用開房間(Room)的機制(比如LOL中的一局比賽)進行游戲,并且會有一個集合保存所有玩家(Player)的,有一個字典保存所有房間,有一個回調(diào)方法隊列。
先上一波Server類的數(shù)據(jù)結(jié)構(gòu):
using Multiplay; //使用協(xié)議
/// <summary>
/// <see langword="static"/>
/// </summary>
public static class Server
{
??? public static Dictionary<int, Room> Rooms;????????????????? //游戲房間集合
?
??? public static List<Player> Players;???????????????????????? //玩家集合
?
??? private static Socket _serverSocket;??????????????????????? //服務(wù)器socket
}
稍后會介紹并用到這些屬性與類型。
雖然服務(wù)器直接利用TCP協(xié)議與客戶端進行長連接,但是服務(wù)器除了會保存玩家狀態(tài),數(shù)據(jù)收發(fā)邏輯與HTTP相似。在HTTP中,服務(wù)器不會保存客戶端的狀態(tài),也不會主動向客戶端發(fā)送信息,只有在客戶端向服務(wù)器請求數(shù)據(jù)的時候,服務(wù)器才會向客戶端發(fā)送響應(yīng)數(shù)據(jù)。
關(guān)于HTTP與TCP/IP可參考:(HTTP與TCP的區(qū)別和聯(lián)系 - CSDN博客)https://blog.csdn.net/u013485792/article/details/52100533
我們首先制作兩個關(guān)鍵的數(shù)據(jù)類型用于保存關(guān)鍵數(shù)據(jù)。
服務(wù)器數(shù)據(jù)類型
Player類型,包含客戶端Socket與玩家的基本信息:
public class Player
{
??? public Socket Socket; //網(wǎng)絡(luò)套接字
?
??? 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類型,包含房間號碼,房間狀態(tài),容納人數(shù),玩家與觀察者的集合:
public class Room
{
??? public enum RoomState
??? {
??????? Await,? //等待
??????? Gaming? //對局開始
??? }
?
??? //房間ID
??? public int RoomId = 0;
??? //房間棋盤信息
??? public GamePlay GamePlay;
??? //房間狀態(tài)
??? public RoomState State = RoomState.Await;
?
??? //最大玩家數(shù)量
??? public const int MAX_PLAYER_AMOUNT = 2;
??? //最大觀察者數(shù)量
??? 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)?????????????????????????? //構(gòu)造
??? {
??????? RoomId = roomId;
??????? GamePlay = new GamePlay();
??? }
}
?
等待客戶端連接
服務(wù)器的思路就是:開啟服務(wù)器監(jiān)聽后,先開啟一個線程(Thread)不斷接受(Accept)客戶端Socket的連接。每當(dāng)有一個客戶端連接上服務(wù)器,服務(wù)器會獲取客戶端的Socket并且啟動一個線程去接收(Receive)這個客戶端發(fā)送的信息。
服務(wù)器上一旦發(fā)生異常且沒有處理,等于服務(wù)器直接掛掉。所有連接上服務(wù)器的客戶端全部會失去連接。所以對于服務(wù)器來說要非常謹慎地編寫關(guān)鍵部分的代碼。
我們繼續(xù)在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}連接成功");
?
??????????????? //創(chuàng)建特定類型的方法
??????????????? ParameterizedThreadStart receiveMethod =
?????????????????? new ParameterizedThreadStart(_Receive);? //Receive方法在后面實現(xiàn)
?
??????????????? Thread listener = new Thread(receiveMethod) { IsBackground = true };
???????????????
??????????????? listener.Start(player); //開啟一個線程監(jiān)聽該客戶端發(fā)送的消息
??????????? }
??????????? catch (Exception ex)
??????????? {
??????????????? Console.WriteLine(ex.Message);
??????????? }
??????? }
??? }
?
通過這個方法,我們已經(jīng)實現(xiàn)等待客戶端連接。雖然我們還沒有開始編寫客戶端代碼。
不急,一步步來,學(xué)習(xí)開發(fā)網(wǎng)絡(luò)游戲的過程已經(jīng)足夠有趣(十分辛苦)了。
(后面的坑還大的很呢,能從頭到尾看完的都是大佬)
封裝數(shù)據(jù)包
在編寫收發(fā)消息的方法之前,還有一個封裝數(shù)據(jù)包的過程。我們是利用Socket基于TCP網(wǎng)絡(luò)協(xié)議,通過流(Stream)傳輸數(shù)據(jù),網(wǎng)絡(luò)流中只能傳輸二進制,并且網(wǎng)絡(luò)流中的數(shù)據(jù)像水流一樣傳輸,我們怎么知道接收數(shù)據(jù)一次該接收多少字節(jié)的Byte?
以下是一個簡易的封裝數(shù)據(jù)的方法以便于解決以上問題:
? ? /// <summary>
??? /// 封裝數(shù)據(jù)
? ??/// </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字節(jié)
??????????? list.AddRange(BitConverter.Getbytes((ushort)type));???????????? //消息類型2字節(jié)
??????????? list.AddRange(data);??????????????????????????????????????????? //消息內(nèi)容n字節(jié)
??????? }
??????? else
??????? {
??????????? list.AddRange((ushort)4);??? ?????????????????????//消息長度2字節(jié)
??????????? list.AddRange((ushort)type);????????????????????? //消息類型2字節(jié)
??????? }
??????? return packer.ToArray();
??? }
?
我們的消息有一個消息總長度,消息類型,消息本體。
總長度用于確定我們該一次從網(wǎng)絡(luò)流中接收多少長度的數(shù)據(jù),消息類型代表該把消息本體反序列化成什么類型。消息本體本身就是一個byte數(shù)組,必須通過反序列化變成一個具體的對象。
簡單來說,數(shù)據(jù)包就是對序列化后消息的封裝,通過這層封裝后,服務(wù)器與客戶端可以進行正常的讀寫操作。
接收客戶端數(shù)據(jù)
接收消息這里有個回調(diào)事件機制:在把數(shù)據(jù)包拆成:消息長度,消息類型之后,我們可以通過不同的消息類型去執(zhí)行不同的回調(diào)方法。
在回調(diào)方法中,把消息本體(此時依然為Byte)當(dāng)做參數(shù)傳入進去,然后在回調(diào)方法內(nèi)部把消息本體反序列化成對象進行使用。
以下為封裝了一個回調(diào)方法的委托(函數(shù)指針),與一個封裝之后的回調(diào)類型:
//回調(diào)委托
public delegate void ServerCallBack(Player client, byte[] data);
?
//回調(diào)類型
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);
??? }
}
?
在服務(wù)器接收客戶端消息之前,我們應(yīng)該把回調(diào)方法存入一個字典中。在Server類型中加入一個回調(diào)方法隊列(線程安全),一個消息類型字典與一個注冊回調(diào)事件的方法。并且在之前的Start方法中初始化這些屬性。
? ? private static ConcurrentQueue<CallBack> _callBackQueue;????????? //回調(diào)方法隊列
?
??? private static Dictionary<MessageType, ServerCallBack> _callBacks
???? ???= new Dictionary<MessageType, ServerCallBack>();????????????? //消息類型與回調(diào)方法
?
??? /// <summary>
??? /// 注冊消息回調(diào)事件
??? /// </summary>
??? public static void Register(MessageType type, ServerCallBack method)
??? {
??????? if (!_callBacks.ContainsKey(type))
? ??????????_callBacks.Add(type, method);
??????? else
??????????? Console.WriteLine("注冊了相同的回調(diào)事件");
??? }
?
并且在服務(wù)器啟動之前就把回調(diào)方法注冊好,以下方法寫在一個新的類型:Network中:
? ? /// <summary>
??? /// 啟動服務(wù)器
??? /// </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);
??????? //啟動服務(wù)器
??????? Server.Start(ip);
??? }
?
至于以上的回調(diào)事件,我們先在Network類中創(chuàng)建好即可,之后再進行填充。
當(dāng)然,把所有的邏輯操作放在回調(diào)事件中執(zhí)行顯然不合適,本項目只是為了方便演示,沒有進行更多邏輯數(shù)據(jù)分離與架構(gòu)設(shè)計。正常的商業(yè)項目中,會抽象更多的層,不同的層進行不同的操作,甚至用的語言都不一樣。很多要保證高效率或者并發(fā)的地方通常會使用C++,而邏輯層可能使用python或者go。
線程安全
因為服務(wù)器對每個客戶端都會開啟線程并接受信息,對于接受信息后觸發(fā)的回調(diào)事件而言,顯然不能在多個單獨的線程中執(zhí)行,得有存放入一個隊列(線程安全),由一個獨立的線程進行執(zhí)行。
特別是極端情況可能會造成同一個房間中的玩家數(shù)據(jù)出現(xiàn)異常,此處不采用鎖(lock),而是采用一個線程安全的隊列:ConcurrentQueue。游戲開始時就開啟一個單獨的線程專門執(zhí)行回調(diào)事件隊列,從而把回調(diào)事件放在單一的線程中執(zhí)行,不同線程只需要往這個隊列里添加回調(diào)事件即可。
以下就是執(zhí)行回調(diào)事件線程的代碼:
? ? ? ? //在開啟Await線程后,開啟回調(diào)方法線程
??????? 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))
??????????????? {
??????????????????? //執(zhí)行回調(diào)
??????????????????? callBack.Execute();
??????????????? }
??????????? }
??????????? //讓出線程
??????????? Thread.Sleep(10);
??????? }
??? }
?
好的,回到Server類,繼續(xù)實現(xiàn)我們的Receive方法,基于我們上面封裝數(shù)據(jù)包的過程,我們也得以相同的格式把數(shù)據(jù)拆出來。
我們先讀取4個字節(jié)的包頭,根據(jù)解析包頭的數(shù)據(jù)確定該數(shù)據(jù)包的長度,從而繼續(xù)接收包體(消息本體),以下是實現(xiàn)代碼:
? ? private static void _Receive(object obj)
??? {
??????? Player player = obj as Player;
??????? Socket client = player.Socket;
???????
??????? //持續(xù)接受消息
??????? while (true)
??????? {
??????????? //解析數(shù)據(jù)包過程(服務(wù)器與客戶端需要嚴(yán)格按照一定的協(xié)議制定數(shù)據(jù)包)
??????????? byte[] data = new byte[4];
?
?????? ?????int length = 0;??????????????????????????? //消息長度
??????????? MessageType type = MessageType.None;?????? //類型
??????????? int receive = 0;?????????????????????????? //接收信息
?
??????????? try
??????????? {
??????????????? receive = client.Receive(data); //同步接受消息
??????????? }
??????????? catch (Exception ex)
??????????? {
??????????????? Console.WriteLine($"{client.RemoteEndPoint}已掉線:{ex.Message}");
??????????????? player.Offline();
??????????????? return;
??????????? }
?
??????????? //包頭接收不完整
??????????? if (receive < data.Length)
??????????? {
??????????????? Console.WriteLine($"{client.RemoteEndPoint}已掉線");
??????????????? player.Offline();
??????????????? return;
??????????? }
?
??????????? //解析消息過程
??????????? using (MemoryStream stream = new MemoryStream(data))
??????????? {
??????????????? BinaryReader binary = new BinaryReader(stream, Encoding.UTF8);
??????????????? try
??????????????? {
??????????????????? length = binary.ReadUInt16();
??????????????????? type = (MessageType)binary.ReadUInt16();
??? ????????????}
??????????????? catch (Exception)
??????????????? {
??????????????????? Console.WriteLine($"{client.RemoteEndPoint}已掉線");
??????????????????? player.Offline();
??????????????????? return;
??????????????? }
??????????? }
?
??????????? //如果有包體
??????????? if (length - 4 > 0)
??????????? {
??????????????? data = new byte[length - 4];
??????????????? receive = client.Receive(data);
??????????????? if (receive < data.Length)
??????????????? {
??????????????????? Console.WriteLine($"{client.RemoteEndPoint}已掉線");
??????????????????? player.Offline();
??????????????????? return;
??????????????? }
??????????? }
??????????? else
??????????? {
??????????????? data = new byte[0];
??????????????? receive = 0;
??????????? }
?
??????????? Console.WriteLine($"接受到消息, 房間數(shù):{Rooms.Count}, 玩家數(shù):{Players.Count}");
?
??????????? //回調(diào)機制機制
??????????? if (_callBacks.ContainsKey(type))
??????????? {
??????????????? CallBack callBack = new CallBack(player, data, _callBacks[type]);
??????????????? //放入回調(diào)隊列
? ??????????????_callBackQueue.Enqueue(callBack);
??????????? }
??????? }
??? }
?
OK,到這里服務(wù)器的基本功能已經(jīng)實現(xiàn),具體的回調(diào)事件與消息發(fā)送等客戶端網(wǎng)絡(luò)部分搭建完成后再一起介紹。
客戶端網(wǎng)絡(luò)
連接服務(wù)器
創(chuàng)建好unity工程后先新建一個NetworkClient腳本直奔主題先。
首先要想該利用那些C#API連接上服務(wù)器呢?又如何向服務(wù)器收發(fā)數(shù)據(jù)?恩,在這里使用C#的API:TcpClient(連接服務(wù)器建立數(shù)據(jù)流),NetworkStream(在數(shù)據(jù)流中讀寫數(shù)據(jù))
在NetworkClient腳本中,我們?nèi)∠^承Monobehaviour而是把這個類型設(shè)置為靜態(tài)類型。

與服務(wù)器類似,客戶端也有消息回調(diào)字典,與一個待發(fā)送消息隊列,需要發(fā)送消息只需把消息添加進這個隊列,然后提供有專門的協(xié)程(Coroutine)去發(fā)送。
以下是部分NetworkClient類的部分屬性:
? ? using Multiplay; //使用協(xié)議
?
??? /// <summary>
??? /// 客戶端網(wǎng)絡(luò)狀態(tài)枚舉
??? /// </summary>
??? private enum ClientState
??? {
??????? None,??????? //未連接
??????? Connected,?? //連接成功
??? }
?
??? //消息類型與回調(diào)字典
??? private static Dictionary<MessageType, CallBack> _callBacks =
??????? new Dictionary<MessageType, CallBack>();
??? //待發(fā)送消息隊列
??? private static Queue<byte[]> _messages;
??? //當(dāng)前狀態(tài)
??? private static ClientState _curState;
??? //向服務(wù)器建立TCP連接并獲取網(wǎng)絡(luò)通訊流
??? private static TcpClient _client;
??? //在網(wǎng)絡(luò)通訊流中讀寫數(shù)據(jù)
??? private static NetworkStream _stream;
?
??? //目標(biāo)ip
??? private static IPAddress _address;
??? //端口號
??? private static int _port;
?
連接服務(wù)器之前肯定要先確定服務(wù)器的ip地址,端口號服務(wù)器跟客戶端使用一個自定義的就行(0-65536),最好是8848這種比較靠后的數(shù)字,像1024之前的多半被操作系統(tǒng)分配給了其他應(yīng)用程序。以下是ip地址與端口號初始化方法:
? ? /// <summary>
??? /// 初始化網(wǎng)絡(luò)客戶端
??? /// </summary>
??? public static void Init(string address = null, int port = 8848)
??? {
??????? //連接上后不能重復(fù)連接
??????? if (_curState == ClientState.Connected)
??????????? return;
??????? //如果為空則默認連接本機ip的服務(wù)器
??????? if (address == null)
??????????? address = NetworkUtils.GetLocalIPv4();
?
??????? //類型獲取失敗則取消連接
??????? if (!IPAddress.TryParse(address, out _address))
??????????? return;
??? }
?
因為客戶端同時擔(dān)任收發(fā)數(shù)據(jù)的責(zé)任,我們得在Unity的不同協(xié)程(利用C#迭代器實現(xiàn)的類似Update的機制)中同時收發(fā)數(shù)據(jù)。
Unity協(xié)程中不建議用同步方法,大部分同步方法會主線程阻塞或?qū)е滤姥h(huán)
因為NetworkClient類型是靜態(tài)類型,無法直接使用StartCoroutine,因此我們可以提供一個繼承自Monobehaviour的類型去提供這個方法。
以下是這個類型的實現(xiàn),使用單例模式,提供一個程序退出時調(diào)用的委托來關(guān)閉網(wǎng)絡(luò)連接:
? ? public class NetworkCoroutine : MonoBehaviour
??? {
??????? public Action ApplicationQuitEvent;
?
??????? private static NetworkCoroutine _instance;
?
??????? // 場景單例(不隨場景改變而銷毀)
??????? public static NetworkCoroutine Instance
??????? {
??????????? get
??????????? {
??????????????? if (!_instance)
??????????????? {
??????????????????? GameObject socketClientObj = new GameObject("NetworkCoroutine");
??????????????????? _instance = socketClientObj.AddComponent<NetworkCoroutine>();
??????????????????? DontDestroyOnLoad(socketClientObj);
??????????????? }
??????????????? return _instance;
??????????? }
??????? }
?
??????? // 程序退出
??????? private void OnApplicationQuit()
??????? {
??????????? if (ApplicationQuitEvent != null)
??????????????? ApplicationQuitEvent();
??????? }
???? }
?
然后我們在NetworkClient創(chuàng)建一個連接服務(wù)器的方法(Connect),下面是關(guān)鍵代碼:
? ? ? ? //以下代碼放在一個叫Connect的協(xié)程方法中
??????? _client = new TcpClient();
?
??????? //異步連接服務(wù)器
?????? ?IAsyncResult async = _client.BeginConnect(_address, _port, null, null);
??????? while (!async.IsCompleted)
??????? {
??????????? Debug.Log("連接服務(wù)器中");
??????????? yield return null;
??????? }
??????? //結(jié)束異步
??????? _client.EndConnect(async);
??????? //獲取網(wǎng)絡(luò)流
??????? _stream = _client.GetStream();
?
??????? _curState = ClientState.Connected;
??????? _messages = new Queue<byte[]>();
??????? Debug.Log("連接服務(wù)器成功");
?
??????? //設(shè)置異步發(fā)送消息
??????? NetworkCoroutine.Instance.StartCoroutine(_Send());
??????? //設(shè)置異步接收消息
? ??????NetworkCoroutine.Instance.StartCoroutine(_Receive());
??????? //設(shè)置退出事件
??????? NetworkCoroutine.Instance.ApplicationQuitEvent +=
?????????? () => { _client.Close(); _curState = ClientState.None; };
?
現(xiàn)在我們啟動服務(wù)器,并在客戶端新建一個Network類型腳本,放在場景中,然后可以在Start方法中調(diào)用NetworkClient的連接服務(wù)器方法嘗試連接服務(wù)器。
好了。到目前為止,我們已經(jīng)實現(xiàn)了消息的序列化、服務(wù)器房間的搭建以及服務(wù)器與客戶端的基本通信。

介于字數(shù)原因,客戶端gameplay部分與收發(fā)數(shù)據(jù)操作放在下一篇介紹。
最后想系統(tǒng)學(xué)習(xí)游戲開發(fā)的童鞋,歡迎訪問?http://www.levelpp.com/
游戲開發(fā)攪基QQ群:869551769??
微信公眾號:皮皮關(guān)