带 CSS 3D 翻转和 Immutable State 的记忆卡片匹配游戏
Source: Dev.to
概述
Memory / concentration / 神経衰弱 — 每个文化都有对应的名称。翻开两张卡片,查看它们是否匹配,尝试清除整个棋盘。游戏逻辑大约有 100 行纯函数;有趣的部分是 CSS 3D 翻转动画以及处理“显示两张卡片但尚未匹配”的状态机。
实时演示
🔗 https://sen.ltd/portfolio/memory-game/
源代码
GitHub: https://github.com/sen-ltd/memory-game
功能
- 4 个难度等级(12 到 64 张卡牌)
- 5 种主题:表情符号、数字、字母、形状、平假名
- CSS 3D 翻转动画
- 步数计数器 + 计时器
- 每个难度的最佳分数追踪(localStorage)
- 获胜时的彩纸庆祝
- 日语 / 英语界面
- 零依赖,38 项测试
状态结构
{
cards: [{ id, value, flipped, matched }],
firstCard: null | cardId, // the unmatched flipped card, if any
moves: 0,
matches: 0,
started: boolean,
startedAt: number | null,
}
每张卡片都有一个稳定的 id 和一个 value。一对卡片共享相同的 value,但 id 不同。firstCard 字段用于跟踪“当前已经翻开的第一张卡片,正在等待第二张”。
flipCard(state, cardId)
export function flipCard(state, cardId) {
const card = state.cards.find(c => c.id === cardId);
if (!card || card.flipped || card.matched) return state; // no‑op
// Starting timer on first flip
const started = state.started || true;
const startedAt = state.startedAt || Date.now();
const newCards = state.cards.map(c =>
c.id === cardId ? { ...c, flipped: true } : c
);
if (state.firstCard === null) {
// First of a pair
return { ...state, cards: newCards, firstCard: cardId, started, startedAt };
}
// Second of a pair — increment moves, caller will follow up with checkMatch
return {
...state,
cards: newCards,
firstCard: state.firstCard,
moves: state.moves + 1,
started,
startedAt,
};
}
每个函数返回一个新状态——不进行突变。if (card.flipped || card.matched) 这条守卫使函数具备幂等性:点击已经翻开的卡片不会产生任何操作。UI 在点击处理器中使用 state = flipCard(state, id) 将其包装。
checkMatch(state)
export function checkMatch(state) {
if (state.firstCard === null) return state;
const flipped = state.cards.filter(c => c.flipped && !c.matched);
if (flipped.length
c.flipped && !c.matched ? { ...c, matched: true } : c
),
firstCard: null,
matches: state.matches + 1,
};
}
// No match — flip both back
return {
...state,
cards: state.cards.map(c =>
c.flipped && !c.matched ? { ...c, flipped: false } : c
),
firstCard: null,
};
}
匹配的卡牌会保持翻转状态,matched: true(UI 会让它们略微变淡)。未匹配的卡牌会翻回去,firstCard 被重置。计时器运行时点击卡牌会被 flipCard 中的守卫阻止。
CSS 3D 翻转动画
.card {
perspective: 1000px;
cursor: pointer;
}
.card-inner {
position: relative;
width: 100%;
height: 100%;
transform-style: preserve-3d;
transition: transform 0.4s;
}
.card.flipped .card-inner {
transform: rotateY(180deg);
}
.card-front,
.card-back {
position: absolute;
inset: 0;
backface-visibility: hidden;
}
.card-back {
transform: rotateY(180deg);
}
关键属性是 backface-visibility: hidden。如果没有它,两个面在旋转时会相互叠加渲染。使用它后,每个面仅在面对摄像机时可见——正面在 0°,背面在 180°。
洗牌算法
export function shuffle(array) {
const result = [...array];
for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[result[i], result[j]] = [result[j], result[i]];
}
return result;
}
这是一种 Fisher–Yates 洗牌,对所有排列均匀。朴素的 .sort(() => Math.random() - 0.5) 会产生偏差,不应在游戏中使用。
作品集条目
这是第 99 个条目,属于 100 多个公开作品集项目系列中的一项。