【Unity技术栈】《Unity3D网络游戏实战》笔记
聪头 游戏开发萌新

《Unity3D网络游戏实战》

2021年2月8日22:54:57

1
2
3
4
5
6
7
8
9
10
11
12
13
/****************************************************
文件:Constant.cs
作者:聪头
邮箱: 1322080797@qq.com
日期:2021/02/11 0:29
功能:常量
*****************************************************/

public class Constant
{
public const string IP = "127.0.0.1";
public const int POINT = 8888;
}

第1章 网络游戏的开端:Echo

1.1 网络基本概念

Socket:网络上的两个程序通过一个双向的通信连接实现数据交换,这个连接的一端称为Socket

包含:

  1. 连接使用的协议
  2. 本地主机的IP地址
  3. 本地的协议端口
  4. 远程主机的IP地址
  5. 远程协议端口

通信流程:

  1. 开启一个连接之前,需要创建一个Socket对象(使用API Socket),然后绑定本地使用的端口(使用API Bind)
  2. 服务端开启监听(使用API Listen),等待客户端接入
  3. 客户端连接服务器(使用API Connect)
  4. 服务器接受连接(使用API Accept)
  5. 客户端和服务端通过Send和Receive等API收发数据,操作系统会自动完成数据的确认、重传等步骤,确保传输的数据准确无误
  6. 某一方关闭连接(使用API Close),操作系统会执行“四次挥手”的步骤,关闭双方连接

1.2 IP地址:网络上计算机都是通过IP地址识别。通常,每一个IP地址对应于一台计算机

1.3 端口:是设备与外界通信交流的出口。范围0~65535

1.4 TCP和UDP协议:

  • TCP是一种面向连接的、可靠的、基于字节流的传输层通信协议(本书的Socket通信特指使用TCP协议的通信)
  • UDP是一种无连接的、不可靠的、但传输效率较高的协议

1.2 开始网络编程:Echo

Echo程序是网络编程中最基础的案例。建立网络连接后,客户端向服务端发送一行文本,服务端收到后将文本发送回客户端

image

客户端Echo代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
/****************************************************
文件:Ch1_Echo.cs
作者:聪头
邮箱: 1322080797@qq.com
日期:2021年2月8日23:40:41
功能:客户端Echo,实现简单的通信
*****************************************************/
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.Net.Sockets;
using UnityEngine.UI;

public class Ch1_Echo : MonoBehaviour
{
//定义套接字
private Socket socket;
//UGUI
public InputField inputField;
public Text text;

//点击连接按钮
public void Connection()
{
//Socket
socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
//Connect 阻塞式连接
socket.Connect("127.0.0.1", 8888);
}

//点击发送按钮
public void Send()
{
//Send
string sendStr = inputField.text;
byte[] sendBytes = System.Text.Encoding.Default.GetBytes(sendStr);
socket.Send(sendBytes); //阻塞式发送 返回发送数据的长度
//Recv
byte[] readBuff = new byte[1024];
int count = socket.Receive(readBuff); //阻塞式接收
string recvStr = System.Text.Encoding.Default.GetString(readBuff, 0, count);
text.text = recvStr;
//Close
socket.Close();
}
}

创建Socket对象

1
socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);

①地址族 AddressFamily

  • 指明使用IPv4还是IPv6
AddressFamily的值 含义
InterNetwork 使用IPv4
InterNetwork 使用IPv6

②套接字类型 SocketType

  • 套接字类型,游戏开发中最常用的是字节流套接字,即Stream
SocketType的值 含义
Dgram 支持数据报,即最大长度固定(通常很小)的无连接、不可靠消息。消息可能会丢失或重复并可能在达到时不按顺序排列。Dgram类型的Socket在发送和接收数据之前不需要任何连接,并且可以与多个对方主机进行通信。Dgram使用数据报协议(UDP)和InterNetworkAddressFamily
Raw 支持对基础传输协议的访问。通过使用SocketTypeRaw,可以使用Internet控制消息协议(ICMP)和Internet组管理协议(Igmp)这样的协议来进行通信。在发送时,您的应用程序必须提供完整的IP标头。所接收的数据报在返回时会保持其IP标头和选项不变
RDM 支持无连接、面向消息、以可靠方式发送的消息,并保留数据中的消息边界。RDM(以可靠方式发送的消息)消息会依次到达,不会重复。此外,如果消息丢失,将会通知发送方。如果使用RDM初始化Socket,则在发送和接收数据之前无需建立远程主机连接。利用RDM,可以与多个对方主机进行通信
Seqpacket 在网络上提供排序字节流的面向连接且可靠的双向传输。Seqpacket不重复数据,它在数据流中保留边界。Seqpacket类型的Socket与单个对方主机通信,并且在通信开始之前需要建立远程主机连接
Stream 支持可靠、双向、基于连接的字节流,而不重复数据,也不保留边界。此类型的Socket与单个对方主机通信,并且在通信开始之前需要建立远程主机连接。Stream使用传输控制协议(TCP)和InterNetworkAddressFamily
Unknown 指定未知的Socket类型

③协议 ProtocolType

常用的协议 含义 常用的协议 含义
GGP 网关到网关的协议 PARC 通用数据包协议
ICMP 网际消息控制协议 RAW 原始IP数据包协议
ICMPv6 用于IPv6的Internet控制消息协议 TCP 传输控制协议
IDP Internet数据报协议 UDP 用户数据包协议
IGMP 网际组管理协议 Unknown 未知协议
IP 网际协议 Unspecified 未指定的协议
Internet 数据包交换协议

若使用UDP协议,则改为:

1
socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);

服务端Echo代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
/****************************************************
文件:Ch1_Echo.cs
作者:聪头
邮箱: 1322080797@qq.com
日期:2021/02/09 0:21
功能:Echo Server
*****************************************************/
using System;
using System.Net;
using System.Net.Sockets;

namespace EchoServer
{
public class Ch1_Echo
{
public static void Main(string[] args)
{
//Socket fd:文件描述符号,类似文件句柄,file descriptor
Socket listenfd = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
//Bind
IPAddress ipAdr = IPAddress.Parse("127.0.0.1");
IPEndPoint ipEp = new IPEndPoint(ipAdr, 8888);
listenfd.Bind(ipEp);
//Listen
listenfd.Listen(0);//参数backlog指定队列中最多可容纳等待的连接数,0表示不限制
Console.WriteLine("[ 服务器 ] 启动成功");
while(true)
{
//Accept
Socket connfd = listenfd.Accept();
Console.WriteLine("[ 服务器 ] Accept");
//Receive
byte[] readBuff = new byte[1024];
int count = connfd.Receive(readBuff);
string readStr = System.Text.Encoding.Default.GetString(readBuff, 0, count);
Console.WriteLine("[ 服务器 ] 接收:" + readStr);
//Send
byte[] sendBytes = System.Text.Encoding.Default.GetBytes(readStr);
connfd.Send(sendBytes);
}
}
}
}

Echo演示

image

1.3 更多API

Socket类的一些常用属性

属性 说明
AddressFamily 获取Socket的地址族
Available 获取已经从网络接收且可供读取的数据量
Blocking 获取或设置一个值,该值指示Socket是否处于阻塞模式
Connected 获取一个值,该值指示Socket是否连接
IsBound 指示Socket是否绑定到特定本地端口
OSSupportsIPv6 指示操作系统和网络适配器是否支持Internet协议第6版(IPv6)
ProtocolType 获取Socket的协议类型
SendBufferSize 指定Socket发送缓冲区的大小
SendTimeout 指定之后同步Send调用将超时的时间长度
ReceiveBufferSize 指定Socket接收缓冲区的大小
ReceiveTimeout 指定之后同步Receive调用将超时的时间长度
Ttl 指定Socket发送的Internet协议(IP)数据包的生存时间(TTL)值

