<p>
实现了不同电脑的联机小游戏,游戏功能比较简单,可以看到不同客户端的物体移动,初步了解了协议的封装和解封装,熟悉了网络服务器和客户端收发消息的过程。
<pre>
整理个笔记方便以后回顾。
</pre>
</p>
-
一、游戏组成
1.服务器
因为是网络游戏,所以肯定是需要服务器啦,主要负责各个客户端的消息交互,通俗说就是把某个客户端发送的消息转发给其他的客户端。代码主要包括以下几个部分:
(1)Connect类
连接类,这个类是Socket的封装,包含了连接时需要的一些字段属性和方法,字段属性包括:socket,接受消息使用的数组,数组的长度,发送消息使用的数组,读取消息的字符串,标志位;
方法包括:初始化socket的方法Init,获取连接客户端IP端口的GetIP方法,以及关闭连接的Close方法;
(2)Server类
服务器类,包含了,初始化连接池,服务器开启,连接客户端,接受以及发送客户端消息的整个过程。
2.客户端
使用的是unity作为客户端,通过服务器传递球体(prefab)运动的消息给其他客户端,包含了:自定义运动协议封装和解封装的消息处理,socket连接服务器,还有球体的运动操作等。
二、实现步骤
1.服务器的实现
分别实现服务器的几个类,然后开启服务即可:
- (1)connect类:
//首先定义属性
public Socket workSocket //和客户端连接成功后使用的socket
public byte[] readByte; //接受客户端消息的字节数组
public int BYTE_NUMBER = 1024; //数组的长度
public byte[] sendByte; //发送消息的字节数组
public string readStr; //读取消息使用的字符串
public bool isUsed; //标示该connect是否使用
//分别实现的方法
//构造方法,实例化的时候先初始化接收数组和标志位,false表示未使用
public Connect()
{
readByte = new byte[BYTE_NUMBER];
isUsed = false;
}
//初始化方法当有客户端连接到改socket后,初始化以及标志位改为true
public void Init(Socket socket)
{
workSocket = socket;
isUsed = true;
}
//返回远程客户端的IP和端口
public string GetIP()
{
if (!isUsed) return "未连接成功";
return workSocket.RemoteEndPoint.ToString();
}
//当客户端传送数据完毕时,断开连接
public void Close()
{
if (!isUsed) return;
Console.WriteLine("和客户端“{0}”断开连接。", GetIP());
workSocket.Shutdown(SocketShutdown.Both);
workSocket.Close();
isUsed = false;
}
- (2)Server类
该类中包含了字段有:监听用的socket,connect连接池,最大连接的数量。
//声明监听的socket
Socket listenSocket;
//最大的连接数量
int maxCount = 50;
//声明连接池
Connect[] conns;
完成字段声明后,需要返回连接ID的方法,然后开始进行建立socket连接,接收数据,发送数据的流程步骤:初始化socket--->绑定服务器ip端口--->设置最大的连接数--->开启异步接收连接--->在回调函数中判断连接是否成功,成功后开启异步接收--->异步接收同样使用回调函数来控制接收和发送数据。(注意其中的参数意义)
//返回一个可用的连接池ID
public int GetIndex()
{
//判断连接池有没有初始化,如果没有则直接return -1
if (conns == null) return -1;
//开始遍历连接池,寻找一个可以使用的连接(isUsed=false)
for (int i = 0; i < conns.Length; i++)
{
if(conns[i]==null)
{
conns[i] = new Connect();
return i;
}
else if(conns[i]!=null&&!conns[i].isUsed)
{
return i;
}
}
//如果没有可用的连接,则返回-1
return -1;
}
//服务器开启方法
public void Start(IPEndPoint ipPoint)
{
//开启之前首先实例化50个连接,为以后提供使用
conns = new Connect[maxCount];
for(int i=0;i<conns.Length;i++)
{
conns[i] = new Connect();
}
//实例化监听sokcet
listenSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
//绑定服务器端口IP
listenSocket.Bind(ipPoint);
//设置服务器最大连接数
listenSocket.Listen(maxCount);
//开启异步等待连接
listenSocket.BeginAccept(AcceptCb, null);
Console.WriteLine("服务器开启,等待连接:");
}
//异步等待连接的回调函数,当有连接时执行
void AcceptCb(IAsyncResult ar)
{
try
{
//有连接接入时,声明新的socket来接收连接
Socket socket = listenSocket.EndAccept(ar);
//获取一个可用的连接ID
int index = GetIndex();
//判断是否有可用的ID
if (index < 0)
{
Console.WriteLine("连接已满,请稍后重试!");
return;
}
//获得连接ID后,初始化连接并打印连接成功的消息
conns[index].Init(socket);
Console.WriteLine("连接成功,客户端地址为:{0}", conns[index].GetIP());
//连接成功后,开始异步接收
conns[index].workSocket.BeginReceive(conns[index].readByte, 0
, conns[index].BYTE_NUMBER, SocketFlags.None, ReceiveCB, conns[index]);
//一个连接完成后,开始进行递归等待新连接
listenSocket.BeginAccept(AcceptCb, null);
}
catch(Exception e)
{
throw e;
}
}
//异步接收的回调函数,接收完毕后执行
void ReceiveCB(IAsyncResult ar)
{
//声明一个connect连接来接收异步接收传递过来的连接(通过最后一个参数)
Connect conn = ar.AsyncState as Connect;
//判断接收的数量
int count = conn.workSocket.EndReceive(ar);
//如果为0,则表示接收完毕,可以断开连接
if(count<=0)
{
Console.WriteLine("从“{0}”接收完毕,断开连接!", conn.GetIP());
//断开连接后,广播发送消息给其他客户端,告知该客户端断开连接,做相应的处理操作
string leave = "LEAVE" + " " + conn.GetIP();
conn.sendByte = System.Text.Encoding.UTF8.GetBytes(leave);
for (int i = 0; i < conns.Length; i++)
{
if (conns[i].isUsed)
{
conns[i].workSocket.Send(conn.sendByte);
}
}
conn.Close();
return;
}
//讲接收到的字节消息转换成成字符串
conn.readStr = System.Text.Encoding.UTF8.GetString(conn.readByte, 0, count);
//在服务器打印收到的消息
Console.WriteLine("服务器收到从“{0}”的消息:{1}", conn.GetIP(), conn.readStr);
//收到消息后广播给其他客户端
conn.sendByte = System.Text.Encoding.UTF8.GetBytes(conn.readStr);
for(int i=0;i<conns.Length;i++)
{
if (conns[i].isUsed)
{
conns[i].workSocket.Send(conn.sendByte);
}
}
//接收消息完毕后,递归接收消息
conn.workSocket.BeginReceive(conn.readByte, 0, conn.BYTE_NUMBER, SocketFlags.None, ReceiveCB, conn);
}
}
(3)完成后,在主函数当中声明服务器类,通过Star方法开启服务器,等待客户端连接。
2.客户端的实现
(1)移动的物体
使用unity中的3d球体加上文本组成的prefab,比较简单:
(2)然后在unity上创建一个空对象,用来挂控制脚本:
(3)码控制脚本
- 属性:
//用来连接服务器的socket相关属性
Socket socket;
int BUFF_NUMBER;
byte[] readByte;
byte[] sendByte;
string readStr;
//玩家列表字典,存放玩家的信息
Dictionary<string, GameObject> players;
//消息list,用来存放服务器发送的消息
List<string> msgList;
//玩家的预设,本次使用的是一个3d球体
public GameObject prefab;
//本机玩家
GameObject player;
//本机玩家的id字段
string m_id;
- 方法
先实现所有的方法,完成后再在Awake,Start,Update中调用相应的方法。
//连接方法,和服务器的连接类似
void ConnetServer()
{
socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
socket.Connect(IPAddress.Parse("127.0.0.1"), 3344);
m_id = socket.LocalEndPoint.ToString();
socket.BeginReceive(readByte, 0, BUFF_NUMBER, SocketFlags.None, ReceiveCB, null);
}
//异步接收的回调函数
void ReceiveCB(IAsyncResult ar)
{
try
{
int count = socket.EndReceive(ar);
if(count<=0)
{
return;
}
readStr = System.Text.Encoding.UTF8.GetString(readByte, 0, count);
//转化成字符串消息后,加入消息列表,统一处理
msgList.Add(readStr);
//递归的调用异步接收
socket.BeginReceive(readByte, 0, BUFF_NUMBER, SocketFlags.None, ReceiveCB, null);
}
catch (Exception e)
{
throw e;
}
}
//封装位置消息,发送给服务器,在每次移动的时候调用
void SendPos()
{
//通过id找到本机的玩家
player = players[m_id];
Vector3 pos = player.transform.position;
//封装玩家的位置信息,格式为:POS+ID+X+Y+Z,通过空格分割。
string msgPos = "POS"+" "+ m_id + " " + pos.x+" "+ pos.y + " "+ pos.z ;
//转化成字节数组发送
sendByte = System.Text.Encoding.UTF8.GetBytes(msgPos);
socket.Send(sendByte);
}
//发送离开消息,在游戏推出时发送(本次没有使用,采用的方式是在服务器上处理。)
void SendLeave()
{
player = players[m_id];
Vector3 pos = player.transform.position;
//封装的格式为:Leave+id
string msgLeave = "Leave" + " " + m_id;
sendByte = System.Text.Encoding.UTF8.GetBytes(msgLeave);
socket.Send(sendByte);
}
//处理玩家离开消息
void HandleLeave(string id)
{
if(players.ContainsKey(id)&&id!=m_id)
{
GameObject.Destroy(players[id]);
players[id] = null;
}
}
//消息处理方法,处理放在消息list中的消息,分割后,通过字符串数组的第一个来确定消息类型并处理
void HandleMsg()
{
//如果消息列表为空,则不处理
if (msgList.Count == 0) return;
//通过空格分割消息,存放到数组中
string[] arg = msgList[0].Split(' ');
//处理一条消息后,从list中删除这条消息
msgList.RemoveAt(0);
//位置消息的处理
if(arg[0]=="POS")
{
HandlePos(arg[1], arg[2], arg[3], arg[4]);
}
//离开消息的处理
else if(arg[0]=="LEAVE")
{
HandleLeave(arg[1]);
}
}
//处理位置消息,负责处理其他玩家的位置移动
void HandlePos(string id,string x,string y,string z)
{
//如果消息的id是本机的话,则不处理
if (id == m_id) return;
//存放位置信息
Vector3 pos = new Vector3(float.Parse(x), float.Parse(y), float.Parse(z));
//做判断,如果该玩家已经存在,则移动该玩家的位置,如果不存在,则生成一个新的玩家。
if(players.ContainsKey(id))
{
players[id].transform.position = pos;
}
else
{
AddPlayer(id, pos);
}
}
//生成玩家方法
void AddPlayer(string id,Vector3 pos)
{
GameObject newPlayer = Instantiate(prefab, pos, Quaternion.identity);
TextMesh mesh = newPlayer.GetComponentInChildren<TextMesh>();
mesh.text = id;
players.Add(id, newPlayer);
}
//本机玩家移动的方法
void Move()
{
//通过id确定本机的玩家
player = players[m_id];
//玩家的移动速度
float spped = 0.1f;
//通过左右上下控制移动,每次移动都会通过SendPos方法发送位置消息
if (Input.GetKey(KeyCode.LeftArrow))
{
player.transform.position += new Vector3(-spped,0,0);
SendPos();
}
else if(Input.GetKey(KeyCode.RightArrow))
{
player.transform.position += new Vector3(spped, 0, 0);
SendPos();
}
else if (Input.GetKey(KeyCode.UpArrow))
{
player.transform.position += new Vector3(0, spped, 0);
SendPos();
}
else if (Input.GetKey(KeyCode.DownArrow))
{
player.transform.position += new Vector3(0, -spped, 0);
SendPos();
}
}
//首先初始化字段
private void Awake()
{
players = new Dictionary<string, GameObject>();
msgList = new List<string>();
BUFF_NUMBER = 1024;
readByte = new byte[1024];
sendByte = new byte[1024];
}
//初始化字段后,开始建立连接,并生成本机玩家
void Start () {
ConnetServer();
AddPlayer(m_id, new Vector3(0, 0, 0));
}
//在update中一直处理从服务器发送的消息以及控制自身移动
void FixedUpdate () {
HandleMsg();
Move();
}
}
三、总结
写到这游戏就算差不多完成啦,可以体验一下啦,首先先开启服务器,然后在开启客户端(客户端可以通过unity build生成客户端,同时运行几个即可),另外,注意几点:
(1)在退出方面,本次由于unity中UI没有建立一个退出按钮,所以没法由客户端主动发送退出消息,所以改成在服务器发送,后续可以改进。
(2)服务器端,使用连接来封装socket,最后组成连接池,这样的好处在于可以在服务器开启前就先建立好连接池,不用等到有接入在生成连接,提高了效率。
-
(3)在码代码中出现的几个问题:
a.服务器在获取可用连接id时,注意判断返回id时的条件,一定要写明清楚,不要使用else,会造成得到的连接占用。
b.客户端异步接收和发送的时候,需要使用两个不同的字节数组来处理,不然会有冲突,导致只会接收一次消息,不能递归。
c.服务器在处理异步接收的时候,如果使用connect中封装的count来接收endReceive的话,会造成客户端收到的消息有误,这个没找到原因。
最后截几个游戏图:
(tips,一开始其他客户端没有移动的时候是不会新建球体的。)