竟然在刮胡子时想到做这么一款小游戏 ——《刮胡子》

2,335 阅读10分钟

我正在参加掘金社区游戏创意投稿大赛个人赛,详情请看:游戏创意投稿大赛

本篇文章中《刮胡子》这个小游戏使用了H5游戏引擎Egret进行开发 🐶 ,游戏体验地址与源码分享地址放在下方,游戏PC端与移动端都可进行体验。

🌟 游戏体验地址

💡 游戏源码分享

创意起源

某天早晨,醒了赖在床上,切着APP刷着手机,看到掘金又在搞创意投稿大赛了,心里十分平静也无在意,心想我已经一把年纪已无什么牛X创意,还是把机会留给年轻人吧。

又在床上瘫了一会,发现已无困意,于是起床洗脸刷牙。

到了厕所看到镜子前的自己,满脸胡渣。原来自从上家公司离职之后,已经失业许久。外头大厂裁员,疫情反复,中小厂用人的需求越来越少,让求职变得越来越难。我已经不知道自甘堕落了多少天。

看着自己脸上满脸的胡子,实在搞笑,想着也该剃一剃了。

开着剃须刀剃着胡须,听着电动剃须刀发出的嗡嗡的响声,不由让我想起了小时候玩GBA中「节奏天国」里中的拔胡子,这个关卡确实让人印象深刻,跟着节奏把胡子拔下来。

不过我想跟更着节奏拔胡子虽然好玩但是不够爽快,如果我来做和胡子有关的游戏,我会做一款拿着剃须刀把脸上胡子剃的四处乱飞小游戏,应该非常意思。

咦~ 既然有了创意想法,而且掘金也正在搞游戏创意投稿大赛”,那咱们就开始动手吧!

创意实现

因为之前学过点H5游戏引擎Egret,所以这个小游戏使用它进行开发。

游戏的入口菜单

首先咱们要做的就是画第一个场景就是菜单界面,所有游戏都是从菜单开始的~

WeChat105fc061e7044e4394f5493b941c968a.png

额,好了,简简单单的。这个小游戏就叫做「刮胡子」吧,而且还脸皮很厚的写上了自己的大名。

菜单界面中有了开始游戏按钮,咱们就可以点击开始游戏来进入我们的游戏场景界面。

【相关源码文件路径】

首先画个脸吧

接下来咱们就要开始画游戏场景了。

我的想法是既然是刮胡子,那么咱们就要先弄出一个脸,

首先去网上找了下,似乎没有符合我想法的素材,

人也快穷的吃不起饭,没有钱请UI帮我设计一个。

那么,我就自己画吧,

于是我就一边琢磨着一边画,

最终就画出了这么一张大脸。

WeChat5f9efbabdfb3d4be7ad4c9523eaf72cb.png

其实也是我自己脑海中刮胡子的表情,皱褶眉头仰着下巴...

感觉这个配色有点太单调了,于是又给脸部皮肤配了一点颜色

WeChat9780b32dbd41839f8508b49078b1a6bc.png

然后又再加了个呼吸动画和眨眼让它变得生动起来

Kapture 2022-04-15 at 09.28.42.gif

没想到这个小玩意最后看起还挺可爱的。

【相关源码文件路径】

游戏核心 “胡子”

接下来就是游戏中最重要的部分“胡子”,我的想法是在游戏角色脸部下巴部分绘制一些线段。

胡子的随机生成

刚想下手时就发现了一个问题,胡子的线段应该是随机分布,而且主角的脸是一个圆形。

那我们怎么在一个圆形里边随机绘制胡子的线段呢。

于是网上研究了一通,

发现可以用圆内均匀分布坐标的算法法来实现这个想法。

以下是随机生成胡子坐标的函数,绘制每根胡子的时候会调用这个函数来获取坐标。

  /**
   * 随机生成胡须在脸上的坐标
   * @param r 为脸圆的半径
   * @param centerX 为脸在游戏场景的x轴中心点坐标
   * @param centerY 为脸在游戏场景的y轴中心点坐标
   * @param tryCount 为重试次数
   * @returns 随机生成胡子的坐标
   */
 private randomPoint(r:number,centerX:number,centerY:number,tryCount:number = 0):[number,number]{
       // 随机生成一个角 [0,2*pi]
       let theta = 2 * Math.PI * Math.random();
       // 随机生成距离圆心的长度 [0,r]
       let len = Math.sqrt(Math.random()) * r;

       let x = centerX + len * Math.cos(theta); 
       let y = centerY + len * Math.sin(theta);

       //  当生成y值太小则重新生成一个坐标,重新生成次数为3次,都为失败则不绘制
       if( y < this.faceY - BREAS_Y && tryCount != 3 ){
           return this.randomPoint(r,centerX,centerY,++tryCount)
       }
       return [x,y];
 }

