关于球球大乱斗
麒麟子一直都想写一个《球球大作战》这样的项目,因为这个项目有几个好处:
- 逻辑简单,非常适合休闲竞技类项目教学
- 虽然逻辑简单,但大厅、匹配、游戏比赛逻辑完整,非常适合作为竞技类项目模板
很久以前就开了《球球大乱斗》这个项目,但由于时间有限,进展缓慢。 最近用 TGX 重构了这个项目,终于在近期完成了,赶上了 618 活动,大家可自取。
不得不说,Cocos Creator 在联机游戏方面的开发效率,真的很不错。
特别是联机调试时,启动多个网页就可以完成组队、对战匹配等测试工作。
发送链接给朋友和同事,就能体验最新版本。
这种丝滑的体验,比 Flash 做页游的时候,还要强好几倍。
《TGX球球大乱斗-多人网络对战游戏模板 - Cocos Store 》
详情可参考文档:【腾讯文档】TGX球球大乱斗在线文档
在线体验:http://game.hzqwing.cn/sticker1/index.html
在制作的过程中,也收获了不少细节经验。有关于匹配的,有关于位置同步的,有关于性能的,有关于效果的等等。
接下来打算抽空写一些分享文章,希望能对大家的网络游戏开发方面有所帮助。
今天的主题
今天我们来分享网络对战游戏中,最常见的问题:位置平滑同步。
平滑同步有两个重要的考量:
- 1、其他玩家的移动要丝滑
- 2、发送数据尽可能少
TGX球球乱斗采用了状态同步方式。接下来,我们从最简单的数据同步开始,一步步讲明白如何让位置同步做到以上目标的。
一、位置同步法
数据同步机制是非常浅显易懂的,流程简化如下:
- 1、每一个玩家控制自己的角色进行移动
- 2、客户端以固定的时间频率发送自己的位置、旋转信息给服务端
- 3、服务端收到信息后,广播给其他玩家
- 4、客户端收到其它玩家的数据后,设置其他玩家对象的位置 、旋转等信息
这个机制浅显易懂,实现起来也方便。但它有两个问题:
-
1、发送的数据量太大
以球球大乱斗为例,每个房间有50个玩家。 也就是说,1个玩家发送同步信息后,需要广播给另外49个玩家。 收发数据量约为同步信息量的 50 倍。
-
2、玩家位置会有抖动现象,即使提高发送频率也无法解决
这个问题,主要是由于网络波动,虽然客户端是以固定频率发送,但服务端并不能按固定频率收到。 当服务端端向其他客户端广播时,接收的客户端也不能按固定频率。
二、插值法
既然抖动问题是收到的数据间隔不一致导致的,那么我们可以在更新其它玩家位置时,进行一个插值操作,相当于一个缓冲作用,在一定程度上,就可以抵销波动带来的影响。具体步骤如下:
1、接收到其他玩家位置信息后,先 push 到一个数组里。
//球球大乱斗 Cell.ts
syncTransform(trans: number[], forceSync: boolean) {
if (forceSync) {
this._transformsCache = [];
this.node.setWorldPosition(trans[0], trans[1], trans[2]);
}
else {
this._transformsCache.push(trans);
}
}
上面代码中,
- trans:数组存放的就是 x,y,z,rotation
- forceSync:用于一些特殊情况(比如重生),服务端需要强制刷新位置。
- _transformsCache:存放了玩家的位置信息。
2、在 update 函数中,以一个固定的插值时长进行插值。
private _lastTweenTimeStart = 0;
private _tweenTime = 0;
protected lateUpdate(dt: number): void {
const LERP_TIME = 100;
if (this._tweenTime > 0 && this._lastTweenTimeStart > 0) {
let percent = (Date.now() - this._lastTweenTimeStart) / this._tweenTime;
//如果超过了 1.0 ,则表示插值完成,直接设置目标点
if (percent > 1.0) {
this._lastTweenTimeStart = 0;
this._tweenTime = (percent - Math.floor(percent)) * LERP_TIME;
//移除插值点
let head = this._transformsCache.shift();
this.node.setWorldPosition(head[0], head[1], head[2]);
}
else {
//进行插值
let head = this._transformsCache[0];
let pos = this.node.worldPosition;
let tx = pos.x * (1 - percent) + head[0] * percent;
let ty = pos.y * (1 - percent) + head[1] * percent;
let tz = pos.z * (1 - percent) + head[2] * percent;
let rot = this.node.eulerAngles;
let tr = rot.z * (1 - percent) + head[3] * percent;
this.node.setWorldPosition(tx, ty, tz);
this.node.setWorldRotationFromEuler(rot.x, rot.y, rot.z);
}
}
if (this._transformsCache.length > 0 && this._lastTweenTimeStart == 0) {
//动态计算_tweenTime,加速处理堆积的情况。
this._tweenTime = LERP_TIME / this._transformsCache.length;
this._lastTweenTimeStart = Date.now();
}
}
上面的代码比较简单,就是从 this._transformsCache
的第一个元素开始,逐个取起来做插值。
比较关键的一句是下面这句:
this._tweenTime = LERP_TIME / this._transformsCache.length;
这段代码使我们可以快速处理堆积的情况,比如切换到后台再回来,等等。
这个方案能够让其他角色在运动时很平滑,但是,他有两个问题。
- 1、由于采用了插值,无法保证每一次插值结尾刚好与目标点对齐(percent = 1.0)。 因此,会出现一顿一顿的现象。
- 2、这个方案依然是基于固定频率广播位置信息,会有大量的同步消息需要发送。
三、连续步长位移法
依然是将接收到的广播位置信息,放到一个数组中待处理。
但这次我们不使用插值,而是使用连续位移法。 可以把 this._transformCache 看成是路径拐点。 我们要做的就是沿着这条线去追即可。
//球球大乱斗 Cell.ts
protected lateUpdate(dt: number): void {
if (!this._transformsCache.length) {
return;
}
//加速处理
let speed = this._transformsCache.length * this.player.speed;
let movement = speed * dt;
while (movement > 0 && this._transformsCache.length > 0) {
let pos = this.node.worldPosition;
let target = this._transformsCache[0];
let dist = this.computeTargetDist(pos.x, pos.y, target[0], target[1]);
if (dist > movement) {
let factor = movement / dist;
let targetX = pos.x * (1 - factor) + target[0] * (factor);
let targetY = pos.y * (1 - factor) + target[1] * (factor);
this.node.setWorldPosition(targetX, targetY, pos.z);
movement = 0;
}
else {
movement -= dist;
this.node.setWorldPosition(target[0], target[1], pos.z);
this._transformsCache.shift();
}
}
}
通过这种方式实现的位置更新,已经没有一顿一顿的了。可以做到非常流畅。 并且依然用了下面这个代码来做加速处理,避免堆积:
let speed = this._transformsCache.length * this.player.speed;
接下来,我们解决,数据量发送太多的问题。
四、流量优化
我们先来算一个数据。
- 假如一个房间,有 50 个玩家玩。
- 玩家每次需要传输的数据 x,y,z,rotation,4 * 4 BYTES * 30 * 50(上行) * 49(下行) = 1,176,000 BYTES = 1.176 MB。
这里的数据,解释一下: - 4:表示有 4 个浮点数
- 4BYTES:每一个浮点数的占 4 字节。
- 30:客户端每秒发送30次。
- 50(上行):有 50 个玩家
- 49(下行):向其他玩家同步
也就是说,每秒会占用 1.176 MB。 如果有 5000 个玩家在线,就是 100 个房间,那么开销是 100.176MB。 实时服务端一般是按峰值计费,这对带宽来说非常不友好。
那么核心优化思路就是:只在移动状态和方向改变的时候,同步一次。
最终的同步步骤为:
- 1、玩家移动、停止、速度改变、方向改变时,客户端向服务端同步消息
- 2、服务端收到消息,向其他客户端广播
- 3、客户端收到服务端消息时,将消息压入队列
- 4、如果队列中累计的里程大于了玩家 1 秒的里程,则强制更新到最新位置
//计算两点间距离
computeTargetDist(x1: number, y1: number, x2: number, y2: number) {
let dx = x1 - x2;;
let dy = y1 - y2;
let dist = Math.sqrt(dx * dx + dy * dy);
return dist;
}
syncTransform(trans: number[], forceSync: boolean) {
if (forceSync) {
this._transformsCache = [];
this.node.setWorldPosition(trans[0], trans[1], trans[2]);
}
else {
this._transformsCache.push(trans);
let dist = 0;
let pos = this.node.worldPosition;
let lastX = pos.x;
let lastY = pos.y;
//统计积累的里程
this._transformsCache.forEach(v => {
dist += this.computeTargetDist(lastX, lastY, v[0], v[1]);
lastX = v[0];
lastY = v[1];
});
//如果超出了一秒的位移距离,则强行同步
if (dist >= this.player.speed) {
this._transformsCache = [];
this.node.setWorldPosition(trans[0], trans[1], trans[2]);
console.log('distance is too far,force sync transform');
}
}
}
5、在 update 中,根据状态更新位置和方向。
protected lateUpdate(dt: number): void {
if (this.player.playerId == GameMgr.inst.selfPlayer.playerId) {
return;
}
if (GameMgr.inst.gameData && GameMgr.inst.gameData.gameState != 'playing') {
return;
}
//如果没有缓存拐点,则朝着运动方向前进。
if (this._transformsCache.length == 0) {
let dirX = Math.cos(this.player.rotation / 180 * Math.PI);
let dirY = Math.sin(this.player.rotation / 180 * Math.PI);
let len = Math.sqrt(dirX * dirX + dirY * dirY);
if (len > 0) {
dirX /= len;
dirY /= len;
}
let pos = this.node.worldPosition;
let targetX = pos.x + dirX * this.player.speed * dt;
let targetY = pos.y + dirY * this.player.speed * dt;
this.node.setWorldPosition(targetX, targetY, pos.z);
}
else {
//如果有拐点,则朝着拐点前进。
let speed = this._transformsCache.length * this.player.speed;
let movement = speed * dt;
while (movement > 0 && this._transformsCache.length > 0) {
let pos = this.node.worldPosition;
let target = this._transformsCache[0];
let dist = this.computeTargetDist(pos.x, pos.y, target[0], target[1]);
if (dist > movement) {
let factor = movement / dist;
let targetX = pos.x * (1 - factor) + target[0] * (factor);
let targetY = pos.y * (1 - factor) + target[1] * (factor);
this.node.setWorldPosition(targetX, targetY, pos.z);
movement = 0;
}
else {
movement -= dist;
this.node.setWorldPosition(target[0], target[1], pos.z);
this._transformsCache.shift();
}
}
}
}
使用了以上方案后。我们就得到了。
- 1、平滑的位移效果。
- 2、轻量的数据发送
五、总结
最后,简单总结一下要点。
- 1、要实现平滑的位移效果,就需要先将目标点缓存起来,再利用 update 做平滑追击更新
- 2、需要做加速处理,防止堆积过多导致位置与服务器相差太远。
- 3、在实现平滑效果的同时,也需要考虑数据传输大小。对于位置等更新数据,要尽可能减少发送和广播次数。
完整代码就去球球大乱斗里翻。
详情可参考文档:【腾讯文档】TGX球球大乱斗在线文档