vue新春游戏-年兽大作战,欢欢喜喜过大年(可在线体验)

11,581 阅读11分钟

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

游戏地址: ihope_top.gitee.io/new-year-ga…
开发语言:vue
运行平台:Chrome
gitee地址:gitee.com/ihope_top/n…
github地址:github.com/heyongsheng…
游戏已开源,欢迎大家体验,也可以自行修改用作公司年会游戏等

技术不够创意来凑系列第二弹来了—— vue新春游戏-拼手速抢车票 上线了,欢迎大家点击体验

记录一下,本篇文章收录于掘金一周 2022.01.12 期

前言

各位掘金的xdm,又是一年新春到,在这里提前给各位兄弟们拜年了,祝大家身体健康,万事如意。今天这篇文章呢,是为了掘金新春征文诞生的,这里特意给大家写了一个小游戏,所谓技术不够创意来凑,虽然游戏用到的技术都是很一般很简单的,但是也让我准备了不少的时间,小游戏全部由自己完成,网上拼凑的资源,美术、音效可能都不完美,大家将就将就哈,希望大家能够喜欢,强烈建议大家在阅读文章之前先点击游戏链接heyongsheng.github.io/game/index.… 前去体验两把。

游戏弹幕及游戏结束祝福语征集

相信感兴趣的同学已经去体验过游戏了,那么你一定看到了游戏时弹幕出现的祝福语,以及游戏结束时出现的对玩家的祝福语,想让你的祝福语出现在弹幕中吗,那么请在评论区留言吧,我会将看到的留言及时更新到弹幕中哦。

弹幕留言

弹幕留言格式:弹幕+昵称+祝福(一句话,别太长),例如 弹幕+河南小伙+祝全国人民早日战胜新冠

image.png

游戏胜利祝福

胜利祝福格式:胜利+昵称+祝福(一句话,别太长),例如 胜利+掘金用户小明+祝你新的一年工作顺利

image.png

可以截图你游戏胜利时随机到的留言然后发到沸点哦,看是否会有缘遇到给你送祝福的人。

那下面我们就正式的来开始游戏开发的讲解了。

小游戏内容较多,不重要的地方会一笔带过或者省略,如果有人对游戏中没有提到的技术感兴趣,可以在评论区提出,后续可以针对性的出文章讲解,另外文中代码仅张贴关键部分代码,如需查看完整代码,请移步gitee或者GitHub。

游戏规则

image.png

玩家需要按住炮竹进行左右移动来攻击年兽,屏幕中间会定时出现问题,回答对问题会增加攻击力等,每道题的回答时间为8秒钟,问题出现的间隔为5秒钟,年兽血量为0时游戏结束,击败年兽用时越少越牛逼。

菜单及全局配置

image.png

全局配置

setting: {
  isPlay: false,
  showBulletChat: true
}

全局配置其实就俩,声音控制和弹幕控制,因为经测试,游戏在性能十分不好的机器会卡顿,所有给出了是否显示弹幕的控制,至于弹幕大小、颜色、密度这些由于时间关系就没有写。至于声音控制,那肯定是必须的,一是因为防止突然播放音乐对用户造成影响,二是浏览器也有限制,禁止声音自动播放。

菜单

布局方面就不说了,这里简单的说一下我菜单生成时的思路,因为给菜单添加鼠标滑过和点击的音效,所以用v-for循环数据的方法比较好,要不然鼠标事件就要写好几遍。具体的代码如下

<div class="menu-box">
  <div
    class="menu-item"
    v-for="(item, index) in menuList"
    :key="index"
    @mouseover="$store.commit('playAudio', hoverMusic)"
    @click="$store.commit('playAudio', clickMusic),item.clickHandle()"
    v-show="item.show()"
  >
    {{item.name}}
  </div>
</div>
// 节选
menuList: [
    {
      name: '开始游戏',
      clickHandle: () => {
        this.gameBegin()
      },
      show: () => true
    },
    {
      name: '打开声音(强烈建议)',
      clickHandle: () => {
        this.$store.commit('tooglePlay', true)
      },
      show: () => !this.$store.state.setting.isPlay
    },
    {
      name: '关闭声音',
      clickHandle: () => {
        this.$store.commit('tooglePlay', false)
      },
      show: () => this.$store.state.setting.isPlay
    }
  ],

菜单的每一项主要有三个属性,名称、点击事件和控制显示,因为有些菜单项需要根据实际情况决定是否显示,比如打开声音和关闭声音,需要根据当前声音是否打开来判断谁显示谁隐藏,如果我们定义数据的时候直接把控制声音的变量赋值给show,那么后续声音变化的时候,show是不会动态更新的,这里我们我们赋值给show一个函数,就可以达到冬天更新的目的了。