Socket类的一些常用方法

方法 说明
Bind 使Socket与一个本地终结点相关联
Listen 将Socket置于侦听状态
Accept 为新建连接创建新的Socket
Connect 建立与远程主机的连接
Send 将数据发送到连接的Socket
Receive 接收来自绑定的Socket的数据
Close 关闭Socket连接并释放所有关联的资源
Shutdown 禁用某Socket上的发送和接收
Disconnect 关闭套接字连接并允许重用套接字
BeginAccept 开始一个异步操作来接受一个传入的连接尝试
EndAccept 异步接受传入的连接尝试
BeginConnect 开始一个对远程主机连接的异步请求
EndConnect 结束挂起的异步连接请求
BeginDisconnect 开始异步请求从远程终结点断开连接
EndDisconnect 结束挂起的异步断开连接请求
BeginReceive 开始从连接的Socket中异步接收数据
EndReceive *将数据异步发送到连接的Socket(结束挂起的异步接收)
BeginSend 开始异步发送数据
EndSend 结束挂起的异步发送
GetSocketOption 返回Socket选项的值
SetSocketOption 设置Socket选项
Poll 确定Socket的状态
Select 确定一个或多个套接字的状态

第2章 分身有术:异步和多路复用

2.1 异步Connect

1
2
3
4
5
6
7
8
9
10
public IAsyncResult BeginConnect(
string host,
int port,
AsyncCallback requestCallback,
object state
)

public void EndConnect(
IAsyncResult asyncResult
)
参数 说明
host 远程主机的名称(IP),如”127.0.0.1“
port 远程主机的端口号,如“8888”
requestCallback 一个AsyncCallback委托,即回调函数,回调函数的参数必须是这样的形式:void ConnectCallback(IAsyncResult ar)
state 一个用户定义对象,可包含连接操作的相关信息。此对象会被传递给回调函数

2.2 异步Receive和Send

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
//异步Receive
public IAsyncResult BeginReceive(
byte[] buffer,
int offset,
int size,
SocketFlags socketFlags,
AsyncCallback callback,
object state
)

public int EndReceive(
IAsyncResult asyncResult
)

//异步Send
public IAsyncResult BeginSend(
byte[] buffer,
int offset,
int size,
SocketFlags socketFlags,
AsyncCallback callback,
object state
)

public int EndSend(
IAsyncReuslt asyncResult
)
参数 说明
buffer Byte类型的数组,它存储接收到的数据
offset buffer中存储数据的位置,该位置从0开始计数
size 最多接收的字节数
socketFlags SocketFlags值的按位组合,这里设置为0
callback 回调函数,一个AsyncCallback委托
state 一个用户定义对象,其中包含接收操作的相关信息。当操作完成时,此对象会被传递给EndReceive/EndSend委托

2.3 异步Accept

1
2
3
4
5
6
7
8
public IAsyncResult BeginAccept(
AsyncCallback callback,
object state
)

public Socket EndAccept(
IAsyncResult asyncResult
)
参数 说明
AsyncCallbcak 回调函数
state 表示状态信息,必须保证state中包含socket的句柄

当Receive返回值小于等于0时,表示Socket连接断开,可以关闭Socket。但也有一种特例,后面介绍

实践:做个聊天室

客户端聊天室代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
/****************************************************
文件:Ch2_Chat.cs
作者:聪头
邮箱: 1322080797@qq.com
日期:2021/02/10 23:44
功能:聊天室客户端
*****************************************************/
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.Net.Sockets;
using UnityEngine.UI;
using System;

public class Ch2_Chat : MonoBehaviour
{
//定义套接字
private Socket socket;
//UGUI
public InputField inputField;
public Text text;

private byte[] readBuff = new byte[1024];
private string recvStr = "";
//点击连接按钮
public void Connection()
{
//Socket
socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
//Connect
socket.Connect(Constant.IP, Constant.POINT);
//异步Receive
socket.BeginReceive(readBuff, 0, 1024, 0, ReceiveCallback, socket);
}

//点击发送按钮
public void Send()
{
//Send
string sendStr = inputField.text;
byte[] sendBytes = System.Text.Encoding.Default.GetBytes(sendStr);
socket.Send(sendBytes);
}

//关闭连接
public void Close()
{
socket.Close();
}

public void ReceiveCallback(IAsyncResult ar)
{
try
{
Socket socket = (Socket)ar.AsyncState;
int count = socket.EndReceive(ar);
string s = System.Text.Encoding.Default.GetString(readBuff, 0, count);
recvStr = recvStr + "\n" + s;
socket.BeginReceive(readBuff, 0, 1024, 0, ReceiveCallback, socket);
} catch (SocketException ex)
{
Debug.Log("Socket Receive Fail" + ex.ToString());
}
}

private void Update()
{
text.text = recvStr;
}
}

服务端聊天室代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
/****************************************************
文件:Ch2_Chat.cs
作者:聪头
邮箱: 1322080797@qq.com
日期:2021/02/11 0:07
功能:聊天室服务端
*****************************************************/
using System;
using System.Net;
using System.Net.Sockets;
using System.Collections.Generic;

namespace ChatServer
{
public class ClientState
{
public Socket socket;
public byte[] readBuff = new byte[1024];
}

public class Ch2_Chat
{
//监听Socket
static Socket listenfd;
//客户端Socket及状态信息
static Dictionary<Socket, ClientState> clients = new Dictionary<Socket, ClientState>();
public static void Main(string[] args)
{
//Scoket
listenfd = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
//Bind
IPAddress ipAdr = IPAddress.Parse(Constant.IP);
IPEndPoint ipEP = new IPEndPoint(ipAdr, Constant.POINT);
listenfd.Bind(ipEP);
//Listen
listenfd.Listen(0);
Console.WriteLine("[ 服务器 ] 启动成功");
//Accept
listenfd.BeginAccept(AcceptCallback, listenfd);
//等待
Console.ReadLine();
}
//1.给新的连接分配ClientState,并把它添加到clients列表中
//2.异步接收客户端数据
//3.再次调用BeginAccept实现循环
private static void AcceptCallback(IAsyncResult ar)
{
try
{
Console.WriteLine("[ 服务器 ] Accept");
Socket listenfd = (Socket)ar.AsyncState;//获得服务器监听Socket
Socket clientfd = listenfd.EndAccept(ar);//取得客户端连接Socket
//clients列表
ClientState state = new ClientState();
state.socket = clientfd;
clients.Add(clientfd, state);
//接收数据
clientfd.BeginReceive(state.readBuff, 0, 1024, 0, ReceiveCallback, state);
//继续Accept
listenfd.BeginAccept(AcceptCallback, listenfd);
}
catch (SocketException ex)
{
Console.WriteLine("Socket Accept fail" + ex.ToString());
}
}

private static void ReceiveCallback(IAsyncResult ar)
{
try
{
ClientState state = (ClientState)ar.AsyncState;
Socket clientfd = state.socket;
int count = clientfd.EndReceive(ar);
//客户端关闭
if(count == 0)
{
clientfd.Close();
clients.Remove(clientfd);
Console.WriteLine("Socket Close");
return;
}
string recvStr = System.Text.Encoding.Default.GetString(state.readBuff, 0, count);
string sendStr = clientfd.RemoteEndPoint.ToString() + ":" + recvStr;
byte[] sendBytes = System.Text.Encoding.Default.GetBytes(sendStr);
//群发消息
foreach(ClientState s in clients.Values)
{
s.socket.Send(sendBytes);
}
clientfd.BeginReceive(state.readBuff, 0, 1024, 0, ReceiveCallback, state);
} catch (SocketException ex)
{
Console.WriteLine("Socket Receive fail" + ex.ToString());
}
}
}
}

