🎨 CG | 使用状态同步实现在线 3D 贪吃蛇

在上一次的实验中,我们使用 OpenGL 实现了 3D贪吃蛇,这次,使用状态同步技术,将其改造成联网的3D多人在线贪吃蛇。

🚀 代码: Github

服务端

网络拓扑

要实现联机游戏,就需要涉及到网络通信,这里选择了简单的 Client-Server 模型作为网络拓扑。

114027_b2fd1f31_1194012

同步方式

对于同步方式,一般的网络游戏都是采用帧同步或者状态同步两种方式。

帧同步适合于一些状态比较多的游戏,每次只需要同步用户的输入,然后同步到各个客户端。每个客户端可以看作一个状态机,接受相同的输入,产生一样的输出。我之前写过的一个类吃鸡网络游戏的服务端就是使用帧同步实现的。现在的一些网游,比如绝地求生、英雄联盟、王者荣耀等都是采用类似帧同步的技术实现的。帧同步仅需要传输极少量的输入信息,因此可以达到比较低的延迟,而且仅需要存储少许信息就可以实现回放功能。另一方面,帧同步的游戏逻辑大部分都是在客户端计算的,这样一来虽然可以减少服务端的计算负担,但是也会给防外挂带来极大的挑战,因此一般来说服务端还需要做一定量的验证计算。

这里只是一个简单的贪吃蛇游戏,因此就使用了状态同步。在状态同步中,整个游戏逻辑都是处于服务端中的。每个客户端把输入传输到服务端,然后服务端经过计算得到新的游戏状态,一一分发到各个客户端。这样一来客户端的实现就可以比较简单,不需要像帧同步一样严格按顺序输入状态然后计算。服务端每一次都可以将完整的游戏状态分发下来。这样一来,客户端可以看作是一个输入设备和显示设备,就像鼠标键盘和显示器,而服务端就充当主机的角色。

传输方式

这里使用 UDP 来传输游戏状态。主要是因为状态同步对于状态数据的顺序和可靠性没有严格的要求,使用UDP是经济并且高性能的方案。

由于下面采用了单线程的事件处理循环,因此对于UDP报文的接受需要采用非阻塞的方式,防止接受事件阻塞了事件循环。

1
2
3
4
5
6
7
char recvbuf = 1;
setsockopt(serverSocket, SOL_SOCKET, SO_RCVBUF, &recvbuf, sizeof(int));
u_long imode = 1;
if (ioctlsocket(serverSocket, FIONBIO, &imode) != 0) {
cout << "Init socket error 2" << endl;
return -1;
}

在接收信息时需要使用 select方法来非阻塞地接受报文信息,并且将其超时时间设置成0

事件处理循环

为了简单起见,服务端使用了单线程,在一个循环里面处理事件。

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
while (true) {
auto start = system_clock::now();
auto duration = duration_cast<microseconds>(system_clock::now() - start);
while (double(duration.count()) * microseconds::period::num < intervalSecond) {
FD_ZERO(&rfd);
FD_SET(serverSocket, &rfd);
int SelectRcv = select(serverSocket + 1, &rfd, 0, 0, &timeout);
if (SelectRcv < 0)
cout << "Fali: " << GetLastError() << endl;
else if (SelectRcv > 0) {
// 收到来自客户端的消息
recvData();
}
duration = duration_cast<microseconds>(system_clock::now() - start);
Sleep(1);
}
// 分发状态
sendStatus();
duration = duration_cast<microseconds>(system_clock::now() - updateTime);
if (duration.count() > 0.5 * microseconds::period::den) {
updateTime = system_clock::now();
if (!running) continue;
// 更新游戏逻辑
updateGame();
}
}

在这个循环里,主要需要处理这些事件:

  • 接受客户端消息:服务端需要不停监听端口,检测是否具有来自客户端的消息。
  • 分发游戏状态:经过一定的事件间隔(这里是0.05s)分发一次游戏状态。
  • 更新游戏逻辑:经过一定的间隔(这里是0.5s)更新一次游戏逻辑。

