接上篇日志,打算使用CocosCreator(以下简称ccc)引擎+colyseus,实现帧同步。
服务器端
服务器端比较简单,可以根据colyseus(https://github.com/gamestdio/colyseus)官方文档提示,安装。然后新建rooms/IOGRoom.ts用来处理服务器逻辑。
colyseus已经将房间查询,玩家匹配之类的常见功能实现,我们只需要在IOGRoom.ts里实现游戏逻辑代码即可。主要内容就是维护一个frame_list列表,并以一个固定的频率FRAME_RATE增加当前帧frame_index,并将frame_list中当前帧的数据发送给所有玩家。同时将玩家提交的指令push到frame_list里。
//以固定频率发送当前帧 setInterval(this.tick.bind(this),1000/this.FRAME_RATE); tick(){ let frames = []; frames.push([this.frame_index,this.getFrameByIndex(this.frame_index)]); this.broadcast(["f",frames]); this.frame_index += this.frame_acc; } //接受玩家的输入指令并存入frame_list this.frame_list[this.frame_index].push(data);
客户端连接服务器
colyseus提供了js版本的客户端代码,根据官方的API可以十分方便的使用。我写了一个简易的界面用来创建或者加入房间,代码比较简单可以直接看代码注释。主要代码文件如下:
IOG/colyseus/colyseus.js //colyseus客户端代码 IOG/colyseus/colyseus.d.ts //colyseus TypeScript定义文件 IOG/CyEngine.ts //用来处理服务器链接等 IOG/CyPlayer.ts //用来储存玩家输入等数据
在CyEngine.ts中调用colyseus方法进行加入,创建,接收发送消息等操作:
this.client = new Colyseus.Client(`ws://${this.ip}:${this.port}`); //链接服务器 this.client.getAvailableRooms(this.roomName, function (rooms, err) {}); //获取可以加入的房间列表 this.room = this.client.join(this.roomName); //加入房间 //接受服务器信息 onMessage(message){ switch(message[0]){ case "f": //帧同步信息 this.onReceiveServerFrame(message); break; case "fs": this.onReceiveServerFrame(message); //把服务器帧同步到本地帧缓存后,读取并执行本地帧缓存 this.nextTick(); break; default: console.warn("未处理的消息:"); console.warn(message); break; } } //发送信息到服务器房间 sendToRoom(data:any){ this.room.send(data); }
客户端帧锁定
实现帧同步最重要的是保证所有客户端每一帧计算结果一致,而且当前帧要保证与服务器同步。在服务器端,我们每隔固定时间间隔发送帧信息f,在客户端的onMessage中收到并处理帧信息。在收到帧信息之前需要停止客户端渲染,等待网络接收到新的帧信息之后再进行渲染。
通常的做法是弃用ccc的游戏循环,维护一个新的游戏循环,已达到完全控制游戏循环的目的。这样就可以在等待新的帧信息的时候停止游戏循环中的逻辑处理(物理引擎等)。但是这样做就会完全破坏ccc原有的工作流程,比如cc.Component中的onLoad,start,update,都无法使用,ccc自带的动画,粒子特效等也没有办法继续使用。
所以为了尽量不改变ccc原有的工作流程,我们需要直接控制ccc的游戏循环。好在ccc提供了cc.game.pause()方法来暂停游戏逻辑,然后在接收到服务器的帧信息之后,调用cc.game.step()来运行下一帧,这样我们就可以继续使用Component中的update等回调。
但是这样做有个严重的缺点,就是cc.game.pause()会暂停所有逻辑,包括UI界面等。如果出现网络卡死,连UI界面(比如弹出窗口提示网络断开)都无法显示。所有这里必须得处理好网络多开的情况,在断开网络时及时的恢复游戏循环,cc.game.resume() 。
客户端接收并处理帧信息
客户端接收到服务器端的帧信息之后,将其缓存到frames中,并以服务器设置的时间间隔进行读取并处理帧信息中的玩家输入。将当前帧数记录到frame_index中,每次累加,如果frames[frame_index]为undefined,则等待接受服务器发来的新的帧信息。
注意这里按时间间隔执行的延时执行函数就不能再使用ccc自带的schedule,scheduleOnce了,因为这两个函数因为cc.game.pause()被停止了。我们可以使用原始的setTimeout,setInterval。
同样因为游戏循环暂停而不能使用的还有update(dt)回调中的dt,因为这个时间间隔在不同的客户端因为网速的原因会有很大差别。我们经常使用dt来计算真实的时间,比如技能CD为10s,在帧同步的情况下就不能以时间为单位了,可以使用帧为单位,技能CD为600帧(每秒60帧)。
正常情况下客户端应该以服务器端规定的间隔处理帧信息。但是如果遇到了网络卡顿,客户端缓存了大量的未处理帧,或者玩家是后加入房间的,需要将之前的历史帧全部执行一边,那么使用相同的帧处理速度将会永远追不上最新的帧进度。这里就需要做追帧处理,即以更快的速度处理帧信息,类似快进。
nextTick() { //处理帧信息 this.runTick(); if (this.frames.length - this.frame_index > 100) { //当缓存帧过多时,一次处理多个帧信息 for (let i = 0; i < 50; i++) { this.runTick(); } this.frame_inv = 0; }else if (this.frames.length - this.frame_index > this.serverFrameAcc){ //追帧 this.frame_inv = 0; } else { if (this.readyToControl == false) { this.readyToControl = true; this.round.onReadyToControl(); } //正常速度 this.frame_inv = 1000 / (this.serverFrameRate * (this.serverFrameAcc + 1)); } setTimeout(this.nextTick.bind(this), this.frame_inv) }
当缓存的帧过多的时(>100)我们还可以一次处理多条(50)帧信息,以提高追帧的效率,但是这里处理的过多的话会导致客户端卡死,而且可能导致物理引擎计算结果出现误差,下面会提到。
服务器帧插值
为了减小服务器带宽的压力,服务器发送帧信息的时间间隔不易过短,因为帧信息里只有玩家输入信息,所以50ms(20fps)左右就已经几乎感觉不到输入延时了,但是客户端如果以20fps渲染的话画面还是有明显卡顿的。为了客户端达到60fps,又减小服务器带宽压力(20fps的速度进行同步),我们需要对服务器的帧信息进行插值,即服务器发送的帧号每次增加3(0,3,6帧),客户端接收到后用空数组([])将帧数补充(0,1,2,3,4,5,6帧)。这样就可以达到节省带宽的目的,毕竟输入并不像渲染对延时那么敏感。
发送用户指令到服务器
在客户端中,不能直接在本地修改游戏物体的状态,比如控制人物移动,进行攻击等。因为没有状态同步,所有的本地修改都会导致客户端之间的结果差异。为了保证同步我们需要把用户输入和指令传到服务器,再由服务器以帧信息的信息分发到本地后再进行响应(用户命令->服务器帧->本地客户端响应)。
对于客户端本地的其他玩家是一样逻辑,等待服务器帧信息,然后将帧信息中命令映射到对应的玩家类中CyPlayer。这个过程就相当于在每个玩家的客户端中,维护一个包含所有玩家的输入代理列表,当某个玩家输入命令通过服务器帧信息同步到所有的玩家客户端上时,匹配并映射到本地代理列表中。
runTick() { //处理当前帧信息 ... if (frame.length > 0) { frame.forEach((cmd) => { //将指令映射到函数中 cmd_input if (typeof this["cmd_" + cmd[1][0]] == "function") { this["cmd_" + cmd[1][0]](cmd); } else { console.log("服务器处理函数cmd_" + cmd[1][0] + " 不存在"); } }) } this.frame_index++; //下一帧 cc.game.step(); //进行渲染 ... } cmd_input(cmd) { //在players中匹配到玩家,并更新输入状态 this.players.forEach((p) => { if (p.sessionId == cmd[0]) { p.updateInput(cmd[1][1]) } }) }
在帧信息处理函数里,将玩家输入同步到客户端的CyPlayer里,然后其他组件里(例如CharacterController)中检查并响应CyPlayer的变化。
InputManager和CyPlayer中需要同步的属性可以根据需要随便设置,而且不需要修改服务器代码,十分方便。
客户端随机
为了保证客户端之间的计算结果一致,我们需要使用伪随机函数(线性同余生成器)。
seededRandom(max = 1, min = 0) { this.seed = (this.seed * 9301 + 49297) % 233280; let rnd = this.seed / 233280.0; return min + rnd * (max - min); }
里面的this.seed就是随机种子,在创建房间的时候生成一个种子,分发到玩家手里,就可以保存玩家之间的随机数返回相同的结果。
客户端需要用seededRandom()代替原有的随机函数Math.random()。当然不一定替换所有的,只需替换必要的。比如粒子特效中的随机,没有必要保证所有客户端里的特效都一致,但是玩家出生位置等重要信息就必须使用seededRandom来保证一致性了。
seededRandom返回的结果是有严格的顺序的,在使用此函数获取随机值的时候一定要保证代码执行的顺序,某些函数比如发生碰撞之后的回调,不确定是否是严格按照顺序执行的,目前在测试比较少没有发现不一致的情况。
客户端逻辑
除了上面提到的用户输入,随机函数等,客户端可以使用ccc自带的其他组件。比如动画,Action,粒子特效,物理引擎,碰撞collider等,基本上与单机游戏开发无异。
以下是游戏gif图,
可以看到左侧玩家开始游戏后数秒后,右侧玩家才点击加入进入房间,经过短暂的追帧之后,两边实现了帧同步,之后的拾取,攻击碰撞判定在两个客户端中都没有出现不同步的现象。
物理引擎确定性问题
上面的展示毕竟时间短,情况简单,为了测试复杂情况下的同步问题,我写了简单的AI。
scripts/AIManager.ts scripts/AIController.ts
在执行数秒之后就可以发现肉眼可见的帧不同步的现象
我将追帧的时候一次执行多帧的代码注释之后,情况有了好转
nextTick() { this.runTick(); if (this.frames.length - this.frame_index > 100) { //当缓存帧过多时,一次处理多个帧信息 // for (let i = 0; i < 50; i++) { // this.runTick(); // } this.frame_inv = 0; }else if (this.frames.length - this.frame_index > this.serverFrameAcc){ this.frame_inv = 0; } else { if (this.readyToControl == false) { this.readyToControl = true; this.round.onReadyToControl(); } this.frame_inv = 1000 / (this.serverFrameRate * (this.serverFrameAcc + 1)); } setTimeout(this.nextTick.bind(this), this.frame_inv) }
猜测是追帧的时候执行过快导致box2d执行结果不一致,毕竟box2d并不是确定性的物理引擎,而且不确定性比我现象的要严重的多(也可能是其他原因导致的,毕竟测试的比较少)。
总结
由于ccc自带物理引擎box2d的不确定性,目前还不能完美的帧同步,除非深入调试box2d以确保计算结果的一致。不过如果设计的游戏不需要物理引擎,比如回合制,或者塔防等,目前的代码还是可以胜任。
以下是服务器和客户端的项目github地址,如果有人感兴趣欢迎fork。
客户端项目:https://github.com/cyclegtx/cocos2dx-iog-lockstep-sync
服务器端项目:https://github.com/cyclegtx/colyseus-iog-lockstep-sync
等有时间再研究下物理引擎一致性的问题,下篇文章将使用状态同步尝试开发。
用Photon TrueSync的定点数类翻一套C++的,然后基于定点数做你这些逻辑,应该是可以的。当然,工作量和你放弃lockstep改用状态同步是一样的。
@funcman:嗯工作量有点大,可以使用定点数简单实现点移动碰撞什么的。主要为了帧同步省流量还是值得写一下的,等有空了把。
想要这个游戏的的素材!能分享一下吗
@colodoo:itch上的素材 https://0x72.itch.io/16x16-dungeon-tileset
最近由 83872309 修改于:2018-12-05 14:40:38@83872309:感谢!
@83872309:图片是自己分割的吗!好像下载到的没有配置文件!
@colodoo:自己分割的
博主用的creator是什么版本的啊 我用2.0.7版本好像无法逐步更新
使用pause之后调用step
step会逐步打印 可是update就不会更新了 博主是否还做了其他操作是我没有发现的嘛
export default class Test extends cc.Component {
start () {
cc.game.pause();
setInterval(() => {
console.log('step');
cc.game.step();
}, 1000);
}
update(dt) {
console.log('update');
}
}
@包打听:2.0.2不知道是不是2.0.7改了
@83872309:应该是不行了 看了下源码 pause 会设置_PAUSE为true,step调用的mainloop无法调用到update, 还是要老老实实写更新帧的方法。
@包打听: 那可能有其他的方法,得看看引擎源代码里是怎么处理的,应该也挺好找。