声音

游戏没有声音怎么行,这里引用的音乐是序曲,哈哈哈,是不是一下子就有年味了。游戏中的声音主要有两个类型,一种是长时间播放,需要控制播放暂停的,比如背景音乐,另一种是即时性的,比如菜单滑动声、子弹撞击声等,所以背景音乐的实例我们需要存储下来,而即时音效随用随建就行,我这里偷了个懒,没有写单独的声音配置文件,直接写vuex里了。

背景音乐

window.backMusic = new Audio()
window.backMusic.src = require('../assets/mp3/back.mp3')
window.backMusic.loop = true
window.backMusic.load()
window.backMusic.currentTime = 127.2 // 背景音乐默认定位到舒缓片段

这样我们在任何地方控制播放直接调用或更改window.backMusic就行了。

即时音效

playAudio (state, src) {
  if (state.setting.isPlay) {
    const audio = new Audio()
    audio.src = src
    audio.load()
    audio.volume = .5
    audio.play()
  }
}

这里播放音效的时候需要判断当前的声音开关是否打开,如果打开的话在进行播放,注意,这里不能通过给单一的audio对象改变地址的方式播放不同的音效,因为如果在当前声音正在播放时候,修改音效地址会报错。

弹幕

这个创意是我在听春节序曲的背景音乐时想到的,因为一听这个就想到春晚,想到短片中全国各地的人民送祝福,于是我就想把这个加进来,结合背景音乐,是不是一下子感觉就来了。也希望大家可以送上自己的祝福,我也会把你的祝福更新到弹幕里的。这里的弹幕就只为了满足游戏的需求,不会太复杂。

首先,我们需要梳理一下弹幕的需求和注意点

  • 弹幕横向和纵向不能重叠
  • 两天弹幕之间的间隔最好可以随机
  • 弹幕超出屏幕要自动移除

首先说弹幕不能重叠的问题,弹幕纵向不能重叠的话,我们就需要有一个弹道的概念,也就是让每一条弹幕都有自己的轨道,各走各的,当然就不会重叠了。我这里是根据屏幕高度,分成了10个弹道,本来打算屏幕越大,弹道越多的,但是考虑到性能问题,就采用了这种方案。

image.png

其次就是弹幕横向的防止重叠,我百度的时候看到其他作者提到的追及问题什么的,奈何我是个学渣,没有看太明白,于是就自己想了解决办法,我们这里每条弹幕的移动速度是一样的,那需要考虑的就是每条弹幕出现的时机问题了,我们需要在同一弹道的前一条弹幕完全出现后,再生成下一条弹幕,中间可以加一个我们规定好范围的随机距离,这样更美观一点。

下面来看一下代码怎么实现的。

ballistic: 0, // 弹道数量
bulletSpeed: 2, // 弹幕速度
bulletInterval: [300, 500], // 弹幕间隔
screenWidth: document.documentElement.clientWidth, // 屏幕宽度
screenHeight: document.documentElement.clientHeight, // 屏幕高度
/**
 * @description: 展示弹幕
 * @param {*}
 * @return {*}
 */
showBullet () {
  // 此处直接设定了10条弹道,也可根据屏幕高度和弹幕高度计算弹道数
  let ballisticArr = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
  // 按随机顺序在所有的弹道添加弹幕
  let ballisticLaunch = () => {
    let randomIndex = Math.floor(Math.random() * ballisticArr.length)
    let ballisticIndex = ballisticArr.splice(randomIndex, 1)[0]
    this.createBullet(ballisticIndex)
    if (ballisticArr.length > 0) {
      setTimeout(ballisticLaunch, Math.random() * 1000)
    }
  }
  ballisticLaunch()
  // this.createBullet(2)
},

我这里的方法是先设定好弹道数,然后把这些的弹道的序号放进一个数组,开始时直接从这个数组去取编号,往这个弹道放进去一个弹幕,然后循环,直到每一条弹道都被用完为止,那么问题来了,这时候我们每条弹道只有一条弹幕,怎么生成后续弹幕呢,这里的思路是在每一条弹幕移动的时候,判断自己的移动距离,当达到合适的距离时(自身完全出现在屏幕中并且距离屏幕右侧达到了我们设定的两条弹幕间的距离)就调用加载下一条弹幕的方法,并把自身的弹道编码传入,加上我们这里弹幕是匀速的,就不会有重叠的问题了。

/**
 * @description: 添加弹幕
 * @param {*} index 弹道索引
 * @return {*}
 */
