前端开源游戏——抗疫的汤圆,一个前端献给所有抗疫人员的敬意

2,796 阅读13分钟

PK创意闹新春,我正在参加「春节创意投稿大赛」,详情请看:春节创意投稿大赛

游戏录制视频:哔哩哔哩
建议先试玩再看视频和文章,推荐在pc上用Chrome浏览,手机上推荐使用QQ浏览器,微信内置浏览器会出现声音卡顿,自带浏览器黑暗模式会让画面看不清

游戏体验地址:ihope_top.gitee.io/tangyuan-ka…
gitee地址:gitee.com/ihope_top/t…
github地址:github.com/heyongsheng…
本文章已同步发表至公众号:百里青山

前言

各位朋友新年快乐,相信大家现在都已经到家了吧,我在这给大家隔空来个云拜年,祝大家新的一年里心想事成,万事如意。我也是昨天刚到家,这几天又是忙离职,又是忙搬家的,回老家之后马上就接着来写文章了,就为了能在春节前发出来。虽然很累,但是也很开心,毕竟能回家过年,要说今年差一点就回不了家了,具体大家都懂,如今能在疫情肆虐的时期安全回家,离不开每一位抗疫人员的坚持和努力,也离不开各位的坚持和配合,所以这个小游戏就送给你们,希望大家能够喜欢,也希望里面用到的知识能够帮到大家。因为时间实在紧迫,这个小游戏也只写了一周多点的时间,还都是晚上下班写,中间还经历了搬家,所以很多地方不够完善,很多想法也没能实现,不过好在该有的都有了,也希望大家在体验之后可以给出一些建议和想法。

插播一个小广告:这个小游戏应该是近期的收官之作了,花费了很多的心思和时间,很长一段时间应该都不会再写了,因为我离职了,要准备准备进行面试了,大概会准备一个月的时间,同时也好好放松放松,做一些自己感兴趣的事,之后应该会去北京,所以各位大佬如果有能看的上我这个菜鸟的,也希望能给个机会,我工作经验四年,技术栈就是vue,工作期间一直写的管理端项目,自学了koa+MongoDB(只会简单的增删查改),学历非全日制本科,如果对我感兴趣的欢迎评论区留言,或者联系公众号百里青山,谢谢。

游戏介绍

由于本次小游戏内容较多,所以不会进行全部代码讲解,主要讲解关键代码和游戏创意,如果有人感兴趣,后面可以写专门的文章进行细节讲解。

游戏玩法参考:flappybird
音乐:
故事模式-Last Reunion[Peter Roe]
自由模式-夜空中最亮的星 (钢琴版)[Jesse T]
故事模式创意参考-国产单机游戏《双子》

image.png

强烈建议有条件体验小游戏的朋友先进行体验,下方包含全部剧透

本次小游戏分为两个模式,故事模式和自由模式,玩法都大致相同,下面会分开进行介绍。虽然本次适配了手机模式,但还是建议使用电脑进行体验,因为手机屏幕宽度过窄,无法看到后面的柱子,所以无法提前调整位置,难度会大大增加(为什么不做手机缩放适配?因为没时间,哈哈)

自由模式

1.gif

游戏的主要玩法参考小游戏《flappybird》,只不过加入了部分自己的创意,如上图所示,玩家通过空格键或者点击屏幕控制汤圆(为啥选汤圆呢,因为汤圆代表着团圆吧)上下跳动,从而从柱子中间的空隙穿过,灰暗的柱子也代表着被病毒污染的城市,汤圆经过之后柱子会变成柔和的橙黄色,也代表着城市被净化了,同时上方的计数牌也会记录被净化过的建筑,代表着分数。下方的三条小竖线代表着生命值,每碰到一次柱子会损失一次生命值并且无敌一段时间,从上方跳出屏幕或者从下方掉出屏幕也会损失一次生命值并且无敌一段时间,如果无敌时间过后还没回到屏幕内,则会判定失败。

image.png

生命值耗尽,会显示最终成绩,玩家可以选择重新开始或者返回菜单。

故事模式

