简介:一套基于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×3 | 12 | 6 | 新手教学或儿童模式 |
| 4×4 | 16 | 8 | 默认难度,最常见 |
| 6×4 | 24 | 12 | 大屏或进阶模式 |
| 2×2 | 4 | 2 | 逻辑自测,调试用 |
实际项目里我一般把rows和cols作为配置项暴露出去,生成牌堆时只校验乘积是否为偶数、符号种类是否够用,这样后续加难度选择器不用动游戏主体逻辑。
2.3 渲染牌桌:DOM 生成的三个注意点
牌桌用 DOM 动态生成,而不是手写 16 个静态<div>,因为网格和牌面都来自配置。渲染时有三件事容易出错:一是每张牌必须同时设置dataset.symbol和dataset.index,index用于调试时定位具体是哪张;二是翻牌动画需要内层结构,单层 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 class="card-inner"> <div class="card-face card-front"></div> <div class="card-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为 true,800ms 后再翻回。这个 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)允许列宽先收缩再按比例分配,牌面再多也保持等宽。难度预设可以直接做成下拉选择:
| 难度 | rows | cols | 总牌数 | 适合场景 |
|---|---|---|---|---|
| 简单 | 4 | 3 | 12 | 新手、儿童、移动端小屏 |
| 普通 | 4 | 4 | 16 | 默认值,桌面和移动端都合适 |
| 困难 | 6 | 4 | 24 | 大屏桌面,硬核玩家 |
| 测试 | 2 | 2 | 4 | 自动对局和压测 |
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 name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">user-scalable=no禁掉双指缩放,配合 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 里带版本号,改规则就换 key:
const 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 覆盖了。
本文还有配套的精品资源,点击获取