在上一次的实验中,我们使用 OpenGL 实现了 3D贪吃蛇,这次,使用状态同步技术,将其改造成联网的3D多人在线贪吃蛇。
🚀 代码: Github
服务端
网络拓扑
要实现联机游戏,就需要涉及到网络通信,这里选择了简单的 Client-Server 模型作为网络拓扑。
同步方式
对于同步方式,一般的网络游戏都是采用帧同步或者状态同步两种方式。
帧同步适合于一些状态比较多的游戏,每次只需要同步用户的输入,然后同步到各个客户端。每个客户端可以看作一个状态机,接受相同的输入,产生一样的输出。我之前写过的一个类吃鸡网络游戏的服务端就是使用帧同步实现的。现在的一些网游,比如绝地求生、英雄联盟、王者荣耀等都是采用类似帧同步的技术实现的。帧同步仅需要传输极少量的输入信息,因此可以达到比较低的延迟,而且仅需要存储少许信息就可以实现回放功能。另一方面,帧同步的游戏逻辑大部分都是在客户端计算的,这样一来虽然可以减少服务端的计算负担,但是也会给防外挂带来极大的挑战,因此一般来说服务端还需要做一定量的验证计算。
这里只是一个简单的贪吃蛇游戏,因此就使用了状态同步。在状态同步中,整个游戏逻辑都是处于服务端中的。每个客户端把输入传输到服务端,然后服务端经过计算得到新的游戏状态,一一分发到各个客户端。这样一来客户端的实现就可以比较简单,不需要像帧同步一样严格按顺序输入状态然后计算。服务端每一次都可以将完整的游戏状态分发下来。这样一来,客户端可以看作是一个输入设备和显示设备,就像鼠标键盘和显示器,而服务端就充当主机的角色。
传输方式
这里使用 UDP 来传输游戏状态。主要是因为状态同步对于状态数据的顺序和可靠性没有严格的要求,使用UDP是经济并且高性能的方案。
由于下面采用了单线程的事件处理循环,因此对于UDP报文的接受需要采用非阻塞的方式,防止接受事件阻塞了事件循环。
1 | char recvbuf = 1; |
在接收信息时需要使用 select
方法来非阻塞地接受报文信息,并且将其超时时间设置成0
事件处理循环
为了简单起见,服务端使用了单线程,在一个循环里面处理事件。
1 | while (true) { |
在这个循环里,主要需要处理这些事件:
- 接受客户端消息:服务端需要不停监听端口,检测是否具有来自客户端的消息。
- 分发游戏状态:经过一定的事件间隔(这里是0.05s)分发一次游戏状态。
- 更新游戏逻辑:经过一定的间隔(这里是0.5s)更新一次游戏逻辑。
游戏状态
服务端维护一个游戏的状态以及每个客户端的状态。
1 | // 客户端状态 |
游戏状态中存储一些游戏类全局信息,如食物位置等等。
客户端的状态需要存储客户端的网络地址、以及玩家的数据等等。
处理客户端信息
服务端收到来自客户端的信息之后,根据类型进行处理
1 | void recvData() { |
这里设计了三种来自客户端的信息类型:
- 客户端心跳维持
- 客户端准备游戏
- 客户端输入
1 | void handleNewClient() { |
首先是心跳维持。因为UDP是无状态的协议,如果客户端突然掉线了,服务端也察觉不到,因此需要一个心跳来维持客户端的在线状态。每当服务端收到客户端的心跳,就将该客户端的连接计数清空,在每次分发循环中,客户端的连接计数都会加一,当这个连接计数达到一定数量时(这里是20),也就是说客户端在(20*0.05s=1s)内没有向服务端发送心跳包,可以判断该客户端已经掉线。
在心跳维持过程中,也可以维护客户端的状态以及发送服务端的状态。当接收到新的客户端的心跳包,可以将其加入到客户端列表中,以便后面向其同步状态。同时,如果游戏已经开始,可以向客户端发送游戏已开始的信息,该客户端需要等待游戏结束后才能加入。
然后是游戏准备。游戏需要在所有客户端都准备好之后才能开始,因此每个客户端都具有一个准备状态,客户端可以通过发送准备/取消准备的信息到服务端改变状态。
最后是客户端输入。在游戏进行时,需要接受客户端的输入并且暂存起来,在游戏逻辑更新时以最新的一次输入来更新游戏状态。
状态同步
在这个阶段,服务端需要将状态同步到各个客户端。
下面是一些核心部分的代码:
1 | // 同步游戏状态 |
首先,在分发状态之前,需要先检测客户端是否掉线,如果是,就将其从客户端列表中移除。
在这里,游戏主要分为两个阶段:游戏准备阶段和游戏进行阶段。每个阶段我都设计了自有的数据结构来同步信息,使用一个字节表示一种数据,尽可能地压缩数据。
在游戏准备阶段,服务端向客户端发送房间内的玩家数量以及准备数量,并向列表中所有客户端发送状态。当所有客户端都准备好了,就可以开始游戏。
在游戏进行阶段,服务端向客户端发送所有玩家的游戏信息以及游戏全局信息。这里采用了自适应变长数据结构,将数据的长度写入到数据中。当游戏中不存在存活者,此局游戏结束。
游戏逻辑
在状态同步中,为了防止状态不唯一,一般都会将游戏逻辑放到服务端进行计算。这里对于游戏逻辑的计算和上一次实验中的本地计算大同小异,服务端需要同时更新所有的玩家的状态,然后统一起来分发给客户端。这里就不再详细叙述,具体可以看我的上一篇博客
运行流程
这是一场双人游戏,首先客户端加入游戏,然后准备。等待所有客户端都准备完毕的时候,游戏就可以开始运行。服务端初始化所有的游戏数据,并且接受来自客户端的输入,生成新的游戏状态再分发到各个客户端。如果所有玩家已经死亡,那么这局游戏就结束。客户端可以选择继续准备进行下一局游戏,也可以选择退出游戏。服务端接受不到来自客户端的心跳包,那么就会将该客户端踢出游戏房间。
客户端
与服务端的通信
在客户端中,我采用了多线程的方式,将渲染线程和通信线程分离开来。通信线程采用阻塞的方式监听来自服务端的消息。并且再开一个心跳线程维持自身的在线状态。
1 | if (net.InitSocket("127.0.0.1", 4000) == 0) { |
需要注意的是,在多线程中需要防止数据读写冲突,这里使用了加锁🔒的方法来解决。
状态同步
在游戏中,每经过一定的帧数(这里是15)就会进行一次游戏数据的更新,从缓冲区中读取来自服务端的状态信息并且解析成游戏对象。
1 | // 从服务端更新游戏数据 |
按照自有的状态数据结构对数据进行解析并保存。
游戏渲染
之前的单机版中蛇🐍和食物🍎因为耦合度的原因组合成了一个游戏精灵对象。网络版由于游戏逻辑都转移到服务端,并且需要支持多条蛇🐍,因此需要先将其分离开来。
然后对于游戏对象的渲染和之前的单机版差不多,只是从渲染一条蛇到渲染多条蛇,如果封装得当,只需在渲染蛇时候加一层for循环即可。
1 | // 渲染精灵 |
这里加了一个 Index 来区分玩家自己和其他玩家,使用不同的皮肤进行渲染。
游戏输入
在游戏进行中,由于输入是一个频率很高的操作,但是并不是每一次输入都需要发送到服务端,我在游戏更新中判断当前的输入状态和服务端的状态是否一致,如果不一致才需要向服务端发送新的输入状态,降低了网络通讯的数据量。
1 | if (this->state == GAME_ACTIVE) { |
运行效果
理论上这一套 C-S 架构是支持无限个客户端的,但是考虑到地图大小有限,服务端限制了最多8名玩家。这里先来看看三个客户端的同步操作
首先是游戏准备阶段,客户端之间会实时同步人数和准备信息。
当游戏开始后,新的客户端将不能进入游戏
在游戏进行中,将同步游戏中的状态。绿色代表自己,黄色代表其他玩家。由于我没有办法同步操作两条虫,因此一开始左边的虫🐍就死掉了😥,死掉的虫将会虫地图上去掉,以免影响其他玩家。