createBullet (index) {
  let bullet = document.createElement('div')
  let bulletHeight = document.documentElement.clientHeight / 10
  bullet.className = 'bullet-chat'
  bullet.style.left = this.screenWidth + 'px'
  bullet.style.top = index * bulletHeight + 'px'
  bullet.createNext = false // 是否已创建下一个弹幕
  bullet.nextSpace = Math.random() * (this.bulletInterval[1] - this.bulletInterval[0]) + this.bulletInterval[0] // 下一个弹幕间隔
  // 从弹幕库随机取弹幕
  let dataLength = this.blessingData.length
  let randomIndex = Math.floor(Math.random() * dataLength)
  let blessing = this.blessingData[randomIndex]
  bullet.innerText = blessing.name + ":" + blessing.value
  this.$refs.bulletChat.appendChild(bullet)

  // 弹幕移动
  let bulletMove = () => {
    bullet.style.left = bullet.offsetLeft - this.bulletSpeed + 'px'
    if (!bullet.createNext) {
      // 如果弹幕距离屏幕右侧距离超出弹幕间隔,则加载下一条弹幕
      if (bullet.offsetLeft < (this.screenWidth - bullet.offsetWidth - bullet.nextSpace)) {
        this.createBullet(index)
        bullet.createNext = true
      }
    }

    // 如果弹幕距离右侧距离大于等于屏幕宽度,则移除弹幕
    if (bullet.offsetLeft < (-bullet.offsetWidth)) {
      this.$refs.bulletChat.removeChild(bullet)
    } else {
      requestAnimationFrame(bulletMove)
    }
  }
  bulletMove()
}

这里我们引入了一个弹幕库,每次从中随机取一条,这样就避免旧弹幕无法被看到的问题了,另外大家也都看到了,这里用的定时方法是requestAnimationFrame,这个真的比setinterval要好,本项目基本所有用到动画的地方都用的这个,也建议大家都用这个方法代替setinterval,好处比较多,这里就不占字数了,大家感兴趣自行百度吧。

年兽

image.png

这个可爱的小东西就是我们的年兽了,年兽的组成很简单,一个小图标,加一个血量,然后我们让它来回动起来就可以了。当血量为0时候我们就让它消失。

<!-- 年兽 -->
<div
  class="nianshou"
  :style="'marginLeft:' + nianshouLeft + 'px'"
  v-show="nianshouHP"
>
  <p>HP: {{ nianshouHP }}</p>
  <img src="../assets/nianshou.png" class="nianshou-img" />
</div>
nianshouLeft: 0, // 年兽距离左边的距离

nianshouMove () {
  // 更新游戏时间
  this.gameDuration = new Date().getTime() - this.gameBeginTime
  if (this.nianshouLeft + 200 >= this.screenWidth) {
    this.nianshouMoveDir = -4
  } else if (this.nianshouLeft < 0) {
    this.nianshouMoveDir = 4
  }
  this.nianshouLeft += this.nianshouMoveDir
  this.nianshouInterval = requestAnimationFrame(this.nianshouMove)
},

我们的游戏规则是用时越少越厉害,所以我们需要计算游戏用时多少,这里我们以年兽开始移动时为游戏开始时间,另外我们还需要在年兽撞墙的时候往反方向运动,所以这里我们判断了年兽距离屏幕左边和右边的距离,一旦达到界定值的时候,则改变移动方向,也就是改变移动值的正负

炮竹

image.png

这个小玩意儿就是我们的炮竹了,也相当于我们的武器,我本来想找一个烟花筒来释放烟花的,奈何资源有限,就用这个将就吧。这个小炮竹会不断的发出光束去打年兽,这里关于炮竹,就是鼠标按下的时候添加移动事件,让他左右移动就可以了。

第一步肯定就是炮竹的移动,这个我们不做的太复杂,直接让鼠标拖动进行左右移动就行了,不让上下移动是为了你举着炮竹往年兽脸上怼。

思路,鼠标点击炮竹,给整个区域添加移动事件,不给炮竹添加移动事件时因为鼠标移动过快的话很容易超出炮竹的范围,造成不好的游戏体验,当鼠标抬起时,我们再把这个事件给移除。至于移动,我们需要先定义一个clientx,每次鼠标移动的时候存储鼠标距离屏幕左侧的距离,当鼠标再次移动的时候,我们用当前光标距离左侧的距离建议刚刚存储的,就可以得出鼠标移动的距离,然后我们把这个值的变化赋值给炮竹的margin-left

<!-- 鞭炮 -->
<div
  class="paozhu"
  ref="paozhu"
  @mousedown="addMove"
  :style="'marginLeft:' + paozhuLeft + 'px'"