这里的r参数会传入会传入绘制主角脸时的半径,调用randomPoint这个函数会在这个主角的圆脸范围中随机生成一个坐标让我来绘制胡子的位置。

然后调用randomPoint时为什么要判断y值太小时要重新调用函数计算一个坐标出来呢?

WeChatec0503a14fc253aeae82e05a3490997f.png

图中蓝色点是这个脸的中心点,红色的点的y坐标的就是我希望生成的坐标的最小y坐标,如果小于这个红色点的y坐标的话,胡子随机生成的坐标可能就要到嘴巴眼睛部分了 😂 。所以当生成的随机坐标的y轴小于这个红点坐标的y轴时则需要重新生成一个随机坐标出来,经过测试通常重新计算2之3次就可以了。

然后让我们来看看绘制胡子的效果

WeChat3abe151672ee23aba4cabd67c062447d.png

哇哦,不错不错

这样最基本胡子的绘制就完成啦。

如果对上面说的圆内均匀分布坐标算法原理感兴趣的朋友们可以看看这位大哥写的这篇文章

胡子的方向

画完之后,看着胡子总觉得哪里怪怪的,

这才发现胡子的方向每根都是一样的,垂直的长在脸上,

这时我们需要给胡子做一个方向。

我的想法是胡子的朝着脸上中间的一个点进行角度旋转。

这里我用了 [脸绘制的中心X坐标,脸绘制的中心Y坐标 * 0.5] 作为要旋转角度的基准坐标,因为在调试中觉得这个坐标计算完的旋转角度是我觉得比较合适的。

这里咱们需要用到 两点坐标计算坐标轴间的夹角角度

因为咱们需要x轴的夹角角度所以用的公式是 :

Math.atan2(y1 - y2 , x1 - x2) * 180 / Math.PI

// 计算出胡子的角度
const beardAngle = Math.atan2((this.faceY * 0.5) - y , this.faceX - x) * 180 / Math.PI;
// 设置胡子的角度,因为我画的是竖线还需再减个90度
beard.rotation = beardAngle - 90;

接下来咱们看看效果

WeChat153fc27d0e919c944c373e9637c30fae.png

棒~ 现在每根胡子都有了自己的方向。

对于 两点坐标计算坐标轴间的夹角角度 的计算方式可以看看这篇文章

把胡子刮下来

绘制胡子方便基本就大功告成啦,接下来咱们要做的就是胡子的交互方面。

游戏叫刮胡子,那现在有了胡子我们就要想怎么把它刮下来。

最开始我的做法为每根胡子绑定TouchMove事件,当手指或者鼠标在游戏角色脸上划过胡子的时候,胡子会进行做一个y轴往下偏移一段距离模拟掉落的动画,执行完动画后把胡子删除。

Kapture 2022-04-15 at 09.48.30.gif

在测试这个效果的时候发现,胡子的点击范围太小了,尤其在移动端上有时候会触碰不到,导致胡子刮不下来。这时老前端们肯定就懂了,那就扩大胡子点击范围吧。

于是我给每根胡子都扩大了点击范围。

WeChat239882168899edbf20160f89688b69a2.png

但是新的问题又出现了,当我们点击或者触碰比较近的两个胡子相交部分的时候,我们期望是两个胡子都可以触发刮掉的事件。但是实际情况是,当点击两根胡子相交的点击范围时,只会触发其中最后绘制z轴在最前面的那一根,另外一根会被挡在后面,不会触发事件。

类似这种情况,像Web网页开发时有Dom事件捕获,就很容易就解决这种问题。

但是像Egret游戏引擎中的元素是绘制在画布上的,并没有事件流这种东西,需要自己额外重新实现。

那只能自己想办法处理了。

  • 思路一

在脸上监听TouchMove事件,加大手指或者鼠标的判断范围,然后每次手指或者鼠标在移动的时候,在触发事件中判断当前还在脸上的胡子有没有进入判断范围的,如果判断进入范围内就把胡子给刮掉。

但是这个方法十分消耗性能。比如脸上有100根胡子时,当鼠标或者手指正在不停的操作时,每一帧都需要去遍历判断100根胡子进入了范围没有。

Kapture 2022-04-15 at 10.02.20.gif

(上图,假设跟随鼠标移动圆圈就是扩大的判断范围)

  • 思路二

这个方法也是目前在使用的方法,仍然监听是每一根胡子的TouchMove事件,当手指触碰到某根胡子触发到事件时,会判断这个胡子的周围有没有胡子,如果有的话就一起刮掉。

使用这个方法就无需在操作时每一帧都的去进行判断,当其中一根胡子触发事件了才会去进行判断,这样做的话性能会好很多。

那么我们就要想办法知道目标位置坐标周围是不是有着其他胡子,

