30分钟完成JavaScript中的记忆游戏

57次阅读

共计 13113 个字符,预计需要花费 33 分钟才能阅读完成。


通过在 30 分钟内构建一个记忆游戏来学习 JS,CSS 和 HTML!
本教程介绍了一些基本的关于 HTML5,CSS3 和 JavaScript 概念。我们将讨论数据属性,定位,透视,转换,flexbox,事件处理,超时和三元表达式。读懂此文章不需要大家有许多编程方面的知识。如果您已经知道 HTML,CSS 和 JS 的用途,那就绰绰有余了!

项目结构

让我们在终端中创建项目文件:

???? mkdir memory-game 
???? cd memory-game 
???? touch index.html styles.css scripts.js 
???? mkdir img

HTML

连接 css 和 js 文件的初始页面模板。

<!-- index.html -->

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">

  <title>Memory Game</title>

  <link rel="stylesheet" href="./styles.css">
</head>
<body>
  <script src="./scripts.js"></script>
</body>
</html>

这个游戏有 12 张卡片。每个卡片由一个名为.memory-card 的容器 div 组成,其中包含两个 img 元素。第一个代表卡片的 front-face(意为正面),第二个代表卡片的 back-face(意为背面)。

<div class="memory-card">
  <img class="front-face" src="img/react.svg" alt="React">
  <img class="back-face" src="img/js-badge.svg" alt="Memory Card">
</div>

我们可以在 Memory Game Repo 下载该项目的资源文件。
这组卡片将被包装在 section 容器元素中。最终代码结果是这样的:

<!-- index.html -->

<section class="memory-game">
  <div class="memory-card">
    <img class="front-face" src="img/react.svg" alt="React">
    <img class="back-face" src="img/js-badge.svg" alt="Memory Card">
  </div>

  <div class="memory-card">
    <img class="front-face" src="img/react.svg" alt="React">
    <img class="back-face" src="img/js-badge.svg" alt="Memory Card">
  </div>

  <div class="memory-card">
    <img class="front-face" src="img/angular.svg" alt="Angular">
    <img class="back-face" src="img/js-badge.svg" alt="Memory Card">
  </div>

  <div class="memory-card">
    <img class="front-face" src="img/angular.svg" alt="Angular">
    <img class="back-face" src="img/js-badge.svg" alt="Memory Card">
  </div>

  <div class="memory-card">
    <img class="front-face" src="img/ember.svg" alt="Ember">
    <img class="back-face" src="img/js-badge.svg" alt="Memory Card">
  </div>

  <div class="memory-card">
    <img class="front-face" src="img/ember.svg" alt="Ember">
    <img class="back-face" src="img/js-badge.svg" alt="Memory Card">
  </div>

  <div class="memory-card">
    <img class="front-face" src="img/vue.svg" alt="Vue">
    <img class="back-face" src="img/js-badge.svg" alt="Memory Card">
  </div>

  <div class="memory-card">
    <img class="front-face" src="img/vue.svg" alt="Vue">
    <img class="back-face" src="img/js-badge.svg" alt="Memory Card">
  </div>

  <div class="memory-card">
    <img class="front-face" src="img/backbone.svg" alt="Backbone">
    <img class="back-face" src="img/js-badge.svg" alt="Memory Card">
  </div>

  <div class="memory-card">
    <img class="front-face" src="img/backbone.svg" alt="Backbone">
    <img class="back-face" src="img/js-badge.svg" alt="Memory Card">
  </div>

  <div class="memory-card">
    <img class="front-face" src="img/aurelia.svg" alt="Aurelia">
    <img class="back-face" src="img/js-badge.svg" alt="Memory Card">
  </div>

  <div class="memory-card">
    <img class="front-face" src="img/aurelia.svg" alt="Aurelia">
    <img class="back-face" src="img/js-badge.svg" alt="Memory Card">
  </div>
</section>

CSS

我们将使用一个简单但非常有用的重置,适用于所有项目:

/* styles.css */

* {
  padding: 0;
  margin: 0;
  box-sizing: border-box;
}