故事模式可以说是花费了我一大半的精力,首先就是创意部分,故事模式与音乐相结合,随着音乐的递进关系分为三个阶段

2.gif

第一个阶段和自由模式差不多,玩家控制汤圆穿过建筑之间的空隙,不同的是穿过之后建筑之间会生成一个绿色的小光点,我们可以把它理解为能量、信仰、人民的感激,这个能量在第三阶段会用到。

3.gif

第二阶段随着音乐感的增强,游戏难度也会增加,具体就是建筑移动的更快了,建筑之间的空隙更小了。为了照顾广大手机用户和手残党的体验,第二关的生命值增加到了5点(电脑模式经本人测验其实可以一滴血不掉)

4.gif

随着音乐到达高潮,我们也会进入第三阶段,但是我们会发现第三阶段的难度骤增,建筑之间的空隙仅仅比汤圆大一点,这根本无法通过好吗?难道作者脑子有病?no,其实这里参考了国产单机游戏双子的创意,建议朋友们可以去体验一下,当然这里无法和原作相比,这里做了大量的简化。朋友们有没有发现上面的光球越来越大了,现在它就要派上用场了。

5.gif

相信很多朋友不经过多次的尝试是无法到达这里的,就算有朋友坚持到了这里,看到这绝境也会变得绝望和愤怒,这时候你会发现上面会弹出提示,问你是否还要坚持,如果你选择是,之前积累的每一个能量都会一个个的来帮助你,等到全部能量赋能完毕,汤圆会变得无敌,然后继续前行

6.gif

汤圆无敌之后速度会逐渐加快,直到达到最快速度,此时建筑会被快速穿过并且净化,此时音乐也会达到高潮,因为音乐高潮部分会持续一分多种,所以这里一直画面不变其实有些无聊,所以这里在上面加入了一些疫情播报(纯属虚构),一方面可以缓解无聊,另一方面,随着数据的逐渐好转,也和游戏互相呼应。

7.gif

随着音乐的收尾,疫情播报中数字也会逐渐清零(这里由于屏幕大小的不同,很难做到音乐卡点十分准确),之后柱子会全部消失,然后展示一些祝福的文字,游戏结束,这里本来是想让那些城市全都亮起灯火,夜空燃放烟花,或者天空变晴朗的,奈何时间实在不够了,只能暂且如此了。

核心技术点

背景

关于背景,下雪部分之前已经单独写过文章了,大家可以到我的主页查看,游戏中为了符合游戏设定,又在移动的城市上方加入了地名。这个和我之前游戏的取弹幕和取问题是一样的,都是随机从一个数组中取一项进行展示,没什么难点。

// 从地区库中随机选择一个地区
let dataLength = this.spaceData.length
let randomIndex = Math.floor(Math.random() * dataLength)
let space = this.spaceData[randomIndex]
let spaceDom = document.createElement('div')
spaceDom.className = 'space-name'
spaceDom.innerText = space
city.appendChild(spaceDom)
this.$refs.bgTop.appendChild(city)

游戏功能方面

汤圆的跳动

image.png

这里用到的原理其实就是物理中的上抛运动和自由落体运动

我们这里设置一个游戏内的重力加速度tangyuanG,然后再给汤圆一个上抛的力,也就是上抛的初速度tangyuanUpV

// 汤圆部分
tangyuanG: 0.02, // 设定的重力加速度
tangyuanUpStartTime: 0, // 汤圆开始上抛的时间
tangyuanDownStartTime: 0, // 汤圆开始下坠的时间
tangyuanUpV: 5, // 汤圆上抛的初速度
tangyuanUpInterval: null, // 汤圆上抛的定时器
tangyuanDownInterval: null, // 汤圆下坠的定时器

那么我们就可以根据上抛运动的公式v=v0gtv = v0 - gt 求出当前汤圆的速度当作位移的距离,当这个速度等于0的时候说明我们已经到达了顶点,这个时候我们就开始让汤圆执行自由落体运动