游戏状态

服务端维护一个游戏的状态以及每个客户端的状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 客户端状态
struct GameClient {
sockaddr_in addr;
bool ready = false;
int connect = 0;

SnakeDir nextDir;
bool isLife = true;
int snakeLength;
int styleHead = 0;
int styleBody = 0;
int id = 0;
glm::vec3 snakePos[300];
};
// 游戏状态
struct ServerData {
glm::vec2 foodPos;
unsigned char frame;
int styleFood = 0;
bool map[MAX_X * 2 + 1][MAX_Y * 2 + 1];
};

游戏状态中存储一些游戏类全局信息,如食物位置等等。

客户端的状态需要存储客户端的网络地址、以及玩家的数据等等。

处理客户端信息

服务端收到来自客户端的信息之后,根据类型进行处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void recvData() {
memset(recvBuf, 0, 1024);
int recvLen = recvfrom(serverSocket, recvBuf, 1024, 0,
(sockaddr*)& clientAddr, &addrLen);
if (recvBuf[0] == 1) {
// 客户端心跳维持
handleNewClient();
}
else if (recvBuf[0] == 2) {
// 客户端准备游戏
handleReady();
}
else if (recvBuf[0] == 3) {
// 处理客户端输入
handleInput();
}
}

这里设计了三种来自客户端的信息类型:

  • 客户端心跳维持
  • 客户端准备游戏
  • 客户端输入
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void handleNewClient() {
bool isExist = false;
for (auto& c : clients) {
if (c.addr.sin_addr.s_addr == clientAddr.sin_addr.s_addr && c.addr.sin_port == clientAddr.sin_port) {
isExist = true;
c.connect = 0;
break;
}
}
// 最多8位玩家
if (!isExist && clients.size() < 8 && !running) {
cout << "Client:" << clientAddr.sin_port << " join." << endl;
clients.push_back({clientAddr, false, 0, DIR_UP, true, 0, 0, 0});
} else if (!isExist && running) {
char status[3] = { 9, 0, 0 };
sendData(clientAddr, status, 3);
}
}

首先是心跳维持。因为UDP是无状态的协议,如果客户端突然掉线了,服务端也察觉不到,因此需要一个心跳来维持客户端的在线状态。每当服务端收到客户端的心跳,就将该客户端的连接计数清空,在每次分发循环中,客户端的连接计数都会加一,当这个连接计数达到一定数量时(这里是20),也就是说客户端在(20*0.05s=1s)内没有向服务端发送心跳包,可以判断该客户端已经掉线。

在心跳维持过程中,也可以维护客户端的状态以及发送服务端的状态。当接收到新的客户端的心跳包,可以将其加入到客户端列表中,以便后面向其同步状态。同时,如果游戏已经开始,可以向客户端发送游戏已开始的信息,该客户端需要等待游戏结束后才能加入。

然后是游戏准备。游戏需要在所有客户端都准备好之后才能开始,因此每个客户端都具有一个准备状态,客户端可以通过发送准备/取消准备的信息到服务端改变状态。

最后是客户端输入。在游戏进行时,需要接受客户端的输入并且暂存起来,在游戏逻辑更新时以最新的一次输入来更新游戏状态。

状态同步

在这个阶段,服务端需要将状态同步到各个客户端。

下面是一些核心部分的代码:

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
// 同步游戏状态
void sendStatus() {
char readyClient = 0;
// 移除掉线客户端
// ....

if (!running) {
/*
返回数据结构
第一位
1 - 游戏准备中
第二位
n - 服务器中玩家数量
第三位
n - 服务器中已准备玩家数量
第四位
1 - 当前玩家未准备
2 - 当前玩家已经准备
*/
char status[5] = {1, clients.size() , readyClient, 0, 0};
for (auto& c : clients) {
status[3] = c.ready + 1;
sendData(c.addr, status, 5);
}
if (readyClient > 0 && readyClient == clients.size()) {
startGame();
}
} else {

/*
返回数据结构
第零位
2 - 游戏运行中
第一位
x - 当前帧百分比
第二位
n - 服务器中玩家数量
第三位
n - 服务器中存活的玩家数量
第四位
1 - 当前玩家已死亡
2 - 当前玩家未死亡
第五位
x - 当前玩家方向
第六位
n - 当前玩家的数据序号
第七、八、九位
x - 食物的x坐标
y - 食物的y坐标
t - 食物的种类
[4]
t - 蛇头的种类
t - 蛇身体的种类
n1 - 蛇身长度1 (0-256)
n2 - 蛇身长度2 (0-256) 长度为 len = n1*256 + n2
[len * 3]
x - 蛇身x
y - 蛇身y
d - 方向
*/

// 建立状态...

for (auto& c : clients) {
status[4] = c.isLife + 1;
status[5] = c.nextDir;
status[6] = c.id;
sendData(c.addr, status, index);
}

if (lifePlayer == 0) {
endGame();
}
}
}

首先,在分发状态之前,需要先检测客户端是否掉线,如果是,就将其从客户端列表中移除。

在这里,游戏主要分为两个阶段:游戏准备阶段游戏进行阶段。每个阶段我都设计了自有的数据结构来同步信息,使用一个字节表示一种数据,尽可能地压缩数据。

在游戏准备阶段,服务端向客户端发送房间内的玩家数量以及准备数量,并向列表中所有客户端发送状态。当所有客户端都准备好了,就可以开始游戏

在游戏进行阶段,服务端向客户端发送所有玩家的游戏信息以及游戏全局信息。这里采用了自适应变长数据结构,将数据的长度写入到数据中。当游戏中不存在存活者,此局游戏结束

游戏逻辑

在状态同步中,为了防止状态不唯一,一般都会将游戏逻辑放到服务端进行计算。这里对于游戏逻辑的计算和上一次实验中的本地计算大同小异,服务端需要同时更新所有的玩家的状态,然后统一起来分发给客户端。这里就不再详细叙述,具体可以看我的上一篇博客

运行流程

这是一场双人游戏,首先客户端加入游戏,然后准备。等待所有客户端都准备完毕的时候,游戏就可以开始运行。服务端初始化所有的游戏数据,并且接受来自客户端的输入,生成新的游戏状态再分发到各个客户端。如果所有玩家已经死亡,那么这局游戏就结束。客户端可以选择继续准备进行下一局游戏,也可以选择退出游戏。服务端接受不到来自客户端的心跳包,那么就会将该客户端踢出游戏房间。

1560486938446

客户端

与服务端的通信

在客户端中,我采用了多线程的方式,将渲染线程和通信线程分离开来。通信线程采用阻塞的方式监听来自服务端的消息。并且再开一个心跳线程维持自身的在线状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
if (net.InitSocket("127.0.0.1", 4000) == 0) {
// 接受数据线程
recThread = std::thread([&]() {
while (true) {
int statusLen;
char* status = net.GetState(statusLen);
mtx.lock();
memcpy_s(&serverStatus, 4096, status, statusLen);
mtx.unlock();
}
});
// 心跳维持线程
heartThread = std::thread([&]() {
while (true) {
char heard[2] = { 1, 0 };
net.Send(heard, 2);
Sleep(300);
}
});
} else {
cout << "无法连接服务器" << endl;
}

需要注意的是,在多线程中需要防止数据读写冲突,这里使用了加锁🔒的方法来解决。

状态同步

