尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

用原生JavaScript打造2048小游戏:从零实现核心算法与交互

用原生JavaScript打造2048小游戏:从零实现核心算法与交互 简介这是一款基于纯JavaScript开发的网页版2048小游戏完整源码包面向初级Web工程师及前端初学者旨在通过一个无需外部框架的实战项目帮助理解JavaScript核心机制、DOM操作、事件驱动编程和游戏状态管理等知识点。压缩包共3个文件整体约830KB内含1个JS脚本、1张图片素材和1份docx说明文档涵盖游戏逻辑、界面元素和设计说明便于对照学习。目前已有409人学习/下载。通过这份源码读者可获得可直接运行的游戏代码并借助文档梳理从棋盘二维数组操作、数字合并算法、随机生成逻辑到得分与胜负判断的完整实现过程同时还能接触到错误处理、调试和基础性能优化思路。对于想独立完成前端小游戏项目、夯实JavaScript功底的初学者而言是一份结构清晰、实用性强的练手素材。 很多初级web工程师问我同一个问题学了HTML、CSS、JS基础语法之后做什么项目练手最合适我的答案一直是——做一个网页版2048小游戏。这个选择很功利也很务实2048的规则人人都懂核心逻辑足够挑战但不至于劝退纯JS就能搞定不需要后端配合而且做完之后你会真切地发现“原来JS真的能让页面动起来”。这次我把我做2048的完整思路、代码结构、以及开发中踩过的坑全部整理出来代码和图片资源我会在文章里拆解到可以直接照着实现的程度。这套东西不需要框架、不需要构建工具、不需要Node环境一个编辑器加浏览器就够了。1. 整体设计与思路拆解1.1 为什么2048是初级工程师的绝佳练手项目2048这个游戏的精髓不在于它多有趣而在于它的“复杂度刚刚好”。你仔细想想一个完整的2048需要什么4x4的棋盘布局、方块的生成与移动、相同数字的检测与合并、分数的累计、游戏结束的判定、以及移动端的滑动支持。拆解下来每一个功能点都是前端开发的高频场景。对初级工程师来说这个项目最大的价值是“强制你去思考数据结构”。2048虽然只有16个格子但如何用JS表示棋盘的当前状态如何在不破坏原数据的前提下完成移动合并这些都会逼着你把数组的map、filter、reduce用熟。我刚带徒弟做这个项目时他用了几个嵌套的switch去处理四个方向的移动逻辑写得极其痛苦——后来我给他展示了“方向翻转统一左移”的思路后他整个人都通了。1.2 项目技术选型为什么坚持纯JS在React、Vue遍地走的时代我依然建议初级工程师用原生JS写2048不是因为我守旧而是因为框架会把核心逻辑的思考过程给掩盖掉。你用Vue写2048你思考的是“数据变了视图怎么自动更新”你用原生JS写你思考的是“数据变了之后我该怎么高效地同步视图”。后者的思考深度完全不一样。而且纯JS部署极其方便。你写完之后把html、css、js三个文件扔到任意一台服务器的静态目录里或者直接用GitHub Pages托管人人都能玩。不需要npm install不需要build不需要担心依赖版本冲突。对于想要快速获得成就感的新手来说没有比这更爽的体验了。具体的技术栈就三样HTML负责页面结构、CSS负责视觉表现、原生JavaScript负责全部游戏逻辑。图片方面我当初是直接用CSS绘制数字方块但如果你想要更好的视觉效果可以用CSS Sprite或者简单地在每个格子上贴数字图片。为了让方案更通用我会同时给出CSS方案和图片方案的做法。2. 核心功能模块实现2.1 棋盘数据结构的设计游戏的核心是4x4的16格棋盘。怎么在JS里表示这个棋盘?最直觉的方式是二维数组const SIZE 4; let board Array.from({ length: SIZE }, () Array(SIZE).fill(0));这样board[0][0]就表示第一行第一列的值0表示空格子2、4、8等数字表示已有方块。选择二维数组而非数组平铺一维数组每4个一段的唯一原因就是“可读性”。初级工程师在写游戏逻辑时board[row][col]这种坐标访问方式远比board[row * 4 col]直观得多。棋盘初始化时需要随机生成两个初始方块。这个逻辑分成两步先找出所有值为0的空格子坐标然后随机选两个坐标分别填入2或4。function getEmptyCells() { const cells []; for (let r 0; r SIZE; r) { for (let c 0; c SIZE; c) { if (board[r][c] 0) cells.push({ r, c }); } } return cells; } function addRandomTile() { const empty getEmptyCells(); if (empty.length 0) return false; const { r, c } empty[Math.floor(Math.random() * empty.length)]; board[r][c] Math.random() 0.9 ? 2 : 4; return true; }这里有个细节值得注意初始方块的赋值概率90%是210%是4。这不是随意定的而是参考了原版游戏的行为目的是让棋盘的起步更平滑如果初始大部分是4很快会引发“填不满”的死局。2.2 移动与合并算法把二维问题变成一维移动是2048最核心的逻辑。很多新手会卡在这里因为四个方向的移动规则对行列数组的遍历方式完全不同。我用了“旋转法”巧妙解决了这个问题把四个方向的移动统一转换成“向左移动”。思路分成三步先写一个moveLeft函数处理一行一维数组的合并将棋盘进行旋转顺时针或逆时针90度然后对每一行调用moveLeft旋转回来棋盘就是完成移动后的状态function slideRow(row) { // 去掉所有0把非零数字紧凑排列 let arr row.filter(v v ! 0); // 从左侧开始合并相邻相同数字 for (let i 0; i arr.length - 1; i) { if (arr[i] ! 0 arr[i] arr[i 1]) { arr[i] * 2; score arr[i]; arr.splice(i 1, 1); } } // 补0到长度4 while (arr.length SIZE) arr.push(0); return arr; } function move(direction) { const original board.map(row [...row]); if (direction left) { board board.map(row slideRow(row)); } else if (direction right) { board board.map(row slideRow(row.reverse()).reverse()); } else if (direction up) { board rotateClockwise(board); board board.map(row slideRow(row)); board rotateCounterClockwise(board); } else if (direction down) { board rotateCounterClockwise(board); board board.map(row slideRow(row)); board rotateClockwise(board); } // 检查棋盘是否发生了变化 const changed JSON.stringify(board) ! JSON.stringify(original); if (changed) { addRandomTile(); render(); } }这里有几个值得推敲的点为什么不直接遍历列数组因为这样每个方向要写一套不同的循环逻辑代码量会翻倍且极易出bug。旋转法把问题“降维”了所有方向都复用同一个slideRow。slideRow里的for循环为什么在splice之后不i因为splice删除了一个元素后数组后面的元素会前移此时当前索引i对应的已经是下一个元素如果不i--就会跳过本该被检查的合并机会。在这个实现里我在i之前不递增因此代码只对相邻已合并的元素继续检查这就是对“连续相同数字只合并一次”规则的正确实现。实际开发中这个合并规则还有一个经典的反例[4, 4, 4, 4]如果从左往右遍历应该合并成[8, 8, 0, 0]而不是[16, 0, 0, 0]。上面的代码可以正确实现这个逻辑。2.3 游戏结束与胜利判定游戏结束的条件是棋盘满了且任意相邻格子上下左右没有相同数字。其实还可以简化棋盘满了之后检查每一行每一列是否存在相邻相同数字只要有一处相同就还有移动的可能。function isGameOver() { if (getEmptyCells().length 0) return false; for (let r 0; r SIZE; r) { for (let c 0; c SIZE; c) { const v board[r][c]; if (c 1 SIZE v board[r][c 1]) return false; if (r 1 SIZE v board[r 1][c]) return false; } } return true; }胜利判定则简单得多遍历整个棋盘只要有一格大于等于2048就弹出胜利提示。注意这里有一个体验细节当玩家合成2048时不要立即阻止游戏继续应该给玩家一个“继续挑战更大数字”的选择所以胜利弹窗一般做成可关闭的。3. 界面渲染与交互实现3.1 用JS动态渲染棋盘还是直接用DOM操作纯JS项目里渲染方式基本就两种选择一次性把16个格子生成好之后每次状态变化只更新对应格子的内容和class或者用模板字符串拼接整块HTML更新。我推荐第一种因为第二种会导致每次更新时整个棋盘重新绘制画面闪烁感很强体验很糟糕。const gridContainer document.getElementById(grid-container); function render() { for (let r 0; r SIZE; r) { for (let c 0; c SIZE; c) { const cell gridContainer.children[r * SIZE c]; const value board[r][c]; cell.textContent value 0 ? : value; cell.className cell cell-${value}; } } document.getElementById(score).textContent score; }这里cell-${value}会形如cell-2、cell-4、cell-128每个class在CSS里定义了对应的背景色和文字颜色。这个做法的好处是视觉风格与数据完全解耦以后你想调整配色只改CSS即可。3.2 CSS让方块拥有质感2048让人感觉“精致”的核心视觉要素有两点背景色渐变和方块缩放动画。背景色我直接按数字大小设置.cell-2 { background-color: #eee4da; color: #776e65; } .cell-4 { background-color: #ede0c8; color: #776e65; } .cell-8 { background-color: #f2b179; color: #f9f6f2; } .cell-16 { background-color: #f59563; color: #f9f6f2; } .cell-32 { background-color: #f67c5f; color: #f9f6f2; } .cell-64 { background-color: #f65e3b; color: #f9f6f2; } .cell-128 { background-color: #edcf72; color: #f9f6f2; } .cell-256 { background-color: #edcc61; color: #f9f6f2; } .cell-512 { background-color: #edc850; color: #f9f6f2; } .cell-1024 { background-color: #edc53f; color: #f9f6f2; } .cell-2048 { background-color: #edc22e; color: #f9f6f2; }这些色值取自原版2048的经典配色视觉上非常协调看起来就像把原版复刻了一样。如果你打算用图片方案也很简单在cell里放一个img元素根据数字动态设置src图片资源就是10张不同背景的数字图命名如tile-2.png、tile-4.png。3.3 键盘与触摸事件的接入键盘事件是PC端的核心交互document.addEventListener(keydown, (e) { const keyMap { ArrowLeft: left, ArrowRight: right, ArrowUp: up, ArrowDown: down }; const direction keyMap[e.key]; if (direction) { e.preventDefault(); // 阻止方向键滚动页面 move(direction); } });这里面最容易忽略的是e.preventDefault()。如果不阻止默认行为按方向键时页面会跟着滚动非常影响体验。移动端的话我封装了一个简单的触摸方向识别let startX, startY; document.addEventListener(touchstart, (e) { startX e.touches[0].clientX; startY e.touches[0].clientY; }); document.addEventListener(touchend, (e) { const dx e.changedTouches[0].clientX - startX; const dy e.changedTouches[0].clientY - startY; const absX Math.abs(dx); const absY Math.abs(dy); if (Math.max(absX, absY) 30) return; // 忽略轻触 if (absX absY) { move(dx 0 ? right : left); } else { move(dy 0 ? down : up); } });30像素的阈值是防止用户点按时误触这个数值是实测比较舒服的范围。4. 完整代码结构与资源文件组织4.1 文件目录设计为了让工程保持清晰哪怕是一个小游戏我也习惯把文件按职责分开2048/ ├── index.html ├── css/ │ └── style.css ├── js/ │ └── game.js └── img/ ├── tile-2.png ├── tile-4.png ├── tile-8.png ├── tile-16.png ├── tile-32.png ├── tile-64.png ├── tile-128.png ├── tile-256.png ├── tile-512.png ├── tile-1024.png └── tile-2048.png如果你的需求是“内含完整代码以及图片”把文件目录整理成上面这个样子就可以直接打包交付。这里我建议图片方案用PNG格式因为2048的方块都是纯色加圆角PNG体积小且支持透明。如果你手头没有图片素材可以直接用我的CSS方案视觉效果其实也相当不错。4.2 index.html核心骨架!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0, user-scalableno title2048小游戏/title link relstylesheet hrefcss/style.css /head body div classcontainer div classheader h12048/h1 div classscore-box span分数/span span idscore0/span /div /div div idgrid-container classgrid-container/div button idrestart-btn重新开始/button /div script srcjs/game.js/script /body /html注意viewport里设置了user-scalableno这个细节是为了防止移动端玩的时候误触发页面缩放。4.3 CSS核心样式棋盘的样式是整个游戏视觉的基石。我用的是经典的“内边距绝对定位格子”方案.grid-container { position: relative; width: 400px; height: 400px; background-color: #bbada0; border-radius: 8px; display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; padding: 10px; box-sizing: border-box; } .cell { display: flex; align-items: center; justify-content: center; font-size: 36px; font-weight: bold; border-radius: 4px; background-color: rgba(238, 228, 218, 0.35); user-select: none; }用CSS Grid来布局4x4格子非常省力gap配合padding天然形成缝隙看起来和原版风格别无二致。user-select: none用来防止方块文字在快速点击时被选中这个细节非常影响体验。4.4 完整的game.js逻辑我把整个游戏逻辑整理成一个完整的Game对象并加入重新开始、最高分记忆等功能。最高分用localStorage存储页面刷新也不会丢失。const Game2048 { SIZE: 4, board: [], score: 0, best: 0, gameOver: false, init() { this.SIZE 4; this.board Array.from({ length: this.SIZE }, () Array(this.SIZE).fill(0)); this.score 0; this.gameOver false; this.best Number(localStorage.getItem(2048_best)) || 0; this.createGrid(); this.addRandomTile(); this.addRandomTile(); this.render(); this.updateBest(); }, createGrid() { const container document.getElementById(grid-container); container.innerHTML ; for (let i 0; i this.SIZE * this.SIZE; i) { const div document.createElement(div); div.className cell; container.appendChild(div); } }, getEmptyCells() { const cells []; for (let r 0; r this.SIZE; r) { for (let c 0; c this.SIZE; c) { if (this.board[r][c] 0) cells.push({ r, c }); } } return cells; }, addRandomTile() { const empty this.getEmptyCells(); if (!empty.length) return false; const { r, c } empty[Math.floor(Math.random() * empty.length)]; this.board[r][c] Math.random() 0.9 ? 2 : 4; return true; }, slideRow(row) { let arr row.filter(v v ! 0); for (let i 0; i arr.length - 1; i) { if (arr[i] arr[i 1]) { arr[i] * 2; this.score arr[i]; arr.splice(i 1, 1); } } while (arr.length this.SIZE) arr.push(0); return arr; }, move(direction) { if (this.gameOver) return; const original JSON.stringify(this.board); const SIZE this.SIZE; let b this.board; if (direction left) { this.board b.map(row this.slideRow([...row])); } else if (direction right) { this.board b.map(row this.slideRow([...row].reverse()).reverse()); } else if (direction up) { this.board this.rotateClockwise(b); this.board this.board.map(row this.slideRow([...row])); this.board this.rotateCounterClockwise(this.board); } else if (direction down) { this.board this.rotateCounterClockwise(b); this.board this.board.map(row this.slideRow([...row])); this.board this.rotateClockwise(this.board); } if (JSON.stringify(this.board) ! original) { this.addRandomTile(); this.render(); this.updateBest(); if (this.isGameOver()) { this.gameOver true; setTimeout(() alert(游戏结束得分 this.score), 10); } } }, rotateClockwise(b) { const SIZE b.length; const res Array.from({ length: SIZE }, () Array(SIZE).fill(0)); for (let r 0; r SIZE; r) { for (let c 0; c SIZE; c) { res[c][SIZE - 1 - r] b[r][c]; } } return res; }, rotateCounterClockwise(b) { const SIZE b.length; const res Array.from({ length: SIZE }, () Array(SIZE).fill(0)); for (let r 0; r SIZE; r) { for (let c 0; c SIZE; c) { res[SIZE - 1 - c][r] b[r][c]; } } return res; }, isGameOver() { if (this.getEmptyCells().length 0) return false; for (let r 0; r this.SIZE; r) { for (let c 0; c this.SIZE; c) { const v this.board[r][c]; if (c 1 this.SIZE v this.board[r][c 1]) return false; if (r 1 this.SIZE v this.board[r 1][c]) return false; } } return true; }, render() { const container document.getElementById(grid-container); const cells container.children; for (let r 0; r this.SIZE; r) { for (let c 0; c this.SIZE; c) { const value this.board[r][c]; const idx r * this.SIZE c; cells[idx].textContent value 0 ? : value; cells[idx].className cell (value ? cell-${value} : ); } } document.getElementById(score).textContent this.score; }, updateBest() { if (this.score this.best) { this.best this.score; localStorage.setItem(2048_best, this.best); } const bestEl document.getElementById(best); if (bestEl) bestEl.textContent this.best; } };这段代码改造自前面分散的示例把状态封装进了对象里避免使用全局变量污染。注意我在move里用JSON.stringify前后对比来判断棋盘是否有变化这是一种偷懒但绝对可靠的检测方式。性能上4x4的小棋盘完全不用操心但如果未来拓展到10x10以上可以改成遍历时用一个changed标志。4.5 图片资源的使用方案如果你想换用图片方案渲染部分稍微改动一下render() { const container document.getElementById(grid-container); const cells container.children; for (let r 0; r this.SIZE; r) { for (let c 0; c this.SIZE; c) { const value this.board[r][c]; const idx r * this.SIZE c; const cell cells[idx]; if (value 0) { cell.innerHTML ; cell.className cell; } else { cell.innerHTML img srcimg/tile-${value}.png alt${value}; cell.className cell cell-has-tile; } } } }图片方案的优点是视觉更精致缺点是需要维护多张图片素材。如果你是用CSS方案那就省去图片目录直接靠背景色撑起整个视觉。两种方案我都测过生产环境里更推荐CSS方案——2048的方块样式完全可以用CSS绘制为什么不减少一次HTTP请求呢5. 常见问题与调试技巧实录5.1 方块不移动或合并错误这是一个特别容易踩的坑。先说最常见的错误slideRow里如果直接修改原数组比如arr row.filter(...)变量引用外部没有同步更新导致渲染时棋盘状态没变。遇到这种问题建议先在move函数里加一行console.log(board)看移动前后数据有没有变化先确认“数据层”没问题再排查“渲染层”。另外一个高发问题是没有在React式思维下考虑“不可变数据”。比如你写了let arr row然后直接修改arr实际上就是在修改原数组这在很多算法逻辑里会导致连锁bug。稳妥的做法是任何需要修改数组前都用[...row]浅拷贝一层我上面的move实现里就是这么处理的。5.2 合并次数和连消问题这是我见过最多人问的“一行里有四个相同的数字为什么合并后结果不对”2048的规则是从移动方向的最远端开始合并每行或每列每个数字每轮移动只能参与一次合并。以[2, 2, 2, 2]为例向左移动应该得到[4, 4, 0, 0]而不是[8, 0, 0, 0]。很多新手在实现时最容易写错的是用嵌套循环从头到尾找相同数合并导致第一个2和第二个2合并成4之后这个4又和下一个4合并成8。我在上面的slideRow里只做一次遍历每次合并后不立即继续检查后续重新形成的新值而是继续处理后面的元素就从逻辑上规避了“连消”问题。5.3 方向键按下时页面跟着滚动这个问题在Windows和部分笔记本上尤为明显。解决办法有两层第一层在keydown监听里调用e.preventDefault()第二层在CSS里给body设置overflow: hidden做兜底。我习惯两种都写上双保险。5.4 localStorage在隐私模式下的兼容问题如果你开了浏览器的无痕模式localStorage.setItem有可能会抛异常导致整个游戏崩溃。保险的做法是做一层try-catchsaveBest(score) { try { localStorage.setItem(2048_best, score); } catch (e) { console.warn(存储失败最高分只在本次会话内有效); } }5.5 关于性能为什么我的游戏滑动起来很卡2048的DOM操作非常轻量理论上不会卡。如果你觉得卡绝大多数情况是每次render时你用了container.innerHTML 再重建所有格子。这种全量重建方式在每秒多次触发时会带来明显的重排成本。我的实现里createGrid只在初始化时调用一次之后render只更新格子的文本和class不重建DOM结构这才是正确的做法。6. 进一步优化动画效果与AI扩展6.1 加入平滑移动和弹出动画当合并或移动发生时方块直接瞬间跳转虽然逻辑正确但视觉上非常生硬。要做出原版那种丝滑的滑动效果可以给格子加上CSS transition.cell { transition: transform 0.1s ease-in-out; }不过由于我的实现里格子是静态网格格子间的移动会产生“瞬移”效果要精确模拟滑动动画需要记录每个方块的前后位置再通过absolute定位和transform过渡实现。这是一个更复杂的方案初级工程师可以留作第二阶段优化。更简单的方案是给cell-2到cell-2048准备一张背景图然后加一个“出现”动画keyframes pop { 0% { transform: scale(0); } 100% { transform: scale(1); } } .cell-new { animation: pop 0.2s ease-out; }在addRandomTile时专门标记新格子的位置然后在下一次render时给它加cell-new类就能模拟出新方块弹出来的效果。这个效果虽小但会让游戏显得“有生命力”。6.2 封装一个简化版AIPython或JS都行做完基础的2048后我发现网上有非常多关于2048 AI的讨论。2048 AI并不难常见的是“期望极大极小算法Expectimax”或者更简单一点每一步都尝试上、下、左、右四个方向评估移动后棋盘的“权重分”然后选分最高的方向。我用JS写过一版简单的AI核心就两个评估因素空格数量越多分越高棋盘上数字的“平滑度”越高分越高。初级工程师暂时不必深入算法但知道这个扩展方向能让你对游戏有更深的理解。6.3 部署上线让朋友能直接玩到写完之后想让朋友也能玩到最简单的做法是上传到GitHub仓库然后开启GitHub Pages把仓库根目录暴露为静态页面。GitHub Pages对纯静态前端项目是免费的不需要服务器、不需要备案整个过程十分钟就能搞定。发布后你会得到一个类似https://你的用户名.github.io/2048/的链接点开就能玩非常方便。7. 踩坑总结与开发心得这个项目我从改第一版到最终版踩了不少坑有两条心得印象最深。第一条写任何逻辑之前先把数据流理清楚。我一开始直接上手写move函数每个方向单独处理结果写完左移动后测着没问题写完右移动后发现偶发合并结果不对。后来改成“旋转统一左移”之后所有方向共用同一个核心函数逻辑统一了bug自然就消失了。写游戏逻辑、甚至写业务代码都是这个道理。第二条善用浏览器的开发者工具。我调试2048时几乎把屏幕上所有格子都点了一遍后来发现一个更高效的办法在move函数里加一行console.table(board)然后在控制台里直接输入Game2048.board[0][0] 1024来强行改数据测试高分格子的渲染效果。学会了这一招测试效率翻倍。初级工程师一定要记住一个观念代码里加console.log不是为了看热闹而是为了确认“每一步的状态是否和自己预期一致”这个习惯能帮你省下大量定位bug的时间。2048这个项目做完你基本就走通了一个前端小项目从设计、编码、调试到发布的全流程。后面你想进阶可以试试在这个基础上增加撤销功能、游戏步数统计、主题切换或者干脆用同样的思路去复刻一个扫雷或俄罗斯方块。前端这条路练就完了。本文还有配套的精品资源点击获取
返回列表