记忆翻牌是检验"前端基本功"的经典项目:洗牌算法、CSS 3D 翻转、定时器、状态锁定,一个游戏把好几样硬功夫串起来。做完它,你就能自信地说自己掌握了数组处理和 DOM 操作的常用技巧。

1. 游戏规则

桌上有 16 张背面朝上的牌,两两成对(共 8 对)。每次翻开两张:如果图案相同,它们就保持翻开并"锁定";如果不同,短暂展示后翻回背面。目标:用最少的步数把全部 8 对都找出来。通常还会配一个计时器增加紧张感。

这个游戏最妙的点在于,它逼迫你同时处理三个不同时间尺度的问题:立即响应(点击翻牌)、短延迟(不同牌 0.8 秒后翻回)、长期状态(哪些牌已配对)。能把这三层协调好,你对"异步"的理解就上了一个台阶。

另外提一句:16 张牌用 4×4 网格,如果你改成 6×6,配对数变成 18,难度陡增——界面结构不用动,只改 emoji 数组和 CSS 的列数,这正体现了"数据驱动"的好处。

2. 洗牌算法

开局第一件事:把 8 对牌打乱。很多人第一反应是 sort(() => Math.random() - 0.5),但这样洗出来的牌分布不均匀,而且依赖排序的随机性,不优雅。正确做法是经典的 Fisher-Yates 洗牌:

const emojis = ['🍎', '🍌', '🍇', '🍓', '🍒', '🍉', '🍋', '🥝'];
let deck = [...emojis, ...emojis];

function shuffle(arr) {
  for (let i = arr.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [arr[i], arr[j]] = [arr[j], arr[i]];
  }
  return arr;
}

deck = shuffle(deck);

思路:从最后一张牌开始,每次在当前"未处理区间"里随机挑一个位置和它交换,然后区间往前缩一格。这样每个位置被选中的概率完全均等,是教科书级的洗牌。[...emojis, ...emojis] 用展开运算符把 8 个图案复制成 16 个,[a, b] = [b, a] 是 ES6 的解构赋值交换法,一行搞定,不需要临时变量。

那 sort(() => Math.random() - 0.5) 为什么不好?它的核心问题是"不公平":排序算法假设比较是稳定的、可传递的,而随机比较器每次给出的答案都不一样,会让某些排列更容易出现;更糟的是,不同浏览器的排序算法不同,洗牌结果也不一样,同样的代码换个浏览器就"变卦"。作为反面教材可以试一次,正式项目一律用 Fisher-Yates。

3. 界面搭建与翻转动画

牌桌是一个空的 div,16 张牌全部由 JavaScript 生成——这正是"数据驱动"思想的又一次应用。每张牌分内外两层:外层管翻转状态,内层是"双面"的,一面背面图案、一面正面图案。

