前言
今年一款叫《羊了个羊》的微信小游戏爆火朋友圈,成为了很多打工人上班摸鱼的一把好手,我当然也不例外哈哈哈,于是我忍不住好奇心的折磨,也点开试玩了一盘,没想到就此落入了深渊,第一关结束后,我寻思,这游戏TM这么简单不是有手就行?于是紧接着第二关就措不及防的给了我几个耳光(爆粗口是吧、觉得简单是吧),在玩了几盘之后,我怒火中夹杂着不服,于是我决定,既然你不让我过,那我自己使用原生js复刻一个《狗了个狗》自己玩可以把?
- 说干就干,于是,在我一上午不懈努力的写代码,修复BUG,终于,项目....塌方了....
- 所以在这里友情提醒大家,写东西前一定要捋清楚思路,提前规划好设计方案,不然盲目开发,开发的过程中拆东墙补西墙,不仅浪费时间,还浪费时间,更浪费时间 (重要的事情说三遍
- 于是我又重新规划了设计方案,终于皇天不负有心人,我成功了! (感谢CCTV1,感谢CCTV2....)
话不多说,直接上最终的效果
请根据以下项目来看本文的内容,实例代码中,部分代码可能不全
项目Gitee 已开源
工具方法 全局使用的
/*
@utils methdos 方法
*/
// 设置样式
function setStyle(d, styleObject) {
for (const key in styleObject) {
d["style"][key] = styleObject[key];
}
d["style"]["transition"] = ".225s";
}
// 生成随机的坐标
function randomPosition(min, max) {
return randomKey(min, max);
}
// 生成随机的数字 (min,max)
function randomKey(min, max) {
return parseInt(Math.random() * (max - min + 1) + min);
}
// 打乱数组
function randomSort(a, b) {
return Math.random() > 0.5 ? -1 : 1;
}
《狗了个狗》开发准备
- 素材: 7张素材🐕图、1张背景图
- 原型设计
- 思路分析
- 开发
原型设计
玩过羊了个羊的应该都知道,这款游戏的设计界面,无非就是两个部分
- 规划存放
Block
块的区域- 规划收集盒区域 (点击上面
Block
的块,存放到下面的盒子里来)
思路分析
现在我们已经知道我们的界面大致的样子了,那么现在我们开始捋清楚我们的功能块
- 生成指定个数得
Block
块,(一组7个Block
,三组为起点,因为三个相同
的Block
才会消失
,(后续的生成也必须围绕3
的倍数来生成对应的组
),存放到Block盒
中。- 覆盖逻辑,
Block
的渲染从第一个开始,到最后一个结束。也就是按照数组的顺序,那么层级关系也就很明显了,优先渲染
的Block
会被下一次渲染
的Block
覆盖掉(重叠
的话会被覆盖,如果两个Block
离得很远,就不会被覆盖)我们就判断当前Block
的后面的所有元素
是否有和我重叠
(重叠逻辑比较复杂,后面有详细讲解)的,如果有就被遮挡
,否则不遮挡
。例如:[1,2,3,4,5]
我们判断3
是否被遮挡,我们需要去和4,5
去进行对比,1,2
是在我们下面的,所以不会遮挡我们,只会被我们遮挡- 当点击上方的
Block
块时,根据x,y
定位 移动到对应的收集盒的位置
- 当点击
Block
块,如果发现在收集盒已经存在相同
的Block
时,那么就将当前点击的Block
插入到相同的Block
位置,后面的Block
依次向后移动一个Block
的宽度- 当出现
三个一起
的Block
时,触发清除
,删除Dom
上的Block
块的实例Dom
,并将收集盒中空余的位置后面的元素诺到前面(比如中间的三个删除了,那么就需要把后面的挪到这里来,因为这里已经空了)- 当收集盒中
元素已经满了
且无法清除
时,表示游戏结束
- 当
收集盒
和Block盒
都为空
时,表示游戏胜利
开发与实现
目录结构
index.html
游戏界面其实很简单,我们这里采用的是最简洁的方案,话不多说,直接上代码
<!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.0" />
<link rel="stylesheet" href="./index.css" />
<title>羊了个羊</title>
</head>
<body>
<div id="app">
<!-- 放置选中的模块 -->
<div id="storageBox"></div>
</div>
<!-- 加载器 主要加载模块 -->
<script src="./loader.js"></script>
</body>
</html>
css
* {
margin: 0;
padding: 0;
}
#app {
width: 500px;
height: 600px;
margin: 50px auto;
background: url('./img/Bg.jpeg');
background-size: 100% auto;
background-position: center;
position: relative;
border-radius: 5px;
}
.imgGlobal {
position: absolute;
border-radius: 5px;
cursor: pointer;
}
.imgFilter {
filter: brightness(30%);
}
#storageBox {
height: 50px;
width: 350px;
position: absolute;
border-radius: 5px;
bottom: 40px;
left: 75px;
}
Block 块设计原理
现在我们已经大致捋清楚了我们开发的思路,
Block
块是我们游戏中最重要的一个部分,我们现在来创建一下我们的Block
块,这里我是使用了一个Class
类来声明的,为什么要使用Class
而不是直接直接使用createElement
渲染Dom
块,我在这里说明一下,因为我们后续需要不断地判断Blcok
块高亮,我这里是用的暴力检测
法,选择的方法是重新渲染Dom
,使用Class
类,保存他的const aCs = new XXX()
实例化结果,可以造成一种映射关系
,aCs
存储着生成dom
的参数,这样我们需要修改页面上的dom
的时候,不需要通过获取dom
的方式来获取它的参数,而是直接使用aCs
映射的参数即可
关系如下:我们只需要获取虚拟映射
的参数值,就可以直接操作页面上的真实Dom
Block 的具体实现
// Blcok块类
class Block {
// n 表示第几张图 (必须0-6) 也是配对的关键 一旦生成不会改变
// i 当前图片在数组中的下表 i 一旦生成 不会改变
constructor(src, i) {
this.width = $width;
this.height = $height;
// n用于当选中图片时 判断 src 是否相同 如果src相同即可
this.n = src;
// 当前图片生成的位置 (用于判断是否被遮盖 0被1遮盖, 1被2遮盖)
this.index = i;
// 图片路径
this.src = src;
// x 坐标
this.x = randomPosition(AppPosition.drawStartX, AppPosition.drawEndX);
// y 坐标
this.y = randomPosition(AppPosition.drawStartY, AppPosition.drawEndY);
// 是否被隐藏 默认被隐藏 (false隐藏. true高亮)
this.blockState = false;
}
// 是否被遮挡
// 判断逻辑: 从我这里开始算起,判断后续的Block是否有与我 x,y 交叉的节点,有就说明我被覆盖
isCover() {
var thatBlock;
var coverState = false;
for (let index = 0; index < allBlock.length; index++) {
// 找到他的位置
if (allBlock[index].index === this.index) {
thatBlock = allBlock[index];
} else if (thatBlock) {
// console.log("thatBlock ==> ", thatBlock);
// 目标元素
const target = allBlock[index];
// 找到当前 this.index 在数组中的位置
// 碰撞逻辑
var xLeft = target.x;
var xRight = target.x + target.width;
var yTop = target.y;
var yBottom = target.y + target.height;
//只要thatBlock在这4个临界值内 那么就说明发生了碰撞
if (
!(
thatBlock.x > xRight ||
thatBlock.x + thatBlock.width < xLeft ||
thatBlock.y > yBottom ||
thatBlock.y + thatBlock.height < yTop
)
) {
coverState = true;
break;
}
}
}
return coverState;
}
// 绘制块
draw() {
const imgDom = new Image();
imgDom.src = this.src;
imgDom.id = this.index;
imgDom.onclick = clickBlock.bind(null, imgDom, this);
// noSelect 用于区分 是否已经被收集 被收集后变成 isSelect
imgDom.classList = "noSelect imgGlobal";
// 获取位置
let style = {
left: this.x + "px",
top: this.y + "px",
width: this.width + "px",
height: this.height + "px",
};
// 判断是否被遮挡
if (this.isCover()) {
imgDom.classList.add("imgFilter");
this.blockState = false;
} else {
imgDom.classList.remove("imgFilter");
this.blockState = true;
}
setStyle(imgDom, style);
return imgDom;
}
}
现在我们来解释一下这段代码
constructor
构造函数接受两个参数分别是图片的路径src
和生成时排列的下表i
,src
赋值给n
和src
,n
是用于判断收集盒中是否有已经存在的Block
块,src
用于控制生成的img
路径x,y
坐标是随机创建生成的,这个生成的区域是有限制的,请看下图,要保证两端均可留出20px
像素的间距
3. 生成Block
块,根据constructor
构造函数初始化的值,来生成真正的DOM
4. 在生成Block
块时,使用isCover
方法判断是当前块是否被覆盖,判断的逻辑是,从当前开始,以此去判断后续生成的Block
是否与我有交叉,如果有跳出循环,设置为覆盖
覆盖逻辑如下
覆盖有四个完全不会重复的逻辑,只要满足任意一个
,就可以保证他不存在
覆盖的情况,所以我们可以判断:如果这四个没有一个符合的,那么就发生了覆盖
大致看一下程序的实现的逻辑,完全是遵守了上图中的四不含覆盖
逻辑
// 目标元素
const target = allBlock[index];
// 找到当前 this.index 在数组中的位置
// 碰撞逻辑
var xLeft = target.x;
var xRight = target.x + target.width;
var yTop = target.y;
var yBottom = target.y + target.height;
//只要thatBlock在这4个临界值内 那么就说明发生了碰撞
if (
!(
thatBlock.x > xRight ||
thatBlock.x + thatBlock.width < xLeft ||
thatBlock.y > yBottom ||
thatBlock.y + thatBlock.height < yTop
)
) {
coverState = true;
break;
}
}
遮挡置灰逻辑判断
// 判断是否被遮挡
if (this.isCover()) {
imgDom.classList.add("imgFilter");
this.blockState = false;
} else {
imgDom.classList.remove("imgFilter");
this.blockState = true;
}
生成N组块
// 多少组一组3个
const BlockNums = 15;
// 消消乐元素
const IMGS = [
"./img/key1.jpeg",
"./img/key2.jpeg",
"./img/key3.jpeg",
"./img/key4.jpeg",
"./img/key5.jpeg",
"./img/key6.jpeg",
"./img/key7.jpeg",
];
// 存放block块的映射
const allBlock = [];
// 生成Block模块
function drawBlock(gloup) {
// IMGS
// 一共多少组
let virtualArr = [];
for (let index = 0; index < gloup; index++) {
// 保存打乱的数组
virtualArr.push(...IMGS.sort(randomSort));
}
// 生成实例化Block
virtualArr.forEach((v, index) => {
const vBlock = new Block(v, index);
allBlock.push(vBlock);
});
// 为什么要分离,因为不用实例化多次
createBlockToDocument();
}
// 创建Block模块到文档
function createBlockToDocument() {
// 上面加入完成后,下面开始绘制
allBlock.forEach((v) => {
app.appendChild(v.draw());
});
}
我们看一下这段代码,我们根据定义了多少组来循环IMGS
,因为IMGS
就是一组,我们在存储的过程中使用IMGS.sort(randomSort)
打乱了数组,确保了Block
块出现的随机性
drawBlock
用于生成映射Block
,每次只生成一次,重复调用会造成映射元素重新生成,导致位置Block
块改变,也就是说,drawBlock
在一局游戏下只会调用一次
createBlockToDocument
根据映射元素,生成实例化的Dom
节点,这个方法,不论调用多少次,都不会改变Dom
的位置,因为没有重新生成映射元素
现在让我们调用方法,查看是否出现Block
window.onload = function () {
// 生成卡片
drawBlock(BlockNums);
// 给收集盒子加边框
setStyle(storageBox, {
border: "10px solid rgb(15, 87, 255)",
});
};
那么现在,不出意外的情况下,我们的页面上应该就已经渲染了我们的
Block
元素
点击Block
块
我们现在已经具有了我们渲染的
Block
块,现在我们来绑定一下我们的点击事件
// 点击块事件
function clickBlock(target, targetDomClass) {
if (targetDomClass.blockState) {
// 将块插入到盒子中
computedBoxPosition(target, targetDomClass);
// 判断是否有可以消除的(已经存在三个一组了)
checkBox();
}
}
前文中,我们在isCover
中判断了是否被遮挡,如果遮挡targetDomClass.blockState
为false
, 也就是说只有targetDomClass.blockState
为true
时,才能表示他没有被覆盖,他才能够被点击
我们在Class Block
中,通过这样的方式进行了绑定
draw() {
const imgDom = new Image();
//...略
imgDom.onclick = clickBlock.bind(null, imgDom, this);
//...略
}
在clickBlock.bind(null, imgDom, this)
中,我们传递了两个参数,分别是imgDom
和this
, this就是指向了当前的映射元素Class Block
,imgDom
指向了我们映射元素
生成的真实DOM
,我们全部传给了clickBlock
方法
紧接着我们就通过computedBoxPosition(target, targetDomClass)
方法将点击的Dom
插入到我们的收集盒
中
移动到收集盒
当我们点击Dom
块时,要将点击的Dom
移动到下方的收集盒
里,需要处理的逻辑有:
- 如果收集盒为空,则直接塞入
Block
块 - 如果不为空,就判断是否有同类
Block
块元素存在
2.1 如果没有直接塞入
2.2 如果有就将后面的元素向后移动后插入到当前位置来
实现代码如下:
// 覆盖逻辑如下
// 按照顺序 0 - 100 存放叠加的block块
const allBlock = [];
// 收集盒: 收集 target和实例化的new Block()
const hasBeenStored = [];
const storageBox = document.getElementById("storageBox");
const borderWidth = 10;
// 插入
// 插入时 删除数组数据 不删除Dom
var StpragePosition;
var startLeft;
function computedBoxPosition(target, targetDomClass) {
// 将元素设置为最顶层 否则无法查看滚动弧
setStyle(target, {
zIndex: 9999,
});
// 获取元素四周的位置
StpragePosition = storageBox.getBoundingClientRect();
// 计算StpragePosition的盒子内容的0,0的位置 (盒子的坐标-外部的坐标(app四周的空白) + 边框)
startLeft = StpragePosition.x - AppPosition.x + borderWidth;
// top 是固定的因为是水平排列都在一条线上
const top = StpragePosition.y - AppPosition.y + borderWidth + "px";
// 每一项的解构 (target节点和 targetDomClass类)
const Item = {
targetDomClass,
target,
};
// debugger;
// 如果盒子是空的,就存放到0,0
if (!hasBeenStored.length) {
setStyle(target, {
left: startLeft + "px",
top,
});
targetDomClass.left = startLeft;
// 在最后面叠加直接push
hasBeenStored.push(Item);
} else {
// 查找是否有同样的元素存在
const hasIndex = hasBeenStored.findIndex(
(v) => v.targetDomClass.n == targetDomClass.n
);
// 没有同类型的盒子
if (hasIndex === -1) {
// 在后面叠加
const left = startLeft + hasBeenStored.length * targetDomClass.width;
setStyle(target, {
left: left + "px",
top,
});
// 修改绑定的实例链
targetDomClass.left = left;
// 在最后面叠加直接push
hasBeenStored.push(Item);
} else {
// 有同类型的盒子
// 插入进来,将后面全部的挪动一个块的位置
// 处理指定下标后面的
for (let index = hasBeenStored.length - 1; index >= hasIndex; index--) {
// 从最后面开始挪动
const newLeft = startLeft + (index + 1) * $width;
setStyle(hasBeenStored[index].target, {
left: newLeft + "px",
});
hasBeenStored[index].targetDomClass.left = newLeft;
}
// 插入新的到指定位置
// hasIndex 默认如果在最前面会是0 所以在他的后方+1
setStyle(target, {
left: startLeft + hasIndex * targetDomClass.width + "px",
top,
});
// 同步实例链上得值
targetDomClass.left = startLeft + hasIndex * targetDomClass.width;
// 因为这里是把后面的向后移动,所以需要使用splice
hasBeenStored.splice(hasIndex, 0, Item);
}
}
// 删除target的 noSelect 换成 isSelect
Item.target.classList.remove("noSelect");
Item.target.classList.add("isSelect");
// 将Item从数组中移除 因为已经加入到 收集盒Box下
const removeIndex = allBlock.findIndex(
(v) => v.index == Item.targetDomClass.index
);
allBlock.splice(removeIndex, 1);
// 暴力高亮 重新渲染
const noSelect = document.querySelectorAll(".noSelect");
// 全部移除Dom元素
for (var i = 0; i < noSelect.length; i++) {
app.removeChild(noSelect[i]);
}
// 重新渲染
createBlockToDocument();
}
首先,我们需要插入到收集盒内,但是请注意,我们这里并不是插入到收集盒DIV
内,而是插入到了他的x,y
的位置,也就是说,收集盒div
的左上角的坐标点,是我们要移动的相对的位置
// 获取元素四周的位置
StpragePosition = storageBox.getBoundingClientRect();
// 计算StpragePosition的盒子左上角的位置 (盒子的坐标-外部的坐标(app四周的空白) + 边框)
startLeft = StpragePosition.x - AppPosition.x + borderWidth;
// top 是固定的因为是水平排列都在一条线上
const top = StpragePosition.y - AppPosition.y + borderWidth + "px";
这段代码,我们分别计算出了startLeft
和top
的值,startLeft
也就是收集盒左上角的left
位置,top
是收集盒左上角top
的位置,top
计算出来后就是固定的,因为所有被点击的Block
都会在同一平面上
定义收集盒的每一项的结构 分别是:实例Dom
和映射元素
const Item = {
targetDomClass, // 映射元素
target, // 实例dom
};
处理收集逻辑,如果收集盒hasBeenStored
是空的
修改target
点击dom的x,y
位置, 然后同步到targetDomClass
映射元素,最后添加到hasBeenStored
收集盒子中
if (!hasBeenStored.length) {
setStyle(target, {
left: startLeft + "px",
top,
});
targetDomClass.left = startLeft;
// 在最后面叠加直接push
hasBeenStored.push(Item);
}
如果收集盒hasBeenStored
存在数据,就去查找是否有和当前点击的target
是相同的元素。
使用targetDomClass
的属性n
,n
相同就表示是同一个,如果没有相同的就直接修改target
的位置并同步映射元素
,如果有,就从当前的位置开始向后移动所有的后方元素
后方元素移动逻辑: 例如: 在[1,2,3]
中插入2
,那么就需要将2,3
的位置向后挪, 然后将2
插入到第二位
这里的逻辑是: startLeft + (自身下表 + 1) * $width
,移动后同步映射元素
else {
// 查找是否有同样的元素存在
const hasIndex = hasBeenStored.findIndex(
(v) => v.targetDomClass.n == targetDomClass.n
);
// 没有同类型的盒子
if (hasIndex === -1) {
// 在后面叠加
const left = startLeft + hasBeenStored.length * targetDomClass.width;
setStyle(target, {
left: left + "px",
top,
});
// 修改绑定的实例链
targetDomClass.left = left;
// 在最后面叠加直接push
hasBeenStored.push(Item);
} else {
// 有同类型的盒子
// 插入进来,将后面全部的挪动一个块的位置
// 处理指定下标后面的
for (let index = hasBeenStored.length - 1; index >= hasIndex; index--) {
// 从最后面开始挪动
const newLeft = startLeft + (index + 1) * $width;
setStyle(hasBeenStored[index].target, {
left: newLeft + "px",
});
hasBeenStored[index].targetDomClass.left = newLeft;
}
// 插入新的到指定位置
// hasIndex 默认如果在最前面会是0 所以在他的后方+1
setStyle(target, {
left: startLeft + hasIndex * targetDomClass.width + "px",
top,
});
// 同步实例链上得值
targetDomClass.left = startLeft + hasIndex * targetDomClass.width;
// 因为这里是把后面的向后移动,所以需要使用splice
hasBeenStored.splice(hasIndex, 0, Item);
}
}
现在我们应该可以进行点击Dom
,进行插入了,但是我们发现,我们点击后,后面原先被隐藏的Dom
,依旧无法变成高亮,那是因为,虽然我们修改了位置,但是我们并没有重新渲染到文档上
重新渲染
我们既然已经将Block
映射块加入到收集盒
中了,所以我们要删除掉在allBlock
数组中这个Block
,防止重新渲染的时候,又渲染出来
// 删除target的 noSelect 换成 isSelect
Item.target.classList.remove("noSelect");
Item.target.classList.add("isSelect");
// 将Item从数组中移除 因为已经加入到 收集盒Box下
const removeIndex = allBlock.findIndex(
(v) => v.index == Item.targetDomClass.index
);
allBlock.splice(removeIndex, 1);
将已经加入到收集盒
中的映射元素
,在allBlock
数组中删除以后,我们就可以确保allBlock
数组中全部都是未点击的Block
块,那么我们现在需要重新渲染他们,因为重新渲染会触发isCover
是否高亮的逻辑,从而达到刷新,逻辑如下:
// 暴力高亮 重新渲染
const noSelect = document.querySelectorAll(".noSelect");
// 全部移除Dom元素
for (var i = 0; i < noSelect.length; i++) {
app.removeChild(noSelect[i]);
}
// 重新渲染
createBlockToDocument();
createBlockToDocument
只会执行遍历allBlock
,所以这也就是为什么我们要删掉已经加入到收集盒
中的映射元素
,因为不删除的话,createBlockToDocument
执行,又会将他重新渲染出来。
消消乐
我们把点击的Block映射元素
加入到收集盒
后,我们需要处理消除逻辑
,也就是说,当相同元素达到三个
时,我们就要执行消除,分别删除收集盒中的Block映射元素
和文档上的元素
我们需要处理三件事情
- 收集验证
- 如果有
三个相同
的,那么就清除掉 - 清除掉以后,将后面的元素,全部移动上来
- 上面程序执行完毕后,判断
收集盒
中映射元素
的数量 - 如果等于
7
游戏结束输
了 - 如果
收集盒数量
等于0
,并且allBlock
也已经没有元素了,就表示你赢了
// 验证组判断和清除 (是否已达成三个一组)
function checkBox() {
const checkMap = {};
hasBeenStored.forEach((v, i) => {
if (!checkMap[v.targetDomClass.n]) {
checkMap[v.targetDomClass.n] = [];
}
// 存下表
checkMap[v.targetDomClass.n].push({
index: i,
// Dom层id
id: v.targetDomClass.index,
});
});
// 检查是否有超过三个的
for (const key in checkMap) {
if (checkMap[key].length === 3) {
// console.log("可以删除", checkMap[key]);
// 删除数组
hasBeenStored.splice(checkMap[key][0].index, 3);
// 同步删除页面Dom
setTimeout(() => {
checkMap[key].forEach((v) => {
var box = document.getElementById(v.id);
box.parentNode.removeChild(box);
});
// 同步页面数据
hasBeenStored.forEach((v, i) => {
let left = startLeft + i * v.targetDomClass.width + "px";
// 同步target
setStyle(v.target, {
left,
});
// 同步映射class数据
v.targetDomClass.left = left;
});
}, 300);
}
}
// 验证状态
GameValidate();
}
// 验证输赢
function GameValidate() {
// 如果消除完毕 还有七个表示游戏结束
if (hasBeenStored.length === 7) {
alert("您G了");
gameOver = true;
}
// 消除后 两个数组全部为空 表示赢了
if (!allBlock.length && !hasBeenStored.length) {
alert("您WIN了");
gameOver = true;
}
}
至此,《狗了个狗》
的实现全部逻辑已经说完,其实实现的方法
有很多种,本文的示例中有很多暴力写法
其实都可以得到优化,在实际的开发中,暴力写法会造成性能有一定的影响,大家可以参考并根据思路进行优化。
结尾
本文纯玩具游戏开发,考虑的东西很少,技术采用的是JavaScript原生实现,希望能帮助到大家