十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

纯JavaScript实现2048小游戏:从二维数组到核心算法详解

纯JavaScript实现2048小游戏:从二维数组到核心算法详解 简介这份网页版2048小游戏源码包采用纯JavaScript开发不依赖任何外部框架是初级Web工程师巩固前端基础、理解交互逻辑的合适练手项目。压缩包共3个文件包含游戏主逻辑js文件、gameover游戏结束图片和docx说明文档整体约830KB轻量完整。当前已有409人学习下载。源码可运行适合课程设计或面试作品配套说明文档按HTML结构、CSS样式、JavaScript语法、DOM操作、二维数组处理、事件驱动与状态管理等模块展开系统梳理了方块移动、相同数字合并、得分刷新、随机项生成等核心机制。借助完整代码和拆解笔记读者不仅能看会逻辑还能动手调试和二次扩展快速掌握前端项目中的排错思路与基础性能优化技巧。1. 项目概述与成果展示1.1 一个不上不下的练手项目为什么偏偏选2048先不聊代码说个现象很多初级web工程师的简历上写着“熟悉JavaScript”但真到面试环节一让手写个稍微完整点的交互逻辑就开始支支吾吾。刷题刷了三个月不如踏踏实实做个小项目来得实在。而在所有适合练手的项目中2048这个游戏几乎是为前端新人量身定做的——玩法简单到不用解释但实现起来又刚好能覆盖二维数组操作、事件监听、DOM操作、动画过渡这些前端核心基本功。这个项目我当初带团队新人时用了很多次纯JavaScript实现不依赖任何框架配完整HTML结构和CSS样式附带游戏所需的图片资源。整份代码压缩下来不到10KB但在浏览器里跑起来体验相当流畅配色也是照搬原版的柔和风格。初级web工程师拿来练手火候刚刚好太简单的计算器、待办列表已经练不出东西了太复杂的框架项目又容易让人直接放弃2048这个难度卡在中间是最舒服的舒适区边缘。1.2 项目最终效果与功能清单这个版本做出来的效果和你在各种网页上玩到的2048没有本质区别4x4的方格棋盘每次按方向键所有数字方块朝对应方向移动相同数字碰到一起就合并成它们的和。每次有效移动后在空白位置随机生成一个新的数字方块90%概率是210%概率是4。当棋盘被填满且没有可合并的相邻方块时游戏结束当出现2048这个方块时玩家获胜。功能清单如下4x4棋盘渲染支持新方块生成动画键盘方向键与WASD双控制方案移动合并判定含相同数字连续合并处理实时分数累计与历史最高分记录localStorage持久化游戏结束与胜利的双条件判定重新开始、撤回上一步等附加功能看起来功能不算复杂但真正动手去做里面有几个地方的逻辑还是需要好好琢磨琢磨的。接下来我按实际开发的顺序把每一个环节掰开揉碎了讲。2. 整体设计与数据结构选型2.1 用二维数组模拟棋盘是唯一靠谱的选择棋盘是4x4的方格最直觉的做法是建一个二维数组grid[4][4]每个元素存一个数字0表示空格。为什么不用一维数组加索引映射也能做但每次判断相邻位置时都要做除法和取余代码可读性会差很多。二维数组在语义上最贴近棋盘本身后续做遍历、查找、渲染都直白。这里有个常见误区有人喜欢直接用DOM元素的位置来推断游戏状态比如遍历每个格子的文本内容来判断能否合并。这种做法在游戏逻辑简单时勉强能用但一旦涉及“移动后哪些格子发生了变化”这类回溯问题就会非常痛苦。游戏状态必须和界面渲染分离数据和视图解耦这是前端开发里最重要的一条准则2048这个小项目恰好能帮你建立这个意识。定义数据结构let grid [ [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0] ]; let score 0; let gameOver false; let winFlag false;2.2 移动算法的两种实现路线我选择旋转法写2048最核心的部分就是四个方向的移动和合并。两两组合一共十六种情况很多人第一次写会写出四个几乎重复的for循环里面各自处理行遍历或列遍历。代码能跑但非常丑陋以后想加个“斜向模式”之类的变体就得重写一半逻辑。我见过两种简化方案。第一种是表驱动法直接枚举出每个方向对应的行/列索引规则比如向左移动时逐行处理向右移动时逐行反向处理向上时逐列处理等等。第二种是旋转法将整个二维数组旋转90度、180度或270度把“向其他方向移动”统一变成“向左移动”。旋转法的思路特别优雅——先实现一个最标准的左移逻辑其他方向的操作都以左移为基准通过旋转-移动-再旋转回来完成。这个方法的代价是每次操作多几次数组遍历但对于4x4的小棋盘性能差距完全可以忽略。而代码的简洁性和可维护性比那零点几毫秒的性能提升重要得多。核心实现思路如下先写左移的核心逻辑然后写一个旋转函数function rotateMatrix(matrix) { const n matrix.length; const newMatrix []; for (let i 0; i n; i) { newMatrix[i] []; for (let j 0; j n; j) { newMatrix[i][j] matrix[n - 1 - j][i]; } } return newMatrix; }右移 旋转180度后左移再旋转180度回来上移 旋转90度后左移再旋转270度回来下移 旋转270度后左移再旋转90度回来。这一套搞明白四个方向的代码量直接从原来的几百行压缩到几十行。2.3 合并逻辑的细节一次滑动只能合并一次写合并逻辑时新手最容易犯的一个错误是数组[2, 2, 2, 2]左移后正确结果是[4, 4, 0, 0]而不是[8, 0, 0, 0]。也就是说一次滑动中同一个方块最多只能参与一次合并不能连锁合并。这是个硬性规则2048官方版本就是这么定的。实现时最简单的做法是左移时先清理每一行的0把非零元素紧凑排列然后从头开始两两比较相邻元素如果相等就合并并把合并后的值放到前一个位置后一个位置置0同时需要一个标记或直接让索引跳过一个位置确保合并过的元素不再参与后续合并。简化版的左移一行逻辑function 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; arr.splice(i 1, 1); score arr[i]; } } while (arr.length 4) { arr.push(0); } return arr; }这个版本用了数组splice操作语义清晰但每次都创建新数组对4x4的棋盘来说完全够用。如果想追求更极致的内存复用可以用双指针原地处理但作为练手项目可读性优先。3. 图片资源的准备与视觉呈现3.1 为什么需要图片而不是纯CSS画方块标题里特别提到了“内含图片”这也是很多人拿到代码后好奇的地方——2048的方块不就是带颜色和数字的矩形吗为什么还需要图片资源两种方案各有取舍。纯CSS画方块好处是代码里能直接看到颜色值方便调整坏处是数字方块的质感、圆角阴影过渡、背景纹理这些细节调起来非常费劲尤其是想要复刻原版那种柔和渐变的质感CSS写起来很啰嗦。用图片的好处是视觉上更接近原版效果加载即用不用为每个数字写单独的样式类坏处是需要额外加载图片资源文件管理稍微麻烦一点。我这个版本采用的是混合方案棋盘背景和格子背景用纯CSS实现因为它们的样式比较简单数字方块的视觉主体用图片资源加载覆盖2、4、8、16、32、64、128、256、512、1024、2048共11种状态。每个数字对应一张独立的PNG图片命名规则直接用数字本身比如2048.png、1024.png这样在代码里拼接路径时非常方便function getTileImage(value) { if (value 0) return ; return img srcimages/${value}.png classtile-image alt${value}; }3.2 图片的制作思路与工具这些图片自己动手做也不难用Figma或者Sketch甚至Photoshop都行。画一个圆角矩形填充色参考原版2048的颜色方案2是#eee4da4是#ede0c88是#f2b17916是#f5956332是#f67c5f64是#f65e3b128是#edcf72256是#edcc61512是#edc8501024是#edc53f2048是#edc22e。数字颜色方面2和4用深棕色#776e658及以上的数字用白色。导出时统一尺寸为128x128像素这个尺寸在高清屏下两倍像素密度也能保持清晰。图片文件名用数字本身命名方便代码里拼接字符串。生产环境上线时可以把11张图片合成一张雪碧图减少HTTP请求但这是后续优化的事儿练手阶段单张加载完全没问题。3.3 DOM渲染与图片的配合方式棋盘渲染时每个格子用一个divclass为tile内容动态填充。格子本身通过CSS Grid或Flex布局排成4x4。每个格子的innerHTML根据grid数组对应位置的值实时更新function renderGrid() { const board document.getElementById(board); board.innerHTML ; for (let row 0; row 4; row) { for (let col 0; col 4; col) { const cell document.createElement(div); cell.className cell; if (grid[row][col] ! 0) { cell.innerHTML getTileImage(grid[row][col]); } board.appendChild(cell); } } }这套渲染方式有个问题——每次移动后是全量重绘动画效果比较生硬。后面优化时可以用CSS transition配合绝对定位让方块滑动时有平滑过渡的效果。但作为第一版先把逻辑跑通更重要动画属于锦上添花的优化项。4. 逐步实现核心代码4.1 初始化从两个方块开始游戏开始时棋盘是空的需要先随机生成两个初始方块然后渲染。生成随机方块时要先统计所有值为0的位置再从中随机挑选一个填入2或4function addRandomTile() { const emptyCells []; for (let i 0; i 4; i) { for (let j 0; j 4; j) { if (grid[i][j] 0) { emptyCells.push({ row: i, col: j }); } } } if (emptyCells.length 0) return false; const randomIndex Math.floor(Math.random() * emptyCells.length); const cell emptyCells[randomIndex]; grid[cell.row][cell.col] Math.random() 0.9 ? 2 : 4; return true; }这里有个小细节生成新方块时随机填2的概率设为90%填4的概率设为10%和原版保持一致。这样做的好处是新方块出现时大多是2给玩家留出更多合并操作的机会游戏节奏更舒服。4.2 键盘事件监听与防抖处理监听键盘事件时要注意方向键和WASD触发的浏览器默认行为滚动页面应该被阻止。同时要避免一次按键触发了多次移动——比如用户按住方向键不放游戏会连续响应这在PC上体验还好但结合浏览器的事件重复触发机制会导致游戏节奏失控。处理方式是增加一个锁定标志在移动动画执行期间忽略新的键盘输入动画结束后再解锁let moving false; document.addEventListener(keydown, function(e) { const keyMap { ArrowLeft: left, ArrowUp: up, ArrowRight: right, ArrowDown: down, a: left, w: up, d: right, s: down }; const direction keyMap[e.key]; if (!direction || moving) return; e.preventDefault(); moving true; move(direction); setTimeout(() { moving false; }, 120); });注意键盘事件监听要绑定在document上而不是某个按钮元素上避免出现点击页面其他区域时光标不在棋盘内导致按键不响应的诡异bug。还有一个很容易踩的坑移动端上keydown事件里用e.key判断方向键可能拿不到正确的值这是移动端浏览器的差异后文会单独说。4.3 四个方向统一调度与游戏结束判断有了旋转矩阵做工具函数四个方向的移动就变得非常简洁了。以向左移动为基准函数function move(direction) { let moved false; if (direction left) { for (let i 0; i 4; i) { const newRow slideRow([...grid[i]]); if (newRow.join() ! grid[i].join()) moved true; grid[i] newRow; } } else if (direction right) { grid rotateMatrix(grid); grid rotateMatrix(grid); for (let i 0; i 4; i) { const newRow slideRow([...grid[i]]); if (newRow.join() ! grid[i].join()) moved true; grid[i] newRow; } grid rotateMatrix(grid); grid rotateMatrix(grid); } // 同理处理 up / down if (moved) { addRandomTile(); renderGrid(); checkGameStatus(); } }游戏结束的判断逻辑是棋盘满且相邻格子没有相同值。这里要遍历所有格子检查右方和下方两个方向上是否存在相邻相等的情况只要有一个存在游戏就没结束function canMove() { for (let i 0; i 4; i) { for (let j 0; j 4; j) { if (grid[i][j] 0) return true; if (j 3 grid[i][j] grid[i][j 1]) return true; if (i 3 grid[i][j] grid[i 1][j]) return true; } } return false; }4.4 localStorage持久化最高分最高分的存取用localStorage就能搞定两个APIgetItem和setItem。存进去的是字符串读取时要parse成数字。游戏结束时判断当前分数是否大于历史最高分如果是就更新同时更新页面显示。const HIGH_KEY 2048_high_score; function getHighScore() { const score localStorage.getItem(HIGH_KEY); return score ? parseInt(score, 10) : 0; } function setHighScore(value) { localStorage.setItem(HIGH_KEY, value); }localStorage在浏览器隐私模式下可能会抛异常写生产级代码时要做个try catch兜底但对于这个练手项目正常模式下使用已经足够。要注意的是localStorage存的是字符串取出来做比较运算前记得转成Number否则100 20这种字符串比较会把你折磨疯。5. 完整代码结构梳理与关键技巧5.1 文件目录与代码分层整个项目只需要三个文件加一个图片目录2048-game/ ├── index.html ├── css/ │ └── style.css ├── js/ │ └── game.js └── images/ ├── 2.png ├── 4.png ├── 8.png ├── ... └── 2048.pngHTML文件负责页面骨架包括棋盘容器、分数区域、按钮区域。CSS负责棋盘布局、格子尺寸、颜色过渡和图片大小控制。JS文件负责所有游戏逻辑包括初始化、移动、合并、渲染、状态判定。三层分离是这个行业的共识哪怕写个小游戏也建议遵守往后项目变复杂时维护成本差异巨大。其中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 classscore-box span最高分/span span idhigh-score0/span /div /div div idboard/div div classcontrols button onclickrestart()重新开始/button /div /div script srcjs/game.js/script /body /html5.2 样式设计中的关键点棋盘使用aspect-ratio: 1保持正方形宽度用min(90vw, 450px)限制最大宽度这样在不同尺寸的屏幕上都能有合适的显示效果。格子内部图片的宽高是100%但盒子本身用padding外扩模拟方块之间的间距#board { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; padding: 10px; background-color: #bbada0; border-radius: 8px; } .cell { aspect-ratio: 1; display: flex; align-items: center; justify-content: center; background-color: rgba(238, 228, 218, 0.35); border-radius: 4px; overflow: hidden; } .cell img { width: 100%; height: 100%; object-fit: cover; }为什么用grid而不用flex因为flex布局下实现等宽正方形每行四个需要额外设置flex-wrap和width百分比相比之下grid的repeat(4, 1fr)一行代码就搞定语义也更清晰。唯一的兼容性顾虑是老的IE浏览器但都2025年了没必要为它妥协。5.3 关于“完整代码”的无私分享姿势涉及代码分享时有个问题值得专门说一下你能找到的每个开源2048项目代码风格和个人习惯都不一样不要直接复制粘贴了事。正确打开方式是先读懂每一行在干什么再动手从零写一遍写完一遍再对照参考代码看哪里可以优化。这个项目完整代码并不长全部代码加起来大概300行左右认真写一遍只需要一个下午的时间但获得的理解深度远远超过抄十遍代码。6. 常见问题与排查技巧6.1 方向键操作方向反了这是新手最容易遇到的困惑。问题是按上键时棋盘内容应该向上移动但很多人的第一版实现里结果却相反。原因是数组的行列索引和屏幕坐标的方向映射没有厘清。grid[0][0]是左上角按上键时第0列的所有元素整体向上移动也就是col固定不变row从1到3逐个读取合并结果重新放回col列。写成代码时如果行列搞反方向自然就错了。排查这类问题的心法是先在Console里打印移动前后的grid数组而不是直接盯屏幕看UI。数组打印出来一目了然哪里反转了马上就能定位。6.2 按下方向键页面跟着滚动方向键的默认行为是滚动页面网上很多人遇到过这个问题尤其是Mac触控板用户。解决办法就是e.preventDefault()。但要注意必须调用在事件捕获阶段的最后一步并且确保事件监听器的第三个参数没有误设置成true捕获模式。一次踩坑经历是我调试时给addEventListener多加了个true参数导致preventDefault没有生效页面滚动不止排查了将近半小时才意识到。6.3 新方块生成在已占用位置这个bug很隐蔽。有人用Math.random()生成随机的行号和列号判断那个位置是否为0如果是0才放置否则重新生成。看起来没什么问题但当棋盘大部分被占满时随机命中空格的概率很低程序会出现明显的卡顿。正确做法是维护一个空格数组从中随机挑选一个点击速度再快也不会卡。前文的addRandomTile()就是这么做的。6.4 关于异步动画中的状态不同步问题如果后续给项目加了滑动动画比如用CSS transform配合requestAnimationFrame一个容易踩的坑是动画还没结束用户就按了下一个方向键此时grid状态已经被上次移动修改了但DOM还在播放旧位置的过渡效果最终会导致视觉画面和逻辑状态脱节。解决方案就是前文提到的moving锁或者在动画结束的回调里才执行下一步逻辑。用CSS transition时可以在元素上监听transitionend事件确保动画播放完毕后再允许新的输入。6.5 常见问题速查表问题现象根因解决方案方向键触发表单提交或页面滚动未阻止默认行为在keydown中调用e.preventDefault()方块的合并结果变成0未处理“相同数字只能合并一次”的规则在循环中使用索引跳过已合并项游戏结束后还能继续移动未在移动前检查游戏状态在move入口处判断gameOver标志刷新页面后最高分消失localStorage未正确存取检查key名是否一致确认parse/stringify类型正确移动端触摸滑动无响应未做touch事件适配增加touchstart和touchmove事件模拟方向7. 练手项目的进阶方向与个人心得7.1 把这个项目从60分做到90分基础功能做完后有几个进阶方向值得自己动手试试。第一是添加滑动动画让方块在移动时有平滑过渡效果这需要使用绝对定位和CSS transition配合。第二是支持触摸滑动事件让游戏在手机上也能玩这涉及到touch事件的坐标计算。第三是增加撤回功能保存前几步的操作记录这需要维护一个操作历史栈。第四是接入Web Worker做AI自动求解写一个策略算法自动玩2048这个过程对算法能力的锻炼非常有帮助。哪一个方向拿出来都能写一篇单独的文章。就拿触摸支持来说touchstart时记录触点坐标touchend时计算位移向量比较水平和垂直位移的绝对值决定这次手势应该映射到哪个方向。这个逻辑看着简单但实际项目中还要考虑触摸滑动距离的阈值设置比如超过30像素才算有效滑动以及在True Mobile浏览器中触摸事件和点击事件的冲突处理这些都是书本上不会教但实际开发中一定会碰到的经验。7.2 我从带新人做2048项目中获得的几点体会做了几轮带新人的练手项目后我最大的体会是做一个成熟项目难点往往不在某个单独的技术点而在于把独立的技术点整合在一起的协调能力。2048的每个功能单拎出来都不难二维数组操作、事件监听、DOM渲染、localStorage读写任何一个前端工程师都能手写。但把这些功能组合成一个整体并且让它们互相配合没有冲突这本身就是对全盘思维能力的一种训练。还有一个容易被忽略的点是代码可读性。很多人的项目刚写完自己过两天再看都认不出那段代码是干嘛用的。所以在这个项目中我特别要求自己写清楚变量名、函数名该拆分的逻辑尽量拆分不要在同一个函数里写100行。这个习惯越早养成越好到真正参与团队项目时代码审查的首要标准就是可读性。7.3 最后分享一个调样式的小技巧2048这个游戏视觉上最影响体验的不是逻辑而是方块之间微妙的间距和颜色过渡。调试CSS时不要直接在浏览器开发者工具里反复改数字刷新先在截图或设计稿里确定目标效果然后一段段调。调整方块圆角、背景色过渡、阴影浓度这些细节时多做A/B对比你会发现原版的那套视觉系统在感知协调性上做得相当讲究。另外一个我从原版学来的细节方块的数字字体要用无衬线体且加粗。原版用的是Arial、Helvetica这些字体在指数级增长的数字中依然保持极佳的辨识度。如果换成衬线体或艺术字体数字一大就糊成一团。这是一个小细节但用户能不能直觉地看懂棋盘往往就取决于这些细节。本文还有配套的精品资源点击获取
返回列表