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

作者:ProcessCA
大家好,我又來了。

廢話不多說,咱們趕緊的,接著上一篇文章把這個聯(lián)網(wǎng)項(xiàng)目搞完。
客戶端發(fā)送消息
然后在NetworkClient中提供發(fā)送消息的方法,發(fā)送消息使用消息隊(duì)列的機(jī)制(就是把給發(fā)送的消息放進(jìn)一個隊(duì)列(Queue) 通過一個協(xié)程專門向服務(wù)器發(fā)送隊(duì)列中的消息)。需要發(fā)送什么消息只需往消息隊(duì)列中添加消息即可。下面是封裝消息數(shù)據(jù)包方法:
/// <summary>
/// 加入消息隊(duì)列
/// </summary>
public static void Enqueue(MessageType type, byte[] data = null)
{
byte[] bytes = _Pack(type, data); // Pack方法在上文中已經(jīng)實(shí)現(xiàn)
if (_curState == ClientState.Connected)
{
//加入隊(duì)列
_messages.Enqueue();
}
}
以下是發(fā)送協(xié)程:
private static IEnumerator _Send()
{
//持續(xù)發(fā)送消息
while (_curState == ClientState.Connected)
{
_timer += Time.deltaTime;
//有待發(fā)送消息
if (_messages.Count > 0)
{
byte[] data = _messages.Dequeue();
yield return _Write(data); //稍后會實(shí)現(xiàn)
}
//心跳包機(jī)制(每隔一段時間向服務(wù)器發(fā)送心跳包)
if (_timer >= HEARTBEAT_TIME)
{
//如果沒有收到上一次發(fā)心跳包的回復(fù)
if (!Received)
{
_curState = ClientState.None;
Debug.Log("心跳包接受失敗,斷開連接");
yield break;
}
_timer = 0;
//封裝消息
byte[] data = _Pack(MessageType.HeartBeat);
//發(fā)送消息
yield return _Write(data);
Debug.Log("已發(fā)送心跳包");
}
yield return null; //防止死循環(huán)
}
}
然后就是NetworkClient關(guān)鍵的發(fā)送信息方法Write:
private static IEnumerator _Write(byte[] data)
{
//如果服務(wù)器下線, 客戶端依然會繼續(xù)發(fā)消息
if (_curState != ClientState.Connected || _stream == null)
{
Debug.Log("斷開連接");
yield break;
}
//異步發(fā)送消息
IAsyncResult async = _stream.BeginWrite(data, 0, data.Length, null, null);
while (!async.IsCompleted)
{
yield return null;
}
//異常處理
try
{
_stream.EndWrite(async);
}
catch (Exception ex)
{
_curState = ClientState.None;
Debug.Log("斷開連接" + ex.Message);
}
}
OK,客戶端的大坑終于快填完(其實(shí)還不到一半)。
在Network中實(shí)現(xiàn)一個發(fā)送創(chuàng)建房間請求的示例(發(fā)送一般是接受用戶操作后,所以這個方法可以綁定在UI上,由UI事件去觸發(fā)),當(dāng)然為了方便測試,放在Start方法里也沒問題:
public void CreatRoomRequest(int roomId)
{
CreatRoom request = new CreatRoom();
request.RoomId = roomId;
byte[] data = NetworkUtils.Serialize(request);
NetworkClient.Enqueue(MessageType.CreatRoom, data);
}
現(xiàn)在,我們的客戶端只會發(fā)送消息,服務(wù)器只會接收消息,簡直就是一個聾子跟一個啞巴。
來點(diǎn)輕松點(diǎn)的,我們先把客戶端的GamePlay部分實(shí)現(xiàn)吧。

客戶端
基本游戲邏輯
棋盤
制作棋盤的時候,先把一張Sprite圖片放進(jìn)Unity場景中。為了方便我們用射線檢測我們的鼠標(biāo)在棋盤上的落點(diǎn),我們可以在棋盤的Layer中添加上ChessBoard并且在棋盤上添加BoxCollider:


