10分钟撸个 “羊了个羊” 出来

实现一个简易版本的“羊了个羊” html 版本(过不了关的我直接自己做一个😁)
不想搞太复杂 直接html一把梭

github:在线体验地址 大佬们点个小小的 start ⭐️ /

带着疑问去开发???

  • 该如何设置卡片呈现的机制(网格布局?随机布局?)
  • 卡片的数量随机创建
  • 卡片的位置是随机呈现?还是网格呈现?而且需要有一定的位置偏移
  • 卡片点击后移动至卡槽中,动画效果如何实现
  • 点击3个相同的卡片消除掉
  • 存放点击卡片的卡槽(下文简称卡槽)数量超过7个判定失败

准备工作

创建index.html文件,接下来的主要逻辑代码就在这了

为了省事些也是直接用了 vue3cdn的方式直接引入

<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1 /><link rel="stylesheet" href="index.css"><script src="@3.2.12/dist/vue.global.js"></script><title>Document</title>
</head>
<body><div id="app"></div>
</body>
</html>

该如何设置卡片呈现的机制(网格?随机?)

这个问题还是比较重要的,网格呈现和随机出现都是可以的,但是我还是选择了网格+随机出现

先制定好显示范围,设置卡片为 40px * 40px 那么制定8行8列的话 那就是 320px * 320px

在风格成小网格,这样就搞定了卡片放置的坑位,到时候随机出现在n行m列就行了

卡片图标的话直接用表情,哈哈哈 就不用找图片了

// 卡片默认图标
const defaultIcons = ['🐑','😀', '😭', '😂', '😍', '😎', '😘', '😳', '😇', '🤪'];

默认用了10个图标,目前设置是第一关选择两个表情,第二关四个表情,以此类推。。。。。

根据关卡生成一定数量的卡片

首先得思考一下如何根据关卡在 defaultIcons中选取图标数量呢

const data = reactive({// 游戏等级level: 1,
});
const icons = computed(() => {return defaultIcons.slice(0, 2 * data.level);
});/*** 等级切换 重置游戏 */
watch(() => data.level, () => {// 重置游戏代码
});

利用 computed计算属性动态的计算根据游戏等级level去浅拷贝一份图标defaultIcons

例如第一关效果如下

第二关效果

等等等。。。以下关卡就不展示了

接着再考虑生成卡片数量

游戏设定是3张一样的卡片即为可消除,那为保证能正常的游戏,那单个图片卡片生成的的数量一定数3的倍数。

那好,接下来已[“🐑”, “😄”]作为例子

第一关就应该生成这样的6张卡片 [“🐑”, “🐑”, “🐑”, “😄”, “😄”, “😄”]

感觉显得有点单调,可以考虑加一个随机数,比如 “🐑” 生成6个又或者生成9个(保证是3的倍数就行,如果不是的话那怎么都通过不了)

// 卡片默认生成3的倍数
const defaultRounds = [3, 6, 9, 2, 7];

哈哈 加了两个不是3的倍数,有百分之六十的几率可以通关😂

生成卡片, 创建init函数 专门处理生成卡片

