☰
单机记忆翻牌游戏开发:原生JS状态管理与Fisher-Yates洗牌实战
2026/10/8 3:52:06 网站建设 项目流程

简介:一款基于MFC框架开发的单机记忆翻牌游戏,面向C++入门者与Windows桌面应用开发学习者,也适合作为课程设计或小游戏练手项目。游戏围绕“翻牌-记忆-配对”的机制展开,涉及对话框模板设计、控件动态创建、ON_BN_CLICKED消息映射、Ctimer定时器自动翻回、CBitmap位图加载以及数组/链表维护配对状态等典型MFC知识点,能够帮助读者将抽象的事件驱动和资源管理概念落到实际代码中。资源压缩包共419个文件,整体大小6.63MB,其中包含384个bmp卡片与图标素材、11个h头文件、9个cpp源文件,以及5个mp3背景/音效文件和Visual Studio解决方案、工程配置、资源脚本,目录结构较为完整,可下载后直接打开编译查看。已有502人学习下载。通过研读源码和资源组织方式,读者可以学到如何在对话框中动态生成卡片网格、如何处理玩家点击与配对逻辑、如何用计时器控制未匹配卡片的回翻,以及如何管理大量图片素材并加入声音反馈,整体是一个适合系统学习MFC入门开发的良好实例。

1. 单机记忆翻牌:一个 HTML 文件背后的状态与时机练习

记忆翻牌这个项目难不倒任何写过年头的人,但「单机」两个字把它拉回了一个很纯粹的场景——没有后端、没有请求、没有鉴权,一个 HTML 文件扔到浏览器里就能玩。它常被当成练手题,是因为麻雀虽小,却把状态管理、事件防抖、动画时机、本地存储全踩了一遍。适合刚学完 JS 想验证自己能不能独立搭一个完整交互的人,也适合做给孩子玩、或者面试前拿来热手。翻牌本身不复杂,真正的坑全在「用户点得比你想的快」这件事上。

2. 最小可玩版:原生 JS 加一个 HTML 文件跑通核心循环

2.1 为什么选原生三件套,而不是直接上框架

做单机小游戏,我一般第一反应是:能不能不依赖构建工具。Vue、React 当然能写,但你得先装 Node、初始化项目、跑 dev server,最后还要处理打包产物怎么给用户。对于一个只有 16 张牌、一个状态对象的项目,这种成本完全没必要。原生 HTML + CSS + JavaScript 双击就能跑,手机浏览器也能直接打开,调试用 console 就够,连 sourcemap 都不用。

就算后面真的要把这游戏做成一个更大的项目,原生版往 Vue 迁移也很容易——因为核心逻辑是数据模型加渲染函数,跟框架无关。很多人一上来就套框架,反而把「游戏规则」和「组件刷新」耦合在一起,改起来两头受罪。我建议先用原生实现,跑通之后你自然知道哪些地方值得抽象。

2.2 完整可运行的最小版代码与拆解

先给一个我常用的可运行版本,4x4 共 16 张牌,数字配对。把它存成index.html,双击打开就能玩:

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"> <title>单机记忆翻牌</title> <style> .board { max-width: 480px; margin: 32px auto; display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; } .card { aspect-ratio: 1 / 1; background: #f2f2f2; border: 1px solid #c8c8c8; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 28px; font-weight: 700; cursor: pointer; user-select: none; } .card.flipped { background: #fff; } .card.matched { border-color: #22bb88; opacity: 0.7; } </style> </head> <body> <div id="board" class="board"></div> <script> // ---- 配置 const ROWS = 4; const COLS = 4; const PAIR_COUNT = (ROWS * COLS) / 2; const FLIP_BACK_DELAY = 800; // 两牌不匹配时,翻回的等待毫秒数 // ---- 数据层:用数组存牌面,DOM 只负责展示 const state = { first: null, // 第一张翻开牌的数组下标 moves: 0, // 已用步数(一次=翻两张) matchedPairs: 0, // 已匹配对数 lock: false // 锁定后不再响应点击 }; 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; } function buildDeck(pairCount) { const values = []; for (let i = 0; i < pairCount; i++) { values.push(i, i); // 每对牌面值相同,各出现两次 } return shuffle(values); } const deck = buildDeck(PAIR_COUNT); // ---- 渲染:根据数组创建牌 DOM,并绑定点击 function render() { const board = document.getElementById('board'); board.innerHTML = ''; deck.forEach((value, index) => { const card = document.createElement('div'); card.className = 'card'; card.dataset.index = index; card.addEventListener('click', () => onFlip(index)); board.appendChild(card); }); } // ---- 核心翻牌逻辑 function onFlip(index) { if (state.lock) return; const board = document.getElementById('board'); const card = board.children[index]; if (card.classList.contains('flipped')) return; card.textContent = deck[index]; card.classList.add('flipped'); // 第一次翻开:记住位置,等第二次 if (state.first === null) { state.first = index; return; } // 第二次翻开:进入判定 state.moves++; const firstIndex = state.first; state.first = null; if (deck[firstIndex] === deck[index]) { card.classList.add('matched'); board.children[firstIndex].classList.add('matched'); state.matchedPairs++; if (state.matchedPairs === PAIR_COUNT) { alert('全部配对完成,共用 ' + state.moves + ' 步'); } } else { // 不匹配:锁住点击,延迟后翻回 state.lock = true; setTimeout(() => { board.children[firstIndex].classList.remove('flipped'); board.children[firstIndex].textContent = ''; card.classList.remove('flipped'); card.textContent = ''; state.lock = false; }, FLIP_BACK_DELAY); } } render(); </script> </body> </html>

