带 CSS 3D 翻转和 Immutable State 的记忆卡片匹配游戏

发布: (2026年4月15日 GMT+8 08:16)
5 分钟阅读
原文: Dev.to

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 多个公开作品集项目系列中的一项。

仓库与现场站点

公司

https://sen.ltd/

0 浏览
Back to Blog

相关文章

阅读更多 »