游戏开发之状态同步
游戏同步。游戏人必须掌握的网络同步机制
这里是转载链接 https://zhuanlan.zhihu.com/p/381445790
1. 背景
游戏人必须掌握的网络同步机制
2. 网络同步的方式
游戏同步主要分为帧同步 和状态同步,帧同步强调的是游戏状态迁移的一致性,而状态同步强调的是游戏状态结果的一致性。
2.1基本概念
2.1.1帧同步
原理:将所有玩家的操作(即输入)转发的所有客户端上,每个客户端把所有玩家的逻辑都跑一边,通过相同的输入来达到相同的结果。需要注意的是,由于帧同步的原理限制,游戏逻辑中不能出现任何随机数或者其他不确定因素。
2.1.2状态同步
原理:把每个需要同步的实体的信息(例如: 位置,方向等等),发给其他的客户端,其他的客户在对应的位置创建出相同的实体出来。
原理虽然不难,但是状态同步还需要考虑到底以哪个客户端的状态为准,发给其他客户端。一般做法会在服务器上再跑一个没有渲染的“客户端”,称之为服务端,所有玩家连接到这个端上,然后服务端定时给所有客户端发放每个实体的状态。
2.2优缺点
2.2.1 状态同步
优点:1. 游戏数据安全,服务端可以有选择性的下发实体的状态,比如不可见的实体不下发,直接杜绝了透视挂。2. 强制所有玩家的游戏数据一致,由于所有数据都是服务器下发,不存在客户端不一致的情况。
缺点:1. 要求服务器的性能高,由于服务器也需要跑一份游戏作为服务端,所以游戏的资源模型等,都需要传到服务器上。 2. 游戏的操作延迟,游戏时所有操作都需要先发到服务端,服务端处理后再将结果发回客户端,因此造成的延迟需要优化。
2.2.2 帧同步
优点:1. 服务器负载低,由于服务器只转发请求,所以一台服务器开几千个房间不成问题。2. 游戏手感好, 因为都是在本地模拟。
缺点: 1. 客户端拥有整局游戏的所有数据,所以很容易就能做出透视外挂,并且没法预防,很多随机性的结果,由于随机数种子都是一样的,客户端甚至可以预测,比如卡牌游戏下一张牌。2. 浮点数无法使用,进而导致大部分物理引擎无法使用。
3. 状态同步
由于我比较了解CounterStrike 1.6,而且它使用的状态同步,所以本文章就是来记录我是怎么一步一步的实现状态同步的。
3.1 网络层
3.1.1 可靠UDP
游戏要求实时性比较高,一般操作延时都在100毫秒内,所以我们选用UDP。状态同步不需要保证所有包都被客户端收到,所以发送的数据包包分为可靠包和非可靠包。
- 可靠包
游戏中时效性不敏感的数据使用可靠包发送给客户端,例如游戏内聊天等数据。
实现:暂时使用一个简单的实现,即确认方增加确认包,发送方发送后,收到确认包才算发送成功,否则一直重复发包。
- 不可靠包
游戏中有时效性的数据,例如各个实体的状态,则使用不可靠包发送。
实现:也是增加确认包,最多重复发送N次,超过N次仍然没收到确认包,则丢弃该包。
3.1.2 数据包设计
经网络调研(本人未证实),Udp单包大小为548以下最优,而游戏一次可能需要同步上百个实体,每个实体肯定不止5字节的数据,所以我们需要给要发的数据包分包。
每个数据包为了保证顺序,所以要在数据包中加入包序号。为了分包,包中需要加入分包的总长度,还有当前包的序号。所以设计的udp协议如下:
message Head
{
int64 Seq = 1;
int32 Length = 2;
int32 Current = 3;
}
message UdpPack
{
Head head = 1;
repeated byte data = 2;
}
网络线程收到udp缓存起来,凑完完整的包以后将二进制数据通过消息队列发给逻辑线程,逻辑线程再次反序列化,并处理数据。
线程模型: 客户端和服务端都是两个线程,一个网络线程 一个逻辑线程。
线程间使用 消息队列(生产者消费者模型) 通信
3.2 客户端和服务端
3.2.1 客户端
客户端负责:
-
将玩家的输入发给服务端。
-
处理服务端发来的实体信息,应用到本地的实体中。
-
本地玩家操作的预演。
本地玩家操作时,不要等待服务器的下发结果,应该经过和服务端判定后,立即响应玩家的操作,避免让玩家有延迟的感觉。
- 状态预测
服务器下发的状态总是有延迟的,所以客户端接收到服务端状态后,应该通过预测将延迟的时间补回来。例如通过实体当前的速度或者本地的物理引擎预测出此时实体的位置应该在哪。
本地预演和状态预测看起来高大上,实际上只是客户端复用服务端的代码,对客户端的实体进行合理的改动,并且预测和预演是允许有误差的,如果出现误差,小的误差客户端应该想办法平滑移动实体到对应位置,大的误差直接强制拉回正确位置。
3.2.2 服务端
服务端负责:
- 游戏逻辑
为了游戏数据安全,所有的游戏逻辑应该由服务端处理
-
接受玩家的输入,并将输入应用到玩家对应的实体上
-
给客户端发放实体信息
可以针对每个玩家,下发该玩家可见的实体(cs就是这么做的)
3.2.3 具体实现
- 实体的同步
服务端各自维护自己的实体列表,客户端收到服务端的实体列表时,先与本地的实体进行diff(主要比较id和类型),如果本地没有这个实体则创建实体,如果本地有的实体下发列表没有,则直接删除实体。
- 本地玩家预演
本地的射击和移动都应该预演,但是暂时只做了移动,说一下移动的做法。
玩家按住移动键时,本地先进行移动,然后将移动的请求发给服务端,本地的移动要比服务端的移动稍慢一些,否则因为延迟的原因,本地总是比服务端走的远一些,并会被拉回。 这样做完,本地玩家移动依然不是很流畅,因为接受到服务端的状态后,纠正本地玩家是强制将玩家的位置移动到正确的位置。
服务端下发本地玩家位置后,客户端可以不立即应用这个位置,而是存起来,每一逻辑帧本地玩家都向正确位置移动一些,至于移动多少,正比例还是曲线移动,可以自己尝试看起来平滑即可,我的做法是每帧向正确的位置移动差量的一般的距离。
- 玩家方向的预演
fps玩家转向是非常迅速的,而且视角稍微移动一点客户端的感知都非常大,所以玩家方向的改变客户端移动鼠标后立即应用到本地玩家身上,并事实给服务器发放玩家当前的方向即可。
3.2.4 协议的设计
// 客户端 => 服务端
message CSInputPack
{
Head head= 1; // 协议头
MoveInput move_input = 2; // 玩家的移动输入
Vector2 PlayerDirection = 3; // 玩家当前的朝向
repeated string cmd = 4; // 远程调用的字符串
...
}
// 服务端 => 客户端
message SCEntityPack
{
Head head= 1; // 协议头
repeated Entity entity = 2; // 实体列表
LocalInfo local_info= 3; // 本地玩家信息
}
// 实体信息
message Entity
{
int32 id = 1; // 实体id
int32 type = 2; // 实体类型
Vector3 position = 3; // 实体位置
Vector4 rotation = 4; // 实体方向
Vector3 velocity = 5; // 实体速度
repeated float fData = 6; // 实体的浮点数据 (用于具体类型需要同步的特殊数据)
repeated int32 iData = 7; //实体的整型数据 (用于具体类型需要同步的特殊数据)
}