所有章节
这一章主要实现粒子效果、死亡判定以及跌落,由于时间关系并且threejs不是我主攻的方向(还是要将时间用在正儿八经的事情上),其它的功能实现会在结尾大致分析一下。
我在调试时内存蹭蹭往上窜,大量对象实例被引用,发现单纯的dispose
效果甚微,得做的彻底一点,像这样:
export const destroyMesh = mesh => {
if (mesh.geometry) {
mesh.geometry.dispose()
mesh.geometry = null
}
if (mesh.material) {
mesh.material.dispose()
mesh.material = null
}
mesh.parent.remove(mesh)
mesh.parent = null
mesh = null
}
现在就不怕内存暴涨了。同时,我为每个类实现了destroy
方法来避免内存问题,
实现小人弹跳
上一章中,我们列出了小人的几个相关注意点:
- 他有2个部分,头和身体 √
- 起跳前有一个蓄力过程 √
- 蓄力时盒子有一个受挤压过程 √
- 蓄力时周围有特效(叫什么不清楚)
- 蓄力时身体缩放,头部下移,也就是说身体的部分需要将缩放原点放在小人的脚下 √
- 起跳时盒子有一个回弹动画 √
- 空中有翻转 √
- 空中有残影
- 落地时身体有短暂的缓冲过程 √
- 落地时地面有特效
接下来实现起跳和落地时的特效。
粒子特效
继续打开微信跳一跳,撸一把先......
- 只要不抬起手指,蓄力的特效是一直在运行的,粒子在小人上方的周围出现(不会从底部出现),此时粒子运动轨迹应该是直线向小人的脚下聚拢
- 落地的特效运行短暂时间后就会消失,粒子在小人的下方周围出现(不会从上方出现),运动轨迹应该也是直线,散射的方式。
那么现在粒子类应该具备基本的2个方法: 粒子流
和粒子喷泉
(我随便起的不专业😄),但是呢,要有这2个特效,我们首先得生成粒子。那么
- 粒子位置随机,但需要限制范围
- 粒子的运动轨迹是有终点的,对于
粒子流
效果,终点很明确,我们可以将小人的脚下中心点作为终点(需要算上粒子的高度),而对于粒子喷泉
,无法确定终点在哪,只能大致确定一个向上的方向,所以也可以认为喷出粒子的终点是随机的,但是统一向外侧方向,同时限制喷出粒子的最大行程就行了 - 需要限制一下粒子的数量
然后根据上述分析画了一张大致的垂直截面图:
图一旦画出来,我们又能想到新的问题
粒子流
效果,只要不松开手指是一直运行的,那么这里需要时时刻刻生成新的粒子吗?粒子喷泉
效果,需要生成新的粒子吗?
然后我想到一个比较类似的场景来解决这个问题:就像一个假山公园,假山上有流水、水池中有喷泉,假山的水是一直在流,是什么让它一直流?肯定是有个水泵在起作用,水泵将流下来的水一直往上抽,同时假山上水也一直往下流,形成一个循环,而喷泉也是利用水池中的水,水最终也落到水池中,不考虑水蒸发,水量是固定的。
不如将这个场景映射到我们的问题中,我们首先在小人脚下准备好定量的粒子(水池中定量的水),再准备一个粒子泵(水泵),粒子泵不断的将小人脚下(水池)的粒子往上抽(设置随机位置,假山),同时让被抽上来的粒子继续前往终点(水往下流),然后粒子喷泉
直接复用脚下的粒子(水池中的水),利用粒子喷泉粒子泵将水向上喷,喷完后将它们放进水池(重置到脚下),形成一个循环。同时,这里的粒子系统,应该是跟着小人走的,所以,我们可以将粒子系统作为小人的一部分(添加到一个组中)。
ok,已经有了思路,现在能大致写出Particle
类的结构
class Particle {
constructor ({
world,
quantity = 20, // 粒子数量
triggerObject, // 触发对象
}) {
this.world = world
this.quantity = quantity
this.triggerObject = triggerObject
this.particleSystem = null
}
// 生产定量的粒子
createParticle () {}
// 将粒子放到脚下
resetParticle () {}
// 粒子流粒子泵
runParticleFlowPump () {}
// 粒子流
runParticleFlow () {}
// 粒子喷泉粒子泵
runParticleFountainPump () {}
// 粒子喷泉
runParticleFountain () {}
}
首先生成定量的粒子,这里threejs的粒子我研究了半天,还是不得要领,有幸在网上找到了一个demo,然后我直接参考了它。然后根据观摩微信跳一跳的粒子效果,粒子的颜色应该只有2种,白色和绿色,所以这里设置一半的粒子为白色,一半为绿色。new THREE.TextureLoader().load('xxx.png')
这种方式出问题毫无征兆,应该使用new THREE.TextureLoader().load(require('./dot.png'), callback)
这种形似,或者套一个Promise。
// 生成粒子
createParticle () {
const { quantity, triggerObject } = this
// 一半白色、一半绿色
const white = new THREE.Color( 0xffffff )
const green = new THREE.Color( 0x58D68D )
const colors = Array.from({ length: quantity }).map((_, i) => i % 2 ? white : green)
const particleSystem = this.particleSystem = new THREE.Group()
new THREE.TextureLoader().load(require('./dot.png'), dot => {
const baseGeometry = new THREE.Geometry()
baseGeometry.vertices.push(new THREE.Vector3())
const baseMaterial = new THREE.PointsMaterial({
size: 0,
map: dot,
// depthTest: false, // 开启后可以透视...
transparent: true
})
colors.forEach(color => {
const geometry = baseGeometry.clone()
const material = baseMaterial.clone()
material.setValues({ color })
const particle = new THREE.Points(geometry, material)
particleSystem.add(particle)
})
this.resetParticle()
triggerObject.add(particleSystem)
})
}
然后将粒子放到小人脚下,需要注意的是,如果这里直接将粒子放到脚下,小人空翻时能被看到,所以需要藏起来。这里约定一个粒子的最大大小值initalY
// 将粒子放到小人脚下
resetParticle () {
const { particleSystem, initalY } = this
particleSystem.children.forEach(particle => {
particle.position.y = initalY
particle.position.x = 0
particle.position.z = 0
})
}
现在,我们已经将定量的粒子生成并放入初始位置了,接下来实现粒子泵,粒子泵的作用就是将脚下的粒子随机放到小人的上方周围(将水往上抽),那么这里的随机值就需要考虑一个范围,并且不能将粒子随机在小人的身体中,这里从分析的第一张图就可以看出来。那现在我们以小人的身高胖瘦为准,约定粒子的随机位置为小人上半身周围,同时以小人的宽度为准限制水平方向的范围。同理,约定喷泉的粒子随机位置为小人的下半身周围,粒子大小为粒子流
的一半(观测比粒子流的小),最大喷射距离(行程)为小人身高的一半。
constructor ({
world,
quantity = 20, // 数量
triggerObject // 触发对象
}) {
this.world = world
this.quantity = quantity
this.triggerObject = triggerObject
this.particleSystem = null
const { x, y } = getPropSize(triggerObject)
this.triggerObjectWidth = x
// 限制粒子水平方向的范围
this.flowRangeX = [-x * 2, x * 2]
// 粒子流,垂直方向的范围,约定从小人的上半身出现,算上粒子最大大小
const flowSizeRange = this.flowSizeRange = [x / 6, x / 3]
this.flowRangeY = [y / 2, y - flowSizeRange[1]]
// 粒子初始的y值应该是粒子大小的最大值
this.initalY = flowSizeRange[1]
// 粒子喷泉,垂直方向的范围,约定从小人的下半身出现,算上粒子最大大小
const fountainSizeRange = this.fountainSizeRange = this.flowSizeRange.map(s => s / 2)
this.fountainRangeY = [fountainSizeRange[1], y / 2]
this.fountainRangeDistance = [y / 4, y / 2]
// 限制粒子水平方向的范围
this.fountainRangeX = [-x / 3, x / 3]
}
既然约定好了安全值,现在就来实现粒子流粒子泵逻辑
// 粒子流粒子泵
runParticleFlowPump () {
const { particleSystem, quantity, initalY } = this
// 粒子泵只关心脚下的粒子(水池)
const particles = particleSystem.children.filter(child => child.position.y === initalY)
// 脚下的粒子量不够,抽不上来
if (particles.length < quantity / 3) {
return
}
const {
triggerObjectWidth,
flowRangeX, flowRangeY, flowSizeRange
} = this
// 比如随机 x 值为0,这个值在小人的身体范围内,累加一个1/2身体宽度,这样做可能有部分区域随机不到,不过影响不大
const halfWidth = triggerObjectWidth / 2
particles.forEach(particle => {
const { position, material } = particle
const randomX = rangeNumberInclusive(...flowRangeX)
const randomZ = rangeNumberInclusive(...flowRangeX)
// 小人的身体内,不能成为起点,需要根据正反将身体的宽度加上
const excludeX = randomX < 0 ? -halfWidth : halfWidth
const excludeZ = randomZ < 0 ? -halfWidth : halfWidth
position.x = excludeX + randomX
position.z = excludeZ + randomZ
position.y = rangeNumberInclusive(...flowRangeY)
material.setValues({ size: rangeNumberInclusive(...flowSizeRange) })
})
}
现在粒子流的泵已经准备好了,我们进一步实现粒子流的效果,打开微信跳一跳,撸几把......
应该能发现粒子除了是直线运动,也是匀速的(就算不是匀速,也将它处理成匀速吧),也可以先不关心速度,这里还需考虑些东西,那就是粒子流是一直运行的(只有不松开手指),然后到达脚下的粒子也是在不断的被重置位置并开始向脚下移动,所以这里我们没有办法使用Tweenjs来控制动画,因为不晓得粒子流会运行多久,那么这里唯一能参考的就只有时间了,我们可以根据时间流失(时间差)的多少来确定粒子应该走多远,然后约定一个粒子的固定速度,那么配合requestAnimationFrame这个api
// 约定一个固定速度,每毫秒走多远
const speed = triggerObjectWidth * 3 / 1000
const prevTime = 0
const animate = () => {
if (prevTime) {
const diffTime = Date.now() - prevTime
// 粒子的行程
const trip = diffTime * speed
}
prevTime = Date.now()
requestAnimationFrame(animate)
}
现在我们能算出粒子的行程,那么算出粒子下一次的坐标也就简单了,根据当前的视角画一张图来理解:
在每一个帧时,根据上一次的坐标和终点算出上一次粒子离小人脚下的距离,同时根据时间差和速度能算出粒子本次应该走多远,然后用相似三角形的特性,我们就能算出z'、x'、y'
,也就是粒子的新位置。同时,粒子流还需要有一个停止的方法,用来在松开手指时终止
// 粒子流
runParticleFlow () {
if (this.runingParticleFlow) {
return
}
this.runingParticleFlow = true
const { world, triggerObjectWidth, particleSystem, initalY } = this
let prevTime = 0
// 约定速度,每毫秒走多远
const speed = triggerObjectWidth * 3 / 1000
const animate = () => {
const id = requestAnimationFrame(animate)
if (this.runingParticleFlow) {
// 抽粒子
this.runParticleFlowPump()
if (prevTime) {
const actives = particleSystem.children.filter(child => child.position.y !== initalY)
const diffTime = Date.now() - prevTime
// 粒子的行程
const trip = diffTime * speed
actives.forEach(particle => {
const { position } = particle
const { x, y, z } = position
if (y < initalY) {
// 只要粒子的y值超过安全值,就认为它已经到达终点
position.y = initalY
position.x = 0
position.z = 0
} else {
const distance = Math.sqrt(Math.pow(x, 2) + Math.pow(z, 2) + Math.pow(y - initalY, 2))
const ratio = (distance - trip) / distance
position.x = ratio * x
position.z = ratio * z
position.y = ratio * y
}
})
world.stage.render()
}
prevTime = Date.now()
} else {
cancelAnimationFrame(id)
}
}
animate()
}
// 停止粒子流
stopRunParticleFlow () {
this.runingParticleFlow = false
this.resetParticle()
}
现在,不出意外,粒子流效果已经实现了,在小人蓄力阶段去触发它,然后松开手指时停止它。接下来我们实现粒子喷泉相关逻辑。首先,粒子喷泉粒子泵也是直接使用小人脚下的粒子,根据我的观摩,喷泉的粒子数量要稍微少一些
// 粒子喷泉
runParticleFountain () {
if (this.runingParticleFountain) {
return
}
this.runingParticleFountain = true
const { particleSystem, quantity, initalY } = this
// 粒子泵只关心脚下的粒子(水池)
const particles = particleSystem.children.filter(child => child.position.y === initalY).slice(0, quantity)
if (!particles.length) {
return
}
const {
triggerObjectWidth,
fountainRangeX, fountainSizeRange, fountainRangeY
} = this
const halfWidth = triggerObjectWidth / 2
particles.forEach(particle => {
const { position, material } = particle
const randomX = rangeNumberInclusive(...fountainRangeX)
const randomZ = rangeNumberInclusive(...fountainRangeX)
// 小人的身体内,不能成为起点,需要根据正反将身体的宽度加上
const excludeX = randomX < 0 ? -halfWidth : halfWidth
const excludeZ = randomZ < 0 ? -halfWidth : halfWidth
position.x = excludeX + randomX
position.z = excludeZ + randomZ
position.y = rangeNumberInclusive(...fountainRangeY)
material.setValues({ size: rangeNumberInclusive(...fountainSizeRange) })
})
// 喷射粒子
this.runParticleFountainPump(particles, 1000)
}
现在,实现粒子喷泉粒子泵,它的逻辑和粒子流粒子泵的逻辑类似,坐标计算方法都是一样的,不同的地方是由于粒子喷泉的粒子各有各的终点,需要将终点记录起来(可以用userData属性),而且粒子喷泉不需要终止方法,只需要注意一下,如果当前粒子喷泉还没有结束时触发了粒子流,则立即停止粒子喷泉,让粒子流看起来有一个连贯的效果。然后粒子喷泉应该是在落地时触发
// 粒子喷泉粒子泵
runParticleFountainPump (particles, duration) {
const { fountainRangeDistance, triggerObjectWidth, initalY, world } = this
// 随机设置粒子的终点
particles.forEach(particle => {
const { position: { x, y, z } } = particle
const userData = particle.userData
userData.ty = y + rangeNumberInclusive(...fountainRangeDistance)
// x轴和z轴 向外侧喷出
const diffX = rangeNumberInclusive(0, triggerObjectWidth / 3)
userData.tx = (x < 0 ? -diffX : diffX) + x
const diffZ = rangeNumberInclusive(0, triggerObjectWidth / 3)
userData.tz = (z < 0 ? -diffZ : diffZ) + z
})
let prevTime = 0
const startTime = Date.now()
const speed = triggerObjectWidth * 3 / 800
const animate = () => {
const id = requestAnimationFrame(animate)
// 已经在脚下的粒子不用处理
const actives = particles.filter(particle => particle.position.y !== initalY)
if (actives.length && !this.runingParticleFlow && Date.now() - startTime < duration) {
if (prevTime) {
const diffTime = Date.now() - prevTime
// 粒子的行程
const trip = diffTime * speed
actives.forEach(particle => {
const {
position,
position: { x, y, z },
userData: { tx, ty, tz }
} = particle
if (y >= ty) {
// 已经到达终点的粒子,重新放到脚下去
position.x = 0
position.y = initalY
position.z = 0
} else {
const diffX = tx - x
const diffY = ty - y
const diffZ = tz - z
const distance = Math.sqrt(Math.pow(diffX, 2) + Math.pow(diffY, 2) + Math.pow(diffZ, 2))
const ratio = trip / distance
position.y += ratio * diffY
position.x += ratio * diffX
position.z += ratio * diffZ
}
})
world.stage.render()
}
prevTime = Date.now()
} else {
this.runingParticleFountain = false
cancelAnimationFrame(id)
}
}
animate()
}
现在,粒子效果终于完成了。整个功能其实还有很多待考虑的地方,这里主要只是针对小人实现,如果后续需要做的更通用一点,可以优化一下。
残影
我按照这个官方例子,尝试了很久,就是看不到一丢丢残影🤮,估计是哪个不太明显的地方用法不对,残影之后有时间再实现,如果朋友们有此经验,可以在下方留言提示一下,感激不尽。
死亡判定
前面已经实现大部分游戏逻辑,此时的游戏中,小人能随意跳跃,并且不管从什么位置起跳,下一次它总是跃向下一个盒子,同时在小人跳跃之前我们就已经算出落地点,所以,这里的死亡判定只需要判断落地点是否在盒子上就ok了,那么直接使用threejs相关的api为Prop
类实现一个containsPoint
方法
// 检测点是否在盒子内
containsPoint (x, y, z) {
const { body } = this
// 更新包围盒
body.geometry.computeBoundingBox()
// 更新盒子世界矩阵
body.updateMatrixWorld()
// 点的世界坐标,y等于盒子高度,这里需要-1
const worldPosition = new THREE.Vector3(x, y - 1, z)
const localPosition = worldPosition.applyMatrix4(new THREE.Matrix4().getInverse(body.matrixWorld))
return body.geometry.boundingBox.containsPoint(localPosition)
}
现在小人的jump
方法中可以确定落地后的状态
if (nextProp.containsPoint(jumpDownX, propHeight, jumpDownZ)) {
// 跃向当前盒子
// 生成新盒子、移动场景......
} else if (!currentProp.containsPoint(jumpDownX, propHeight, jumpDownZ)) {
// gameOver
}
但是......这个方法只对立方体有效,如果是圆柱体就没法用了,所以这里不能直接使用包围盒来检测。既然不能用包围盒,那就自己算呗,由于死亡是统一在一个高度判定的,所以可以简化为计算一个点是否在平面内,即落地点是否在盒子的顶部平面,也就是说,只需要知道当前盒子是立方体还是圆柱体,然后分别处理一下就能算出来点是否在盒子上了。由于我没有找到判断当前盒子类型的方法,并且BufferGeometry
经过clone
之后也无法使用instanceof
来判断是否是BoxBufferGeometry
或者CylinderBufferGeometry
,所以,我在通用的立方体中使用了userData
属性
// 立方体
export const baseBoxBufferGeometry = new THREE.BoxBufferGeometry(1, 1, 1, 10, 4, 10)
baseBoxBufferGeometry.userData.type = 'box'
// 圆柱体
export const baseCylinderBufferGeometry = new THREE.CylinderBufferGeometry(1, 1, 1, 30, 5)
baseCylinderBufferGeometry.userData.type = 'Cylinder'
现在,将containsPoint
方法改造一下
containsPoint (x, z) {
const { body } = this
const { type } = body.geometry.userData
const { x: sx, z: sz } = this.getSize()
const { x: px, z: pz } = this.getPosition()
if (type === 'box') {
const halfSx = sx / 2
const halfSz = sz / 2
const minX = px - halfSx
const maxX = px + halfSx
const minZ = pz - halfSz
const maxZ = pz + halfSz
return x >= minX && x <= maxX && z >= minZ && z <= maxZ
} else {
const radius = sx / 2
// 小人脚下中心点离圆心的距离
const distance = Math.sqrt(Math.pow(px - x, 2) + Math.pow(pz - z, 2))
return distance <= radius
}
}
跌落
如果需要实现跌落效果,我们需要将gameOver
分支再细分一下,打开微信跳一跳撸一撸......
- 小人完全处于半空中
- 小人处于盒子边缘
- 小人同时跨越2个盒子
对于情况1,处理起来相当简单,而对于情况2,咱们还得再琢磨琢磨
- 小人落在当前方向
currentProp
的远边缘 - 小人落在当前方向
nextProp
的近边缘 - 小人落在当前方向
nextProp
的远边缘 - 它也有可能落在当前方向
nextProp
的两侧边缘,这取决于盒子的大小和距离
现在整理一下
- 小人完全出于半空中
- 小人落在盒子边缘
- 小人落在
currentProp
的远边缘 - 小人落在
nextProp
的近边缘 - 小人落在
nextProp
的远边缘 - 小人落在
nextProp
的两侧边缘
- 小人落在
- 小人同时跨越2个盒子
小人完处于半空中,直接让小人垂直下落就行
小人落在盒子边缘,这种情况的跌落需要将动作分解为3个,一个是旋转,一个是向下位移,然后是想外位移。对于这个过程,我拿着我那包快抽完的软白沙烟盒在电脑桌上开启了我的小实验,思考了良久之后,我决定将效果实现的比较贴近自然一点,但是在实现过程中,碰到了比较麻烦的东西(数学太弱了),之后仔细体会了微信跳一跳的处理方式,发现他们其实也并没有想将这些细节做得尽善尽美,毕竟这只是整个游戏中的一个不起眼的小插曲。所以我也就退一步用简单的方式实现,或者熟悉物理引擎的朋友们也可以考虑物理引擎。用一张图来描述一下我的简单思路。
首先,确定一下支撑点(图中红点),然后让小人沿着支撑点旋转90度,接着将小人着地。在跌落之前,得先让小人以统一的姿势站好(不然算起来太麻烦),也就是说,假设此游戏中小人的正前方是Z轴
方向,不做处理时,若跌落的方向不是Z轴
就需要计算出3个方向的角度和位移值,反之如果将小人旋转到当前的跌落方向,我们就能统一以小人的本地坐标系来实现动效。那现在约定小人的正前方是Z轴
,通过调整Y轴
角度后,统一调整小人X轴
值向下旋转,调整Z轴
值让小人向下跌落,调整Y轴
值让小人在跌落过程中向外侧偏移
- 要达到这种目的,首先得算出小人沿
Y轴
旋转的角度,让小人面朝跌落方向 - 需要算出小人脚下中心点到支撑点的距离,用来设置小人的旋转原点
现在将之前的containsPoint
方法改造一下:
/**
* 计算跌落数据
* @param {Number} width 小人的宽度
* @param {Number} x 小人脚下中心点的X值
* @param {Number} z 小人脚下中心点的Z值
* @return {
* contains, // 小人中心点是否在盒子上
* isEdge, // 是否在边缘
* translateZ, // 将小人旋转部分移动 -translateZ,将网格移动translateZ
* degY, // 调整小人方向,然后使用小人的本地坐标进行平移和旋转
* }
*/
computePointInfos (width, x, z) {
const { body } = this
if (!body) {
return {}
}
const { type } = body.geometry.userData
const { x: sx, z: sz } = this.getSize()
const { x: px, z: pz } = this.getPosition()
const halfWidth = width / 2
// 立方体和圆柱体的计算逻辑略有差别
if (type === 'box') {
const halfSx = sx / 2
const halfSz = sz / 2
const minX = px - halfSx
const maxX = px + halfSx
const minZ = pz - halfSz
const maxZ = pz + halfSz
const contains = x >= minX && x <= maxX && z >= minZ && z <= maxZ
if (contains) {
return { contains }
}
const translateZ1 = Math.abs(z - pz) - halfSz
const translateZ2 = Math.abs(x - px) - halfSx
// 半空中
if (translateZ1 >= halfWidth || translateZ2 >= halfWidth) {
return { contains }
}
// 计算是否在盒子的边缘
let isEdge = false
let degY = 0
let translateZ = 0
// 四个方向上都有可能
if (x < maxX && x > minX) {
if (z > maxZ && z < maxZ + halfWidth) {
degY = 0
} else if (z < minZ && z > minZ - halfWidth) {
degY = 180
}
isEdge = true
translateZ = translateZ1
} else if (z < maxZ && z > minZ) {
if (x > maxX && x < maxX + halfWidth) {
degY = 90
} else if (x < minX && x > minX - halfWidth) {
degY = 270
}
isEdge = true
translateZ = translateZ2
}
return {
contains,
translateZ,
isEdge,
degY
}
} else {
const radius = sx / 2
// 小人脚下中心点离圆心的距离
const distance = Math.sqrt(Math.pow(px - x, 2) + Math.pow(pz - z, 2))
const contains = distance <= radius
if (contains) {
return { contains }
}
// 半空中
if (distance >= radius + halfWidth) {
return { contains }
}
// 在圆柱体的边缘
const isEdge = true
const translateZ = distance - radius
let degY = Math.atan(Math.abs(x - px) / Math.abs(z - pz)) * 180 / Math.PI
if (x === px) {
degY = z > pz ? 0 : 180
} else if (z === pz) {
degY = x > px ? 90 : 270
} else if (x > px && z > pz) {
} else if (x > px && z < pz) {
degY = 180 - degY
} else if (z < pz) {
degY = 180 + degY
} else {
degY = 360 - degY
}
return {
contains,
translateZ,
isEdge,
degY
}
}
}
然后,就能根据这个方法实现跌落的效果了,首先改造一下小人的jump
方法,增加一个落地后的回调,在回调中判断是否死亡,如果没有死亡,则执行缓存效果并生成新的道具继续游戏,反之,根据计算出的结果让小人跌落。
// 跳跃
jump () {
const {
stage, body,
currentProp, nextProp,
world: { propHeight }
} = this
const duration = 400
const start = body.position
const target = nextProp.getPosition()
const { x: startX, y: startY, z: startZ } = start
// 开始游戏时,小人从第一个盒子正上方入场做弹球下落
if (!currentProp && startX === target.x && startZ === target.z) {
// ...
} else {
if (!currentProp) {
return
}
const { bodyScaleSegment, headSegment, G, world, width } = this
const { v0, theta } = this.computePowerStorageValue()
const { rangeR, rangeH } = computeObligueThrowValue(v0, theta * (Math.PI / 180), G)
const { jumpDownX, jumpDownZ } = computePositionByRangeR(rangeR, start, target)
// 水平匀速
// ...
// y轴上升段、下降段
const rangeHeight = Math.max(world.width / 3, rangeH) + propHeight
const yUp = animate(
{
from: { y: startY },
to: { y: rangeHeight },
duration: duration * .65,
easing: TWEEN.Easing.Cubic.Out,
autoStart: false
},
({ y }) => {
body.position.setY(y)
}
)
const yDown = animate(
{
from: { y: rangeHeight },
to: { y: propHeight },
duration: duration * .35,
easing: TWEEN.Easing.Cubic.In,
autoStart: false
},
({ y }) => {
body.position.setY(y)
},
() => yDownCallBack()
)
yUp.chain(yDown).start()
// 空翻
this.flip(duration)
// 从起跳开始就回弹
currentProp.springbackTransition(500)
// 落地后的回调
const yDownCallBack = () => {
const currentInfos = currentProp.computePointInfos(width, jumpDownX, jumpDownZ)
const nextInfos = nextProp.computePointInfos(width, jumpDownX, jumpDownZ)
// 没有落在任何一个盒子上方
if (!currentInfos.contains && !nextInfos.contains) {
// gameOver 游戏结束,跌落
console.log('GameOver')
this.fall(currentInfos, nextInfos)
} else {
bufferUp.onComplete(() => {
if (nextInfos.contains) {
// 落在下一个盒子才更新场景
// 落地后,生成下一个方块 -> 移动镜头 -> 更新关心的盒子 -> 结束
world.createProp()
world.moveCamera()
this.currentProp = nextProp
this.nextProp = nextProp.getNext()
}
// 粒子喷泉
this.particle.runParticleFountain()
// 跳跃结束了
this.jumping = false
}).start()
}
}
// 落地缓冲段
const bufferUp = animate(
{
from: { s: .8 },
to: { s: 1 },
duration: 100,
autoStart: false
},
({ s }) => {
bodyScaleSegment.scale.setY(s)
}
)
}
}
接下来根据前面的分析实现跌落方法fall
。
// 跌落
fall (currentInfos, nextInfos) {
const {
stage, body,
world: { propHeight }
} = this
let degY, translateZ
if (currentInfos.isEdge && nextInfos.isEdge) {
// 同时在2个盒子边缘
return
} else if (currentInfos.isEdge) {
// 当前盒子边缘
degY = currentInfos.degY
translateZ = currentInfos.translateZ
} else if (nextInfos.isEdge) {
// 目标盒子边缘
degY = nextInfos.degY
translateZ = nextInfos.translateZ
} else {
// 空中掉落
return animate(
{
from: { y: propHeight },
to: { y: 0 },
duration: 400,
easing: TWEEN.Easing.Bounce.Out
},
({ y }) => {
body.position.setY(y)
stage.render()
}
)
}
// 将粒子销毁掉
this.particle.destroy()
const {
bodyRotateSegment, bodyScaleSegment,
headSegment, bodyTranslateY,
width, height
} = this
const halfWidth = width / 2
// 将旋转原点放在脚下,同时让小人面向跌落方向
headSegment.translateY(bodyTranslateY)
bodyScaleSegment.translateY(bodyTranslateY)
bodyRotateSegment.translateY(-bodyTranslateY)
bodyRotateSegment.rotateY(degY * (Math.PI / 180))
// 将旋转原点移动到支撑点
headSegment.translateZ(translateZ)
bodyScaleSegment.translateZ(translateZ)
bodyRotateSegment.translateZ(-translateZ)
let incrementZ = 0
let incrementDeg = 0
let incrementY = 0
// 第一段 先沿着支点旋转
const rotate = animate(
{
from: {
degY: 0
},
to: {
degY: 90
},
duration: 500,
autoStart: false,
easing: TWEEN.Easing.Quintic.In
},
({ z, degY }) => {
bodyRotateSegment.rotateX((degY - incrementDeg) * (Math.PI / 180))
incrementDeg = degY
stage.render()
}
)
// 第二段 跌落,沿z轴下落,沿y轴向外侧偏移
const targZ = propHeight - halfWidth - translateZ
const fall = animate(
{
from: {
y: 0,
z: 0
},
to: {
y: halfWidth - translateZ,
z: targZ,
},
duration: 300,
autoStart: false,
easing: TWEEN.Easing.Bounce.Out
},
({ z, y }) => {
headSegment.translateZ(z - incrementZ)
bodyScaleSegment.translateZ(z - incrementZ)
bodyRotateSegment.translateY(y - incrementY)
incrementZ = z
incrementY = y
stage.render()
}
)
rotate.chain(fall).start()
}
现在跌落基本已经实现,但此时的跌落时是可以穿过盒子的,这也是比较麻烦的一点,由于算力有限,这里仅做一个简单的碰撞效果
- 第一段跌落过程中碰到前方盒子时,立即停止。如果是从立方体跌落到立方体,这里停止没啥大毛病,但是如果有圆柱体参与的话,效果看起来比较尴尬,讲道理应该会向旁边跌落,不过时间有限先就这样了😂
- 第二段跌落过程中碰到前方盒子时(也就是头碰到了盒子),让脚着地。圆柱体同样的问题
那么首先得实现一个检测物体碰撞的方法,找来找去还是得用到射线,然后在网上找到了这个粒子。要用这个方式,首先需要注意一下物体的顶点数量,如果太多的话,那性能就没法看,所以
- 需要适当的调整一下物体的分段数(包括小人和道具),如图高度分段数不要设置太多,可以从图中理解,绿点代表一个顶点。
- 如下图,尽量将不需要比较的顶点过滤掉,判断图中小人是否与红色盒子相撞时,只会涉及到内侧(小人面前的这一侧)的顶点,并且如果要进一步优化,以下图来说,只会涉及到小人跌落路径上的盒子内侧中间区域的一部分顶点,其余的所有顶点都是干扰。
- 针对第2点,这里只做了最简单的处理,以上图为例子,仅过滤掉红色盒子所有顶点中
Z
值大于0的顶点(差不多取一半)。😄其实是可以算出盒子某一侧的顶点的,并且也可以算出小人的路径经过的那部分顶点,如果这样做了,那就是几十倍的优化,因为在动画requestAnimationFrame
过程中,大量计算很容易造成卡顿。 - 要过滤顶点,需要确定小人的跳跃方向
X轴
或者Y轴
(世界坐标系),还需需要知道小人坠落的方向(基于方向的前后),比如像图中一样倒向红色盒子,需要过滤掉红色盒子距离小人远端的顶点,若倒向的是绿色盒子,则需要过滤掉绿色盒子离小人远端的顶点。
下面,根据上面的分析,将射线检测方法改造一下
/**
* 获取静止盒子的碰撞检测器
* @param {Mesh} prop 检测的盒子
* @param {String} direction 物体过来的方向(世界坐标系)
* @param {Boolean} isForward 基于方向的前后
*/
export const getHitValidator = (prop, direction, isForward) => {
const origin = prop.position.clone()
const vertices = prop.geometry.attributes.position
const length = vertices.count
// 盒子是静止的,先将顶点到中心点的向量准备好,避免重复计算
const directionVectors = Array.from({ length })
.map((_, i) => new THREE.Vector3().fromBufferAttribute(vertices, i))
.filter(vector3 => {
// 过滤掉一部分盒子离小人远端的顶点
if (direction === 'z' && isForward) {
// 从当前盒子倒向目标盒子
return vector3.z < 0
} else if (direction === 'z') {
// 从目标盒子倒向当前盒子
return vector3.z > 0
} else if (direction === 'x' && isForward) {
return vector3.x < 0
} else if (direction === 'x') {
return vector3.x > 0
}
})
.map(localVertex => {
const globaVertex = localVertex.applyMatrix4(prop.matrix)
// 先将向量准备好
return globaVertex.sub(prop.position)
})
return littleMan => {
for (let i = 0, directionVector; directionVector = directionVectors[i]; i++) {
const raycaster = new THREE.Raycaster(origin, directionVector.clone().normalize())
const collisionResults = raycaster.intersectObject(littleMan, true)
// 发生了碰撞
if(collisionResults.length > 0 && collisionResults[0].distance < directionVector.length() + 1.2 ){
return true
}
}
return false
}
}
接下来,将fall
方法完善一下,增加碰撞检测
// 跌落
fall (currentInfos, nextInfos) {
const {
stage, body, currentProp, nextProp,
world: { propHeight }
} = this
// 跳跃方向
const direction = currentProp.nextDirection
let degY, translateZ,
validateProp, // 需要检测的盒子
isForward // 相对方向的前、后
if (currentInfos.isEdge && nextInfos.isEdge) {
// 同时在2个盒子边缘
return
} else if (currentInfos.isEdge) {
// 当前盒子边缘
degY = currentInfos.degY
translateZ = currentInfos.translateZ
validateProp = nextProp
isForward = true
} else if (nextInfos.isEdge) {
// 目标盒子边缘
degY = nextInfos.degY
translateZ = nextInfos.translateZ
// 目标盒子边缘可能是在盒子前方或盒子后方
if (direction === 'z') {
isForward = degY < 90 && degY > 270
} else {
isForward = degY < 180
}
validateProp = isForward ? null : currentProp
} else {
// 空中掉落
return animate(
{
from: { y: propHeight },
to: { y: 0 },
duration: 400,
easing: TWEEN.Easing.Bounce.Out
},
({ y }) => {
body.position.setY(y)
stage.render()
}
)
}
// 将粒子销毁掉
this.particle.destroy()
const {
bodyRotateSegment, bodyScaleSegment,
headSegment, bodyTranslateY,
width, height
} = this
const halfWidth = width / 2
// 将旋转原点放在脚下,同时让小人面向跌落方向
headSegment.translateY(bodyTranslateY)
bodyScaleSegment.translateY(bodyTranslateY)
bodyRotateSegment.translateY(-bodyTranslateY)
bodyRotateSegment.rotateY(degY * (Math.PI / 180))
// 将旋转原点移动到支撑点
headSegment.translateZ(translateZ)
bodyScaleSegment.translateZ(translateZ)
bodyRotateSegment.translateZ(-translateZ)
let incrementZ = 0
let incrementDeg = 0
let incrementY = 0
let hitValidator = validateProp && getHitValidator(validateProp.body, direction, isForward)
// 第一段 先沿着支点旋转
const rotate = animate(
{
from: {
degY: 0
},
to: {
degY: 90
},
duration: 500,
autoStart: false,
easing: TWEEN.Easing.Quintic.In
},
({ degY }) => {
if (hitValidator && hitValidator(body.children[0])) {
rotate.stop()
hitValidator = null
} else {
bodyRotateSegment.rotateX((degY - incrementDeg) * (Math.PI / 180))
incrementDeg = degY
stage.render()
}
}
)
// 第二段 跌落,沿z轴下落,沿y轴向外侧偏移
const targZ = propHeight - halfWidth - translateZ
const fall = animate(
{
from: {
y: 0,
z: 0
},
to: {
y: halfWidth - translateZ,
z: targZ,
},
duration: 300,
autoStart: false,
easing: TWEEN.Easing.Bounce.Out
},
({ z, y }) => {
if (hitValidator && hitValidator(body.children[0])) {
fall.stop()
// 稍微处理一下,头撞到盒子的情况
const radian = Math.atan((targZ - z) / height)
if (isForward && direction === 'z') {
bodyRotateSegment.translateY(-height)
body.position.z += height
body.rotateX(-radian)
} else if (direction === 'z') {
bodyRotateSegment.translateY(-height)
body.position.z -= height
body.rotateX(radian)
} else if (isForward && direction === 'x') {
bodyRotateSegment.translateY(-height)
body.position.x += height
body.rotateZ(radian)
} else if (direction === 'x') {
bodyRotateSegment.translateY(-height)
body.position.x -= height
body.rotateZ(-radian)
}
stage.render()
hitValidator = null
} else {
headSegment.translateZ(z - incrementZ)
bodyScaleSegment.translateZ(z - incrementZ)
bodyRotateSegment.translateY(y - incrementY)
incrementZ = z
incrementY = y
stage.render()
}
}
)
rotate.chain(fall).start()
}
到这里,跌落和碰撞就差不多实现完成了,还有很大的瑕疵,所以,如果朋友你看到这里觉得不太友好的话,暂时很抱歉。若后续我需要更多的涉及到threejs,我再来优化它🙏。
未实现功能分析
加分
这个效果和粒子效果类似,创建后将它们添加到小人的组合中,需要的时候亮出来就行。
中心点提示、落地波纹
这个中心点在全局只需要创建一个,然后在需要时显示它,波纹可能就是中心点扩散的效果。
停留加分
在盒子上停留加分这种功能估计需要支持外部自定义,提供给外部加分的api,但是由于外部不知道停留多久,所以还得通过一种方式告诉外部小人在盒子上的整个生命周期过程,既然这样,那就干脆支持一下外部定义盒子的生命周期(类似vue、react的方式),可能包括盒子创建、小人跳上盒子时、小人蓄力时、小人起跳离开时等等......然后游戏内部在不同时期调用对应的钩子。
大致能想到的就这些了,希望对你有帮助。