《羊了个羊》第二关我过不去,那我自己写个《狗了个狗》玩可以把?

38,910 阅读11分钟

前言

今年一款叫《羊了个羊》的微信小游戏爆火朋友圈,成为了很多打工人上班摸鱼的一把好手,我当然也不例外哈哈哈,于是我忍不住好奇心的折磨,也点开试玩了一盘,没想到就此落入了深渊,第一关结束后,我寻思,这游戏TM这么简单不是有手就行?于是紧接着第二关就措不及防的给了我几个耳光(爆粗口是吧、觉得简单是吧),在玩了几盘之后,我怒火中夹杂着不服,于是我决定,既然你不让我过,那我自己使用原生js复刻一个《狗了个狗》自己玩可以把?

  • 说干就干,于是,在我一上午不懈努力的写代码,修复BUG,终于,项目....塌方了....
  • 所以在这里友情提醒大家,写东西前一定要捋清楚思路,提前规划好设计方案,不然盲目开发,开发的过程中拆东墙补西墙,不仅浪费时间,还浪费时间,更浪费时间 (重要的事情说三遍
  • 于是我又重新规划了设计方案,终于皇天不负有心人,我成功了! (感谢CCTV1,感谢CCTV2....)

话不多说,直接上最终的效果 7hibp-mwcca.gif

请根据以下项目来看本文的内容,实例代码中,部分代码可能不全
项目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的块,存放到下面的盒子里来)

原型图.png

思路分析

现在我们已经知道我们的界面大致的样子了,那么现在我们开始捋清楚我们的功能块

  • 生成指定个数得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盒都为时,表示游戏胜利

开发与实现

目录结构

image.png

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

image.png

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;
  }
}

现在我们来解释一下这段代码

  1. constructor构造函数接受两个参数分别是图片的路径src和生成时排列的下表i,src赋值给nsrc,n是用于判断收集盒中是否有已经存在的Block块, src用于控制生成的img路径
  2. x,y坐标是随机创建生成的,这个生成的区域是有限制的,请看下图,要保证两端均可留出20px像素的间距

image.png 3. 生成Block块,根据constructor构造函数初始化的值,来生成真正的DOM
4. 在生成Block块时,使用isCover方法判断是当前块是否被覆盖,判断的逻辑是,从当前开始,以此去判断后续生成的Block是否与我有交叉,如果有跳出循环,设置为覆盖

覆盖逻辑如下

覆盖有四个完全不会重复的逻辑,只要满足任意一个,就可以保证他不存在覆盖的情况,所以我们可以判断:如果这四个没有一个符合的,那么就发生了覆盖

image.png

大致看一下程序的实现的逻辑,完全是遵守了上图中的四不含覆盖逻辑

    // 目标元素
    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.blockStatefalse, 也就是说只有targetDomClass.blockStatetrue时,才能表示他没有被覆盖,他才能够被点击

我们在Class Block中,通过这样的方式进行了绑定

draw() {
    const imgDom = new Image();
    //...略
    imgDom.onclick = clickBlock.bind(null, imgDom, this);
    //...略
}

clickBlock.bind(null, imgDom, this)中,我们传递了两个参数,分别是imgDomthis, this就是指向了当前的映射元素Class Block,imgDom指向了我们映射元素生成的真实DOM,我们全部传给了clickBlock方法

紧接着我们就通过computedBoxPosition(target, targetDomClass)方法将点击的Dom插入到我们的收集盒

移动到收集盒

当我们点击Dom块时,要将点击的Dom移动到下方的收集盒里,需要处理的逻辑有:

  1. 如果收集盒为空,则直接塞入Block
  2. 如果不为空,就判断是否有同类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的左上角的坐标点,是我们要移动的相对的位置

image.png

// 获取元素四周的位置
StpragePosition = storageBox.getBoundingClientRect();
// 计算StpragePosition的盒子左上角的位置 (盒子的坐标-外部的坐标(app四周的空白) + 边框)
startLeft = StpragePosition.x - AppPosition.x + borderWidth;
// top 是固定的因为是水平排列都在一条线上
const top = StpragePosition.y - AppPosition.y + borderWidth + "px";

这段代码,我们分别计算出了startLefttop的值,startLeft也就是收集盒左上角的left位置,top是收集盒左上角top的位置,top计算出来后就是固定的,因为所有被点击的Block都会在同一平面上

image.png

定义收集盒的每一项的结构 分别是:实例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映射元素文档上的元素
我们需要处理三件事情

  1. 收集验证
  2. 如果有三个相同的,那么就清除掉
  3. 清除掉以后,将后面的元素,全部移动上来
  4. 上面程序执行完毕后,判断收集盒映射元素的数量
  5. 如果等于7 游戏结束
  6. 如果收集盒数量等于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原生实现,希望能帮助到大家