了解多人游戏下的客户端与服务器体系结构
直连
![image-20230821204552758](https://xiaonenglife.oss-cn-hangzhou.aliyuncs.com/static/pic/2023/08/20230821204555_image-20230821204552758.png)
直连模式下,选择一个玩家充当服务器(房主)。如果游戏出现不同步,那么均按房主的世界来,玩家1可以作弊修改其游戏来影响其他玩家的世界
针对两个玩家来说,直连连接质量更好,延迟小
如果玩家数量很多,不同玩家间的通信则需要靠房主为中介,那通信质量与房主主机配置、网络情况有很大关系
专用服务器
![image-20230821205140980](https://xiaonenglife.oss-cn-hangzhou.aliyuncs.com/static/pic/2023/08/20230821205143_image-20230821205140980.png)
所有玩家与专用服务器通信,专用服务器通常运行没有游戏界面的游戏代码的修改版本,因此可以在配置较低的计算机上运行,还可以检测作弊,服务器维护的游戏世界是最权威的
① 客户端和服务器
客户端全听服务器的
游戏状态由服务器单独管理,客户端只把操作(按键,指令)发送给服务端,服务器定期更新游戏状态,将新的状态发送给客户端,客户端只需要在屏幕上渲染即可
![image-20230821232557753](https://xiaonenglife.oss-cn-hangzhou.aliyuncs.com/static/pic/2023/08/20230821232600_image-20230821232557753.png)
可以预防大范围作弊
- 玩家本地修改生命值为99999,服务器那依旧是10,玩家依旧会收到死亡事件消息
- 玩家本地修改位置,服务器处理玩家向右移动一个单位,玩家位置被同步修复
应用举例:慢节奏游戏,如策略或卡牌
延迟问题
网络数据传输要经过大量路由器,还可能遇到网络拥塞情况,远距离传输延迟可能会很高(100~500ms)
玩家发送一个向右移动一格
指令,花费了100ms传到服务器,服务器处理状态,再花费100ms将状态传给玩家
在玩家看来,按下了右键后有0.2秒的时间游戏没有任何反应,然后角色才向右移动一格,这是能明显感知到的卡顿,严重时玩家无法进行游戏
② 客户端预测和服务器对账
客户端预测
大部分玩家的输入都是按预期效果执行的。 输入 = 操作 = 行为 = 动作 = 鼠标点击移动,键盘按下释放等
如果给定游戏状态和输入操作,客户端能完全预测游戏世界的变化(跟服务器一样的处理),且只有唯一一种结果(绝对性)
我们可以将输入操作发送给服务端,先不等服务端返回,在客户端立即生效预测的结果,这种方式消除了输入操作和状态变化之间的延迟,而且客户端预测的结果大多是正确的,能跟服务器返回的结果相匹配。
假设有100ms的网络延迟(往返),移动位置的动画需要100ms,整个操作需要200ms,这是方案①导致的结果
![image-20230822000158999](https://xiaonenglife.oss-cn-hangzhou.aliyuncs.com/static/pic/2023/08/20230822000201_image-20230822000158999.png)
使用方案②后,客户端检测到输入预测玩家位置并更新,动画播放和网络请求同步进行,原来的200ms现在只需要100ms。服务器返回的结果与客户端预测的结果一致。注:服务器依旧是权威的,维护着所有玩家真实的状态
![image-20230822000419011](https://xiaonenglife.oss-cn-hangzhou.aliyuncs.com/static/pic/2023/08/20230822000423_image-20230822000419011.png)
同步问题
假设有250ms的网络延迟,玩家按了两次右键,向右移动两次,客户端立即模拟,间隔地向服务器发送两次"向右移动"操作
两次移动均完成后过50ms才收到服务器传来的第一次“向右移动”操作的结果,此时出现状态冲突
由于服务器权威性,客户端根据“真实的状态”重新设置了角色位置(角色居然跳回去了),然后又跳回来
![image-20230822000815170](https://xiaonenglife.oss-cn-hangzhou.aliyuncs.com/static/pic/2023/08/20230822000821_image-20230822000815170.png)
服务器对账
对账:会计先做个手工表,每算一会把系统算的表拿出来核对,如果不对就纠正手工表,然后接着算
要修复同步问题需要知道服务器可能没有处理完客户端的所有输入,客户端的状态是现在时,而服务器传来的状态是过去某一时刻的,两者有一定的时间差
可以为客户端的每个输入添加一个数字标记,按照输入的顺序递增这个标记。客户端发送的两个输入分别被标记了#1、#2,且保存了各自的副本。下面的例子演示了一个客户端和一个服务端的网络交互
服务器每发来一个真实状态,客户端就重新模拟一次:当t=250ms,客户端接收到服务器对#1输入的结果时,客户端丢弃#1输入的副本,并在#1的结果上根据#2副本重新计算当前游戏状态。计算后与当前状态对比,如果有差异则重新设置
![image-20230822002124542](https://xiaonenglife.oss-cn-hangzhou.aliyuncs.com/static/pic/2023/08/20230822002127_image-20230822002124542.png)
当t=350ms,客户端接收到服务器对#2输入的结果时,丢弃#2输入的副本,此时因为输入副本队列为空,不需要重新模拟。只需要将结果与当前状态对比,如果有差异则重新设置
应用:在回合制战斗中,玩家A攻击另一个角色B时,可以优先显示血液效果和造成伤害的数字,但不应该在服务器返回结果前更新角色健康状态(不是绝对的,可能有多种结果,如这一击是否把角色B打断了腿,或者是角色B使用医疗包的事件比攻击早)
不容易逆转:如果A可以预测并更新健康状态、角色B打医疗包的事件又在A攻击之前,此时A客户端还没有受到角色B健康状态的更新,A客户端预测B生命值降至0,触发角色死亡事件(可能销毁了这个实体),而实际上角色还在服务器那活得好好的,那还原前一步就十分复杂了
即客户端预测的结果与服务器预测的结果不匹配问题,在多个客户端情况下经常发生
ANTI WEB SPIDER BOT www.cnblogs.com/linxiaoxu
③ 实体插值(平滑插值)
服务器时间步长
考虑方案②,当大量客户端接入一个服务器时,服务器将收到大量的操作(按键、鼠标)输入,每接收一个输入就要更新当前世界并广播游戏状态,需要消耗大量的CPU和带宽
最好的办法是整个游戏世界以低频率周期性地更新,例如每秒10次,每次更新延迟为100ms,称为时间步长
把接收到的输入全部放入队列中,不做处理(个人认为接收到输入直接处理也行)。每100ms,处理队列,将更新后的状态广播给每个客户端
在这种情况下,游戏世界以可预测的速率独立于客户端输入的存在、输入数量进行更新
航位推算
航位推算是通过使用先前确定的位置或定位,并结合对速度、航向(或方向或航向)和经过时间的估计,来计算移动物体的当前位置的过程。
多人模式下,在实体方向和速度都会立即改变的情况下,航位推算的结果是不准确的。比如多人赛车,服务器每100ms将其他车辆的位置、速度、方向状态传递给客户端,客户端仅能根据这些信息来模拟赛车运动100ms,最后模拟得到的位置跟服务器下一次发来的位置有较大的差别,位置纠正,汽车瞬移。
详细的说(省略了对账过程):
- 有玩家A(0,0)跟玩家B(0,0),服务器C
- C告诉A:B正在往左移动,速度100m/s,位置是(0,0)
- C告诉B:A现在没移动,位置是(0,0)
- 50ms后中B开始(往右移动,速度瞬间变成1000m/s);这两个事件被加入了C的队列
- 又过了50ms,到现在 t = 100ms
- B在A上渲染的位置(-10,0)
- B自己渲染的位置(-5+50,0)
- C处理队列,将状态发送给A跟B
- C告诉A:B正在往右移动,速度1000m/s,位置是(45,0)
- C告诉B:A现在没移动,位置是(0,0)
- A发现服务器给的B位置与自己预测的位置有出路
- A修复B的位置,B被瞬移了
玩家自身跟服务器通信是不会出现这种问题的,因为玩家操作的实体是实时的,没有延迟;其他玩家不是实时的,同步数据都是服务器给的,即其他实体相关信息的一种稀疏性
实体插值
由于玩家的方向和速度都会立即改变,航位推算无法应用。如FPS,玩家通常以非常高的速度奔跑、蹲比和转弯,这使得航位推算毫无用处,因为无法再根据之前的状态准确地预测位置和速度。
为了给玩家带来连续性和流畅移动的错觉,采用一种巧妙的做法
每个玩家本身是现在时,而其他玩家都是过去式:玩家自己是实时的,而看到的其他玩家都是他们过去某一时刻的状态。
将服务器最新发来的状态记P1,前一个时间步长的状态记P2(旧状态)客户端在本地,那接下来一个时间步长内整个世界状态将线性从P2变为P1
也就是说,你比其他所有人都快了一个时间步长,其他人比你都慢一拍
下图很好解释了线性插值,v=(11.75,10)意味着客户端2在收到P1后已经过了75ms(时间步长100ms,线性插值的步长也是100ms),当时间过了100ms,客户端2的世界状态将完全变为P1,此时可能已经有了新的P1或者还没收到P1,我们可以根据网络延迟动态修改线性插值的步长
![image-20230822020146078](https://xiaonenglife.oss-cn-hangzhou.aliyuncs.com/static/pic/2023/08/20230822020150_image-20230822020146078.png)
目前的功能
- 客户端在本地发送输入并模拟效果
- 服务器从所有客户端获取带有时间戳的输入
- 服务器处理输入并更新世界状态
- 服务器向所有客户端发送服务器世界状态的快照
- 客户端接收服务器发来的世界状态更新
- 根据这个状态与没被服务器处理的输入 重新模拟
- 对其他实体状态进行线性插值
④ 延迟补偿
对时间和空间敏感的事件来说,比如射击事件,当玩家向另一名玩家射击时,由于其他玩家都是过去的玩家,所以你的瞄准延迟为100毫秒,你在对100毫秒延迟之前的敌人射击。
通用解决方法 服务器根据射击事件的时间戳,重建该时间戳时的世界状态,可以准确地知道你开枪的那一刻准星瞄准的实体
但由于是过去式,在敌人看来,100ms之后可能已经移动到掩体之后,却依旧被爆头了,不过这个解决方案已经很不错了
⑤ 帧同步
帧同步
该部分还没讲全,未来某天补上代码
状态同步讲完,接下来讲主流同步方式的另外一种:帧同步。通常用于实时战略和FPS
帧同步通过同步玩家的动作,确保每个人都能获得相同的输入,并在每一帧上执行相同的逻辑,最终获得一致的性能和结果
相同的输入 + 相同的时序 = 相同的输出
如何确保同一时间点
等待所有玩家加载完成,由于加载完成后还会有一系列初始化操作,可以播个开场动画,做到所有玩家都在同一时间点开始游戏
同步设备时间
客户端访问服务器,服务器返回一个ping值,乘以2加上服务器返回的时间就是准确的当前服务器时间。游戏期间后续同步中根据较小的ping值修改时间
同步种子
游戏里经常会使用随机数,同步随机数种子可以保证各个客户端模拟的一致性
命令同步
服务器每帧收集所有玩家操作,然后将其广播给所有玩家,没有玩家操作就广播一个空指令,向前推动游戏帧
核心逻辑-命令队列
命令队列的设计可以轻松实现战斗回放。创建两种侦听器,分别是本地模式和网络模式
- 本地模式下侦听玩家的操作并将操作填充到队列中
- 网络模式下侦听玩家的操作并发送给服务器,同时监视服务器发来的数据并将操作填充到队列中
核心逻辑-游戏主循环识别
帧同步需要我们严格控制整个游戏的执行顺序,通常情况下,不能直接使用引擎更新,需要把一切掌握在自己手中。首先需要控制的是帧速率
- 以特定的帧率来运行游戏,如每秒60帧
- 跟踪帧进度并控制,如果当前设备帧索引落后过多,加快它的帧率
对象的更新应当是按特定顺序执行的,需要进行排序
网络延迟
添加帧缓冲区和前滚动画,用UDP取代底层TCP如KCP
由于TCP超时重传机制。没有收到一帧的数据包时,游戏的逻辑无法正常执行,直到数据包被重新发送
或者直接帧锁定,直到有数据来,以超快的帧率同步
不用帧锁定,客户端请求服务器状态副本,实现回滚跟重试然后恢复
重新连接
如果是一个小的重新连接,只丢失了几帧数据,会用这几帧的数据进行补充。如果是一个大型重新连接,服务器序列化的数据此时将缓存5秒。如果在这段时间内重复断开连接并重新连接,服务器将重用这些缓存的数据。
优点
使用帧同步可以节省消息量,状态同步需要服务器对每个客户端发送大量状态信息(大量实体,每个实体各自维护大量字段),帧同步只需要发送操作指令和帧索引
由于消息量得到了节省,在网络情况不佳的情况下,也能实现实时战斗游戏的同步问题
我们可以轻松实现回放,服务器记录所有操作,客户端请求回放文件执行每一帧
参考资料
Client-Server Game Architecture - Gabriel Gambetta
Networking (part 2) · GitBook (rvagamejams.com)
Game server synchronization of large amounts of data in a battle (monstar-lab.com)
Tutorial: Technical Implementation Details of Frame Synchronization in Games
ENet
ENet是LOVE使用的一个第三方网络库,采用UDP协议,在运输层帮我们完成了各种事情,包括消息确认
带心跳检测功能,当有一方不回复超过5~30秒时则认为其disconnect