JavaScript实现2048军旗版:二维数组与移动合并算法全解析
发布时间:2026/9/15 10:42:02 锦皓数字建站

简介一份基于JavaScript实现的2048军旗版游戏完整源码包面向Web前端初学者和游戏开发入门者可帮助理解数字拼图游戏从棋盘建模到交互响应的完整实现也适用于课程设计、期末作业或想要在经典2048基础上增加自定义玩法的改版参考。压缩包体积约13KB整体轻量下载后直接运行调试即可看到效果目前已有113人学习。源码围绕二维数组构建4x4棋盘完整实现上、下、左、右四个方向的滑动合并逻辑移动后会在空位随机生成新方块同时引入了军旗特殊方块其初始数值随机落在1024至2048之间为游戏增加随机性与挑战性。游戏还包含胜利与结束判断、得分累计、重置开局等功能并通过HTML/CSS构建界面JavaScript将棋盘状态同步更新到页面。跟随源码可掌握数组遍历、事件监听、状态管理以及前端三件套协作等关键技能还能学习如何扩展经典2048的玩法设计。1. 军旗版2048JS源码包里值得拆的三块东西2048军旗版听起来像给数字换了个皮真拆开源码才发现它比原版多了两处玩法上的改动棋盘左上角开局就放一个1024到2048之间的“军旗”方块其他空位保留经典的2和4随机生成合并目标也从单纯凑2048变成了守护军旗不被挤走。这个zip包里的HTML页面完全用JavaScript实现没有依赖框架打开浏览器就能跑。适合想用最短路径理解二维数组、事件监听和DOM渲染关系的开发者也适合拿来当面试题做手写练习。下面按数据、逻辑、交互三层来拆。2. 棋盘数据结构与移动合并算法从二维数组到20482.1 用二维数组模拟棋盘为什么不用对象打开源码第一行声明棋盘用的就是每个JS新手都见过的Arraylet board Array(4).fill(null).map(() Array(4).fill(0));这里有个关键点如果直接写成Array(4).fill(Array(4).fill(0))会得到4行引用同一数组的棋盘改一行其他三行跟着变。源码里用map重新生成4个独立数组至少这个细节是过关的。二维数组比对象数组更适合这里因为移动合并需要频繁按下标访问相邻位置board[i][j]直接定位访问开销比key-value结构小得多。真正跑起来后这4x4数组就是游戏的全部状态得分、胜负、渲染全由它推导。用对象数组也能表达棋盘比如[{x:0,y:0,value:2}, ...]但每次移动都要先遍历找坐标再做碰撞合并代码量翻倍。二维数组的另一个优势是天然符合行列概念旋转、反转可以用标准矩阵运算处理。之后所有逻辑都围绕board展开这也是2048类游戏最常规的建模方式。2.2 四个方向的移动合并统一实现移动逻辑分四份写并不优雅我一般会把“向左移动”写成基础函数其他三个方向通过旋转矩阵复用。先看合并一行的核心代码function mergeRow(row) { const nums row.filter(v v ! 0); for (let i 0; i nums.length - 1; i) { if (nums[i] nums[i 1]) { nums[i] * 2; nums.splice(i 1, 1); } } while (nums.length row.length) { nums.push(0); } return nums; }这个函数做三件事先去掉所有0把非零数字压缩到数组头部然后遍历相邻对从左到右合并一次合并过的方块不再参与后续合并最后在尾部补0回到固定长度。注意splice(i 1, 1)之后i自增会跳过刚合并过的数字正好保证每个方块一次移动最多合并一次。有了mergeRow左移就是每一行直接调用它。右移、上移、下移怎么处理先旋转矩阵把目标方向转回“左”处理完再转回去。旋转用90度顺时针旋转函数function rotateClockwise(matrix) { const N matrix.length; const rotated Array.from({ length: N }, () Array(N).fill(0)); for (let i 0; i N; i) { for (let j 0; j N; j) { rotated[j][N - 1 - i] matrix[i][j]; } } return rotated; } function move(matrix, direction) { const steps [left, right, up, down].indexOf(direction); let m matrix.map(row row.slice()); for (let k 0; k steps; k) { m rotateClockwise(m); } m m.map(row mergeRow(row)); for (let k steps; k 4; k) { m rotateClockwise(m); } return m; }方向映射逻辑是向右等于先旋转一次左移后旋转三次向上等于先旋转两次左移后旋转两次向下等于先旋转三次左移后旋转一次。这种写法把四种方向的算术逻辑收敛成一个参数后续如果要支持5x5或8x8棋盘不需要改移动函数只改旋转尺寸即可。2.3 合并优先级与边界条件为什么不能直接相邻比较初次写2048的人很容易直接在原数组上比较board[i][j] board[i][j1]然后就地合并结果碰到跨0或连续相同数对时出错。看这几个用例输入行向左正确输出直接相邻比较的常见错误[2,0,2,0][4,0,0,0][2,0,2,0]没先压缩[2,2,4,0][4,4,0,0][8,0,0,0]合并后的4又和后面的4合并[2,2,2,2][4,4,0,0][8,0,0,0]连锁合并两次正确规则是先压缩后合并且每个方块每次移动最多合并一次。mergeRow里的filter先完成压缩splice又天然限制了一次遍历中的合并次数所以能避开这些坑。这也是为什么源码没有直接写四个方向的重复循环而是把行处理抽出来——排错时只要测mergeRow就能覆盖所有方向的潜在问题。3. 军旗方块的生成逻辑与游戏结束判定3.1 军旗值怎么随机范围、权重与边界军旗版和原版最直观的区别是开局或游戏过程中会生成一个值很大的军旗方块。源码里用了一句类似这样的代码let flagValue Math.floor(Math.random() * 1024) 1024; // 1024 ~ 2047严格说Math.random()返回[0,1)乘以1024后最大是1023.999取整后最大1023加1024后最大2047也就是1024到2047拿不到2048。如果想包含2048标准写法是function createFlagValue() { const min 1024; const max 2048; return Math.floor(Math.random() * (max - min 1)) min; }max - min 1等于1025随机整数落在[0,1024]加min后正好闭区间。表面看只差一个数但在2048这种幂等数值体系里2048是通关标志如果军旗永远不会是2048对冲着“合成2048”来的玩家是个隐性误导。所以我建议如果自己改造务必确认区间端点。3.2 新方块产生时机玩家移动后而不是移动前原版2048的规则是只有玩家移动导致棋盘状态变化时才会在空位随机生成一个新方块。军旗版同样遵守这个规则但多了军旗生成概率。常见的生成函数如下function spawnTile(isFlag false) { const emptyCells []; for (let i 0; i board.length; i) { for (let j 0; j board[i].length; j) { if (board[i][j] 0) emptyCells.push({ x: i, y: j }); } } if (emptyCells.length 0) return; const { x, y } emptyCells[Math.floor(Math.random() * emptyCells.length)]; if (isFlag) { board[x][y] createFlagValue(); } else { board[x][y] Math.random() 0.9 ? 2 : 4; } }这里先用emptyCells收集所有空位再随机选一个避免“随机坐标后判断为空不为空再重试”的写法。棋盘快满时重试法可能会递归很多次而收集法只会把每个空位访问一遍。军旗概率在调用方控制比如每次生成时用Math.random() 0.05决定是否生成军旗。你也可以在开局强制放一个军旗后续不再放两种玩法手感差别很大。3.3 游戏结束判定扫描空位与可合并相邻对游戏结束不能只检查有没有空位还要看是否存在相邻且相同的数字。如果棋盘满了但相邻还能合并游戏状态本来是可以继续的。完整判定function canMove() { for (let i 0; i board.length; i) { for (let j 0; j board[i].length; j) { if (board[i][j] 0) return true; if (j board[i].length - 1 board[i][j] board[i][j 1]) return true; if (i board.length - 1 board[i][j] board[i 1][j]) return true; } } return false; }逻辑很简单横向扫描每一行的相邻对纵向扫描每一列的相邻对有0或相等就返回true。注意j和i的边界条件避免下标越界。有的实现会把两块军旗视为不可合并那就要在这段代码里额外排除军旗值所在位置否则会出现“提示还能走实际点不动”的假活状态。4. 事件监听与DOM同步从键盘到界面刷新4.1 keydown方向映射与浏览器默认行为桌面版2048靠键盘方向键移动端靠touch事件。源码里最基础的是监听keydowndocument.addEventListener(keydown, (e) { const keyMap { ArrowUp: up, ArrowDown: down, ArrowLeft: left, ArrowRight: right }; const direction keyMap[e.key]; if (direction) { e.preventDefault(); const before board.map(row row.slice()); board move(board, direction); if (hasChanged(before, board)) { spawnTile(Math.random() 0.05); render(); if (!canMove()) showGameOver(); } } });e.preventDefault()很关键不阻止的话按下箭头时页面会滚动尤其嵌套在长页面里时体验极差。keyMap只映射方向键避免用户按其他键触发移动。before复制用了map(row row.slice())这是二维数组深拷贝的懒人方案防止移动函数内部直接修改原数组导致hasChanged判断失效。hasChanged我用最简单的方式function hasChanged(before, after) { return JSON.stringify(before) ! JSON.stringify(after); }严格说JSON.stringify在性能上不是最优但4x4数组只有16个数字每次序列化开销可以忽略。如果改成8x8以上再考虑逐元素比较。4.2 用CSS Grid渲染棋盘数组到页面的同步渲染层不需要复杂的虚拟DOM直接根据board重建元素即可const grid document.querySelector(.grid); function render() { grid.innerHTML ; for (let i 0; i board.length; i) { for (let j 0; j board[i].length; j) { const cell document.createElement(div); cell.className cell; cell.dataset.value board[i][j] || ; cell.textContent board[i][j] || ; grid.appendChild(cell); } } }CSS里让.grid使用Grid布局.grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; width: 320px; height: 320px; }每次render清空重建简单但会丢失过渡动画。如果想把数字移动过程的动画做出来就不能用innerHTML全量清空而是给每个单元格设>let score 0; function mergeRow(row) { const nums row.filter(v v ! 0); for (let i 0; i nums.length - 1; i) { if (nums[i] nums[i 1]) { nums[i] * 2; score nums[i]; nums.splice(i 1, 1); } } while (nums.length row.length) nums.push(0); return nums; }重置功能保存当前状态记录最高分到localStoragefunction resetGame() { board Array(4).fill(null).map(() Array(4).fill(0)); score 0; spawnTile(true); // 开局放军旗 spawnTile(false); render(); } function saveHighScore() { const high localStorage.getItem(flag2048_high) || 0; if (score high) { localStorage.setItem(flag2048_high, score); } }这里把board重新赋值而不是board[i][j] 0循环因为原始数组可能被之前的move函数返回的新数组覆盖直接重置引用更干净。5. 改造成自己的变体棋盘尺寸、军旗阈值与动画节奏5.1 从4x4改成5x5只需要改三处大多数逻辑基于board.length所以改成5x5并不难。需要改的是初始化棋盘尺寸、CSS网格列数、以及军旗初始位置。const SIZE 5; board Array(SIZE).fill(null).map(() Array(SIZE).fill(0));CSS对应改成.grid { grid-template-columns: repeat(5, 1fr); }军旗位置如果固定在左上角在5x5里依然有效但策略空间变了。实际测试时5x5的密度更低军旗存活时间长游戏时长会明显拉长。建议把军旗值的下限也提高比如2048到4096否则后期满盘都是大数字时会显得军旗不够“旗”。5.2 调整军旗生成概率从固定值到动态权重源码里写死的Math.random() 0.05只适合Demo。如果想让难度曲线更平滑可以根据当前最大方块值动态调整概率function getFlagProbability() { const maxVal board.flat().reduce((a, b) Math.max(a, b), 0); if (maxVal 256) return 0.02; if (maxVal 1024) return 0.05; return 0.08; }这样前期军旗少玩家能稳定发育后期棋盘大数字多了军旗出现频率提升给游戏增加“必须优先处理军旗”的博弈感。注意board.flat()需要现代浏览器如果兼容老环境用两层循环求最大值。5.3 合并特效与本地存档手感提升的最小改动不需要引入动画库只给单元格加一个过渡就能让方块“滑”过去。前提是渲染时保留每个单元格的DOM节点而不是全部重建。一种取巧的方式是每次移动前记录每个非零元素的位置移动后再对比坐标差用CSStransform做位移动画。这需要额外几百行代码但效果明显。本地存档更简单在hasChanged为真后立即保存function saveState() { localStorage.setItem(flag2048_board, JSON.stringify(board)); localStorage.setItem(flag2048_score, score); }刷新后加载const saved localStorage.getItem(flag2048_board); if (saved) { board JSON.parse(saved); score Number(localStorage.getItem(flag2048_score)) || 0; }这个资源包最大的优点是没有任何构建步骤改完直接刷新浏览器就能看效果。你可以先把mergeRow抽出来单独放到浏览器控制台里测试边界数据确认无误后再改棋盘尺寸或军旗概率整个过程比在框架工程里调状态快得多。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。