在这里我使用 计算两点之间的距离 的计算方法得到两个点之间的距离,然后有了这个距离值,我们就可以用来判断是否在坐标的周围。

这里使用 计算两点之间的距离 的公式是:

Math.abs(Math.sqrt(Math.pow(x1 - x2,2) + Math.pow(y1 - y2,2)))

/** 
 * 通过目标位置坐标找到周围的胡子
 * @param x 目标胡子的x轴位置
 * @param y 目标胡子的y轴位置
 * @return 目标位置的胡子数组
 */
private findClosestBeards(x:number,y:number){
    // this.beards为一个数组,当游戏中创建完毕的胡子都会存入到this.beards中。
    const nearBeards = this.beards.filter(beard=>{
        // 当胡子在上次被 findClosestBeard 找到后进行剔除操作时就会给它设置上drop等于true的标识
        // drop是true的胡子下次执行findClosestBear遍历胡子就会跳过它
        if(beard.drop) return false;
        // 计算每根胡子坐标与目标坐标之间的距离
        const distance = Math.abs(Math.sqrt(Math.pow(x - beard.x,2) + Math.pow(y - beard.y,2)))
        return distance <= 25
    })
    return nearBeards
}

好了,有了findClosestBeard函数,我们把它放到我们胡子的触碰事件中进行运用。

/** 
 * 胡子的触碰触发事件
 */
private touchBeard = async (event:egret.TouchEvent)=> {
   // 通过 findClosestBeard 找到位置周围的胡子
   const closestBeards = this.findClosestBeards(event.$stageX,event.$stageY);
   // closestBeards 找到周围胡子的胡子数组把他们进行剔除
   closestBeards.forEach(beard=>this.removeBeard(beard))
}

这里 findClosestBeard 传进去的 event.stageXevent.stageY 就是鼠标或者手指当前的位置。为什么不用触碰到的那根胡子的位置坐标呢,因为通过上面已经知道我为所有胡子扩大了点击范围,有时候触碰到的胡子位置是在手指的上方或者下方,我希望是触到某个胡子时,是以手指或者鼠标的坐标为中心点找到周围的胡子进行刮掉的操作。

胡子四处乱飞的效果

对于刮掉胡子时的掉落动画效果还不是很满意的,和想象中的游戏呈现效果还有一段的距离。

通常我们去理发厅剪头发时,Tony拿着理发器对着我们头发咔嚓咔嚓狂剪时,头发会飞的到处都是。像类似于这种效果是我比较期望在游戏中刮胡子时能表现出来的效果。

于是我就想如何在游戏中的胡子和现实中头发一样,当被剪掉时可以会一个四处乱飞的效果。

这个时候我就想到了物理引擎。

使用物理引擎来对根据鼠标或者手指的方向来做一个方向的力来对胡子做一个弹飞的效果,然后胡子随着重力飘落到地下。

Egret文档中推荐使用的物理引擎是 p2.js,于是我使用了它。

首先在初始化的地方使用p2.js创建我们的物理世界

// 初始化物理世界,设置物理世界的重力
this.word = new p2.World({ gravity:[0,0.01] });
// 让刚体进行睡眠状态
this.word.sleepMode = p2.World.BODY_SLEEPING;

然后在绘制创建胡子时创建刚体,在刚体中设置胡子贴图。

因为Egret中使用单位的是px,p2.js使用单位是MKS(米 千克 秒),

所以在p2.js设置物理元素位置时需要进行转换,

换算时使用的factor变量我设置为50,相对于p2.js的一米长度是Egret中屏幕的50px。

// 绘制胡子函数
private drawBeard(x:number,y:number,face:egret.Sprite):BeardItem{
    // 设置胡子碰撞盒
    var boxShape = new p2.Box({ radius:20 / factor });
    // 设置胡子的刚体
    const boxBody : p2.Body = new p2.Body({  
        mass:1,
        position:[x/factor , y/factor],
        collisionResponse:false,
        ccdIterations:false 
    });
    // 把碰撞盒加入刚体
    boxBody.addShape(boxShape);
    // 创建胡子的精灵对象
    const shp:egret.Sprite = new egret.Sprite();
    
    ...
    
    // 把刚体加入世界
    this.word.addBody(boxBody);
    // 把胡子贴图加入刚体
    boxBody.displays = [shp];
    
    ...
    
    // this.breads 中存入的胡子对象
    // shp为点击范围圆形,line为胡子线段,body为p2刚体,drop为是否刮掉标识
    const beardItem = { shape:shp,line:line,body:boxBody,drop:false }
}

接下来游戏主角脸上的绑定了TouchStart事件,记录手指或者鼠标开始的位置设置在静态属性 touchPositionRecrod中。

