[TGX干货分享]网络游戏中的位置平滑同步方案

关于球球大乱斗

麒麟子一直都想写一个《球球大作战》这样的项目,因为这个项目有几个好处:

  • 逻辑简单,非常适合休闲竞技类项目教学
  • 虽然逻辑简单,但大厅、匹配、游戏比赛逻辑完整,非常适合作为竞技类项目模板

很久以前就开了《球球大乱斗》这个项目,但由于时间有限,进展缓慢。 最近用 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球球大乱斗在线文档

7赞

已入手,非常适合学习

1赞

已经开始学习

1赞

好文!!!!!这才是高质量的论坛。

1赞

已入手,代码质量阔以,适合学习,也很容易维护二开

1赞

image

不好意思,链接给错了。 已修正
https://store.cocos.com/app/detail/6401