“我报名参与金石方案1期应战——瓜分10万奖池,这是我的第1篇文章,点击查看活动概况”

Hello,我是Xc,一位因antfu结缘开源的前端菜鸟,今天和我们分享最近用vue3+ts+vite3做的一个小游戏项目【兔了个兔】。

【羊了个羊】之我用vue3+ts+vite3从0到1开发了个【兔了个兔】

在线demo

一、为什么做这样一个项目

【羊了个羊】之我用vue3+ts+vite3从0到1开发了个【兔了个兔】

哦咯雷丽丽雷丽丽~~

这个画面和魔性的布景音乐我们应该都不陌生吧,席卷多少人的朋友圈和深夜。

由于一直在接近通关的边际折戟(菜是原罪)color{darkgray}{(菜是原罪)},所以就干脆自己做一个玩。

二、怎么做一个这样的游戏

作为一个IT社畜,做了以下的作业:

1.需求分析(仅分析功用)

【羊了个羊】作为一个三消类型的游戏,其玩法就是就是选中三个相同的既可以毁掉,将界面上的卡牌都毁掉结束即通关,该游戏卡牌是一层层叠加上去,且存在隐秘联络(即卡牌上方有其他卡牌时不可点击)。

2.技术分析(前端角度)

2.1 游戏引擎方案

由于有过Phaser的一点开发履历,就想说通过这个H5游戏引擎去处理这些隐秘的判别,快速结束(可以偷闲),查了半天英文文档和测验写了demo都未结束,扔掉该方案。

2.2 js+css方案

从css角度来看隐秘,那不就是必定定位+zIndex的作业嘛~~

3.方案执行

3.1 层级

【羊了个羊】之我用vue3+ts+vite3从0到1开发了个【兔了个兔】

先看这种游戏的卡牌布局图,会发现上一层相对一层是类型这样的一个布局

【羊了个羊】之我用vue3+ts+vite3从0到1开发了个【兔了个兔】

关于层级和数量问题可以通过层级的平方去设置每个层级元素数量,当然每个层级并不是铺满的,所以这个数量只能是层级最大元素数量

3.2 方位

【羊了个羊】之我用vue3+ts+vite3从0到1开发了个【兔了个兔】

层级的数量设定好了,在看下方位要怎样处理?从上图加上坐标轴后来看,卡牌宽度2,第一层第一张卡牌中心坐标(0,0),第二层第一张卡牌坐标(1,1),会有以下发现:

按照层的角度,中心坐标不变的情况下,每一层相对上一层的上下左右都外扩了50%卡牌的宽高。

按照卡牌的角度,第二层第一个卡牌是根据第一层的第一张卡牌进行的左上各50%卡牌宽高的偏移。

3.3 遮罩联络

元素的层级和方位承认后,那遮罩联络要怎样判别?

已然有了层级和方位,可以通过每个卡牌的左上角的坐标进行判别,如下图,根据第一层的一张卡牌的左上角为中心建立一个2倍长宽的遮罩区(其实也可以不必2倍),只需第二层卡牌的左上角XY轴坐标和遮罩区中心XY散布相减且必定值都小于长宽的值,那即存在遮罩联络。

【羊了个羊】之我用vue3+ts+vite3从0到1开发了个【兔了个兔】

4.技术选型

话不多说,就是vue了~

// package.json
{
"dependencies": {
    "canvas-confetti": "^1.5.1",
    "lodash-es": "^4.17.21",
    "vue": "^3.2.37"
  },
  "devDependencies": {
    "@antfu/eslint-config": "^0.26.3",
    "@iconify-json/carbon": "^1.1.8",
    "@types/canvas-confetti": "^1.4.3",
    "@types/lodash-es": "^4.17.6",
    "@types/node": "^18.7.18",
    "@vitejs/plugin-vue": "^3.1.0",
    "eslint": "^8.23.1",
    "typescript": "^4.6.4",
    "unocss": "^0.45.21",
    "vite": "^3.1.0",
    "vue-tsc": "^0.40.4"
  }
 }

【羊了个羊】之我用vue3+ts+vite3从0到1开发了个【兔了个兔】

5.功用开发

5.1 type定义

首要定义卡牌的数据类型,一个明晰的数据结构,在开发上可以事半功倍。

// 卡片节点类型
type CardNode = {
  id: string           // 节点id zIndex-index
  type: number         // 类型
  zIndex: number       // 图层
  index: number        // 地点图层中的索引
  parents: CardNode[]  // 父节点
  row: number          // 行
  column: number       // 列
  top: number
  left: number
  state: number        // 是否可点击 0: 无情况  1: 可点击 2:已选 3:已消除
}

5.2 中心代码结束

生成cardNodes流程图

graph TD
生成卡牌池 --> 打乱卡牌;
打乱卡牌 --> 卡牌分层;
卡牌分层 --> 建立遮罩联络;

代码如下:

// useGame.ts
// 生成节点池
const itemTypes = (new Array(cardNum).fill(0)).map((_, index) => index + 1)
let itemList: number[] = []
const selectedNodes = ref<CardNode[]>([])
for (let i = 0; i < 3 * layerNum; i++)
    itemList = [...itemList, ...itemTypes]