/**
     * @description: 汤圆上抛中
     * @param {*}
     * @return {*}
     */
    tangyuanUping () {
      let now = Date.now()
      let t = now - this.tangyuanUpStartTime
      let v0 = this.tangyuanUpV
      let g = this.tangyuanG
      let y = v0 - g * t
      if (y < 0) {
        this.tangyuanStartDown()
      } else {
        this.$refs.tangyuan.style.top = this.$refs.tangyuan.offsetTop - y + 'px'
        this.tangyuanUpInterval = requestAnimationFrame(this.tangyuanUping)
        if (this.$refs.tangyuan.offsetTop <= -this.$refs.tangyuan.offsetHeight) {
          this.gameFail()
        }
      }
    },

也可以根据自由落体运动的公式v=gtv = gt 求出当前汤圆下落的速度当作位移的距离

/**
     * @description: 汤圆下坠中
     * @param {*}
     * @return {*}
     */
    tangyuanDown () {
      let now = Date.now()
      let t = now - this.tangyuanStartTime
      let g = this.tangyuanG
      let y = g * t
      this.$refs.tangyuan.style.top = this.$refs.tangyuan.offsetTop + y + 'px';
      this.tangyuanDownInterval = requestAnimationFrame(this.tangyuanDown)
      if (this.$refs.tangyuan.offsetTop >= this.screenHeight + this.$refs.tangyuan.offsetHeight / 2) {
        this.gameFail()
      }
    },

关于汤圆运动定时器等其他细节请查看源代码

生成柱子

首先我们需要确定柱子之间的间隙的高度,然后根据这个间隙的高度取确定上下两跟柱子的位置。

// 柱子部分
  pillarCount: 0, // 已生成柱子的数量
  createPillarInterval: null, // 创建柱子的定时器
  createPillarLastTime: '', // 上一次创建柱子的时间
  pillarFrequency: 4000, // 柱子生成的频率 毫秒/次
  pillarWidth: 100, // 柱子的宽度
  pillarGapHeight: 220, // 柱子的间距
  pillarMoveInterVal: null, // 柱子移动的定时器
  pillarSpeed: 2, // 柱子移动的速度
 /**
     * @description: 开始生成柱子
     * @param {*}
     * @return {*}
     */
    createPillar () {
      // 根据上跟柱子的生成时间和设定的生成频率生成柱子
      let now = new Date().getTime()
      if (now - this.createPillarLastTime > this.pillarFrequency) {
        // 需要先根据屏幕高度算出柱子的空隙区间范围
        // 此处暂时设定为一格的可用范围为屏幕高度的十分之一
        let screenSpan = this.screenHeight / 10
        // 设定间隙区间范围为屏幕中间的四格,那么间隙顶部的坐标范围就是屏幕的十分之三到(十分之七-间隙高度)之间
        let gapTop = Math.floor(Math.random() * (screenSpan * 7 - this.pillarGapHeight)) + screenSpan * 3
        let gapBottom = gapTop + this.pillarGapHeight

        // 根据间隙区间范围生成柱子
        this.createPillarDom(0, this.screenHeight - gapTop, 'pillar-item-top')
        this.createPillarDom(gapBottom, 0, 'pillar-item-bottom')
        this.createPillarLastTime = now
        this.pillarCount++
      }
    },
    /**
     * @description: 柱子生成器
     * @param {*}
     * @return {*}
     */
    createPillarDom (top, bottom, className) {
      let pillar = document.createElement('div')
      // pillar.className = className
      pillar.className = ['pillar-item', className].join(' ')
      pillar.style.left = this.screenWidth + 'px'
      pillar.style.top = top + 'px'
      pillar.style.bottom = bottom + 'px'
      pillar.style.width = this.pillarWidth + 'px'
      this.$refs.pillarWrap.appendChild(pillar)
    },

碰撞检测

image.png

