记忆翻牌是检验"前端基本功"的经典项目:洗牌算法、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 回滚界面)。这四个技能在之后的游戏和交互动效里会反复出现。
练习一下:
- 加一个 60 秒倒计时,超时判负,并显示"差几对没找到"。
- 加难度选择:6 对(12 张)、8 对(16 张)、10 对(20 张,网格改成 4×5)。
- 用
localStorage记录最佳步数,开局时展示"历史纪录:xx 步"。
💡 如果觉得 0.8 秒翻回太快或太慢,把setTimeout的延迟和 CSS 的transition时长调成匹配的值,动画会更跟手。