three.js 打造游戏小场景(拾取武器、领取任务、刷怪)

4,702 阅读8分钟

预览

2023-05-11 14.04.11.gif

场景创建

底板创建

创建底板时,需考虑底板的可扩展性,比如修改任意尺寸,所以在创建时,先定义几个基础数据

let len = 100
const size = 1
const y = size / 2

len为底板的尺寸,宽度和高度,size为每一个障碍物的尺寸,y为障碍物体的高度位置,

const textureLoader = new THREE.TextureLoader();
const PlaneSize = len

const geometry = new THREE.PlaneGeometry(PlaneSize, PlaneSize);
const material = new THREE.MeshBasicMaterial({ color: 0xffffff, side: THREE.DoubleSide });
plane = new THREE.Mesh(geometry, material);
plane.rotation.x = Math.PI * 0.5

textureLoader.load('../src/assets/textures/grid.png', function (texture) {
    texture.wrapS = THREE.RepeatWrapping;
    texture.wrapT = THREE.RepeatWrapping;
    texture.repeat.set(len, len);
    plane.material.map = texture;
    plane.material.needsUpdate = true;
});
scene.add(plane)

创建一个平面元素为底板模型,找一个合适的图片作为底板的贴图,上面的代码用的是一个正方形作为基础贴图,方便之后查看,每1个单位含有一个正方形,如下:

image.png

随机障碍

将底板分为两部分,一部分为可行走标记为1,另一部分为不可行走标记为0,横项纵向组成一个二维数组,

[
    [1,0],
    [0,1]
]

0的位置创建一个cube,并添加到场景中,

let flag = 0
let xArr: number[][] = []
for (let i = 0; i < len; i++) {
    let yArr: number[] = []
    for (let j = 0; j < len; j++) {
        const int = getRandomInt(0, len)
        const { x, y: vy } = analysisVector(i, j)

        if (i % int === 0 && j % int === 0) {
            const pos = new THREE.Vector3(x, y / 2, vy)
            const cube2 = cube.clone()
            cube2.position.copy(pos)
            cubeGroup.add(cube2)
            yArr.push(0)
        } else {
            yArr.push(1)
        }
    }
    xArr.push(yArr)
}

套两次循环,i为行j为列,便得到一个二维数组,getRandomInt是定义的随机整数的方法,随机一个数字int,如果ij取余int为0,将这个位置标记为不可行走,并添加一个cube到场景中,循环后得到一个二维数组如下:

image.png

商城后的二维数组先放着,之后会用得到

角色创建

主角创建

定义主角索引和坐标

let playerIndex = new Vector2(gFI(3 / 4), gFI(3 / 4))
let playerPos = analysisVector(playerIndex.x, playerIndex.y)

gFI随机一个索引,analysisVector为解析索引为实际坐标

function gFI(bl: number) {
    return Math.floor(len * bl)
}
// 索引解析 索引转向量
function analysisVector(i: number, j: number): THREE.Vector2 {
    return new THREE.Vector2(-len / 2 + i + (size / 2), -len / 2 + j + (size / 2))
}

之前的二维数组视为索引,从0开始,底板在3D世界中相当于一个多象限的坐标,存在负值,所以需要根据前面定义的底板尺寸len进行转换,如果len为10,索引0,0将转换为-5.5,-5.5,多出来的0.5为格子中心位置,通过前面定义的size得到,

创建主角

startPoint.copy(playerIndex)
// 主角 不带武器
const xbot: any = await loadFbx('./model/Idle.fbx')

xbot.animations[0].name = 'Idle'
// 主角  带武器
WeaponPlayer = await loadFbx('./model/Weapon_Idle.fbx')
WeaponPlayer.animations[0].name = 'Weapon_Idle'
// 加载动作合集
await createObjects()
animations.push(...xbot.animations, ...WeaponPlayer.animations)
// 注册主角
player = new HandleAnimation(xbot, animations)
player.model.scale.set(0.01, 0.01, 0.01)
player.play(isWeapon ? 'Weapon_Idle' : 'Idle')
player.once(['weaponOnce'])

player.model.position.set(playerPos.x, 0, playerPos.y)
// camera.lookAt(playerPos.x, 0, playerPos.y)
playerGroup.add(player.model)

主角的加载和创建,参考之前的文章 three.js——镜头跟踪 ,主要讲一下startPoint.copy(playerIndex)的作用,startPoint为主角的移动起点索引,提供给寻路方法使用,

主角动画

