news 2026/9/14 15:42:40

HTML5卡牌配对小游戏:从洗牌算法到状态管理的完整实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
HTML5卡牌配对小游戏:从洗牌算法到状态管理的完整实现

简介:一套基于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; }

循环从最后一个元素往前扫描,j0i之间的随机整数,把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逻辑自测,调试用

实际项目里我一般把rowscols作为配置项暴露出去,生成牌堆时只校验乘积是否为偶数、符号种类是否够用,这样后续加难度选择器不用动游戏主体逻辑。

2.3 渲染牌桌:DOM 生成的三个注意点

牌桌用 DOM 动态生成,而不是手写 16 个静态<div>,因为网格和牌面都来自配置。渲染时有三件事容易出错:一是每张牌必须同时设置dataset.symboldataset.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 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}`; }

formatTimepadStart保证分秒两位,避免出现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 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.md

index.htmldeck.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.rowsCONFIG.cols改成 2,连开几局,每次牌面排列应各不相同;若两次完全一致,检查shuffleMath.random()的调用是否被外部 mock 覆盖了。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/14 15:40:50

遥感影像镶嵌:GDAL Python批量处理实战指南

简介&#xff1a;本资源是一份面向遥感、GIS及地理信息开发者的Python实战脚本工具包&#xff0c;聚焦遥感影像自动化镶嵌这一高频处理需求&#xff0c;适用于环境监测、地图制图、农业遥感分析等实际项目场景。压缩包为6KB的ZIP文件&#xff0c;共含2个Python源码文件&#xf…

作者头像 李华
网站建设 2026/9/14 15:39:08

icloudpd iCloud照片本地备份实操:从首次认证到定时同步

icloudpd iCloud照片本地备份实操&#xff1a;从首次认证到定时同步 【免费下载链接】icloud_photos_downloader A command-line tool to download photos from iCloud 项目地址: https://gitcode.com/GitHub_Trending/ic/icloud_photos_downloader 你手里的 iCloud 照片…

作者头像 李华
网站建设 2026/9/14 15:39:01

欧姆龙NJ与EtherCAT多轴伺服控制:ST语言编程与调试实战要点

最近刚交付了一条新能源电池方向的产线改造&#xff0c;核心是1台欧姆龙NJ系列PLC&#xff0c;EtherCAT总线挂了24台伺服&#xff0c;程序主体用ST语言来写。整套系统从选型到调试做了将近两个月&#xff0c;中间换过思路、踩过不少坑&#xff0c;也沉淀出一套适合中大型多轴项…

作者头像 李华