当我做了一个网页版的地铁跑酷

2,745 阅读11分钟

背景

由于笔者最近在研究图形学Three.js相关的知识,也是想自己实现一个有趣且有一丢丢难度的demo,所以就决定做一个地铁跑酷的一个游戏,之前也搜过一些资料也没人做过类似地铁跑酷的游戏,所以可以简单的算是全网第一个地铁跑酷网页版游戏了,可以简单看下最后的成果视频

仓库地址:github.com/DanielLin05…

游戏体验地址:subway-surfers-threejs.vercel.app/

20240121204030-convert.gif

玩法介绍

目前只有一些基本玩法,见下图:

image.png

代码结构介绍

主要核心代码还是在controlPlayer模块,大量的碰撞检测、游戏判定等都在这个里面,如果要看实现逻辑就看这个就好啦

未命名文件 (2).png

核心技术实现

无限地图

首先我们需要把我们的环境分组,好让我们能够一组一组的进行添加场景,我是这样进行划分的,下面的蓝框则为一整组场景:

image.png 我们可以发现在地铁跑酷过程当中,角色不倒下那么地图也是永无止境的,那么这个“永无止境”的地图是怎么实现的呢? 方案其实有两个:

  1. 让角色动,去动态添加地图
  2. 让角色原地跑动,动态添加障碍物

而在本项目实现方案是方案1,当我们人物在跑动的时候我们可以算出他跑动的距离 在角色没死的情况下,将每秒跑动的距离相加,就能获取到总跑动路程:

const moveZ = this.runVelocity * delta;
if (!this.frontCollide) {
    if (this.status !== playerStatus.DIE) {
        this.playerRunDistance += moveZ;
        this.model.position.z -= moveZ;
    }
}

在我们获取到角色跑动的路程和每个场景的地板长度,那么就可以很轻松获取到角色占当前板块的百分比 0-100%,在我们人物跑到当前板块的45%距离时候添加下一组板块,这样就形成了无线地图:

注意:添加完新的场景,记得把跑过的板块销毁,不然会随着人物跑动越远板块也无法销毁,内存增加造成掉帧卡死的情况,在本项目还没做这部分,后面会进行完善

// 检查玩家距离
checkPlayerDistance() {
    const ds = this.playerRunDistance;
    // 当前所在的地板块
    const nowPlane = Math.floor(ds / roadLength) + 1;

    // 当前走的路程站总长度的百分比
    // 当到达45%的时候动态添加场景  无限地图
    const runToLength = (ds - roadLength * (nowPlane - 1)) / roadLength;
    if (runToLength > 0.45 && this.currentPlane !== nowPlane) {
        console.log('添加下一个地板');
        this.currentPlane = nowPlane;
        this.environement.z -= roadLength;
        const newZ = this.environement.z;
        // 放置在z轴方向上
        this.environement.setGroupScene(newZ, -5 - nowPlane * roadLength, false);
    }
}

碰撞检测

在本项目里其实最复杂的就是碰撞检测,可能有很多种case,例如:左右碰撞火车回弹、正面大面积碰撞火车、小部分碰撞障碍物(不会终止游戏)等,也尝试过以下碰撞检测方案,最终还是选择了射线碰撞检测,以下说明原因以及实现细节

  1. 八叉树碰撞 ❌
  2. 射线碰撞 ✅
  3. 包围盒碰撞 ❌

方案三包围盒由于人物是骨骼动画会导致包围盒大小获取错误,只能自己给固定大小,也没太深入钻研包围盒碰撞检测的一些方法

项目刚开始选择的是八叉树碰撞,具体八叉树原理这里就不仔细讲解了,在使用的过程中发现Threejs提供给我们暴露出的八叉树碰撞api有点鸡肋,他只能将胶囊体赋值给人物角色,并将胶囊体碰撞检测结果给角色去做对应的操作,并且缺少我们需要的一些功能,当然我们也可以进行魔改八叉树的逻辑但这样往往时间成本以及上手成本会大大提升,例如:

  1. 碰到障碍物,获取障碍物信息
  2. 若八叉树场景有动态(例如火车),不用全局再构建八叉树,而是做局部更新
  3. ……

八叉树检测

在实验八叉树检测后,发现地铁跑酷障碍物过多构建八叉树时长会很久,可能会造成页面卡顿的情况,当然也尝试放在webworker里面去进行计算,但放在webworker里面计算会造成穿模的情况(运动速度大于计算速度,例如下落的时候),最终打算就把八叉树碰撞检测废弃掉了,但是八叉树碰撞检测是这几个碰撞方案里面最精确的,值得夸赞!!可以看当初尝试八叉树的demo:

20240124001648-convert.gif 可以简单大概总结一下八叉树碰撞检测的使用场景(网友也可以自行补充~):

  1. 小场景
  2. 静态物品居多(类似展览馆等)
  3. 碰撞检测精度要求高
  4. ……

