大家久等,本期日志将选择使用状态同步的方式制作io类游戏。依旧是客户端CocosCreator(以下简称ccc)引擎+服务器端Colyseus。
状态同步需要将游戏逻辑再服务器编写,客户端只做展示部分。因此需要大量的服务器端的开发,这里用到的是基于Nodejs的Colyseus,编程语言是TypeScript。
先上截图
github:
服务器端:https://github.com/cyclegtx/colyseus-iog-state-sync
客户端:https://github.com/cyclegtx/cocos2dx-iog-state-sync
状态同步
Colyseus对状态同步支持非常好,有整套的状态同步机制,可以省下很多功夫。具体的可以参考官方文档:
服务器端:http://colyseus.io/docs/api-room-state/
客户端:http://colyseus.io/docs/client-state-synchronization/
简单来说Colyseus将服务器端逻辑都放到Room类之中,Room拥有一个函数setState用于将一个状态结构赋给Room,每当状态发生改变时,Room将改变的数据分发给所有房间中的玩家,在客户端只需监听发生变化的属性即可。
我在Colyseus的基础上抽象出GameRoom,GameState,Entity三个类。
- Entity为游戏中的实体,拥有自己的状态属性。实体可以是出现在游戏画面中的人物,子弹等具体的事物,也可以是玩家名字,积分等不出现在画布中的属性。Entity有唯一的id用于在客户端和服务器端索引,这里使用shortid生成以免重复。
- GameState为房间状态和合集,只有一个变量。entities:用来存储所有的Entity实体,索引为Entity的id。
- GameRoom用于处理游戏逻辑,其中变量state用来存储GameState,每当新建实体的时候只需交将实体实例加入到 state.entities之中。这样Colyseus就会自动处理状态变化并分发到客户端。
客户端监听代码:
//Entity新建,删除等 this.room.listen("entities/:id", this.onEntityChange.bind(this)); //Entity属性发生变化 this.room.listen("entities/:id/:attribute", this.onEntityAttributeChange.bind(this));
其中entities/:id/:attribute entities为GameState的成员变量名称,这里只有entities,:id为Entity的id,:attribute为变化的属性,即Entity的成员变量。我们可以根据id在客户端找到服务器端对应的Entity然后再修改其变化的属性attribute。
这里为服务器端的Entity加一个type变量,用于客户端辨别Entity的类型,为了方便规定客户端将所有的Entity的Prefab放到resources/Entities目录下。当服务器端新建了一个Entity比如玩家发射出的子弹,并将子弹Entity加入到state.entites中;客户端就会收到消息,并根据Entity的type,Bullet去找resources/Entities/Bullet.prefab 如果没有就用默认的resources/Entities/Entity.prefab实例化(cc.instantiate)。客户端抽象了CyEntity类,对服务器端发送的数据做最基础的插值,渲染等处理。
服务器端实体Entity
在服务器端Entity作为实体的抽象,不仅需要存储实体状态,还要处理实体的逻辑。这里为Entity加入了update(dt)函数,在每一帧调用,用于处理实体逻辑。
既然要处理逻辑就免不了使用变量保存一些引用,比如玩家发射的子弹需要用owner变量存储发射者的实体引用。这里需要特别注意,Entity的实例是要加入GameState中,Entity中的成员变量都会被同步到客户端,引用类型的变量很容易造成无限循环,比如玩家类中引用了子弹类,子弹类的owner又引用了玩家类。如果遇到服务器端报错Maximum call stack size exceeded不要慌张检查下实体类中是否存在这种引用变量。如果存在就使用@nosync 将其标记为不进行同步,Colyseus就会忽略此函数。
import { nosync } from "colyseus"; ... export class Bullet extends Entity{ @nosync owner:Character = null; }
这里十分建议为每一个成员变量都默认加上@nosync ,然后再选出有必要同步的变量去掉@nosync ,需要同步的变量尽量为基础类型,引用等类型最好不要设为同步。同步过多的无用变量会浪费宝贵的带宽。
服务器端游戏循环
为了实现逻辑需要在服务器端维护游戏的主循环,可以使用setInterval,但是Colyseus提供了更稳定方法
//以16.6ms (60fps)的间隔访问,update函数 this.setSimulationInterval(this.update.bind(this),16.6); update() { //遍历并运行每个entity的update函数 for (let k in this.state.entities) { this.state.entities[k].update(this.clock.deltaTime); } //更新物理引擎 Engine.update(this.engine, this.clock.deltaTime); }
服务器端物理引擎
服务器端的物理引擎我选择了Matter.js(http://brm.io/matter-js/)。官方文档和案例也比较丰富。使用起来也很简单,只需要在Entity中加入body(Matter.Body),就可以为实体赋予物理效果。当然不是所有实体都需要物理效果,因此派生出PhysicsEntity类用于创建具有物理效果的实体。
Entity //基础实体类 ->PhysicsEntity //物理实体类 ->RectBodyEntity //矩形物理实体类
在物理实体类中加入了两个函数用于处理碰撞
//当碰撞开始 onCollisionStart(entityA: PhysicsEntity, entityB: PhysicsEntity) {} //当碰撞结束 onCollisionEnd(entityA: PhysicsEntity, entityB: PhysicsEntity) {}
body实例过于庞大,不适合网络同步,因此body需要标记@nosync 然后在update函数中将body中需要同步的部分赋值到Entity的变量上,例如位置变量。entity.x = body.position.x
客户端
客户端的工作就简单多了,只需要将实体的外观,动画,声音等,按照服务器端同步过来的状态进行显示就可以了(这里实体的状态用变量action存放,常用的state被Colyseus占用了),跟普通的状态机一样。
客户端发送用户输入到服务器端,只需要发送CMD到服务器,服务器端就会根据用户的sessionId找到用户的控制的Entity,将指令交由Entity处理。
CyStateEngine.room.send({ CMD: "指令名称", value: "指令内容" });
坐标系:
cocos2dx的坐标系跟Matter.js的坐标系(标准屏幕坐标系)不太一样,y轴相反,因此在收到服务器传来的坐标之后要将其y值乘以-1。同样在传给服务器指令的时候,y值也要取反。
为了方便客户端调试,我在CyEntity加入了debug变量,开启后会在客户端显示所有的Entity的大小,位置,状态。白色为静态物理实体,红色为动态物理实体,黄色为非物理实体。
传输优化
状态同步一大劣势就是过于占用带宽,为了减少带宽的消耗,做了以下处理。
服务器端设置同步频率:
在Room设置同步间隔,设置成50ms虽然只相当于20fps,但是在客户端进行插值之后依然可以平滑的移动,达到60fps的效果。如果游戏体验允许的情况下可以设置更大的间隔,以减小同步频率,节省带宽。
this.setPatchRate(50);
服务器端设置同步阈值:
具有物理效果的实体经常会发生微小的位移,是由物理引擎引起的,这种位移小到无法辨识,也没有必要进行同步,因此在update函数中进行赋值的时候可以加上阈值。
//当位移超过阈值时,进行同步,增大阈值以减小频繁同步带来的额流量压力 if(Math.abs(this.x - this.body.position.x) > 0.1){ this.x = this.body.position.x; } if(Math.abs(this.y - this.body.position.y) > 0.1){ this.y = this.body.position.y; } if(Math.abs(this.angle - this.body.angle) > 0.01){ this.angle = this.body.angle; }
客户端减少上传频率:
客户端在上传某些用户指令的时候,比如鼠标移动,不要在mousemove的事件回调之中上传指令,过于频繁。可以将鼠标位置记录到变量中,然后以固定时间间隔判断是否有变化然后再上传。
除了以上几点,还应在设计时尽量减少移动的物体,比如游戏中捡拾的经验豆等,可以将body类型设置成静态,以免与其他物体碰撞导致移动占用同步带宽。
Colyseus文档中说明同步的数据通过MessagePack编码成二进制,并使用Fossil's Delta algorithm算法传输,我没有仔细研究是否还可以继续优化传输数据的大小,看起来传输数据没有使用gzip压缩,不知道gzip压缩对这种小的二进制包压缩效果怎么样。
总结
缺点:
- 根据游戏截图来看,状态同步还是十分流畅的,但是资源消耗还是比帧同步要高出几倍。我使用1核1g,1m带宽的阿里云ECS来运行,跑起4个房间,cpu就占用了15%,带宽就达到150kbps。同等情况下的帧同步,cpu 5% ,70kbps,而且增加房间几乎不会增加cpu占用。显然状态同步需要在服务器端进行游戏循环,而且每增加一个房间就多一个循环,一个服务器可以承载多少玩家变成了需要重点考虑的问题。当然我的代码也没有进行优化,优化过后应该会好很多。
优点:
- 玩家可以随时加入,无需等待匹配其他玩家。
- 一套服务器代码可以在多种终端使用,可以多平台联机。客户端可以是cocos2dx也可以是Unity3D,可以是手机也可以是网页。
下集预告:
继续抽象封装代码,将常用的游戏玩法抽象到服务器端代码。争取做到游戏玩法在服务器端简单设置,游戏画面可以在客户端随意修改。可以给游戏随意更换皮肤,或者自定义游戏角色。
楼主有在做独立游戏 或者微信小游戏吗?
@张海超℡¹⁵⁰⁷⁶²¹⁸¹⁸⁸:是在做啊
我也在用colyseus, 一起交流:)
最近由 samael65535 修改于:2019-04-03 19:00:39目前对 colyseus 感兴趣,希望交流。
楼主是换平台发日志了吗… 想看后续