前言
上一篇文章介绍了 LockstepDemo 这个项目可以作为帧同步入门读物,解决了跨域限制的问题以后,这个开源项目就可以运行起来啦,虽然我没有使用js写过实际的项目,但看的多了自然也能看懂大部分的js代码了,作为一个帧同步领域的小白,我开始了阅读这个项目代码的旅程,看过之后确实解开了我之前的迷惑,所以简单记录一下学习心得。
- 基础的帧同步模式,每个客户端必须回报给服务器收到帧数,服务器再次发送确认包才执行帧数据,否则所有人等待,也就是一卡全卡。
- 基于现在手游的流行程度和弱网环境,手游一般都采取乐观锁模式。即收到服务器推帧后,客户端立即执行,不等待其他人。这样卡顿的人自己卡,不影响其他人的游戏体验。同时卡顿的人在收到数据后,自行加速补帧来追赶上正确的游戏速度。
连帧同步都出SDK了
插播一条消息,今天在搜索帧同步资料的时候,无意间发现了游戏巨头——腾讯居然发布了帧同步SDK-LockStep,简直“丧心病狂”,真的是盘子大了什么都做啊,不过也挺好,我可以从中学到不少问题的处理方案,我摘抄官方文档部分内容,感兴趣的可以去看看
帧同步(LockStep)服务为手游开发提供一套快速、可靠的帧同步游戏开发框架。基于GCloud云服务
进行快速部署,同时支持TCP、UDP、RUDP三种通道。帧同步开发框架提供一致性数学库与一致性检测工具,并且针对弱网设计具有高可靠、低延时的特性。帧同步与更为传统的状态同步均为游戏常见同步方案,主要区别在于:状态同步主要逻辑计算放在服务器端,将计算结果下发给客户端;而帧同步服务器仅仅起到收集客户端输入并广播的作用。基于帧同步相对状态同步流量消耗更低、开发效率更高、打击感更好等优点,《王者荣耀》选择了帧同步方案。
《拳皇命运》项目从完全不熟悉帧同步技术的情况下,接入SDK仅用了两个月时间,将原有状态同步游戏,改造成为帧同步游戏;项目仅需编写游戏逻辑,无需关心同步、网络品质、录像回放等核心功能。
专业的事情交给专业的人来做,如果之前没有积累,将这套SDK直接拿过来用也是不错的,我看到支持C++和C#两种语言,其他的语言就得自己去沟通了
LockstepDemo
言归正传,开始阅读LockstepDemo这个项目的源码,服务端app.js使用node运行,前端main.js+main.css+index.html,在浏览器中运行,其实主要的逻辑代码就在main.js中,后端就只有app.js一个文件,总共166行,前端还引用了jquery.min.js和socket.io.js两个库文件,但包含主要逻辑代码的main.js文件仅有328行。
在开始阅读自定义逻辑代码之前先来看看引用的这两个库:
socket.io.js
负责网络的建立、管理,消息的发送等等,看了逻辑中的调用真的是挺方便的
var io = require("socket.io")(server)
: 创建基于 Node.js 的 WebSocket 服务器,并将其绑定到了一个 HTTP 服务器实例 server 上io.on('connection', function (socket) {...])
: 使用Socket.IO 的服务器实例的 on 方法来监听客户端与服务器建立连接的事件socket.on('join', function(account) {...})
: 使用与客户端建立的连接的对象 socket 的 on 方法来监听客户端发送的 ‘join’ 事件socket.emit("open", {...})
: 使用了 Socket.IO 库的 emit 方法来向当前客户端发送一个名为 “open” 的自定义事件,并附带自定义对象作为数据socket.broadcast.emit('system', ...)
: 用于向除当前连接的客户端之外的所有客户端发送消息io.sockets.emit('start', {...})
: 用于向所有连接的客户端发送消息
jquery.min.js
即使没有做过前端,jQuery这个库的大名也应该听过,它是一个流行的 JavaScript 库,简化了在网页开发中的 JavaScript 编程。它提供了一系列功能强大且易于使用的 API,使得诸如 DOM 操作、事件处理、动画效果、AJAX 请求等任务变得更加简单和高效,主要特点如下:
- 简化 DOM 操作:提供了简洁而强大的 DOM 操作方法,使得选择元素、修改元素属性、添加/删除元素等操作变得更加便捷
- 事件处理:提供了简单易用的事件处理方法,可以方便地为元素绑定事件、移除事件、触发事件等,大大简化了事件处理的代码编写
- 动画效果:提供了丰富的动画效果和特效,可以通过简单的方法实现页面元素的平滑过渡、淡入淡出、滑动等效果,为用户提供更流畅的交互体验
- AJAX 请求:提供了简洁的 AJAX 方法,可以方便地进行异步数据加载和交互,从而实现更灵活和动态的网页内容加载和更新
- 跨浏览器兼容性:封装了复杂的跨浏览器兼容性处理,使得开发者可以更加轻松地编写跨浏览器兼容的代码
简单来说它就是一个封装了常用操作的库,稍后会在main.js发现它的使用方法,简单摘录如下:
- 文档加载完成事件:
$(function () {...})
用于在文档加载完成后执行的函数,这是 jQuery 的快捷方式,等同于$(document).ready(function() {...})
,表示在 DOM 树构建完成后执行指定的函数 - 元素选择:
$('body')
、$('#start_btn')
、$('#reconnect_btn')
等通过 jQuery 选择器选择了 HTML 元素。这些选择器能够基于元素的标签名、ID、类名等来选择元素,返回 jQuery 对象,以便进行后续的操作 - 事件处理:
$('body').keydown(function(e) {...})
注册了键盘事件处理函数,当键盘按键按下时执行相应的操作 - 动画效果:
$("#tips").animate({...})
使用了 jQuery 的动画效果,在提示框显示时执行动画效果,让提示框从屏幕中间上方滑动到屏幕中间 - 样式操作:
$("#tips").show()
、$("#tips").fadeOut()
等使用了 jQuery 提供的方法来控制元素的显示和隐藏
在阅读app.js和main.js之前还是得说明一下,今天只介绍核心逻辑,像断线重连、消息提示、显示网络延迟等功能,都是在核心逻辑上的补充和优化,可以先忽略不看,并且这次看代码发现,整个运动的表现和实现逻辑是符合牛顿第一定律的,真的挺有意思:
一切物体总保持匀速直线运动状态或静止状态,直到有外力迫使它改变这种状态为止
这只是这个项目的特点,并不是所有的游戏都是这样的,有些游戏的实现就是和这惯性定律相违背的,比如很多游戏必须一直拖动摇杆才会移动,否则就会停住静止,它们所表达出来的就是,“力是维持物体运动的关键”,好了,扯得有点远了,一起看看代码实现吧
服务器app.js
1 | var server = require('http').Server() |
定义服务器实例,启动并监听3000端口,这里已经做了跨域允许访问的配置
1 | var g_onlines = {} // 所有在线玩家 |
一些游戏全局变量和游戏状态枚举的定义,注释写的很详细,记一遍看下面的逻辑时能想起来就行
1 | // frame定时器 |
开始进入主要逻辑,lastUpdate是上次走过的时间,在游戏开始g_gameStatus == STATUS.START
后不断将 dt
积累到变量 stepUpdateCounter
,超过一帧的间隔后执行一帧逻辑 stepUpdate()
,setInterval
是内部函数,第二个参数表示调用的时间间隔,默认为0,可以可以认为是游戏中常用的 tick()
函数
1 |
|
这是转发客户端操作的核心函数,首先是遍历所有玩家 g_onlines
,执行 message[key] = {step:g_stepTime, id:key}
为每个玩家构建一个空指令,然后遍历当前收到的所有命令 g_commands
,将命令的帧值设置为当前帧,并且过掉一帧中的多个指令,保证一帧只能朝一个方向运动,将收集到的所有指令 commands
通过 socket.emit()
函数发送给所有玩家 g_onlines
1 | io.on('connection', function (socket) { |
这是服务器上所有事件监听的基础,io
监听新玩家连接事件,建立连接后向客户端发送一个名为 “open” 的自定义事件,并附带了一个包含 id 和 stepInterval 属性的对象作为数据,id的值是 socket.id
,stepInterval
表示每帧的时间间隔,getAccount()
函数的作用通过socket.id
获取账号名
1 | socket.on('join', function(account) { |
这是监听socket收到 'join'
事件的处理函数,实现逻辑有些技巧,通过查询账号是否已经登录过服务器,来判定是否为重连,如果为重连则一次发送 'join'
、'start'
、'message'
事件和数据,其中 'message'
事件中的数据是从游戏开始以来的所有指令
如果不是重连就要判断匹配人数,超过到 g_maxJoinCount
不允许进入,达到g_maxJoinCount
游戏开始,通知客户开始游戏,否则通知客户端正在匹配中
1 | socket.on('timeSync', function(time) { |
socket.on('timeSync', function(time) {...})
收到后立即返回,用于客户端计算延迟,socket.on('message', function(json) {...})
,收到客户端指令后将其放入全局缓存指令,等待每帧处理
1 | socket.on('disconnect', function () { |
socket.on('disconnect', function () {...})
断开连接是否要结束游戏的处理逻辑,如果在玩家断开后还有其他玩家在线,则游戏继续,等待玩家重连回来,否则有些结束
完整的游戏代码就这么多,还是比较清晰的,记住服务器上监听和发送的各种事件,比如 'join'
、'start'
、'message'
、'disconnect'
等,一会再客户端代码分析的时候也会出现,对照着分析逻辑就串起来了
客户端main.js
客户端的代码行数相对多一些,我只把重要的部分列举出来:
1 | // 游戏对象 |
这段代码定义了一个函数,该函数可以用来创建游戏对象(Game Objects)的实例,在 JavaScript 中,函数也可以用来定义对象的构造函数。在这个例子中,函数 GameObject
就是一个构造函数,用于创建具有特定属性和方法的游戏对象。当你使用 new GameObject(id)
来调用这个函数时,它会返回一个新的对象实例,该实例拥有指定的 id
、x
、y
、direction
、speed
属性以及 move()
方法,尽管这段代码中的 GameObject
是一个函数,但它被设计用来创建具有特定属性和行为的对象。
1 | $(function () { |
这是利用jQuery库的写法,等同于 $(document).ready(function() {...})
,表示在 DOM 树构建完成后执行指定的函数,也就是每次加载完页面都会执行这个函数,这个函数里包含了客户端绝大部分逻辑,函数的开始定义了一些变量,用于记录游戏数据,具体含义参考注释即可,之后便初始化了界面显示
1 | // 连接socket |
使用 socket = io.connect('http://127.0.0.1:3000')
语句连接服务器,再接收到 'open'
事件之后,使用服务器同步数据给客户端变量赋值,函数末尾是根据本地存储情况决定是否需要重连,这部分逻辑可以先不看
1 | // 收到游戏开始事件 |
收到游戏开始事件'start'
之后,根据服务器下发的数据创建游戏对象 gameObjects[id] = new GameObject(id)
, 收到'message'
缓存服务器发送的指令到 recvCommands
中,后续在tick函数中处理
1 | // 发送指令 |
监听键盘按键,键盘上下左右键移动方块,回车键停止方块,每次将按键指令发送给服务器,重点看下这段实现,这就涉及到我们前边提到的牛顿第一定律,也就是每次按键时向服务器发送一次,可以改变物体的运动方向或者停止,之后便不再向服务器发消息了
1 | // frame定时器 |
这段代码是客户端的核心逻辑,需要多看几遍,看懂了这一段,帧同步的思想也就基本算掌握了,调用 setInterval
的逻辑比较好理解,之前在服务器代码中也存在,就是启动tick函数,不断积累 dt
,用于做客户端物体移动的表现
重点在 setInterval
我们化繁为简,不用看函数末尾的绘制部分,这一段就是根据GameObject的坐标绘制图形,因为这个项目没有实现UI表现和逻辑分离,所以函数开始更新变量 stepUpdateCounter
的逻辑也没有用,简化完成后函数逻辑就剩下这些:
1 | function update(dt) { |
注意这个参数 dt
很微妙,虽然传入的值是tick实际的时间间隔,但分析完代码你会发现这个dt传入任意值,因为真正调用obj.move
函数进行移动出入的参数是 ms
,当 ms
等于 dt
时,是正常播放,当 ms
> dt
时,是加速播放,当 ms
< dt
时是减速播放,这里的代码只存在加速和正常两种情况
看到没有,你可以通过 scale
变量人为的改变时间的快慢,是不是很神奇,所以在帧同步中绝对顺序是靠帧数来决定的,而物理时间只是一个数字,想快就快,想慢就慢
思考下为什么会有这个判断 if(runningCommands.ms < ms) { ms = runningCommands.ms }
, 它的含义是无论你怎么加速,每个指令执行时间不能超过一帧的时间间隔,不然就和正常播放的逻辑数据不一致了
1 | if(command.direction) { |
这几句比较有意思,翻译过来就是如果命令我改变方向,那么我就改变方向后移动,否则我按照原来的方向移动或者保持静止,再想想是不是惯性定律?
1 | runningCommands.ms = runningCommands.ms - ms |
最后这几句处理的是一个tick跑不完一帧时间间隔的情况,逐个tick改变物体坐标,其实就是一个指令运行一帧的分段表现
好了,写到这里基本上也讲完了,有什么疑问欢迎交流哈,我要睡觉去了
总结
- 严格的帧同步,服务器必须等待所有客户端上报帧数才会下发当前帧命令,会造成一卡全卡
- 乐观锁模式,服务器不等,会定时推帧,卡顿的人在收到数据后,自行加速补帧来追赶上正确的游戏速度
- 一切物体总保持匀速直线运动状态或静止状态,直到有外力迫使它改变这种状态为止
- 在帧同步中绝对顺序是靠帧数来决定的,而物理时间只是一个数字,想快就快,想慢就慢
- 对于UDP丢包问题,上行采用重发3次,下行采用根据网络情况在2次到9次范围内调整
纵有千古,横有八荒,沧海一粟,还妄图超脱三界吗?
2024-4-24 20:47:44