上面说了柱子的生成,没说柱子的移动,是因为我这里让柱子的移动和碰撞检测一起做了,我们给所有柱子的移动添加一个定时器,一是节省性能,而是能够更好的控制所有柱子的移动状态,比如游戏失败时要让柱子暂停移动。我们这里就是添加一个定时器,遍历所有柱子,让他们按照要求移动,同时找出离我们右边最近的柱子进行碰撞检测,再找出离我们左边最近的柱子增加净化效果。

    /**
     * @description: 整体柱子移动
     * @param {*}
     * @return {*}
     */
    movePillar () {
      // 获取所有柱子
      let pillarDoms = this.$refs.pillarWrap.children
      let pillarList = Array.from(pillarDoms)
      for (let index = 0; index < pillarList.length; index++) {
        let item = pillarList[index]
        if (item.offsetLeft < -this.pillarWidth) {
          this.$refs.pillarWrap.removeChild(item)
        } else {
          item.style.left = item.offsetLeft - this.pillarSpeed + 'px'
        }
      }
      // 获取当前与汤圆最近左侧的柱子添加净化效果
      let leftOne = this.$refs.tangyuan.offsetLeft
      let pillartListReverse = [...pillarList].reverse()
      let prevDomIndex = pillartListReverse.findIndex(item => {
        return (item.offsetLeft + item.offsetWidth) < leftOne - this.$refs.tangyuan.offsetWidth / 2
      })
      if (prevDomIndex > -1) {
        // 因为此处把数组反转了,所以prevDomIndex+1是上面那个
        let prevTop = pillartListReverse[prevDomIndex + 1]
        let prevBottom = pillartListReverse[prevDomIndex]
        if (!prevTop.isClear) {
          // 给柱子添加净化效果
          prevTop.classList.add('pillar-item-active')
          prevBottom.classList.add('pillar-item-active')
          // 从柱子间隙处生成能量飞往能量池
          // 获取柱子间隙中心
          let gapCenterX = prevTop.offsetLeft + this.pillarWidth / 2
          let gapCenterY = prevTop.offsetHeight + this.pillarGapHeight / 2
          // 生成能量,阶段三不生成能量
          if (this.stage !== 3) {
            this.createEnergy(gapCenterX, gapCenterY)
          }
          prevTop.isClear = true
        }
      }
      // 判断汤圆是否处于无敌状态
      if (this.$refs.tangyuan.status !== 'invincible') {
        this.pillarMoveInterVal = requestAnimationFrame(this.movePillar)

        // 获取当前与汤圆最近右侧的柱子,作为碰撞检测的对象
        let leftTwo = this.$refs.tangyuan.offsetLeft - this.pillarWidth - this.$refs.tangyuan.offsetWidth / 2
        let nextDomIndex = pillarList.findIndex(item => {
          return item.offsetLeft > leftTwo
        })
        if (nextDomIndex > -1) {
          let pillarTop = pillarList[nextDomIndex]
          let pillarBottom = pillarList[nextDomIndex + 1]

          // 获取汤圆的半径及圆心坐标
          let tangyuanRadius = this.$refs.tangyuan.offsetWidth / 2
          let tangyuanCenterX = this.$refs.tangyuan.offsetLeft
          let tangyuanCenterY = this.$refs.tangyuan.offsetTop
          // 检测汤圆与上方柱子是否碰撞
          // 获取上方柱子中心的坐标
          let pillarTopCenterX = pillarTop.offsetLeft + this.pillarWidth / 2
          let pillarTopCenterY = pillarTop.offsetHeight / 2
          if (this.computeCollision(this.pillarWidth, pillarTop.offsetHeight, tangyuanRadius, tangyuanCenterX - pillarTopCenterX, tangyuanCenterY - pillarTopCenterY)) {
            this.gameFail()
          }
          // 检测汤圆与下方柱子是否碰撞
          // 获取下方柱子中心的坐标
          let pillarBottomCenterX = pillarBottom.offsetLeft + this.pillarWidth / 2
          let pillarBottomCenterY = pillarTop.offsetHeight + this.pillarGapHeight + pillarBottom.offsetHeight / 2

          if (this.computeCollision(this.pillarWidth, pillarBottom.offsetHeight, tangyuanRadius, tangyuanCenterX - pillarBottomCenterX, tangyuanCenterY - pillarBottomCenterY)) {
            this.gameFail()
          }
        }
      } else {
        this.pillarMoveInterVal = requestAnimationFrame(this.movePillar)
      }
    },

里面用到的圆形和矩形的碰撞检测其实我也是从网上搜的