// 打乱节点
itemList = shuffle(shuffle(itemList))
// 初始化各个层级节点
let len = 0
let floorIndex = 1
const floorList: number[][] = []
const itemLength = itemList.length
while (len <= itemLength) {
    const maxFloorNum = floorIndex * floorIndex
    const floorNum = ceil(random(maxFloorNum / 2, maxFloorNum))
    floorList.push(itemList.splice(0, floorNum))
    len += floorNum
    floorIndex++
}
const containerWidth = container.value!.clientWidth
const containerHeight = container.value!.clientHeight
const width = containerWidth / 2
const height = containerHeight / 2 - 60
// 建立遮罩联络
floorList.forEach((o, index) => {
  indexSet.clear()
  let i = 0
  const floorNodes: CardNode[] = []
  o.forEach((k) => {
    i = floor(random(0, (index + 1) ** 2))
    while (indexSet.has(i))
      i = floor(random(0, (index + 1) ** 2))
    const row = floor(i / (index + 1))
    const column = index ? i % index : 0
    const node: CardNode = {
      id: `${index}-${i}`,
      type: k,
      zIndex: index,
      index: i,
      row,
      column,
      top: height + (size * row - (size / 2) * index),
      left: width + (size * column - (size / 2) * index),
      parents: [],
      state: 0,
    }
    const xy = [node.top, node.left]
    perFloorNodes.forEach((e) => {
      if (Math.abs(e.top - xy[0]) <= size && Math.abs(e.left - xy[1]) <= size)
        e.parents.push(node)
    })
    floorNodes.push(node)
    indexSet.add(i)
  })
  nodes.value = nodes.value.concat(floorNodes)
  perFloorNodes = floorNodes
})

【羊了个羊】之我用vue3+ts+vite3从0到1开发了个【兔了个兔】

这时分中心功用卡牌的生成和联络绑定现已结束,剩下工作的处理和卡牌组件的封装。

5.3 卡牌组件封装

这儿我比较喜爱先把UI搞定,所以选择先处理卡牌组件的封装了

组件封装先从其所拥有的功用进行分析如下:

  1. 根据卡牌的top和left以及type在正确的方位烘托出对应的卡牌
  2. 内部能判别是否可点击,不可点击添加遮罩
  3. 支撑约束是否运用必定定位(提供给选中卡槽时分运用)
  4. 点击工作反应

结束如下:

【羊了个羊】之我用vue3+ts+vite3从0到1开发了个【兔了个兔】

由于没有require赶时间就先这么写了,现在看这段代码着实太丑了,马上优化

第一步,定义props,其实我们入参也就卡牌节点和是否约束运用必定定位

interface Props {
  node: CardNode
  isDock?: boolean
}

第二步,定义emits,作为点击工作的反应

const emit = defineEmits(['clickCard'])

第三步,通过核算属性判别卡牌是否可点击(冻住情况)

const isFreeze = computed(() => {
  return props.node.parents.length > 0 ? props.node.parents.some(o => o.state < 2) : false
},
)

第四步,html处理

<template>
  <div
    class="card"
    :style="isDock ? {} : { position: 'absolute', zIndex: node.zIndex, top: `${node.top}px`, left: `${node.left}px` }"
    @click="handleClick"
  >
    <img :src="IMG_MAP[node.type]" width="40" height="40" :alt="`${node.type}`">
    <div v-if="isFreeze" class="mask" />
  </div>
</template>

看下效果:

【羊了个羊】之我用vue3+ts+vite3从0到1开发了个【兔了个兔】

5.4 工作处理

首要先拾掇有哪些工作:

  1. 点击卡牌工作
  2. 消除工作
  3. 成功工作
  4. 失利工作

其间2 3 4的触发前提都是在1的基础上,所以结束如下

// useGame.ts
  function handleSelect(node: CardNode) {
    if (selectedNodes.value.length === 7)
      return
    node.state = 2
    histroyList.value.push(node)
    preNode.value = node
    const index = nodes.value.findIndex(o => o.id === node.id)
    if (index > -1) {
      delNode && nodes.value.splice(index, 1)
      // 判别是否现已清空卡牌,即是否成功
      if (delNode ? nodes.value.length === 0 : nodes.value.every(o => o.state > 0)) {
        removeFlag.value = true
        backFlag.value = true
        events.winCallback && events.winCallback()
      }
    }
    // 判别是否有可以消除的节点
    if (selectedNodes.value.filter(s => s.type === node.type).length === 2) {
      selectedNodes.value.push(node)
      // 为了动画效果添加推迟
      setTimeout(() => {
        for (let i = 0; i < 3; i++) {
          const index = selectedNodes.value.findIndex(o => o.type === node.type)
          selectedNodes.value.splice(index, 1)
        }
        preNode.value = null
        events.dropCallback && events.dropCallback()
      }, 100)
    }
    else {
      const index = selectedNodes.value.findIndex(o => o.type === node.type)
      if (index > -1)
        selectedNodes.value.splice(index, 0, node)
      else
        selectedNodes.value.push(node)
      events.clickCallback && events.clickCallback()
      // 判别卡槽是否已满,即失利
      if (selectedNodes.value.length === 7) {
        removeFlag.value = true
        backFlag.value = true
        events.loseCallback && events.loseCallback()
      }
    }
  }