聊天室演示

image

2.4 状态监测Poll

通过不断判断,利用同步的方式替代异步,因为同步程序更简单,而且不会引发线程问题。不足之处在于不断检测可能导致CPU占用过高。

1
2
3
4
public bool poll(
int microSeconds,
SelectMode mode
)
参数 说明
microSeconds 等待回应的时间,以微秒为单位,如果该参数为-1,表示一直等待,如果为0,表示非阻塞
mode 有3种可选的模式,分别如下:
SelectRead:如果Socket可读(可以接受数据),返回true,否则返回false;
SelectWrite:如果Socket可写,返回true,否则返回false;
SelectError:如果连接失败,返回true,否则返回false

Poll方法将会检查Socket的状态。如果指定mode参数为SelectMode.SelectRead,则可确定Socket是否为可读;指定参数为SelectMode.SelectWrite可确定为是否可写;指定参数为SelectModeError,可以监测错误条件。Poll将在指定的时段(以微秒为单位)内阻止执行,如果希望无限期地等待响应,可将microSeconds设置为一个负整数;如果希望不阻塞,可将microSeconds设置为0(即没有任何等待,如果设置较长时间,服务端无法及时处理多个客户端同时连接的情况,且这样设置会导致程序的CPU占用率很高)

客户端Poll核心代码

1
2
3
4
5
6
7
8
9
10
11
public void Update()
{
if(socket == null) reutrn;
if(socket.Poll(0, SelectMode.SelectRead))
{
byte[] readBuff = new byte[1024];
int count = socket.Receive(readBuff);
string recvStr = System.Text.Encoding.Default.GetString(readBuff, 0, count);
text.text = recvStr;
}
}

服务端Poll完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
/****************************************************
文件:Ch2_Poll.cs
作者:聪头
邮箱: 1322080797@qq.com
日期:2021/02/11 0:26
功能:Poll的使用
*****************************************************/
using System;
using System.Net;
using System.Net.Sockets;
using System.Collections.Generic;

namespace Ch2
{
public class ClientState
{
public Socket socket;
public byte[] readBuff = new byte[1024];
}

class Ch2_Poll
{
//监听Socket
static Socket listenfd;
//客户端Socket及状态信息
static Dictionary<Socket, ClientState> clients = new Dictionary<Socket, ClientState>();
public static void Main(string[] args)
{
//Scoket
listenfd = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
//Bind
IPAddress ipAdr = IPAddress.Parse(Constant.IP);
IPEndPoint ipEP = new IPEndPoint(ipAdr, Constant.POINT);
listenfd.Bind(ipEP);
//Listen
listenfd.Listen(0);
Console.WriteLine("[ 服务器 ] 启动成功");
//主循环
while (true)
{
//检查listenfd
if (listenfd.Poll(0, SelectMode.SelectRead))
{
ReadListenfd(listenfd);
}
//检查clientfd
foreach (ClientState s in clients.Values)
{
Socket clientfd = s.socket;
if (clientfd.Poll(0, SelectMode.SelectRead))
{
//返回false代表已断开,clients列表发生变换,故需要终止循环,否则会导致遍历失败
if (!ReadClientfd(clientfd))
{
break;
}
}
}
//防止CPU占用过高 挂起1ms
System.Threading.Thread.Sleep(1);
}
}

//读取Listenfd
public static void ReadListenfd(Socket listenfd)
{
Console.WriteLine("Accept");
Socket clientfd = listenfd.Accept();
ClientState state = new ClientState();
state.socket = clientfd;
clients.Add(clientfd, state);
}

//读取Clientfd
public static bool ReadClientfd(Socket clientfd)
{
ClientState state = clients[clientfd];
//接收
int count = 0;
try
{
count = clientfd.Receive(state.readBuff);
} catch(SocketException ex)
{
clientfd.Close();
clients.Remove(clientfd);
Console.WriteLine("Receive SocketException" + ex.ToString());
return false;
}
//客户端关闭
if(count == 0)
{
clientfd.Close();
clients.Remove(clientfd);
Console.WriteLine("Socket Close");
return false;
}
//广播
string recvStr = System.Text.Encoding.Default.GetString(state.readBuff, 0, count);
Console.WriteLine("Receive" + recvStr);
string sendStr = clientfd.RemoteEndPoint.ToString() + ":" + recvStr;
byte[] sendBytes = System.Text.Encoding.Default.GetBytes(sendStr);
foreach (ClientState cs in clients.Values)
{
cs.socket.Send(sendBytes);
}
return true;
}
}
}

2.5 多路复用Select

同时检测多个Socket的状态。在设置要监听的Socket列表后,如果有一个(或多个)Socket可读(或可写,或发生错误信息),那就返回这些可读的Socket,如果没有可读的,那就阻塞。此方法解决了Poll导致CPU占用过高的问题。

1
2
3
4
5
6
public static void Select(
IList checkRead,
IList checkWrite,
IList checkError,
int microSeconds
)
参数 说明
checkRead 检测是否有可读的Socket列表
checkWrite 检测是否有可写的Socket列表
checkError 检测是否有出错的Socket列表
microSeconds 等待回应时间,以微秒为单位,如果该参数为-1表示一直等待,如果为0表示非阻塞

客户端Select核心代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public void Update()
{
if(socket == null){
return;
}
//填充checkRead列表
checkRead.Clear();
checkRead.Add(socket);
//select
Socket.Select(checkRead, null, null, 0);
//check
foreach(Socket s in checkRead)
{
byte[] readBuff = new byte[1024];
int count = socket.Receive(readBuff);
string recvStr = System.Text.Encoding.Default.GetString(readBuff, 0, count);
text.text = recvStr;
}
}

由于程序在Update中不断检测数据,性能较差。商业上为了做到性能上的极致,多使用异步(或使用多线程模拟异步程序)。本书将会使用异步客户端、Select服务端演示程序。

服务端Select核心代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
/****************************************************
文件:Ch2_Select.cs
作者:聪头
邮箱: 1322080797@qq.com
日期:2021/02/11 0:47
功能:Select多路复用
*****************************************************/
using System;
using System.Net;
using System.Net.Sockets;
using System.Collections.Generic;

namespace Ch2
{
public class ClientState
{
public Socket socket;
public byte[] readBuff = new byte[1024];
}

class Ch2_Select
{
//监听Socket
static Socket listenfd;
//客户端Socket及状态信息
static Dictionary<Socket, ClientState> clients = new Dictionary<Socket, ClientState>();
public static void Main(string[] args)
{
//Scoket
listenfd = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
//Bind
IPAddress ipAdr = IPAddress.Parse(Constant.IP);
IPEndPoint ipEP = new IPEndPoint(ipAdr, Constant.POINT);
listenfd.Bind(ipEP);
//Listen
listenfd.Listen(0);
Console.WriteLine("[ 服务器 ] 启动成功");

//checkRead
List<Socket> checkRead = new List<Socket>();
//主循环
while (true)
{
//填充checkRead列表
checkRead.Clear();
checkRead.Add(listenfd);
foreach(ClientState s in clients.Values)
{
checkRead.Add(s.socket);
}
//select
Socket.Select(checkRead, null, null, 1000);
//检查可读对象
foreach(Socket s in checkRead)
{
//服务端监听Socket
if(s == listenfd)
{
ReadListenfd(s);
}
else //客户端连接Socket
{
ReadClientfd(s);
}
}
}
}

//读取Listenfd
public static void ReadListenfd(Socket listenfd)
{
//同Poll
...
}

//读取Clientfd
public static bool ReadClientfd(Socket clientfd)
{
//同Poll
...
}
}
}

