做拼图游戏这个需求,看起来简单,真正动手你会发现坑全藏在细节里:随机洗牌可能洗出一个永远拼不回来的死局,图片切得好好的放到页面上却对不齐,登录页面刚写完又遇到路由守卫反复跳回登录页。我之前自己从零写过一个完整版拼图游戏,用 Vue3 + Element Plus 做了登录界面,把完整游戏逻辑、切换图片、重新游戏全部串起来,跑通之后回头整理了一遍思路。今天就把这整套实现拆开讲,包括棋盘数据模型、保证可解的洗牌、点击移动与胜利判定、图片切片方案、登录注册与进度保存,还有我调过的那些典型问题。适合正在做课程设计、面试项目,或者想用一个小而全的案例练手 Vue3 状态管理和组件通信的同学参考。
1. 项目拆解与整体设计思路
1.1 这个拼图游戏到底要做什么
先别急着写代码,先把需求捋清楚。所谓“完整版拼图游戏”,至少包含三条主流程:登录、游戏、赛后操作。登录解决的是“谁在玩”的问题,游戏解决的是“怎么玩”的问题,赛后操作则包含切换图片、重新游戏、查看最佳纪录这些延续体验的功能。
拼图游戏本身采用的是经典滑动拼图规则:棋盘是 N x N 的网格,其中一个格子是空白,玩家点击空白格相邻的拼图块,该拼图块就滑进空白格。通过一次次滑动,把打乱后的拼图恢复到原图顺序,游戏结束。实际上整个项目的核心难点不是页面长什么样,而是棋盘的“状态”怎么维护:哪块拼图在哪个位置、空白格在哪、怎么判断相邻、怎么判断胜利、怎么保证洗牌后一定有解。
网上流传的那套黑马拼图素材很多人都用过,图片本身质量不错,拿来当内置图片很合适。但素材只是输入,真正值钱的是你如何处理像素、如何把一张图“切”成 N x N 块并且不出现错位。这一点我后面会专门讲。
1.2 技术选型:为什么用 Vue3 + Element Plus
技术栈我选了 Vue3 + Vite + Element Plus,理由很直接。这个项目要求登录界面、表单校验、按钮状态这些交互,Element Plus 的 Form 组件和校验规则可以省掉一大堆手写代码;而游戏逻辑里大量状态变化,比如棋盘数组、当前图片、计时器、步数,Vue3 的 ref 和 reactive 处理起来非常顺手。
如果你习惯 DevExpress 那套 .NET 控件,当然也能做,但那是桌面端/企业级控件体系,跟 Vue3 网页方案不是一个赛道,这里不展开。也有人用原生 JavaScript 写,完全可以,只是当你要加登录态、路由守卫、多个页面切换时,原生代码会变得很散,组件划分不清晰,后面维护成本高。Vue3 单文件组件天然把模板、脚本、样式聚在一起,游戏棋盘、登录表单、图片选择面板各自独立成组件,逻辑边界清楚,出现问题也好定位。
还有一个设计层面的选择:我没有引入 Pinia,只用了普通的 reactive 单例模块。原因很简单,这个项目全局共享的状态不多,主要是当前用户、当前图片、棋盘状态。用 Pinia 当然更规范,但对一个单页应用来说是过度设计。如果你之后要扩展成多玩法、多用户排行榜,再引入 Pinia 也不迟。
1.3 核心状态管理与游戏流程
整个项目按状态可以分为两类:一类是用户状态,一类是游戏状态。用户状态负责记录当前登录的用户名、用户的最佳成绩;游戏状态负责棋盘数据、空白格位置、步数、耗时、游戏状态标识。
游戏状态我建议全部放在一个独立的组合式函数里,比如usePuzzle(),而不是散落在组件里。这样登录页面和游戏页面各管各的,游戏页面内部再把usePuzzle()对外暴露的方法和状态绑定到 UI 上。核心状态变量如下:
| 状态 | 类型 | 作用 |
|---|---|---|
| tiles | Ref<number[]> | 一维棋盘数组,0 表示空白格 |
| blankIndex | Ref | 空白格在数组中的下标 |
| moveCount | Ref | 有效移动次数 |
| elapsed | Ref | 游戏已进行秒数 |
| status | Ref | ready / playing / win 三种状态 |
| currentImage | Ref | 当前使用的图片地址 |
| boardSize | Ref | 棋盘阶数,3/4/5 |
游戏流程我用一句话概括:登录成功后进入游戏页,选择图片和难度,点击开始后棋盘被打乱,玩家通过点击相邻拼图块恢复原图,胜利后弹出结果并自动保存最佳成绩。这里有个容易忽略的点:开始按钮和重新游戏按钮本质上是同一个动作——重置棋盘并打乱。把resetGame()和shuffleBoard()拆成两个方法,按钮只需要按顺序调用,逻辑就非常清晰。
2. 完整游戏逻辑的落地实现
2.1 棋盘状态建模:一维数组和空白格
很多新手喜欢用二维数组表示 N x N 棋盘,每个格子存一个拼图块编号。但滑动拼图有一个特点:空白格只有一个,二维数组的坐标计算反而绕。我改用一维数组,长度为N * N,下标i对应的行号是Math.floor(i / N),列号是i % N。
初始化状态下,数组内容是从 0 到N*N - 1的递增序列,其中 0 表示空白格,其余数字表示对应位置的拼图块编号。判断胜利时只需要比较数组是否等于目标序列[1, 2, ..., N*N-1, 0]。这个数据模型非常干净,移动、交换、判断都围绕下标操作。
import { ref } from 'vue' export function usePuzzle() { const boardSize = ref(4) const tiles = ref([]) const blankIndex = ref(0) const status = ref('ready') const createSolvedBoard = () => { const total = boardSize.value * boardSize.value const arr = Array.from({ length: total }, (_, i) => i) tiles.value = arr blankIndex.value = total - 1 status.value = 'ready' } return { boardSize, tiles, blankIndex, status, createSolvedBoard } }为什么用 0 而不是 null 或者 -1 表示空白格?因为判断胜利时可以用every直接和目标数组比较,0 也是一个正常数字,不需要额外处理“空白格是不是 null”的分支。另外在渲染阶段,值为 0 的格子不显示图片,逻辑上也统一。
2.2 洗牌算法:保证有解的反向移动法
这是整个项目里最容易踩坑的地方。如果你直接对数组做shuffle,洗出来的棋盘大概率无解——也就是无论怎么滑动都拼不回来。原因在于滑动拼图的奇偶性约束:每做一次水平滑动,逆序数不变;每做一次垂直滑动,逆序数变化 N-1。最终能否还原,取决于逆序数和空白格所在行的奇偶性组合。
最稳妥的方案不是先随机打乱再检查逆序数,而是“从还原态反向走”。思路很简单:从已经还原的棋盘开始,模拟空白格随机上下左右移动几万步,得到的一定是可通过合法移动还原的初始状态。这就相当于先把结果做对了,再去打乱它。
const getNeighborIndexes = (index, size) => { const neighbors = [] const row = Math.floor(index / size) const col = index % size if (row > 0) neighbors.push(index - size) if (row < size - 1) neighbors.push(index + size) if (col > 0) neighbors.push(index - 1) if (col < size - 1) neighbors.push(index + 1) return neighbors } const shuffleBoard = () => { const size = boardSize.value const total = size * size const arr = Array.from({ length: total }, (_, i) => i) let blank = total - 1 for (let step = 0; step < 20000; step++) { const neighbors = getNeighborIndexes(blank, size) const target = neighbors[Math.floor(Math.random() * neighbors.length)] ;[arr[blank], arr[target]] = [arr[target], arr[blank]] blank = target } tiles.value = arr blankIndex.value = blank status.value = 'playing' moveCount.value = 0 elapsed.value = 0 }步数 20000 不是固定标准,我试过 3x3 用 500 步就已经足够随机,4x4 用 2000 步,5x5 用 10000 步。写得大一点无妨,反正是 O(n) 的开销。如果你确实想要完全随机的打乱,也可以先随机洗牌,再用逆序数公式判断可解性,不可解就交换两个非空白拼图块。但从工程效率看,反向移动法更简单直观,不容易算错。
2.3 点击移动与边界判断
移动逻辑是整个游戏的关键。玩家点击一个拼图块,只有当它和空白格在同一行或同一列,并且刚好相邻时,才能交换。判断方法我用了行列差:
const moveTile = (index) => { if (status.value !== 'playing') return const blank = blankIndex.value const size = boardSize.value const rowDiff = Math.abs(Math.floor(index / size) - Math.floor(blank / size)) const colDiff = Math.abs((index % size) - (blank % size)) const isAdjacent = (rowDiff === 0 && colDiff === 1) || (colDiff === 0 && rowDiff === 1) if (!isAdjacent) return const arr = [...tiles.value] ;[arr[blank], arr[index]] = [arr[index], arr[blank]] tiles.value = arr blankIndex.value = index moveCount.value += 1 checkWin() }这里有两个细节要注意。第一,不要用“曼哈顿距离等于 1”这种宽泛判断,因为对角线上的两个格子曼哈顿距离也是 1,但它们不能互相交换。所以我用“行列差”分成两个条件,确保只有上下左右相邻才算合法。第二,tiles.value = arr这一步必须用新数组赋值,不能直接修改tiles.value[blank],否则 Vue3 的响应式可能不会触发更新,页面会觉得“点了没反应”。
如果你想让操作更顺手,还可以加键盘方向键控制:监听keydown事件,按方向键移动空白格对应方向的拼图块。这个功能不是必须的,但对提升游戏体验帮助很大。
2.4 胜利检测、计时与计步
胜利条件很简单:一维数组完全等于[1, 2, ..., N*N-1, 0]。我每次移动后调用checkWin(),用every遍历一次,时间复杂度只有 O(N^2),完全够用。
const checkWin = () => { const total = boardSize.value * boardSize.value const solved = Array.from({ length: total - 1 }, (_, i) => i + 1).concat(0) const isWin = tiles.value.every((value, index) => value === solved[index]) if (isWin) { status.value = 'win' stopTimer() saveBestRecord() } }计时器不要用setInterval里不断elapsed += 1的方式,因为浏览器标签页切走再切回来时,定时器会被暂停,统计的秒数就会偏少。正确做法是只记录游戏开始时的时间戳startTimestamp = Date.now(),然后每秒用Math.floor((Date.now() - startTimestamp) / 1000)重新计算耗时。这样即使标签页休眠了几秒,回来之后算出来的时间也是准确的。
步数只统计“成功交换”的次数,所以我把moveCount += 1放在moveTile合法判断之后,而不是点击事件里。这个细节能避免玩家疯狂点击不相邻格子导致步数虚高。
3. 切换图片与重新游戏功能的工程化实现
3.1 图片切片:CSS 背景定位而不是 Canvas
拼图游戏必然要处理图片切片。很多人第一反应是用 Canvas 把图片切成 N x N 块,导出成独立的小图片再渲染。这个方案能做,但有两个麻烦:一是跨域图片会让 Canvas 被污染,无法调用toDataURL;二是大量小图会带来额外的内存和调试成本。
我的做法是用 CSS 背景定位。每个拼图块是一个<div>,它的背景图是完整原图,但通过background-size把图片放大到棋盘全尺寸,再用background-position偏移到目标区域。这样视觉上每个格子都只显示原图的一个切片,实际上并不存在真正的切片。
假设棋盘是 N 阶,每个格子的背景图尺寸设置为N * 100% x N * 100%,这样背景图正好覆盖整个棋盘区域。某个拼图块应该显示“原图第 r 行第 c 列”的内容,那么它的背景偏移量就是c / (N - 1) * 100%和r / (N - 1) * 100%。
<template> <div class="puzzle-board" :style="{ '--n': boardSize }"> <div v-for="(value, index) in tiles" :key="index" class="puzzle-tile" :class="{ blank: value === 0 }" :style="tileStyle(value)" @click="moveTile(index)" ></div> </div> </template>const tileStyle = (value) => { if (value === 0) return {} const row = Math.floor(value / boardSize.value) const col = value % boardSize.value return { backgroundImage: `url(${currentImage.value})`, backgroundSize: `${boardSize.value * 100}% ${boardSize.value * 100}%`, backgroundPosition: `${(col / (boardSize.value - 1)) * 100}% ${(row / (boardSize.value - 1)) * 100}%` } }样式部分使用 CSS Grid 排列格子,空白格直接设置为透明并且关闭点击事件:
.puzzle-board { display: grid; grid-template-columns: repeat(var(--n), 1fr); grid-template-rows: repeat(var(--n), 1fr); width: 400px; height: 400px; border: 2px solid #ddd; border-radius: 8px; overflow: hidden; } .puzzle-tile { width: 100%; height: 100%; background-repeat: no-repeat; transition: transform 0.15s ease; } .puzzle-tile.blank { opacity: 0; pointer-events: none; }这里的计算有个容易错的点:backgroundPosition用的是百分比,而百分比计算方式会受到backgroundSize影响。好在我们统一使用N * 100%的背景尺寸,公式可以稳定成立。只要图片本身是正方形,这个方案几乎不会出错。
3.2 内置图片与自定义上传
图片来源我分成两种:内置图片列表和用户本地上传。内置图片我建议放在项目src/assets目录下,打包后是同一域名资源,不存在跨域问题。用户上传则通过<input type="file">拿到文件,用URL.createObjectURL生成临时地址。
但这里有个重要步骤:上传的图片不一定是正方形,而棋盘是正方形。如果直接把一张长方形图片丢给 CSS 背景切割,拼接出来的画面是变形的。所以用户在开始游戏前,我先用 Canvas 把图片中心裁剪成正方形,再输出为 dataURL 或者继续使用 objectURL。
const processImageToSquare = (src) => { return new Promise((resolve, reject) => { const img = new Image() img.onload = () => { const side = Math.min(img.width, img.height) const sx = (img.width - side) / 2 const sy = (img.height - side) / 2 const canvas = document.createElement('canvas') canvas.width = side canvas.height = side canvas.getContext('2d').drawImage(img, sx, sy, side, side, 0, 0, side, side) resolve(canvas.toDataURL('image/jpeg', 0.85)) } img.onerror = reject img.src = src }) }切换图片的流程我统一封装成switchImage(imageUrl):先更新当前图片地址,再调用resetGame()生成还原态棋盘,再调用shuffleBoard()打乱。这样无论玩家选择内置图片还是上传图片,走的是同一条路径,不会出现选了新图片但棋盘还是旧图片的错乱状态。
3.3 重新游戏与难度等级调整
重新游戏按钮的逻辑其实和“开始游戏”一样,很多人单独写了一个restart()方法,其实没必要。我直接复用resetGame()加shuffleBoard()的组合。难度切换则通过修改boardSize实现,切换后同样执行重置。
const resetGame = () => { const total = boardSize.value * boardSize.value tiles.value = Array.from({ length: total }, (_, i) => i) blankIndex.value = total - 1 moveCount.value = 0 elapsed.value = 0 status.value = 'ready' stopTimer() } const startNewGame = () => { resetGame() shuffleBoard() }难度等级我提供了 3x3、4x4、5x5 三档。3x3 适合新手试水,4x4 是体验平衡点,5x5 已经需要花不少时间了。切换难度时注意 UI 上要同步更新 CSS 变量--n,因为 Board 的列数完全依赖这个变量。如果你在 4x4 游戏进行中直接切到 5x5,还要注意拼图块的数字编号范围变了,所以重置棋盘数组一定是重新生成,而不是复用之前的数组。
4. 登录界面与用户进度系统的实现
4.1 登录/注册表单与校验
登录界面我用的 Element Plus,表单分成两个 Tab:登录和注册。登录字段是用户名、密码,注册字段额外加一个确认密码。校验规则写在rules里,Element Plus 的 Form 组件会在失焦和提交时自动触发校验。
<el-form ref="loginFormRef" :model="loginForm" :rules="loginRules" label-width="80px" > <el-form-item label="用户名" prop="username"> <el-input v-model="loginForm.username" placeholder="请输入用户名" /> </el-form-item> <el-form-item label="密码" prop="password"> <el-input v-model="loginForm.password" type="password" show-password placeholder="请输入密码" /> </el-form-item> <el-form-item> <el-checkbox v-model="rememberMe">记住我</el-checkbox> </el-form-item> <el-form-item> <el-button type="primary" @click="handleLogin">登录</el-button> </el-form-item> </el-form>const loginRules = { username: [ { required: true, message: '请输入用户名', trigger: 'blur' }, { min: 2, max: 12, message: '用户名长度为 2-12 个字符', trigger: 'blur' } ], password: [ { required: true, message: '请输入密码', trigger: 'blur' }, { min: 6, max: 20, message: '密码长度为 6-20 个字符', trigger: 'blur' } ] }值得说明的是,Element Plus 校验只有在你给el-form-item设置prop属性、并且model中的字段名对应一致时才会生效。我一开始漏掉了prop="username",结果点击登录后校验规则完全没触发,页面啥反应都没有,排查了很久才发现是组件属性绑定问题。
4.2 用户数据的本地持久化
这个项目没有后端,我用 localStorage 做伪用户系统。做法很简单:本地存一个puzzle_users数组,每个元素是{ username, password }。注册时先检查用户名是否已存在,不存在就 push 进去并写回 localStorage;登录时遍历数组比对用户名和密码。
const USERS_KEY = 'puzzle_users' const CURRENT_USER_KEY = 'puzzle_current_user' const getUsers = () => { try { return JSON.parse(localStorage.getItem(USERS_KEY)) || [] } catch { return [] } } const saveUsers = (users) => { localStorage.setItem(USERS_KEY, JSON.stringify(users)) } const registerUser = (username, password) => { const users = getUsers() if (users.some((u) => u.username === username)) { return { ok: false, message: '用户名已存在' } } users.push({ username, password }) saveUsers(users) return { ok: true } } const loginUser = (username, password) => { const users = getUsers() const user = users.find((u) => u.username === username && u.password === password) if (user) { localStorage.setItem(CURRENT_USER_KEY, username) return { ok: true } } return { ok: false, message: '用户名或密码错误' } }这里必须强调:这只是演示项目的行为。真实项目中绝对不能把明文密码存到 localStorage,密码需要经过后端加密存储,登录态要用 token。不过对课程设计、个人练习项目来说,这套伪用户系统足够完成“登录界面”的展示效果。
玩家通关后的最佳成绩我也按用户维度存储,key 是puzzle_best_{username},内容是{ boardSize, moves, elapsed }。这样下次登录后,游戏页就能读取当前用户的历史最佳成绩并展示出来。
4.3 路由守卫与登录态保持
登录界面做出来之后,还需要一个核心机制:未登录用户不能直接访问游戏页。我用 vue-router 的全局前置守卫来实现。如果目标路由是游戏页且本地没有当前用户,就重定向到登录页;反之如果已经登录还访问登录页,就重定向到游戏页,避免反复跳转。
router.beforeEach((to) => { const currentUser = localStorage.getItem(CURRENT_USER_KEY) if (to.path === '/game' && !currentUser) { return { path: '/login' } } if (to.path === '/login' && currentUser) { return { path: '/game' } } return true })“记住我”的实现我用了双存储方案:勾选记住我时,用户信息存 localStorage;不勾选时,用户信息存 sessionStorage。sessionStorage 在浏览器会话结束后自动清空,正好符合“临时登录”的语义。这里要小心一点:路由守卫里读取存储时,两个 key 都要检查,不能只读 localStorage,否则不勾选记住我的用户刷新页面后也会因为 sessionStorage 里的登录态被忽略而错误跳回登录页。
还有个小提醒,有些朋友在搜索“反复登录界面”时会碰到操作系统层面的登录循环问题,那是系统会话异常,跟 Web 应用里的登录表单完全是两回事。咱们这里的“登录”只负责管理页面访问权限,不涉及系统账号,排查思路不要混在一起。
5. 常见问题与排查技巧实录
5.1 洗牌后无解:棋盘一点也不像拼图
症状是每次洗牌后,把棋子按正确顺序还原到就差最后一步时,怎么都差一口气,无论怎么移动,最后两块永远反着。这就是典型的不满足拼图可解性条件。
我建议优先使用反向移动洗牌法,直接绕开这个问题。但如果你确实想用随机数组,那你必须在洗牌后做可解性校验,代码如下:
const isSolvable = (arr, size) => { let inversions = 0 const flattened = arr.filter((val) => val !== 0) for (let i = 0; i < flattened.length; i++) { for (let j = i + 1; j < flattened.length; j++) { if (flattened[i] > flattened[j]) inversions++ } } if (size % 2 === 1) { return inversions % 2 === 0 } const blankRowFromBottom = size - Math.floor(arr.indexOf(0) / size) return (blankRowFromBottom % 2 === 0) ? (inversions % 2 === 0) : (inversions % 2 === 1) }奇数阶只要逆序数是偶数即可;偶数阶还要考虑空白格从底部数的行号奇偶。我用反向移动法之后就再也没遇到这个坑,所以建议你直接抄这个方案。
5.2 图片错位、变形与跨域
图片错位的情况分两种。第一种是背景定位算偏了,比如该显示第 1 行第 2 列的内容,结果显示成第 2 行第 2 列。这个问题的根源通常是下标从 1 开始计算。记住我的写法:row = Math.floor(value / size)、col = value % size,value 是拼图块编号,不是格子下标。
第二种是整张图被拉伸变形,原因几乎都是图片不是正方形。解决办法就是我在 3.2 节说的 Canvas 中心裁剪。这个处理流程我建议无论内置图片还是上传图片都走一遍,保证进入游戏状态的图片永远是正方形。
跨域问题主要出现在使用远程图片时。CSS 背景引用远程图片本身不会报跨域错,但如果你在 Canvas 里读取这张图的像素做裁剪或导出 dataURL,就会触发 tainted canvas,直接抛 SecurityError。最简单的规避方案是只使用同域资源,或者让后端给图片地址加上 CORS 响应头。自定义上传的图片是本地文件,走URL.createObjectURL生成的地址属于当前页面源,Canvas 不会被污染,可以放心用。
5.3 计时器叠加与状态不同步
我在开发时碰到过一次很隐蔽的 BUG:连续点了三次“重新游戏”,结果页面上计时跳得飞快,一秒能涨三秒。原因是每次重新游戏都创建一个新的setInterval,但没有清除旧的,多个定时器同时累加。
解决办法有两个要点:第一,在startTimer()开头先clearInterval再setInterval;第二,使用onUnmounted在组件销毁时清理定时器。加上这层保护之后,无论玩家怎么快速点击重新游戏,计时器都只有一份。
状态不同步的问题也经常出在胜利之后。玩家已经弹出了胜利弹窗,但棋盘还允许继续点击,一移动又把状态从 win 改回 playing,导致弹窗关不掉。解决方法是所有移动入口先判断status === 'playing',非 playing 状态直接 return。
5.4 登录卡死与数据异常
登录卡死的常见场景有三种。第一种:点击登录按钮后没有任何反馈,原因是我在前面提到的prop没绑定;第二种:登录成功后跳转到游戏页,但游戏页一刷新又回到登录页,原因是登录成功时只写入了内存变量,没有同步到 localStorage 或 sessionStorage;第三种:localStorage 里的 JSON 格式损坏,JSON.parse直接抛错,导致整个路由守卫失效,页面白屏。
第三种问题最好用防御式写法解决,所有JSON.parse都包一层 try/catch,解析失败就返回默认值。这是一个很小的习惯,但能避免生产环境很多诡异问题。我在项目里专门写了一个safeParse工具函数,所有存储读取都走它,这个建议值得成为你的肌肉记忆。
6. 文件结构整理与实操心得
6.1 一份可以直接上手的文件结构
项目做到最后,我整理成了一个清晰的结构,每个文件职责单一,看名字就能知道是干什么的:
src ├── main.js ├── App.vue ├── router │ └── index.js ├── views │ ├── LoginView.vue │ └── GameView.vue ├── components │ ├── PuzzleBoard.vue │ ├── ImageSelector.vue │ └── ResultDialog.vue ├── composables │ └── usePuzzle.js └── utils ├── storage.js ├── puzzle.js └── image.jsusePuzzle.js承载所有游戏逻辑,包括棋盘状态、洗牌、移动、胜利判断、计时和重新游戏。puzzle.js放可解性判断、相邻格子计算这类纯函数。image.js放图片裁切和预加载逻辑。storage.js放所有 localStorage/sessionStorage 的读写封装。视图层只做数据绑定和用户交互,不掺和逻辑。
6.2 我踩过几次坑之后的几条建议
如果只让我留三条经验,我会说这三条。
第一,先把棋盘逻辑用纯函数写完,再碰 UI。我用 composable 把状态和逻辑抽出来后,发现调试效率高了很多,因为只需要在测试环境里调用moveTile和checkWin,根本不用打开页面。等逻辑全部跑通,再一层层接模板。
第二,洗牌一定要用反向移动法。哪怕你已经写了随机洗牌和逆序数校验,我还是建议换成反向移动。少一条校验分支就少一类边界问题,代码阅读起来也更直观。
第三,图片处理要在游戏开始前完成,不要一边玩一边裁。切换图片后的第一次对局,我遇到过空白格背景闪一下旧图的状况,原因就是新图片还在加载,但棋盘已经开始渲染。解决办法是在switchImage里先await图片预加载完成,再执行startNewGame。
我在实际开发中最容易出错的地方是重新游戏后忘记重置状态,导致玩家虽然赢了,但棋盘状态还是 playing,后续点击全乱套。后来我把所有“新开局”入口都统一收敛到startNewGame(),内部先 reset 再 shuffle,这个 BUG 就再也没出现过。整套做完之后,Vue3 的响应式数据流、组件通信、路由守卫和 Element Plus 表单校验基本都摸熟了,拿它当面试项目或者课程设计,都是性价比很高的选择。