射线检测

具体代码可见:github.com/DanielLin05…

我们这里主要讲代码实现思路,不讲具体的代码实现细节

简单来说碰撞检测的部分是用 Three.js 提供的 RayCaster api来实现的, 在三维空间中计算射线碰撞了什么物体

而我们给角色发出不同的向下、向前、向左右的射线,在移动的同时,每一帧去判断射线碰撞到的物体去做对应的操作即可,若下图所示: image.png 例如我们向前的射线如果检测到碰撞则是撞到障碍物,此刻我们人物角色就会停下来这一个行为:

// 如果没发生前面碰撞
if (!this.frontCollide) {
    // 并且人物没死
    if (this.status !== playerStatus.DIE) {
        // 将角色的position位置移动
        this.playerRunDistance += moveZ;
        this.model.position.z -= moveZ;
    }
}

对于射线它如果对当前场景中的所有障碍物进行判断, 会带来大量的计算量, 造成掉帧的现象

所以此时前面提到的动态拼接地图优点就来了,我们只需让射线检测角色当前所在的地板块的障碍物即可,目的就是为了能欧减小计算量,举个例子:人物现在在第一个板块,那么只需要检测第一个板块里面的地板、火车、金币…… 如下图所示

流程图-202401251920.png

解决方法:通过减少计算量来增加效率,对于每一次的碰撞检测来说, 并不需要去检测场景中的所有的障碍物, 而仅需要检测当前角色是否碰撞到周围的障碍物即可, 由于这个无限地图是一个根据一个板块拼接而成,所以我们只需要对角色脚下的当前板块进行判断即可,从而减小计算量

但是在尝试射线碰撞过程中还是踩了很多坑,例如会发生障碍物穿模现象,不知道是那一帧没检测到还是什么原因,有网友可以解答一下,而我在栅栏中间加一块plane材质检测就没啥问题啦,整体来说射线检测精确度 < 八叉树检测

左右碰撞回弹效果

实现效果如下图所示: 20240126230417.gif 如果要实现这个效果,那么一定要先实现最基本的左右移动,实现起来还是比较简单的

在我们按A键和D键的时候,我们此刻需要做这么几个事情

  1. 记录目标位置(例如去左边轨道、右边轨道x轴的值)
  2. 记录原始位置(角色当前所在轨道x轴的值)

下面以按下a键举例子:

// 按下a键
if (key === 'a') {
    if (!this.gameStart || this.status === playerStatus.DIE) {
        return;
    }
    // 位于最左边的道路 也按下a键则失误+1
    if (this.way === 1) {
        this.runlookback = true;
        this.emit('collision');
        showToast('撞到障碍物!请注意!!!');
        setTimeout(() => {
            this.runlookback = false;
        }, 1040);
        this.smallMistake += 1;
        return;
    }
    // 道路分别为1、2、3 三条道路  想左则减一
    this.way -= 1;
    // 后面讲解
    this.originLocation = this.model.position.clone();
    // 原始位置的变量记录
    this.lastPosition = this.model.position.clone().x;
    // 目标位置的变量记录 向左则移动道路宽度的1/3
    this.targetPosition -= roadWidth / 3;
}

到这里已经差不多实现一半了,我们现在拿到这些变量存储关系,我们需要将角色丝滑的从左向右移动并不是向左瞬移一个单位距离,如何实现丝滑的移动呢?我们在浏览器的每一帧去更新角色的位置不就行了吗?所以我们在 window.requestAnimationFrame里面每一帧去判断,如果目标位置做变更了(原本目标位置 === 原始位置),则每一帧去更新角色的位置并重新赋值lastPosition变量,直到lastPositiontargetPosition差值不超过0.0001,则代表到达目标位置

handleLeftRightMove() {
    const targetPosition = this.targetPosition;
    const lastPosition = this.lastPosition;
    if (targetPosition !== lastPosition) {
        // 平滑移动逻辑
        const moveSpeed = 0.15; // 移动速度
        const diff = targetPosition - lastPosition;
        if (Math.abs(diff) > 0.0001) {
            this.model.position.x += diff * moveSpeed;
            this.lastPosition += diff * moveSpeed;
        }
    }
}

在这个基础上实现左右碰撞回弹逻辑就更简单啦,在前面我们讲的射线碰撞判断到有左右碰撞的时候去做处理就行

这时候讲解前面originLocation变量的作用,其实这个作用就是存储角色最原始的值,因为我们在“左右丝滑移动”的时候lastPosition值会发生变动,所以需要额外一个不会变得值去存储角色原本的位置,使得用户能够“正确回弹”到正确位置,当我们检测到有左右检测碰撞时,只需要将originLocation赋值给targetPosition,即可“回弹”