第3章 实践出真知:大乱斗游戏

游戏说明:

  1. 打开客户端即视为进入游戏,在随机出生点刷出角色
  2. 使用鼠标左键点击场景,角色会自动走到指定位置
  3. 在站立状态下,点击鼠标右键可使角色发起攻击,角色会向鼠标指向的方向进攻
  4. 每个角色默认有100滴血(hp),受到攻击会掉血,死亡后从场景消失,提示“game over”
  5. 若玩家断线,视为死亡,从场景消失

知识铺垫

1.委托

回调函数的一种实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//声明委托类型
public delegate void DelegateStr(string str);
//需要调用的方法
public static void PrintStr(string str)
{
Console.WriteLine("PrintStr:" + str);
}
//主函数
public static void Main(string[] args)
{
//创建delegate对象
DelegateStr fun = new DelegateStr(PrintStr);
//调用
fun("Hello CT");
Console.ReadLine();
}

2.通信协议

通信协议时通信双方对数据传送控制的一种约定,通信双方必须共同遵守。本节使用一种最简单的字符串协议来实现。协议格式如下:

1
2
3
消息名|参数1, 参数2, 参数3,...
//例子.
Move|127.0.0.1:1234, 10, 0, 8,

其他客户端收到服务端转发的字符串后,使用Split(‘|’)和Split(‘,’)解析

1
2
3
4
5
6
7
8
9
10
11
string str = "Move|127.0.0.1:1234, 10, 0, 8,";

string[] args = str.Split('|');
string msgName = args[0];//协议名:Move
string msgBody = args[1];//协议体:127.0.0.1:1234, 10, 0, 8,

string[] bodyArgs = msgBody.Split(',');
string desc = bodyArgs[0];//玩家描述:127.0.0.1:1234
float x = float.Parse(bodyArgs[1]);//坐标x:10
float y = float.Parse(bodyArgs[2]);//坐标y:0
float z = float.Parse(bodyArgs[3]);//坐标z:8

结合委托的知识,客户端程序提供各种消息类型(通过消息名区分)的处理方法,网络模块解析消息,将不同类型的消息派发给不同的方法去处理。例如:如果收到一条”Move“协议,就交给OnMove方法处理;收到”Enter”协议,就交给OnEnter方法去处理

3.消息队列

C#的异步通信由线程池实现,不同的BeginReceive不一定在同一线程中执行。创建一个消息列表,每当收到消息便在列表末端添加数据,这个列表由主线程读取,它可以作为主线程和异步接收线程之间的桥梁。本章例子,消息队列使用List实现。

消息队列示意图:

image

客户端大乱斗代码

BaseHuman.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
/****************************************************
文件:Ch3_BaseHuman.cs
作者:聪头
邮箱: 1322080797@qq.com
日期:2021年2月11日14:14:16
功能:基础角色类,它处理“操控角色”和“同步角色”的一些共有功能
*****************************************************/
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Ch3_BaseHuman : MonoBehaviour
{
//是否正在移动
protected bool isMoving = false;
protected bool isAttacking = false;
protected float attackTime = float.MinValue;
//移动目标点
private Vector3 targetPosition;
//移动速度
public float speed = 3f;
//动画组件
private Animator animator;
//描述
public string desc = "";

//移动到某处
public void MoveTo(Vector3 pos)
{
targetPosition = pos;
isMoving = true;
animator.SetBool("isMoving", true);
}

//移动Update
public void MoveUpdate()
{
if (isMoving == false)
return;

Vector3 pos = transform.position;
transform.position = Vector3.MoveTowards(pos, targetPosition, speed * Time.deltaTime);
transform.LookAt(targetPosition);
if(Vector3.Distance(pos, targetPosition) < 0.05f)
{
isMoving = false;
animator.SetBool("isMoving", false);
}
}

//攻击动作
public void Attack()
{
isAttacking = true;
attackTime = Time.time;
animator.SetBool("isAttacking", true);
}

//攻击Update
public void AttackUpdate()
{
if (!isAttacking) return;
if (Time.time - attackTime < 1.2f) return;
isAttacking = false;
animator.SetBool("isAttacking", false);
}

protected void Start()
{
animator = GetComponent<Animator>();
}

protected void Update()
{
MoveUpdate();
AttackUpdate();
}

}

CtrlHuman.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
/****************************************************
文件:Ch3_CtrlHuman.cs
作者:聪头
邮箱: 1322080797@qq.com
日期:2021/2/11 6:15:47
功能:操控角色,在BaseHuman的基础上处理鼠标操控功能
*****************************************************/
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Ch3_CtrlHuman : Ch3_BaseHuman
{
new void Start()
{
base.Start();
}

new void Update()
{
base.Update();
//移动
if(Input.GetMouseButtonDown(0))
{
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
RaycastHit hit;
Physics.Raycast(ray, out hit);
if(hit.collider.tag == "Terrain")
{
MoveTo(hit.point);
//发送协议
string sendStr = "Move|";
sendStr += Ch3_NetManager.GetDesc() + ",";
sendStr += hit.point.x + ",";
sendStr += hit.point.y + ",";
sendStr += hit.point.z + ",";
Ch3_NetManager.Send(sendStr);
}
}
//攻击
if(Input.GetMouseButtonDown(1))
{
if (isAttacking) return;
if (isMoving) return;

Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
RaycastHit hit;
Physics.Raycast(ray, out hit);

transform.LookAt(hit.point);
transform.eulerAngles = new Vector3(0, transform.eulerAngles.y, 0);
Attack();
//发送协议
string sendStr = "Attack|";
sendStr += Ch3_NetManager.GetDesc() + ",";
sendStr += transform.eulerAngles.y + ",";
Ch3_NetManager.Send(sendStr);
//攻击判定
Vector3 lineEnd = transform.position + 0.5f * Vector3.up;
Vector3 lineStart = lineEnd + 20 * transform.forward;
if(Physics.Linecast(lineStart,lineEnd,out hit))
{
GameObject hitObj = hit.collider.gameObject;
if (hitObj == gameObject) return;//如果为自身,则返回
Ch3_SyncHuman h = hitObj.GetComponent<Ch3_SyncHuman>();
if (h == null) return;
sendStr = "Hit|";
sendStr += Ch3_NetManager.GetDesc() + ",";
sendStr += h.desc + ",";
Ch3_NetManager.Send(sendStr);
}
}
}
}

SyncHuman.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/****************************************************
文件:Ch3_SyncHuman.cs
作者:聪头
邮箱: 1322080797@qq.com
日期:2021/2/11 6:16:2
功能:同步角色,也继承自BaseHuman,处理网络同步
*****************************************************/
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Ch3_SyncHuman : Ch3_BaseHuman
{
public void SyncAttack(float eulY)
{
transform.eulerAngles = new Vector3(0, eulY, 0);
Attack();
}

new void Start()
{
base.Start();
}

new void Update()
{
base.Update();
}
}

NetManager.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
/****************************************************
文件:Ch3_NetManager.cs
作者:聪头
邮箱: 1322080797@qq.com
日期:2021/2/11 6:55:36
功能:网络管理类,封装网络模块
*****************************************************/
using System;
using System.Collections;
using System.Collections.Generic;
using System.Net.Sockets;
using UnityEngine;

