全部章节前端
搞了三年前端,呆过几个不大的公司,作过几个不大的项目。三年来心态略显浮躁,昔日的朋友早已练就大佬之身,而我却在原地停留了好久好久。因为在前段时间离职,因此近期正在备试恶补,弄得日夜颠倒,已分不清白天黑夜。强行灌输老是那么枯燥,而且我那该死的记忆力太不争气,左脑进右脑出,因此找点有意思的事情(临摹个小游戏)给本身找找刺激!git
表达能力有限,文笔又差,若是有不少病句还请海量......github
因为本文只是尝试对微信跳一跳进行一次深刻的临摹,和原游戏确定还存在很大的差距,而且首次使用threejs,因此本解析仅做为一个简单的向导,但愿能对你有些做用,若是哪里有不妥的地方读者能够自由发挥。算法
万字多图长文预警!!!spring
本章源码已放github,这是示例,这是一个半成品,到此时尚未写完,过几天再发完整版编程
微信跳一跳,这个游戏刚出的时候,本身在闲暇时间写过一个很是简单的版本,自觉得接下来就很简单了,但毫无疑问那只是没有丝毫起伏的波澜,这一次重写让我踩了好几个坑plus。看似风平浪静的水面,你要是不下水,就不知道水下有多少暗流涌动。canvas
考虑具体实现以前,咱们首先得了解一部分与本游戏相关的threejs的知识api
1、threejs三大组件缓存
场景(Scene)安全
const scene = new THREE.Scene()
// 坐标辅助线,在调试阶段很是好用
scene.add(new THREE.AxesHelper(10e3))
复制代码
相机(Camera),这里重点关注正交相机,游戏实现将使用它。
正交相机
const camera = new THREE.OrthographicCamera(left, right, top, bottom, near, far)
// 将正交相机放入场景中
scene.add(camera)
复制代码
正交相机看物体的大小与它和物体之间的距离没有关系,远近皆同样大。好比你固定视野的范围为宽高比:200x320,最远能看到1000米内的物体,最近能看到1米之外的物体,那么:
const camera = new THREE.OrthographicCamera(-200 / 2, 200 / 2, 320 / 2, -320 / 2, 1, 1000)
复制代码
透视相机咱们用不到
渲染器(Renderer)
const renderer = new THREE.WebGLRenderer({
antialias: true // 抗锯齿
})
// 具体渲染
renderer.render(scene, camera)
复制代码
2、建立物体
首先是你须要什么形状的物体?几何形状(Geometry)
,物体的外观是什么样的?材质(Material)
,而后建立它网格(Mesh)
。你须要看到物体吗?灯光(Light)
3、物体阴影
接收阴影的物体,好比建立一个地面来接收阴影
const geometry = new THREE.PlaneBufferGeometry(10e2, 10e2, 1, 1)
const meterial = new THREE.MeshLambertMaterial()
const plane = new THREE.Mesh(geometry, meterial)
// 接收阴影
plane.receiveShadow = true
复制代码
物体开启投影
// 建立一个立方体
const geometry = new THREE.BoxBufferGeometry()
const meterial = new THREE.MeshLambertMaterial()
const box = new THREE.Mesh(geometry, meterial)
// 投射个人影子
box.castShadow = true
// 别人的影子也能够落在我身上
box.receiveShadow = true
复制代码
光源开启阴影
// 平行光
const lightght = new THREE.DirectionalLight(0xffffff, .8)
// 投射阴影
light.castShadow = true
// 定义可见域的投射阴影
light.shadow.camera.left = -400
light.shadow.camera.right = 400
light.shadow.camera.top = 400
light.shadow.camera.bottom = -400
light.shadow.camera.near = 0
light.shadow.camera.far = 1000
复制代码
场景也须要开启阴影
const const renderer = new THREE.WebGLRenderer({ ... })
renderer.shadowMap.enabled = true
复制代码
4、threejs的变换原点
旋转(rotation)、缩放(scale)的原点是网格(Mesh)
中心点,画来一张图来描述:
也就是说,能够经过位移几何形状(Geometry)
达到控制缩放原点的目的,除此以外,threejs中还有组(Group)
,那么若是对一个组内物体进行缩放操做,对应的就是经过控制组内物体的位置来控制物体的缩放原点
5、threejs的优化
// 建立一个立方体,大小默认为 1,1,1
const baseBoxBufferGeometry = new THREE.BoxBufferGeometry()
// 克隆几何体
const geometry = baseBoxBufferGeometry.clone()
// 经过缩放设置几何体的大小
geometry.scale(20, 20, 20)
复制代码
dispose
既然要分析如何开始,那么就须要先将手机拿出来,把微信跳一跳多撸几把先熟悉下地形
对于这种不知道从何开始的状况,咱们首先必需要找到一个切入点(好比必需要先作什么),而后根据这个切入点层层展开,直至揭开这个游戏的面纱,这有点相似编程界常常冒出的一个词面向过程式
,不怎么高大上,但很实用。
场景的建立很简单,也就是threejs的三大组件。须要注意的是,场景有多大?其实我不知道...
打开微信跳一跳撸几把......,别忘了仔细观察!!!
实际上是没法肉眼肯定场景是多大的,可是能肯定在场景中应该使用什么相机。没错,正交相机,这个从微信跳一跳的界面应能很清晰的感受到,物体大小和远近没有关系,这里2张图片直观的展现了正交相机和透视相机的区别。
那解决方法就显而易见了,咱们只须要本身定义一个场景大小,而后将里面的物体大小相对场景大小取一个合适的范围就好了,canvas
的宽高有点像视觉视口,场景大小有点像布局视口,而后将布局视口缩放至视觉视口大小。假设图中物体宽度是场景宽度的一半,若是我设置场景宽度为1000
,那么我绘制物体时将宽度设置为500
就行了,或者也能够定义其它尺寸,考虑微信跳一跳是全屏并适应不一样手机的,咱们使用innerWidth、innerHeight
设置场景大小。
既然要用正交相机,也肯定了场景大小,那也就是肯定了正交相机的视锥体的宽高,而后近端面和远端面合理就行,这取决于相机角度。咱们建立相机,并将相机位置设置为-100,100,-100
,让X轴
和Z轴
在咱们前方,缘由就是以后移动的时候能够没必要用负数坐标(辅助线的方向是正向)
const { innerWidth, innerHeight } = window
/** * 场景 */
const scene = new THREE.Scene()
// 场景背景,用于调试
scene.background = new THREE.Color( 0xf5f5f5 )
// 坐标辅助线,在调试阶段很是好用
scene.add(new THREE.AxesHelper(10e3))
/** * 相机 */
const camera = new THREE.OrthographicCamera(-innerWidth / 2, innerWidth / 2, innerHeight / 2, -innerHeight / 2, 0.1, 1000)
camera.position.set(-100, 100, -100)
// 看向场景中心点
camera.lookAt(scene.position)
scene.add(camera)
/** * 盒子 */
const boxGeometry = new THREE.BoxBufferGeometry(100, 50, 100)
const boxMaterial = new THREE.MeshLambertMaterial({ color: 0x67C23A })
const box = new THREE.Mesh(boxGeometry, boxMaterial)
scene.add(box)
/** * 渲染器 */
const canvas = document.querySelector('#canvas')
const renderer = new THREE.WebGLRenderer({
canvas,
alpha: true, // 透明场景
antialias:true // 抗锯齿
})
renderer.setSize(innerWidth, innerHeight)
// 渲染
renderer.render(scene, camera)
复制代码
这些过程都比较简单,没什么难的,可是发现盒子是个纯黑的,只能看到一点点轮廓,这是由于没有光线照射,如今给它一丢丢光线...
/** * 平行光 */
const light = new THREE.DirectionalLight(0xffffff, .8)
light.position.set(-200, 600, 300)
// 环境光
scene.add(new THREE.AmbientLight(0xffffff, .4))
scene.add(light)
复制代码
如今咱们看到了这个盒子的颜色,也有了相应的轮廓,咱们的第一步完成了,嘿嘿嘿。可是少了点什么?
打开微信跳一跳一顿琢磨......
看完发现盒子的影子在哪呢?
根据阴影的必要条件,咱们首先须要建立一个地面,用来接收盒子等物品的阴影,这个地面做为整个游戏全部物体的阴影接收者。
const planeGeometry = new THREE.PlaneBufferGeometry(10e2, 10e2, 1, 1)
const planeMeterial = new THREE.MeshLambertMaterial({ color: 0xffffff })
const plane = new THREE.Mesh(planeGeometry, planeMeterial)
plane.rotation.x = -.5 * Math.PI
plane.position.y = -.1
// 接收阴影
plane.receiveShadow = true
scene.add(plane)
复制代码
与此同时
// 让物体投射阴影
box.castShadow = true
// 让平行光投射阴影
light.castShadow = true
// 定义可见域的投射阴影
light.shadow.camera.left = -400
light.shadow.camera.right = 400
light.shadow.camera.top = 400
light.shadow.camera.bottom = -400
light.shadow.camera.near = 0
light.shadow.camera.far = 1000
// 定义阴影的分辨率
light.shadow.mapSize.width = 1600
light.shadow.mapSize.height = 1600
// 场景开启阴影
renderer.shadowMap.enabled = true
复制代码
ok,阴影出现了。可是,能够发现白色的地面没有彻底撑满相机的可视区,露出了地面之外的场景,这确定是不能接受的。咱们指望的效果应该是地面铺满整个可视区,为何发生这种状况?(即便此时将地面设置的很是大)
写到后面时,无心中在threejs文档看到阴影材质(ShadowMaterial),能够将地面的材质更换为这个,而后为场景设置一个背景色。
一张相机右侧的垂直截面,能够发现,当咱们以场景中心点固定一个垂直方向角度∠a
的时候,相机和地面的距离y
是有范围限制的,当y
小于minY
时,将出现可视区下边的空白区,大于maxY
的时候会出现上边的空白区。此时咱们经过调整相机远近就能够解决这种空白问题。
同时,很容易看出minY
能够经过∠a
和正交相机的下侧面高度算出来
const computeCameraMinY = (radian, bottom) => Math.cos(radian) * bottom
复制代码
对于maxY
,能够先算出场景中心点到视锥体远截面的垂直距离,而后就能获得近截面到场景中心点的距离,就能算出最大的maxY
了
const computeCameraMaxY = (radian, top, near, far) => {
const farDistance = top / Math.tan(radian)
const nearDistance = far - near - farDistance
return Math.sin(radian) * nearDistance
}
复制代码
固定垂直方向角度的状况下,相机的y
值范围肯定好了,那么水平方向有范围限制吗?根据上图能够发现,只要y
值正常,水平的坐标x
和z
应该是由y
和水平方向的夹角决定的。
因此咱们还须要肯定一个水平方向的角度,不妨以X轴
来肯定它,固定水平方向角度为∠b
为了方便理解,上图是以225
度画出来的。如今:
∠a
,计算出y
(能够取一个区间内的值)∠b
,计算出x
、z
/** * 根据角度计算相机初始位置 * @param {Number} verticalDeg 相机和场景中心点的垂直角度 * @param {Number} horizontalDeg 相机和x轴的水平角度 * @param {Number} top 相机上侧面 * @param {Number} bottom 相机下侧面 * @param {Number} near 摄像机视锥体近端面 * @param {Number} far 摄像机视锥体远端面 */
export function computeCameraInitalPosition (verticalDeg, horizontalDeg, top, bottom, near, far) {
const verticalRadian = verticalDeg * (Math.PI / 180)
const horizontalRadian = horizontalDeg * (Math.PI / 180)
const minY = Math.cos(verticalRadian) * bottom
const maxY = Math.sin(verticalRadian) * (far - near - top / Math.tan(verticalRadian))
if (minY > maxY) {
console.warn('警告: 垂直角度过小了!')
}
// 取一个中间值靠谱
const y = minY + (maxY - minY) / 2
const longEdge = y / Math.tan(verticalRadian)
const x = Math.sin(horizontalRadian) * longEdge
const z = Math.cos(horizontalRadian) * longEdge
return { x, y, z }
}
复制代码
感谢兴趣的朋友能够本身尝试一下,将函数中的y
设置成minY,maxY
区间以外的值,就会出现前面讨论的问题。
地面的大小范围就不用纠结了,咱们知道相机视锥体的范围是多大,因此尽量将地面的大小设置的稍微大一点就好了
如今地面应该能彻底展现在可视区了,而后撸一把微信跳一跳,大体肯定一下游戏的摄像机位置
const { x, y, z } = computeCameraInitalPosition(35, 225, offsetHeight / 2, offsetHeight / 2, 0.1, 1000)
camera.position.set(x, y, z)
复制代码
按照如今的设置,会警告垂直角度过小了,这时能够根据刚刚的分析将相机的远截面调大一些
const camera = new THREE.OrthographicCamera(-innerWidth / 2, innerWidth / 2, offsetHeight / 2, -offsetHeight / 2, 0.1, 2000)
const { x, y, z } = computeCameraInitalPosition(35, 225, offsetHeight / 2, offsetHeight / 2, 0.1, 2000)
camera.position.set(x, y, z)
复制代码
能够发现此时的盒子只有一半露出了地面,咱们须要将它放到地平面上,由于在微信跳一跳中生成新的盒子的时候,是由上方掉落的,有一个物体弹球下落的动画过程,那么咱们这样作:
box.translateY(15)
复制代码
有问题吗?这须要在玩游戏的时候观察的仔细一点,奉劝你打开微信跳一跳撸一把先......
仔细研究会发现盒子除了出场时候的动画,在小人蓄力的时候一样是有动画过程的,那是一个缩放操做。而后根据前置知识中的第四点,咱们须要将盒子的缩放原点放在底部中心,因而就有:
box.geometry.translate(0, 15, 0)
复制代码
如今盒子被放置在地面上,在以后咱们写盒子落地动画和缩放时,就方便不少了。
前面约定了场景大小为innerWidth、innerHeight
,那么对于盒子的大小,为了让不一样的手机看到的盒子大小比例是一致的,能够先根据场景大小酌情而定,毕竟这是个临摹项目,也没有什么设计规范,因此盒子的宽度、深度、高度咱们酌情处理。
同时,经过体验和观摩微信跳一跳,里面的盒子应该是有一部分定制的,有一部分是随机的,有不一样大小和不一样形状。那么咱们能够优先考虑实现随机的那一部分,而后试试经过相似可配置的方式支持一下定制的盒子,毕竟也就是外观上的不一样,游戏逻辑是不变的。
既然须要随机生成盒子,考虑到不一样盒子之间有太多可能的差别,咱们只能从众多盒子中找出一部分有类似性的盒子抽象出来,而后用一个专门的函数来生成它,好比实现一个boxCreator
函数,这个函数生成大小不1、颜色随机的立方体盒子。想到这里,咱们彷佛能够经过维护一个集合,这个集合专门存放各类不一样风格的盒子的生成器(即函数),来达到可定制化的需求,好比后期产品须要加一个贴了xxx广告的xxx形状的盒子,咱们能够往这个集合中添加一个新的道具生成器就好了,而这个盒子的样式由外部来定。
既然盒子的样式能够由外部来定,那就须要有一个统一的规范,好比盒子的宽度、深度、高度范围,再好比考虑性能上的优化,咱们最好提供一个可copy的几何对象和材质。
// 维护一个道具生成器集合
const boxCreators = []
// 共享立方体
const baseBoxBufferGeometry = new THREE.BoxBufferGeometry()
// 共享材质
const baseMeshLambertMaterial = new THREE.MeshLambertMaterial()
// 随机颜色
const colors = [0x67C23A, 0xE6A23C, 0xF56C6C]
// 盒子大小限制范围
const boxSizeRange = [30, 60]
// 实现一个默认的生成大小不1、颜色随机的立方体盒子的生成器
const defaultBoxCreator = () => {
const [minSize, maxSize] = boxSizeRange
const randomSize = ~~(random() * (maxSize - minSize + 1)) + minSize
const geometry = baseBoxBufferGeometry.clone()
geometry.scale(randomSize, 30, randomSize)
const randomColor = colors[~~(Math.random() * colors.length)]
const material = baseMeshLambertMaterial.clone()
material.setValues({ randomColor })
return new THREE.Mesh(geometry, material)
}
// 将盒子创造起存入管理集合中
boxCreators.push(defaultBoxCreator)
复制代码
到如今为止,咱们应该已经有了一个实现该游戏的思路雏型,在开始大刀阔斧的以前,我认为应该先作点什么。回看前面的代码,彻底是过程式,没有抽象也没有模块化概念,初期这可能对咱们很是有帮助,但在后期这种思惟可能对咱们产生不少负面影响,没有一个清晰的架构,实现过程多是拆东墙补西墙似的痛苦。因此接下来,思考一下针对这个游戏框架咱们须要作什么样的优化。
人生本就是一场游戏,游戏中有你我他,游戏有游戏的规则,还有什么比现实世界更具备参考性的?
咱们建立一个跳一跳的游戏世界,它应该维持整个游戏的运转:
// index.js
class JumpGameWorld {
constructor () {
// ...
}
}
复制代码
就像咱们人类生活在地球上,地球做为咱们放飞自个人大舞台,那跳一跳怎么能没有一个相似地球的载体?建立一个跳一跳的游戏舞台:
// State.js
class Stage {
constructor () {}
}
复制代码
舞台中有一个小人:
// LittleMan.js
class LittleMan {
constructor () {}
}
复制代码
舞台中还有道具(盒子)
// Prop.js
class Prop {
constructor () {}
}
复制代码
道具各有各的特点,而且不是凭空产生,因此实现一个道具生成器(就像工厂):
// PropCreator.js
class PropCreator () {
constructor () {}
}
复制代码
此外,还有通用的几何体和材质、工具方法管理
// utils.js
// 材质
export const baseMeshLambertMaterial = new THREE.MeshLambertMaterial()
// 立方体
export const baseBoxBufferGeometry = new THREE.BoxBufferGeometry()
// ...
复制代码
肯定好了游戏的结构,那以后就跟着这个骨架来完善它,接下来将舞台逻辑完善下:
class Stage {
constructor ({
width,
height,
canvas,
axesHelper = false, // 辅助线
cameraNear, // 相机近截面
cameraFar, // 相机远截面
cameraInitalPosition, // 相机初始位置
lightInitalPosition // 光源初始位置
}) {
this.width = width
this.height = height
this.canvas = canvas
this.axesHelper = axesHelper
// 正交相机配置
this.cameraNear = cameraNear
this.cameraFar = cameraFar
this.cameraInitalPosition = cameraInitalPosition
this.lightInitalPosition = lightInitalPosition
this.scene = null
this.plane = null
this.light = null
this.camera = null
this.renderer = null
this.init()
}
init () {
this.createScene()
this.createPlane()
this.createLight()
this.createCamera()
this.createRenterer()
this.render()
this.bindResizeEvent()
}
bindResizeEvent () {
const { container, renderer } = this
window.addEventListener('resize', () => {
const { offsetWidth, offsetHeight } = container
this.width = offsetWidth
this.height = offsetHeight
renderer.setSize(offsetWidth, offsetHeight)
renderer.setPixelRatio(window.devicePixelRatio)
this.render()
}, false)
}
// 场景
createScene () {
const scene = this.scene = new THREE.Scene()
if (this.axesHelper) {
scene.add(new THREE.AxesHelper(10e3))
}
}
// 地面
createPlane () {
const { scene } = this
const geometry = new THREE.PlaneBufferGeometry(10e2, 10e2, 1, 1)
const meterial = new THREE.ShadowMaterial()
meterial.opacity = 0.5
const plane = this.plane = new THREE.Mesh(geometry, meterial)
plane.rotation.x = -.5 * Math.PI
plane.position.y = -.1
// 接收阴影
plane.receiveShadow = true
scene.add(plane)
}
// 光
createLight () {
const { scene, lightInitalPosition: { x, y, z }, height } = this
const light = this.light = new THREE.DirectionalLight(0xffffff, .8)
light.position.set(x, y, z)
// 开启阴影投射
light.castShadow = true
// // 定义可见域的投射阴影
light.shadow.camera.left = -height
light.shadow.camera.right = height
light.shadow.camera.top = height
light.shadow.camera.bottom = -height
light.shadow.camera.near = 0
light.shadow.camera.far = 2000
// 定义阴影的分辨率
light.shadow.mapSize.width = 1600
light.shadow.mapSize.height = 1600
// 环境光
scene.add(new THREE.AmbientLight(0xffffff, .4))
scene.add(light)
}
// 相机
createCamera () {
const {
scene,
width, height,
cameraInitalPosition: { x, y, z },
cameraNear, cameraFar
} = this
const camera = this.camera = new THREE.OrthographicCamera(-width / 2, width / 2, height / 2, -height / 2, cameraNear, cameraFar)
camera.position.set(x, y, z)
camera.lookAt(scene.position)
scene.add(camera)
}
// 渲染器
createRenterer () {
const { canvas, width, height } = this
const renderer = this.renderer = new THREE.WebGLRenderer({
canvas,
alpha: true, // 透明场景
antialias:true // 抗锯齿
})
renderer.setSize(width, height)
// 开启阴影
renderer.shadowMap.enabled = true
// 设置设备像素比
renderer.setPixelRatio(window.devicePixelRatio)
}
// 执行渲染
render () {
const { scene, camera } = this
this.renderer.render(scene, camera)
}
add (...args) {
return this.scene.add(...args)
}
remove (...args) {
return this.scene.remove(...args)
}
}
复制代码
前面咱们已经大体肯定了须要维护一个道具生成器集合,集合中有默认的道具生成器,也支持后期添加定制化的生成器。基于这个逻辑PropCreator
应该对外提供一个api
好比createPropCreator
来新增生成器,这个api
中还须要提供对应的辅助属性,好比道具的大小范围、通用材质等等。
那这个对外api
须要考虑些什么呢?
/** * 新增定制化的生成器 * @param {Function} creator 生成器函数 * @param {Boolean} isStatic 是不是动态建立 */
createPropCreator (creator, isStatic) {
if (Array.isArray(creator)) {
creator.forEach(crt => this.createPropCreator(crt, isStatic))
}
const { propCreators, propSizeRange, propHeight } = this
if (propCreators.indexOf(creator) > -1) {
return
}
const wrappedCreator = function () {
if (isStatic && wrappedCreator.box) {
// 静态盒子,下次直接clone
return wrappedCreator.box.clone()
} else {
const box = creator(THREE, {
propSizeRange,
propHeight,
baseMeshLambertMaterial,
baseBoxBufferGeometry
})
if (isStatic) {
// 被告知是静态盒子,缓存起来
wrappedCreator.box = box
}
return box
}
}
propCreators.push(wrappedCreator)
}
复制代码
假若有一个生成器只有一种样式,那将么有必要每次都从新生成,支持传入一个isStatic
来告诉生成器是否能够缓存,这样后续重复生成时就没必要从新建立。
接下来实现内置的生成器,为了方便扩展,这里新建一个文件来维护defaultProp.js
const colors = [0x67C23A, 0xE6A23C, 0xF56C6C, 0x909399, 0x409EFF, 0xffffff]
// 静态
export const statics = [
// ...
]
// 非静态
export const actives = [
// 默认纯色立方体创造器
function defaultCreator (THREE, helpers) {
const {
propSizeRange: [min, max],
propHeight,
baseMeshLambertMaterial,
baseBoxBufferGeometry
} = helpers
// 随机颜色
const color = randomArrayElm(colors)
// 随机大小
const size = rangeNumberInclusive(min, max)
const geometry = baseBoxBufferGeometry.clone()
geometry.scale(size, propHeight, size)
const material = baseMeshLambertMaterial.clone()
material.setValues({ color })
return new THREE.Mesh(geometry, material)
},
]
复制代码
默认的道具生成器实现了,可能我不须要默认的,能够实现一下可配置:
constructor ({
propHeight,
propSizeRange,
needDefaultCreator
}) {
this.propHeight = propHeight
this.propSizeRange = propSizeRange
// 维护的生成器
this.propCreators = []
if (needDefaultCreator) {
this.createPropCreator(actives, false)
this.createPropCreator(statics, true)
}
}
复制代码
而后对于游戏内部,须要提供一个api
来随机执行生成器生成道具,这里注意到微信跳一跳每次开局的头2个盒子都是一种风格(立方体),因此能够作一下控制,支持传入一个索引来生成指定的盒子。
createProp (index) {
const { propCreators } = this
return index > -1
? propCreators[index] && propCreators[index]() || randomArrayElm(propCreators)()
: randomArrayElm(propCreators)()
}
复制代码
到这里,道具生成器就差很少了,可是不要掉以轻心,作正儿八经的产品时我估计少不了一顿琢磨。好比:
道具类后期须要不断的扩充,除了几个基本的属性外,后续还有其它的东西须要扩展,好比对道具某些属性的访问和计算,以及道具的动画,此时我也肯定写到后面须要什么。
class Prop {
constructor ({
world, // 所处世界
stage, // 所处舞台
body, // 主体
height
}) {
this.world = world
this.stage = stage
this.body = body
this.height = height
}
getPosition () {
return this.body.position
}
setPosition (x, y, z) {
return this.body.position.set(x, y, z)
}
}
复制代码
接下在游戏世界中将舞台和道具生成器进行初始化,同时须要注意,道具生成器只负责生成道具,它并不知道生成的道具应该出如今什么位置,因此在JumpGameWorld
咱们须要实现一个内部的createProp
方法来告诉道具生成器给我生成一个盒子,而后由我决定将它放在那里。
constructor ({
container,
canvas,
needDefaultCreator = true,
axesHelper = false
}) {
const { offsetWidth, offsetHeight } = container
this.container = container
this.canvas = canvas
this.width = offsetWidth
this.height = offsetHeight
this.needDefaultCreator = needDefaultCreator
this.axesHelper = axesHelper
// 通过屡次尝试
const [min, max] = [~~(offsetWidth / 6), ~~(offsetWidth / 3.5)]
this.propSizeRange = [min, max]
this.propHeight = ~~(max / 2)
this.stage = null
this.propCreator = null
this.init()
}
// 初始化舞台
initStage () {
const { container, canvas } = this
const { offsetHeight } = container
const axesHelper = true
const cameraNear = 0.1
const cameraFar = 2000
// 计算相机应该放在哪里
const cameraInitalPosition = this.cameraInitalPosition = computeCameraInitalPosition(35, 225, offsetHeight / 2, offsetHeight / 2, cameraNear, cameraFar)
const lightInitalPosition = this.lightInitalPosition = { x: -300, y: 600, z: 200 }
this.stage = new Stage({
container,
canvas,
axesHelper,
cameraNear,
cameraFar,
cameraInitalPosition,
lightInitalPosition
})
}
// 初始化道具生成器
initPropCreator () {
const { needDefaultCreator, propSizeRange, propHeight } = this
this.propCreator = new PropCreator({
propHeight,
propSizeRange,
needDefaultCreator
})
}
// 对外的新增生成器的接口
createPropCreator (...args) {
this.propCreator.createPropCreator(...args)
}
复制代码
那么接下来我须要将盒子放到哪里呢?打开微信跳一跳,撸一把......
撸完回来会发现,新出的盒子可能会在2个方向上生成,X轴
和X轴
,而且生成2个盒子之间的距离应该是随机的,可是距离确定得有一个范围限制,不能出现盒子挨着盒子或者盒子出如今可视区以外的状况。因此,这里先根据盒子大小范围和场景大小约定一下盒子之间的间距范围propDistanceRange = [~~(min / 2), max * 2]
,先酌情而定,不行再调一下。
那么想到这里,咱们彷佛须要先实现一个计算盒子入场位置的方法computeMyPosition
。要计算下一个盒子的距离就得拿到上一个盒子,同时游戏过程当中会生成一大堆盒子,不可能将它们丢弃无论,为了考虑性能,咱们还须要定时对盒子进行清理和销毁操做,因此还须要有一个集合props
来管理已经建立的盒子,这样的话,每次建立盒子时拿到最近建立的一个盒子就好了。这里须要注意一下:
PropCreator
的默认道具生成器处理一下,判断若是是前面2个盒子就设置固定尺寸0
了,以后的盒子入场高度是多少我也不知道,酌情而定// utils.js
export const getPropSize = box => {
const box3 = getPropSize.box3 || (getPropSize.box3 = new THREE.Box3())
box3.setFromObject(box)
return box3.getSize(new THREE.Vector3())
}
// Prop.js
getSize () {
return getPropSize(this.body)
}
复制代码
而后Prop
类
class Prop {
constructor ({
// ...
enterHeight,
distanceRange,
prev
}) {
// ...
this.enterHeight = enterHeight
this.distanceRange = distanceRange
this.prev = prev
}
// 计算位置
computeMyPosition () {
const {
world,
prev,
distanceRange,
enterHeight
} = this
const position = {
x: 0,
// 头2个盒子y值为0
y: enterHeight,
z: 0
}
if (!prev) {
// 第1个盒子
return position
}
if (enterHeight === 0) {
// 第2个盒子,固定一个距离
position.z = world.width / 2
return position
}
const { x, z } = prev.getPosition()
// 随机2个方向 x or z
const direction = Math.round(Math.random()) === 0
const { x: prevWidth, z: prevDepth } = prev.getSize()
const { x: currentWidth, z: currentDepth } = this.getSize()
// 根据区间随机一个距离
const randomDistance = rangeNumberInclusive(...distanceRange)
if (direction) {
position.x = x + prevWidth / 2 + randomDistance + currentWidth / 2
position.z = z
} else {
position.x = x
position.z = z + prevDepth / 2 + randomDistance + currentDepth / 2
}
return position
}
// 将道具放入舞台
enterStage () {
const { stage, body, height } = this
const { x, y, z } = this.computeMyPosition()
body.castShadow = true
body.receiveShadow = true
body.position.set(x, y, z)
// 须要将盒子放到地面
body.geometry.translate(0, height / 2, 0)
stage.add(body)
stage.render()
}
// 获取道具大小
getSize () {
return getPropSize(this.body)
}
// ...
}
复制代码
如今能够实现盒子生成的逻辑了
// JumpGameWorld.js
// 建立盒子
createProp (enterHeight = 100) {
const {
height,
propCreator,
propHeight,
propSizeRange: [min, max],
propDistanceRange,
stage, props,
props: { length }
} = this
const currentProp = props[length - 1]
const prop = new Prop({
world: this,
stage,
// 头2个盒子用第一个创造器生成
body: propCreator.createProp(length < 3 ? 0 : -1),
height: propHeight,
prev: currentProp,
enterHeight,
distanceRange: propDistanceRange
})
const size = prop.getSize()
if (size.y !== propHeight) {
console.warn(`高度: ${size.y},盒子高度必须为 ${propHeight}`)
}
if (size.x < min || size.x > max) {
console.warn(`宽度: ${size.x}, 盒子宽度必须为 ${min} - ${max}`)
}
if (size.z < min || size.z > max) {
console.warn(`深度: ${size.z}, 盒子深度度必须为 ${min} - ${max}`)
}
prop.enterStage()
props.push(prop)
}
复制代码
而后初始化一下
init () {
this.initStage()
this.initPropCreator()
// 第一个道具
this.createProp()
// 第二个道具
this.createProp()
}
复制代码
到这里,已经实现了随机生成道具的功能,但如今场景是静止的,无法去验证生成更多道具的逻辑,因此下一步,咱们先实现场景移动。
拿起手机打开微信跳一跳继续琢磨......
咱们先无论小人是否存在,能够发现每一次生成盒子的同时,场景就开始移动了。那么如何移动呢?能够经过移动相机达到场景移动的效果,没啥好纠结的,这就是规律,就像拍电影同样,人动了,你的摄像机能不跟着动吗?
那么问题来了,咱们要把相机移动到哪一个位置?
不要紧,先拿起手机打开微信跳一跳撸一撸......
你会发现场景每次移动后,中心点差很少是最新的2个盒子的中间,可是感受略有向下偏移,咱们不妨把它分解一下
这就好办了,咱们算出最新的2个盒子中间的点,将这个点向下偏移一个值,而后将结果加上相机的初始位置,不就获得相机的位置了吗?这里约定偏移值为视锥体高度的1/10
,而后在JumpGameWorld
中:
// 计算最新的2个盒子的中心点
getLastTwoCenterPosition () {
const { props, props: { length } } = this
const { x: x1, z: z1 } = props[length - 2].getPosition()
const { x: x2, z: z2 } = props[length - 1].getPosition()
return {
x: x1 + (x2 - x1) / 2,
z: z1 + (z2 - z1) / 2
}
}
// 移动相机,老是看向最后2个小球的中间位置
moveCamera () {
const {
stage,
height
cameraInitalPosition: { x: initX, y: initY, z: initZ }
} = this
// 将可视区向上偏移一点,这样看起来道具的位置更合理
const cameraOffsetY = height / 10
const { x, y, z } = this.getLastTwoCenterPosition()
const to = {
x: x + initX + cameraOffsetY,
y: initY, // 高度是不变的
z: z + initZ + cameraOffsetY
}
// 移动舞台相机
stage.moveCamera(to)
}
复制代码
获得了相机的位置后,咱们须要在舞台类中提供对应的方法,Stage
中
// 移动相机
moveCamera ({ x, z }) {
const { camera } = this
camera.position.x = x
camera.position.z = z
this.render()
}
复制代码
如今相机已经能够移动了,咱们设置一个定时器来测试一下,能够先将盒子的y值
统一设置为0
init () {
this.initStage()
this.initPropCreator()
// 第一个道具
this.createProp()
// 第二个道具
this.createProp()
// 首次调整相机
this.moveCamera()
// 测试
const autoMove = () => {
setTimeout(() => {
autoMove()
// 每次有新的道具时,须要移动相机
this.createProp()
this.moveCamera()
}, 2000)
}
autoMove()
}
复制代码
ok,很是nice,可是测试时问题来了
暂时发现这么几个问题,咱们一个个解决它。
影子的问题,这是由于地面不够大,那能将地面设置的足够大吗?根据咱们前面对相机的分析能够知道,是能够的,由于咱们没有改变相机的任何角度,只是进行了平移,可是这样作也太low了,而且最大值是有限的,因此,咱们能够在每次移动相机的同时移动地面,形成地面没有移动的假象。那么地面的位置也就呼之而出了,就是那个中心点的位置。
阴影的问题,这和地面相似,咱们也可让光源跟着相机移动,可是光线须要注意一点
平行光的方向是从它的位置到目标位置。默认的目标位置为原点 (0,0,0)。 注意: 对于目标的位置,要将其更改成除缺省值以外的任何位置,它必须被添加到 scene 场景中去。
意思就是光线的目标位置若是改变了,必需要建立一个目标对象并添加到场景中去,也就是说,除了更新光源的位置,还须要对光照的目标位置进行更新
var targetObject = new THREE.Object3D();
scene.add(targetObject);
light.target = targetObject;
复制代码
场景过渡,这个就没什么复杂的了,直接使用Tween.js
插件,因为后续还有不少地方要用到过渡效果,咱们能够先将它简单封装一下
export const animate = (configs, onUpdate, onComplete) => {
const {
from, to, duration,
easing = k => k,
autoStart = true // 为了使用tween的chain
} = configs
const tween = new TWEEN.Tween(from)
.to(to, duration)
.easing(easing)
.onUpdate(onUpdate)
.onComplete(() => {
onComplete && onComplete()
})
if (autoStart) {
tween.start()
}
animateFrame()
return tween
}
const animateFrame = function () {
if (animateFrame.openin) {
return
}
animateFrame.openin = true
const animate = () => {
const id = requestAnimationFrame(animate)
if (!TWEEN.update()) {
animateFrame.openin = false
cancelAnimationFrame(id)
}
}
animate()
}
复制代码
盒子的销毁,对于不在可视区的盒子,确实是有必要进行销毁的,毕竟当数量很是庞大的时候,会带来显著的性能问题。咱们能够选择一个恰当的时机作这件事情,好比每次相机移动完成后执行盒子的清理操做。那该如何判断盒子是否在可视区?先搁着,待解决前面几个问题在考虑。
而后根据上面总结的问题改造一下moveCamera
,不要忘记加一个光源目标对象lightTarget
,而后还须要提供一个相机移动完成的回调(等下用来执行盒子销毁)
// Stage.js
// center为2个盒子的中心点
moveCamera ({ cameraTo, center, lightTo }, onComplete, duration) {
const {
camera, plane,
light, lightTarget,
lightInitalPosition
} = this
// 移动相机
animate(
{
from: { ...camera.position },
to: cameraTo,
duration
},
({ x, y, z }) => {
camera.position.x = x
camera.position.z = z
this.render()
},
onComplete
)
// 灯光和目标也须要动起来,为了保证阴影位置不变
const { x: lightInitalX, z: lightInitalZ } = lightInitalPosition
animate(
{
from: { ...light.position },
to: lightTo,
duration
},
({ x, y, z }) => {
lightTarget.position.x = x - lightInitalX
lightTarget.position.z = z - lightInitalZ
light.position.set(x, y, z)
}
)
// 保证不会跑出有限大小的地面
plane.position.x = center.x
plane.position.z = center.z
}
复制代码
对应的,JumpGameWorld
中也改造下
// 移动相机,老是看向最后2个小球的中间位置
moveCamera (duration = 500) {
const {
stage,
cameraInitalPosition: { x: cameraX, y: cameraY, z: cameraZ },
lightInitalPosition: { x: lightX, y: lightY, z: lightZ }
} = this
// 向下偏移值,取舞台高度的1/10
const cameraOffsetY = stage.frustumHeight / 10
const { x, y, z } = this.getLastTwoCenterPosition()
const cameraTo = {
x: x + cameraX + cameraOffsetY,
y: cameraY, // 高度是不变的
z: z + cameraZ + cameraOffsetY
}
const lightTo = {
x: x + lightX,
y: lightY,
z: z + lightZ
}
// 移动舞台相机
const options = {
cameraTo,
lightTo,
center: { x, y, z }
}
stage.moveCamera(
options,
() => {
// 执行盒子销毁操做
},
duration
)
}
复制代码
什么时候进行销毁咱们已经有思路了,那么销毁的依据是什么?很显然只要盒子不在可视区了就能够销毁了,由于场景是前进的,可视区的中心不断的往X轴
或者Z轴
方向前移。那么首先想到的是实现一个检测盒子是否在可视区的方法,threejs也有提供相应api
可操做,感兴趣的朋友能够去了解下相关的算法,我就看不下去了,数学太弱。另外,threejs中的算法彷佛是跟顶点和射线相关,物体(顶点越多)越复杂计算量越大。咱们不妨尝试换一种方式看这个问题,那就是必定须要计算盒子是否在可视区吗?
凑合着看吧,不太好画出来。假设咱们的场景大小是200*320
,盒子大小范围是[30,60]
,另外还有盒子之间的间距限制[20,100]
,那么咱们以最小的安全值来大体估算一下,放2个盒子30+20+30
,已经有80
宽了,也就是说200
宽横放不超过4个。另外咱们的可视区的中心点是处于最近的2个盒子的中心(不考虑相机的下偏移量),那么竖着放时,160
高的范围最多竖着放3个盒子,再加上中心点上边的一个,也是4个盒子。也就是说,按照估算,可视区可能最多同时存在8个盒子(若是要抠字眼,能够实际测试一下,这里仅估算,偏差应该还和相机角度有关)。
如今,逻辑已经很明确了,根据假设,当咱们管理的盒子集合props
的长度大于8
时,就能够执行盒子销毁操做了,而且没有必要每次相机移动后都清理,能够固定一下每次清理几个,好比咱们约定每次清理4
个,那么每次有12个盒子时销毁4个,以此类推......
// JumpGameWorld.js
// 销毁道具
clearProps () {
const {
width,
height,
safeClearLength,
props, stage,
props: { length }
} = this
const point = 4
if (length > safeClearLength) {
props.slice(0, point).forEach(prop => prop.dispose())
this.props = props.slice(point)
}
}
// 估算销毁安全值
computeSafeClearLength () {
const { width, height, propSizeRange } = this
const minS = propSizeRange[0]
const hypotenuse = Math.sqrt(minS * minS + minS * minS)
this.safeClearLength = Math.ceil(width / minS) + Math.ceil(height / hypotenuse / 2) + 1
}
// Prop.js
// 销毁
dispose () {
const { body, stage } = this
body.geometry.dispose()
body.material.dispose()
stage.remove(body)
// 解除对前一个的引用
this.prev = null
}
复制代码
回想一下,若是用算法去处理盒子的销毁,可能也是得有一个安全值的,为何呢?
若是出现图中的状况,而且没有设定一个安全值的话,算法会告诉你,图中倒数第4个盒子已经出了可视区了,那咱们应该清理吗?按照下一个盒子可能的方向,若是和图中一致,场景会右移,这时候这个盒子应该出如今可视区,而不是销毁掉。
问题一个接着一个的来,下一步,咱们实现盒子的入场弹球下落
加入一个动画其实很简单,能够在建立盒子进入舞台时处理它,如今实现一个entranceTransition
方法
// 放入舞台
enterStage () {
// ...
this.entranceTransition()
}
// 盒子的入场动画
entranceTransition (duration = 400) {
const { body, enterHeight, stage } = this
if (enterHeight === 0) {
return
}
animate(
{
to: { y: 0 },
from: { y: enterHeight },
duration,
easing: TWEEN.Easing.Bounce.Out
},
({ y }) => {
body.position.setY(y)
stage.render()
}
)
}
复制代码
到此,咱们已经实现了场景、道具的主要逻辑,已经初具规模嘿嘿嘿。
如今来实现小人的逻辑,打开微信跳一跳多撸几把......
而后分析一下和小人相关点都有哪些?
下面,将它们一一解开......
首先头部很简单,就是一个圆。身体部分是一个不规则的圆柱,因为刚接触threejs,不知道有什么捷径去画这个身体部分,因此这里我用三个几何体将身体组合起来,画以前咱们得回看分析的那些点,看看画的时候是否是须要注意什么。首先有影响的确定是缩放功能(注意头部不会缩放),这要求画的时候将身体的缩放原点放在他脚下,而后还有空中翻转,这部分暂时不太清楚翻转的原点在哪里(太快),多是身体和头部的总体的中心点,也可能不是,但这不影响咱们能肯定身体和头是一个总体(threejs的组),至于翻转的原点在哪,等咱们作出来以后调试效果时再作处理,那么稳妥起见,用一张图来描述应该怎么画
每一个虚线框都表明一层包装(网格或者组),对于小人,若是要修改旋转原点只须要调整头和身体组的上下偏移位置便可作到。
我琢磨了一下微信跳一跳的开场画面(就是尚未点开始游戏时),小人是从空白的地方跳上盒子的,开始游戏后是从空中落到盒子上,那么小人应该有一个入场的方法enterStage
,而后身体建立的方法createBody
,还应该有一个跳跃方法jump
。so:
class LittleMan {
constructor ({
world,
color
}) {
this.world = world
this.color = color
this.stage = null
}
// 建立身体
createBody () {}
// 进入舞台
enterStage () {}
// 跳跃
jump () {}
}
复制代码
咱们先将身体画出来,因为场景宽度是根据视口宽度设置的,因此小人的尺寸动态须要算一下。
// 建立身体
createBody () {
const { color, world: { width } } = this
const material = baseMeshLambertMaterial.clone()
material.setValues({ color })
// 头部
const headSize = this.headSize = width * .03
const headTranslateY = this.headTranslateY = headSize * 4.5
const headGeometry = new THREE.SphereGeometry(headSize, 40, 40)
const headSegment = this.headSegment = new THREE.Mesh(headGeometry, material)
headSegment.castShadow = true
headSegment.translateY(headTranslateY)
// 身体
this.width = headSize * 1.2 * 2
this.bodySize = headSize * 4
const bodyBottomGeometry = new THREE.CylinderBufferGeometry(headSize * .9, this.width / 2, headSize * 2.5, 40)
bodyBottomGeometry.translate(0, headSize * 1.25, 0)
const bodyCenterGeometry = new THREE.CylinderBufferGeometry(headSize, headSize * .9, headSize, 40)
bodyCenterGeometry.translate(0, headSize * 3, 0)
const bodyTopGeometry = new THREE.SphereGeometry(headSize, 40, 40)
bodyTopGeometry.translate(0, headSize * 3.5, 0)
const bodyGeometry = new THREE.Geometry()
bodyGeometry.merge(bodyTopGeometry)
bodyGeometry.merge(new THREE.Geometry().fromBufferGeometry(bodyCenterGeometry))
bodyGeometry.merge(new THREE.Geometry().fromBufferGeometry(bodyBottomGeometry))
// 缩放控制
const translateY = this.bodyTranslateY = headSize * 1.5
const bodyScaleSegment = this.bodyScaleSegment = new THREE.Mesh(bodyGeometry, material)
bodyScaleSegment.castShadow = true
bodyScaleSegment.translateY(-translateY)
// 旋转控制
const bodyRotateSegment = this.bodyRotateSegment = new THREE.Group()
bodyRotateSegment.add(headSegment)
bodyRotateSegment.add(bodyScaleSegment)
bodyRotateSegment.translateY(translateY)
// 总体身高 = 头部位移 + 头部高度 / 2 = headSize * 5
const body = this.body = new THREE.Group()
body.add(bodyRotateSegment)
}
复制代码
而后咱们须要让小人走到舞台中的指定位置
// 进入舞台
enterStage (stage, { x, y, z }) {
const { body } = this
body.position.set(x, y, z)
this.stage = stage
stage.add(body)
stage.render()
}
复制代码
在游戏中初始化,并让小人进入场景
// JumpGameWorld.js
// 初始化小人
initLittleMan () {
const { stage, propHeight } = this
const littleMan = this.littleMan = new LittleMan({
world: this,
color: 0x386899
})
littleMan.enterStage(stage, { x: 0, y: propHeight, z: 0 })
}
复制代码
第一步已经完成,接下来,咱们须要让小人动起来,实现他的弹跳功能。
打开微信跳一跳,这个须要仔细琢磨琢磨......
咱们能够将整个弹跳过程分解一下,蓄力 -> 起跳 -> 抛物线运动 -> 着地 -> 缓冲
,这里 的蓄力
就是鼠标按下(touchstart
或者mousedown
)时发生,起跳
是松开时(touchend
或者mouseup
)发生。须要注意的是,若是连续按下和松开,在小人没有落地前是不能作任何操做的,还有一种状况就是:若是小人在空中时鼠标按下,落地一段时间后鼠标松开,这时也是不能作任何操做的,因此咱们能够在按下以后绑定松开事件,而后松开事件发生后当即移除它。
bindEvent () {
const { container } = this.world
const isMobile = 'ontouchstart' in document
const mousedownName = isMobile ? 'touchstart' : 'mousedown'
const mouseupName = isMobile ? 'touchend' : 'mouseup'
// 该起跳了
const mouseup = () => {
if (this.jumping) {
return
}
this.jumping = true
// 蓄力动做应该中止
this.poweringUp = false
this.jump()
container.removeEventListener(mouseupName, mouseup)
}
// 蓄力的时候
const mousedown = event => {
event.preventDefault()
// 跳跃没有完成不能操做
if (this.poweringUp || this.jumping) {
return
}
this.poweringUp = true
this.powerStorage()
container.addEventListener(mouseupName, mouseup, false)
}
container.addEventListener(mousedownName, mousedown, false)
}
// 进入舞台
enterStage (stage, { x, y, z }) {
// ...
this.bindEvent()
}
复制代码
蓄力的目的是为了跳的更远,也就是说,力度决定了远近,咱们能够根据力度大小 * 系数
去模拟计算一个射程,说到这里,脑海里蹦出一个词斜抛运动
,彷佛n年没有接触过了,而后默默的打开百度:斜抛运动
斜抛运动: 物体以必定的初速度斜向射出去,在空气阻力能够忽略的状况下,物体所作的这类运动叫作斜抛运动。物体做匀变速曲线运动,它的运动轨迹是抛物线。
微信跳一跳中是斜抛运动吗?打开它去琢磨一下......
上上下下观察了许久以后,以个人空间感几乎能判定它"应该"不是一个匀变速曲线的斜抛运动
,毕竟斜抛运动公式
是在空气阻力能够忽略的状况下
才有效,而微信跳一跳的轨迹彻底就不像一个对称的抛物线嘛,它看起来像这样:
这应该比较像一个有阻力的斜抛运动,但我在网上没有找到考虑阻力的斜抛公式,因此,我们在利用斜抛运动
的时候可能得稍稍作一点改变。在不作修改的状况下,y
值是须要经过x
的值计算出来的,这样咱们就无法比较直接的控制y
的曲线。如今绕个弯,不如将y
的运动分离出来,而且保留x
轴的匀速,建立一个x
轴的过渡,同时建立两个y
轴的过渡,上升段减速,降低段加速,这里约定下上升时间为总时间的60%。而后,根据斜抛运动
的相关公式,咱们能够计算出水平射程
和射高
,运动时间
我感受微信跳一跳中是一个固定值,这里就不算了。
既然须要利用斜抛公式,那就须要建立2个变量,速度v0
和theta
,在蓄力的同时经过递增v0
和递减theta
来模拟轨迹。先将公式准备好,同时JumpGameWorld
中新增一个重力参数重力G
,默认先用9.8
// 斜抛计算
export const computeObligueThrowValue = function (v0, theta, G) {
const sin2θ = sin(2 * theta)
const sinθ = sin(theta)
const rangeR = pow(v0, 2) * sin2θ / G
const rangeH = pow(v0 * sinθ, 2) / (2 * G)
return {
rangeR,
rangeH
}
}
复制代码
而后,咱们如今实现蓄力的基本逻辑,要作的事情就是递增斜抛参数以及缩放小人,这里先不关注斜抛参数值,待咱们让小人动起来以后再去调整它。还有一点须要注意一下,蓄力结束后,须要将小人复原,但不能直接复原,须要将效蓄力结束时的值保存起来,而后在抛物线运动阶段将小人复原,这样效果就比较平滑了。
resetPowerStorageParameter () {
this.v0 = 20
this.theta = 90
// 因为蓄力致使的变形,须要记录后,在空中将小人复原
this.toValues = {
headTranslateY: 0,
bodyScaleXZ: 0,
bodyScaleY: 0
}
this.fromValues = this.fromValues || {
headTranslateY: this.headTranslateY,
bodyScaleXZ: 1,
bodyScaleY: 1
}
}
// 蓄力
powerStorage () {
const { stage, bodyScaleSegment, headSegment, fromValues, bodySize } = this
this.resetPowerStorageParameter()
const tween = animate(
{
from: { ...fromValues },
to: {
headTranslateY: bodySize - bodySize * .6,
bodyScaleXZ: 1.3,
bodyScaleY: .6
},
duration: 1500
},
({ headTranslateY, bodyScaleXZ, bodyScaleY }) => {
if (!this.poweringUp) {
// 抬起时中止蓄力
tween.stop()
} else {
this.v0 *= 1.008
this.theta *= .99
headSegment.position.setY(headTranslateY)
bodyScaleSegment.scale.set(bodyScaleXZ, bodyScaleY, bodyScaleXZ)
// 保存此时的位置用于复原
this.toValues = {
headTranslateY,
bodyScaleXZ,
bodyScaleY
}
stage.render()
}
}
)
}
复制代码
如今按下鼠标,应该能看到小人蓄力的效果了,接下来咱们还须要实现小人对盒子的挤压效果。
有几个我以前根本没有想到的问题就是,我(好比我是舞台中的小人)进入了舞台,我站在哪里?我接收到下一步指令后,下一步要往哪里走?
根据上面的分析,小人应该知道他当前所在的盒子currentProp
是哪一个,值多是null,固然还知道下一个目标盒子nextProp
是哪一个。
首先肯定什么时候设置当前所在盒子currentProp
,咱们以前实现了进入舞台的enterStage
方法,那此时应该就明确了这个方法仅仅是进入舞台,和盒子没有关系,因此如今咱们须要在小人进入舞台后,跳向第一个盒子,根据观摩微信跳一跳:
斜抛运动
跳向第一个盒子弹球下落运动
跳向第一个盒子,针对这个动做咱们须要单独实现以上分析若是还不是很清楚,建议你拿上你的手机打开微信跳一跳多撸几把......
那么要怎么作就很明确了,在小人进入舞台时设置下一个跳跃的目标盒子,而后执行跳跃操做,跳上去以后将其设置成当前盒子,同时将此盒子的下一个设置为下次的跳跃目标,这里能够回头在盒子生成的地方将它与下一个关联一下,方便处理
// JumpGameWorld.js
// 建立盒子
createProp (enterHeight = 100) {
// ...
// 关联下一个用于小人寻找目标
if (currentProp) {
currentProp.setNext(prop)
}
prop.enterStage()
props.push(prop)
}
// Prop.js
setNext (next) {
this.next = next
}
getNext (next) {
return this.next
}
// 销毁
dispose () {
const { body, stage, prev, next } = this
// 解除关联的引用
this.prev = null
this.next = null
if (prev) {
prev.next = null
}
if (next) {
next.prev = null
}
body.geometry.dispose()
body.material.dispose()
stage.remove(body)
}
复制代码
// LittleMan.js
enterStage (stage, { x, y, z }, nextProp) {
const { body } = this
body.position.set(x, y, z)
this.stage = stage
// 进入舞台时告诉小人目标
this.nextProp = nextProp
stage.add(body)
stage.render()
this.bindEvent()
}
// 跳跃
jump () {
const {
stage, body,
currentProp, nextProp,
world: { propHeight }
} = this
const { x, z } = body.position
const { x: nextX, z: nextZ } = nextProp.position
// 开始游戏时,小人从第一个盒子正上方入场作弹球下落
if (!currentProp && x === nextX && z === nextZ) {
body.position.setY(propHeight)
this.currentProp = nextProp
this.nextProp = nextProp.getNext()
} else {
// ...
}
stage.render()
}
复制代码
具体的跳跃动画以后再解决。如今已经知道了当前站在哪一个盒子,能够愉快的实现挤压效果了。那么具体的挤压效果咱们应该如何实现呢?前面已经实现了小人的蓄力,根据微信跳一跳的效果,挤压效果也是在蓄力期间过渡,盒子被挤压的同时,小人也须要更新它的总体y轴位置,因此,如今对蓄力动画进行改造
// 初始化斜抛相关参数
resetPowerStorageParameter () {
// ...
this.toValues = {
// ...
propScaleY: 0
}
this.fromValues = this.fromValues || {
// ...
propScaleY: 1
}
}
// 蓄力
powerStorage () {
const {
stage,
body, bodyScaleSegment, headSegment,
fromValues,
currentProp,
world: { propHeight }
} = this
// ...
const tween = animate(
{
from: { ...fromValues },
to: {
// ...
propScaleY: .8
},
duration: 1500
},
({ headTranslateY, bodyScaleY, bodyScaleXZ, propScaleY }) => {
if (!this.poweringUp) {
// 抬起时中止蓄力
tween.stop()
} else {
// ...
currentProp.scale.setY(propScaleY)
body.position.setY(propHeight * propScaleY)
// ...
stage.render()
}
}
)
}
复制代码
如今,挤压效果已经实现,接下来分析起跳的过程,请打开微信跳一跳......
那速度超级的快,看不清,仍是本身分析一下吧。首先,按照生活常识,小人跳起来的初始速度应该是大于盒子的回弹速度的,在盒子回弹到顶点以前应该是不会相撞的,那么咱们能够同时开启2个动画,一个是盒子的回弹,一个是小人的斜抛运动。
第一个动画,先为盒子实现一个回弹功能springbackTransition
:
// 回弹动画
springbackTransition (duration) {
const { body, stage } = this
const y = body.scale.y
animate(
{
from: { y },
to: { y: 1 },
duration,
easing: TWEEN.Easing.Bounce.Out
},
({ y }) => {
body.scale.setY(y)
stage.render()
}
)
}
复制代码
第二个动画,小人的抛物线运动,这个已经分析过了,X轴
作匀速运动,Y轴
分2段,上升段是减速,降低段是加速。整个跳跃过程,除了抛物线运动,还包括一个落地缓冲,缓冲理论上也是2段变化,但这里因为变化很是快,我以为肉眼是很难识别出来的,因此先只设置后半段看看效果,同时,缓冲结束时间点应该是整个跳跃过程的结束时间点。
另外,小人运动的方向多是X轴
或Z轴
,因此须要先肯定小人的方向,咱们能够经过比较2个盒子的x
值和z
值来断定方向,x
相等则是Z轴方向
,不然是X轴
如今,肯定了方向、运动曲线、射程,那就能够开始动手了吗?too young too simple,这个射程
咱们能直接用于小人在X轴
或者Z轴
的偏移吗?
如上图,先假设小人不会跳出盒子,很明确的,小人每次跳跃都须要瞄准下一个盒子的中心点,至于能不能准确落到中心点,那是不肯定的,由射程
决定,但必定不会跳出从起跳点到下一个盒子中心点相连的这一条线,如今,再进一步分解下:
从图中规律能够看出,已知c1
到p2
的直线距离和坐标差,而后根据类似三角行特性就能算出X轴
和Z轴
方向的偏移量。接下来就是套公式了,求出真正的x
和z
,咱们实现一个computePositionByRangeR
方法。
/** * 根据射程算出落地点 * @param {Number} range 射程 * @param {Object} c1 起跳点 * @param {Object} p2 目标盒子中心点 */
export const computePositionByRange = function (range, c1, p2) {
const { x: c1x, z: c1z } = c1
const { x: p2x, z: p2z } = p2
const p2cx = p2x - c1x
const p2cz = p2z - c1z
const p2c = sqrt(pow(p2cz, 2) + pow(p2cx, 2))
const jumpDownX = p2cx * range / p2c
const jumpDownZ = p2cz * range / p2c
return {
jumpDownX: c1x + jumpDownX,
jumpDownZ: c1z + jumpDownZ
}
}
复制代码
而后咱们将以前总结的起跳逻辑都实现一下,包括小人首次的弹球下落,因为我实现完以后体验发现若是蓄力时间很短,计算获得的射高
值有点低(和微信体验差异有点大),因此我直接将射高
写死了一个最小值😄,看起来和微信跳一跳体验更接近些。
// 跳跃
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) {
animate(
{
from: { y: startY },
to: { y: propHeight },
duration,
easing: TWEEN.Easing.Bounce.Out
},
({ y }) => {
body.position.setY(y)
stage.render()
},
() => {
this.currentProp = nextProp
this.nextProp = nextProp.getNext()
this.jumping = false
}
)
} else {
if (!currentProp) {
return
}
const { bodyScaleSegment, headSegment, G } = this
const { v0, theta } = this.computePowerStorageValue()
const { rangeR, rangeH } = computeObligueThrowValue(v0, theta * (Math.PI / 180), G)
// 水平匀速
const { jumpDownX, jumpDownZ } = computePositionByRangeR(rangeR, start, target)
animate(
{
from: {
x: startX,
z: startZ,
...this.toValues
},
to: {
x: jumpDownX,
z: jumpDownZ,
...this.fromValues
},
duration
},
({ x, z, headTranslateY, bodyScaleXZ, bodyScaleY }) => {
body.position.setX(x)
body.position.setZ(z)
headSegment.position.setY(headTranslateY)
bodyScaleSegment.scale.set(bodyScaleXZ, bodyScaleY, bodyScaleXZ)
}
)
// y轴上升段、降低段
const rangeHeight = Math.max(60, 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)
}
)
// 落地后,生成下一个方块 -> 移动镜头 -> 更新关心的盒子 -> 结束
const ended = () => {
const { world } = this
world.createProp()
world.moveCamera()
this.currentProp = nextProp
this.nextProp = nextProp.getNext()
// 跳跃结束了
this.jumping = false
}
// 落地缓冲段
const bufferUp = animate(
{
from: { s: .8 },
to: { s: 1 },
duration: 100,
autoStart: false
},
({ s }) => {
bodyScaleSegment.scale.setY(s)
},
() => {
// 以落地缓冲结束做为跳跃结束时间点
ended()
}
)
// 上升 -> 降低 -> 落地缓冲
yDown.chain(bufferUp)
yUp.chain(yDown).start()
// 须要处理不一样方向空翻
const direction = currentProp.getPosition().z === nextProp.getPosition().z
this.flip(duration, direction)
// 从起跳开始就回弹
currentProp.springbackTransition(500)
}
stage.render()
}
// 空翻
flip (duration, direction) {
const { bodyRotateSegment } = this
let increment = 0
animate(
{
from: { deg: 0 },
to: { deg: 360 },
duration,
easing: TWEEN.Easing.Sinusoidal.InOut
},
({ deg }) => {
if (direction) {
bodyRotateSegment.rotateZ(-(deg - increment) * (Math.PI/180))
} else {
bodyRotateSegment.rotateX((deg - increment) * (Math.PI/180))
}
increment = deg
}
)
}
复制代码
ok,如今小人能够起跳了,而且老是朝向下一个盒子的中心点方向,游戏已经处具规模。不过如今有一个比较明显的问题,就是蓄力值的变化,接下来调整蓄力值。
当前的蓄力值变化逻辑是放在动画中,其实就是在requestAnimationFrame
中,requestAnimationFrame
的执行时间是不稳定的,因此得换一种方式来处理,那若是用定时器呢?其实定时器也不必定是定时(准时),最可靠的方法就是记录一个鼠标按下的时间,而后根据鼠标松开时的时间差来算蓄力值,但这个时间差有一个最大值,就是蓄力的最大时间。如今实现一个computePowerStorageValue
方法经过时间计算蓄力值,而后将jump
方法中的参数替换一下(系数试了不少遍肯定这样算比较像微信跳一跳的感受)
computePowerStorageValue () {
const { powerStorageDuration, powerStorageTime, v0, theta } = this
const diffTime = Date.now() - powerStorageTime
const time = Math.min(diffTime, powerStorageDuration)
const percentage = time / powerStorageDuration
return {
v0: v0 + 30 * percentage,
theta: theta - 50 * percentage
}
}
复制代码
本觉得几天能写的差很少,没想到估算偏差太大,待我几天后继续更新......
若是对你有帮助,请给个赞,谢了老铁!