动画分为三个,idle空闲,run跑步,weaponOnce攻击,其中攻击为单次播放,其他均为循环播放, HandleAnimation类封装的是注册动画器,包含动画模型model,切换动画fadeToAction,播放动画play只第一次调用,过滤一次性动画onceplayer.once(['weaponOnce'])如此使用,更新动画upDate,接受一个参数camera或其他Object3D,主要是跟随使用,如果想使镜头跟随模型运动,将镜头传入update方法中即可, player&&player.upDate(camera)如此使用。

属性作用参数
model传入的动画模型-
fadeToAction切换动画动画名称
play播放动画-
once过滤单次动画动画名称合集
upDate更新动画摄像机(非必传)
animations动画合集
createSkeleton创建骨骼-

文档地址 animation.ts

动画的引入

按理说,动画是一个合集,绑定到同一个模型中的,但是咱们现在并没有3D设计师的支持,所以就网上找一些模型动画,拼一个合集

// 跑
await getAnimations('./model/Run.fbx', 'Run')
// 携带武器 空闲
await getAnimations('./model/Weapon_Idle.fbx', 'Weapon_Idle')
// 武器攻击 单次动画
await getAnimations('./model/weaponOnce.fbx', 'weaponOnce')

getAnimations为封装的方法,加载fbx模型,并得到模型中绑定的动画,并收集到animations中,导出animations,给HandleAnimation方法使用

import { loadFbx } from "../../src/ts/loaders"
export const animations: any = []
export const getAnimations = async (url: string, name) => {
    const module = await loadFbx(url)
    if (module.animations[0]) {
        module.animations[0].name = name
        animations.push(module.animations[0])
    }

}

其他角色及障碍物

剑的索引和坐标

let swordIndex = new Vector2(gFI(0.7), gFI(0.5))
let swordPos = analysisVector(swordIndex.x, swordIndex.y)
const sword: any = await loadFbx('./model/sword.fbx')
    const s = sword.children[0]
    const sScale = 0.001
    // 武器
    s.scale.set(sScale, sScale, sScale)
    s.material = new THREE.MeshLambertMaterial({ color: 0x292c33, side: THREE.DoubleSide });
    s.rotation.x = Math.PI
    s.position.set(swordPos.x, 0.8, swordPos.y)
    s.name = 'sword'
    swordGroup.add(s)

武器没有动画,所以只加载出来,给个材质,放在那里就可以了

NPC

npc的索引和坐标

let npcIndex = new Vector2(gFI(0.2), gFI(0.3))
let npcPos = analysisVector(npcIndex.x, npcIndex.y)

敌人

let enemyIndex = new Vector2(gFI(0.1), gFI(0.9))
let enemyPos = analysisVector(enemyIndex.x, enemyIndex.y)

房子

let houseIndex = new Vector2(gFI(0.2), gFI(0.2))
let housePos = analysisVector(houseIndex.x, houseIndex.y)

以上三个模型加载跟主角一致,就不贴代码了,

物体占位

前面定义了一个二维数组,用来标记某个位置是否为可行走路线,像房子,敌人,npc的位置均为不可通过,剑是允许通过,其中这两类位置,都是不可以放置随机障碍物,创建两个数组,存放这两种情况的索引

// 可通过 不可创建box的位置
const passIndex = [swordPos, playerPos]
// 不可通过
const unPassIndex = [...housePosList, npcPos, enemyPos]

将之前的createCube方法改造一下

 const indexV2 = analysisVector(i, j)
const { x, y: vy } = indexV2
const passFlag = passIndex.some((modelIndex: Vector2) => {
    return indexV2.equals(modelIndex)
})
const unPassFlag = unPassIndex.some((modelIndex: Vector2) => {
    return indexV2.equals(modelIndex)
})