public static class Ch3_NetManager
{
//定义套接字
static Socket socket;
//接收缓冲区
static byte[] readBuff = new byte[1024];
//委托类型
//参数①:协议字符串
public delegate void MsgListener(string str);
//监听列表
private static Dictionary<string, MsgListener> listeners = new Dictionary<string, MsgListener>();
//消息列表
static List<string> msgList = new List<string>();
//添加监听
public static void AddListener(string msgName, MsgListener listener)
{
listeners[msgName] = listener;
}
//获取描述
public static string GetDesc()
{
if (socket == null) return "";
if (!socket.Connected) return "";
return socket.LocalEndPoint.ToString();
}
//连接
public static void Connect(string ip, int port)
{
//Socket
socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
//Connect(用同步方式简化代码)
socket.Connect(ip, port);
//BeginReceive
socket.BeginReceive(readBuff, 0, 1024, 0, ReceiveCallback, socket);
}
//Receive回调
private static void ReceiveCallback(IAsyncResult ar)
{
try
{
Socket socket = (Socket)ar.AsyncState;
int count = socket.EndReceive(ar);
string recvStr = System.Text.Encoding.Default.GetString(readBuff, 0, count);
msgList.Add(recvStr);//将消息添加到消息队列
socket.BeginReceive(readBuff, 0, 1024, 0, ReceiveCallback, socket);
} catch(SocketException ex)
{
Debug.Log("Socket Receive fail" + ex.ToString());
}
}
//发送
public static void Send(string sendStr)
{
if (socket == null) return;
if (!socket.Connected) return;

byte[] sendBytes = System.Text.Encoding.Default.GetBytes(sendStr);
socket.Send(sendBytes);
}
//Update
public static void Update()
{
if (msgList.Count <= 0) return;

string msgStr = msgList[0];
msgList.RemoveAt(0);
string[] split = msgStr.Split('|');
string msgName = split[0];
string msgArgs = split[1];
//监听回调
if(listeners.ContainsKey(msgName))
{
listeners[msgName](msgArgs);
}
}
}

Main.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
/****************************************************
文件:Ch3_Main.cs
作者:聪头
邮箱: 1322080797@qq.com
日期:2021/2/11 7:20:25
功能:网络核心类,驱动网络模块,解析协议
*****************************************************/
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Ch3_Main : MonoBehaviour
{
//人物模型预设
public GameObject humanPrefab;
//人物列表
public Ch3_BaseHuman myHuman;
public Dictionary<string, Ch3_BaseHuman> otherHumans = new Dictionary<string, Ch3_BaseHuman>();

private void Awake()
{
//网络模块
Ch3_NetManager.AddListener("Enter", OnEnter);
Ch3_NetManager.AddListener("List", OnList);
Ch3_NetManager.AddListener("Move", OnMove);
Ch3_NetManager.AddListener("Leave", OnLeave);
Ch3_NetManager.AddListener("Attack", OnAttack);
Ch3_NetManager.AddListener("Die", OnDie);
Ch3_NetManager.Connect(Constant.IP, Constant.POINT);
//添加一个角色(自己)
GameObject obj = (GameObject)Instantiate(humanPrefab);
float x = UnityEngine.Random.Range(-5, 5);
float z = UnityEngine.Random.Range(-5, 5);
obj.transform.position = new Vector3(x, 0, z);
myHuman = obj.AddComponent<Ch3_CtrlHuman>();
myHuman.desc = Ch3_NetManager.GetDesc();
//发送Enter协议
Vector3 pos = myHuman.transform.position;
Vector3 eul = myHuman.transform.eulerAngles;
string sendStr = "Enter|" + Ch3_NetManager.GetDesc() + "," +
pos.x + "," + pos.y + "," + pos.z + "," + eul.y;
Ch3_NetManager.Send(sendStr);
}

//目前暂未处理粘包,协议发送间隔不能太短
private void Start()
{
//请求List列表
Ch3_NetManager.Send("List|");
}

private void Update()
{
Ch3_NetManager.Update();
}

void OnEnter(string msgArgs)
{
Debug.Log("OnEnter:" + msgArgs);
//解析参数
string[] split = msgArgs.Split(',');
string desc = split[0];
float x = float.Parse(split[1]);
float y = float.Parse(split[2]);
float z = float.Parse(split[3]);
float eulY = float.Parse(split[4]);
//是自己
if (desc == Ch3_NetManager.GetDesc())
return;
//添加一个角色
GameObject obj = (GameObject)Instantiate(humanPrefab);
obj.transform.position = new Vector3(x, y, z);
obj.transform.eulerAngles = new Vector3(0, eulY, 0);
Ch3_BaseHuman h = obj.AddComponent<Ch3_SyncHuman>();
h.desc = desc;
otherHumans.Add(desc, h);
}

private void OnList(string msgArgs)
{
Debug.Log("OnList" + msgArgs);
//解析参数
string[] split = msgArgs.Split(',');
int count = (split.Length - 1) / 6;
for(int i = 0; i < count; i++)
{
string desc = split[i * 6 + 0];
float x = float.Parse(split[i * 6 + 1]);
float y = float.Parse(split[i * 6 + 2]);
float z = float.Parse(split[i * 6 + 3]);
float eulY = float.Parse(split[i * 6 + 4]);
int hp = int.Parse(split[i * 6 + 5]);
Debug.Log("test:" + desc);
//是自己
if (desc == Ch3_NetManager.GetDesc())
continue;
//添加一个角色
GameObject obj = (GameObject)Instantiate(humanPrefab);
obj.transform.position = new Vector3(x, y, z);
obj.transform.eulerAngles = new Vector3(0, eulY, 0);
Ch3_BaseHuman h = obj.AddComponent<Ch3_SyncHuman>();
h.desc = desc;
otherHumans.Add(desc, h);
}
}

void OnMove(string msgArgs)
{
Debug.Log("OnMove:" + msgArgs);
//解析参数
string[] split = msgArgs.Split(',');
string desc = split[0];
float x = float.Parse(split[1]);
float y = float.Parse(split[2]);
float z = float.Parse(split[3]);
//移动
if (!otherHumans.ContainsKey(desc)) return;
Ch3_BaseHuman h = otherHumans[desc];
Vector3 targetPos = new Vector3(x, y, z);
h.MoveTo(targetPos);
}

void OnLeave(string msgArgs)
{
Debug.Log("OnLeave:" + msgArgs);
//解析参数
string[] split = msgArgs.Split(',');
string desc = split[0];
//删除
if (!otherHumans.ContainsKey(desc))
return;
Ch3_BaseHuman h = otherHumans[desc];
Destroy(h.gameObject);
otherHumans.Remove(desc);
}

void OnAttack(string msgArgs)
{
Debug.Log("OnAttack" + msgArgs);
//解析参数
string[] split = msgArgs.Split(',');
string desc = split[0];
float eulY = float.Parse(split[1]);
//攻击动作
if(!otherHumans.ContainsKey(desc))
{
return;
}
Ch3_SyncHuman h = (Ch3_SyncHuman)otherHumans[desc];
h.SyncAttack(eulY);
}

void OnDie(string msgArgs)
{
Debug.Log("OnDie:" + msgArgs);
//解析参数
string[] split = msgArgs.Split(',');
string hitDesc = split[0];
//自己死了
if(hitDesc == myHuman.desc)
{
Debug.Log("Game Over!");
return;
}
//其他玩家死了
if (!otherHumans.ContainsKey(hitDesc)) return;
Ch3_SyncHuman h = (Ch3_SyncHuman)otherHumans[hitDesc];
h.gameObject.SetActive(false);
}
}

服务端大乱斗代码

Battle.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
/****************************************************
文件:Ch3_Battle.cs
作者:聪头
邮箱: 1322080797@qq.com
日期:2021/02/11 15:35
功能:大乱斗游戏服务端,利用反射处理协议
*****************************************************/
using System;
using System.Net;
using System.Net.Sockets;
using System.Collections.Generic;
using System.Reflection;
using System.Linq;