// 初始化
const init = () => {const cards = [];for(const i in icons.value) {// 随机3的倍数const rounds = defaultRounds[Math.floor(Math.random() * defaultRounds.length)];for(let k = 0; k < rounds; k++) {cards.push(icons.value[i]);}}console.log(cards);
}

多随机几次看看效果

哈哈哈这关肯定通过不了,有7个“😄”

这样下来数量搞定了

数量搞定了,光一个表情肯定是不行的,那接着完善一下卡片信息

跟位置有关那先定义一下默认偏移量的集合,之所以需要偏移量的集合,那是因为观察“羊了个羊”游戏,可以确定没有出现过完全重叠的情况,那么就要考虑卡片偏移的因素,当然这个值是随意定的

例如下图,绿框和红框是不能完全覆盖重叠的,所以出现偏移的情况,而又需要偏移的角度不能固定死,有上下左右组合成八个方向的偏移

由此设置以下defaultOffsetValue偏移量集合

// 卡片默认偏移值
const defaultOffsetValue = [7, -7, 20, -20, 25, -25, 33, -33, 40, -40];
const defaultOffsetValueLength = defaultOffsetValue.length;

那怎么使用这个偏移呢,随机在集合中取一个,真是机智

// 偏移 
const offset = defaultOffsetValue[Math.floor(defaultOffsetValueLength * Math.random())];

接下来就是生成行列,为了不让卡片太飘逸的生成位置,决定固定列数8 * 8

// 随机8列 8行 
const row = Math.floor(Math.random() * 8); 
const col = Math.floor(Math.random() * 8);

重点来了,生成卡片的xy轴位置信息

x位置 = 列 * 宽度 + 偏移量

y位置 = 行 * 高度 + 偏移量

// 默认这是卡片高宽度 40px
let x = col * 40 + offset; 
let y = row * 40 + offset;

下一步把cards.push(cards);代码改造一下,抽离成功函数 定义成 createCardInfo函数

在此之前先把卡片配置项声明一下,还有生成id的随机函数

// 卡片配置项
const config = reactive({// 默认卡片宽高base: 40,// 行row: 8,// 列col: 8
});const data = reactive({// 游戏等级level: 1,// 卡片信息集合cards: []
});/*** 随机生成指定长度id*/
const randomCreateId = (length) => {return (Math.random() + new Date().getTime()).toString(32).slice(0,length);
}

修改后的init函数


// 初始化
const init = () => {for(const i in icons.value) {// 随机3的倍数const rounds = defaultRounds[Math.floor(Math.random() * defaultRounds.length)];for(let k = 0; k < rounds; k++) {// 把图标传入创建卡片属性函数中createCardInfo(icons.value[i])}}
}
init();

createCardInfo函数主体

卡片的信息包括 (目前只考虑这么多)

  • id
  • 图标
  • x
  • y
  • 控制遮罩层
  • 是否在卡槽中
  • 是否需要清除
  • 清除后是否隐藏
// 创建卡片属性集合
const createCardInfo = (icon) => {// 偏移量const offset = defaultOffsetValue[Math.floor(defaultOffsetValueLength * Math.random())];// 随机8列 8行const row = Math.floor(Math.random() * config.row);const col = Math.floor(Math.random() * config.col);let x = col * config.base + offset;let y = row * config.base + offset;data.cards.push({id: randomCreateId(6),icon,x,y,// 控制遮罩层not: true,// 是否在卡槽中 0否 1是status: 0,// 是否清除clear: false,// 隐藏display: false})
}

打印出来瞅一瞅

console.log(data.cards);

看看打印出来的效果,符合预期

接下来就是要考虑怎么把他呈现在容器里边 🤔

先设计一下html标签的设计

<div id="app"><!-- 容器 --><div class="container"><div class="card" v-for="(item, index) in cards" :key="index"><span>{{ item.icon }}</span></div></div
</div><style>
/*** 卡片容器 ***/
.container {position: relative;width: 320px;height: 320px;border: 1px solid #ccc;
}
/*** 卡片 ***/
.card {position: absolute;display: flex;justify-content: center;align-items: center;height: 40px;width: 40px;font-size: 30px;cursor: pointer;user-select: none;
}
.card span {opacity: 0.5;font-size: 24px;
}
</style>

div.container是卡片容器 也就是8*8的div div.card就是卡片 看看效果,所有的卡片都重叠在一起了

目前完成一大半了 nice 😊 接下来就是位置了 这我打算用translate来做 当然用top left也可以,看自己吧

<div class="card" v-for="(item, index) in cards" :key="index" :style="`transform: translateX(${item.x}px) translateY(${item.y}px);`"><span>{{ item.icon }}</span>
</div>

再看看效果,位置随机了,但是又发现一个小问题,有的卡片出容器外面了,这是为什么?

原来是设置的偏移量导致的,例如defaultOffsetValue中有一个-40,刚好算的x轴是-40px,而卡片又是40*40,这种情况就导致了笑脸卡片刚好出去了,我们可以对html标签结构做个小改动,外面在包一层然后在给个padding 不就搞定了吗 真是小机灵鬼

<div id="app"><!-- 容器 --><div class="wrap"><div class="container"><div class="card" v-for="(item, index) in cards" :key="index"><span>{{ item.icon }}</span></div></div</div>
</div>
<style>
.wrap {// 给个40px 再加个4px的间隙padding: 44px;border: 1px solid #ccc;border-radius: 10px;
}
</style>

再看看效果, 这问题不就解决了吗

位置算是搞定了,但出现另外一个问题 那就是被重叠的卡片需要一个阴影且不能点击,如图

如上图所示,被覆盖的卡片变灰,那怎么判断是否被覆盖呢??

怎么判断是否被覆盖呢??

首先先制定一下覆盖的边界,如下图所示,8种情况任何一种都属于被重叠覆盖

假设左上角位置(x, y)那么四个顶点的位置坐标分别是(x+width,y)(x,y+height)(x+width,y+height)。计算a,b卡片之间是否是重叠状态只要判断b卡片的四个顶点是否在a卡片的范围之中

此时我们遍历一下卡片集合。例如有6张卡片,第一张卡片的位置信息跟后面5张卡片做一个比较,以为前面的已经在上一次遍历过程中比较过了,以此类推,8种情况结合一下得出总结以下4种判断条件

a(x, y)、b(x1, y1) 两种卡片 宽高为40px,例如a在b下面 以下4种情况

// 1、左上顶点
x1 >= x && x1 <= x + 40 && y1 >= y &7 y1 <= y + 40
// 2、左下顶点
x1 >= x && x1 <= x + 40 && y1 + 40 >= y &7 y1 + 40 <= y + 40
// 3、右上顶点
x1 + 40 >= x && x1 + 40 <= x + 40 && y1 >= y &7 y1 <= y + 40
// 4、右下顶点
x1 + 40 >= x && x1 + 40 <= x + 40 && y1 + 40 >= y &7 y1 + 40 <= y + 40

搞定。 这一大串判断种感觉很不简洁,接着来优化一波~~~

b卡片的 y1 小于或者大于 a卡片的 y,x1 小于或者大于 a卡片的 x。 再取反值 这样不就搞定了吗

y1 + 40 <= y || y1 >= y + 40 || x1 + 40 <= x || x >= x + 40

完整代码如下:

/*** 是否有阴影*/
const checkShading = () => {
const cards = data.cards;
for (let i = 0; i < cards.length; i++) {const cur = cards[i];// 默认没有遮罩cur.not = true;const { x: x1, y: y1 } = cur;const x2 = x1 + config.base, y2 = y1 + config.base;for (let j = i + 1; j < cards.length; j++) {const compare = cards[j];const { x, y } = compare;if (!(y + config.base <= y1 || y >= y2 || x + config.base <= x1 || x >= x2)) {// 设置遮罩cur.not = false;break;}}}
}

html中也需要修改,声明一个样式 .is-allow

<div id="app"><!-- 容器 --><div class="wrap"><div class="container"><div class="card" v-for="(item, index) in cards" :key="index":class="[item.not && 'is-allow']"><span>{{ item.icon }}</span></div></div</div>
</div><style>
.is-allow {background-color: white;
}
.is-allow span {opacity: 1;
}
</style>

最后的效果。可以看到成功被遮挡,可以点击的用.is-allow来表示样式

离成功不远啦

卡片点击后移动至卡槽中,动画效果如何实现

在容器下方设置一个卡槽,专门存放被点击的卡片 设置7个卡片的位置 40*7

代码实现

<div id="app"><!-- 容器 --><div class="wrap"><div class="container"><div class="card" v-for="(item, index) in cards" :key="index":class="[item.not && 'is-allow']"><span>{{ item.icon }}</span></div></div</div>
</div>
<!-- 卡槽 -->
<div class="card-slot"></div><style>
/*** 卡槽 ***/
.card-slot {margin-top: 20px;padding: 10px 20px 10px 20px;border: 1px solid #ccc;height: 40px;width: 280px;border-radius: 10px;
}
</style>

当我们点击卡片的时候 让卡片的xy坐标设置到卡槽中,设置点击事件,而点击时需要判断点击的卡片是否已存在卡槽中 status=0 否status=1 在两种状态 1时禁止点击,并且还需判断not的状态是否被覆盖、卡槽中是否已经满了,超过最大限制则判断游戏失败,为什么需要用到setTimeout呢,那是因为需要等到动画效果结束后才执行判断,否则的话太突兀的闪烁一下

定义变量存放卡槽中的卡片 data.select 初始值默认Map,为什么要定义map结构呢 下面会详细讲的。

定义selectLength计算属性,为卡槽中卡片的数量

// 卡片配置项
const config = reactive({// 默认卡片宽高base: 40,// 行row: 8,// 列col: 8,// 定义动画时间 毫秒animationTime: 300,// 卡槽存放最大卡片数selectMaxLength: 7
});
const data = reactive({level: 1,cards: [],select: new Map()
});/*** 卡槽已存在卡片长度*/
const selectLength = computed(() => {let length = 0;data.select.forEach((item) => {length += item.length;})return length;
});

模板中定义clcik事件

<div class="card" v-for="(item, index) in cards" :key="index":class="[item.not && 'is-allow']"@click="clickCard(item, index)"><span>{{ item.icon }}</span>
</div>/*** 点击卡片*/
const clickCard = async (item, index) => {// 卡槽中的卡片不允许点击if(item.status === 1) return;const length = selectLength.value;const { selectMaxLength } = config;if(item.not && length < selectMaxLength) {const cards = data.cards;const currentCard = cards[index];currentCard.status = 1;// 刷新卡槽位置await refreshCardPosition(currentCard);// 刷新被遮挡卡片checkShading();};// 校验卡片卡槽卡片数量长度setTimeout(() => {if(selectLength.value >= config.selectMaxLength) {alert('游戏失败 重新开始');init();}}, config.animationTime);}

上面代码中提到 refreshCardPosition函数是做什么作用的呢??没错,是用来设置点击卡片新坐标的。如下图 上面的 🐑 需要到卡槽中的 🐑 这一位置该怎么去计算呢???

第一步是先获取卡槽的位于页面上的位置、第二步获取容器的位置

const data = reactive({level: 1,cards: [],select: new Map(),// 容器信息containerInfo: null,// 卡槽信息cardSlotInfo: null
});
onMounted(() => {const containerDom = document.querySelector('.container');data.containerInfo = containerDom.getClientRects()[0];const cardSlotDom = document.querySelector('.card-slot');data.cardSlotInfo = cardSlotDom.getClientRects()[0];
})

新位置怎么算的??


如图我们可以总结一下新坐标是怎么计算的

newY = cardSlotInfo.y - containerInfo.y + 12(看卡槽上padding)

newX = 卡片的index * width + width/2(看卡槽左padding)

由此引出另外一个问题,两种卡片之间怎么插入进去呢??第三个 🐑 怎么加入卡槽中的第三个位置,后面的 😀 怎么自动移动一个单元位置(40 * 40)

再回到之前定义select时,为什么要定义成Map结构,好处就体现出来了,每次点击进来的时候只要判断卡槽中是否存在这个表情,有的话就push进去。最后把Map机构数据 forEach以次遍历一下,位置就这样搞定了

最终看看效果

有点生硬 直接变过去,加入个过渡效果

/*** 卡片 ***/
.card {...transition: all 0.2s;
}

完整代码如下

/*** 刷新卡槽卡片位置*/
const refreshCardPosition = (item) => {const { x, y } = data.cardSlotInfo;const { top } = data.containerInfo;const cards = data.select.get(item.icon);if (cards) {cards.push(item);// 校验是否已经三个一样的卡片checkSelectQueue(cards);} else {data.select.set(item.icon, [item]);}// 重新刷新位置let index = 0;data.select.forEach((item) => {item.forEach((card) => {card.x = index * config.base + config.base / 2;card.y = y - top + 12;index++;});});
}

消除效果

然后又需要解决三个一样的卡片即为成功消除的效果,代码中提到的 checkSelectQueue函数 就是干这个的

只需要判断是否等于设定的消除个数就行了,等于的话直接消除,而消除用的display来模拟,只是在页面中隐藏,并没有直接删除。原因是响应式数据v-for的形式,数组改变了 会重新渲染(加入动画后导致),消除完后还需判断是否全部消除完,消除完则data.level++,自动进入下一关。二话不说 上代码

// 卡片配置项
const config = reactive({// 默认卡片宽高base: 40,// 行row: 8,// 列col: 8,// 定义动画时间 毫秒animationTime: 300,// 可已备消除个数maxCount: 3
});
/*** 校验卡槽中是否3个相同的存在*/
const checkSelectQueue = (cards) => {if(cards.length === config.maxCount) {// 动画效果执行完后执行setTimeout(() => {// 删除卡槽中卡片data.select.delete(cards[0].icon);// 删除cards中的卡片 软删除 display代替cards.forEach((item) => {item.display = true;})// 属性卡槽卡片位置refreshCardPosition();// 校验是否卡片列表是否还有未消除的卡片const hasCards = data.cards.filter((item) => !item.display);if(!hasCards.length) {alert(`通关啦, 开始第${data.level + 1}关`);data.level++;}}, config.animationTime);}
}

加入消除动画效果”缩小“ ,避免卡片突然消失显得很low,我们稍微改一下 checkSelectQueue函数,加入clear属性控制。

/*** 校验卡槽中是否3个相同的存在*/
const checkSelectQueue = (cards) => {if(cards.length === config.maxCount) {// 加入clear属性cards.forEach((item) => {item.clear = true;})...... (其他代码)}
}

为了避免缩小的时候不好看,又在card标签外头嵌套了一层,这样下来 div.card-wrap 只要关注位置的变化,div.card 关注动画效果

<div class="wrap"><div class="container"><div class="card-wrap"v-for="(item, index) in cards" :key="index"  :style="setCardStyle(item)"><div class="card" :class="[item.not && 'is-allow', item.id]":style="setAnimation(item)"@click="clickCard(item, index)"><span>{{ item.icon }}</span></div></div></div>
</div>// 设置卡片位置
const setCardStyle = ({ x, y }) => {return `transform: translateX(${x}px) translateY(${y}px);`;
}

设置卡片缩小动画

/*** 卡片动画 ***/
@keyframes scaleDraw {0% {transform: scale(1.1);}20% {transform: scale(1);}100% {transform: scale(0);}
}
 <div class="card" :class="[item.not && 'is-allow', item.id]":style="setAnimation(item)"@click="clickCard(item, index)"><span>{{ item.icon }}</span></div>// 设置卡片动画
const setAnimation = ({ id, clear, display }) => {let isClear = ''if(clear) {isClear = `animation: scaleDraw ${config.animationTime}ms;`}if(display) {isClear += 'display: none;';}return isClear;
}

怎么判断都消除成功了呢??

clickCard函数中判断卡槽卡片是否等于设定的

/*** 点击卡片*/
const clickCard = async (item, index) => {......(其他代码)// 校验卡片卡槽卡片数量长度setTimeout(() => {if(selectLength.value >= config.selectMaxLength) {alert('游戏失败 重新开始');init();}}, config.animationTime);}

游戏重置

卡片清空,卡槽清空,再初始化游戏

<span class="btn" @click="handleReset">重置</span>/*** 重置游戏*/
const handleReset = () => {// 清空已有的卡片data.cards.length = 0;data.select.clear();init();
}

上下关

<span class="btn" @click="handleSwitch('prev')">上一关</span>
<span class="btn" @click="handleSwitch('next')">下一关</span>/*** 切换关卡*/
const handleSwitch = (type) => {if(type === 'prev') {if(data.level === 1) {window.alert('已经是第一关了');return;}data.level--;} else {if(data.level === defaultIcons.length) {window.alert('已经是最后一关了');return;}data.level++;}
}

总结:目前所有功能都在这咯 后续加入其他的道具功能

后续补上其他

有建议的欢迎评论

效果图

完整代码 有兴趣的话可以去github地址: