本篇主要是提供帧同步主体框架的实现方法以及源码。还有一些坑点的分析。如果对帧同步理论感兴趣的话可以看看这里《帧同步理论篇》。
ps:我的同步框架是在lua里面写的,已经经过了测试没有什么问题即使网络很卡也可以通过调节同步参数保证游戏的流畅。原来的代码里包含了一些项目内部的信息所以就不直接贴出来了。这里只给出核心代码具体的细节大家可以根据项目需求自己扩充。另外我个人这边是用lua写的同步框架,所以贴的也是lua代码哦。
目录
主体架构
LockStep
战斗模块类
浮点数学
断线重连
踩坑
socket粘包、断包
镜像处理
Update函数+切后台
关于socket
如上图所示,整体的架构为:
1.战斗类管理着同步类和其他模块类。
2.各模块使用的数学计算和随机数等使用自定义的浮点数学类。
3.各模块必须实现两个接口即逻辑执行接口和渲染执行接口。
4.lockstep会接管各模块的逻辑接口和渲染接口保证整个战斗在同步下进行。
lockstep主要的功能是控制整个游戏的流程,替代unity的update,让游戏所有逻辑和渲染通在lockstep的控制下在规定的时间点执行。
这里采用了几个关键逻辑来划分lockstep流程周期。
操作发送 => 用户操作时立即上报给服务器。
事件帧 => 到达事件帧时向服务器请求缓存的用户操作,并立即执行。[1个事件帧=N个逻辑帧]
逻辑帧 => 每个逻辑帧执行战斗各个模块的逻辑运算接口,如位移伤害等等。[1个逻辑帧=M个渲染帧]
渲染帧 => 每个渲染帧根据上一次逻辑帧时自身缓存的信息和当前逻辑帧的数据做插值后得到渲染帧的数据并执行渲染,如位移旋转的修改。[1个渲染帧=unity自身的最小update间隔]
--累计经过的时间
local accumilateTime
--下一次事件帧时间
local nextKeyTime
--事件帧间隔
local keyLen
--下一次逻辑帧时间
local nextLogicTime
--当前逻辑帧数
local logicNum
-逻辑帧间隔
local logicLen
--dt即为渲染帧间隔(可以和unity保持一致即60帧/s)
function lockStep.Update(dt)
--计算累计经过的时间
accumilateTime = accumilateTime + dt
--累积时间大于关键帧的时候
if accumilateTime >= nextKeyTime then
--*2*.取得缓存的用户操作并处理
--如果缓存没数据了则锁定客户端
if noData then
return
end
--更新下一个事件帧时间
nextKeyTime = nextKeyTime + keyLen
end
--处理逻辑帧(这里while循环是防止某一帧间隔过大跨度了多个逻辑帧的情况)
while(accumilateTime > nextLogicTime) do
--*3*.执行逻辑处理
lockStep:LogicUpdate(logicLen)
--下一个事件帧到达前
if nextLogicTime == nextKeyTime - logicLen then
--*1*.向服务器请求当前用户的所有操作并缓存
end
--更新当前逻辑帧数和下一次逻辑帧时间
logicNum = logicNum + 1
nextLogicTime = nextLogicTime + logicLen
end
--设置渲染参数(结果为当前渲染帧在两个逻辑帧之间比值)
renderLerpValue = (accumilateTime + logicLen - nextLogicTime) / logicLen
--*4*.调用渲染处理将计算的插值参数传进去
lockStep:RenderUpdate(renderLerpValue)
end
上述代码为同步逻辑主体,update由unity的fixupdate调用。
中间有四个地方需要扩展(标记为*n*了):
*1*:发送一个空包向服务器请求上一个事件帧到此时为止用户做的操作并缓存起来。
此处发包有两个作用,1.告诉服务器当前客户端准备就绪可以执行下一个step了。2.提前一个逻辑帧向服务器请求操作中间有一个逻辑帧的缓冲提供了一个缓冲,服务器只要在缓冲内下发用户操作都不会导致客户端锁帧(解决了网络延迟带来的卡顿)。
*2*:从缓存中取得用户的操作并执行。
此处的作用是,若没有取得到操作则锁定客户端直到服务器下发了*1*中的数据包。保证了多个客户端执行的一致性。
*3*:将战斗所有模块中的逻辑方法执行一遍,传入逻辑帧时间间隔。
由于是用逻辑帧间隔执行的逻辑方法,所有客户端的逻辑帧间隔都是一样的,因此逻辑的执行结果也会保持一直。
*4*:将战斗所有模块中的渲染方法执行一遍,传入一个插值结果。
上述的流程保证了逻辑的一致性,最终渲染的间隔每个机器还是不一样的,但是由于渲染帧只处理表现,所以这样的表现差别已经不影响游戏逻辑了。在渲染帧方法中我们需要缓存上一次的逻辑结果比如上一次怪物的逻辑位置,然后根据本次怪物的逻辑位置和上一次的怪物逻辑位置做一个插值即可得到当前怪物在场景中的位置再setposition即可。
战斗模块类只要实现了LogicUpdate和RenderUpdate即可。
lockstep会在逻辑帧和渲染帧遍历所有的战斗模块去调用这两个方法。
战斗模块的划分和游戏具体业务逻辑息息相关,不属于同步框架的范畴。
只要记住战斗模块必须实现这两个接口让lockstep去调用即可。
浮点数学类需要封装好所有float和vector3的数学运算,外部只管从这里调用即可。另外还需要提供随机数的运算。
这里只贴了最关键的代码:
1.随机数的生成
2.关于浮点数精度处理的几个方法
**最终的数学运算可以根据项目情况自行封装,这里提供了float的部分封装举个例。
fixMath = {}
--[[
模块:同步浮点数
功能描述:
1.处理同步的浮点问题
2.flot,vector3
3.包含了基本运算以及插值,移动的封装
]]
--放大倍数
fixMath.Muti = 1000
--随机种子
local r = 9
--系数base
local base = 256
--系数a
local a = 13
--系数b
local b = 147
--设置随机种子
function fixMath:SetRandSed(n)
r = n
end
--返回一个随机数
local function GetRand()
local tmp1 = a*r+b
local tmp2 = math.modf(tmp1 / base)
local tmp3 = tmp1 - tmp2 * base
r = tmp3
return tmp3/base
end
--返回一个随机数(n为范围, 最终生成1-n范围的随机数)
function fixMath:Rand(n)
local rd2 = GetRand()
rd2 = fixMath:GetNearBigInt(rd2)
local final = fixMath:GeNSmallInt(rd2 * (n-1),1000) + 1
return final
end
--返回小数整数
function fixMath:GetNearSmallInt(value)
return value > 0 and math.floor(value + 0.5) or math.ceil(value - 0.5)
end
--返回大数整数
function fixMath:GetNearBigInt(value)
return value > 0 and math.floor(value * 1000 + 0.5) or math.ceil(value * 1000 - 0.5)
end
--返回缩小N倍的数
function fixMath:GeNSmallInt(value,N)
return value > 0 and math.floor(value / N + 0.5) or math.ceil(value / N - 0.5)
end
--Float的封装
function fixMath:GetFlot(value)
local float = {}
--将浮点数放大1000倍并截取整数部分
float.value = fixMath:GetNearBigInt(value)
--设值(传入正常浮点数)
float.Set = function(setValue)
float.value = fixMath:GetNearBigInt(setValue)
end
--乘法
float.Mut = function(mutValue)
float.value = fixMath:GetNearSmallInt(float.value * mutValue.value / 1000)
end
--除法
float.Div = function(divValue)
float.value = fixMath:GetNearSmallInt(float.value / divValue.value)
end
--返回正常浮点数
float.RValue = function()
return float.value / 1000
end
--朝指定浮点数移动(spd为移动步长)
float.MoveTo = function(moveValue, spd)
if float.value == moveValue.value then
return true
elseif math.abs( moveValue.value - float.value ) < math.abs( spd.value ) then
float.value = moveValue.value
return true
else
float.value = float.value + spd.value
end
end
return float
end
关于随机数生成的原理可以参考一下https://blog.csdn.net/fengying2016/article/details/80570702。
上述浮点数生成最核心的思路是将正常浮点数放大1000倍截取整数部分后存储起来。
以后做各种运算的时候均需要使用封装的浮点数,并且如果任何操作可能导致小数都必须用封装好的放大截取整数的函数去处理。
断线重连这里采用的方法是,断线后重连时。服务器将从游戏开始到当前的所有操作都发送给客户端,客户端用while循环直接执行所有的逻辑update,直到与当前服务器的逻辑帧数一致。之后再回到正常流程。
--断线重连逻辑
function lockStep:ReLogic()
local num = 0
while(num < reLogicNum) do
--到达事件帧时
if num >= nextKeyNum then
--取得用户操作并执行
end
--执行逻辑帧
self:LogicUpdate(logicLen)
--同步逻辑帧数
logicNum = logicNum + 1
--更新步骤
num = num + 1
end
--同步累积时间、逻辑帧、关键帧等信息
nextLogicTime = logicNum * logicLen
accumilateTime = nextLogicTime
nextKeyTime = accumilateTime + keyLen
end
--断线重连走完之后让逻辑回到正常的update即可
这里的取得用户操作并执行和正常lockstep里的事件解析方式保持一致即可。
帧同步的整个基础架构并不是很麻烦,但是实际结合游戏项目的时候坑还是很多的。
这里分享一些坑点吧。
socket接收数据并不能保证数据的完整性,会出现粘包(即本次的数据后面还带着下一个消息的数据)或者断包的情况(和粘包类似,本次消息只返回了一部分,另一部份下一次才会返回)。我们需要在socket底层将粘包和断包的情况处理好后封装给业务层。
做对战游戏有时候会需要做镜像,即a和b对打,双方在自己的客户端都看到自己在左边而对手在右边。
我当时在项目里logicUpdate中遍历英雄的时候出现过一个问题。
我的英雄按位置信息生成key值后存储在lua的表中。表中数据的顺序完全由key值控制了。
而a和b由于做了镜像,导致双方由位置生成的key是一样的。
最后出现了遍历到某个英雄的时候a和b分别认为这个英雄是自己的导致不同步。
最后我将英雄按镜像前的一些逻辑值生成key就好了。
最开始我的lockstep update由unity的update调用。
结果a玩家切后台之后update传入的dt变得非常大,甚至超过了一个事件帧的间隔。导致无法同步。
后改为由unity的fixupdate并且对dt做了一个最大限制,当dt超过某个阈值时强制dt为阈值。
帧同步和socket息息相关,关于这块的处理后续写了关于socket的框架再把链接贴出来吧。