// 左右移动控制
handleLeftRightMove() {
    const targetPosition = this.targetPosition;
    const lastPosition = this.lastPosition;
    if (Math.abs(targetPosition - lastPosition) < 1) {
        this.removeHandle = true;
    }
    if (targetPosition !== lastPosition) {
        // removehandle处理单次碰撞
        // 处理左右碰撞回弹效果
        // 射线检测到左右碰撞则进行回弹处理
        if ((this.leftCollide || this.rightCollide) && this.removeHandle) {
            // 失误+1 作为判断终止比赛的条件
            this.smallMistake += 1;
            this.emit('collision');
            showToast('撞到障碍物!请注意!!!');
            this.targetPosition = this.originLocation.x;
            this.removeHandle = false;
            if (targetPosition > lastPosition) {
                this.way -= 1;
            }
            else {
                this.way += 1;
            }
        }
        // 平滑移动逻辑
        const moveSpeed = 0.15; // 移动速度
        const diff = targetPosition - lastPosition;
        if (Math.abs(diff) > 0.0001) {
            this.model.position.x += diff * moveSpeed;
            this.lastPosition += diff * moveSpeed;
        }
    }
}

向前碰撞检测判定

玩过地铁跑酷的老玩家都知道,当我们在不小心碰撞到障碍物的小面积时,角色会自动跃过障碍物,失误+1,并不会马上终止游戏,这里放了一个原游戏的效果图和自己实现一个大致效果:

实现结果
原游戏自己实现
20240127232520-convert.gif20240127234443.gif

其实这部分实现起来也是比较简单的,我们前面讲解的使用RayCaster进行碰撞检测,如果碰撞到相关物体,这条射线则会返回物体的信息大概是这样的,并且我们需要将这些数据在碰撞的时候存储起来 image.png

// 向前的射线碰撞检测
const r1 = this.raycasterFront.intersectObjects([intersectObstacal, intersectCoin])[0];
this.frontCollideInfo = r1;
c1 ? (this.frontCollide = true) : (this.frontCollide = false);

而在这些信息我们只需要用到碰撞的物体名称以及射线相交点即可(上图框选的点),这些信息我们来干嘛用呢?

拿到这些信息就能计算出角色脚底的射线碰撞到障碍物的点相对于障碍物的占比(y/y1),拿到计算的这个信息就能判断若角色撞击的面积比较小,则算失误+1并不会终止比赛,反之则终止比赛,这也是实现这个功能的重中之重,这句话是什么意思,看下面的示意图: image.png

image.png 在项目里如果撞击面积小于0.75 则直接判定游戏结束,而剩下的0.25则让角色自己去实现一个跳跃逻辑

具体的代码实现如下(放在每一帧去执行该逻辑):

frontCollideCheckStatus() {
    // 若发生前面的碰撞 以及 是否是第一次碰撞
    if (this.frontCollide && this.firstFrontCollide.isCollide) {
        // 撞击物体信息
        const {object} = this.frontCollideInfo;
        // 撞击点位  图中的y
        const {y} = this.frontCollideInfo.point;
        const point = Number(y - 2);
        // 撞击物体总高度  也就是图中的y1
        const obstacal = Number(Obstacal[object.name]?.y);
        // 计算撞击面积百分比
        const locateObstacal = point / obstacal;
        console.log('障碍物', object.name, '障碍物的百分比', locateObstacal);
        this.firstFrontCollide = {isCollide: false, name: object.name};
        // 障碍物撞击面积大于0.75,直接判定游戏结束 播放角色死亡动画
        if (locateObstacal < 0.75) {
            this.status = playerStatus.DIE;
            this.gameStatus = GAME_STATUS.END;
            showToast('你死了!请重新开始游戏!');
            this.game.emit('gameStatus', this.gameStatus);
        }
        else {
            this.fallingSpeed += 0.4;
            this.model.position.y += obstacal * (1 - locateObstacal);
            this.smallMistake += 1;
            this.emit('collision');
            showToast('撞到障碍物!请注意!!!');
            this.firstFrontCollide.isCollide = false;
            setTimeout(() => {
                this.firstFrontCollide.isCollide = true;
            }, 400);
        }

    }
}

未来计划

Bug Fix:

  • 板块衔接处 动作错乱
  • 碰撞检测栅栏 不准确
  • 金币计数不准确
  • 人物视角被遮挡
  • 走过的路程障碍物做销毁

想做的功能:

  • 动态场景(火车、等等)
  • 不规则地图
  • 道具(弹力鞋、吸金币……)

总结

这篇文章就到这啦,感谢大家的观看,由于自己本身还有一些本职工作要做,抽出时间来给大家进行分享,有写的不好的地方见谅,或者有更优的方案也可以给我提~ 有时间会继续更新这个项目,因为还有很多更有趣东西指值得我们去探索以及学习,如果有朋友想知道这些人物角色建模等等开发人员如何去找免费相关素材,会额外单独出一篇文章进行介绍~

游戏体验地址: subway-surfers-threejs.vercel.app/

仓库地址:github.com/DanielLin05…