
简介一份基于jQuery与HTML5的扑克翻牌消除小游戏完整源码面向前端初学者、网页设计课程学员以及休闲游戏开发者可以帮助快速理解HTML5语义化标签、CSS3过渡动画、jQuery事件绑定与DOM操作如何协同完成翻牌配对的核心玩法。压缩包共8个文件包含1个HTML页面、2个JavaScript脚本、1个CSS样式表及3张JPG和1张PNG图片资源总大小仅349KBHTML负责页面结构CSS控制翻转与消除特效JavaScript驱动配对逻辑与游戏状态整体目录按styles、scripts、images清晰划分便于查找和二次修改。游戏支持点击翻牌、配对消除、自动翻回、胜利提示等常见机制代码中还涉及data()存储、toggleClass切换、计时重置等实用技巧适合学习者逐行研读并在此基础上扩展难度或更换主题。已有146人学习下载说明该小项目在入门级前端开发练习中具备一定参考价值。通过动手运行和修改这份代码可以同时巩固网页布局、动画交互与原生逻辑实现的综合能力也可作为课程设计或简单H5游戏的起步模板。1. 为什么还把“jQueryHTML5扑克翻牌消除小游戏”当回事如果你常接手活动页、积分商城、课件交互这类需求大概率会被“jQueryHTML5扑克翻牌消除小游戏代码.zip”这样的标题吸引。玩法不难卡片背面朝上排成矩阵玩家每次翻两张图案相同就保持翻开并计一次有效匹配不同则翻回去。相比一上来就引一个完整的Canvas游戏框架用jQuery加原生HTML5手写这套逻辑反而更可控一个静态zip解压就能跑老机型不用做太多降级处理。真正值得花时间的不是蹩脚的界面而是状态设计、洗牌算法和翻转时序这三件事做好后面加计时、计步、锁卡都只是参数问题。下面按我实现这个玩法时常用的方案拆开讲。2. 翻牌消除的核心机制与代码模块划分2.1 先想清楚状态机再写jQuery事件翻牌游戏最常见的bug不是洗牌错误而是点击状态错乱。快速连点三张两张判定还没结束就点了第三张同一张牌在翻转动画中用hover触发了第二次翻转匹配失败的回翻动画还没完成用户点的牌已经被标记成“匹配成功”。用jQuery写事件很快但如果没有显式的状态控制代码在演示时“看起来能跑”一旦连点、拖拽、快速换设备就会露馅。我会先把整个游戏划分成五个阶段IDLE没有任何牌被翻开、FLIPPED_ONE第一张已翻开等待第二张、FLIPPED_TWO两张都翻开正在等待匹配结果、LOCKED匹配失败后动画回翻期间禁止一切点击、WON全部卡牌匹配完成。这五个状态用一个普通对象保存而不是依赖CSS类名去推断当前逻辑。CSS类只负责表现状态变量才负责业务判断。var GameState { IDLE: idle, FLIPPED_ONE: flipped_one, FLIPPED_TWO: flipped_two, LOCKED: locked, WON: won }; var game { state: GameState.IDLE, pairs: 8, moves: 0, left: 8, flipped: [], // 保存当前翻开的jQuery对象 timer: null // 回翻动画的setTimeout句柄 };这段结构定义了之后所有逻辑的边界。flipped数组最多存两个元素存jQuery对象是为了直接对节点加删类不需要再查一次DOM。timer字段在第四章会说明为什么必须在重置时清理这里先留出来。2.2 卡牌数据模型与洗牌算法每张牌在数据层至少有两个字段uid用于标识节点cardId用于判断两张牌是否成对。不能用uid代替cardId也不能把cardId直接写成HTML的id属性因为同样的cardId会出现两次而DOM中的id必须唯一。常见做法是把这两个字段分别写到>function createCardArray(pairCount) { var cards []; for (var i 0; i pairCount; i) { cards.push({ uid: i * 2, cardId: i }); cards.push({ uid: i * 2 1, cardId: i }); } return cards; }洗牌算法是翻牌游戏另一个容易出问题的地方。如果直接用sort(() Math.random() - 0.5)随机分布并不均匀而且随机比较会让排序行为依赖具体实现牌堆容易出现“同一个图案相邻”的扎堆现象。我一般用Fisher-Yates洗牌function shuffle(array) { for (var i array.length - 1; i 0; i--) { var j Math.floor(Math.random() * (i 1)); var tmp array[i]; array[i] array[j]; array[j] tmp; } return array; }这个算法的核心是从尾部开始每次把当前元素与前面任意位置交换保证每个排列等概率出现。对8到12对卡牌来说性能完全没有讨论的必要重点在公平性和可复现性。如果要固定某一局的局面只需要在洗牌前把Math.random()替换成带种子的伪随机函数逻辑不用动。2.3 渲染方式选DOM还是Canvas翻牌消除游戏单次渲染的卡牌通常不超过20对也就是40个节点用DOM和CSS3动画完全撑得住没有必要上Canvas。用Canvas反而要自己做点击命中检测把背面图案和正面图案画在两帧里还要在翻转动画的每一帧里手动插值复杂度会成倍上涨。渲染方式优点缺点适用场景jQuery CSS3 transform状态好调样式易改代码直观节点多时性能下降6~15对的常见翻牌游戏原生JS CSS3 transition不依赖库包体更小事件绑定和动画链条要自己写极简页面、不允许引入jQuery的站点Canvas 绘制卡牌数量大时渲染稳定命中检测和动画要手写30对以上或3D牌桌效果用jQuery实现这套玩法真正有价值的是事件委托。卡牌节点是动态生成的如果写在$(.card).click(...)新创建的卡牌不会自动收到事件必须在每次渲染后重新绑定使用委托后无论卡牌是什么时候创建的只要它还在#game-board内点击都能被统一处理$(#game-board).on(click, .card, handleCardClick);这是一个低成本但容易被新手忽略的写法。委托的另一个好处是将来如果要在牌桌上动态插入提示按钮或取消按钮不需要为每个节点单独绑定事件。3. 用jQuery和HTML5搭出可运行的翻牌游戏3.1 最小HTML骨架在实际项目里我倾向把HTML结构压到最薄游戏内容由JavaScript渲染只提供一个空容器和功能按钮。这样更换卡牌主题时不需要改HTML只改CSS和cardId对应的文案。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 titlejQueryHTML5扑克翻牌消除小游戏/title link relstylesheet hrefcss/style.css /head body div classgame-wrap div classgame-info span剩余配对b idpairs-left8/b/span span翻牌步数b idmoves0/b/span button idrestart重新开始/button /div div idgame-board classgame-board/div /div script srcjs/jquery.min.js/script script srcjs/game.js/script /body /htmlpairs-left显示剩余配对数量moves显示玩家翻牌次数这两个数字在每次匹配成功后更新。jQuery库放在页面底部、game.js之前保证脚本执行时DOM已经可用。注意这里没有把卡牌直接写死在HTML里而是留给JS渲染否则洗牌逻辑就只能作用于已经写死的顺序失去意义。3.2 CSS3做牌面翻转动画翻转动画是这套小游戏视觉上最核心的部分。我的做法是让.card负责提供透视.card-inner负责真正的3D翻转正面和背面都用绝对定位叠在.card-inner里。* { box-sizing: border-box; } body { background: #123; } .game-board { display: flex; flex-wrap: wrap; justify-content: center; width: 520px; margin: 20px auto; } .card { width: 100px; height: 140px; margin: 6px; cursor: pointer; perspective: 600px; } .card-inner { position: relative; width: 100%; height: 100%; transform-style: preserve-3d; transition: transform 0.4s ease; } .card.flipped .card-inner, .card.matched .card-inner { transform: rotateY(180deg); } .card-face { position: absolute; top: 0; left: 0; width: 100%; height: 100%; backface-visibility: hidden; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 36px; color: #333; background: #fff; box-shadow: 0 2px 4px rgba(0,0,0,0.2); } .card-back { background: #2a5caa; } .card-front { transform: rotateY(180deg); background: #f8f8f8; border: 1px solid #ddd; }这里容易踩的坑有三个第一perspective必须加在父容器或.card上如果直接加在.card-inner上透视效果会按照元素自身为视点计算翻转时会出现奇怪的拉伸第二.card-front必须预先旋转180度否则翻开后看到的是镜像内容第三backface-visibility: hidden要同时写在两个面上否则背面朝向自己时内容会透出。上面代码里用了transform: rotateY(180deg)作为最终状态所以匹配成功后不需要再定义一套新动画类名matched直接复用翻转状态只是额外通过JS加一个灰度或缩放效果。3.3 初始化卡牌与洗牌渲染游戏开始时的初始化函数要做四件事清空牌桌、生成数据、洗牌、渲染DOM。如果不清空牌桌直接append点击“重新开始”会出现两组卡牌叠在一起。function renderBoard(pairCount) { $(#game-board).empty(); var cards createCardArray(pairCount); cards shuffle(cards); var $board $(#game-board); $.each(cards, function (index, card) { var $card $(div) .addClass(card) .attr(data-uid, card.uid) .attr(data-card, card.cardId); var $inner $(div).addClass(card-inner); var $faceBack $(div).addClass(card-face card-back); var $faceFront $(div) .addClass(card-face card-front) .text(card.cardId); $inner.append($faceBack, $faceFront); $card.append($inner); $board.append($card); }); game.left pairCount; game.moves 0; game.flipped []; $(#pairs-left).text(pairCount); $(#moves).text(0); }>function handleCardClick() { if (game.state GameState.LOCKED || game.state GameState.WON) { return; } var $card $(this); if ($card.hasClass(flipped) || $card.hasClass(matched)) { return; } $card.addClass(flipped); game.flipped.push($card); game.moves; $(#moves).text(game.moves); if (game.flipped.length 1) { game.state GameState.FLIPPED_ONE; return; } game.state GameState.FLIPPED_TWO; checkMatchPair(); }checkMatchPair负责比较两张牌的>function checkMatchPair() { var cardA game.flipped[0].data(card); var cardB game.flipped[1].data(card); if (cardA cardB) { game.flipped.forEach(function ($card) { $card.addClass(matched); }); game.left--; $(#pairs-left).text(game.left); game.flipped []; game.state GameState.IDLE; if (game.left 0) { game.state GameState.WON; // 生产环境建议替换成自绘弹层 alert(全部配对成功共用了 game.moves 步); } return; } game.state GameState.LOCKED; game.timer setTimeout(function () { game.flipped.forEach(function ($card) { $card.removeClass(flipped); }); game.flipped []; game.state GameState.IDLE; }, 800); }最后在DOM ready里完成初始化和事件绑定$(function () { renderBoard(game.pairs); $(#game-board).on(click, .card, handleCardClick); $(#restart).on(click, function () { if (game.timer) { clearTimeout(game.timer); game.timer null; } game.state GameState.IDLE; renderBoard(game.pairs); }); });这里clearTimeout不是可选优化而是必须做。如果用户点了重新开始旧的回翻定时器还在等它触发时会错误地移除新卡牌的flipped类表现就是新游戏里第一张翻开的牌“自己跳了回去”。4. 翻牌消除状态的边界处理与参数调优4.1 为什么状态锁要放在第一个if里在handleCardClick里第一个判断必须是状态机然后才是单卡类名判断。如果把单卡判断放在前面用户在LOCKED状态下点击另一张未翻开的牌它会正常通过单卡判断然后被加入flipped数组数组变成3个元素后面所有逻辑都会错乱。正确的顺序是先用全局状态把整个交互锁住再检查当前卡片本身是否可点。这两层的作用域不同状态锁管的是时间窗口单卡锁管的是牌面位置。如果把两层合并比如只判断.hasClass(flipped)那么玩家快速点击第三张牌时第三张没有flipped类点击事件不会被拦截三连翻就会出现。顺带一提不要试图在动画结束后用transitionend事件代替状态锁。transitionend在连续触发类名变化时可能被跳过而且不同浏览器对transition的支持不完全一致用它做业务逻辑的锚点后期会很难受。setTimeout配合状态锁在翻牌游戏里更可靠。4.2 重置游戏前清理setTimeout前面已经提到restart时如果不清空game.timer旧定时器会污染新一局的UI。相同的问题也会出现在“倒计时模式”的setInterval上。所以我一般会在game对象里集中保存所有定时器句柄在reset方法里统一清理function resetGame() { if (game.timer) { clearTimeout(game.timer); game.timer null; } if (game.countdown) { clearInterval(game.countdown); game.countdown null; } game.state GameState.IDLE; game.flipped []; renderBoard(game.pairs); }这里补一个容易忽略的点setTimeout返回值在浏览器里是数字在Node里是对象但只要保存了句柄clearTimeout都能正确取消。不要把game.timer存成jQuery动画的返回值jQuery动画返回的是Promise对象取消动画应该用.stop()两者不要混用。4.3 手感相关的三个参数翻牌消除游戏的好玩程度很大程度取决于动画节奏。我整理了几个默认参数直接对应代码里的写死值。参数代码位置建议值说明翻转动画时长.card-inner的transition0.4s小于0.2s看起来像硬切大于0.6s拖沓匹配失败回翻延迟setTimeout的第二个参数800ms500ms以下来不及看清牌1200ms以上等得烦躁牌桌卡牌对数game.pairs8新手关6对普通8对挑战12对修改时注意transition时长和setTimeout时长不要完全相等。因为浏览器渲染和JS定时器之间存在微小误差如果两者都是600ms极端情况下setTimeout触发移除flipped类而过渡动画还没结束卡牌会瞬间闪一下再翻。我会把回翻延迟设置成transition 时长 200ms左右的余量。4.4 倒计时模式加在状态机哪里如果运营要给翻牌游戏加时间限制不要把倒计时写在handleCardClick里而是把计时器看作是独立于游戏状态之外的另一个状态维度。常见做法是单独维护game.timeLeft倒计时结束时如果游戏还在FLIPPED_TWO或LOCKED先强制清理当前翻开的牌再进入结束状态function startCountdown(seconds) { game.timeLeft seconds; game.countdown setInterval(function () { game.timeLeft--; $(#time-left).text(game.timeLeft); if (game.timeLeft 0) { clearInterval(game.countdown); game.flipped.forEach(function ($card) { $card.removeClass(flipped); }); game.flipped []; game.state GameState.WON; // 显示超时结果 } }, 1000); }这段代码里先清空flipped再置WON是为了避免超时弹层出现时桌面上还残留着半翻开的卡牌。这样处理之后计时器只负责“超时”这一件事匹配逻辑完全不用感知计时器的存在。5. 整理代码、本地验证与发布zip5.1 用DevTools验证翻转动画和连点保护代码写完先别急着打包。打开Chrome DevTools的Animations面板重新加载页面后点击两张牌应该能看到flipped类触发一次transform变化匹配失败时类名移除也会产生一段回翻动画。如果在这里看不到任何动画说明perspective或backface-visibility没生效优先检查.card-inner的父元素有没有被压缩成0高度。连点保护用DevTools的CPU降频来模拟慢动画环境Performance面板里选择6x CPU再快速点击三张牌。正常表现是第三张没有反应前两张在回翻延迟结束后归位。如果出现三张同时翻开说明handleCardClick里的状态判断顺序写错了回到4.1检查。5.2 用本地HTTP服务代替file协议这个项目只有一个HTML和几个静态文件直接双击index.html通常能跑起来但如果后续加了Web Audio音效、fetch读配置或者ES6的import语句file://协议会被浏览器安全策略拦截。所以我在发布前都会先起一个静态服务测试。python -m http.server 8080如果你用的是新版Node环境也可以这样npx http-server -p 8080启动后访问http://localhost:8080把手机和电脑连在同一个局域网里用手机访问电脑IP加8080端口实测触摸事件和点击事件是否都能正常触发。注意game-board如果设置了cursor: pointer在触摸设备上不会有视觉反馈需要在CSS里另行处理:active状态的背景色。5.3 压缩zip前的目录清理打包成zip时项目根目录只需要保留运行业务需要的文件。我一般用下面这个结构flip-game/ ├── index.html ├── css/ │ └── style.css ├── js/ │ ├── jquery.min.js │ └── game.js └── assets/ └── card-icon.pnggame.js里提到的本地jQuery路径要和目录一致不要从CDN加载否则zip包脱离网络后直接变白屏。在命令行里打包时可以用zip -r flip-game.zip index.html css js assets只打包指定目录避免把调试用的.DS_Store、node_modules一起塞进zip。如果是Windows右键压缩先确认当前目录是flip-game而不是外层文件夹这样别人解压后第一眼看到的就是index.html不用再翻一层。这个结构对成熟操作者来说可以直接放进Nginx或OSS静态目录也可以作为H5页面挂在活动服务里后面改卡牌图案、换主题、加音效都不需要动事件绑定和匹配逻辑。本文还有配套的精品资源点击获取