namespace Ch3
{
public class ClientState
{
public Socket socket;
public byte[] readBuff = new byte[1024];
public int hp = -100;
public float x = 0;
public float y = 0;
public float z = 0;
public float eulY = 0;
}

class Ch3_Battle
{
//监听Socket
static Socket listenfd;
//客户端Socket及状态信息
public static Dictionary<Socket, ClientState> clients = new Dictionary<Socket, ClientState>();
public static void Main(string[] args)
{
//Scoket
listenfd = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
//Bind
IPAddress ipAdr = IPAddress.Parse(Constant.IP);
IPEndPoint ipEP = new IPEndPoint(ipAdr, Constant.POINT);
listenfd.Bind(ipEP);
//Listen
listenfd.Listen(0);
Console.WriteLine("[ 服务器 ] 启动成功");

//checkRead
List<Socket> checkRead = new List<Socket>();
//主循环
while (true)
{
//填充checkRead列表
checkRead.Clear();
checkRead.Add(listenfd);
foreach (ClientState s in clients.Values)
{
checkRead.Add(s.socket);
}
//select
Socket.Select(checkRead, null, null, 1000);
//检查可读对象
foreach (Socket s in checkRead)
{
//服务端监听Socket
if (s == listenfd)
{
ReadListenfd(s);
}
else //客户端连接Socket
{
ReadClientfd(s);
}
}
}
}

//读取Listenfd
public static void ReadListenfd(Socket listenfd)
{
Console.WriteLine("Accept");
Socket clientfd = listenfd.Accept();
ClientState state = new ClientState();
state.socket = clientfd;
clients.Add(clientfd, state);
}

//读取Clientfd
public static bool ReadClientfd(Socket clientfd)
{
ClientState state = clients[clientfd];
//接收
int count = 0;
try
{
count = clientfd.Receive(state.readBuff);
}
catch (SocketException ex)
{
MethodInfo mei = typeof(Ch3_EventHandler).GetMethod("OnDisconnect");
object[] ob = { state };
mei.Invoke(null, ob);

clientfd.Close();
clients.Remove(clientfd);
Console.WriteLine("Receive SocketException" + ex.ToString());
return false;
}
//客户端关闭
if (count == 0)
{
MethodInfo mei = typeof(Ch3_EventHandler).GetMethod("OnDisconnect");
object[] ob = { state };
mei.Invoke(null, ob);

clientfd.Close();
clients.Remove(clientfd);
Console.WriteLine("Socket Close");
return false;
}
//消息处理
string recvStr = System.Text.Encoding.Default.GetString(state.readBuff, 0, count);
string[] split = recvStr.Split('|');
Console.WriteLine("Receive" + recvStr);
string msgName = split[0];
string msgArgs = split[1];
string funName = "Msg" + msgName;
//MethodInfo类对象mi包含它所指代的方法的所有信息,通过这个类可以得到方法的名称、参数、返回值等
MethodInfo mi = typeof(Ch3_MsgHandler).GetMethod(funName);
object[] o = { state, msgArgs };
//参数①:this指针,由于消息处理方法都是静态方法,此填null
//参数②:参数列表
mi.Invoke(null, o);
return true;
}

public static void Send(ClientState cs, string sendStr)
{
byte[] sendBytes = System.Text.Encoding.Default.GetBytes(sendStr);
cs.socket.Send(sendBytes);
}
}
}

MsgHandler.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
/****************************************************
文件:Ch3_MsgHandler.cs
作者:聪头
邮箱: 1322080797@qq.com
日期:2021/02/11 15:41
功能:服务端协议处理
*****************************************************/
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace Ch3
{
public class Ch3_MsgHandler
{
//msgArgs:desc,x,y,z,eulY
public static void MsgEnter(ClientState c, string msgArgs)
{
Console.WriteLine("MsgEnter:" + msgArgs);
//解析参数
string[] split = msgArgs.Split(',');
string desc = split[0];
float x = float.Parse(split[1]);
float y = float.Parse(split[2]);
float z = float.Parse(split[3]);
float eulY = float.Parse(split[4]);
//赋值
c.hp = 100;
c.x = x;
c.y = y;
c.z = z;
c.eulY = eulY;
//广播
string sendStr = "Enter|" + msgArgs;
foreach (ClientState cs in Ch3_Battle.clients.Values)
{
Ch3_Battle.Send(cs, sendStr);
}
}
//msgArgs: ""
public static void MsgList(ClientState c, string msgArgs)
{
//Console.WriteLine("MsgList:" + msgArgs);
string sendStr = "List|";
foreach (ClientState cs in Ch3_Battle.clients.Values)
{
sendStr += cs.socket.RemoteEndPoint.ToString() + ",";
sendStr += cs.x.ToString() + ",";
sendStr += cs.y.ToString() + ",";
sendStr += cs.z.ToString() + ",";
sendStr += cs.eulY.ToString() + ",";
sendStr += cs.hp.ToString() + ",";
}
Console.WriteLine("MsgList:" + sendStr);
Ch3_Battle.Send(c, sendStr);
}
//msgArgs:desc,x,y,z,
public static void MsgMove(ClientState c, string msgArgs)
{
//解析参数
string[] split = msgArgs.Split(',');
string desc = split[0];
float x = float.Parse(split[1]);
float y = float.Parse(split[2]);
float z = float.Parse(split[3]);
//赋值
c.x = x;
c.y = y;
c.z = z;
//广播
string sendStr = "Move|" + msgArgs;
foreach (ClientState cs in Ch3_Battle.clients.Values)
{
Ch3_Battle.Send(cs, sendStr);
}
}

public static void MsgAttack(ClientState c, string msgArgs)
{
//广播
string sendStr = "Attack|" + msgArgs;
foreach(ClientState cs in Ch3_Battle.clients.Values)
{
Ch3_Battle.Send(cs, sendStr);
}
}

public static void MsgHit(ClientState c, string msgArgs)
{
//解析参数
string[] split = msgArgs.Split(',');
string attDesc = split[0];
string hitDesc = split[1];
//找出被攻击角色
ClientState hitCS = null;
foreach(ClientState cs in Ch3_Battle.clients.Values)
{
if (cs.socket.RemoteEndPoint.ToString() == hitDesc)
hitCS = cs;
}
if (hitCS == null) return;
//扣血
hitCS.hp -= 25;
//死亡
if(hitCS.hp <= 0)
{
string sendStr = "Die|" + hitCS.socket.RemoteEndPoint.ToString();
foreach(ClientState cs in Ch3_Battle.clients.Values)
{
Ch3_Battle.Send(cs, sendStr);
}
}
}
}
}

EventHandler.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/****************************************************
文件:Ch3_EventHandler.cs
作者:聪头
邮箱: 1322080797@qq.com
日期:2021/02/11 15:44
功能:服务端事件处理
*****************************************************/
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace Ch3
{
public class Ch3_EventHandler
{
public static void OnDisconnect(ClientState c)
{
string desc = c.socket.RemoteEndPoint.ToString();
Console.WriteLine("OnDisconnect:"+ desc);
string sendStr = "Leave|" + desc + ",";
foreach (ClientState cs in Ch3_Battle.clients.Values)
{
Ch3_Battle.Send(cs, sendStr);
}
}
}
}

大乱斗游戏截图演示

image image

第4章 正确收发数据流

2021年2月11日18:47:30

4.1 粘包问题的三种解决方法

1.长度信息法

在每个数据包前面加上长度信息。每次接收到数据后,先读取表示长度的字节,如果缓冲区的数据长度大于要取的字节数,则取出相应的字节,否则等待下一次数据接收。(本书采用该方法解决粘包问题)