/**
     * @description: 圆形与矩形的碰撞检测
     * @param {*} w 矩形的宽
     * @param {*} h 矩形的高
     * @param {*} r 圆形的半径
     * @param {*} rx 圆心与矩形中心的x距离
     * @param {*} ry 圆心与矩形中心的y距离
     * @return {*}
     */
    computeCollision (w, h, r, rx, ry) {
      var dx = Math.min(rx, w * 0.5);
      var dx1 = Math.max(dx, -w * 0.5);
      var dy = Math.min(ry, h * 0.5);
      var dy1 = Math.max(dy, -h * 0.5);
      return (dx1 - rx) * (dx1 - rx) + (dy1 - ry) * (dy1 - ry) <= r * r;
    },

汤圆超出屏幕检测

这个其实刚才在说汤圆上抛和下坠运动的时候,代码里已经体现了,这里就不详细说了。

其他细节类技术点

声音的淡入淡出

这个其实很简单,就是写一个定时器,定时减少或者增加声音的音量,之后在音量达到目标值的时候清除定时器就可以了,声音部分完整的代码如下

/*
 * @Author: 贺永胜
 * @Date: 2022-01-23 17:23:34
 * @email: 1378431028@qq.com
 * @LastEditors: 贺永胜
 * @LastEditTime: 2022-01-30 13:58:43
 * @Descripttion: 
 */
class AudioObj {
  // static status = false
  constructor() {
    this.status = false
    this.backMusic = new Audio();
    this.playInterval = null
  }
  playAudio (src) {
    if (this.status) {
      const audio = new Audio()
      audio.src = src
      audio.load()
      audio.volume = .5
      audio.play()
    }
  }
  backMusicPlay (src) {
    clearInterval(this.playInterval)
    if (this.status) {
      if (src) {
        this.backMusic.src = src
        this.backMusic.load()
      }
      this.backMusic.volume = 0
      this.backMusic.play()
      this.playInterval = setInterval(() => {
        if (this.backMusic.volume < 1) {
          this.backMusic.volume = (this.backMusic.volume + 0.1).toFixed(1)
        } else {
          clearInterval(this.playInterval)
        }
      }, 100)
    }
  }
  backMusicStop () {
    clearInterval(this.playInterval)
    this.playInterval = setInterval(() => {
      if (this.backMusic.volume > 0) {
        this.backMusic.volume = (this.backMusic.volume - 0.1).toFixed(1)
      } else {
        clearInterval(this.playInterval)
        this.backMusic.pause()
      }
    }, 100)
  }
}
export default new AudioObj();

柱子颜色渐变的过渡

大家可以观察到,我们的柱子颜色在变化是有过渡效果的,但是在css中渐变色是没有过渡效果的,这里我们其实是通过曲线救国的方式实现的渐变效果,渐变不支持过渡,但是背景颜色支持啊,所以我们寻找一个目标的背景颜色,然后再在上面加一层半透明的渐变,然后需要改变是就修改下面那个背景色就可以达到想要的效果了,不过这个方法的局限性较大,而且需要长时间的调试,不如直接在上面叠加一层目标背景色,然后改变透明度来的直接。

.pillar-item {
  position: absolute;
  transition: background-color 1s;
}
.pillar-item-top {
  background: rgb(48, 48, 48)
    linear-gradient(to top, rgba(255, 241, 113, 0), rgba(255, 253, 223, 0.5));
}
.pillar-item-bottom {
  background: rgb(48, 48, 48)
    linear-gradient(to bottom, rgba(255, 241, 113, 0), rgba(255, 253, 223, 0.5));
}
.pillar-item-active {
  background-color: #ffdb8f;
  /* background-image: linear-gradient(to top, #ffd06c, #ffedc7); */
}

空格键控制汤圆跳跃

这个其实就更简单了,就是通过给div增加tab-index属性来让它可以获取焦点,因为只有可以获取焦点的元素才能监听键盘事件,注意如果是给全屏dom设置这个属性的话,记得要设置outline:none要不然会有一条边框,在有的浏览器可见,有的不可见。

