02复刻 memory.toys:翻牌动画、洗牌和运气分
约 25 分钟
这游戏复刻的是 memory.toys 的 Classic Memory:翻牌找对子,统计时间、命中率,还有个好玩的指标——“蒙对几次”。玩法抄思路,代码和图案全部自己写。
一、翻牌用 CSS 3D,不用 JS 算
每张牌是一个两面结构:
<div class="card">
<div class="inner">
<div class="face back">?</div>
<div class="face front">🐶</div>
</div>
</div>
.card .inner {
transform-style: preserve-3d;
transition: transform 0.28s ease;
}
.card.flip .inner { transform: rotateY(180deg); }
.face {
backface-visibility: hidden; /* 关键属性 */
}
.face.front { transform: rotateY(180deg); }
正面一开始就转了 180 度背对着你,backface-visibility: hidden 保证朝后的那一面不可见。JS 要做的只有一件事:切换 .flip 类。动画、缓动、透视全是 CSS 的事。
不要用 JS 定时器一帧帧改角度——那和上一课“鼹鼠冒头”是同一个道理:能用 transition 表达的位移和旋转,都交给 CSS。
二、洗牌和发牌
function shuffle(a) {
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
这是 Fisher–Yates 洗牌,O(n),每个位置等概率。别用 sort(() => Math.random() - 0.5)——分布不均匀,而且慢。
牌堆的生成:faces.flatMap(f => [f, f]) 把选出的图案复制成对,再洗一次。
三、防连点:一个 lock 就够
翻牌游戏最常见的 bug:玩家手快,第三张牌在“前两张翻回去”的动画期间被翻开,状态就乱了。
let first = null; // 已翻开的第一张
let lock = false; // 翻回动画期间锁输入
if (first.face === c.face) {
// 配对成功:标记 done,不用锁
} else {
lock = true;
setTimeout(() => { /* 翻回去 */ lock = false; }, 700);
}
规则很简单:
- 同一时间最多允许两张
up,第三张只有在配对成功(瞬间完成)之后才能再翻 - 配对失败要播 700ms 的“给你看一眼再翻回去”动画,期间锁死输入
这比给每张牌单独加状态检查省心得多——锁的是输入,不是牌。
四、accuracy 和 lucky:两个容易混的统计
原版网站除了命中率还统计“lucky guesses”(运气好蒙对)。自己实现时要定义清楚:
- 命中率 = 配对成功次数 ÷ 翻开第二张牌的次数。注意分母不是翻牌总次数(第一张牌没有对错可言)
- 运气 = 第二张牌翻到的图案,之前累计翻开次数 ≤ 2(也就是你只见过它一次或根本没见过,就一次配对成功)
这两个指标方向相反:命中率奖励“记得住”,运气分奖励“敢猜”。两个都要,游戏才同时适合仔细型玩家和手快型玩家。
五、难度曲线不用改机制
三档难度唯一的区别是 cols × rows:4×4(8 对)、4×5(10 对)、5×6(15 对)。牌多了,记忆负担自然上升,不需要任何额外机制——这正好反面印证了上一课打地鼠的做法:先想清楚参数化什么,难度曲线往往只是改参数。
配套的“挑战徽章”(零失误、限时内完成)也是纯展示层:通关时按条件打 ★☆,不影响主流程。
结课的坑
pointerdown绑在 board 上、牌再closest('.card')查归属——和打地鼠同一套事件委托,别给每张牌单独绑- 翻回动画用
setTimeout(700),期间用户如果点了“再来一局”重建牌堆,要顺手clearTimeout,否则旧定时器会操作已经不在 DOM 里的牌(本实现里新局会重置lock,但旧引用还在,稳妥起见应清理) backface-visibility记得写-webkit-前缀,Safari 需要
练习
- 加“总翻牌数”统计,和命中率一起显示在结算面板
- 连续配对成功三次加一个 ★ 连击提示
- 把 emoji 换成自己的 SVG 图标集,保持每对视觉差异明显