简介一套基于HTML5的卡牌配对小游戏完整源码面向Web前端初学者、HTML5游戏开发入门者及有课程设计需求的在校生可帮助读者理解Canvas绘制、事件监听、DOM操作以及卡牌翻转与配对逻辑。压缩包共4个文件含一个HTML入口页面、一个CSS重置样式表以及两个JavaScript脚本分别引入jQuery与Isotope布局插件整体仅49KB结构精简、易于逐行阅读其中HTML文件负责页面结构CSS承担基础样式与视觉呈现JavaScript则实现核心游戏逻辑。目前已有136人学习/下载代码涵盖牌组洗牌、翻牌状态管理、配对判断、计数及胜利反馈等核心交互完整呈现经典记忆配对小游戏的实现思路。通过分析源码不仅能掌握第三方库在真实项目中的引入与使用方式还可以学习Isotope完成卡片网格布局、CSS3动画实现翻转过渡以及jQuery简化DOM操作和事件绑定等方法也适合用来分析类名切换与CSS选择器在状态管理中的作用。读者可在此基础上扩展新主题、新关卡或增加计分统计功能适合作为网页游戏开发的实战练习。1. 卡牌配对看起来简单真正难的是翻牌之外的状态管理浏览器里跑一个卡牌配对记忆翻牌小游戏规则很简单桌面上的牌全部面朝下每次翻开两张图案相同就保留不同就翻回去直到全部配对完成。这个项目出现在 HTML5 网页设计作业和前端面试题里的频率一直很高因为把视觉做出来只占一半工作量真正的难点在翻牌之外的状态管理——第二张牌没翻开时要不要响应点击、两张不匹配时怎么防连点、计时器从哪一刻开始算。下面按“生成牌堆 → 翻转判定 → 参数调优 → 本地存档”的顺序拆一个可运行的 HTML5 卡牌配对小游戏源码给出能直接改参数、换图包的完整思路。适合要交网页设计作业的学生也适合想给活动页加一个轻量游戏的前端工程师。2. 卡牌配对的牌堆生成与洗牌Fisher-Yates 和 dataset 匹配键2.1 洗牌算法选型为什么不要用 sort 加随机数拿到“生成配对牌堆”这个需求第一反应通常是准备一组符号每个符号重复两次再把数组打乱。打乱这一步很多初学源码直接写// 反面教材sort Math.random 结果有偏置不要用于洗牌 const deck [▲, ▲, ■, ■]; deck.sort(() Math.random() - 0.5);这段代码看起来“像是随机的”但 V8 引擎的 sort 在不同数组长度下走插入排序或快排比较器返回值不稳定会让元素落位概率不相等牌堆出现局部聚集。卡牌配对对均匀性要求高因为玩家会记住“上一局右上角是什么”偏置洗牌会让某些图案总出现在固定区域游戏很快被看穿。正确做法是用 Fisher-Yates 算法从数组末尾开始每次在当前未处理区间随机选一个位置交换。function shuffle(deck) { for (let i deck.length - 1; i 0; i--) { const j Math.floor(Math.random() * (i 1)); [deck[i], deck[j]] [deck[j], deck[i]]; } return deck; }循环从最后一个元素往前扫描j取0到i之间的随机整数把deck[i]与deck[j]交换。每轮只处理一次当前元素已交换的尾部不参与因此每个排列出现概率相等复杂度 O(n)一局 16 张牌耗时可忽略。做自动对局验证时想换更强随机源可以把Math.random替换成基于crypto.getRandomValues的实现接口不变。2.2 用 dataset 存匹配键不直接比较 DOM配对逻辑依赖一个“种类标识”。常见做法是给每张牌存>// 可配置符号表数量必须大于等于牌堆种类数 const SYMBOLS [▲, ■, ●, ◆, ★, ✚, ♠, ♣]; function createDeck(pairCount) { if (pairCount SYMBOLS.length) { throw new RangeError(符号表只有 ${SYMBOLS.length} 种无法生成 ${pairCount} 对); } const half SYMBOLS.slice(0, pairCount); return shuffle([...half, ...half]); } const deck createDeck(8); // 8 对共 16 张createDeck先用slice截取前pairCount个符号再用展开运算符复制一份拼起来得到 16 个元素的数组最后交给shuffle。这里的硬约束是总牌数必须为偶数每张牌要有唯一配对。做 6×4 的 24 张牌时符号表至少要有 12 种否则要报错而不是静默缺牌。网格尺寸与牌堆种类的对应直接决定难度做源码时通常把这张表做成配置网格总牌数符号种类推荐场景4×3126新手教学或儿童模式4×4168默认难度最常见6×42412大屏或进阶模式2×242逻辑自测调试用实际项目里我一般把rows和cols作为配置项暴露出去生成牌堆时只校验乘积是否为偶数、符号种类是否够用这样后续加难度选择器不用动游戏主体逻辑。2.3 渲染牌桌DOM 生成的三个注意点牌桌用 DOM 动态生成而不是手写 16 个静态div因为网格和牌面都来自配置。渲染时有三件事容易出错一是每张牌必须同时设置dataset.symbol和dataset.indexindex用于调试时定位具体是哪张二是翻牌动画需要内层结构单层 div 做不出 3D 翻转三是每次开局要清空旧牌桌否则重复开始时 DOM 会累积。function renderGrid(deck) { const board document.getElementById(board); board.innerHTML ; // 清空上一局 deck.forEach((symbol, i) { const card document.createElement(div); card.className card; card.dataset.symbol symbol; card.dataset.index i; card.innerHTML div classcard-inner div classcard-face card-front/div div classcard-face card-back${symbol}/div /div; board.appendChild(card); }); }card-inner是翻转动画的旋转载体card-front是未翻开时的背面card-back是翻开后的真实牌面。这里命名容易反直觉视觉上玩家先看到card-front翻转 180 度后露出card-back所以图案必须放在card-back里。card.dataset.symbol symbol等价于加>.board { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; perspective: 800px; } .card { aspect-ratio: 3 / 4; cursor: pointer; touch-action: manipulation; } .card-inner { position: relative; width: 100%; height: 100%; transform-style: preserve-3d; transition: transform 0.45s ease; } .card.flipped .card-inner { transform: rotateY(180deg); } .card-face { position: absolute; inset: 0; backface-visibility: hidden; border-radius: 10px; } .card-front { background: #3b5bdb; } .card-back { background: #fff; transform: rotateY(180deg); display: flex; align-items: center; justify-content: center; font-size: 2rem; }preserve-3d让子元素保持在三维空间backface-visibility: hidden让背对屏幕的一面不显示。.card-back额外转 180 度是因为两个面初始叠在一起翻转后露出的那一面必须预先反向否则图案是镜像的。transition时长是手感关键参数450ms 是我常用的值太短像闪现太长让玩家干等。3.2 用锁变量挡住三连点state 对象怎么设计翻牌逻辑最常出 bug 的是连点玩家快速点了第一张、第二张发现不匹配又点第三张程序没防御就会出现三张同时翻开、配对计数错乱。标准解是加一个“锁定”标志当前回合判定未结束时拒绝处理新点击。let totalPairs 8; // 在 initGame 里由 rows * cols / 2 计算 const state { first: null, // 当前回合翻开的第一张牌 lock: false, // 判定中是否锁定输入 matched: 0, // 已配对数量 moves: 0, // 总翻转次数 timer: null, // setInterval 句柄 seconds: 0 // 游戏用时 }; function handleFlip(card) { if (state.lock) return; if (card state.first) return; if (card.classList.contains(flipped)) return; if (card.classList.contains(matched)) return; card.classList.add(flipped); state.moves 1; startTimerIfNeeded(); if (state.first null) { state.first card; return; } const first state.first; if (first.dataset.symbol card.dataset.symbol) { first.classList.add(matched); card.classList.add(matched); state.matched 1; state.first null; if (state.matched totalPairs) finishGame(); } else { state.lock true; setTimeout(() { first.classList.remove(flipped); card.classList.remove(flipped); state.first null; state.lock false; }, 800); } }四个前置判断各挡一类误操作lock挡判定中的第三张牌card state.first挡连点同一张牌flipped挡已翻开的牌被重复点matched挡已配对的牌再参与。配对成功只加计数并清空first失败时置lock为 true800ms 后再翻回。这个 800ms 就是不匹配停留时长应该比动画长 300ms 左右让玩家看清第二张牌面再翻回。匹配键用dataset.symbol比较以后换成图片牌面比对逻辑一行不用改。3.3 计时器从第一次翻牌开始计时器启动时机是常见实现分歧。有人进页面就开setInterval玩家没开始玩就开始计时成绩没有意义。正确策略是懒启动第一次翻牌时检查timer是否为空为空才创建。function startTimerIfNeeded() { if (state.timer ! null) return; state.timer setInterval(() { state.seconds 1; document.getElementById(timer).textContent formatTime(state.seconds); }, 1000); } function formatTime(sec) { const m String(Math.floor(sec / 60)).padStart(2, 0); const s String(sec % 60).padStart(2, 0); return ${m}:${s}; }formatTime用padStart保证分秒两位避免出现1:5这类显示。注意setInterval在页面切后台时会被浏览器节流实际计时偏慢要求严格时把seconds改成基于Date.now()差值计算每次渲染用Math.floor((Date.now() - startAt) / 1000)得出后台切回来时间依然准。3.4 事件委托注册点击不绑 16 个监听器给每张牌单独addEventListener不是不行但开局清空重建 DOM 时旧监听器没解绑会造成内存泄漏和重复触发。更省事的是把监听器注册在牌桌容器上利用事件冒泡统一处理const board document.getElementById(board); board.addEventListener(click, (e) { const card e.target.closest(.card); if (!card || !board.contains(card)) return; handleFlip(card); });closest(.card)让点击牌面内部文字或空白区域都能命中外层卡片不管玩家点中哪个子元素。board.contains(card)是防呆判断过滤冒泡路径上意外带进来的元素。事件委托还有一个好处以后加重开本局、下一关按钮只要按钮放在board里同一套监听逻辑直接复用。翻牌反馈的时序参数可以汇总成一张速查表方便调试手感参数推荐区间作用调大/调小的影响transition 时长350–550ms翻转动画快慢调大更柔和调小更干脆不匹配翻回延时600–900ms第二张牌停留时间调大让玩家看清牌面调小加快节奏网格间距8–16px牌面视觉密度调大降低误触调小更紧凑4. HTML5 卡牌配对小游戏的难度参数、计分公式与移动端适配4.1 把网格尺寸改成配置项而不是复制三份源码交付源码时最常被改的需求是“把 4×4 换成 6×4”。如果网格写死在 CSS 和createDeck里每改一次动多处代码。做法是把参数集中到一个CONFIG对象渲染和逻辑都从它读取用编辑器打开源码就能改const CONFIG { rows: 4, cols: 4, flipDuration: 450, matchTimeout: 800, symbolTable: SYMBOLS }; function initGame(config CONFIG) { const totalCards config.rows * config.cols; if (totalCards % 2 ! 0) { throw new Error(总牌数 ${totalCards} 不是偶数无法配对); } totalPairs totalCards / 2; const deck createDeck(totalPairs); renderGrid(deck); resetState(totalPairs); }注意rows * cols必须为偶数这是配对游戏成立的硬约束必须在initGame里校验而不是等渲染后报错。resetState负责清零state里的计数和计时器必须clearInterval(state.timer)否则上一局的定时器继续跑。CSS 侧用 CSS 变量传列数避免为每种难度写一套样式board.style.setProperty(--cols, String(CONFIG.cols));.board { grid-template-columns: repeat(var(--cols, 4), minmax(0, 1fr)); }minmax(0, 1fr)比直接写1fr可靠1fr的最小值是auto牌面内容较宽时列宽被内容撑开最后一行对不齐minmax(0, 1fr)允许列宽先收缩再按比例分配牌面再多也保持等宽。难度预设可以直接做成下拉选择难度rowscols总牌数适合场景简单4312新手、儿童、移动端小屏普通4416默认值桌面和移动端都合适困难6424大屏桌面硬核玩家测试224自动对局和压测4.2 计分公式把步数和时间换算成可比较的分数只用“用时最短”或“步数最少”单一指标会鼓励玩家乱点多翻来刷新步数。常见做法是把步数和时间都折算成分数玩家追求的是既少走弯路又快。这里用一个可调整的线性衰减公式function calcScore(moves, seconds, totalPairs) { const base 1000; const extraMoves Math.max(0, moves - totalPairs); const movePenalty extraMoves * 15; const timePenalty Math.floor(seconds / 3) * 5; return Math.max(0, base - movePenalty - timePenalty); }totalPairs是理论最优步数每多翻一次扣 15 分每 3 秒扣 5 分。设计意图是让玩家自己权衡为了确认某张牌的位置多翻两下30 分和犹豫 6 秒10 分之间做选择比单一指标更能体现记忆准确度。Math.max(0, ...)保证分数不会为负结算面板不用处理负数。4.3 移动端要处理的三个问题卡牌配对大部分流量来自手机移动端适配不只是响应式布局还有三个容易忽略的点。第一个是 300ms 点击延迟。老版本移动浏览器单击要等 300ms 确认不是双击才触发让人感觉卡顿。现在的浏览器大多已修复保险起见在 HTML 头部加上 viewport 设置meta nameviewport contentwidthdevice-width, initial-scale1.0, maximum-scale1.0, user-scalablenouser-scalableno禁掉双指缩放配合 CSS 里的touch-action: manipulation告诉浏览器该元素不需要双击缩放点击事件可立即触发。第二个是误触。手机上牌面小快速连点很容易点偏。除了把间距调到 10px 以上handleFlip里的四个前置判断必须保留尤其是flipped判断移动端触摸事件和模拟点击存在两条触发路径同一张牌可能在一次操作里被触发两次幂等判断能挡住重复翻转。第三个是牌面文字在窄屏下的适配。固定font-size: 2rem在 320px 宽的屏幕会溢出改用视口单位配合clamp.card-back { font-size: clamp(1.2rem, 6vw, 2.4rem); }clamp中间值用视口宽度比例小屏自动缩小大屏封顶。调试时用 Chrome DevTools 的设备模拟器重点看 320px 和 768px 两个断点。5. 本地最佳成绩与源码分发localStorage 存档zip 目录一次到位5.1 用带版本号的 key 存最佳成绩没有服务端最佳成绩只能存 localStorage。一个坑是以后改计分规则旧成绩和新规则混在一起排行榜会自相矛盾。常见做法是在 key 里带版本号改规则就换 keyconst STORE_KEY cardMatch.best.v1; function loadBest() { try { return JSON.parse(localStorage.getItem(STORE_KEY)); } catch (e) { return null; } } function saveBest(record) { const prev loadBest(); const isBetter !prev || record.moves prev.moves || (record.moves prev.moves record.seconds prev.seconds); if (isBetter) { localStorage.setItem(STORE_KEY, JSON.stringify(record)); } return isBetter; }比较逻辑用“步数优先步数相同看时间”的字典序玩家只有一个努力方向用更少的翻牌次数完成。try/catch包住JSON.parse是因为 localStorage 可能被用户清成非法值解析失败按无记录处理不影响结算流程。结算时调用const isNewRecord saveBest({ moves: state.moves, seconds: state.seconds }); if (isNewRecord) showBestBadge();saveBest返回布尔值判断是否刷新纪录比结算后重新读一遍再比较更直接少一次 JSON 解析。5.2 把源码整理成可直接分发使用的 zip 包拿到一个卡牌配对小游戏源码包第一件事不是看代码而是检查目录结构。可维护的分发结构长这样card-match/ ├── index.html ├── css/ │ └── style.css ├── js/ │ ├── deck.js │ ├── game.js │ └── storage.js └── README.mdindex.html按deck.js → game.js → storage.js顺序引入把洗牌、游戏逻辑、存档拆文件比单文件好维护。从 GitHub 下载的 zip 解压后跑不起来大概率不是代码问题而是打开方式不对直接从文件系统双击index.html会以file://协议加载部分浏览器对本地 JS 有限制。验证源码是否可用在项目根目录起一个静态服务器python -m http.server 8080 # 浏览器访问 http://localhost:8080用 HTTP 方式打开后在 DevTools 的 Application 面板里能看到cardMatch.best.v1这条 localStorage 记录说明存档逻辑已生效。最后验证牌堆是否均匀把CONFIG.rows和CONFIG.cols改成 2连开几局每次牌面排列应各不相同若两次完全一致检查shuffle里Math.random()的调用是否被外部 mock 覆盖了。本文还有配套的精品资源点击获取