“我报名参与金石方案1期应战——瓜分10万奖池,这是我的第1篇文章,点击查看活动概况”
Hello,我是Xc,一位因antfu结缘开源的前端菜鸟,今天和我们分享最近用vue3+ts+vite3做的一个小游戏项目【兔了个兔】。
在线demo
一、为什么做这样一个项目
哦咯雷丽丽雷丽丽~~
这个画面和魔性的布景音乐我们应该都不陌生吧,席卷多少人的朋友圈和深夜。
由于一直在接近通关的边际折戟(菜是原罪)color{darkgray}{(菜是原罪)},所以就干脆自己做一个玩。
二、怎么做一个这样的游戏
作为一个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
四、最后
在线demo
假设觉得不错可以给个关注和star~~
Xc GitHub
兔了个兔源码
假设有问题可以留言或许在项目提issue哈~~