if ((i % int === 0 && j % int === 0 && !passFlag) || unPassFlag) {
    if (!unPassFlag) {
        const pos = new THREE.Vector3(x, y / 2, vy)
        const cube2 = cube.clone()
        cube2.position.copy(pos)
        cubeGroup.add(cube2)
    }
    yArr.push(0)
    ......

这样就可以避免在有其他模型的时候 创建多余的障碍物,其中housePosList为房子的占位索引,房子占4个位置

image.png

其他元素创建

npc任务发布图标为一个2d的元素,接取到任务时,隐藏这个图标,npc发布任务和角色完成任务时,显示图标,完成时,将?改为!

const createNpcTask = (pos: Vector3) => {
    var div = document.createElement("div");
    div.classList.add('task')
    div.innerText = '?'
    var task = new CSS2DObject(div);
    task.position.copy(pos);
    task.name = 'task'

    group2D.add(task);
}

提示语创建,比如拾取武器时的提示,

image.png

const cationAlert = (message: string, pos: Vector3) => {
    var div = document.createElement("div");
    div.classList.add('alert')
    div.innerText = message
    console.log(pos)
    var alert = new CSS2DObject(div);
    alert.position.copy(pos);
    group2D.add(alert);

    new TWEEN.Tween(alert.position)
        .to(pos.clone().setY(2), 1000)
        .start()
        .onComplete(() => {
            group2D.remove(alert)
        })
}

创建一个2D对象,并使其向上运动,接受一个提示信息和坐标参数 关于TWEEN参照官网或源码即可,如果有需要,可单独出一篇tween动画使用

至此,所有元素都创建完毕,下面介绍主角行为、寻路、碰撞检测等功能

寻路

寻路还是使用的astar.js 之前文章有专门介绍过 Astar算法基础使用——寻路

astar.js的方法都存在window对象上

image.png

定义一个个全局windows对象

const w = window as any

将之前存放点位的二维数组注册到astar.js中maps = new w.Graph(xArr);

鼠标移动时候获取可行走路径

 window.addEventListener("mousemove", (e) => {
    if (isCanRun) {
        getTrails(e)
    }
});

获取引导线

getTrails参数获取鼠标射线,已底板为目标,获取鼠标位置,再用逆解析,将鼠标所在二维向量转换为x和y的索引,作为终点坐标,起点坐标就设为主角的位置startPoint,再使用astar提供的search方法获取到路径的具体索引值,如果返回的trailPoints数组为空, 视为不可通过,不过添加了closest属性,一般不会出现不可到达的情况,closest属性为当不可到达时,是否返回最近的有效点位。

// 引导线
const getTrails = (event: any) => {
    const raylist = rayMesh(event, [plane])
    if (raylist.length !== 0) {
        const { x, z } = raylist[0].point
        const { x: ex, y: ey } = reverseAnalysisVector(new Vector2(x, z))
        var starPosition = maps.grid[startPoint.x][startPoint.y];
        var endPosition = maps.grid[ex][ey];
        trailPoints = w.astar.search(maps, starPosition, endPosition, {
            closest: true
        });

        const { x: px, y: py } = reverseAnalysisVector(new Vector2(player.model.position.x, player.model.position.z))

        if (trailPoints.length === 0) {
            console.log('不可到达')
            return
        } else {
            trailPoints.unshift(new Vector2(px, py))
            trailIndex = turnPoint(trailPoints)

            drawTrail(trailIndex)
            // player.fadeToAction('walk')
            // run(trailIndex)
        }
    }
}

比如这条可行走路线,返回后面的四个红框点位,第一个点位是unshift起始索引,像这样分段的路径,并不适合后期主角移动的处理,每一个点都会影响主角停顿,并且也增加的动画的计算量,所以需要使用turnPoint方法,只提取拐点,中间的位置将不进行绘制,

精简路径坐标

image.png

提取拐点后存在的点位索引: image.png

提取拐点方法:

// 获取拐点,去掉中间直线部分
function turnPoint(result): any[] {
    let arr: any[] = []
    for (let i = 0, j = 1, l = 2; i < result.length - 2; i++, j++, l++) {
        const that = new THREE.Vector2(result[i].x, result[i].y)
        const next = new THREE.Vector2(result[j].x, result[j].y)
        const last = new THREE.Vector2(result[l].x, result[l].y)
        // console.log(a, b)
        if (i === 0) {
            arr.push({
                time: 1,
                vector2: that
            })
        }
        if (that.x !== last.x && that.y !== last.y) {
            const vector2 = next
            const time = getRunTime(arr[arr.length - 1].vector2, vector2)
            arr.push({
                time, vector2
            })
        }
        if (l === result.length - 1) {
            const vector2 = last
            const time = getRunTime(arr[arr.length - 1].vector2, vector2)
            arr.push({
                time, vector2
            })

        }
    }

    return arr

}

与前一个点相同的方向视为非拐点,方向不同视为拐点,则提取出来

image.png

设置相邻3个点that 1,next 2,last 3

点位1和点位3,x不同相同,y不同,说明中间的那个点位为拐点,可提取,

点位2和点位4,x不同,y相同,则为同一方向,不视为拐点,所以3不提取,

最终提取的点位为[0,2] 0为起始点

最后再判断终点,无论如何,终点是要添加到可提取点位,为了方便主角移动动画,使用getRunTime方法,计算出当前点到下一个点所需要的时间,判断同一方向的距离即可

// 根据两点之间的距离计算出所需时间
function getRunTime(v: Vector2, v1: Vector2): number {
    let time = 0
    if (v.x === v1.x) {
        time = v.y - v1.y
    } else if (v.y === v1.y) {
        time = v.x - v1.x
    }
    return Math.abs(time || 1)
}

绘制引导线

前面trailIndex已经收集到所有的关键点位,drawTrail方法接受一个关键点数组,其中vector2为关键点索引,通过analysisVector方法解析成坐标,收集到linePoints数组中,定义一个线模型const geometry = new LineGeometry();,将linePoints坐标数组setPositions到这个线模型中,再给线模型一个材质,添加到场景中,便绘制好了一条引导线

// 绘制轨迹
function drawTrail(indexList: any) {
    lineGroup.remove(lineGroup.children[0])
    const linePoints: number[] = []
    for (let i = 0; i < indexList.length; i++) {
        const point = indexList[i].vector2
        const vector: Vector2 = analysisVector(point.x, point.y)
        const v3 = new THREE.Vector3(vector.x, 0, vector.y)
        linePoints.push(...v3.toArray())
    }
    const geometry = new LineGeometry();
    if (!linePoints.length) {
        console.log('距离过近')
        return
    }

    geometry.setPositions(linePoints);

    //类型数组创建顶点颜色color数据
    // 设置几何体attributes属性的颜色color属性
    const matLine = new LineMaterial({
        linewidth: 0.01, // 可以调整线宽
        dashed: true,
        opacity: 0.5,
        dashScale: 0.01,
        vertexColors: true, // 是否使用顶点颜色
    });

    let line = new Line2(geometry, matLine);
    lineGroup.add(line);
}

线模型的用法,可以参照之前的文章# threejs 笔记 09 —— 玩一玩 ‘线’

主角移动

前面收集到的trailIndex索引集合为关键点合集,每两个点为一个线段,循环时从第一个(数组第二位)开始循环,当前点为移动的终点,上一个点为移动的起点

let i = 1

const start = trailIndex[i - 1]
const end = trailIndex[i]

每次一段动画完成后i++继续下一段动画,当i >= trailIndex.length 视为所有动画都结束,角色已走到终点,完整代码如下

// 主角动画
let i = 1
async function run() {
    if (trailIndex.length === 0) {
        console.log('距离过近')
        isCanRun = true
        return
    }
    if (i === 1) {
        player.fadeToAction('Run')
    }
    if (i >= trailIndex.length) {
        player.fadeToAction('Idle')
        startPoint.copy(trailIndex[i - 1].vector2)
        i = 1
        isCanRun = true
        return
    }
    const start = trailIndex[i - 1]
    const end = trailIndex[i]

    const endVector = analysisVector(end.vector2.x, end.vector2.y)
    const lookAt = new THREE.Vector3(endVector.x, 0, endVector.y)
    new TWEEN.Tween(start.vector2)
        .to(end.vector2, end.time * 400)
        .start()
        .onUpdate((v: any) => {
            const { x, y } = analysisVector(v.x, v.y)
            player.model.lookAt(lookAt)
            player.model.position.set(x, 0, y)
            camera.lookAt(x, 0, y)

        })
        .onComplete(() => {
            i++
            run()
        })
}

run 方法在mouseup时调用,如果在click调用会出现一个问题,按下后鼠标稍微有点抖动,再抬起鼠标,会和控制器controls的拖拽改变视角功能冲突,所以利用mousedownmouseup结合判断当前鼠标是否有效的点击了底图,

解决鼠标功能冲突

声明一个全局变量

const downMouse = new Vector2()

mousedown时候存一下当前鼠标的点位

 window.addEventListener("mousedown", (e) => {
    downMouse.x = (e.clientX / document.body.offsetWidth) * 2 - 1;
    downMouse.y = -(e.clientY / document.body.offsetHeight) * 2 + 1;
});

mouseup时候对比一下两次鼠标位置,如果不同flag为false,视为无效点击,如果相同,flag为true,则视为有效点击,对比使用的是Vector2equalsapi。

 window.addEventListener("mouseup", (event) => {
    const mouse = new Vector2()
    mouse.x = (event.clientX / document.body.offsetWidth) * 2 - 1;
    mouse.y = -(event.clientY / document.body.offsetHeight) * 2 + 1;
    const flag = mouse.equals(downMouse)

    if (isCanRun && flag) {
        const raylist = rayMesh(event, [plane, npcAni.model])
        if (raylist.length !== 0 && raylist[0].object.name === 'floor') {
            isCanRun = false
            run()
            ......

效果

2023-05-12 16.34.20.gif

碰撞检测(盒模型)

碰撞检测前面的文章说过几种,一种是使用物理引擎ammo.js 这种方式相对比较消耗性能,但是更精准,模型的每一个顶点坐标都会去检测,当然也可以通过自定义形状,减少顶点去优化性能,还有一种是ray射线,从模型单一方向发射一条射线,通过射线检测,判断是否与其他模型相交,弊端是只能做单一方向的,当然,如果每个面都放一个也无所谓,精度也不是很高,优点是相对比较节省性能,这里用到的是box3盒模型,可以检测两个盒模型,点和盒模型之间的碰撞,如果没有精度要求,我认为这个是碰撞检测的首选方案,原理相对简单,围绕模型创建一个box3,与另一个盒模型对比时,判断另一个盒模型的每个顶点是否在当前盒模型范围内即可,废话不多说...

创建盒模型

将前面最开始加载的模型和障碍物都统一放在otherModelGroup组,在主角运动时候,检测是否与其他盒模型有交叉,对于动态模型,需要在render方法更新盒模型,

otherModelGroup.add(houseGroup)
otherModelGroup.add(npcGroup)
otherModelGroup.add(enemyGroup)
otherModelGroup.add(swordGroup);

以怪物为例,创建怪物时,顺带创建一个box3box3Helper

const enemyBox3 = new THREE.Box3()
let enemyBox3Helper: THREE.Box3Helper = new THREE.Box3Helper(enemyBox3, new THREE.Color('red'))

...
enemyAni = new HandleAnimation(enemy, enemy.animations)
enemyBox3.expandByObject(enemyAni.model)
enemyBox3.expandByScalar(1)
enemyBox3Helper = new THREE.Box3Helper(enemyBox3, new THREE.Color('red'))
enemyBox3Helper.name = 'enemyBox3'
helperGroup.add(enemyBox3Helper)
...

expandByScalar可以将box3放大,相当于更大的监测区域

检测盒模型

render函数中 或者在主角移动过程中,对怪物的盒模型进行检测,如果有相交则视为触碰,如果不相交则视为离开怪物区域,render函数调用playerRay方法

const PlayerBox3 = new THREE.Box3()
const playerRay = () => {
    PlayerBox3.expandByObject(player.model)
    const box3Flag = PlayerBox3.intersectsBox(enemyBox3)
    if (box3Flag && isWeapon) {
        if (!enemyCollision) {
            console.log('碰撞怪物', enemyPos)
            enemyCollision = true
        }
    } else {
        enemyCollision = false
    }
    ......
}

intersectsBox是box3提供的检测两个盒模型之间是否相交

.intersectsBox ( box : Box3 ) : Boolean

box - 用来检测是否相交的包围盒

isWeapon字段为是否佩戴武器,enemyCollision标记为触碰到敌人,视为在攻击范围,攻击时,删除敌人,其他模型之间的检测与敌人相同,检测到相交盒模型,并找到盒模型所属的模型,根据标识判断当前触碰的是什么模型,再执行近一步动作。

在这里不再讲其他模型之间的碰撞,感兴趣的同学可以看一下代码,因为是demo,所以很多公共方法没整理,如果将碰撞检测功能抽离出来后,可以做很多有趣的效果,比如主角的跳跃,跳跃后保持在另一个模型的上边,或者飞行游戏的飞机坠毁检测等等...

各环节效果

拾取武器

2023-05-12 17.58.37.gif

接取任务

2023-05-12 18.04.51.gif

欺负小僵尸

2023-05-12 18.10.20.gif

交任务

2023-05-12 18.10.20.gif

项目地址 gitee

历史文章

# Javascript基础之写一个好玩的点击效果

# Javascript基础之鼠标拖拉拽

# three.js 打造游戏小场景(拾取武器、领取任务、刷怪)

# threejs 打造 world.ipanda.com 同款3D首页

# three.js——物理引擎

# three.js——镜头跟踪

# threejs 笔记 03 —— 轨道控制器