box-sizing: border-box 属性使元素充满整个边框,因此我们可以跳过数学计算。
通过设置 display: flex 到 body 和 margin: auto 到.memory-game 容器,它将垂直地和水平地居中。
.memory-game 也将是一个 flex-container。默认情况下,里面的元素会缩小宽度来适应这个容器。通过将 flex-wrap 设置为 wrap,flex-items 会根据弹性元素的大小进行自适应。

/* styles.css */

body {
  height: 100vh;
  display: flex;
  background: #060AB2;
}

.memory-game {
  width: 640px;
  height: 640px;
  margin: auto;
  display: flex;
  flex-wrap: wrap;
}

每个卡片的 width(意为宽度)和 height(意为高度)都是用 calc() CSS 函数计算的。我们将 width 设置为 25%,height 设置为 33.333%,并从 margin(意为边距)中减去 10px,来制作三行四张牌。
对于.memory-card 子元素,我们添加 position: relative,这样我们就可以相对它进行子元素的绝对定位。
把属性 front-face 和 back-face 的属性设置为 position: absolute,这样就可以从原始位置移除元素,并使它们堆叠在一起。


/* styles.css */

.memory-card {width: calc(25% - 10px);
  height: calc(33.333% - 10px);
  margin: 5px;
  position: relative;
  box-shadow: 1px 1px 1px rgba(0,0,0,.3);
}

.front-face,
.back-face {
  width: 100%;
  height: 100%;
  padding: 20px;
  position: absolute;
  border-radius: 5px;
  background: #1C7CCC;
}

这时页面模版看上去应该是这样:


让我们也添加一个点击效果。:active 伪类将在每次点击元素时触发。它引发一个 0.2 秒的过渡:

.memory-card {width: calc(25% - 10px);
  height: calc(33.333% - 10px);
  margin: 5px;
  position: relative;
  transform-style: preserve-3d;
  box-shadow: 1px 1px 0 rgba(0, 0, 0, .3);
+ transform: scale(1);
}

+.memory-card:active {+  transform: scale(0.97);
+  transition: transform .2s;
+}

翻转卡片

要在单击时翻转卡片,我们需要向元素添加类别 flip(意为翻转)。为此,让我们使用 document.querySelectorAll 选择所有的 memory-card 元素。然后使用 forEach 循环遍历它们并附加一个事件监听器。每次卡片被点击时,都会触发 flipCard(意为翻转卡片)功能。this 变量表示被单击的卡片。该函数访问元素的 classList 并切换 flip 类:

// scripts.js
const cards = document.querySelectorAll('.memory-card');

function flipCard() {this.classList.toggle('flip');
}

cards.forEach(card => card.addEventListener('click', flipCard));

在 CSS 里 flip 类把卡片旋转了 180 度:

.memory-card.flip {transform: rotateY(180deg);
}

为了产生 3D 翻转效果,我们将把 perspective 属性添加到.memory-game 中。这个属性用来设置对象与用户在 z 轴上的距离。数值越低,透视效果越大。为了达到最佳的效果,让我们设置为 1000px:

.memory-game {
  width: 640px;
  height: 640px;
  margin: auto;
  display: flex;
  flex-wrap: wrap;
+ perspective: 1000px;
}

对于.memory-card 元素,我们添加 transform-style: preserve-3d 属性,这样就把卡片置于在父节点中创建的 3D 空间中,而不是将其平铺在 z = 0 平面上(transform-style)。

.memory-card {width: calc(25% - 10px);
  height: calc(33.333% - 10px);
  margin: 5px;
  position: relative;
  box-shadow: 1px 1px 1px rgba(0,0,0,.3);
  transform: scale(1);
+ transform-style: preserve-3d;
}

现在,我们需要把 transition 属性的值设置为 transform 就可以生成动态效果了:

.memory-card {width: calc(25% - 10px);
  height: calc(33.333% - 10px);
  margin: 5px;
  position: relative;
  box-shadow: 1px 1px 1px rgba(0,0,0,.3);
  transform: scale(1);
  transform-style: preserve-3d;
+ transition: transform .5s;
}

