《羊了个羊》第二关我过不去,那我自己写个《狗了个狗》玩可以吧?
共 28945字,需浏览 58分钟
·
2022-10-25 08:58
前言
今年一款叫《羊了个羊》的微信小游戏爆火朋友圈,成为了很多打工人上班摸鱼的一把好手,我当然也不例外哈哈哈,于是我忍不住好奇心的折磨,也点开试玩了一盘,没想到就此落入了深渊,第一关结束后,我寻思,这游戏TM这么简单不是有手就行?于是紧接着第二关就措不及防的给了我几个耳光(爆粗口是吧、觉得简单是吧),在玩了几盘之后,我怒火中夹杂着不服,于是我决定,既然你不让我过,那我自己使用原生js复刻一个《狗了个狗》自己玩可以把?
说干就干,于是,在我一上午不懈努力的写代码,修复BUG,终于,项目....塌方了.... 所以在这里友情提醒大家,写东西前一定要捋清楚思路,提前规划好设计方案,不然盲目开发,开发的过程中拆东墙补西墙,不仅浪费时间,还浪费时间,更浪费时间 (重要的事情说三遍 于是我又重新规划了设计方案,终于皇天不负有心人,我成功了! (感谢CCTV1,感谢CCTV2....)
话不多说,直接上最终的效果
请根据以下项目来看本文的内容,实例代码中,部分代码可能不全
项目Gitee 已开源[1]
工具方法 全局使用的
/*
@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原生实现,希望能帮助到大家
关于本文
作者:大概三只鱼
https://juejin.cn/post/7144992171735646244