这段代码的核心思路是:数据存在deck数组里,DOM 只做展示。state.first记的是数组下标而不是 DOM 节点引用,因为 setTimeout 回调执行时,我无法确定 DOM 对象是不是已经被重建了,但数组下标永远是稳定的。lock标志用来防止用户在等待翻回的那 800ms 里继续点第三张牌——这就是这个游戏最基础的防连点机制。

FLIP_BACK_DELAY这个参数值得单独说。它必须大于 CSS 里翻转动画的时长。这里虽然没有写 transition,但一旦你加上动画,延时就决定了两张牌翻回的动作是否会被肉眼感知为「卡顿」。800ms 是大多数人觉得舒服的节奏,新手玩可以调到 1000-1200ms,给更多记忆时间;老手玩 500ms 反而更有压迫感,这属于手感参数,后面会展开讲。

2.3 从「能玩」到「像样」:缺什么

上面这个版本可以跑,但离一个能真正投入使用的游戏还差几样东西:洗牌算法需要证明均匀性(下一章讲);翻牌时没有任何动画,状态切换生硬;没有计时,步数也只是弹窗提醒;刷新页面后进度清零;最要命的是——如果用户在翻转动画进行中快速点击,只靠一个lock其实还不够稳。这些问题单独看都是小事,但叠在一起就会让游戏显得「能用但不好用」。下面两章我会按「洗牌与判定 → 难度与计分」的顺序把它补完整。

3. 洗牌与翻牌判定:Fisher-Yates 和状态锁的正确姿势

3.1 洗牌为什么不能用 sort 加随机数

很多人写洗牌第一反应是arr.sort(() => Math.random() - 0.5)。这个写法在眼睛上没问题,但概率分布并不均匀,原因是Math.random()每次调用产生的值是独立的,而 sort 的比较函数在排序过程中会被调用多次,同一对元素可能被多次比较,导致元素不是等概率地出现在所有位置。Chrome 旧版 V8 的 sort 实现还会让这种写法出现明显的偏向。

正确做法是 Fisher-Yates 算法,也叫 Knuth 洗牌。它从数组末尾开始,每次在当前剩余区间里随机选一个位置,与当前位置交换,保证每个排列出现的概率都是1/n!。我通常把它作为默认工具函数存下来,不只是这里用:

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; }

Math.floor(Math.random() * (i + 1))是关键:i + 1保证了选中的位置一定在当前未处理区间[0, i]内,Math.floor确保下标是整数。这里最容易写错的是写成Math.random() * i,那样最后一个元素永远不参与交换,牌面分布就会偏。还有一个常见坑:构建牌组时如果先造一个「配对数组」再洗牌,那没问题;但如果你用两个独立数组拼接,洗完后同一张牌可能出现两次而不是成对出现,开局就直接无解。正确的构建方式是先按对数生成各出现两次的数组,再一次性洗乱。

3.2 三种翻牌判定写法:从可用到稳

最小版里的判定是一种写法,我再给出两种递增的版本,方便你理解这个逻辑的演进方向。

第一种是「记录两张、直接比较」:

let firstCard = null; let secondCard = null; function onFlip(index) { if (firstCard && secondCard) resetTurn(); if (!firstCard) firstCard = index; else if (!secondCard) secondCard = index; else return; if (deck[firstCard] === deck[secondCard]) { match(firstCard, secondCard); firstCard = null; secondCard = null; } else if (firstCard && secondCard) { setTimeout(() => { flipBack(firstCard, secondCard); firstCard = null; secondCard = null; }, FLIP_BACK_DELAY); } }

这种写法的缺点是:用户连续点第三张、第四张时,resetTurn()会被反复调用,很容易把「正在翻回」的牌又重新翻起来。实际跑起来感觉就是:牌面疯狂闪烁,匹配错乱。

第二种推荐写法,也是我现在仍在用的——「单指针 + 锁定」:

const state = { first: null, // 当前回合第一张的数组下标 lock: false, // 回合未结束时锁住新点击 pendingTimer: null, matchedPairs: 0, moves: 0 }; function onFlip(index) { if (state.lock) return; const card = getCard(index); if (card.classList.contains('flipped')) return; card.textContent = deck[index]; card.classList.add('flipped'); if (state.first === null) { state.first = index; return; } state.moves++; const firstIndex = state.first; state.first = null; if (deck[firstIndex] === deck[index]) { handleMatch(firstIndex, index); } else { state.lock = true; state.pendingTimer = setTimeout(() => { flipBack(firstIndex, index); state.lock = false; }, FLIP_BACK_DELAY); } }

好处在于:整个回合只有一个state.first,新点击要么被lock拦住,要么被flipped类名拦住;pendingTimer存下来,重置游戏时先clearTimeout,避免上一局的定时器把下一局的牌翻回去。这一步很关键,很多人忽略之后会出现「开局第一秒,有两张牌自己翻过去再翻回来」的灵异现象。

getCard(index)这个辅助函数建议写成document.querySelectorAll('.card')[index]或board.children[index],不要每次在事件回调里现查document.getElementById,高频点击下重复查询 DOM 虽然不至于卡顿,但会让代码里的职责变模糊。

3.3 参数范围与测试方法

洗牌是否正确,有简单的验证手段:连续跑 1 万次洗牌,统计每个值出现在每个位置的概率。均匀洗牌下,每格概率都应在 1/n 附近。我通常直接用 Node 跑,命令是node verify.js,脚本里用Array.from({length: 10000}, () => shuffle(deck.slice()))统计频率。如果某格明显偏高或偏低,就检查随机区间是不是写成了[0, i-1]。这类验证虽然现实中很少每人都做,但一旦你以后写抽奖、推荐排序,会感谢当年写过这个测试。

翻牌判定的验证则靠「手动点乱序」:快速点三连、四连,观察是否只有两张同时翻开;在 setTimeout 等待间隙刷新页面;用手机浏览器触摸快速连击。这三个场景能覆盖 80% 的状态管理 bug。

4. 难度、计时与计分:三个必调参数和配置化改造

4.1 把写死的常量收敛成 CONFIG 对象

最小版里我用ROWS、COLS、FLIP_BACK_DELAY几个顶层常量,但游戏一旦要支持多难度,就需要一个 CONFIG 对象。常见做法是把布局、翻转延时、是否限时、是否显示步数都集中放进去,一个文件只改一处就能换难度:

const CONFIG = { layout: { rows: 4, cols: 4 }, // 标准档 16 张 flipBackDelay: 800, // 失败翻回等待 countdown: 0, // 0 表示不限时,>0 为秒数 showMoves: true, starThresholds: [28, 24, 20] // 三星、二星、一星对应的步数上限 }; function resetGame() { // 先清理上一局残留的定时器 if (state.pendingTimer) clearTimeout(state.pendingTimer); state.first = null; state.lock = false; state.moves = 0; state.matchedPairs = 0; state.startTime = Date.now(); const pairCount = (CONFIG.layout.rows * CONFIG.layout.cols) / 2; deck.length = 0; deck.push(...buildDeck(pairCount)); render(); }

这里有个细节:重新生成牌组时,我不直接deck = buildDeck(...),因为deck是用const声明的引用,我选择重置数组内容然后push,保持其他函数里对deck的引用不变。如果直接重新赋值,还得确保所有读deck的地方都拿的是新引用——问题不大,但当代码变长时会多出几个隐藏 bug 的可能性。

CONFIG.layout.rows * CONFIG.layout.cols必须算出来是偶数,且对数能整除规格布局,否则会生成半张牌。我一般会在buildDeck里加一个防御:if (pairCount % 1 !== 0) throw new Error('布局必须能整除成整数对'),这种报错比用户玩到一半发现牌面是奇数要友好得多。

4.2 计时器别用累加秒数,用时间戳差值

新手写的计时器一般是:

setInterval(() => { seconds++; display(seconds); }, 1000);

这在标签页切到后台再切回来时会露馅——浏览器为了省资源会把后台页面的setInterval降到 1 秒甚至暂停,切回来后你会发现秒数比实际少很多,或者某段时间突然跳变。正确做法是只在开始时记录startTime = Date.now(),显示时计算差值:

function tick() { const elapsed = Math.floor((Date.now() - state.startTime) / 1000); const mm = String(Math.floor(elapsed / 60)).padStart(2, '0'); const ss = String(elapsed % 60).padStart(2, '0'); document.getElementById('time').textContent = mm + ':' + ss; } // 游戏开始时 state.startTime = Date.now(); state.timerId = setInterval(tick, 250);

用 250ms 的刷新间隔,是为了让秒数变化看起来更平滑,不会出现刚过 59 秒就跳 01:00 的观感。setInterval本身还是需要的,但它只负责刷新显示,不再负责累计时间,就算后台被限速,切回来之后时间也会立刻校正。

4.3 计分规则与三个必调参数

单机记忆翻牌的计分,常见的做法是「先比步数、步数相同比用时」。步数这个指标能直接反映玩家策略水平,用时则反映记忆效率。我给一个可用的星级算法:

function getStarRating(moves) { const thresholds = CONFIG.starThresholds; if (moves <= thresholds[0]) return 3; if (moves <= thresholds[1]) return 2; return 1; }

星级阈值需要根据难度手动调。4x4 共 8 对牌的理论最低步数是 8 步(每次翻开都恰好配对),新手 30 步左右,老手 16-20 步。我把阈值设为 20 / 24 / 28,是比较宽松的节奏,让玩家有成就感而不是挫败感。6x6 共 18 对牌,理论最低 18 步,阈值就要放宽到 45 / 55 / 65。阈值太紧会让玩家觉得「这游戏在惩罚我」,太松又会让三星毫无含金量,这里需要玩过几局再调。

三个必调参数按优先级排是这样的:

参数推荐默认值调整方向影响
flipBackDelay800ms新手 1200,竞速 400决定记忆和惩罚的节奏感
布局规格4x4入门 4x3,进阶 6x4,地狱 6x6决定记忆负荷与单局时长
starThresholds步数阈值数组按布局和玩家水平放宽/收紧决定成就感和挑战度

flipBackDelay这个参数非常敏感。太短(<400ms)时,玩家根本来不及看完两张牌的内容就被翻回去了,挫败感极强;太长(>1500ms)时玩家会靠死记而不是看图,游戏就变成了「等结果」,失去挑战性。这个参数与翻牌动画时长耦合,改完延迟记得看动画有没有撕裂。

另外我建议在界面放一个「难度提示」——开局时明确写出当前是几对牌、理论最低步数、三星线在哪。这看着是小事,但能让玩家接受「这个难度要求高」而非「我是不是变笨了」,对留存有真实影响。

5. 翻牌避坑:五条最容易翻车的实现细节

5.1 三连翻和四连翻导致牌面错乱

现象:玩家快速连点第三张、第四张牌,那两张还在翻转动画中的牌突然被重新翻开,牌面对不上。

原因:lock只在第二次判定后生效,但第一次翻牌和第二次翻牌之间没有任何拦截;如果用户在第一张翻开的瞬间还没翻第二张时,连点同区域的牌,state.first会被覆盖,或者pendingTimer没被清理,动画中又被写入新状态。

解决:onFlip函数入口处先检查state.lock,再检查目标牌是否已经flipped;同时把pendingTimer存在 state 里,开局和重置时一律clearTimeout。我用一个统一守卫函数,三步全放进去:

function onFlip(index) { if (state.lock) return; const card = getCard(index); if (card.classList.contains('flipped') || card.classList.contains('matched')) return; // 正常流程往下走 }

5.2 洗牌后开局总觉得「有规律」

现象:每次开局牌面分布都类似,或者第一行总有两张相同牌相邻。

原因:没有用 Fisher-Yates,而是用 sort 加随机数;或者构建牌组时用了两个数组拼接,同一个值被放进了相邻位置而且没有全局洗牌。

解决:按「对数生成两个相同值 → 整体洗牌」的顺序,且只用一个数组。我通常会在 debug 模式下打印deck数组,连续开局三次,检查是否有相邻重复或位置偏向。偶尔出现相邻相同是正常的概率事件,但连续出现就该怀疑洗牌函数。

5.3 翻回动画撕裂,牌面只转了一半

现象:两张牌翻开后没配对,过了一会它们像是「跳变」一样瞬间翻回,看不到半透明的翻面过程。

原因:CSS 翻转动画用了transform: rotateY(0deg) → rotateY(180deg),但翻回时直接在同一个类名上移除样式,浏览器没有经历从 180 到 0 的过渡。另一个常见原因是setTimeout的延时小于 CSS transition 的时长,动画还没走完就被移除类名。

解决:翻转机制不要用添加/移除同一个类名,而是基于一个 index 控制的两段式切换:翻开时加flipped,翻回时先加flipping-back,用 CSS 的两个 keyframe 分别控制。或者更简单——翻回时仍然先触发翻转动画,待transitionend后再把flipped移除。我自己的经验是:把翻转动画时长固定为 300ms,flipBackDelay设为 800ms,两者差值足够大,就能避免大部分撕裂问题。

5.4 切到后台再切回来,计时器时间不对

现象:玩到一半切到微信回个消息,回来发现计时器比实际少了几分钟。

原因:setInterval在后台标签页被浏览器限速,甚至被暂停。

解决:不靠定时器累加秒数,而是用Date.now() - startTime计算真实用时,定时器只负责刷新界面。这个改动一行就能完成,但效果是决定性的。页面切后台不会导致计时偏差,只有系统休眠到唤醒的极端情况下,Date.now()也会走,但至少比以前靠谱得多。

5.5 最佳成绩被差成绩覆盖

现象:第一次玩 30 步破了记录,第二把 45 步,刷新页面后发现最佳成绩写的是 45 步。

原因:写入localStorage前没有比较,直接把当前成绩写入,覆盖了旧值。

解决:读出旧值,只有当前成绩更优才写入。同时注意localStorage.getItem返回的是字符串,需要转数字再比较:

function saveBest(moves, seconds) { const key = 'memory-game-best-' + CONFIG.layout.rows + 'x' + CONFIG.layout.cols; const old = parseInt(localStorage.getItem(key) || '0', 10); if (moves < old || old === 0) { localStorage.setItem(key, JSON.stringify({ moves, seconds })); } }

这里有个小细节:key 里带上布局规格,这样 4x4 和 6x6 的记录互不干扰,同一个玩家在不同难度下都能看到自己的历史最佳。这类「带维度分开存」的思路在真实项目里到处都是,做后台系统时尤其重要。

6. 进阶:本地存成绩、自定义卡面与复盘模式

三样东西能让这个单机小游戏从练手项目变成真正经常打开的工具:记录进步、卡面个性化、复盘功能。

先看记录进步怎么落地。localStorage我已经在上一章的避坑里用了,再补一个「显示在界面上」的做法:胜利时把步数和用时写入一个历史数组,界面顶部列出最近五局的趋势。代码很简单,localStorage.setItem(key, JSON.stringify(history)),读取时同样要处理 JSON.parse 失败的情况——这是本地存储最容易崩的点。至于卡面,把deck里存的值从数字换成 emoji 或图片 URL 即可,注意配对判断仍然靠value相等,而显示内容可以任意换。我做过的版本里有人给娃存了十二生肖、国旗、甚至家人的名字,效果很好。

复盘模式是我个人很推荐的隐藏功能。它给每张牌记录翻开时间和当时的步数,结束后按时间线回放,玩家能看到自己反复翻同一对牌的路径,从而发现记忆策略上的效率问题。单机游戏做到这,体验已经可以拿去投给学校或培训机构的互动课件方案了。由于不需要后端,这套东西可以做成一个压缩包发给任何想玩的人,还能离线打开,这也是「单机」最有价值的点。

我自己的翻车教训是:第一次做这个游戏时,把牌面状态放在 DOM 的className里,赢了就翻所有牌,输了就移除动画类,结果每次加功能都得先猜 DOM 现在是啥状态。后来全部改成 state 对象驱动,页面渲染只认 state,翻车率直线下降。现在重写任何小工具,我都会先想清楚 state 和渲染之间的单向流关系。希望这篇笔记能让你少走一次这条弯路,祝你的牌局把把三星。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询