2.固定长度法

每次都以相同的长度发送数据,假设规定每条信息的长度都为10个字符,那么发送“Hello” “Unity”两条信息可以发送成”Hello…” “Unity…”,其中”.”表示填充字符,是为凑数,无实际意义。接收方读取10个字符,作为一条消息去处理。否则存起来,等待下次接收。

3.结束符号法

规定一个结束符号,作为消息间的分隔符。假设规定结束符号位”$”,那么发送“Hello” “Unity”可以发送成”Hello$” “Unity$”。接收方读到”$”,就将它前面的数据提取出来,作为一条消息去处理。后面的存起来,等待下次接收

4.2 解决粘包问题的代码实现

发送数据

1
2
3
4
5
6
7
8
9
10
11
//点击发送按钮
public void Send(string sendStr)
{
//组装协议
byte[] bodyBytes = System.Text.Encoding.Default.GetBytes(sendStr);
Int16 len = (Int16) bodyBytes.Length;
byte[] lenBytes = BitConverter.GetBytes(len);
byte[] sendBytes = lenBytes.Concat(bodyBytes).ToArray();
//为了精简,同步Send且不考虑异常
socket.Send(sendBytes);
}

以HelloWorld为例

变量 数据
sendStr “HelloWorld”
bodyBytes H e l l o W o r l d
len 10
lenBytes 0 A
sendBytes 0 A H e l l o W o r l d

接收数据

游戏程序一般会使用”长度信息法”处理粘包问题,核心思想是定义一个缓冲区(readBuff)和一个指示缓冲区有效长度变量(buffCount)

1
2
3
4
//接收缓冲区
byte[] readBuff = new byte[1024];
//接收缓冲区的数据长度
int buffCount = 0;

因为存在粘包现象,缓冲区里面会保存尚未处理的数据。所以接收数据时不再从缓冲区开头的位置写入,而是把新数据放在有效数据之后。

1
2
3
4
5
6
7
//使用异步Scoket
socket.BeginReceive(readBuff, //缓冲区
buffCount, //开始位置
1024-buffCount, //最多读多少数据
0, //标志位,设0即可
ReceiveCallback, //回调函数
socket); //状态
image

在收到数据后,程序需要更新buffCount,以使下一次接收数据时,写入到缓冲区有效数据末尾

1
2
3
4
5
6
7
8
public void ReceiveCallback(IAsyncResult ar)
{
Socket socket = (Socket) ar.AsyncState;
//获取接收数据长度
int count = socket.EndReceive(ar);
buffCount += count;
...
}

处理数据(三种情况)

对于缓冲区数据长度,会有以下几种情况:

  • 缓冲区长度小于等于2B

由于消息长度是16位(2B),缓冲区至少要有2个字节数据才能把长度信息解析处理,否则消息不完整,不解析它

1
2
3
4
5
6
7
public void OnReceiveData()
{
if(buffCount <= 2)
return;
//如果是完整的消息,就处理它
...
}
image
  • 缓冲区长度大于2,但还不足以组成一条消息
1
2
3
4
5
6
7
8
9
10
public void OnReceiveData()
{
if(buffCount <= 2)
return;
Int16 bodyLength = BitConverter.ToInt16(readBuff, 0);
//消息不完整
if(buffCount < 2 + bodyLength);
//如果是完整的消息,就处理它
...
}
image
  • 缓冲区长度大于等于一条完整信息,就解析它
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public void OnReceiveData()
{
//有效消息长度不足
if(buffCount <= 2)
return;
Int16 bodyLength = BitConverter.ToInt16(readBuff, 0);
//消息体
if(buffCount < 2 + bodyLength)
return;
string s = System.Text.Encoding.UTF8.GetString(readBuff, 2, bodyLength);
//s是消息内容
//更新缓冲区
int start = 2 + bodyLength;
int count = buffCount - start;
Array.Copy(readBuff, start, readBuff, 0, count);
buffCount -= start;
//继续读取消息
if(readBuff.length > 2)
{
OnReceiveData();
}
}
image

Array.Copy解析:

参数 说明
sourceArray 源数组
sourceIndex 源数组的起始位置
destinationArray 目标数组
destinationIndex 目标数组的起始位置
length 要复制的消息长度

4.3 大端小端问题

如果使用BitConverter.GetBytes将数字转换成二进制数据,转换出来的数据有可能基于大端模式,也有可能基于小端模式。我们规定必须使用小端编码(低地址放低位,高地址放高位)

image

经过简化的BitConverter.ToInt16源码

  • 可以发现,对于不同计算机编码方式会有不同,那么不同计算机读取出老的数据长度也可能不同
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public static short ToInt16(byte[] value, int startIndex)
{
if(startIndex % 2 == 0)
{
return *((short *) pbyte);
}
else
{
if(IsLittleEndian)
{
return (short)((*pbyte) | (*(pbyte + 1) << 8));
}
else
{
return (short)((*pbyte << 8) | (*(pbyte + 1)));
}
}
}

使用Reverse()兼容大小端编码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public void Send()
{
string sendStr = InputField.text;
//组装协议
byte[] bodyBytes = System.Text.Encoding.Default.GetBytes(sendStr);
Int16 len = (Int16)bodyBytes.Length;
byte[] lenBytes = BitConverter.GetBytes(len);
//大小端编码
if(!BitConverter.IsLittleEndian)
{
Debug.Log("[Send] Reverse lenBytes");
lenBytes.Reverse();
}
//拼接字节
byte[] sendBytes = lenBytes.Concat(bodyBytes).ToArray();
socket.Send(sendBytes);
}

手动还原数值

1
2
3
4
5
6
7
void OnReceiveData()
{
...
Int16 bodyLength = (short)((readBuff[1] << 8) | readBuff[0]);
...
}

4.5 完整发送数据

不完整发送示例

image

解决发送不完整问题

要让数据能够发送完整,需要在发送前将数据保存起来;如果发送不完整,在Send回调函数中继续发送数据。分析详见书P113-117

概括:构造一个ByteArray结构和一个发送队列Queue,使用异步发送。判断Queue不为空,就取队列第一个ByteArray发送,如果发送不完整,则继续发送。如果发送完整,则查看Queue是否为空,不为空,则发送下一条(把自己删除,将下一条移到First)

ByteArray

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
using System;

public class ByteArray {
//默认大小
const int DEFAULT_SIZE = 1024;
//初始大小
int initSize = 0;
//缓冲区
public byte[] bytes;
//读写位置
public int readIdx = 0;
public int writeIdx = 0;
//容量
private int capacity = 0;
//剩余空间
public int remain { get {return capacity-writeIdx; }}
//剩余数据长度
public int length { get { return writeIdx - readIdx; }}
//1.构造函数--用于接收缓冲区
public ByteArray(int size = DEFAULT_SIZE)
{
bytes = new byte[size];
capacity = size;
initSize = size;
readIdx = 0;
writeIdx = 0;
}
//构造函数--用于发送缓冲区
public ByteArray(byte[] defaultBytes)
{
bytes = defaultBytes;
capacity = defaultBytes.Length;
initSize = defaultBytes.Length;
readIdx = 0;
writeIdx = defaultBytes.Length;
}
//2.重设尺寸
public void Resize(int size)
{
if(size < length) return;
if(size < initSize) return;
int n = 1;
while(n < size) n*=2;
capacity = n;
byte[] newBytes = new byte[capacity];
Array.Copy(bytes, readIdx, newBytes, 0, writeIdx - readIdx);
bytes = newBytes;
writeIdx = length;
readIdx = 0;
}
//3.检查并移动数据
public void CheckAndMoveBytes(){
if(length < 8){
MoveBytes();
}
}
//移动数据
public void MoveBytes()
{
Array.Copy(bytes, readIdx, bytes, 0, length);
writeIdx = length;
readIdx = 0;
}
//4.读写功能
//写入数据--不可行,如果重设失败,直接爆缓冲了!!!
public int Write(byte[] bs, int offset, int count)
{
if(remain < count){
Resize(length + count);
}
Array.Copy(bs, offset, bytes, writeIdx, count);
writeIdx += count;
return count;
}
//读取数据
public int Read(byte[] bs, int offset, int count)
{
count = Math.Min(count, length);
Array.Copy(bytes, readIdx, bs, offset, count);
readIdx += count;
CheckAndMoveBytes();
return count;
}
//读取Int16
public Int16 ReadInt16()
{
if(length < 2) return 0;
Int16 ret = (Int16)((bytes[readIdx+1] << 8) | bytes[readIdx]);
readIdx += 2;
CheckAndMoveBytes();
return ret;
}

//5.ByteArray调试
//打印缓冲区
public override string ToString()
{
return BitConverter.ToString(bytes, readIdx, length);
}
//打印调试信息
public string Debug()
{
return string.Format("readIdx({0}) writeIdx({1}) bytes({2})",
readIdx,
writeIdx,
BitConverter.ToString(bytes, 0, bytes.Length));
}
}