>
  <img src="../assets/paozhu.png" alt="" />
</div>
clientX: 0, // 鼠标上次的位置
paozhuLeft: 0 // 炮竹距离左边的距离

// 鼠标按下,添加移动事件
addMove (e) {
  e = e || window.event
  this.clientX = e.clientX
  this.clientY = e.clientY
  this.$refs.gemeWrap.onmousemove = this.moveFunc
},
// 鼠标拖动,移动炮竹
moveFunc (e) {
  e = e || window.event
  e.preventDefault()
  let movementX = e.clientX - this.clientX
  this.paozhuLeft += movementX
  this.clientX = e.clientX
},
// 鼠标抬起,移除移动事件
removeMove () {
  this.$refs.gemeWrap.onmousemove = null
},

子弹

我们暂且称炮竹发出的光束为子弹吧,子弹的实现原理很简单,定时发射子弹,发射子弹时获取炮竹的横向坐标,再以屏幕高度减去炮竹高度为纵向坐标,生成之后让子弹往上跑就行了,当子弹距离顶部距离小于等于年兽的高度时,判断子弹的横向坐标是否和年兽的横向坐标重合,如果重合就对年兽扣血,播放击中音效,移除子弹,如果未重合,则在子弹跑出屏幕时移除子弹。

这里我们设置了一个子弹飞行速度,如果你玩过了游戏,一定会发现,刚开始不好射中吧,哈哈哈,这也算是增加了难度,当然,如果答对了问题,射速,攻速,伤害都会相应的增加。

image.png

createBulletInterval: null, // 创建子弹的定时器
frequency: 5, // 发射子弹频率
bulletSpeed: 10, // 子弹飞行速度
damage: 2,// 子弹攻击力
lastBulletTime: 0, // 上次发射子弹时间

// 生成子弹
createBullet () {
  // 子弹
  let now = new Date().getTime()
  if (now - this.lastBulletTime > (1000 / this.frequency)) {
    let bullet = document.createElement('div')
    bullet.className = 'bullet'
    bullet.style.left = this.paozhuLeft + 25 + 'px'
    bullet.style.top = this.screenHeight - 123 + 'px'
    this.$refs.gemeWrap.appendChild(bullet)
    this.$store.commit('playAudio', require('../assets/mp3/emit.mp3'))
    // 子弹移动
    let bulletMove = () => {
      bullet.style.top = bullet.offsetTop - this.bulletSpeed + 'px'
      // 如果子弹距离顶部的距离为年兽的高度时,判断子弹和年兽的水平位置是否重合
      if (bullet.offsetTop <= 250 && bullet.offsetLeft >= this.nianshouLeft && bullet.offsetLeft <= this.nianshouLeft + 200) {
        // 年兽掉血
        this.nianshouHP -= this.damage
        this.$store.commit('playAudio', require('../assets/mp3/boom.wav'))
        if (this.nianshouHP <= 0) {
          this.nianshouHP = 0
          this.gameOver()
        }
        // 子弹消失
        this.$refs.gemeWrap.removeChild(bullet)
        // cancelAnimationFrame(bulletMove)
      } else if (bullet.offsetTop <= 0) {
        this.$refs.gemeWrap.removeChild(bullet)
        // cancelAnimationFrame(bulletMove)
      } else {
        requestAnimationFrame(bulletMove)
      }
    }
    bulletMove()
    this.lastBulletTime = now
  }
  this.createBulletInterval = requestAnimationFrame(this.createBullet)
}

由于requestAnimationFrame不能设置间隔时间,所以这里我们就在生成子弹的时候记录下生成子弹的时间,在requestAnimationFrame下一次运行的时候,判断时间间隔是否满足我们对子弹频率的要求,如果满足则往下执行,如果不满足跳过本次执行。

问题

本游戏的一大特色,就是加入了答题系统,否则一直在那biubiubiu的打怪兽有啥意思呢,年兽的血量为2021,靠初始攻速和伤害得打半天,如果答对问题,则会增加buff,打年兽能力蹭蹭的往上涨。

首先来分析一下问题的需求

  • 每道题的答题时间是8秒钟,无论是否提前选择均展示8秒
  • 答对题目则增加buff
  • 答错或者在倒计时结束未选择答案将展示正确答案
  • 每道题的间隔时间是5秒钟
  • 每次出题从题库随机取题,出现过的题目不会第二次抽取

先从最简单的开始,从题库抽取题目

questionJson: require('@/assets/data/question.json'), //问题源数据
questionData: [], // 本轮游戏题库
questionList: [],// 问题列表