// 记录手指或者鼠标开始的位置
private touchStart(event:egret.TouchEvent){
    this.touchPositionRecrod = { x:event.$stageX , y:event.$stageY }
}

还是那个熟悉的touchBeard函数,这次添加了一些新东西。

我们通过之前记录在 touchPositionRecrod 得知了鼠标或者手指的起始坐标,那我们可以通过手指或者鼠标接下来滑动到胡子位置计算得到当前移动的方向,然后对要进行刮掉的胡子做一个方向的力。

/** 
 * 胡子的触碰触发事件
 */
private touchBeard = async (event:egret.TouchEvent)=> {
    // 通过 findClosestBeard 找到位置周围的胡子
    const closestBeards = this.closestBeards(event.$stageX,event.$stageY);
    // 上下的位置 -1 为上 1 为下
    const verticalDirection = event.$stageY > this.touchPositionRecrod.y ? -1 : 1;
    // 左右的位置 -1 为左 1 为右
    const horizontalDirection = event.$stageX > this.touchPositionRecrod.x ? 1 : -1;
    // 重新记录当前手指或者鼠标位置
    this.touchPositionRecrod = { x:event.$stageX , y:event.$stageY }
    
    closestBeards.forEach(beard=>{
        // 对胡子绑定的刚体施加一个力
        beard.body.applyForceLocal([0.1 * horizontalDirection,0.1 * verticalDirection],[beardItem.shape.x / factor,beardItem.shape.y / factor]);
        // closestBeards 找到周围胡子的胡子数组把他们进行剔除
        this.removeBeard(beard)    
    })
}

最后一步,让p2.js的世界动起来。

Egret 中监听帧事件

this.addEventListener(egret.Event.ENTER_FRAME,this.Update,this);

Egret帧事件中与p2.js的世界联系起来,

在每一帧中得到p2.js胡子元素当前的物理位置,Egret 中的胡子绘图在画面中的位置也跟着一起更新。

private Update(){    
    // 设置p2世界更新
    this.word.step(2.5);
    /// 获取所有drop等于true的胡子,就是被刮掉的胡子
    //  当胡子中刚体元素位置与当前胡子元素在画面上的位置不一致时,说明正在执行物理运动,则对胡子在画面上的位置进行更新。
    this.beards.filter(i=>i.drop).forEach((i)=>{
        if(Math.floor(i.shape.x) !== Math.floor(i.body.position[0] * factor)) i.shape.x = i.body.position[0] * factor;
        if(Math.floor(i.shape.y) !== Math.floor(i.body.position[1] * factor)) i.shape.y = i.body.position[1] * factor;
    })
}

这样咱们就为每根胡子加入了物理效果,让我们看看游戏的效果。

Kapture 2022-04-15 at 10.08.17.gif

以上文中提到的 p2.js 如果有感兴趣的同学们,这是它的官方文档

【相关源码文件路径】

如何让这个创意更好玩

我们游戏创意部分已经基本完成,接下来要想的就是怎么让它好玩起来。

基础玩法

于是我就把刮胡子做成一个拼手速的小游戏。

看看规定在限定的时间内能够刮掉多少胡子,当胡子刮掉后又会有新的胡子长出来 😁,刮掉胡子越多的玩家得分越高 。

Kapture 2022-04-15 at 10.11.36.gif

奖励

为了鼓励玩家们提高手速,我加入了奖励机制 😜。

在一秒内中刮下一定数量时的胡子时会触发奖励分数,

奖励分为三个档次,触发不同档次的奖励时会执行不同的动画效果,越高档次奖励的奖励分数越高。

Kapture 2022-04-15 at 10.15.41.gif

音效

为了提高游玩的爽快感,当玩家进行刮胡子时加入了剃须刀的声音。

而且触发奖励时也加也会触发不同的音效 🐶。

排行榜

为了能让玩家有反复游玩的动力,加入了排行榜功能,让玩家们可以挑战排行榜上的分数。

每次游戏结束后会检查分数是否进入排行榜

WeChat617bb02a018fccf0b857b9222ea301cb.png

排行榜会记录前20名玩家的分数(截图中是为了展示设置的假数据)

WeChata56b921375537b91e55c42ee957475ba.png

【相关源码文件路径】

最后

这篇文章记录了《刮胡子》这个小游戏从创意想法的萌生,想法的实现过程,其中想法实现中遇到的困难与遇到问题时的解决方案,到最终完善玩法完成了这款小游戏。其实在制作这款小游戏的过程是十分有趣的,在这其中不断的在思考,到最终小游戏完成心中充满喜悦也学习到了很多东西。

游戏的体验地址与源码链接都在文章顶部,各位可以进行体验。如果有什么建议与问题可以在评论区留言。

在文章的最后,祝各位的在掘金努力学习的兄弟们工资越来越高,生活越来越棒!咱们下一篇文章见~

download.jpg