所以,我们使得卡片可以 3D 翻转了,耶!但为什么卡片的另一面不出现呢?现在,.front-face 和.back-face 都堆叠在一起,因为它们被绝对定位了。每个元素都有一个 back face(意为背面),这是它 front face(意为正面)的镜像。属性 backface-visibility 默认为 visible(意为可见的),因此当我们翻转卡片时,我们得到的是背面的 JS 徽章。


为了显示它背面的图像,让我们把 backface-visibility: hidden 应用到.front-face 和.back-face。

.front-face,
.back-face {
  width: 100%;
  height: 100%;
  padding: 20px;
  position: absolute;
  border-radius: 5px;
  background: #1C7CCC;
+ backface-visibility: hidden;
}

如果我们刷新页面并翻转一张卡片,它就消失了!


因为我们把两个图像都隐藏在了背面,所以另一面什么也没有。接下来我们需要把.front-face 旋转 180 度:

.front-face {transform: rotateY(180deg);
}

现在,我们有了想要的翻转效果!

匹配卡片

现在我们已经完成翻转卡片的功能之后,接下来我们来处理匹配的逻辑。
当我们点击第一张牌时,它需要等待另一张牌被翻转。变量 hasFlippedCard 和 flippedCard 将管理翻转状态。如果没有翻转的卡片,hasFlippedCard 被设置为 true, flippedCard 被设置为已点击的卡片。让我们切换 toggle 方法来 add(意为添加):

  const cards = document.querySelectorAll('.memory-card');

+ let hasFlippedCard = false;
+ let firstCard, secondCard;

  function flipCard() {-   this.classList.toggle('flip');
+   this.classList.add('flip');

+   if (!hasFlippedCard) {
+     hasFlippedCard = true;
+     firstCard = this;
+   }
  }

cards.forEach(card => card.addEventListener('click', flipCard));

现在,当用户点击第二张牌时,我们将进入 else 块。我们会检查一下它们是否匹配。为了做到这一点,我们需要做到能够识别每一张卡片。
每当我们想向 HTML 元素添加额外的信息时,我们就可以使用数据属性。通过使用以下语法:data-,其中, 可以是任何单词,该属性将插入元素的 dataset 属性中。所以,让我们为每张卡片添加一个 data-framework:

<section class="memory-game">
+ <div class="memory-card" data-framework="react">
    <img class="front-face" src="img/react.svg" alt="React">
    <img class="back-face" src="img/js-badge.svg" alt="Memory Card">
  </div>

+ <div class="memory-card" data-framework="react">
    <img class="front-face" src="img/react.svg" alt="React">
    <img class="back-face" src="img/js-badge.svg" alt="Memory Card">
  </div>

+ <div class="memory-card" data-framework="angular">
    <img class="front-face" src="img/angular.svg" alt="Angular">
    <img class="back-face" src="img/js-badge.svg" alt="Memory Card">
  </div>

+ <div class="memory-card" data-framework="angular">
    <img class="front-face" src="img/angular.svg" alt="Angular">
    <img class="back-face" src="img/js-badge.svg" alt="Memory Card">
  </div>

+ <div class="memory-card" data-framework="ember">
    <img class="front-face" src="img/ember.svg" alt="Ember">
    <img class="back-face" src="img/js-badge.svg" alt="Memory Card">
  </div>

+ <div class="memory-card" data-framework="ember">
    <img class="front-face" src="img/ember.svg" alt="Ember">
    <img class="back-face" src="img/js-badge.svg" alt="Memory Card">
  </div>

+ <div class="memory-card" data-framework="vue">
    <img class="front-face" src="img/vue.svg" alt="Vue">
    <img class="back-face" src="img/js-badge.svg" alt="Memory Card">
  </div>

+ <div class="memory-card" data-framework="vue">
    <img class="front-face" src="img/vue.svg" alt="Vue">
    <img class="back-face" src="img/js-badge.svg" alt="Memory Card">
  </div>

+ <div class="memory-card" data-framework="backbone">
    <img class="front-face" src="img/backbone.svg" alt="Backbone">
    <img class="back-face" src="img/js-badge.svg" alt="Memory Card">
  </div>

