ARTICLE DETAIL

资讯详情

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

用原生JavaScript实现拼图游戏:DOM操作与数组处理的实战指南

用原生JavaScript实现拼图游戏:DOM操作与数组处理的实战指南 前阵子收拾代码仓库翻出一个用原生 JavaScript 写的拼图游戏。这本来是给一个初学前端的朋友做的 demo目的是把 DOM 操作、数组处理、事件绑定、CSS 布局这些零散知识点串起来。结果做完之后我发现它比很多教程项目都适合当教学范例——拼图游戏的状态管理和界面渲染非常直观需求又足够完整天然能拆出数据结构、算法、交互三个层次而且玩家一眼就能看懂规则测试反馈特别快。这篇文章就把从设计到实现的过程完整复盘一遍包括方案选型、核心代码、以及调试过程中踩过的坑新手看完可以自己从零写一个有基础的朋友也可以直接拿去改造成自己的小项目。1. 设计思路拼图游戏到底难在哪1.1 图形化界面的选型谁说小游戏必须用 Canvas拼图游戏最常见的实现方案其实有两种用 Canvas 绘制整块画布或者用 DOM CSS 拼装界面。很多人一提到图形界面小游戏就默认选 Canvas理由是它能逐帧绘制、能做粒子特效、能精确控制画面这个思路在做射击游戏、跑酷游戏时确实对但拼图游戏的情况不太一样。拼图游戏的本质是网格交互玩家点击的是一块块可以识别的区域这些区域天然就是一个个 DOM 元素。用 Canvas 的话你得自己维护每块图片的坐标然后在点击时计算鼠标位置落在哪个格子再手动触发重绘代码量翻一倍不止。而 DOM 方案里每个格子天然就是一个独立的 div点击事件直接绑在 div 上浏览器帮你完成了判断点到哪一块这个最麻烦的步骤这正好是拼图游戏交互的关键。另一个考虑是样式和动画。DOM CSS 可以借助 CSS 的定位、背景图、过渡动画快速实现视觉效果而且浏览器兼容性极好不需要处理 Canvas 高分屏适配、坐标缩放这些问题。拼图游戏最多也就二三十个元素5x5 也就 25 块根本不需要 Canvas 级别的绘制性能。所以这个项目的结论很明确用 DOM 加 CSS 构建图形化界面。这不只是因为简单而是技术选型贴合业务场景——拼图游戏的高频交互是点格子DOM 的事件模型能让代码结构更清晰把精力集中在游戏逻辑本身。1.2 数据结构一张表搞定所有状态拼图游戏要管理的信息其实很少每一格放的是哪一块图片、空格在哪、移动了多少步、用了多长时间。这里最关键的是前面两个。我选的是一个一维数组tiles数组下标代表棋盘位置数组值代表图片碎片编号。比如一个 3x3 的初始状态就是tiles [0, 1, 2, 3, 4, 5, 6, 7, 8];下标 0 的位置放第 0 块图下标 1 的位置放第 1 块图以此类推。如果打乱之后下标 0 的位置变成了第 5 块图那tiles[0] 5。这种写法下完成状态的判断就极其简单只要tiles[i] i全部成立游戏就通关了。空位也用一个变量单独记比如emptyPos size * size - 1默认空位在右下角。这样移动一块拼图本质就是交换空位和相邻格子的数组值同时更新emptyPos。所有状态都收敛在这两个变量里后续逻辑就很好写了。可能有朋友会问为什么不用二维数组二维数组tiles[row][col]确实更符合人脑对棋盘结构的直觉移动判断也直接但对新手来说一维数组 下标换算反而是更好的训练——Math.floor(index / size)得到行号index % size得到列号这是一个非常经典的下标映射思路以后处理图片墙、轮播图、表格数据都用得上。而且一维数组做遍历检查、序列化存储都更省事。1.3 可解性问题不加判断的随机洗牌会坑死玩家第一次做拼图游戏的人最容易踩的坑就是直接对tiles数组做随机打乱然后发现有些局面怎么拼都拼不回去。这不是游戏的 bug而是经典数学问题拼图游戏随机生成的局面有一半是永远无法通过合法移动还原的。原因可以这样理解拼图游戏中每次移动本质上都是把空格和相邻数字交换这种交换会改变整个序列的逆序数即排在前面的数字比排在后面的数字大的对数的奇偶性。在一维展开序列中每移动一次数字逆序数奇偶性就翻转一次。而空格在棋盘上每水平移动一格不改变奇偶性每垂直移动一格也会翻转。综合下来一个局面可解与否是由逆序数奇偶性和空格所在行共同决定的光靠猜是不行的。对于 n x n 的拼图有一个成熟判定公式把棋盘按从左到右、从上到下的顺序展开成一维序列去掉空格记逆序数为inv空格所在行从底部往上数的行数为rowFromBottom。当 n 为奇数时局面可解当且仅当inv为偶数当 n 为偶数时可解当且仅当inv rowFromBottom为偶数。这是理论上的严谨做法代码也不难。但我自己在项目里用了更直接的笨办法从完成状态出发模拟合法移动随机打乱一定次数。既然每一步都是合法移动那最终局面一定存在一套逆操作把它恢复回去天然保证有解。for (let i 0; i stepCount; i) { const movable getMovablePositions(emptyPos); const target movable[Math.floor(Math.random() * movable.length)]; swap(target); }这个方案代码更少、思路更不容易出错而且通过控制步数可以灵活调节打乱程度。要说缺点就是打乱的随机性不如完整 Fisher-Yates 洗牌那么彻底但对普通拼图游戏来说完全够用。4x4 打乱 300 步之后观察到的局面已经足够混乱。2. 界面布局与交互设计2.1 棋盘、控制区与信息栏图形化界面部分我把它拆成了三个视觉区域顶部的控制区、中间的信息栏、底部的棋盘。控制区放了难度下拉框、重新开始按钮、自定义图片按钮。信息栏是步数和计时两个字段。棋盘是游戏的核心区域我用一个相对定位的容器#board每个图块用绝对定位放在容器内位置完全由计算出的top和left决定。棋盘尺寸我做成动态的固定每块拼图的边长TILE_SIZE棋盘总宽高就是TILE_SIZE * size。这样从 3x3 切换到 5x5棋盘会自动缩放不需要写死多个尺寸。布局上有一点值得注意我用一个小技巧让棋盘始终居中且不被挤压给#board设置了margin: 0 auto同时用 flex 布局让整个 App 容器垂直居中在页面上。块与块之间不设 margin而是给棋盘整体加border-radius和overflow: hidden切割出来的每块拼图贴在棋盘内视觉上就像一张完整图片被切碎体验比每块之间有缝隙要好得多。2.2 图片切分background-position 就是一把手术刀拼图最难且最容易出错的地方是把一张完整图片切成 N 块碎片。很多人第一反应是用 Canvas 把图片裁剪成多张小图或者用 JS 动态生成多个img标签这两种都不是最优解。我在项目里用的是background-position定位法每个调色板一样大小的 div背景图都设为同一张完整图片通过调整背景图偏移量让每个 div 只显示图片的某个局部。这样不需要真正切割图片也不产生额外的网络请求改起来特别方便。具体计算公式很简单第 i 块拼图在图片矩阵中的行为Math.floor(i / size)列为i % size那它的背景偏移就是backgroundSize: ${boardSize}px ${boardSize}px; backgroundPosition: -${col * TILE_SIZE}px -${row * TILE_SIZE}px;这里的backgroundSize很关键。如果整张图片原始尺寸是 800x600而棋盘只有 270x270直接设置background-size: 800px 600px会导致背景图只显示左上角一块。所以必须把背景图缩放到容器的大小即棋盘尺寸再靠偏移量取局部。为了不依赖外部图片资源我还在项目里用 Canvas 生成了一张默认背景图。它是一个渐变色底加上几个几何图形function generateDefaultImage() { const canvas document.createElement(canvas); canvas.width 360; canvas.height 360; const ctx canvas.getContext(2d); const gradient ctx.createLinearGradient(0, 0, 360, 360); gradient.addColorStop(0, #667eea); gradient.addColorStop(1, #764ba2); ctx.fillStyle gradient; ctx.fillRect(0, 0, 360, 360); ctx.fillStyle rgba(255,255,255,0.25); ctx.beginPath(); ctx.arc(100, 90, 60, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(280, 250, 45, 0, Math.PI * 2); ctx.fill(); // ...省略其他装饰 imageSrc canvas.toDataURL(image/png); }生成的数据 URL 可以直接作为background-image使用整个项目不需要任何外部图片也能跑出像模像样的画面。这个办法还顺带解决了另一个问题——用户上传的不规则图片我可以先画到 Canvas 上做裁剪再转成 DataURL 使用避免图片拉伸变形。2.3 交互逻辑点击、移动与胜利检测点击交互的逻辑是拼图的灵魂。每次点击一个格子先判断这个格子是否为空格相邻上下左右四个方向只有相邻才允许移动。相邻判断我封装成了getMovablePositions(emptyPos)function getMovablePositions(pos) { const row Math.floor(pos / size); const col pos % size; const result []; if (row 0) result.push(pos - size); if (row size - 1) result.push(pos size); if (col 0) result.push(pos - 1); if (col size - 1) result.push(pos 1); return result; }这段代码看起来很平凡但它把边界判断这种最容易出错的细节一次收敛好了。比如 3x3 中下标 2第一行最右的右侧没有格子col size - 1不成立就不会把 3 误判为相邻位置。移动时交换数组元素然后只更新两个格子的 DOM 状态而不是整个棋盘重新渲染。这是性能上的小优化更重要的是一旦你养成局部变化对应局部更新的习惯以后做任何数据驱动的视图都会更顺手。胜利检测我放在每次移动之后用tiles.every((v, i) v i)一行搞定。检测通过就清掉计时器显示通关信息。这里有个细节因为空位默认在右下角且完成状态是tiles[i] i所以空位必须归位这个条件也自动被包含进去了。3. 核心代码实现与讲解3.1 HTML 骨架与 CSS 样式完整项目不依赖框架直接一个 HTML 文件就能跑。先看骨架!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title原生 JavaScript 拼图游戏/title /head body div idapp h1拼图小游戏/h1 div classcontrols select iddifficulty option value33x3入门/option option value44x4进阶/option option value55x5炼狱/option /select button idrestartBtn重新开始/button label classupload-btn自定义图片 input typefile idimageInput acceptimage/* hidden /label /div div classinfo span步数b idmovesCount0/b/span span时间b idtimerCount0s/b/span /div div idboard/div div idmessage/div /div /body /htmlCSS 部分只要抓住核心布局#app { background: #fff; padding: 24px; border-radius: 12px; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08); } #board { position: relative; margin: 0 auto; background: #e5e7eb; border-radius: 8px; overflow: hidden; border: 2px solid #d1d5db; } .tile { position: absolute; cursor: pointer; user-select: none; transition: none; } .tile.empty { background: #f3f4f6; cursor: default; }transition: none先关掉动画是为了保证基础逻辑可预期。后面对话里我会专门讲怎么把动画打开而不会破坏逻辑。3.2 JavaScript初始化、洗牌、移动、判定完整实现下面这段是游戏的核心逻辑我按函数逐个讲清楚。const board document.getElementById(board); const difficulty document.getElementById(difficulty); const restartBtn document.getElementById(restartBtn); const imageInput document.getElementById(imageInput); const movesCount document.getElementById(movesCount); const timerCount document.getElementById(timerCount); const message document.getElementById(message); const TILE_SIZE 90; const SHUFFLE_STEPS 300; let size 3; let boardSize TILE_SIZE * size; let tiles []; let emptyPos 0; let moves 0; let seconds 0; let timer null; let imageSrc ;初始化状态function initState() { tiles []; for (let i 0; i size * size; i) { tiles.push(i); } emptyPos size * size - 1; moves 0; seconds 0; clearInterval(timer); timer null; message.textContent ; }这里的clearInterval(timer)放在timer null时调用也不会有问题——浏览器对clearInterval(null)是静默忽略的所以可以放心写。创建棋盘 DOMfunction createBoard() { board.innerHTML ; board.style.width boardSize px; board.style.height boardSize px; for (let i 0; i size * size; i) { const tile document.createElement(div); tile.className tile; tile.style.width TILE_SIZE px; tile.style.height TILE_SIZE px; tile.addEventListener(click, () handleClick(i)); board.appendChild(tile); } renderBoard(); }闭包里捕获的i就是棋盘下标在创建时每个格子跟自己的位置绑定死了。注意board.innerHTML 清空再创建是可行的因为这个游戏规模小创建 DOM 的耗时可以忽略。按当前tiles数组刷新格子显示function renderBoard() { for (let i 0; i tiles.length; i) { updateTileAt(i); } } function updateTileAt(pos) { const tile board.children[pos]; const tileId tiles[pos]; if (tileId size * size - 1) { tile.style.backgroundImage none; tile.classList.add(empty); } else { tile.classList.remove(empty); const row Math.floor(tileId / size); const col tileId % size; tile.style.backgroundImage url(${imageSrc}); tile.style.backgroundSize ${boardSize}px ${boardSize}px; tile.style.backgroundPosition -${col * TILE_SIZE}px -${row * TILE_SIZE}px; } }洗牌函数从完成状态出发模拟合法移动function shuffleTiles(steps) { for (let i 0; i steps; i) { const movable getMovablePositions(emptyPos); const target movable[Math.floor(Math.random() * movable.length)]; [tiles[emptyPos], tiles[target]] [tiles[target], tiles[emptyPos]]; emptyPos target; } }[tiles[a], tiles[b]] [tiles[b], tiles[a]]是 ES6 的解构赋值交换比传统的temp变量法清爽很多也是我推荐新手逐步掌握的一个语法点。点击处理function handleClick(index) { const movable getMovablePositions(emptyPos); if (!movable.includes(index)) return; [tiles[emptyPos], tiles[index]] [tiles[index], tiles[emptyPos]]; updateTileAt(emptyPos); updateTileAt(index); emptyPos index; moves; movesCount.textContent moves; checkWin(); }胜利检测function checkWin() { const solved tiles.every((tileId, idx) tileId idx); if (solved) { clearInterval(timer); message.textContent 通关啦你用了 ${moves} 步耗时 ${seconds} 秒。; } }开始新游戏function startGame() { size parseInt(difficulty.value, 10); boardSize TILE_SIZE * size; initState(); shuffleTiles(SHUFFLE_STEPS); createBoard(); movesCount.textContent 0; timerCount.textContent 0s; timer setInterval(() { seconds; timerCount.textContent seconds s; }, 1000); } restartBtn.addEventListener(click, startGame); difficulty.addEventListener(change, startGame);注意difficulty.addEventListener(change, startGame)这里在切换难度时也会调startGame不需要额外写一套换图重开的逻辑。图片上传的逻辑imageInput.addEventListener(change, (e) { const file e.target.files[0]; if (!file) return; const reader new FileReader(); reader.onload (ev) { const img new Image(); img.onload () { const canvas document.createElement(canvas); const sizeSide Math.min(img.width, img.height); canvas.width 360; canvas.height 360; const ctx canvas.getContext(2d); ctx.drawImage( img, (img.width - sizeSide) / 2, (img.height - sizeSide) / 2, sizeSide, sizeSide, 0, 0, 360, 360 ); imageSrc canvas.toDataURL(image/jpeg, 0.9); startGame(); }; img.src ev.target.result; }; reader.readAsDataURL(file); });这里用ctx.drawImage做了图片的居中裁剪并且强制压缩到 360x360处理长图才不会变形内存占用也更小。3.3 进阶如何让拼图块滑动起来基础版本里格子是原地换图的玩家点击后图片瞬间切换没有过渡。要做得像真实拼图一样有滑动感核心思路是让 DOM 元素跟随图块编号移动而不是固定在棋盘位置上。具体做法维护一个映射表tileEls[tileId]每个图块 DOM 一旦创建就不改变它内部的背景样式只改变它的top和left。移动时把相邻图块 DOM 的位置切到空位上由于 DOM 一直在文档里且设置了transition: top 0.15s ease, left 0.15s它会自动滑过去。// 创建时不render只绑定背景 function createTileElement(tileId) { const el document.createElement(div); el.className tile; el.style.width TILE_SIZE px; el.style.height TILE_SIZE px; const row Math.floor(tileId / size); const col tileId % size; el.style.backgroundImage url(${imageSrc}); el.style.backgroundSize ${boardSize}px ${boardSize}px; el.style.backgroundPosition -${col * TILE_SIZE}px -${row * TILE_SIZE}px; el.addEventListener(click, () handleClick(tileId)); return el; }这里的事件绑定也从绑定棋盘位置变成绑定图块编号因为点击的目标是图块而不是位置。移动时更新定位function placeTile(el, pos) { const row Math.floor(pos / size); const col pos % size; el.style.top row * TILE_SIZE px; el.style.left col * TILE_SIZE px; }这套方案的视觉效果立刻上一个档次同时代码复杂度也明显提升——图块的身份和位置分离了必须在心里时刻分清。我的建议是先把基础版跑通再考虑加动画如果一开始就用动画方案会被定位和渲染的逻辑绕晕排查问题时不好定位。4. 开发中常见问题与避坑实录4.1 background-position 偏移搞反的经典问题我在第一次实现时曾经把背景偏移写成正值结果每块拼图显示的图像区域完全错乱整个棋盘像被打翻的调色盘。后来意识到background-position的值表示背景图像相对于容器的偏移方向要让图片右移一格内容背景图本身得向左移动所以偏移必须是负数。正确的记忆方式是你要显示图片的哪一块区域就把背景图往相反方向拉多少距离。显示右侧的内容背景图左移显示下方的内容背景图上移。再配合background-size等于棋盘总尺寸就能保证偏移量计算一致。另外一个小坑如果你改了TILE_SIZE却没有同步改backgroundSize会出现图对不上号的错乱。建议把这两个参数定义成同一个变量衍生出来的值避免手改一处忘了另一处。4.2 无解局面的两种解法对比无解的问题我前面已经详细解释过原理这里说说两个方案的取舍。方案一模拟合法移动打乱。优点代码简单绝对有解逻辑不容易出 bug缺点如果打乱步数设置太少局面会不够乱玩家两三步就能拼回去失去挑战性。我的经验是步数至少设置成size * size * 303x3 就是 270 步左右4x4 需要 480 步以上。方案二随机洗牌后加判定。优点随机性最均匀缺点你要完整理解逆序数理论代码写完也得认真测试边界情况比如 2x2、4x4、5x5 的奇偶规则不同。我的建议是如果是学习项目或普通产品需求选方案一如果你正好想深入钻研算法题、或者想把这部分扩展到其他需要约束随机的场景那方案二的逆序数实现值得认真写一遍它也是一个很好的面试题库素材。4.3 计时器和步数统计的边界条件计时器有个容易忽略的边界玩家反复点击重新开始时如果上一轮setInterval没有被清理就会同时跑多个计时器秒钟跳得不正常的快。所以我在initState()里每次都会clearInterval(timer)同时把timer重新赋值。另一个边界是步数统计的起点。有玩家会在开始前乱点空白位置的格子没有点击事件但其实空位相邻的格子仍然可以移动。合理的设计是让startGame()创建棋盘那刻起就允许操作但如果觉得开始前的点击不该算步数可以在handleClick里加一个if (!gameStarted) return的开关。我自己的做法是把游戏是否开始隐含在timer null里用计时器状态统一管理。4.4 移动端触控优化如果你把这个项目放到手机上跑会发现点击有大约 300ms 的延迟这是移动端为了区分单击与双击所保留的等待时间。好在现代浏览器对设置了viewport的页面已经做了优化第一步确保页面里有meta nameviewport contentwidthdevice-width, initial-scale1.0基本就能消除延迟。要更彻底的话可以在touchstart事件里处理点击或者引入 Pointer Events。但对于拼图这种低频点击、不追求毫秒级响应的游戏我认为不需要特别处理。倒是有一点更值得注意手机上手指会挡住格子建议把TILE_SIZE调大到 75px 以上避免误触相邻格子。4.5 自定义图片会遇到的两个实际问题上传图片有两个常见问题。第一是图片比例不接近正方形直接平铺会导致拼图画面对不上。我的处理是通过 Canvas 居中裁剪成正方形再用drawImage压缩到固定尺寸代码在前面已经给出。第二是图片太大比如手机拍出来四五 MB直接塞进background-image会很卡。压缩两步走先用 Canvas 把图缩到 360x360再转成 JPEG DataURL 并传入0.9的质量参数这样单张图片的体积能控制在几十 KB 级别移动端也能流畅运行。我在实际使用中发现用 Canvas 数据作为图源还有个隐藏好处图片始终存在不存在加载失败、跨域污染、缓存过期这些问题这让整个项目的调试体验非常稳定排错时可以完全排除图片资源层的干扰。最后再分享一个小技巧。基础版完成后如果你想快速感受不同难度的手感调参别去反复点下拉框直接把size改成 4 跑一次感受 4x4 和 3x3 在思维复杂度上的差异你就能理解为什么拼图游戏的难度曲线设计这么讲究——有时候不是步数变多而是下一步该怎么下手的信息量成倍增加了。这种体感会反向帮你优化代码的可玩性比如在 5x5 关卡自动把SHUFFLE_STEPS提高到 600让混乱度匹配难度等级。
返回列表