全部章节vue
这一章主要实现粒子效果、死亡断定以及跌落,因为时间关系而且threejs不是我主攻的方向(仍是要将时间用在正儿八经的事情上),其它的功能实现会在结尾大体分析一下。react
我在调试时内存蹭蹭往上窜,大量对象实例被引用,发现单纯的dispose
效果甚微,得作的完全一点,像这样:github
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
方法来避免内存问题,web
上一章中,咱们列出了小人的几个相关注意点:spring
接下来实现起跳和落地时的特效。segmentfault
继续打开微信跳一跳,撸一把先......api
那么如今粒子类应该具有基本的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
分支再细分一下,打开微信跳一跳撸一撸......
对于状况1,处理起来至关简单,而对于状况2,我们还得再琢磨琢磨
currentProp
的远边缘nextProp
的近边缘nextProp
的远边缘nextProp
的两侧边缘,这取决于盒子的大小和距离如今整理一下
currentProp
的远边缘nextProp
的近边缘nextProp
的远边缘nextProp
的两侧边缘小人完处于半空中,直接让小人垂直下落就行
小人落在盒子边缘,这种状况的跌落须要将动做分解为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()
}
复制代码
如今跌落基本已经实现,但此时的跌落时是能够穿过盒子的,这也是比较麻烦的一点,因为算力有限,这里仅作一个简单的碰撞效果
那么首先得实现一个检测物体碰撞的方法,找来找去仍是得用到射线,而后在网上找到了这个粒子。要用这个方式,首先须要注意一下物体的顶点数量,若是太多的话,那性能就无法看,因此
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的方式),可能包括盒子建立、小人跳上盒子时、小人蓄力时、小人起跳离开时等等......而后游戏内部在不一样时期调用对应的钩子。
大体能想到的就这些了,但愿对你有帮助。