透过当前元素点击下方元素

大家可以看到游戏中很多页面的切换都使用了过渡效果,这个其实就是用的vue的transition标签

.fade-enter-active, .fade-leave-active {
  transition: opacity 1s, transform 1s;
}
.fade-enter /* .fade-leave-active below version 2.1.8 */ {
  opacity: 0;
  transform: translateX(200px)
}
.fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
  opacity: 0;
  transform: translateX(-200px)
}

效果很好,就是有一个小毛病,就是有时候我们明明已经看到下一个页面了,却因为上一个页面还没有完全离开,而无法点击下一个页面的元素,虽然间隔很短,但是造成的用户体验会很不好,css有一个属性完美的解决了我们这个问题,那就是pointer-events: none;这个属性可以让当前用户不接收鼠标事件,作用在它身上的所有鼠标事件都会穿过它跑到它下面的元素上,不过同时它里面的元素也会不可点击,如果想点击里面的元素的话,可以加上pointer-events: auto;

游戏内的文字alert

这个是我临时想的解决方案,因为也没啥时间,我也没去研究最优的方案,不过我这个基本满足了我的要求,文字出现有过渡效果,重复调用文字不能重叠可以控制每个消息的展示时间。

过渡效果很简单,加一个过渡的css,添加的时候先设置一个偏移的位置,然后马上修改它的位置到目标位置就行了。离开也一样,修改它的位置到过渡的位置,搞一个位置偏移,加一个透明度变化,完了移除dom就行了。

控制展示时间也很简单,把时间作为一个变量传进去,到时间了移除就行。

消息不能重叠这个,因为游戏所需,我没有搞element那样的堆叠式,就是一个摞一个,一方面是因为不符合场景,另一方面是因为我不会。我这里采取的方案是队列式(我自己这么叫的,不知道对不对),就是把消息弹窗搞成一个公共类,类里保存一个消息队列,还有一个弹消息的方法,当我们调用弹消息的方法时,会先判断消息队列里有没有消息,有的话就把当前消息推送到最后一条,没有的话就直接弹,当前消息弹完会有一个回调,判断消息队列里有没有消息,有的话就加载下一条。完整代码如下

/* 提示语样式 */
.alert-text {
  position: absolute;
  top: 20%;
  left: 50%;
  transform: translate(-50%);
  font-size: 32px;
  opacity: 1;
  color: #fff;
  text-shadow: 0 0 5px #fff;
  transition: all 1s;
  text-align: center;
}
class Alert {
  constructor () {
    this.messageList = []
  }
  showText (text, time = 2000) {
    this.messageList.push({
      text,
      time
    })
    if (this.messageList.length <= 1) {
      this.showTextHandle(text, time)
    }
  }
  showTextHandle (text, time) {
    let dom = document.createElement('p')
    dom.innerText = text
    dom.style.opacity = 0
    dom.style.top = '30%'
    dom.className = 'alert-text'
    document.body.appendChild(dom)
    setTimeout(() => {
      dom.style.opacity = 1
      dom.style.top = '20%'
      setTimeout(() => {
        dom.style.opacity = 0
        dom.style.top = '10%'
        this.messageList.splice(0, 1)
        if (this.messageList.length > 0) {
          this.showTextHandle(this.messageList[0].text, this.messageList[0].time)
        }
        setTimeout(() => {
          dom.remove()
        }, 1000)
      }, time)
    }, 16)
  }
  clear () {
    this.messageList = []
    let alertDom = document.querySelectorAll('.alert-text')
    alertDom.forEach(item => {
      item.remove()
    })
  }
}

export default new Alert()

结束语

本篇文章就到这了,确实时间很少,所以没办法把所有的规划都实现,也没有办法把实现的都讲清楚,而且据我观察,我找来测试小游戏的人的反响都不怎么样,不过这个小游戏还是倾注了我很多的精力,很多人说小游戏太难,也很无聊,但抗疫的生活不也这样吗,一次又一次的核酸,一次又一次的排查,最终才换来了今天的胜利,感谢所有为抗疫努力的人,也祝大家新年快乐,身体健康,万事如意