用于发送缓冲区(writeIdx=sendBytes.Length)

image

用于接收缓冲区

image

个人:ByteArray存在的问题

在重设失败时内存会导致溢出异常,正确的重设方式应该是把传入的数据和readIdx+remain作比较,考虑是否可以通过移动数据的方式解决,如果传入数据的count>readIdx+remain,此时才应该重设尺寸

Queue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
//定义
Queue<ByteArray> writeQueue = new Queue<ByteArray>();
//点击发送按钮
public void Send()
{
//拼接字节,省略组装sendBytes的代码
byte[] sendBytes = 要发送的数据;
ByteArray ba = new ByteArray(sendBytes);
int count = 0;
lock(writeQueue) {
writeQueue.Enqueue(ba);
count = writeQueue.Count;
}
//Send
if(count == 1)
{
socket.BeginSend(sendBytes, 0, sendBytes.Length, 0, SendCallback, socket);
}
Debug.Log("[Send]" + BitConverter.ToString(sendBytes));
}
//Send回调
public void SendCallback(IAsyncResult ar)
{
//获取state、EndSend的处理
Socket sokcet = (Socket) ar.AsyncState;
int count = socket.EndSend(ar);

ByteArray ba;
lock(writeQueue)
{
ba = writeQueue.First();
}
ba.readIdx += count;
//发送完整
if(ba.Length == 0)
{
lock(writeQueue)
{
writeQueue.Dequeue();
ba = writeQueue.First();
}
}
if(ba != null)
{
socket.BeginSend(ba.bytes, ba.readIdx, ba.length, 0, SendCallback, socket);
}
}

第5章 深入了解TCP,解决暗藏问题

5.1 从TCP到铜线

应用层

应用层功能是应用程序(游戏程序)提供的功能。在给客户端发送“hello”的例子中,程序把“hello”转化成二进制流传给传输层(传送给send方法,如图 5-1 所示)。操作系统会对二进制数据做一系列加工,使它适合于网络传输

image

传输层

IP协议最大的数据长度65535B = 20B(TCP头部) + 65515(用户数据)

TCP就是在网络层(IP协议)的基础上,增加了数据拆分(把TCP数据拆分成多个IP包)、确认重传、流量控制等机制

image

网络层

IP协议会给TCP数据添加本地地址、目的地址等信息

image

网络接口

在多层处理后,数据通过物理介质(如电缆、光纤)传输到接收方,接收方再依照相反的过程解析,得到用户数据。实际上,IP协议还会被封装成更为底层的链路层协议,以完成数据校验等一些功能。

5.2 数据传输流程

三次握手

image

四次挥手

image

5.3 常用TCP参数

  • ReceiveBufferSize:指定了操作系统读缓冲区的大小,默认是8192。当接收缓冲区满了的时候,发送端回暂停发送数据,较大的缓冲区可以减少发送端暂停的概率,提高发送效率
  • SendBufferSize:指定了操作系统写缓冲区大小,默认也是8192
  • NoDelay:指定发送数据时是否使用Nagle算法,对于实时性要求较高的游戏,该值需要设置成true。Nagle是一种节省网络流量的机制,默认情况下,TCP会使用Nagle算法去发送数据。Nagle算法的机制在于,如果发送端欲多次发送包含少量字节的数据包时,发送端不会立马发送数据,而是积攒到一定数量后再将其组成一个较大的数据包发送出去。这样可以提升网络传输效率,但降低网络的实时性。大部分网络游戏都会关闭Nagle算法
  • TTL:发送的IP数据包的生存时间值(Time To Live,TTL)。TTL是IP头部的一个值,该值表示一个IP数据报能够经过的最大的路由器跳数。主要作用是避免IP包在网络中的无限循环和收发。
  • ReuseAddress:端口复用,让同一个端口可被多个socket使用。最常见的用途是,防止服务器重启时,之前绑定的端口还未释放或者程序突然退出而系统没有释放端口。
1
2
Socket socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
  • LingerState:设置套接字保持连接的时间。开启LingerState能够在一定程度上保证发送数据的完整性。
1
2
3
//参数①:LingerState.Enabled,代表是否启用LingerState
//参数②:LingerState.LingerTime,指定超时时间,如果超时时间大于0(比如10s),操作系统会尝试发送缓冲区中的数据。如果为0,系统会一直等待到数据发送完才关闭连接,无论等待多长时间
socket.LingerState = new LingerOption(true, 10);

当客户端调用Close()关闭Socket连接时,会给服务端发送FIN信号,然后进入等待。当服务端收到FIN信号时,数据长度为0,这也说明可以通过Receive的count==0判断Close。

5.4 Close的恰当时机

客户端当有数据需要发送时,就不Close;发送完,再Close

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
bool isClosing = false;
//关闭连接
public void Close()
{
//还有数据在发送
if(writeQueue.Count > 0)
{
isClosing = true;
}
//没有数据在发送
else
{
socket.Close();
}
}

在关闭连接过程中,程序只负责将已有的数据发送完,不会发送新的数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//同步
public void Send()
{
if(isClosing) return;
...
}
//异步
public void SendCallback(IAsyncResult ar)
{
...
if(ba != null)
{
socket.BeginSend(ba.bytes, ba.readIdx, ba.length, 0, SendCallback, socket);
}
else if(isClosing)
{
socket.Close();
}
}

5.5 异常处理

以EndReceive为例

异常 发生条件
ArgumentNullException asyncResult为null
ArgumentException asyncResult通过调用未返回BeginReceive方法
InvalidOperationException EndReceive之前已调用为异步读取
SocketException 尝试访问套接字时出错
ObjectDisposedException Socket已关闭

5.6 心跳机制

TCP有一个连接检测机制,如果在指定时间内没有数据发送,会给对端发送一个信号(通过SetSocketOption的KeepAlive选项开启)。对端如果收到这个信号,回送一个TCP信号,确认已经收到,这样就知道此连接通畅。如果一段时间没有收到对方响应,会重试,几次无果后,关闭socket。

1
Socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true);

TCP默认的KeepAlive很烂,上述“一段时间”默认为2小时。一般会自行实现心跳机制。客户端定时(如间隔1min)向服务端发送PING消息,服务端收到后回应PONG消息。

补充

一条完整数据的编码格式

image

蛮牛网课——游戏同步算法

URL:https://edu.manew.com/course/741/task/22633/show

作者:罗培羽

image
 评论