简单联网
unity/c++ mmo大型多人在线游戏开发系列教程,本节代码在此。
我们先打通客户端与服务端的通信链路,目前先使用TCP。
UI
简单创建了一个登录UI。
NetworkManager
我们创建一个NetworkManager脚本来管理与服务器之间的通信。
在场景中创建一个空物体,挂载NetworkManager。
异步
对于网络的操作,无论是connect,还是read/write,都有可能造成线程的阻塞。
有多种方式可以解决阻塞的问题,对于connect,可以使用TcpClient::ConnectAsync
,比较直观,而read/write则可以放到单独的线程中。
public async void Connect(string host, int port, Action<bool> connectCallback)
{
Debug.Log("connecting to server");
_isConnected = await ConnectAsync(host, port, ConnectTimeoutMs);
connectCallback(_isConnected);
if (_isConnected)
{
Task sendTask = Task.Run(() => { SendThreadFunc(); });
Task readTask = Task.Run(() => { RecvThreadFunc(); });
Task.WhenAll(sendTask, readTask);
}
}
public void Send(string msg)
{
Debug.Log("Send: " + msg);
byte[] bytes = Encoding.UTF8.GetBytes(msg);
_sendBuffer.Send(bytes);
}
private void SendThreadFunc()
{
while (_isConnected)
{
if (_sendBuffer.Empty())
{
Thread.Sleep(100);
}
else
{
try
{
_sendBuffer.Flush(_stream);
}
catch (Exception e)
{
Debug.LogError(e.ToString());
Close();
break;
}
}
}
}
private void RecvThreadFunc()
{
byte[] buffer = new byte[2048];
while (_isConnected)
{
try
{
int nReads = _stream.Read(buffer, 0, buffer.Length);
if (nReads == 0)
break;
string msg = Encoding.UTF8.GetString(buffer, 0, nReads);
Debug.Log("Recv: " + msg);
}
catch (Exception e)
{
Debug.LogError(e.ToString());
break;
}
}
}
网上很多示例都没有单独的发送线程,但其实发送也有可能阻塞,例如服务器繁忙,一直没有处理数据包,导致tcp接收窗口为0,当客户端的tcp发送缓冲区也用光时,发送就会阻塞了。因此,这里使用了一个SendBuffer
,用于主线程与发送线程的同步。
极简服务端
到现在为止,我们已经可以跟服务器建立连接,然后收发数据了。但目前还未正式进入服务器的部分,为了测试,可以先用python写一个极简的server。
先实现一个简单的echo服务好了,即客户端发送的消息都是utf-8编码的字符串,服务端收到后会原封不动地发回去:
# Echo server program
import socket
HOST = '' # Symbolic name meaning all available interfaces
PORT = 1988 # Arbitrary non-privileged port
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind((HOST, PORT))
s.listen(1)
conn, addr = s.accept()
print('Connected by ', addr)
while 1:
data = conn.recv(1024)
if not data: break
conn.sendall(data)
conn.close()
然后我们新建一个NetworkComponent
组件,监听按键E
的按下消息,每按一次,就调用NetworkManager::Send
发送一条消息:
void Update()
{
if (Input.GetKey(KeyCode.E))
{
NetworkManager.Instance.Send("hello");
}
}
把这个组件挂在主角上,测试一下:
消息格式
仔细观察上面日志的输出,会发现有时客户端多条分开发送的hello
,被合并发回来了。
这是因为tcp并不知道消息的边界,多条消息可能会被合并到同个tcp数据包发送,甚至一条消息也有可能被分成几个连续的tcp包发送,例如消息的长度超过了tcp连接的MSS(最大分段大小)。
这也导致上层调用read的时候,并不保证能获取到一条单独完整的消息。因此,我们要自己来处理。
最简单的方法是给消息添加一个大小,我们可以规定,每条消息的头2个字节代表了这条消息带的字符串的长度,并且规定这2个字节以小端字节序编码,即:
| length of the following string (2 bytes) | utf-8 encoding string |
客户端以此规则修改一下发送和接收的代码,就能正确地分割消息:
public void Send(string msg)
{
Debug.Log("Send: " + msg);
using (MemoryStream mem = new MemoryStream())
{
using (BinaryWriter binaryWriter = new BinaryWriter(mem))
{
byte[] bytes = Encoding.UTF8.GetBytes(msg);
UInt16 msgLen = (UInt16)bytes.Length;
// 现在要先写入字符串长度
binaryWriter.Write(msgLen);
binaryWriter.Write(bytes);
}
byte[] data = mem.ToArray();
_sendBuffer.Send(data);
}
}
private void RecvThreadFunc()
{
byte[] buffer = new byte[2048];
while (_isConnected)
{
try
{
int nReads = _stream.Read(buffer, 0, buffer.Length);
if (nReads == 0)
break;
byte[][] msgs = _recvBuffer.Recv(buffer, nReads);
foreach (byte[] bytes in msgs)
{
string msg = Encoding.UTF8.GetString(bytes);
Debug.Log("Recv: " + msg);
}
}
catch (Exception e)
{
Debug.LogError(e.ToString());
break;
}
}
if (_isConnected)
OnLostServer();
}
发送部分很简单,在发送时写入字符串长度即可。注意我们并没有刻意去处理小端字节序,毕竟现在小端字节序几乎已经统一世界了,整数在内存中本身就已经是小端字节序了。
接收部分则是添加了一个RecvBuffer,用于缓存数据并提取出一条一条独立完整的消息。
服务端倒不用改,毕竟它没有单独处理每一条消息的必要,只需要把数据原样返回就够了。
正式服务端
如果你有去看我github上的代码,会发现没有上面那个python的服务器代码。那是因为从一开始,我对服务端的规划就是c++来写底层,或许会嵌入python写玩法逻辑。
使用c++主要是为了性能,后续还会有aoi、寻路、动画、物理等模块要实现。而嵌入python是为了开发效率与动态热更新,玩法逻辑会经常变动,用python写会方便很多。
或许也可以直接用c#一步到位吧,但我对c#不熟。(说得好像对c++很熟似的)
下一章正式进入服务端的环节。