添加完錨點(diǎn)之后,棋盤的制作就已經(jīng)完成,然后找兩個適合的棋子圖片做成預(yù)制體就OK了。
然后我們應(yīng)該想,如何去完善棋盤的數(shù)據(jù)結(jié)構(gòu),創(chuàng)造一個保存所有落點(diǎn)世界坐標(biāo)的二維數(shù)組。
一個比較好的做法是:利用這三個錨點(diǎn),求出棋盤左右的寬度與上下的高度,進(jìn)而可以求出每一個方格的寬度與高度。然后我們再根據(jù)左下角的錨點(diǎn)(原點(diǎn)),用一個雙重循環(huán)遍歷這個保存落點(diǎn)世界坐標(biāo)的二維數(shù)組并進(jìn)行賦值。以下是實(shí)現(xiàn)代碼:
using UnityEngine;
using Multiplay; //為協(xié)議的命名空間
/// <summary>
/// 處理下棋邏輯
/// </summary>
public class NetworkGameplay : MonoBehaviour
{
//單例
private NetworkGameplay() { }
public static NetworkGameplay Instance { get; private set; }
[SerializeField]
private GameObject _blackChess; //需要實(shí)例化的黑棋
[SerializeField]
private GameObject _whiteChess; //需要實(shí)例化的白棋
//棋盤上的錨點(diǎn)
[SerializeField]
private GameObject _leftTop; //左上
[SerializeField]
private GameObject _leftBottom; //左下
[SerializeField]
private GameObject _rightTop; //右上
private Vector2[,] _chessPos; //儲存棋子世界坐標(biāo)
private float _gridWidth; //網(wǎng)格寬度
private float _gridHeight; //網(wǎng)格高度
private void Awake()
{
if (Instance == null)
Instance = this;
_chessPos = new Vector2[15, 15];
Vector3 leftTop = _leftTop.transform.position;
Vector3 leftBottom = _leftBottom.transform.position;
Vector3 rightTop = _rightTop.transform.position;
//初始化每一個格子(一共14個)的寬度與高度
_gridWidth = (rightTop.x - leftTop.x) / 14;
_gridHeight = (leftTop.y - leftBottom.y) / 14;
//初始化每個下棋點(diǎn)的位置
for (int i = 0; i < 15; i++)
{
for (int j = 0; j < 15; j++)
{
_chessPos[i, j] = new Vector2
(
leftBottom.x + _gridWidth * i,
leftBottom.y + _gridHeight * j
);
}
}
}
}
OK,有了棋盤,接下來當(dāng)然就是在棋盤類中提供用戶輸入檢測接口與實(shí)例化棋子的接口。
在這里提供一個Vec2類型,方便表示棋子在棋盤上的下標(biāo):
public struct Vec2
{
public int X;
public int Y;
public Vec2(int x, int y)
{
X = x;
Y = y;
}
}
檢測用戶輸入
/// <summary>
/// 下棋
/// </summary>
public Vec2 PlayChess()
{
//創(chuàng)建射線
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
RaycastHit hit;
//如果用戶點(diǎn)中棋盤
if (Physics.Raycast(ray, out hit, 100, 1 << LayerMask.NameToLayer("ChessBoard")))
{
//遍歷棋盤
for (int i = 0; i < 15; i++)
{
for (int j = 0; j < 15; j++)
{
//計算鼠標(biāo)點(diǎn)擊點(diǎn)與下棋點(diǎn)的距離(只算x,y平面距離)
float distance = _Distance(hit.point, _chessPos[i, j]);
//鼠標(biāo)點(diǎn)擊在落點(diǎn)周圍半個格子寬度就算下棋
if (distance < (_gridWidth / 2))
{
//返回這個落點(diǎn)在二維數(shù)組中的下標(biāo)
return new Vec2(i, j);
}
}
}
}
//未點(diǎn)擊到棋盤
return new Vec2(-1, -1);
}
/// <summary>
/// 計算兩個Vector2的距離
/// </summary>
private float _Distance(Vector2 a, Vector2 b)
{
Vector2 distance = b - a;
return distance.magnitude;
}
實(shí)例化棋子
/// <summary>
/// 實(shí)例化棋子
/// </summary>
public void InstChess(Chess chess, Vec2 pos)
{
//獲取該落點(diǎn)的世界坐標(biāo)
Vector2 vec2 = _chessPos[pos.X, pos.Y];
//棋子坐標(biāo):棋子的z坐標(biāo)不能與棋盤一致且必須更靠近攝像機(jī)近截面,
//不然有可能會與棋盤重疊導(dǎo)致棋子不可見。
Vector3 chessPos = new Vector3(vec2.x, vec2.y, -1);
if (chess == Chess.Black)
{
Instantiate(_blackChess, chessPos, Quaternion.identity);
}
else if (chess == Chess.White)
{
Instantiate(_whiteChess, chessPos, Quaternion.identity);
}
}
制作好了棋盤腳本,可以先開始制作玩家類(Player)。
以下為玩家類,掛在場景中接收用戶輸入并做簡單的邏輯檢測。
using System;
using UnityEngine;
using Multiplay; //為協(xié)議的命名空間
/// <summary>
/// 一個游戲客戶端只能存在一個網(wǎng)絡(luò)玩家
/// </summary>
public class NetworkPlayer : MonoBehaviour
{
//單例
private NetworkPlayer() { }
public static NetworkPlayer Instance { get; private set; }
[HideInInspector]
public Chess Chess; //棋子類型
[HideInInspector]
public int RoomId = 0; //房間號碼
[HideInInspector]
public bool Playing = false; //正在游戲
[HideInInspector]
public string Name; //名字
private void Awake()
{
if (Instance == null)
Instance = this;
}
private void Update()
{
if (Input.GetMouseButtonDown(0) && Playing)
{
//發(fā)送下棋請求 TODO
}
}
}
在這里提供個客戶端的類型,方便之后開發(fā)。
Info類型作用于UI,可以在某個Text上顯示信息:
using UnityEngine;
using UnityEngine.UI;
public class Info : MonoBehaviour
{
private Info() { }
public static Info Instance { get; private set; }
private Text _text;
private void Awake()
{
if (Instance == null)
Instance = this;
_text = GetComponent<Text>();
}
/// <summary>
/// 打印
/// </summary>
public void Print(string str, bool warning = false)
{
if (warning)
Debug.LogWarning(str);
else
Debug.Log(str);
_text.text = str;
}
}
至于游戲的UI部分,此處不做詳細(xì)介紹。