<div class="board" id="board"></div>
<p id="moves">步数:0</p>
<button id="restart">重新洗牌</button>
.board {
  display: grid;
  grid-template-columns: repeat(4, 90px);
  gap: 10px;
}
.card {
  width: 90px;
  height: 90px;
  perspective: 600px;
  cursor: pointer;
}
.card-inner {
  width: 100%;
  height: 100%;
  position: relative;
  transition: transform 0.5s;
  transform-style: preserve-3d;
}
.card.flipped .card-inner { transform: rotateY(180deg); }
.card-face {
  position: absolute;
  inset: 0;
  backface-visibility: hidden;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 2rem;
  border-radius: 10px;
}
.card-back { background: #4f6ef7; }
.card-front { background: #fff; transform: rotateY(180deg); }

翻转的奥秘:.card-inner 是一个"双面"元素,.card-back(背面)和 .card-front(正面)背靠背叠放,正面预先旋转 180°;当内层整体旋转 180° 时,正面恰好转到朝外。配合 backface-visibility: hidden 隐藏转过去的那一面,transition: transform 0.5s 提供平滑动画——这三个属性缺一不可。外层 perspective: 600px 给整个翻转加上透视感,没有它翻转会显得很"平"。

小技巧:调试翻转动画时,可以临时把 transition 改成 2 秒,放慢看每一帧的变化,确认 3D 结构没问题后再改回 0.5 秒。

4. 配对逻辑

现在写核心的点击逻辑。要点:用 flipped 数组记录当前翻开的两张牌;用 lock 布尔值做"锁",在判定期间禁止点击;配对的牌打上 matched 标记,永远保持翻开。

const boardEl = document.getElementById('board');
let flipped = [];
let lock = false;
let moves = 0;

function flipCard(card) {
  if (lock) return;
  if (card.classList.contains('flipped')) return;
  if (card.classList.contains('matched')) return;
  card.classList.add('flipped');
  flipped.push(card);
  if (flipped.length === 2) {
    moves++;
    document.getElementById('moves').textContent = '步数:' + moves;
    checkMatch();
  }
}

function checkMatch() {
  const [a, b] = flipped;
  if (a.dataset.value === b.dataset.value) {
    a.classList.add('matched');
    b.classList.add('matched');
    flipped = [];
    checkWin();
  } else {
    lock = true;
    setTimeout(() => {
      a.classList.remove('flipped');
      b.classList.remove('flipped');
      flipped = [];
      lock = false;
    }, 800);
  }
}

function checkWin() {
  const total = document.querySelectorAll('.card').length;
  const matched = document.querySelectorAll('.card.matched').length;
  if (matched === total) {
    setTimeout(() => alert('🎉 全部配对成功!用了 ' + moves + ' 步'), 300);
  }
}

flipCard 开头的三个 if 守卫分别挡住"判定中""重复翻同一张""翻已配对的牌"三种非法操作,这是状态机思想的朴素体现。checkMatch 里如果两张不同,setTimeout 延迟 0.8 秒翻回,期间 lock = true 锁住整个牌桌,防止玩家趁动画期间疯狂点击导致状态错乱。

配对判断直接比较 dataset.value 字符串,因为洗牌时我们保证了每种图案恰好两张——数据结构的正确性,让逻辑变得极其简单。checkWin 用 querySelectorAll 数一数带 matched 的牌是否等于总数,相等就是全配对了。

还有一个边界情况值得注意:如果玩家快速连点同一张牌两次,第二次点击会被第二个守卫拦住,不会把同一张牌塞进 flipped 两次——否则两张"相同"的牌会被误判成配对成功,这是记忆翻牌最容易出现的高级 bug。

5. 生成牌桌与重新开局

卡片生成和"重新洗牌"逻辑几乎一样,干脆抽成一个 buildBoard 函数,开局和重开都调用它,避免代码重复:

function buildBoard() {
  boardEl.innerHTML = '';
  flipped = [];
  lock = false;
  moves = 0;
  deck.forEach((value) => {
    const card = document.createElement('div');
    card.className = 'card';
    card.dataset.value = value;
    card.innerHTML =
      '<div class="card-inner">' +
      '<div class="card-face card-back">?</div>' +
      '<div class="card-face card-front">' + value + '</div>' +
      '</div>';
    card.addEventListener('click', () => flipCard(card));
    boardEl.appendChild(card);
  });
  document.getElementById('moves').textContent = '步数:0';
}

buildBoard();
document.getElementById('restart').addEventListener('click', () => {
  deck = shuffle([...emojis, ...emojis]);
  buildBoard();
});

document.createElement 动态创建元素,className 和 dataset 给它上"身份",最后 appendChild 挂到牌桌上——这是前端"渲染列表"的标准流程。注意 card.innerHTML 里拼接的是字符串,value 是 emoji,直接插进去没问题。boardEl.innerHTML = '' 一行清空旧牌桌,比逐个删除子元素省事得多。

组装时有个高频错误:JS 放在 <head> 里,导致 getElementById 拿到 null。记住口诀:要操作 DOM 的脚本,放在 body 最后,或者等 DOMContentLoaded 事件触发后再执行。

想加倒计时,思路和定时器那套完全一样:一个每秒走一次的定时器,归零时结束游戏并结算:

let timeLeft = 60;
let timeTimer = null;

function startTimer() {
  timeLeft = 60;
  clearInterval(timeTimer);
  timeTimer = setInterval(() => {
    timeLeft--;
    document.getElementById('timer').textContent = '时间:' + timeLeft;
    if (timeLeft <= 0) {
      clearInterval(timeTimer);
      const left = 16 - document.querySelectorAll('.card.matched').length;
      alert('⏰ 时间到!还差 ' + left / 2 + ' 对没找到');
    }
  }, 1000);
}

在 buildBoard 里调用 startTimer(),并在页面上加一个 <span id="timer">时间:60</span> 就能让每局都有 60 秒倒计时。注意"步数"和"时间"是两种不同的成绩维度,超时判负后,用已匹配牌数反推剩余对数(left / 2),玩家就知道自己差多少。

6. 总结与练习

这篇你收获了四个可复用的技能:Fisher-Yates 洗牌(公平随机)、CSS 3D 翻转(perspective、rotateY、backface-visibility 三件套)、状态锁(动画期间禁止输入)、延迟恢复(setTimeout 回滚界面)。这四个技能在之后的游戏和交互动效里会反复出现。

练习一下:

💡 如果觉得 0.8 秒翻回太快或太慢,把 setTimeout 的延迟和 CSS 的 transition 时长调成匹配的值,动画会更跟手。