张欣炜
汪元标
github repo:
- https://github.com/zhangxwww/hamswing (游戏主体代码)
- https://github.com/agil27/hamswing-sub (排行榜相关的微信子域代码)
小程序码
在游戏中,玩家将会扮演一只森林中仓鼠,利用一根可伸缩钩爪抓住云朵,借助抓钩的力量前进。在路途中,玩家会遇到各种各样的道具,提供各种 buff,也会遇到各种怪物,阻挡仓鼠的前进。玩家需要合理利用抓钩,躲过怪物的攻击,或是借助 buff 的力量,击败面前的怪物。
游戏设计最初的灵感来自于蜘蛛侠,利用蜘蛛丝在城市中来回穿梭。随后演变成一只青蛙,通过吐舌头的方式在森林中前行。最后在游戏开发的过程中逐渐主角演变成了最终版的仓鼠。由于在青蛙阶段,我们就已经完成了代码的主体框架,所以很多代码的命名与实际所代表的物品存在一些差异。主角仓鼠的英文名为ym,这其实是我们的一位共同好友外号"原妈"的缩写,因为青蛙的灵感来自于其头像(如下);抓钩在代码中是用 tongue 表示的,即青蛙的舌头。
游戏中,玩家通过手指按压屏幕来操控仓鼠。按下时,仓鼠会向前方 45 度方向发射一根钩索。这根钩索会抓住屏幕顶部的云层。松开手指,仓鼠会收起钩索,并依靠惯性继续向前飞行。
游戏中玩家会遇到两种道具,星星与蘑菇。吃掉星星后,玩家可以在 3 秒内得分翻倍。而吃掉蘑菇后,玩家获得 5 秒的无敌时间。
同样,玩家会遇到两种怪物,被怪物碰到就会 gg。当然,如果在无敌状态下碰到怪物,可以击败对方。此外,如果玩家操控仓鼠踩到怪物的头,也可以打败怪物。打败怪物后,可以获得额外的加分。
进入游戏时,页面如下所示
页面中央为游戏 logo,玩家可以选择进入教学模式,直接开始游戏,或查看好友排行。
游戏中,仓鼠位于屏幕左侧,得分位于屏幕中央。在分数旁边会显示当前拥有的 buff(双倍得分/无敌状态)。
Gameover 时界面如下所示。这里展示了玩家的最终得分,玩家可以选择再来一次,或是返回主菜单。
本游戏采用 cocos 引擎。场景之间的切换使用 loadScene 函数。游戏的逻辑控制通过组件的生命周期回调实现。
游戏的主体逻辑主要由 canvas 控制,包括按照一定时间与距离间隔生成道具、怪物、云,接收点击事件并分派给其他组件,更新背景及各种物品的位置,记录得分并将分数发送给负责显示分数的组件,清理视野范围外的物品,以及处理 gameover 的情况。
仓鼠相关的逻辑由 ym.js 控制。之所以叫 ym.js,这是上文谈到过的命名的历史遗留问题。在完成这部份代码的时候,游戏的主角还不是仓鼠...... 这部分代码主要处理与仓鼠相关的事件,例如点击屏幕后应该发射钩索,松开手指后应该收回钩索,吃了星星以后会加速,吃了蘑菇以后无敌,等等。
钩索相关的逻辑位于 tongue.js 中,负责钩索的伸长,缩短,钩住(attach)等行为。
怪物相关的逻辑位于 monster.js 与 ghost.js 中。在这两段代码中,我们为怪物增加了更多的动作,包括上下移动,变大变小,以及被玩家踩死以后的下落。
开始界面的逻辑位于 start.js 中,游戏的场景加载,排行榜的显示与关闭与它有关。
教程模式的逻辑位于tutorial.js中,对普通模式略作修改,降低了难度(不会一开始就出现星星、蘑菇或怪物,出现相应提示之后才会产生这些精灵),并增加了定时出现的提示。
排行榜的展示位于rankPanel.js和游戏子域(位于第二份github仓库中)index.js中,rankPanel主要解决游戏主流程和开放数据域的交互问题,而index.js主要解决游戏数据的更新、读取以及排行榜的绘制。
游戏的音效由 audioController.js 控制。这部份代码会监听各种事件,包括吃星星,吃蘑菇,等等,并播放相应的音效。
这款游戏的重点在于物理引擎的使用,包括刚体组件,关节组件,以及刚体之间的碰撞处理。
仓鼠需要在森林之中来回飞跃,这需要使用刚体组件来实现其受到重力的效果。将该刚体组件的类型设置为 dynamic,并设置 gravity scale 为 1。
为了能够让仓鼠通过抓钩在森林中来回摆动,我们使用了 ropeJoint 这个关节组件。这个组件会限制两个刚体间的最大距离,达到绳子的效果。ropeJoint 一端连接仓鼠,另一端连接一个叫 ceiling 的刚体。ceiling 位于游戏画面的最上端,为仓鼠的抓钩提供一个附着点。
游戏中松开抓钩,可以通过将这个 ropeJoint 的 active 设置为 false 来实现。而发射抓钩时,需要首先计算附着点的位置(也就是 ropeJoint 的 connectedAnchor 属性),然后激活 ropeJoint.
游戏中的碰撞处理,是通过碰撞组件实现的。借助 polygonCollider,我们可以更加精细的控制碰撞范围。在仓鼠的脚本中添加碰撞的回调函数,即可处理仓鼠与其他物品的碰撞事件。
正如上文所说,发射抓钩时,首先会计算附着点的位置并记录下来。为了显得更加真实,ropeJoint 并不是在发射抓钩的同时激活的。发射抓钩后的每一帧,都会计算当前仓鼠距离附着点的距离,以及所成的角度。同时,抓钩的长度也会逐渐增加。用当前抓钩的长度,以及所成的角度来更新抓钩的位置,就可以实现抓钩逐渐变长的效果。当抓钩的长度超过当前仓鼠到附着点的距离时,激活 ropeJoint,并将 maxLength 属性设为当前二者的距离。
收回抓钩时类似。在松开抓钩时将关闭 ropeJoint。之后每一帧更新角度,并缩短抓钩长度。当抓钩长度小于 0 时就不再显示抓钩了。
由于我们使用一张长方形的图片来绘制抓钩,只需要简单的更改其 width 属性,通过该属性来拉伸/压缩图片,实现抓钩的伸长与缩短。但是这样处理会使得抓钩末端的钩子同样被拉伸/压缩。我们通过将绳子与钩子分离的方式解决了这个问题。将钩子作为绳子的子节点,始终保持钩子位于绳子的最前端。通过这样的方式实现了拉伸/压缩绳子时钩子不变的效果。
我们的实现方案是,在游戏的画布Canvas下设立一个Cocos提供的主摄像机Main Camera,然后让“相对来看位置不变的”对象的位置逐帧随着摄像机的位置更新,从而达到各种静态控件(如得分、文字提示、结算面板)跟随主角镜头视角的效果。
在其中我们曾经尝试对主角增加摄像机并进行复杂的坐标转换,但效果不佳会出现轻微抖动。
经过前期的试错,我们最终的方案为,使用两张完全相同的背景图片,交替显示这两个图片。每当左边的图片位于屏幕范围以外时,将其移动到右边图片的右边。同时由于仓鼠无法向左边发射抓钩,所以不需要考虑右边的图片需要移动到左边的情况。
由于游戏中使用了很多异步执行的函数,带来了很多 bug。例如仓鼠吃掉蘑菇以后会进入无敌状态并维持 5 秒,这 5 秒内会持续翻滚。5 秒后该翻滚的动作会被停下(stopAction)。但是如果在这 5 秒内由于触及地图底部而死,并重新开始游戏,那么 5 秒后的 stopAction 就会作用在 null 上。再比如游戏重启后,之前的update函数进程会仍然异步执行,导致出现访问错误。类似这样的问题花费了我们很多的时间,我们也在异步执行的地方都加入了各种判断来保证程序的鲁棒性(如判断 if(this.node)
来确认不会出现中断游戏流程的错误)。
此外,一个很严重的错误是由于不清楚回调函数机制带来的。由于Cocos脚本是面向对象的机制,很多时候需要在事件响应中对于对象本身的方法或者成员做一些调用,但是由于Cocos运行在严格模式之下,在回调函数执行的过程中this指针会失效,带来很多cannot read the property '...' of undefined
的错误,经过长期的调研我们最终才发现应该采用预先保存this指针的方法来解决问题。当然,一些早期的问题,比如重启时this指针失效,我们由于未了解到这个机制采用了重构代码的方式解决,损失了一些模块化和分层设计的特性,使得代码架构劣化,也算是一个遗憾。
将小游戏分包后发布到微信平台时同样需要了许多问题。加载分包时,微信 web 开发工具报错 net::ERR_BLOCKED_BY_CLIENT
。我们发现这是因为加载分包与加载场景中的图片位于同一个生命周期回调内,导致某些图片在加载完成之前被请求。经过一番探索,并没有在网上找到解决这类问题的方案。最终我们选择在所有场景之前,加入一个 preload 场景,作为初始场景。该场景负责加载分包,并在加载完成之后切换到开始游戏的场景。
游戏的排行榜使用到了微信的开放数据域。在Cocos 2.0.1版本之后,提供了WxSubDomain的控件来解决。然而由于分包加载等多种原因,Cocos的该控件由于其包装了微信等原生api的特性导致bug调试几乎无法进行(针对引擎本身对于微信api的调用报错),同时存在大小适配、体积臃肿、性能低下、显示漂移等多种问题,我们最终采用微信小游戏的raw api,运用html的canvas绘图完成了排行榜的绘制,并且通过cocos老版本的sharedCanvas来共享texture,运用wx.postMessage
来实现数据的云存储,此时开放数据域的代码仅有7k,轻便高效。
在制作动画时,使用单个的图片文件体积大,调用麻烦。为了解决这个问题,使用了Shoebox和TexturePacker两款软件,完成了原始Spritesheet的拆分,雪碧图和plist的制作,在保证特效充足的情况下压缩图片资源的大小。
我们在不同品牌,不同屏幕尺寸的移动设备上进行过测试,对于屏幕的适配较好,同时没有明显的卡顿现象。
游戏的代码逻辑同样经过我们的大量测试,并没有发现异常情况。
部分机型可能会因为微信的小游戏测试版限制出现游戏加载黑屏的情况,此时打开/关闭调试模式然后重新进入即可。
在游戏中运用了多种多样的动画特效设计,如抓钩伸长、踩怪兽头击退、怪兽的动作、移动或放缩、无敌模式变色和旋转特效、仓鼠不停跑动、星星旋转、蘑菇跳动,除了在网上搜寻丰富的雪碧图资源外,我们也对事件处理增加了大量的action,手动原创设计了仓鼠奔跑的动画(可参见ext文件下ym目录的设计文件)
此外,游戏中精美的文字提示、logo几乎都是我们自己制作或加工的,部分图像也是自己设计的(参见ext文件下的cloud目录)
小游戏命名有传统X一X,跳一跳、弹一弹、飞一飞、投一投几乎都被人玩烂了,我们出于操作简单(仅非定点触摸)、无限性、趣味性的原则思考了很久,最终确定了摆一摆的物理形式,的确从没出现过,又在排除了落地奔跑、空中二段跳等落入窠臼的运动机制后想出了滞空躲避的游戏目标,并通过数值设计解决了可玩性问题。从自己和同学沉迷的情况来看,这款游戏的可玩性是很成功的。
既然是无限的游戏目标,好友排行榜的社交功能自然是顺水推舟一定要完成的,看到好友的高分,很难不心痒去游玩我们的《仓鼠摆一摆》。
为了保证游戏的可玩性,我们做了很多测试和创新
模仿Flappy bird的机制我们增加了重力加速度而不是普通的匀速下坠,并且通过合理设置加速度的值使得游戏具有适合的操作难度。
为了保证游戏体验我们在松开绳子的一瞬间给予主角一个切线方向的加速,一方面是模拟绳子的弹性,同时也避免因为能量守恒带来的用户不能飞高的不好的游戏体验。
在撞到“天花板”时,有很多处理思路,一开始设置为反弹固定速度,后来发现玩家可以利用这个机制作弊;改为原速反弹,则速度太快,玩家无法驾驭;最终采用损失一定比例动量的方式达到了权衡。
蘑菇、星星和怪物的产生一开始是打算提高怪物数量降低奖励的比例来控制游戏难度,后来发现游戏难度过大无法控制,最终采用了固定时间间隔根据随机数决定产生怪物或奖励的方法,保证了可以轻松上手但是很难拿高分。并通过增加怪物移动和缩放来加大一定难度。同时,无敌和双倍时间设为5秒并给了清楚的UI提示(但是没给倒计时),并给予了踩头和无敌模式消灭怪物的奖励,鼓励位置把控和时间把控操作比较精确的玩家通过击杀怪物来获得高分,提升了游戏体验。
在游戏场景中,仓鼠向前飞跃,前方会不断生成物品,包括星星、蘑菇、云,等等。大部分物品都会很快离开视野范围。如果不及时清理这些物品,那么每一帧的运算量会不断增长,其中包括(1) 更新这些物品坐标,(2) 这些物品的动画效果,(3) 降低全局事件监听的效率,(4) 降低查找结点的效率,最终造成游戏的卡顿。我们通过每过一段时间(100ms),及时清理屏幕范围外的物品,避免了较长游戏时间后的卡顿问题。
此外,对于在仓鼠前方生成物品,更新分数等操作,它们并不需要每一帧都执行。我们通过 setInterval 或 setTimeOut 的方法降低了每一帧的运算量。
在排行榜的设计中,摒弃了Cocos自带控件固定刷新率的用法,每当打开或者翻页的时候再向子域发送重绘的消息,减轻了前端服务的负担。
张欣炜主要完成了项目构建,并对 cocos 引擎的进行了早期的探索与踩坑,包括物理引擎的使用,交互判定,事件响应及组件间消息传递的实现方式。实现了游戏主体的物理逻辑,创建游戏中的模型,并为游戏添加了音效。
汪元标主要提出了游戏创意,设计了UI框架,解决了无限背景、镜头追踪、游戏中止、场景切换的一些困难,实现了游戏流程逻辑,添加了排行榜、教程模式等内容,制作了游戏的动画、动作特效,并进行了一些美术设计。