大家好,我又来了。
废话不多说,咱们赶紧的,接着上一篇文章把这个联网项目搞完。
客户端发送消息
然后在NetworkClient中提供发送消息的方法,发送消息使用消息队列的机制(就是把给发送的消息放进一个队列(Queue) 通过一个协程专门向服务器发送队列中的消息)。需要发送什么消息只需往消息队列中添加消息即可。下面是封装消息数据包方法:
/// <summary>
/// 加入消息队列
/// </summary>
public static void Enqueue(MessageType type, byte[] data = null)
{
byte[] bytes = _Pack(type, data); // Pack方法在上文中已经实现
if (_curState == ClientState.Connected)
{
//加入队列
_messages.Enqueue();
}
}
以下是发送协程:
private static IEnumerator _Send()
{
//持续发送消息
while (_curState == ClientState.Connected)
{
_timer += Time.deltaTime;
//有待发送消息
if (_messages.Count > 0)
{
byte[] data = _messages.Dequeue();
yield return _Write(data); //稍后会实现
}
//心跳包机制(每隔一段时间向服务器发送心跳包)
if (_timer >= HEARTBEAT_TIME)
{
//如果没有收到上一次发心跳包的回复
if (!Received)
{
_curState = ClientState.None;
Debug.Log("心跳包接受失败,断开连接");
yield break;
}
_timer = 0;
//封装消息
byte[] data = _Pack(MessageType.HeartBeat);
//发送消息
yield return _Write(data);
Debug.Log("已发送心跳包");
}
yield return null; //防止死循环
}
}
然后就是NetworkClient关键的发送信息方法Write:
private static IEnumerator _Write(byte[] data)
{
//如果服务器下线, 客户端依然会继续发消息
if (_curState != ClientState.Connected || _stream == null)
{
Debug.Log("断开连接");
yield break;
}
//异步发送消息
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,客户端的大坑终于快填完(其实还不到一半)。
在Network中实现一个发送创建房间请求的示例(发送一般是接受用户操作后,所以这个方法可以绑定在UI上,由UI事件去触发),当然为了方便测试,放在Start方法里也没问题:
public void CreatRoomRequest(int roomId)
{
CreatRoom request = new CreatRoom();
request.RoomId = roomId;
byte[] data = NetworkUtils.Serialize(request);
NetworkClient.Enqueue(MessageType.CreatRoom, data);
}
现在,我们的客户端只会发送消息,服务器只会接收消息,简直就是一个聋子跟一个哑巴。
来点轻松点的,我们先把客户端的GamePlay部分实现吧。
客户端
基本游戏逻辑
棋盘
制作棋盘的时候,先把一张Sprite图片放进Unity场景中。为了方便我们用射线检测我们的鼠标在棋盘上的落点,我们可以在棋盘的Layer中添加上ChessBoard并且在棋盘上添加BoxCollider:
在左上角,左上角,右上角都添加好锚点
添加完锚点之后,棋盘的制作就已经完成,然后找两个适合的棋子图片做成预制体就OK了。
然后我们应该想,如何去完善棋盘的数据结构,创造一个保存所有落点世界坐标的二维数组。
一个比较好的做法是:利用这三个锚点,求出棋盘左右的宽度与上下的高度,进而可以求出每一个方格的宽度与高度。然后我们再根据左下角的锚点(原点),用一个双重循环遍历这个保存落点世界坐标的二维数组并进行赋值。以下是实现代码:
using UnityEngine;
using Multiplay; //为协议的命名空间
/// <summary>
/// 处理下棋逻辑
/// </summary>
public class NetworkGameplay : MonoBehaviour
{
//单例
private NetworkGameplay() { }
public static NetworkGameplay Instance { get; private set; }
[SerializeField]
private GameObject _blackChess; //需要实例化的黑棋
[SerializeField]
private GameObject _whiteChess; //需要实例化的白棋
//棋盘上的锚点
[SerializeField]
private GameObject _leftTop; //左上
[SerializeField]
private GameObject _leftBottom; //左下
[SerializeField]
private GameObject _rightTop; //右上
private Vector2[,] _chessPos; //储存棋子世界坐标
private float _gridWidth; //网格宽度
private float _gridHeight; //网格高度
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;
//初始化每个下棋点的位置
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,有了棋盘,接下来当然就是在棋盘类中提供用户输入检测接口与实例化棋子的接口。
在这里提供一个Vec2类型,方便表示棋子在棋盘上的下标:
public struct Vec2
{
public int X;
public int Y;
public Vec2(int x, int y)
{
X = x;
Y = y;
}
}
检测用户输入
/// <summary>
/// 下棋
/// </summary>
public Vec2 PlayChess()
{
//创建射线
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
RaycastHit hit;
//如果用户点中棋盘
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++)
{
//计算鼠标点击点与下棋点的距离(只算x,y平面距离)
float distance = _Distance(hit.point, _chessPos[i, j]);
//鼠标点击在落点周围半个格子宽度就算下棋
if (distance < (_gridWidth / 2))
{
//返回这个落点在二维数组中的下标
return new Vec2(i, j);
}
}
}
}
//未点击到棋盘
return new Vec2(-1, -1);
}
/// <summary>
/// 计算两个Vector2的距离
/// </summary>
private float _Distance(Vector2 a, Vector2 b)
{
Vector2 distance = b - a;
return distance.magnitude;
}
实例化棋子
/// <summary>
/// 实例化棋子
/// </summary>
public void InstChess(Chess chess, Vec2 pos)
{
//获取该落点的世界坐标
Vector2 vec2 = _chessPos[pos.X, pos.Y];
//棋子坐标:棋子的z坐标不能与棋盘一致且必须更靠近摄像机近截面,
//不然有可能会与棋盘重叠导致棋子不可见。
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; //为协议的命名空间
/// <summary>
/// 一个游戏客户端只能存在一个网络玩家
/// </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)
{
//发送下棋请求 TODO
}
}
}
在这里提供个客户端的类型,方便之后开发。
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部分,此处不做详细介绍。
以下是本游戏UI提供的用户接口,仅供参考:
[SerializeField]
private InputField _ipAddressIpt; //服务器IP输入框
[SerializeField]
private InputField _roomIdIpt; //房间号码输入框
[SerializeField]
private InputField _nameIpt; //名字输入框
[SerializeField]
private Button _connectServerBtn; //连接服务器按钮
[SerializeField]
private Button _enrollBtn; //注册按钮
[SerializeField]
private Button _creatRoomBtn; //创建房间按钮
[SerializeField]
private Button _enterRoomBtn; //加入房间按钮
[SerializeField]
private Button _exitRoomBtn; //退出房间按钮
[SerializeField]
private Button _startGameBtn; //开始游戏按钮
[SerializeField]
private Text _gameStateTxt; //游戏状态文本
[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);
}
实现了基本的客户端棋盘逻辑。接下来就是重点了,涉及到客户端与服务器的网络通讯部分。现在我们的客户端已经具备接受玩家输入,并且可以把用户鼠标点击的位置转化为棋盘上的位置。
客户端接受消息
有了发送消息肯定少不了接受消息,客户端必须对服务器发来的反馈再进行操作。
接收消息这里有个也有回调事件机制:在把数据包拆成:消息长度,消息类型之后,我们可以通过不同的消息类型去执行不同的回调方法。
以下为NetworkClient的接收消息(Receive)方法的关键代码:
while(true)
{
//解析数据包过程(服务器与客户端需要严格按照一定的协议制定数据包)
byte[] data = new byte[4]; //数据包包头长度(2 + 2)
int length; //消息总长度
MessageType type; //类型
int receive = 0; //接收到的数据长度
//异步读取
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);
}
回调事件对客户端做的具体操作这里就不放源码了,只示范一个事件:
private void _Heartbeat(byte[] data)
{
NetworkClient.Received = true;
Debug.Log("收到心跳包回应");
}
与服务器类似,回调的核心就是把消息类型与相对应的回调事件一起注册一个字典(这个过程在客户端接收服务器数据之前)。
每次接受消息后,只需要把这次的消息类型与字典中进行匹配,进而客户端执行相对应的回调事件即可。以下为回调机制关键代码:
//注册回调事件
public static void Register(MessageType type, CallBack method)
{
if (!_callBacks.ContainsKey(type))
_callBacks.Add(type, method);
else
Debug.LogWarning("注册了相同的回调事件");
}
//执行回调,这里的代码应该放在接收消息方法中
if (_callBacks.ContainsKey(type))
{
//执行回调事件
CallBack method = _callBacks[type];
method(data);
}
到这里,客户端的关键制作思路已经介绍完成。
服务器发送消息
以下是服务器对客户端的发送消息的方法,下面是代码:
/// <summary>
/// 封装并发送信息 ,写在Server中的Player类型扩展方法
/// </summary>
public static void Send(this Player player, MessageType type, byte[] data = null)
{
byte[] bytes = _Send(type, data); //在介绍数据包时已经实现
//发送消息
player.Socket.Send(bytes);
}
终于不是两个残疾人在通信了。
服务器房间系统
接下来还有房间类型,房间构成了一局游戏的基础。
在本游戏中,房间号码不可重复。当一个玩家创建好一个房间时,其他玩家在玩家人数未满时加入房间,就会成为玩家。在玩家人数满了,但是观察者人数未满时加入房间就是观察者。
如果全部满了之后就无法进入该房间。当一个房间的状态进入开始游戏状态后,所有人都无法再进入其中。当一局游戏结束后,此房间会自动关闭。
然后我们在服务器的Room类中添加一个方法:
/// <summary>
/// 关闭房间:从房间字典中移除并且所有房间中的玩家清除
/// </summary>
public void Close()
{
//所有玩家跟观战者退出房间
foreach (var each in Players)
{
each.ExitRoom();
}
foreach (var each in OBs)
{
each.ExitRoom();
}
Server.Rooms.Remove(RoomId);
}
由于服务器回调事件较多,只展示创建心跳包回调事件的关键代码:
private void _HeartBeat(Player player, byte[] data)
{
//仅做回应
player.Send(MessageType.HeartBeat);
}
服务器核心逻辑实现
服务器可以存在多个不同房间号的房间,每个房间对应一个棋盘,每个房间上还拥有多个玩家。按照这个思路再去设计棋盘的逻辑。
以下为棋盘的关键代码:
/// <summary>
/// 初始化棋盘
/// </summary>
public GamePlay()
{
ChessState = new Chess[15, 15];
_totalChess = 0;
Playing = true;
Turn = Chess.Black;
}
public Chess[,] ChessState; //储存棋子状态
private int _totalChess; //总棋数
public bool Playing; //游戏进行中
public Chess Turn; //轮流下棋
游戏逻辑实现
服务器上的这个棋盘才是真正进行五子棋逻辑操作的棋盘,关键的算法都在上面实现。客户端每发送一次下棋操作,都会在棋盘上进行计算,结果再由服务器广播给在这个房间中的所有人,包括玩家们与观察者们。
以下为五子棋玩法逻辑的算法:
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;
}
//计算结果
bool? result = _CheckWinner();
//要么平局要么胜利(任意一方胜利后不在交替下棋,游戏结束)
if (result != false)
{
//游戏结束
Playing = false;
//胜利
if (result == true)
{
return Turn;
}
//平局
else
{
return Chess.Draw;
}
}
//继续下棋
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++;
}
}
//胜负判断
if (horizontal == 5 || vertical == 5 || rightUp == 5 || rightDown == 5)
{
return true;
}
}
}
//棋盘下满
if (_totalChess == ChessState.GetLength(0) * ChessState.GetLength(1))
{
//平局
return null;
}
return false;
}
那么,对于服务器来说,还有一件重要的事情,如何把一个玩家的操作发送给相同房间里的所有玩家与观察者呢?这个可以通过对房间内保存的每一个玩家的套接字(Socket)进行发送数据操作。
以下为广播给所有玩家的关键代码:
//判断结果
Chess chess = Server.Rooms[receive.RoomId].GamePlay.Calculate(receive.X, receive.Y);
//检测操作:如果游戏结束
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}下棋成功");
//向该房间中玩家与观察者广播结果
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);
}
还有很多服务器对房间系统所做的逻辑处理都写在服务器的回调事件中,因为代码量较多不能一一列出。更多细节会在文章结尾放出源码,欢迎学习或吐槽。
好了。到此为止,恭喜你已经达成“从无到有制作局域网联机游戏”这样一个成就。过程中的代码量和经验有没有给你一种脱胎换骨的赶脚?
完整工程如下:https://pan.baidu.com/s/19rfHgHZIe55dZ7LVIL1oUg?errno=0&errmsg=Auth%20Login%20Sucess&&bduss=&ssnerror=0&traceid=
OK,希望本文对在游戏开发的道路上的你有所启发。