let dataLength = this.questionData.length
let randomIndex = Math.floor(Math.random() * dataLength)
let question = this.questionData.splice(randomIndex, 1)[0]

很简单,接下来就是添加倒计时,先加的是题目间隔倒计时,在一道题目被添加时候,展示5秒钟倒计时,然后展示题目并开始答题倒计时

image.png

// 添加展示倒计时
  let showCountDown = () => {
    data.showTime--
    if (data.showTime > 0) {
      setTimeout(showCountDown, 1000)
    } else {
      // 倒计时结束,展示问题并开始答题倒计时
      answerCountDown()
    }
  }

接下来是答题倒计时,游戏设置的题目是5道,每道题结束会先判断用户是否作答,如果没有作答,自动将结果设置为错误答案,之后再判断题目是否达到5道,如果没有达到则继续添加,直到够5道为止。

image.png

// 添加回答倒计时
  let answerCountDown = () => {
    data.answerTime--
    if (data.answerTime > 0) {
      setTimeout(() => {
        showCountDown()
      }, 1000)
    } else {
      // 倒计时结束,如果没有选择正确答案,则添加一道错误答案
      if (!data.result) {
        data.result = '2021'
      }
      // 如果问题不足5道,则添加一道问题
      if (this.questionList.length < 5) {
        this.addQuestion()
      }
    }
  }

在接下来就是答题了,先来看一下问题面板的dom结构

<!-- 问题面板 -->
  <div
    class="question-panel panel-item"
    :class="{ clientCenter: question.answerTime > 0 }"
    v-for="(question, index) in questionList"
    :key="index"
  >
    <p class="show-count-down" v-if="question.showTime > 0">
      {{ question.showTime }}
    </p>
    <div class="question-wrap" v-else>
      <div class="count-down" v-if="question.answerTime > 0">
        <p>请在{{ question.answerTime }}秒内点击正确答案</p>
      </div>
      <div class="question-panel-title">问题 {{ index + 1 }}</div>
      <div class="question-container">
        <div class="question-title">{{ question.question.title }}</div>
        <div class="answer-wrap show" v-if="!question.result">
          <div
            class="answer-item"
            v-for="item in question.question.option"
            :key="item.key"
            @mouseover="$store.commit('playAudio', hoverMusic)"
            @click="answerQuestion(item.key, question)"
          >
            {{ item.key }}:{{ item.value }}
          </div>
        </div>
        <div class="answer-wrap result" v-else>
          <div
            class="answer-item"
            v-for="item in question.question.option"
            :key="item.key"
            :class="{
              result: question.question.answer === item.key,
            }"
          >
            {{ item.key }}:{{ item.value }}
            <span class="check" v-if="question.result === item.key"></span>
          </div>
        </div>
        <div
          class="buff"
          v-if="question.result === question.question.answer"
        >
          攻速+1 射速+1 伤害+1
        </div>
        <div
          class="desc"
          v-if="
            question.result && question.result !== question.question.answer
          "
        >
          {{ question.question.desc }}
        </div>
      </div>
    </div>
  </div>

再看一下题库中题目的结构

  {
    "title": "以下哪位是神舟十三号航天员?",
    "option": [
      {
        "key": "A",
        "value": "翟志刚"
      },
      {
        "key": "B",
        "value": "刘伯明"
      },
      {
        "key": "C",
        "value": "聂海胜"
      }
    ],
    "answer": "A",
    "desc": "神舟十三号航天员是翟志刚、王亚平、叶光富"
  },

结合我们上面倒计时,回答等,一个问题的完整结构应该是下面这样

{
    question: question, // 题库中的题目
    answerTime: 9, // 回答倒计时,
    showTime: 6, // 展示倒计时
    result: null, // 用户回答的答案
}

这么一看就好办多了,我们只需要再点击选项的时候,把选项的值赋值给result就行了,然后根据result的值判断用户是否答题,是否答对。

这里在最外层的dom结构上,有这样一行代码

:class="{ clientCenter: question.answerTime > 0 }"

这个判断答题倒计时是否结束,如果没有结束,则展示在屏幕最中央,方便用户查看和选择,已经结束,则展示在屏幕左侧,方面用户查看和分享。

image.png

游戏结束

image.png

游戏结束将展示游戏成绩,并从用户祝福中随机抽取一条进行展示

到这里整个游戏就完成了,由于篇幅有限,确实无法将每一个细节讲解详细,如果有朋友对哪里有问题,欢迎在评论区进行提问或者前往github或者码云提issue,在这里提前给各位拜年了!祝大家工作顺利,身体健康,全家和和美美,万事如意!