这儿为什么是callback?

从项目的规划上,中心代码只负责处理逻辑功用,工作的后续功用通过callback给运用者自行定义。

5.5 道具功用结束

【羊了个羊】有四个功用(移除前三个卡牌,回退,洗牌,复生且移出前三个) 先结束移除前三个卡牌回退两个功用吧 结束以上三个功用需要添加一下变量

// useGame.ts
  const histroyList = ref<CardNode[]>([]) // 历史记录
  const backFlag = ref(false)      // 由于功用只能运用一次,做了flag约束
  const removeFlag = ref(false)    // 同上
  const removeList = ref<CardNode[]>([])  // 寄存移出的卡牌节点
  const preNode = ref<CardNode | null>(null)  // 寄存回退节点

回退功用:

// useGame.ts
  function handleBack() {
    const node = preNode.value
    // 当node存在时回退功用才华触发,由于在触发消除或许其他道具功用的时分,是无法触发回退的
    if (!node)
      return
    preNode.value = null
    backFlag.value = true
    node.state = 0
    delNode && nodes.value.push(node)
    const index = selectedNodes.value.findIndex(o => o.id === node.id)
    selectedNodes.value.splice(index, 1)
  }

移出功用:

  function handleRemove() {
  // 从selectedNodes.value中取出3个 到 removeList.value中
    if (selectedNodes.value.length < 3)
      return
    removeFlag.value = true
    preNode.value = null
    for (let i = 0; i < 3; i++) {
      const node = selectedNodes.value.shift()
      if (!node)
        return
      removeList.value.push(node)
    }
  }

5.6 效果处理

5.6.1 添加通过效果

想起了antfu直播扫雷时分的通过动画库canvas-confetti,毕竟结束效果如下

【羊了个羊】之我用vue3+ts+vite3从0到1开发了个【兔了个兔】

5.6.2 添加音效

const clickAudioRef = ref<HTMLAudioElement | undefined>()
const dropAudioRef = ref<HTMLAudioElement | undefined>()
const winAudioRef = ref<HTMLAudioElement | undefined>()
const loseAudioRef = ref<HTMLAudioElement | undefined>()
function handleClickCard() {
  if (clickAudioRef.value?.paused) {
    clickAudioRef.value.play()
  }
  else if (clickAudioRef.value) {
    clickAudioRef.value.load()
    clickAudioRef.value.play()
  }
}
function handleDropCard() {
  dropAudioRef.value?.play()
}
function handleWin() {
  winAudioRef.value?.play()
  isWin.value = true
  fireworks()
}
function handleLose() {
  loseAudioRef.value?.play()
  setTimeout(() => {
    alert('槽位已满,再接再厉~')
    window.location.reload()
  }, 500)
}
    <audio
      ref="clickAudioRef"
      style="display: none;"
      controls
      src="./audio/click.mp3"
    />
    <audio
      ref="dropAudioRef"
      style="display: none;"
      controls
      src="./audio/drop.mp3"
    />
    <audio
      ref="winAudioRef"
      style="display: none;"
      controls
      src="./audio/win.mp3"
    />
    <audio
      ref="loseAudioRef"
      style="display: none;"
      controls
      src="./audio/lose.mp3"
    />

上面需要特别处理的一个就是点击音效,由于音效时长是1s,在移动端运用的时分,点击的数据是会快于1s,所以假设不按照上方处理可能会导致第二下点击的音效丢掉。

5.6.3 添加卡牌动画效果

已然运用了vue,那就运用transition来结束吧

// useGame.ts
// 由所以默认删除节点,就无法处理动画,所以加了delNode的一个flag来处理。
delNode && nodes.value.splice(index, 1)
<template v-for="item in nodes" :key="item.id">
  <transition>
    <Card
      v-if="[0, 1].includes(item.state)"
      :node="item"
      @click-card="handleSelect"
    />
  </transition>
</template>

5.7 运用

useGame参数

interface GameConfig {
  container?: Ref<HTMLElement | undefined>,   // cardNode容器
  cardNum: number,                            // card类型数量
  layerNum: number                            // card层数
  trap?:boolean,                              //  是否敞开圈套
  delNode?: boolean,                          //  是否从nodes中剔除已选节点
  events?: GameEvents                         //  游戏工作
}

App.vue

提一下trap参数,由于收到反应说太好通关了,所以加了这个。(感受一下社会的历练)

三、 拥有你自己的x了个x

项目规划上去就现已可以支撑我们去fork项目后自定义自己的游戏,中心逻辑在useGame中,UI和效果之类的我们可以自定义card.vue中的图片文件和重写App.vue

四、最后

在线demo

假设觉得不错可以给个关注和star~~

Xc GitHub

兔了个兔源码

假设有问题可以留言或许在项目提issue哈~~