ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Vue3 + Element Plus 实现完整拼图游戏:状态管理与可解性校验实践

Vue3 + Element Plus 实现完整拼图游戏:状态管理与可解性校验实践 做拼图游戏这件事听起来很像前端新手村的练手项目但当你真正把完整游戏逻辑、切换图片、重新游戏、登录界面这些能力全都塞进一个小项目里你会发现它其实是特别好的前端综合训练场。我最近用 Vue3 Element Plus 从零到一实现了一个“完整版”拼图游戏页面里有三阶、四阶、五阶的可调节打乱还原多套图片素材切换一键重新开局还有带校验的登录注册界面。整个流程走通之后最大的感受是拼图的视觉复杂度不高但它把状态管理、算法、异步资源加载、表单交互这几个基本功全揉在了一起。这篇文章会把实现过程中的设计思路、代码细节和踩坑记录都整理出来适合想拿一个完整项目练手、又想弄清“产品化的小游戏到底要写哪些代码”的前端同学参考。1. 项目整体设计与技术选型1.1 需求拆解一个“完整版”到底包含什么多数拼图 demo 只做一个棋盘随机打乱数字块点击交换到序就弹窗。这样的项目拿去面试其实体现不出工程能力。我这次刻意把需求扩到四个模块核心游戏逻辑、多图片切换、重新游戏、登录界面。核心游戏逻辑是拼图的本体包含洗牌、可解性保证、移动规则、完成判定切换图片让同一个棋盘能复用多套素材考验异步加载和状态同步重新游戏考验的是状态重置是否干净登录界面则是把“用户”概念引入让最佳战绩能和具体账号挂钩。把四块串起来才是一个真实可玩的闭环产品而不是一个只会在控制台里 console.log 的玩具。1.2 技术栈怎么选Vue3、Element Plus、Pinia最终我选了 Vue3 组合式 API Element Plus Pinia Vite。先解释 Vue3拼图游戏的核心状态是pieces数组、当前图片、步数、计时器组合式 API 里用ref和computed就能很自然地表达如果用 Options API这些状态就会散落在data、methods、computed里项目一大就不清爽。Element Plus 主要负责登录界面和按钮、消息提示它的表单校验规则比手写校验省了很多时间尤其是el-form-item绑定prop之后的联动错误提示几乎是写登录页的标准答案。Pinia 用来做跨组件状态共享图片选择器要改棋盘游戏工具栏要重置状态头部要显示步数和时间如果没有全局 store组件之间的事件会把代码绕成一团。构建工具用 Vite开发时热更新快打包也省心这类中小型前端项目不用犹豫。有人可能会说这种小游戏用原生 JS 也能写何必上框架我的理由是原生 JS 做三阶可能还行做到四阶五阶、图片切换、登录流程一体化时命令式的 DOM 操作会越来越难维护。Vue 的响应式能让我把“状态变了自动更新界面”这件事交给框架我只需要关心游戏规则本身。把项目拆成目录后分工很清晰views放页面级组件components放棋盘、选图、工具栏stores放全局数据utils放纯函数算法。这种结构对我来说是“完整版”必须有的基础。1.3 一个关键取舍状态全局化我在第一版里曾经把棋盘数据放在PuzzleBoard.vue内部选图组件也只是“选中后通知父组件换图”。结果切换图片时棋盘内部要重新初始化但胜利弹窗的开关却在另一个组件里两边很容易出现状态不同步。后来我把游戏状态统一收进 Pinia 的gameStorepieces、imageIndex、moves、timeUsed、isWin全都放那里。组件只负责派发动作和读取数据。这就像拼图游戏本身一样每个组件各司其职但能运行的最终状态由一张“全局棋盘”决定。这个取舍也直接影响了后边的重新游戏实现——重置所有状态只需要一个resetGame()而不是一个个去调用不同组件的内部方法。如果一开始就抱着“功能简单不用上状态管理”的心态后期加需求时大概率会返工。1.4 开发环境与目录结构项目初始化很简单Vite 官方脚手架一条命令就能拉起来。考虑到“完整版”要长期维护目录结构我按功能而不是按文件类型划分这样每个功能自带页面、组件、状态查找效率高。最终结构大概长这样src/ ├── views/ │ ├── LoginView.vue │ └── GameView.vue ├── components/ │ ├── PuzzleBoard.vue │ ├── ImagePicker.vue │ └── GameToolbar.vue ├── stores/ │ ├── user.js │ └── game.js ├── router/ │ └── index.js └── utils/ └── puzzle.jsviews放路由级别的页面components放可复用的小组件stores放全局状态utils放纯函数算法。图片素材我当时从几个免版权图片站找了一批正方形风景图统一压到 800x800 再用。这里有个容易忽略的细节拼图块的背景是按正方形裁剪的原图如果不是正方形backgroundSize会直接把图片拉伸变形看起来就像一块块被压扁的色块。所以素材准备阶段就把正方形裁剪做掉后面会省很多事。2. 游戏逻辑难点不在交换在“可解性”2.1 洗牌不是随机打乱Fisher-Yates 才是标准做法很多人写拼图洗牌会直接pieces.sort(() Math.random() - 0.5)这在三阶小数组上勉强能看但有两个问题一是Array.prototype.sort并不保证“每种排列等概率”排序结果是基于比较器的偏序不是随机排列二是把排序当洗牌用语义上完全错误。正确的做法是 Fisher-Yates 洗牌从数组尾部往前遍历每一步在当前未处理区间里随机取一个下标做交换。这样每一种排列出现的概率严格相等。核心代码也就八行function shuffle(arr) { const result [...arr]; for (let i result.length - 1; i 0; i--) { const j Math.floor(Math.random() * (i 1)); [result[i], result[j]] [result[j], result[i]]; } return result; }注意我先把原数组拷贝了一份再打乱因为后续胜利判定需要一个标准target数组洗牌不应该污染它。这个函数我没有放在组件里而是写进了utils/puzzle.js方便单独测试。2.2 可解性校验为什么偶尔剩两块永远换不过来随机打乱的十五拼图有一半可能是无解状态。这个现象第一次遇到时很让人崩溃棋盘洗出来看起来完全没问题但玩家怎么交换最后总有两块的位置是反的。原因在于拼图的移动本质上是置换群里的偶置换而随机打乱会引入 50% 的奇置换。判断可解性的经典规则是把空白格视为0先将数字序列抽出来算逆序数如果棋盘边长是奇数逆序数为偶数时才能还原如果边长是偶数还需要把空白格从底部数的行号加进奇偶判断。function isSolvable(pieces, size) { const arr pieces.filter((n) n ! 0); let invCount 0; for (let i 0; i arr.length; i) { for (let j i 1; j arr.length; j) { if (arr[i] arr[j]) invCount; } } if (size % 2 1) return invCount % 2 0; const blankRowFromBottom size - Math.floor(pieces.indexOf(0) / size); return (invCount blankRowFromBottom) % 2 0; }明白了这个原理之后洗牌流程就变成先用 Fisher-Yates 打乱再调isSolvable判断如果不可解就重新洗直到生成一个合法开局。这里的代价可以忽略因为单次可解概率接近 50%期望循环两次就能结束。很多公开的拼图教程不会提这层但缺少可解性判断的项目一旦被玩家玩到“终极残局”就会立刻穿帮。2.3 移动规则用“点击空白相邻块”代替拖拽移动方式我选了点击交换没有用 HTML5 拖拽。原因很实际拖拽在桌面端用draggable属性实现不难但移动端触屏事件天然不兼容而且拖拽过程中手指遮挡严重点击交换在 PC 和手机上都成立交互成本低。规则很简单点一个块如果它在空白的正左、正右、正上、正下相邻位置就和空白交换否则无动作。这里最容易写错的是“对角也算相邻”很多粗糙实现会允许斜角交换导致拼图规则完全失真。function canMove(pieces, size, clickedIndex) { const blankIndex pieces.indexOf(0); const clickedRow Math.floor(clickedIndex / size); const clickedCol clickedIndex % size; const blankRow Math.floor(blankIndex / size); const blankCol blankIndex % size; return Math.abs(clickedRow - blankRow) Math.abs(clickedCol - blankCol) 1; }移动后pieces里两个值交换视图由响应式自动刷新。我建议把“判断是否相邻”和“执行交换”拆成纯函数放utils/puzzle.js里不要在组件里写一堆 if 分支这样单元测试也能直接针对纯函数写。实际开发中我还加了简单的点击动画被点击块会先做一次轻微位移再交换视觉反馈比瞬间跳变舒服很多但实现上只是给块加transition属性没有引入额外逻辑。2.4 胜利判定、步数与计时胜利判定非常朴素把当前pieces和目标target逐位比较全等就胜利。目标数组我统一生成成Array.from({ length: size * size - 1 }, (_, i) i 1).concat(0)也就是从 1 到最后一个数字再加一个 0 表示空白。每次移动后检查一次即可因为三阶最多 9 个元素、五阶 25 个O(n) 的检查开销可以忽略。胜利时要做三件事停止计时、把步数和用时写入结果弹窗、把最佳成绩和当前登录用户关联保存。步数和计时是“完整游戏逻辑”里最容易漏的两项少了它们玩家就不知道自己玩得好不好也失去了反复挑战的动机。计时我用setInterval每秒更新一次但有一个必须注意的坑setInterval很容易叠加。组件卸载前要clearInterval每次重新开始游戏前也要先清掉旧定时器否则切路由再回来页面上的秒数会开始“超速增长”。我在 5.3 节会再展开讲这个问题的排查过程。2.5 多阶难度数组规模与布局适配“完整版”只做一个三阶拼图会显得单薄所以我加了难度切换3x3、4x4、5x5。切换难度的本质是把pieces数组长度从size * size变化每次生成新数组后重新洗牌并做可解性校验。布局上棋盘容器可以保持固定宽度比如 400px那么每个格子的像素就是Math.floor(400 / size)。格子越小图片区域越精细但点击误触概率也会上升五阶已经是手感比较合理的上限。如果做成六阶一块只有 60 多像素手机上基本没法点。难度切换和重新游戏是两条独立的逻辑前者要改棋盘阶数后者保持当前阶数只重置一局两者都需要重新洗牌并清空步数。3. 图片切换与重新游戏状态同步的试金石3.1 图片切割用 background-position 实现零拷贝把一张图片切成九宫格或十六宫格最直观的方案是用 canvas 把图片按区域切出来生成多张 base64 子图。但我没有用这个方案原因有二生成多张 base64 会白白增加内存占用而且 canvas 的toDataURL是异步序列化数量一多容易出现加载顺序错乱。更稳妥的做法是让每个拼图块使用同一个图片 URL通过background-size和background-position只展示对应区域。视觉上是“拼图块”底层其实是同一张背景图在不同位置上的裁剪。div classpuzzle-piece :style{ width: piecePixel px, height: piecePixel px, backgroundImage: url(${currentImage}), backgroundSize: ${size * piecePixel}px ${size * piecePixel}px, backgroundPosition: -${col * piecePixel}px -${row * piecePixel}px } /div代码里piecePixel由棋盘总宽和阶数计算出来比如棋盘总宽 400px、四阶时每块就是 100px。背景图本身缩放成整个棋盘的大小再把可视窗口定位到当前格子的坐标这样图片源文件不需要预先切成小图任意正方形图片都能直接复用。格子的摆放用 CSS Grid 或者绝对定位都行我选了绝对定位因为后台还要根据pieces数组里每个位置的编号计算行列坐标。3.2 图片预加载切换图片时最容易踩的坑切换图片时最大的坑就是图片加载时序。第一次写的时候我把图片 URL 直接赋值给响应式状态结果点击“切换图片”按钮后棋盘经常出现一大片空白或者在加载过程中显示错乱的色块。原因是浏览器还没把新图片解码完成background-image就渲染到了 DOM此时背景图宽高是 0任何background-position都看不到内容。解决方法是预加载function preloadImage(src) { return new Promise((resolve) { const img new Image(); img.onload () resolve(img); img.onerror () resolve(null); img.src src; }); }切换图片的流程应该是先把新图片预加载完成再更新currentImage并重新洗牌。如果连续切换多张图片我还会用一个 Map 做缓存同一张图片只预加载一次避免来回切换时反复闪烁。素材多的时候静态引入的图片会全部打进 bundle所以我把图片列表放在一个配置对象里切换时才动态触发预加载既不拖慢首屏也不影响后续体验。3.3 重新游戏到底要重置什么“重新游戏”看起来就一个按钮但很多人的实现只重新洗牌步数和胜利状态没人管。我的resetGame里固定做这几件事清除计时器的 interval让timeUsed归零。基于当前图片和当前阶数重新生成可解洗牌结果。把moves清零把isWin设为 false关掉胜利弹窗。如果当前图片资源尚未预加载完成先等图片加载再更新棋盘。关键是要把“重置玩法数据”和“重置界面状态”放在同一个事务里处理。比如玩家已经在胜利弹窗里点了“再来一局”那么弹窗必须先关闭如果玩家先从选图列表切换了图片那么棋盘重排和新图片必须同时生效。分开处理就容易出现“弹窗关了但步数还是上一局”“图片换了但格子还是旧图”这种肉眼可见的不一致。我调试时发现最容易遗漏的是胜利弹窗的开关状态因为弹窗组件挂在GameView下而它的值存在gameStore里如果重置时忘了顺手设置isWin false就永远看到弹窗挡在游戏前面。3.4 选图组件给足交互反馈选图组件我用了缩略图列表每个小项显示图片缩略图当前选中项加一个高亮边框。点击后组件先触发preloadImage再在gameStore里更新图片索引如果图片已经在缓存里切换几乎是瞬间完成否则需要一两秒等待。为了避免玩家以为按钮坏了我在加载期间给缩略图加了一排淡入的 loading 效果图片到位后再让棋盘重新洗牌。这里的细节是切换图片不仅仅是换背景还要把当前棋盘重新洗乱否则玩家会看到一张新图但棋子还停留在上一局的排列里。至于要不要在切换图片时保留步数我选择了清零因为新图片本质上是一局新游戏。4. 登录界面把游戏流程做成完整闭环4.1 用 Element Plus 搭出注册与登录表单登录界面用了 Element Plus 的el-form并启用它的校验规则能力。登录表单只有用户名和密码两项但校验不能少用户名为空要提示密码长度要在 6 到 20 位之间。注册表单多一个“确认密码”需要自定义 validator 校验两次输入是否一致。Element Plus 的rules写法很直观const rules { username: [{ required: true, message: 请输入用户名, trigger: blur }], password: [ { required: true, message: 请输入密码, trigger: blur }, { min: 6, max: 20, message: 密码长度应为 6 到 20 位, trigger: blur } ], confirmPassword: [ { required: true, message: 请再次输入密码, trigger: blur }, { validator: (_, value, callback) { if (value ! form.password) callback(new Error(两次输入的密码不一致)); else callback(); }, trigger: blur } ] };实际开发时不建议把密码原文存进本地存储更不用说用明文比对。作为纯前端 demo至少也要做一层不可逆的哈希到了真实项目里校验和存储都应该交给后端前端表单只负责把合法数据传出去。我在 demo 里用的是浏览器提供的crypto.subtle.digest计算哈希虽然对游戏项目来说有点过度但至少把这个安全观念写进了代码里。4.2 用户数据与登录状态怎么存因为没有后端我用 localStorage 模拟一个“用户表”结构大概是这样{ puzzle_users: [ { username: alice, passwordHash: a1b2c3..., bestMove: 42 } ] }登录时从 localStorage 读取用户列表比对用户名和哈希后的密码注册时先查重再追加一条记录。登录成功后我会把当前用户名单独放到puzzle_current_user这个 key 下这样刷新页面后仍能保持登录状态。最佳成绩的读写逻辑也围绕这个 key 展开胜利时把当前用户的最佳步数取出来如果本次更好就更新。localStorage 的容量虽然只有几 MB 级别但这项目存千把个用户绰绰有余。跨浏览器不能共享这点不可避免但对一个纯前端 demo 完全够用。如果后续要接真实后端只需要把store/user.js里的读写逻辑换成 axios 请求页面层代码几乎不用改。4.3 路由守卫未登录访问游戏区先回登录页登录与游戏的联动通过路由守卫完成。我给游戏路由加了一个meta: { requiresAuth: true }在全局前置守卫里检查登录状态router.beforeEach((to) { const currentUser localStorage.getItem(puzzle_current_user); if (to.meta.requiresAuth !currentUser) { return { path: /login }; } });有了守卫之后整个项目就形成“未登录只能看登录/注册页登录后才能进游戏”的闭环。这里有一个细节判断登录态时应该从 localStorage 读取而不是只依赖 Pinia 里的内存变量否则刷新页面后会因为内存数据丢失而被误判为未登录。登录成功后通过ElMessage.success给一个友好提示再跳转到游戏页登出按钮则反过来清除puzzle_current_user并回到登录页。这些交互单独看都不复杂但合在一起就是真实产品里用户习以为常的完整流程。4.4 最佳成绩与用户名联动展示游戏头部我放了三个信息当前登录用户名、当前最佳步数、本局步数计时。最佳步数在页面加载时就从 localStorage 读出来胜利时如果当前步数更低就更新到用户记录里。这种“和账号绑定”的设定让玩家有重复挑战的动力。如果只是单个本地记录换个浏览器成绩就没了体验上会差很多。我还顺手做了个小小的排序展示在登录页下方列出一个“排行榜”占位只显示本机已注册用户的前五名这个功能不依赖后端却能明显提升“完整产品”的质感。5. 调试实录这些坑我先替大家踩过了5.1 切换图片后棋盘全白的排查过程遇到的第一个问题是切换图片之后棋盘区域全白。当时网络面板里图片加载是正常的DOM 里style属性也正确但界面上就是没有内容。后来我在backgroundImage渲染前手动执行了一次new Image().onload发现只有等onload触发后才正常。确认根因是背景图加载完成前就渲染了解决方式就是对每个图片 URL 做预加载再赋值。如果你遇到类似问题可以打开浏览器的 Rendering 面板把 “Paint flashing” 打开观察是绘制问题还是资源问题。我当时还误判过 CSS 背景定位算错其实方向完全不对浪费了不少时间。5.2 随机洗牌无解玩家永远“解不开”这可能是拼图类游戏的特有 bug。一开始我直接用 Fisher-Yates 洗牌自测前几局都正常但玩到十几局时突然出现一个“只差最后两块”的死局。排查时我用循环穷举了一遍移动确认当前排列无法到达目标态再回头查可解性规则补齐isSolvable后问题消失。我的建议是不要在游戏代码里临时凑逻辑而是把可解性判断当成标准配置每次生成开局后都校验一次。后来我还给isSolvable写了几个单元测试用例专门覆盖奇数阶和偶数阶心里才算踏实。5.3 计时器越走越快原来是 setInterval 叠加快速点击“重新游戏”按钮时我发现秒数开始“超速”一秒内能跑三四秒。打开控制台发现每次重启都会有一个新的 interval 在跑旧的没有清掉。修复方案很简单startTimer函数里第一步就是if (timer) clearInterval(timer)。这里值得提醒的是组件卸载时也要清理尤其是用 Vue Router 切换页面后后台计时器是很容易被忽略的“内存泄漏”。如果不放心可以在onUnmounted里统一调用stopTimer养成这个习惯后基本不会再栽在定时器上。5.4 高频问题速查表现象可能原因处理方向切换图片后棋盘空白背景图未预加载完成就渲染先用new Image()等待 onload再更新响应式状态完成判定始终不通过目标数组定义错误0的位置不对统一生成Array.from({length: n*n-1}, (_, i) i 1).concat(0)刷新页面回到登录页路由守卫只检查内存变量守卫从 localStorage 中读取当前用户计时速度异常setInterval 未清理重复创建每次启动前 clearInterval组件卸载时清理洗牌后偶尔无法通关无解排列未被过滤增加可解性判断不可解就重新洗牌图片换完棋子排列没变只更新了背景没有同步重置棋盘切换图片时同时触发重新洗牌这张表在调试时能省很多时间。当然每个项目的具体情况都会有些差异这只是我在这套项目里最高频遇到的六个问题。经验上看拼图游戏这类“小而全”的项目难点往往不在算法而在于状态在不同模块之间传递时失真所以我排查时习惯先看状态再看渲染通常能定位到七成以上的问题。5.5 调试工具与习惯我用 Vue Devtools 观察gameStore的变化重点盯三个时间点洗牌后、移动后、切换图片后。只要某个时间点的pieces数组不符合预期就能快速定位是算法问题还是组件派发问题。另外我在utils/puzzle.js里加了一个printBoard函数把当前棋盘按行列打印到控制台。拼图这种二维结构一旦数组顺序错误控制台里一行一行看会比盯着彩色格子更容易发现规律。调试习惯对这类小游戏尤其重要别等用户报告 bug 才想起加日志。最后分享两个我从这个项目里沉淀下来的习惯。第一把纯逻辑洗牌、可解性、移动判断全部抽到不带 DOM 依赖的工具函数里这样每次修改完都能跑一遍快速验证我顺手给isSolvable和shuffle各写了几条单元测试后面加难度级别时信心明显足了很多。第二重构resetGame时不妨把要重置的状态列一张清单对照着检查有没有遗漏我后来数了一下一个看似简单的“重新游戏”其实要管住五个状态漏一个都会让玩家觉得界面卡在上一局。这个项目做完之后我最大的体会是所谓“完整版”真正的价值不在于多了几个功能按钮而在于每个功能之间切换和重置时用户不会觉得被“卡住”或“穿帮”。希望这篇记录能让大家少走点弯路。
返回列表