前言
虽然本文标题为介绍一个水压套圈h5游戏,但是窃以为仅仅如此对读者是没什么帮助的,毕竟读者们的工作生活很少会再写一个类似的游戏,更多的是面对需求的挑战。我更希望能举一反三,给大家在编写h5游戏上带来一些启发,无论是从整体流程的把控,对游戏框架、物理引擎的熟悉程度还是在某一个小难点上的思路突破等。因此本文将很少详细列举实现代码,取而代之的是以伪代码展现思路为主。
游戏 demo 地址:http://jdc.jd.com/fd/demo/waterful/game.html
希望能给诸位读者带来的启发
技术选型
整体代码布局
难点及解决思路
优化点
技术选型
一个项目用什么技术来实现,权衡的因素有许多。其中时间是必须优先考虑的,毕竟效果可以减,但上线时间是死的。
本项目预研时间一周,真正排期时间只有两周。虽然由项目特点来看比较适合走 3D 方案,但时间明显是不够的。最后保守起见,决定采用 2D 方案尽量逼近真实立体的游戏效果。
从游戏复杂度来考虑,无须用到 Egret 或 Cocos 这些“牛刀”,而轻量、易上手、团队内部也有深厚沉淀的 CreateJS 则成为了渲染框架的首选。
另外需要考虑的是是否需要引入物理引擎,这点需要从游戏的特点去考虑。本游戏涉及重力、碰撞、施力等因素,引入物理引擎对开发效率的提高要大于学习使用物理引擎的成本。因此权衡再三,我引入了同事们已经玩得挺溜的 Matter.js。( Matter.js 文档清晰、案例丰富,是切入学习 web 游戏引擎的一个不错的框架)
整体代码布局
在代码组织上,我选择了面向对象的手法,对整个游戏做一个封装,抛出一些控制接口给其他逻辑层调用。
伪代码:
<!-- index.html --><!-- 游戏入口 canvas --><canvas id="waterfulGameCanvas" width="660" height="570"></canvas>
// game.js/** * 游戏对象 */class Waterful { // 初始化函数 init () {} // CreateJS Tick,游戏操作等事件的绑定放到游戏对象内 eventBinding () {} // 暴露的一些方法 score () {} restart () {} pause () {} resume () {} // 技能 skillX () {}}/** * 环对象 */class Ring { // 于每一个 CreateJS Tick 都调用环自身的 update 函数 update () {} // 进针后的逻辑 afterCollision () {}}
// main.js// 根据业务逻辑初始化游戏,调用游戏的各种接口const waterful = new Waterful()waterful.init({...})
初始化
游戏的初始化接口主要做了4件事情:
参数初始化
CreateJS 显示元素(display object)的布局
Matter.js 刚体(rigid body)的布局
事件的绑定
下面主要聊聊游戏场景里各种元素的创建与布局,即第二、第三点。
一、CreateJS 结合 Matter.js
阅读 Matter.js 的 demo 案例,都是用其自带的渲染引擎 Matter.Render。但是由于某些原因(后面会说到),我们需要使用 CreateJS 去渲染每个环的贴图。
不像 Laya 配有和 Matter.js 自身用法一致的 Render,CreateJS 需要单独创建一个贴图层,然后在每个 Tick 里把贴图层的坐标同步为 Matter.js 刚体的当前坐标。
伪代码:
createjs.Ticker.addEventListener('tick', e => { 环贴图的坐标 = 环刚体的坐标})
使用 CreateJS 去渲染后,要单独调试 Matter.js 的刚体是非常不便的。建议写一个调试模式专门使用 Matter.js 的 Render 去渲染,以便跟踪刚体的运动轨迹。
二、环
本游戏的难点是要以 2D 去模拟 3D,环是一点,进针的效果是一点,先说环。
环由一个圆形的刚体,和半径稍大一些的贴图层所组成。如下图,蓝色部分为刚体:
伪代码:
class Ring { constructor () { // 贴图 this.texture = new createjs.Sprite(...) // 刚体 this.body = Matter.Bodies.circle(...) }}
三、刚体
为什么把刚体半径做得稍小呢,这也是受这篇文章 推金币 里金币的做法所启发。推金币游戏中,为了达到金币间的堆叠效果,作者很聪明地把刚体做得比贴图小,这样当刚体挤在一起时,贴图间就会层叠起来。所以这样做是为了使环之间稍微有点重叠效果,更重要的也是当两个紧贴的环不会因翻转角度太接近而显得留白太多。如图:
为了模拟环在水中运动的效果,可以选择给环加一些空气摩擦力。另外在实物游戏里,环是塑料做成的,碰撞后动能消耗较大,因此可以把环的 restitution 值调得稍微小一些。
需要注意 Matter.js 中因为各种物理参数都是没有单位的,一些物理公式很可能用不上,只能基于其默认值慢慢进行微调。下面的 frictionAir 和 restitution 值就是我慢慢凭感觉调整出来的:
this.body = Matter.Bodies.circle(x, y, r, { frictionAir: 0.02, restitution: 0.15})
四、贴图
环在现实世界中的旋转是三维的,而 CreateJS 只能控制元素在二维平面上的旋转。对于一个环来说,二维平面的旋转是没有任何意义的,无论如何旋转,都只会是同一个样子。
想要达到环绕 x 轴旋转的效果,一开始想到的是使用 rotation + scaleY。虽然这样能在视觉上达到目的,但是 scaleY 会导致环有被压扁的感觉,图片会失真:
显然这样的效果是不能接受的,最后我采取了逐帧图的方式,最接近地还原了环的旋转姿态:
注意在每个 Tick 里需要去判断环是否静止,若非静止则继续播放,并将贴图的 rotation 值赋值为刚体的旋转角度。如果是停止状态,则暂停逐帧图的播放:
// 贴图与刚体位置的小数点后几位有点不一样,需要降低精度const x1 = Math.round(texture.x)const x2 = Math.round(body.position.x)const y1 = Math.round(texture.y)const y2 = Math.round(body.position.y)if (x1 !== x2 || y1 !== y2) { texture.paused && texture.play() texture.rotation = body.angle * 180 / Math.PI} else { !texture.paused && texture.stop()}texture.x = body.position.xtexture.y = body.position.y
五、舞台
舞台需要主要由物理世界、背景图,墙壁,针所组成。
1. 物理世界
为了模拟真实世界环在水中的向下加速度,可以把 y 方向的 g 值调小:
engine.world.gravity.y = 0.2
左右重力感应对环的加速度影响同样可以通过改变 x 方向的 g 值达到:
// 最大倾斜角度为 70 度,让用户不需要过分倾斜手机// 0.4 为灵敏度值,根据具体情况调整window.addEventListener('deviceorientation', e => { let gamma = e.gamma if (gamma < -70) gamma = -70 if (gamma > 70) gamma = 70 this.engine.world.gravity.x = (e.gamma / 70) * 0.4})
2. 背景图
本游戏布景为游戏机及海底世界,两者可以作为父容器的背景图,把 canvas 的位置定位到游戏机内即可。canvas 覆盖范围为下图的蓝色蒙层:
3. 墙壁
因为环的刚体半径比贴图半径小,因此墙壁刚体需要有一些提前位移,环贴图才不会溢出,位移量为 R - r(下图红线为墙壁刚体的一部分):
4. 针
为了模拟针的边缘轮廓,针的刚体由一个矩形与一个圆形所组成。下图红线描绘了针的刚体:
为什么针边缘没有像墙壁一样有一些提前量呢?这是因为进针效果要求针顶的平台区域尽量地窄。作为补偿,可以把环刚体的半径尽可能地调得更大,这样在视觉上环与针的重叠也就不那么明显了。
进针
进针是整个游戏的核心部分,也是最难模拟的地方。
进针后
两个二维平面的物体交错是不能产生“穿过”效果的:
除非把环分成前后两部分,这样层级关系才能得到解决。但是由于环贴图是逐帧图,分两部分的做法并不合适。
最后找到的解决办法是利用视觉错位来达到“穿过”效果:
具体做法是,当环被判定成功进针时,把环刚体去掉,环的逐帧图逐渐播放到平放的那一帧,rotation 值也逐渐变为 0。同时利用 CreateJS 的 Tween 动画把环平移到针底。
进针后需要去掉环刚体,平移环贴图,这就是上文为什么环的贴图必须由 CreateJS 负责渲染的答案。
伪代码:
// Object RingafterCollision (waterful) { // 平移到针底部 createjs.Tween.get(this.texture) .to({y: y}, duration) // 消去刚体 Matter.World.remove(waterful.engine.world, this.body) this.body = null // 接下来每一 Tick 的更新逻辑改变如下 this.update = function () { const texture = this.texture if 当前环贴图就是第 0 帧(环平放的那一帧){ texture.gotoAndStop(0) } else { 每 5 个 Tick 往前播放一帧(相隔多少 Tick 切换一帧可以凭感觉调整,主要是为了使切换到平放状态的过程不显得太突兀) } // 使针大概在环中央位置穿过 if (texture.x < 200) ++texture.x if (texture.x > 213 && texture.x < 300) --texture.x if (texture.x > 462) --texture.x if (texture.x > 400 && texture.x < 448) ++texture.x // 把环贴图尽快旋转到水平状态 let rotation = Math.round(texture.rotation) % 180 if (rotation < 0) rotation += 180 if (rotation > 0 && rotation <= 90) { texture.rotation = rotation - 1 } else if (rotation > 90 && rotation < 180) { texture.rotation = rotation + 1 } else if (frame === 0) { this.update = function () {} } } // 调用得分回调函数 waterful.score()}
进针判断
进针条件
1. 到达针顶
到达针顶是环进针成功的必要条件。
2. 动画帧
环必须垂直于针才能被顺利穿过,水平于针时应该是与针相碰后弹开。
当然条件可以相对放宽一些,不需要完全垂直,下图红框内的6帧都被规定为符合条件:
为了降低游戏难度,我规定超过针一半高度时,只循环播放前6帧:
this.texture.on('animationend', e => { if (e.target.y < 400) { e.target.gotoAndPlay('short') } else { e.target.gotoAndPlay('normal') }})
3. rotation 值
同理,为了使得环与针相垂直,rotation 值不能太接近 90 度。经试验后规定 0 <= rotation <= 65 或 115 <= rotation <= 180 是进针的必要条件。
下图这种过大的倾角逻辑上是不能进针成功的:
初探
一开始我想的是把三维的进针做成二维的“圆球进桶”,进针的判断也就归到物理事件上面去,不需要再去考虑。
具体做法如下图,红线为针壁,当环刚体(蓝球)掉入桶内且与 Sensor (绿线)相碰,则判断进针成功。为了使游戏难度不至于太大,环刚体必须设置得较小,而且针壁间距离要比环刚体直径稍大。
这种模拟其实已经能达到不错的效果了,但是一个技能打破了这种思路的可能性。
产品那边想做一个放大技能,当用户使用此技能时环会放大,更容易套中。但是在桶口直径不变的情况下,只是环贴图变大并不能降低游戏难度。如果把环刚体变小,的确容易进了,但相近的环之间的贴图重叠范围会很大,这就显得很不合理了。
改进
“进桶”的思路走不通是因为不兼容放大技能,而放大技能改变的是环的直径。因此需要找到一种进针判断方法在环直径小时,进针难度大,直径大时,进针难度小。
下面两图分别为普通环和放大环,其中红色虚线表示水平方向的内环直径:
在针顶设置一小段探测线(下图红色虚线),当内环的水平直径与探测线相交时,证明进针成功,然后走进针后的逻辑。在环放大时,内环的水平直径变长,也就更容易与探测线相交。
伪代码:
// Object Ring// 每一 Tick 都去判断每个运动中的环是否与探测线相交update (waterful) { const texture = this.texture // 环当前中心点坐标 const x0 = texture.x const y0 = texture.y // 环的旋转弧度 const angle = texture.rotation // 内环半径 const r = waterful.enlarging ? 16 * 1.5 : 16 // 根据旋转角度算出内环水平直径的开始和结束坐标 // 注意 Matter.js 拿到的是 rotation 值是弧度,需要转成角度 const startPoint = { x: x0 - r * Math.cos(angle * (Math.PI / 180)), y: y0 - r * Math.sin(angle * (Math.PI / 180)) } const endPoint = { x: x0 + r * Math.cos(-angle * (Math.PI / 180)), y: y0 + r * Math.sin(angle * (Math.PI / 180)) } // mn 为左侧探测线段的两点,uv 为右侧探测线段的两点 const m = {x: 206, y: 216}, n = {x: 206, y: 400}, u = {x: 455, y: 216}, v = {x: 455, y: 400} if (segmentsIntr(startPoint, endPoint, m, n) || segmentsIntr(startPoint, endPoint, u, v)) { // 内环直径与 mn 或 uv 相交,证明进针成功 this.afterCollision(waterful) } ...}
判断线段是否相交的算法可以参考这篇文章:谈谈"求线段交点"的几种算法
这种思路有两个不合常理的点:
1.当环在针顶平台直到静止时,内环水平直径都没有和探测线相交,或者相交了但是 rotation 值不符合进针要求,视觉上给人的感受就是环在针顶上静止了:
解决思路一是通过重力感应,因为设置了重力感应,只要用户稍微动一下手机环就会动起来。二是判断环刚体在针顶平台完全静止了,则给它施加一个力,让它往下掉。
2.有可能环的运动轨迹是在针顶划过,但与探测线相交了,此时会给玩家一种环被吸下来的感觉。可以通过适当设置探测线的长度来减少这种情况发生的几率。
优化
资源池
资源回收复用,是游戏常用的优化手法,接下来通过讲解气泡动画的实现来简单介绍一下。
气泡动画是逐帧图,用户点击按钮时,即创建一个 createjs.Sprite。在 animationend 时,把该 sprite 对象从 createjs.Stage 中 remove 掉。
可想而知,当用户不停点击时,会不断的创建 createjs.Sprite 对象,非常耗费资源。如果能复用之前播放完被 remove 掉的 sprite 对象,就能解决此问题。
具体做法是每当用户按下按钮时,先去资源池数组找有没有 sprite 对象。如果没有则创建,animationend 时把 sprite 对象从 stage 里 remove 掉,然后 push 进资源池。如果有,则从资源池取出并直接使用该对象。
当然用户的点击操作事件需要节流处理,例如至少 300ms 后才能播放下一个气泡动画。
伪代码:
// Object WaterfulgetBubble = throttle(function () { // 存在空闲泡泡即返回 if (this._idleBubbles.length) return this._idleBubbles.shift() // 不存在则创建 const bubble = new createjs.Sprite(...) bubble.on('animationend', () => { this._stage.removeChild(bubble) this._idleBubbles.push(bubble) }) return bubble}, 300)
环速度过快导致飞出边界
Matter.js 里由于没有实现持续碰撞检测算法(CCD),所以在物体速度过快的情况下,和其他物体的碰撞不会被检测出来。当环速度很快时,也就会出现飞出墙壁的 bug。
正常情况下,每次按键给环施加的力都是很小的。当用户快速连续点击时,y 方向累积的力也不至于过大。但还是有玩家反应游戏过程中环不见了的问题。最后发现当手机卡顿时,Matter.js 的 Tick 没有及时触发,导致卡顿完后把卡顿时累积起来的力一次性应用到环刚体上,环瞬间获得很大的速度,也就飞出了游戏场景。
解决方法有两个:
给按钮节流,300ms才能施加一次力。
每次按下按钮,只是把一个标志位设为 true。在每个 Matter.js 的 Tick 里判断该标志位是否为 true,是则施力。保证每个 Matter.js 的 Tick 里只对环施加一次力。
伪代码:
btn.addEventListener('touchstart', e => { this.addForce = true})Events.on(this._engine, 'beforeUpdate', e => { if (!this.addForce) return this.addForceLeft = false // 施力 this._rings.forEach(ring => { Matter.Body.applyForce(ring.body, {x: x, y: y}, {x: 0.02, y: -0.03}) Matter.Body.setAngularVelocity(ring.body, Math.PI/24) })})
结语
如果对「H5游戏开发」感兴趣,欢迎关注我们的专栏
本文分享自微信公众号 - 凹凸实验室(AOTULabs)。
如有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。