+ <div class="memory-card" data-framework="backbone">
    <img class="front-face" src="img/backbone.svg" alt="Backbone">
    <img class="back-face" src="img/js-badge.svg" alt="Memory Card">
  </div>

+ <div class="memory-card" data-framework="aurelia">
    <img class="front-face" src="img/aurelia.svg" alt="Aurelia">
    <img class="back-face" src="img/js-badge.svg" alt="Memory Card">
  </div>

+ <div class="memory-card" data-framework="aurelia">
    <img class="front-face" src="img/aurelia.svg" alt="Aurelia">
    <img class="back-face" src="img/js-badge.svg" alt="Memory Card">
  </div>
</section>

所以现在我们可以通过访问两个卡片数据集来检查匹配。让我们将匹配逻辑提取到它自己的方法 checkForMatch(),并将 hasFlippedCard 设置为 false。如果匹配,则调用 disableCards() 并分离两个卡片上的事件侦听器,以防止再一次翻转。否则,unflipCards() 会将两张卡都恢复成超过 1500 毫秒的超时,从而删除.flip 类:
把全部代码组合在一起:

const cards = document.querySelectorAll('.memory-card');

  let hasFlippedCard = false;
  let firstCard, secondCard;

  function flipCard() {this.classList.add('flip');

    if (!hasFlippedCard) {
      hasFlippedCard = true;
      firstCard = this;
+     return;
+   }
+
+   secondCard = this;
+   hasFlippedCard = false;
+
+   checkForMatch();
+ }
+
+ function checkForMatch() {+   if (firstCard.dataset.framework === secondCard.dataset.framework) {+     disableCards();
+     return;
+   }
+
+   unflipCards();
+ }
+
+ function disableCards() {+   firstCard.removeEventListener('click', flipCard);
+   secondCard.removeEventListener('click', flipCard);
+ }
+
+ function unflipCards() {+   setTimeout(() => {+     firstCard.classList.remove('flip');
+     secondCard.classList.remove('flip');
+   }, 1500);
+ }

  cards.forEach(card => card.addEventListener('click', flipCard));

编写匹配条件的更简练的方法是使用三元运算符。它由三个块组成。第一个块是要判断的条件。如果条件符合就执行第二个块,否则执行的块是第三个:

- if (firstCard.dataset.name === secondCard.dataset.name) {-   disableCards();
-   return;
- }
-
- unflipCards();

+ let isMatch = firstCard.dataset.name === secondCard.dataset.name;
+ isMatch ? disableCards() : unflipCards();

锁定

现在我们已经完成了匹配逻辑,接着为了避免同时转动两组卡片,我们还需要锁定它们,否则翻转将会失败。
我们先声明一个 lockBoard 变量。当玩家点击第二张卡片时,lockBoard 将设置为 true,条件 if (lockBoard) return; 在卡片被隐藏或匹配之前会阻止其他卡片翻转:

  const cards = document.querySelectorAll('.memory-card');

  let hasFlippedCard = false;
+ let lockBoard = false;
  let firstCard, secondCard;

  function flipCard() {+   if (lockBoard) return;
    this.classList.add('flip');

    if (!hasFlippedCard) {
      hasFlippedCard = true;
      firstCard = this;
      return;
    }

    secondCard = this;
    hasFlippedCard = false;

    checkForMatch();}

  function checkForMatch() {
    let isMatch = firstCard.dataset.name === secondCard.dataset.name;
    isMatch ? disableCards() : unflipCards();
  }

  function disableCards() {firstCard.removeEventListener('click', flipCard);
    secondCard.removeEventListener('click', flipCard);
  }

  function unflipCards() {
+     lockBoard = true;

    setTimeout(() => {firstCard.classList.remove('flip');
      secondCard.classList.remove('flip');

+     lockBoard = false;
    }, 1500);
  }

  cards.forEach(card => card.addEventListener('click', flipCard));

点击同一卡片

可能会出现玩家在同一张卡上点击两次的情况。如果匹配条件判断为 true,从该卡片上删除事件侦听器。


为了防止这种情况,需要检查当前点击的卡片是否等于 firstCard,如果是肯定的则返回。

if (this === firstCard) return;

