io类游戏快速开发 3 状态同步 - &大飞

时间:2024-03-10 14:33:45

io类游戏快速开发 3 状态同步

转自:https://cowlevel.net/article/2007725

 

本期日志将选择使用状态同步的方式制作io类游戏。依旧是客户端CocosCreator(以下简称ccc)引擎+服务器端Colyseus。

状态同步需要将游戏逻辑再服务器编写,客户端只做展示部分。因此需要大量的服务器端的开发,这里用到的是基于Nodejs的Colyseus,编程语言是TypeScript。

先上截图

9e9a337bc12e7df3ef0d02d7987f963f.gif

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的基础上抽象出GameRoomGameStateEntity三个类。

  • 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: "指令内容" });

坐标系:

ff291308f0bfafc57f58f0eeb80c3e3e.jpg

cocos2dx的坐标系跟Matter.js的坐标系(标准屏幕坐标系)不太一样,y轴相反,因此在收到服务器传来的坐标之后要将其y值乘以-1。同样在传给服务器指令的时候,y值也要取反。

为了方便客户端调试,我在CyEntity加入了debug变量,开启后会在客户端显示所有的Entity的大小,位置,状态。白色为静态物理实体,红色为动态物理实体,黄色为非物理实体。

6e07011272c5e4ba90b4b05768548ef0.jpg

传输优化

状态同步一大劣势就是过于占用带宽,为了减少带宽的消耗,做了以下处理。

服务器端设置同步频率:

在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,可以是手机也可以是网页。