在游戏中,每经过一定的帧数(这里是15)就会进行一次游戏数据的更新,从缓冲区中读取来自服务端的状态信息并且解析成游戏对象。

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
// 从服务端更新游戏数据
mtx.lock();
this->isBusy = this->serverStatus[0] == 9;
this->isNet = this->serverStatus[0] != 0;
if (this->serverStatus[0] == 1) {
// 游戏准备中
this->state = GAME_MENU;
this->spriteFood->First = true;
this->isReady = this->serverStatus[3] == 2;
this->playerCount = this->serverStatus[1];
this->readyCount = this->serverStatus[2];
}
else if (this->serverStatus[0] == 2) {
// 游戏进行中
// 设置游戏全局信息
this->statusframe = this->serverStatus[1];
this->playerCount = this->serverStatus[2];
this->lifeCount = this->serverStatus[3];
this->isOver = this->serverStatus[4] == 1;
this->snakeDir = (SnakeDir) this->serverStatus[5];
this->myIndex = this->serverStatus[6];
if (this->state != GAME_ACTIVE) {
// 首次开始游戏
this->state = GAME_ACTIVE;
this->snakes.clear();
for (int i = 0; i < playerCount; i++) {
SpriteSnake* s = new SpriteSnake(this->Renderer, MAX_X, MAX_Y);
s->First = true;
this->snakes.push_back(s);
}
}
// 设置食物信息
this->spriteFood->SetData(glm::vec2(this->serverStatus[7], this->serverStatus[8]), this->serverStatus[9]);
if (this->spriteFood->First == true) {
this->spriteFood->First = false;
this->spriteFood->Update();
}
// 设置玩家信息
int index = 10;
if (!snakes.empty()) {
for (int i = 0; i < this->playerCount; i++) {
vector<glm::vec3> pos;
int head = serverStatus[index++];
int body = serverStatus[index++];
int len = (unsigned char)serverStatus[index++] * 256;
len += (unsigned char)serverStatus[index++];
for (int j = 0; j < len; j++) {
int x = serverStatus[index++];
int y = serverStatus[index++];
int d = serverStatus[index++];
pos.push_back(glm::vec3(x, y, d));
}
int oldLen = this->snakes[i]->GetLenght();
this->snakes[i]->SetData(pos, len, head, body);
if (len != oldLen) {
this->snakes[i]->Update(true);
}
if (this->snakes[i]->First == true) {
this->snakes[i]->First = false;
this->snakes[i]->Update();
this->snakes[i]->Dir = this->snakeDir;
}
}
mySnake = snakes[myIndex];
}
}
mtx.unlock();

按照自有的状态数据结构对数据进行解析并保存。

游戏渲染

之前的单机版中蛇🐍和食物🍎因为耦合度的原因组合成了一个游戏精灵对象。网络版由于游戏逻辑都转移到服务端,并且需要支持多条蛇🐍,因此需要先将其分离开来。

然后对于游戏对象的渲染和之前的单机版差不多,只是从渲染一条蛇到渲染多条蛇,如果封装得当,只需在渲染蛇时候加一层for循环即可。

1
2
3
4
5
6
// 渲染精灵
int snakeIndex = 0;
for (auto& snake : this->snakes) {
snake->Render(this->frame / (float)this->updateFrameCount, snakeIndex == myIndex);
snakeIndex++;
}

这里加了一个 Index 来区分玩家自己和其他玩家,使用不同的皮肤进行渲染。

游戏输入

在游戏进行中,由于输入是一个频率很高的操作,但是并不是每一次输入都需要发送到服务端,我在游戏更新中判断当前的输入状态和服务端的状态是否一致,如果不一致才需要向服务端发送新的输入状态,降低了网络通讯的数据量。

1
2
3
4
5
6
if (this->state == GAME_ACTIVE) {
if (this->mySnake->Dir != this->snakeDir) {
char status[3] = { 3, (char)this->mySnake->Dir, 0 };
net.Send(status, 3);
}
}

运行效果

理论上这一套 C-S 架构是支持无限个客户端的,但是考虑到地图大小有限,服务端限制了最多8名玩家。这里先来看看三个客户端的同步操作

首先是游戏准备阶段,客户端之间会实时同步人数和准备信息。

ready

当游戏开始后,新的客户端将不能进入游戏

1560487714991

在游戏进行中,将同步游戏中的状态。绿色代表自己,黄色代表其他玩家。由于我没有办法同步操作两条虫,因此一开始左边的虫🐍就死掉了😥,死掉的虫将会虫地图上去掉,以免影响其他玩家。

game

土豪通道
0%