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

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

“我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第1篇文章,点击查看活动详情

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

image.png

在线demo

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

image.png

哦咯雷丽丽雷丽丽~~

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

由于一直在临近通关的边缘折戟(菜是原罪)\color{darkgray}{(菜是原罪)},所以就索性自己做一个玩。

二、如何做一个这样的游戏

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

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

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

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

2.1 游戏引擎方案

由于有过Phaser的一点开发经历,就想说通过这个H5游戏引擎去处理这些遮盖的判断,快速实现(可以偷懒),查了半天英文文档和尝试写了demo都未实现,放弃该方案。

2.2 js+css方案

从css角度来看遮盖,那不就是绝对定位+zIndex的事情嘛~~

3.方案落实

3.1 层级

image.png

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

image.png

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

3.2 位置

image.png

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

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

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

3.3 遮罩关系

元素的层级和位置确认后,那遮罩关系要怎么判断?

既然有了层级和位置,可以通过每个卡牌的左上角的坐标进行判断,如下图,基于第一层的一张卡牌的左上角为中心建立一个2倍长宽的遮罩区(其实也可以不用2倍),只要第二层卡牌的左上角XY轴坐标和遮罩区中心XY分布相减且绝对值都小于长宽的值,那即存在遮罩关系。

image.png

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"
  }
 }
复制代码

image.png

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
})
复制代码

86d11ffb1bbacdbbed28df22dfcade7.jpg

这时候核心功能卡牌的生成和关系绑定已经完成,剩下事件的处理和卡牌组件的封装。

5.3 卡牌组件封装

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

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

  1. 根据卡牌的top和left以及type在正确的位置渲染出对应的卡牌
  2. 内部能判断是否可点击,不可点击添加遮罩
  3. 支持限制是否使用绝对定位(提供给选中卡槽时候使用)
  4. 点击事件反馈

实现如下:

image.png

因为没有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>
复制代码

看下效果:

image.png

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,最终实现效果如下

image.png

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哈~~

分类:
前端
标签:
收藏成功!
已添加到「」, 点击更改