“我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第1篇文章,点击查看活动详情”
Hello,我是Xc,一位因antfu结缘开源的前端菜鸟,今天和大家分享最近用vue3+ts+vite3做的一个小游戏项目【兔了个兔】。
一、为什么做这样一个项目
哦咯雷丽丽雷丽丽~~
这个画面和魔性的背景音乐大家应该都不陌生吧,席卷多少人的朋友圈和深夜。
由于一直在临近通关的边缘折戟,所以就索性自己做一个玩。
二、如何做一个这样的游戏
作为一个IT社畜,做了以下的事情:
1.需求分析(仅分析功能)
【羊了个羊】作为一个三消类型的游戏,其玩法就是就是选中三个相同的既可以销毁,将界面上的卡牌都销毁完成即通关,该游戏卡牌是一层层叠加上去,且存在遮盖关系(即卡牌上方有其他卡牌时不可点击)。
2.技术分析(前端角度)
2.1 游戏引擎方案
由于有过Phaser
的一点开发经历,就想说通过这个H5游戏引擎去处理这些遮盖的判断,快速实现(可以偷懒),查了半天英文文档和尝试写了demo都未实现,放弃该方案。
2.2 js+css方案
从css角度来看遮盖,那不就是绝对定位
+zIndex
的事情嘛~~
3.方案落实
3.1 层级
先看这种游戏的卡牌布局图,会发现上一层相对一层是类型这样的一个布局
关于层级和数量问题可以通过层级的平方去设置每个层级元素数量
,当然每个层级并不是铺满的,所以这个数量只能是层级最大元素数量
3.2 位置
层级的数量设定好了,在看下位置要怎么处理?从上图加上坐标轴后来看,卡牌宽度2,第一层第一张卡牌中心坐标(0,0),第二层第一张卡牌坐标(1,1),会有以下发现:
按照层的角度,中心坐标不变的情况下,每一层相对上一层的上下左右都外扩了50%卡牌的宽高。
按照卡牌的角度,第二层第一个卡牌是基于第一层的第一张卡牌进行的左上各50%卡牌宽高的偏移。
3.3 遮罩关系
元素的层级和位置确认后,那遮罩关系要怎么判断?
既然有了层级和位置,可以通过每个卡牌的左上角的坐标进行判断,如下图,基于第一层的一张卡牌的左上角为中心建立一个2倍长宽的遮罩区(其实也可以不用2倍),只要第二层卡牌的左上角XY轴坐标和遮罩区中心XY分布相减且绝对值都小于长宽的值,那即存在遮罩关系。
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"
}
}
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
})
这时候核心功能卡牌的生成和关系绑定已经完成,剩下事件的处理和卡牌组件的封装。
5.3 卡牌组件封装
这里我比较喜欢先把UI搞定,所以选择先处理卡牌组件的封装了
组件封装先从其所拥有的功能进行分析如下:
- 根据卡牌的top和left以及type在正确的位置渲染出对应的卡牌
- 内部能判断是否可点击,不可点击添加遮罩
- 支持限制是否使用绝对定位(提供给选中卡槽时候使用)
- 点击事件反馈
实现如下:
因为没有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>
看下效果:
5.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,最终实现效果如下
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
四、最后
如果觉得不错可以给个关注和star~~
如果有问题可以留言或者在项目提issue哈~~