以下是本游戲UI提供的用戶接口,僅供參考:
[SerializeField]
private InputField _ipAddressIpt; //服務(wù)器IP輸入框
[SerializeField]
private InputField _roomIdIpt; //房間號碼輸入框
[SerializeField]
private InputField _nameIpt; //名字輸入框
[SerializeField]
private Button _connectServerBtn; //連接服務(wù)器按鈕
[SerializeField]
private Button _enrollBtn; //注冊按鈕
[SerializeField]
private Button _creatRoomBtn; //創(chuàng)建房間按鈕
[SerializeField]
private Button _enterRoomBtn; //加入房間按鈕
[SerializeField]
private Button _exitRoomBtn; //退出房間按鈕
[SerializeField]
private Button _startGameBtn; //開始游戲按鈕
[SerializeField]
private Text _gameStateTxt; //游戲狀態(tài)文本
[SerializeField]
private Text _roomIdTxt; //房間號碼文本
[SerializeField]
private Text _nameTxt; //名字文本
private void Start()
{
//綁定按鈕事件
_connectServerBtn.onClick.AddListener(_ConnectServerBtn);
_enrollBtn.onClick.AddListener(_EnrollBtn);
_creatRoomBtn.onClick.AddListener(_CreatRoomBtn);
_enterRoomBtn.onClick.AddListener(_EnterRoomBtn);
_exitRoomBtn.onClick.AddListener(_ExitRoomBtn);
_startGameBtn.onClick.AddListener(_StartGameBtn);
}
實(shí)現(xiàn)了基本的客戶端棋盤邏輯。接下來就是重點(diǎn)了,涉及到客戶端與服務(wù)器的網(wǎng)絡(luò)通訊部分。現(xiàn)在我們的客戶端已經(jīng)具備接受玩家輸入,并且可以把用戶鼠標(biāo)點(diǎn)擊的位置轉(zhuǎn)化為棋盤上的位置。
客戶端接受消息
有了發(fā)送消息肯定少不了接受消息,客戶端必須對服務(wù)器發(fā)來的反饋再進(jìn)行操作。
接收消息這里有個也有回調(diào)事件機(jī)制:在把數(shù)據(jù)包拆成:消息長度,消息類型之后,我們可以通過不同的消息類型去執(zhí)行不同的回調(diào)方法。
以下為NetworkClient的接收消息(Receive)方法的關(guān)鍵代碼:
while(true)
{
//解析數(shù)據(jù)包過程(服務(wù)器與客戶端需要嚴(yán)格按照一定的協(xié)議制定數(shù)據(jù)包)
byte[] data = new byte[4]; //數(shù)據(jù)包包頭長度(2 + 2)
int length; //消息總長度
MessageType type; //類型
int receive = 0; //接收到的數(shù)據(jù)長度
//異步讀取
IAsyncResult async = _stream.BeginRead(data, 0, data.Length, null, null);
while (!async.IsCompleted)
{
yield return null;
}
//異步讀取完畢
receive = _stream.EndRead(async);
//解析包頭
using (MemoryStream stream = new MemoryStream(data))
{
BinaryReader binary = new BinaryReader(stream, Encoding.UTF8); //UTF-8格式
length = binary.ReadUInt16();
type = (MessageType)binary.ReadUInt16();
}
//如果有包體
if (length - 4 > 0)
{
data = new byte[length - 4];
//異步讀取
async = _stream.BeginRead(data, 0, data.Length, null, null);
while (!async.IsCompleted)
{
yield return null;
}
//異步讀取完畢
receive = _stream.EndRead(async);
}
//沒有包體
else
{
data = new byte[0];
receive = 0;
}
//反序列化回消息類型
CreatRoom result = NetworkUtils.Deserialize<CreatRoom>(data);
}
回調(diào)事件對客戶端做的具體操作這里就不放源碼了,只示范一個事件:
private void _Heartbeat(byte[] data)
{
NetworkClient.Received = true;
Debug.Log("收到心跳包回應(yīng)");
}
與服務(wù)器類似,回調(diào)的核心就是把消息類型與相對應(yīng)的回調(diào)事件一起注冊一個字典(這個過程在客戶端接收服務(wù)器數(shù)據(jù)之前)。
每次接受消息后,只需要把這次的消息類型與字典中進(jìn)行匹配,進(jìn)而客戶端執(zhí)行相對應(yīng)的回調(diào)事件即可。以下為回調(diào)機(jī)制關(guān)鍵代碼:
//注冊回調(diào)事件
public static void Register(MessageType type, CallBack method)
{
if (!_callBacks.ContainsKey(type))
_callBacks.Add(type, method);
else
Debug.LogWarning("注冊了相同的回調(diào)事件");
}
//執(zhí)行回調(diào),這里的代碼應(yīng)該放在接收消息方法中
if (_callBacks.ContainsKey(type))
{
//執(zhí)行回調(diào)事件
CallBack method = _callBacks[type];
method(data);
}
到這里,客戶端的關(guān)鍵制作思路已經(jīng)介紹完成。
服務(wù)器發(fā)送消息
以下是服務(wù)器對客戶端的發(fā)送消息的方法,下面是代碼:
/// <summary>
/// 封裝并發(fā)送信息 ,寫在Server中的Player類型擴(kuò)展方法
/// </summary>
public static void Send(this Player player, MessageType type, byte[] data = null)
{
byte[] bytes = _Send(type, data); //在介紹數(shù)據(jù)包時已經(jīng)實(shí)現(xiàn)
//發(fā)送消息
player.Socket.Send(bytes);
}
終于不是兩個殘疾人在通信了。
服務(wù)器房間系統(tǒng)
接下來還有房間類型,房間構(gòu)成了一局游戲的基礎(chǔ)。
在本游戲中,房間號碼不可重復(fù)。當(dāng)一個玩家創(chuàng)建好一個房間時,其他玩家在玩家人數(shù)未滿時加入房間,就會成為玩家。在玩家人數(shù)滿了,但是觀察者人數(shù)未滿時加入房間就是觀察者。
如果全部滿了之后就無法進(jìn)入該房間。當(dāng)一個房間的狀態(tài)進(jìn)入開始游戲狀態(tài)后,所有人都無法再進(jìn)入其中。當(dāng)一局游戲結(jié)束后,此房間會自動關(guān)閉。
然后我們在服務(wù)器的Room類中添加一個方法:
/// <summary>
/// 關(guān)閉房間:從房間字典中移除并且所有房間中的玩家清除
/// </summary>
public void Close()
{
//所有玩家跟觀戰(zhàn)者退出房間
foreach (var each in Players)
{
each.ExitRoom();
}
foreach (var each in OBs)
{
each.ExitRoom();
}
Server.Rooms.Remove(RoomId);
}
由于服務(wù)器回調(diào)事件較多,只展示創(chuàng)建心跳包回調(diào)事件的關(guān)鍵代碼:
private void _HeartBeat(Player player, byte[] data)
{
//僅做回應(yīng)
player.Send(MessageType.HeartBeat);
}
服務(wù)器核心邏輯實(shí)現(xiàn)
服務(wù)器可以存在多個不同房間號的房間,每個房間對應(yīng)一個棋盤,每個房間上還擁有多個玩家。按照這個思路再去設(shè)計棋盤的邏輯。
以下為棋盤的關(guān)鍵代碼:
/// <summary>
/// 初始化棋盤
/// </summary>
public GamePlay()
{
ChessState = new Chess[15, 15];
_totalChess = 0;
Playing = true;
Turn = Chess.Black;
}
public Chess[,] ChessState; //儲存棋子狀態(tài)
private int _totalChess; //總棋數(shù)
public bool Playing; //游戲進(jìn)行中
public Chess Turn; //輪流下棋
游戲邏輯實(shí)現(xiàn)
服務(wù)器上的這個棋盤才是真正進(jìn)行五子棋邏輯操作的棋盤,關(guān)鍵的算法都在上面實(shí)現(xiàn)??蛻舳嗣堪l(fā)送一次下棋操作,都會在棋盤上進(jìn)行計算,結(jié)果再由服務(wù)器廣播給在這個房間中的所有人,包括玩家們與觀察者們。
以下為五子棋玩法邏輯的算法:
public Chess Calculate(int x, int y)
{
if (!Playing) return Chess.Null;
//邏輯判斷
if (x < 0 || x >= 15 || y < 0 || y >= 15 || ChessState[x, y] != Chess.None)
{
return Chess.Null;
}
//下棋
_totalChess++;
//黑棋
if (Turn == Chess.Black)
{
ChessState[x, y] = Chess.Black;
}
//白棋
else if (Turn == Chess.White)
{
ChessState[x, y] = Chess.White;
}
//計算結(jié)果
bool? result = _CheckWinner();
//要么平局要么勝利(任意一方勝利后不在交替下棋,游戲結(jié)束)
if (result != false)
{
//游戲結(jié)束
Playing = false;
//勝利
if (result == true)
{
return Turn;
}
//平局
else
{
return Chess.Draw;
}
}
//繼續(xù)下棋
else
{
//交替下棋
Turn = (Turn == Chess.Black ? Chess.White : Chess.Black);
return Chess.None;
}
}
private bool? _CheckWinner()
{
//遍歷棋盤
for (int i = 0; i < 15; i++)
{
for (int j = 0; j < 15; j++)
{
//各方向連線
int horizontal = 1, vertical = 1, rightUp = 1, rightDown = 1;
Chess curPos = ChessState[i, j];
if (curPos != Turn)
continue;
//判斷5連
for (int link = 1; link < 5; link++)
{
//掃描橫線
if (i + link < 15)
{
if (curPos == ChessState[i + link, j])
horizontal++;
}
//掃描豎線
if (j + link < 15)
{
if (curPos == ChessState[i, j + link])
vertical++;
}
//掃描右上斜線
if (i + link < 15 && j + link < 15)
{
if (curPos == ChessState[i + link, j + link])
rightUp++;
}
//掃描右下斜線
if (i + link < 15 && j - link >= 0)
{
if (curPos == ChessState[i + link, j - link])
rightDown++;
}
}
//勝負(fù)判斷
if (horizontal == 5 || vertical == 5 || rightUp == 5 || rightDown == 5)
{
return true;
}
}
}
//棋盤下滿
if (_totalChess == ChessState.GetLength(0) * ChessState.GetLength(1))
{
//平局
return null;
}
return false;
}
那么,對于服務(wù)器來說,還有一件重要的事情,如何把一個玩家的操作發(fā)送給相同房間里的所有玩家與觀察者呢?這個可以通過對房間內(nèi)保存的每一個玩家的套接字(Socket)進(jìn)行發(fā)送數(shù)據(jù)操作。
以下為廣播給所有玩家的關(guān)鍵代碼:
//判斷結(jié)果
Chess chess = Server.Rooms[receive.RoomId].GamePlay.Calculate(receive.X, receive.Y);
//檢測操作:如果游戲結(jié)束
bool over = _ChessResult(chess, result);
PlayChess result = new PlayChess();
result.Chess = receive.Chess;
result.X = receive.X;
result.Y = receive.Y;
Console.WriteLine($"玩家:{player.Name}下棋成功");
//向該房間中玩家與觀察者廣播結(jié)果
data = NetworkUtils.Serialize(result);
foreach (var each in Server.Rooms[receive.RoomId].Players)
{
each.Send(MessageType.PlayChess, data);
}
foreach (var each in Server.Rooms[receive.RoomId].OBs)
{
each.Send(MessageType.PlayChess, data);
}
還有很多服務(wù)器對房間系統(tǒng)所做的邏輯處理都寫在服務(wù)器的回調(diào)事件中,因?yàn)榇a量較多不能一一列出。更多細(xì)節(jié)會在文章結(jié)尾放出源碼,歡迎學(xué)習(xí)或吐槽。
好了。到此為止,恭喜你已經(jīng)達(dá)成“從無到有制作局域網(wǎng)聯(lián)機(jī)游戲”這樣一個成就。過程中的代碼量和經(jīng)驗(yàn)有沒有給你一種脫胎換骨的趕腳?


完整工程如下:https://pan.baidu.com/s/19rfHgHZIe55dZ7LVIL1oUg?errno=0&errmsg=Auth%20Login%20Sucess&&bduss=&ssnerror=0&traceid=
OK,希望本文對在游戲開發(fā)的道路上的你有所啟發(fā)。
想系統(tǒng)學(xué)習(xí)游戲開發(fā)的童鞋,歡迎訪問 http://levelpp.com/
游戲開發(fā)攪基QQ群:869551769
微信公眾號:皮皮關(guān)