变量 firstCard 和 secondCard 需要在每一轮之后被重置,所以让我们将它提取到一个新方法 resetBoard() 中,再其中写上 hasFlippedCard = false; 和 lockBoard = false。ES6 的解构赋值功能 [var1, var2] = [‘value1’, ‘value2’] 允许我们把代码写得超短:

function resetBoard() {[hasFlippedCard, lockBoard] = [false, false];
  [firstCard, secondCard] = [null, null];
}

接着我们调用新方法 disableCards() 和 unflipCards():

  const cards = document.querySelectorAll('.memory-card');

  let hasFlippedCard = false;
  let lockBoard = false;
  let firstCard, secondCard;

  function flipCard() {if (lockBoard) return;
+   if (this === firstCard) return;

    this.classList.add('flip');

    if (!hasFlippedCard) {
      hasFlippedCard = true;
      firstCard = this;
      return;
    }

    secondCard = this;
-   hasFlippedCard = false;

    checkForMatch();}

  function checkForMatch() {
    let isMatch = firstCard.dataset.name === secondCard.dataset.name;
    isMatch ? disableCards() : unflipCards();
  }

  function disableCards() {firstCard.removeEventListener('click', flipCard);
    secondCard.removeEventListener('click', flipCard);

+   resetBoard();}

  function unflipCards() {
    lockBoard = true;

    setTimeout(() => {firstCard.classList.remove('flip');
      secondCard.classList.remove('flip');

-     lockBoard = false;
+     resetBoard();}, 1500);
  }

+ function resetBoard() {+   [hasFlippedCard, lockBoard] = [false, false];
+   [firstCard, secondCard] = [null, null];
+ }

  cards.forEach(card => card.addEventListener('click', flipCard));

洗牌

我们的游戏现在看起来相当不错,但是如果不能洗牌就失去了乐趣,所以我们现在来处理这个功能。
当 display: flex 在容器上被声明时,flex-items 会按照组和源的顺序进行排序。每个组由 order 属性定义,该属性包含正整数或负整数。默认情况下,每个 flex-item 都将其 order 属性设置为 0,这意味着它们都属于同一个组,并将按源的顺序排列。如果有多个组,则首先按组升序顺序排列。
游戏中有 12 张牌,因此我们将迭代它们,生成 0 到 12 之间的随机数并将其分配给 flex-item order 属性:

function shuffle() {
  cards.forEach(card => {let ramdomPos = Math.floor(Math.random() * 12);
    card.style.order = ramdomPos;
  });
}
view raw

为了调用 shuffle 函数,让它成为一个立即调用函数表达式(IIFE),这意味着它将在声明后立即执行。脚本应如下所示:

const cards = document.querySelectorAll('.memory-card');

  let hasFlippedCard = false;
  let lockBoard = false;
  let firstCard, secondCard;

  function flipCard() {if (lockBoard) return;
    if (this === firstCard) return;

    this.classList.add('flip');

    if (!hasFlippedCard) {
      hasFlippedCard = true;
      firstCard = this;
      return;
    }

    secondCard = this;
    lockBoard = true;

    checkForMatch();}

  function checkForMatch() {
    let isMatch = firstCard.dataset.name === secondCard.dataset.name;
    isMatch ? disableCards() : unflipCards();
  }

  function disableCards() {firstCard.removeEventListener('click', flipCard);
    secondCard.removeEventListener('click', flipCard);

    resetBoard();}

  function unflipCards() {setTimeout(() => {firstCard.classList.remove('flip');
      secondCard.classList.remove('flip');

      resetBoard();}, 1500);
  }

  function resetBoard() {[hasFlippedCard, lockBoard] = [false, false];
    [firstCard, secondCard] = [null, null];
  }

+ (function shuffle() {
+   cards.forEach(card => {+     let ramdomPos = Math.floor(Math.random() * 12);
+     card.style.order = ramdomPos;
+   });
+ })();

  cards.forEach(card => card.addEventListener('click', flipCard));

看之后

点赞,让更多的人也能看到这篇内容(收藏不点赞,都是耍流氓 -_-)
关注公众号「新前端社区」,享受文章首发体验!
每周重点攻克一个前端技术难点。

正文完
 0