体验地址
体验地址浏览器F12
打开控制台以手机调试模式效果更佳,模型稍大 耐心等待
源码下载地址
技术栈
- vite
- threejs
- nipplejs
前言
现如今数字化时代,商圈为吸引顾客和增加顾客黏性,能够让人顾客有更好的购物体验和参与感,移动端小游戏应运而生,而今天要介绍的项目就是通过虚实结合制作的小游戏,通过高德提供的地图,结合threejs写一个趣味小游戏,项目主要是通过用户操作手柄控制角色移动,领取任务和做任务,文中只介绍一条任务线,通过手柄控制角色去抓鸡,当然,你也可以做一个寻宝游戏,或者其他类型的游戏。
访问体验地址时,浏览器调为手机模式,效果更佳
游戏流程:领取任务--> 抓鸡 --> 完成任务
准备AMAP地图
src/content/AMapScene.ts
路径下createAMap
方法创建基础地图服务,并返回一个promise
,返回一个地图服务AMap
,地图实例map
,自定义图层customLayer
和自定义坐标系customCoords
。需要注意的是为了能体验最新版本的APIAMAP
和Loca
的版本统一2.0,Loca为数据可视化API。
接下来就是根据map返回的坐标系customCoords
和其他参数创建一个可视化图层,具体参数可以参考ThreeJS 加载模型和路径动画,在createGLCustomLayer
方法中创建AMap.GLCustomLayer
时通过init
创建渲染threejs所需的场景、镜头、渲染器等元素,相机采用PerspectiveCamera
透视相机,需要配合创建map时候的参数 viewMode: '3D',
开启3D视图,默认为关闭,如果不需要用3d视角,可以结合# 正交相机(OrthographicCamera),具体API参考官网文档,渲染器采用WebGLRenderer
,如果想用更强的渲染性能或者光追可以使用WebGPURender
,不过作者没在高德地图上使用过,目前高德地图对threejs的只支持貌似只有r157左右,版本太高会报错。灯光和模型组也都可以在init中创建,render用来循环渲染,当然你也可以将其他需要渲染的内容也加进去,比如tweenjs的渲染和动画的渲染,本项目中添加了定时器的渲染和模型骨骼动画渲染,以callback的方式,在render的方法中进行调用
typescript
var createGLCustomLayer = (map: any, AMap: any, customCoords: any, render: ()=>void): Promise<CreateGLCustomLayerType> => {
return new Promise((resolve) => {
const customLayer = new AMap.GLCustomLayer({
zIndex: 110, // 图层的层级
init: async (gl: any) => {},
render: () => {
map.render(); // 渲染地图
render() // 调用传入的渲染函数
},
});
resolve({
customLayer
})
})
}
调用定时器的渲染和动画的渲染,后续内容会讲到。
typescript
// 渲染函数
const render = () => {
// 渲染小鸡骨骼动画
chickenAnimation && chickenAnimation.upDate()
// 渲染主角骨骼动画
motorAnimation && motorAnimation.upDate()
// 更新定时器
intervalTime && intervalTime.update();
}
加载模型
主角模型采用fbx格式,小鸡模型和商店模型采用gltf格式,尽管不同的格式,但是对于目前项目的使用除了性能问题并没什么区别。实战中尽量用gltf
文件路径src/utils/loaders.ts
封装了各种格式模型的加载器,直接通过加载器可以将模型加载到页面上,拿fbx举例:
文件路径src/content/handleModal.ts
将所有模型一次性加载出来,如果考虑性能问题,可以将模型懒加载到页面,或者直接缓存到indexedDB
中。
typescript
const player = await loadFbx(`${import.meta.env.VITE_ASSETS_URL}assets/model/fbx/Idle.fbx`);
typescript
var position = customCoords.lngLatsToCoords(mapCenter.toArray())[0];
// 加载模型
const { player } = await handleModal();
// 旋转模型
player.rotation.set(Math.PI * 0.5, Math.PI, 0);
// 改变模型位置,将模型添加到地图正中央
player.position.copy(numArr2v3(position));
// 将主角添加到场景
scene.add(player);
主角是自带谷歌动画的,目前没渲染骨骼动画,所以默认T形,模型默认是躺在地上的,这并不是模型有问题,而是高德地图和threejs的坐标系之间的转换问题, scene.add(new THREE.AxesHelper(70)) // 添加坐标轴辅助
可以加载一个辅助线观察一下,在threejs中蓝色代表z轴,而y轴是朝上的,在高德地图中,xy两个轴是平行于地图的,所以修改高度需要调整z轴,在后续的主角移动和旋转都需要注意这个问题。
map中的自定义坐标提供了一个方法可以将经纬度转化成3d世界所有的坐标,根据3d世界的中心点0,0,0和地图世界的中心点mapcenter计算出来的。返回3d世界的xy轴是个数字数组,而numArr2v3
方法是将xy轴数组转为vector3
,其中z轴作为可选参数默认是0。
后续会用相同的方式将糖果屋模型加载到地图上,文章就不赘述了。
动画
文件地址src/utils/animation.ts
封装了一个加载骨骼动画的方法HandleAnimation
,包含采集动画、播放动画、切换动画、播放和切换一次性动画(例如跳、嘲讽、手势)、更新等方法。并且upDate
更新动画支持传入相机,可以使相机跟随主角的移动而跟随主角,在高德地图中并不需要用到。
而高德地图中的镜头跟随,有另一套规则,首先在创建自定义图层时的render中,相机的各种参数包括远近端位置,lookat和矩阵都是从自定义地图方法customCoords.getCameraParams
中赋值的,所以不需要自己去修改相机的参数,只需要单纯的控制地图即可,在后面的人物控制中会讲到。
typescript
const player = await loadFbx(`${import.meta.env.VITE_ASSETS_URL}assets/model/fbx/Idle.fbx`);
player.animations[0].name = 'idle'
const walkP = await loadFbx(`${import.meta.env.VITE_ASSETS_URL}assets/model/fbx/walk.fbx`);
walkP.animations[0].name = 'walk'
const walk_2P = await loadFbx(`${import.meta.env.VITE_ASSETS_URL}assets/model/fbx/walk_2.fbx`);
walk_2P.animations[0].name = 'walk_2'
const talkP = await loadFbx(`${import.meta.env.VITE_ASSETS_URL}assets/model/fbx/talk.fbx`);
talkP.animations[0].name = 'talk'
const pick_up = await loadFbx(`${import.meta.env.VITE_ASSETS_URL}assets/model/fbx/pick_up.fbx`);
这段代码加载了很多主角的动画,包含站立、行走、对话、拾取等,其实如果有技术的话这些动画完全可以放在同一个fbx文件里,接下来修改一下加载主角部分代码,执行主角动画。
加载动画
typescript
...已有的加载模型代码
playerAnimation = new HandleAnimation(player, animations);
playerAnimation.once(['pick_up'])
playerAnimation.play('idle');
...修改主角位置代码
scene.add(playerAnimation.model)
使用HandleAnimation
构造函数创建主角的动画功能,animations
为前文加载的动画fbx模型中包含的动作,调用once
将pick_up拾取动画设置为只执行一次,构造函数中如果切换成once的动作,将在执行完成以后切换为上一个动画,主角默认执行idle
闲置动画,将主角动画添加到场景。
这样我们就将带动画的主角添加到场景中了。
除了闲置动画,前文还加载了两种walk的方法,动画名称为walk是正常速度走路,在没领取任务的时候和距离抓捕目标距离太远的情况下使用,动画名称为walk_2为静悄悄走路,为了真实还原抓鸡的过程,在主角靠近到抓捕目标的一定距离内,都采用这种方式走路。动画名称为pick_up为拾取(这个动画有点问题),当主角与抓捕目标模型有交集的时候(碰撞检测),则播放该动画
控制人物方向
控制人物行走首先需要一个手柄,项目中采用nipplejs
工具类,在屏幕中间生成一个可操作手柄,过多的不介绍,简单说一下提供的几个回调,在文件src/utils/nipple.ts
中封装了创建手柄的方法,接受三个callback,开始移动,移动中,移动结束,三个方法对应主角的不同动画和方法。
开始移动
typescript
// 开始移动玩家
const startPlayer = () => {
playerAnimation.fadeToAction('walk');
run()
}
调用fadeToAction
方法切换为行走动画,并开始移动动画,run方法中主要修改主角位置、方向、高德地图视角、下面在介绍。
移动中
typescript
// 移动玩家
const movePlayer = (data: any) => {
deg = data.angle.degree
}
移动中的回调主要是将当前手柄的角度赋值给全局变量deg,以便在run方法中修改主角方向时使用。
移动结束
javascript
// 停止玩家移动
const endPlayer = () => {
playerAnimation.fadeToAction('idle');
intervalTime.removeIntervalById(intervalId)
}
结束移动,将主角动画切换为空闲,并将移动的定时器删除。这个定时器也是在run方法中定义的
run方法内容解析
主角方向
typescript
const euler = deg2Euler(deg);
// 设置玩家模型的旋转角度
playerAnimation.model.rotation.copy(euler);
前文中movePlayer的回调方法中储存的deg
角度在这里就用到了,将角度转成欧拉角,并赋值给模型的rotation属性,角度转欧拉角需要先用threejs提供的MathUtils.degToRad
工具将角度转为弧度, new Euler(Math.PI * 0.5, radians + Math.PI * 0.5, 0, 'XYZ');
,不仅要沿着y轴方向旋转,还要在主角原有的旋转角度基础上进行计算,这样主角就能保证站立状态旋转。
主角移动
typescript
// 获取玩家当前位置的副本
const copyPos = playerAnimation.model.position.clone()
// 根据欧拉角和移动速度计算新的位置
const offsetPos = euler2Matrix(copyPos, euler, mute ? 0.2 : 0.8)
// 更新玩家模型的位置并将Z轴设置为0
playerAnimation.model.position.copy(offsetPos).setZ(0);
要想计算出主角的下一个位置,需要当前位置、旋转欧拉角、加速度这几个必要条件,主要用到四元数、三维向量、乘积这些数学相关的知识,这里我就不班门弄斧了,大家看代码吧
typescript
// 基于欧拉角改变向量位置
export const euler2Matrix = (originalVector: Vector3, eulerAngles: Euler, distance = 1) => {
// 1. 将Euler转换为Quaternion
const quaternion = new Quaternion().setFromEuler(eulerAngles);
// 2. 创建一个单位方向向量(例如沿Z轴)
const directionVector = new Vector3(0, 0, 1);
// 3. 使用Quaternion旋转方向向量
directionVector.applyQuaternion(quaternion);
// 4. 将旋转后的方向向量乘以距离,并将其加到原始向量上
const movedVector = originalVector.clone().addScaledVector(directionVector, distance);
return movedVector;
}
镜头跟踪
文件src/utils/index.ts
中封装了getViewCp
3d坐标转屏幕坐标、coordsToLngLats
屏幕坐标转高德经纬度,根据前文计算出的主角位置,再通过这两个方法就可以设置地图视角,而设置好的地图视角,又会被自定义图层中的render方法赋值给camera,这样往复循环,便实现了高德地图镜头跟踪方法。
typescript
// 屏幕坐标
const screenPos = new Vector2()
// 获取玩家模型在屏幕上的位置
getViewCp(playerAnimation.model.position.clone(), screenPos, camera);
// 地图坐标
const mapPos = new Vector2();
// 将屏幕坐标转换为地图坐标
coordsToLngLats(screenPos, AMap, map, mapPos);
// 设置地图中心为玩家位置
map.setCenter(mapPos.toArray())
玩家坐标转屏幕坐标---屏幕坐标转高德经纬度---根据经纬度设置mapcenter
模型间交互
在角色抓捕目标或者和npc交互的时候,都需要计算模型之间的交互,下面介绍一下距离判断和模型之间的碰撞检测
判断距离
主角行走过程中,判断主角和抓捕目标的距离,当距离达到一定范围时,切换行走方式
makeTask
方法中获取主角和抓捕目标之间的距离
typescript
// 获取小鸡和玩家的位置
const A = chickenAnimation.model.position.clone()
const B = playerAnimation.model.position.clone()
// 计算小鸡和玩家之间的距离
const distance = A.distanceTo(B);
当distance小于某个距离的时候切换walk_2动画,否则执行walk动画,并设置mute变量,当mute为true时,获取主角下一个位置的时候加速度为0.2,行走速度变慢; 否则为0.8,行走速度变快。
typescript
euler2Matrix(copyPos, euler, mute ? 0.2 : 0.8)
碰撞检测
由于没有添加物理引擎,所以这里的碰撞检测使用包围盒box3,当两个box3有交集的时候视为碰撞
typescript
// 创建一个包围盒并设置为小鸡模型的包围盒
const box = new Box3()
box.setFromObject(chickenAnimation.model);
// 创建一个包围盒并设置为玩家模型的包围盒
const box2 = new Box3()
box2.setFromObject(playerAnimation.model);
box2.intersectsBox(box)
检测使用box3包围盒提供的intersectsBox
API,后续如果要做角色交付任务时与npc的检测也可以使用
小鸡的操作
基于前面的内容,添加一个小鸡模型,收集动作,播放动画,设置位置,这些不再详细介绍,主要介绍一下小鸡的行走路线,小鸡有自己的一套行动路线,并在行走结束的时候播放吃食、闲置等动作
路径规划
typescript
// 规划一条路线并让这个模型沿着这条路线移动,并在移动的过程中正确的展示模型的方向,使模型始终朝着行动的方向
const path = new CatmullRomCurve3([
numArr2v3(customCoords.lngLatsToCoords([120.188767, 30.193832])[0]),
numArr2v3(customCoords.lngLatsToCoords([120.189102, 30.194031])[0]),
numArr2v3(customCoords.lngLatsToCoords([120.189198, 30.193524])[0]),
numArr2v3(customCoords.lngLatsToCoords([120.189371, 30.192999])[0]),
numArr2v3(customCoords.lngLatsToCoords([120.189565, 30.192896])[0]),
numArr2v3(customCoords.lngLatsToCoords([120.189654, 30.192632])[0]),
numArr2v3(customCoords.lngLatsToCoords([120.189746, 30.192867])[0]),
numArr2v3(customCoords.lngLatsToCoords([120.189524, 30.192999])[0]),
numArr2v3(customCoords.lngLatsToCoords([120.189353, 30.193057])[0]),
]);
// 获取路径上的所有点
const points = path.getPoints(800);
创建一条平滑的三维样条曲线,并获取到路径上所有的点位信息,下面这段我用AI写的,大概是循环每一个点位,根据不同点位移动小鸡,结束的时候使用setInterval
,切换不同的动画。
typescript
// 遍历路径上的所有点
points.forEach((p, index) => {
i++
// 设置一个定时器,按照顺序移动小鸡到路径上的每个点
const timeOut = setTimeout(() => {
// 更新小鸡模型的位置
chickenAnimation.model.position.copy(p)
// 获取下一个点
const lookat = points[index + 1];
if (lookat) {
// 计算当前点和下一个点之间的角度
const angle = calculateAngle(p, lookat)
// 设置小鸡模型的旋转角度
chickenAnimation.model.rotation.set(Math.PI * 0.5, Math.PI * 0.5 + angle, 0)
}
if (index >= points.length - 1) {
// 如果到达路径的最后一个点,清除定时器,并切换到Peck动画
clearTimeout(timeOut);
chickenAnimation.fadeToAction('Peck');
chickedMove = false
// 设置一个间隔定时器,循环切换小鸡的动画
setInterval(() => {
j++
if (j % 2 === 0) {
chickenAnimation.fadeToAction('Twerk')
} else if (j % 3 === 0) {
chickenAnimation.fadeToAction('Peck')
} else {
chickenAnimation.fadeToAction('Idle')
}
}, 1000)
}
}, i * 40)
})
结语
这只是一个游戏的操作层面的基础,可以根据高德地图获取周边商家,为每一个商家发布任务,供玩家领取,不同任务对应不同价值优惠券之类的促销活动,需要注意的是对于模型的选择,需要短小精悍的,而不是像本项目中20M的模型,一个动作都500kb,对于手机压力太大了,好在是在高德地图基础上绘制的3d世界,高德地图至少能对性能做些许优化