简介:一份基于JavaScript实现的2048军旗版游戏完整源码包,面向Web前端初学者和游戏开发入门者,可帮助理解数字拼图游戏从棋盘建模到交互响应的完整实现,也适用于课程设计、期末作业或想要在经典2048基础上增加自定义玩法的改版参考。压缩包体积约13KB,整体轻量,下载后直接运行调试即可看到效果,目前已有113人学习。源码围绕二维数组构建4x4棋盘,完整实现上、下、左、右四个方向的滑动合并逻辑,移动后会在空位随机生成新方块;同时引入了军旗特殊方块,其初始数值随机落在1024至2048之间,为游戏增加随机性与挑战性。游戏还包含胜利与结束判断、得分累计、重置开局等功能,并通过HTML/CSS构建界面,JavaScript将棋盘状态同步更新到页面。跟随源码可掌握数组遍历、事件监听、状态管理以及前端三件套协作等关键技能,还能学习如何扩展经典2048的玩法设计。
1. 军旗版2048:JS源码包里值得拆的三块东西
2048军旗版听起来像给数字换了个皮,真拆开源码才发现,它比原版多了两处玩法上的改动:棋盘左上角开局就放一个1024到2048之间的“军旗”方块,其他空位保留经典的2和4随机生成;合并目标也从单纯凑2048变成了守护军旗不被挤走。这个zip包里的HTML页面完全用JavaScript实现,没有依赖框架,打开浏览器就能跑。适合想用最短路径理解二维数组、事件监听和DOM渲染关系的开发者,也适合拿来当面试题做手写练习。下面按数据、逻辑、交互三层来拆。
2. 棋盘数据结构与移动合并算法:从二维数组到2048
2.1 用二维数组模拟棋盘,为什么不用对象
打开源码,第一行声明棋盘用的就是每个JS新手都见过的Array:
let 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][j+1]然后就地合并,结果碰到跨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事件。源码里最基础的是监听keydown:
document.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; }
重置功能保存当前状态,记录最高分到localStorage:
function 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抽出来单独放到浏览器控制台里测试边界数据,确认无误后再改棋盘尺寸或军旗概率,整个过程比在框架工程里调状态快得多。
本文还有配套的精品资源,点击获取