简介:这是一份面向初级Web工程师的网页版2048小游戏实战项目资源,聚焦JavaScript核心能力训练,帮助学习者在零框架依赖环境下掌握DOM操作、事件驱动编程、二维数组逻辑处理及基础CSS动效实现。资源共3个文件:1个核心JS脚本(含完整游戏逻辑与状态管理)、1张游戏结束提示图(用于UI反馈)、1份Word文档(含开发说明、关键算法注释与调试建议),总大小830KB,结构精简、开箱即用。已有414人学习下载,适合课后练习、技术面试准备或前端入门项目复现。读者可直接运行HTML页面体验交互,深入阅读JS源码理解移动合并判定、随机数生成与棋盘刷新机制,并结合文档梳理从HTML结构搭建、CSS布局响应到JS逻辑闭环的完整开发链路,切实提升代码组织与问题拆解能力。
1. 网页版2048小游戏:为什么一个“纯JS+HTML+CSS”的极简项目,成了初级Web工程师绕不开的「能力试金石」
你刚学完DOM操作、事件监听和数组基本方法,但写个待办清单总觉得缺了点“手感”;你看过三遍闭包和this指向,可一到真实交互逻辑里还是容易绕晕;你下载过几十个GitHub上的“小项目”,打开却发现依赖一堆、构建脚本满天飞、连跑起来都要查半小时文档……这时候,一个不依赖任何框架、不调用外部API、不走打包流程、所有逻辑塞进单个HTML文件里的网页版2048,就成了最扎实的落脚点。它不是玩具——它强制你亲手管理格子状态、处理方向键响应、实现合并动画、校验游戏结束条件、甚至用localStorage存最高分;它也不设门槛——打开编辑器就能改,F5就能看效果,控制台里一行console.log(grid)就能看清整个世界。这不是教你怎么“用轮子”,而是逼你把轮子怎么转、卡在哪、响几声,全都听清楚。适合刚脱离“Hello World”阶段、正卡在“能看懂代码”和“能写出健壮交互”之间那道窄缝里的开发者。
2. 从零搭起游戏骨架:HTML结构、CSS布局与JS核心数据模型
2.1 HTML容器与语义化结构:为什么不用div套div狂魔写法
2048的视觉结构极其规整:一个4×4主网格,外围是标题、分数栏、重玩按钮和说明文字。很多人第一反应是写16个<div class="cell">硬编码进去——这在初期调试时看似省事,但一旦要加动画、响应式或动态尺寸,就会变成维护噩梦。真正可持续的做法,是只保留一个空容器,用JS动态生成格子:
<div id="game-container"> <div class="game-header"> <h1 class="title">2048</h1> <div class="scores-container"> <div class="score-container"> <p>SCORE</p> <p id="score">0</p> </div> <div class="score-container"> <p>BEST</p> <p id="best">0</p> </div> </div> </div> <div id="grid-container"> <!-- JS将在这里注入4×4的grid-cell --> </div> <div class="game-controls"> <button id="restart-button">New Game</button> </div> </div>提示:
id="grid-container"是唯一需要JS操作的挂载点。所有格子(包括空位)都由JS创建并插入,这样后续增删格子、批量添加class、绑定事件才可控。硬编码16个div会导致状态与DOM强耦合,比如“合并后清空某格”得手动找DOM再remove,而动态生成则直接操作数组再重绘,逻辑更干净。
2.2 CSS网格布局:用display: grid替代float和绝对定位
老派写法喜欢用position: absolute配合top/left硬算每个格子坐标,或者用float: left加margin拼4列——这两种方式在响应式、动画、缩放时都会出问题。现代写法直接用CSS Grid,4行4列自动对齐,且支持gap统一间距、aspect-ratio锁定正方形:
#grid-container { position: relative; width: 400px; height: 400px; margin: 20px auto; background-color: #bbada0; border-radius: 6px; padding: 15px; box-sizing: border-box; } .grid-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 15px; width: 100%; height: 100%; } .grid-cell { width: 100%; height: 100%; border-radius: 3px; background-color: rgba(238, 228, 218, 0.35); }关键点在于:.grid-row作为每行容器(共4个),每个内部用grid-template-columns: repeat(4, 1fr)均分;.grid-cell本身不设宽高,靠父容器撑开。这样无论屏幕多大,只要#grid-container尺寸固定,格子永远等比自适应。别碰transform: scale()做缩放——它会模糊文字、打乱点击区域,Grid原生缩放才是正解。
2.3 JS数据模型设计:二维数组不是终点,状态机才是核心
很多初学者把grid = [[0,0,0,0], [0,0,0,0], ...]当成全部,然后在moveUp()里疯狂for嵌套if——结果代码越写越长,合并逻辑错漏百出。真正的分水岭,在于把“格子值”和“格子行为”解耦:
// 核心状态对象 —— 所有业务逻辑围绕它展开 const gameState = { grid: Array(4).fill().map(() => Array(4).fill(0)), // 4x4数字矩阵,0表示空 score: 0, best: parseInt(localStorage.getItem('best') || '0'), isOver: false, isWon: false, // 每次移动前保存快照,用于撤销(进阶功能) previousGrid: null, previousScore: 0 }; // 初始化:随机放两个2 function initGrid() { addRandomTile(); addRandomTile(); } function addRandomTile() { const emptyCells = []; for (let i = 0; i < 4; i++) { for (let j = 0; j < 4; j++) { if (gameState.grid[i][j] === 0) { emptyCells.push({ row: i, col: j }); } } } if (emptyCells.length > 0) { const { row, col } = emptyCells[Math.floor(Math.random() * emptyCells.length)]; gameState.grid[row][col] = Math.random() < 0.9 ? 2 : 4; // 90%概率出2 } }注意:grid只存数字(0/2/4/8/16...),绝不存DOM引用、不存样式类名、不存动画状态。所有视觉表现(如颜色、大小、位置)都由CSS class控制,JS只负责更新grid数组,再触发一次render()函数批量重绘。这种“数据驱动视图”的思维,是跨过初级门槛的第一块砖。
3. 实现四大方向移动:合并逻辑、动画触发与状态同步
3.1 移动算法本质:不是“格子滑动”,而是“数组压缩+合并”
新手常陷入误区:以为moveLeft()就是把每个非零格子往左拽,边拽边判断相邻是否相等。这会导致逻辑爆炸——要处理“多个2连在一起怎么合并”“合并后新生成的数能否再合并”“空格怎么填补”等等。正确解法是把每行(或每列)抽出来,当成独立一维数组,做三步原子操作:
- 过滤空格→ 得到紧凑数组
[2,0,2,0]→[2,2] - 相邻合并→ 遍历紧凑数组,若
arr[i] === arr[i+1],则arr[i] *= 2,arr[i+1] = 0,score += arr[i],再过滤一次 →[4] - 补零至长度4→
[4,0,0,0]
这个过程完全数学化,无DOM干扰,可单元测试。以下是moveRowLeft的完整实现:
function moveRowLeft(row) { // 步骤1:过滤0,得到紧凑数组 const compact = row.filter(num => num !== 0); // 步骤2:合并相邻相同数字(注意:只合并一次,从左到右) const merged = []; let skipNext = false; for (let i = 0; i < compact.length; i++) { if (skipNext) { skipNext = false; continue; } if (i < compact.length - 1 && compact[i] === compact[i + 1]) { merged.push(compact[i] * 2); gameState.score += compact[i] * 2; skipNext = true; } else { merged.push(compact[i]); } } // 步骤3:补零到长度4 while (merged.length < 4) merged.push(0); return merged; } // moveLeft主函数:遍历每行,用moveRowLeft处理 function moveLeft() { if (gameState.isOver || gameState.isWon) return; let moved = false; const newGrid = []; for (let i = 0; i < 4; i++) { const newRow = moveRowLeft(gameState.grid[i]); newGrid.push(newRow); if (!arraysEqual(gameState.grid[i], newRow)) moved = true; } if (moved) { gameState.grid = newGrid; addRandomTile(); updateScoreDisplay(); checkWin(); checkGameOver(); } } function arraysEqual(a, b) { return a.length === b.length && a.every((val, idx) => val === b[idx]); }逻辑说明:
moveRowLeft是纯函数,输入一行数组,输出处理后的新行。它不修改原数组,不操作DOM,不读取全局状态(除了gameState.score这个副作用)。这种隔离让调试变得极其简单——你可以在控制台直接调moveRowLeft([2,2,4,4])看返回值,秒级验证逻辑。
3.2 动画实现:用CSS transition + JS class切换,拒绝setTimeout硬延迟
很多教程用setTimeout逐格移动,结果动画卡顿、无法中断、时间难对齐。2048官方动画的精髓,在于“状态驱动”而非“时间驱动”:JS只负责给格子元素添加tile-position-*类名,CSS用transition定义移动过程,JS再用getBoundingClientRect()读取目标位置,一次性设置transform: translate()。
// render函数中为每个非零格子生成DOM并设置初始位置 function renderCell(row, col, value) { const cell = document.createElement('div'); cell.className = `tile tile-${value}`; cell.textContent = value; // 初始位置:根据row/col计算left/top(单位px) const pos = getCellPosition(row, col); cell.style.left = `${pos.x}px`; cell.style.top = `${pos.y}px`; // 添加到grid-container gridContainer.appendChild(cell); // 关键:触发浏览器重排,让初始位置生效 cell.clientTop; // 再设置目标位置(此时transition开始) const targetPos = getCellPosition(row, col); cell.style.transform = `translate(${targetPos.x - pos.x}px, ${targetPos.y - pos.y}px)`; } function getCellPosition(row, col) { const size = 95; // 格子宽高(含gap) const padding = 15; return { x: col * (size + 15) + padding, y: row * (size + 15) + padding }; }参数说明:
size=95是格子实际像素宽高(CSS中.tile设为width:95px;height:95px),15是gap值。getCellPosition返回绝对坐标,transform用相对位移避免重排。所有动画都靠CSStransition: transform 0.1s cubic-bezier(.25,.46,.45,.94)完成,JS只做开关。
3.3 方向键监听与防抖:为什么键盘连按会“吃掉”一次移动
keydown事件默认会重复触发(长按时),导致用户按住→键想连续上移,结果只执行了一次。必须用event.repeat过滤重复事件,并用节流防止高频触发:
let lastMoveTime = 0; const MOVE_COOLDOWN = 100; // 100ms内只响应一次 document.addEventListener('keydown', function(e) { if (e.repeat) return; // 忽略系统自动重复 const now = Date.now(); if (now - lastMoveTime < MOVE_COOLDOWN) return; lastMoveTime = now; switch(e.key) { case 'ArrowUp': moveUp(); break; case 'ArrowDown': moveDown(); break; case 'ArrowLeft': moveLeft(); break; case 'ArrowRight': moveRight(); break; case 'r': if (e.ctrlKey) restartGame(); break; // Ctrl+R重玩 } });注意:
e.repeat是标准属性,现代浏览器全支持。不要用setTimeout模拟防抖——它会让按键响应变迟钝。MOVE_COOLDOWN=100ms是经验值,太短易误触,太长影响操作感。
4. 常见问题排查:5个让初级工程师深夜抓狂的真实坑点
4.1 现象:合并后分数没加,或加了双倍
原因:在moveRowLeft的合并循环里,错误地用了for...in遍历数组,或未正确处理skipNext标志,导致同一格子被多次计入得分。
解决:严格使用for (let i = 0; i < arr.length; i++),合并后立即将skipNext = true,并在下轮循环开头continue。加分只在merged.push(compact[i] * 2)这一行执行一次。
4.2 现象:按方向键后格子“闪一下”就消失,或新数字没出现
原因:addRandomTile()在moveX()末尾调用,但未判断是否有空格可填。当emptyCells.length === 0时,函数静默失败,用户感觉“没反应”。
解决:在addRandomTile()开头加守卫:
if (emptyCells.length === 0) { console.warn('No empty cells to add tile — game may be stuck'); return; }同时确保checkGameOver()在每次移动后调用,及时置isOver = true。
4.3 现象:移动端触摸操作完全失灵,或方向键在部分浏览器不响应
原因:只监听了keydown,未处理移动端touchstart/touchmove/touchend,且未阻止touchmove默认滚动行为。
解决:补充触摸支持(精简版):
let touchStartX = 0, touchStartY = 0; gridContainer.addEventListener('touchstart', e => { touchStartX = e.touches[0].clientX; touchStartY = e.touches[0].clientY; e.preventDefault(); }, { passive: false }); gridContainer.addEventListener('touchend', e => { if (!touchStartX || !touchStartY) return; const touchEndX = e.changedTouches[0].clientX; const touchEndY = e.changedTouches[0].clientY; const dx = touchEndX - touchStartX; const dy = touchEndY - touchStartY; if (Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > 30) { if (dx > 0) moveRight(); else moveLeft(); } else if (Math.abs(dy) > 30) { if (dy > 0) moveDown(); else moveUp(); } touchStartX = touchStartY = 0; });4.4 现象:localStorage存的best分数重启浏览器后归零
原因:localStorage.setItem('best', gameState.best)存的是数字,但getItem()返回字符串,parseInt('0')没问题,可若存的是'1024',后续比较gameState.best > currentScore时,字符串比较'1024' > 1000会返回false(字典序)。
解决:统一用Number()转换,且初始化时强转:
gameState.best = Number(localStorage.getItem('best') || '0'); // 存储时 localStorage.setItem('best', String(gameState.best));4.5 现象:格子动画结束后,位置偏移几个像素,或文字模糊
原因:transform: translate()的像素值是浮点数(如translate(100.32px, 200.78px)),而浏览器渲染时会对亚像素做抗锯齿,导致模糊;或left/top与transform混用,造成坐标系冲突。
解决:所有位置计算强制取整:
cell.style.left = `${Math.round(pos.x)}px`; cell.style.top = `${Math.round(pos.y)}px`; cell.style.transform = `translate(${Math.round(targetPos.x - pos.x)}px, ${Math.round(targetPos.y - pos.y)}px)`;5. 进阶技巧:如何用这个项目练出生产级工程习惯
5.1 把“单HTML文件”拆成模块化结构:为未来扩展埋下伏笔
现在所有代码挤在一个HTML里,很适合入门,但当你要加音效、主题切换、难度选择时,就会失控。我一般会在第3次迭代时,用ES6模块拆分:
2048/ ├── index.html # 只留基础结构和<script type="module"> ├── js/ │ ├── core.js # gameState、moveX、addRandomTile等纯逻辑 │ ├── renderer.js # render、getCellPosition、animateTile等视图逻辑 │ ├── input.js # 键盘/触摸事件监听与分发 │ └── storage.js # localStorage读写封装 └── css/ └── style.cssindex.html中:
<script type="module"> import { initGame } from './js/core.js'; import { setupInput } from './js/input.js'; initGame(); setupInput(); </script>这么做的好处:
core.js可单独npm test,renderer.js可替换为Canvas或WebGL渲染器而不动逻辑,input.js能轻松接入手柄API。模块化不是为了炫技,而是让“改一个功能不波及十个文件”成为可能。
5.2 用Chrome DevTools做性能诊断:揪出隐藏的重排重绘
2048看似简单,但格子多、动画频,稍不注意就会掉帧。打开DevTools → Rendering → 勾选“Paint flashing”和“FPS meter”,操作游戏时观察:
- 绿色闪块:正常绘制;
- 红色闪块:强制同步布局(Layout Thrashing),通常是读取
offsetTop后立刻改style.left; - 黄色长条:JS执行时间过长(>16ms)。
常见优化点:
- ✅ 用
transform代替left/top(已做); - ✅ 批量DOM操作:
render()中先用documentFragment收集所有cell,最后appendChild(fragment)一次; - ❌ 避免在
for循环里反复读cell.getBoundingClientRect()——提前缓存。
5.3 写可验证的单元测试:用Jest验证核心算法
别等上线再测合并逻辑。用Jest对moveRowLeft写测试,5分钟搞定:
// __tests__/core.test.js import { moveRowLeft } from '../js/core.js'; test('merges adjacent equal numbers', () => { expect(moveRowLeft([2, 2, 0, 0])).toEqual([4, 0, 0, 0]); expect(moveRowLeft([2, 2, 2, 2])).toEqual([4, 4, 0, 0]); // 只合并一次 }); test('handles zeros in middle', () => { expect(moveRowLeft([0, 2, 0, 2])).toEqual([4, 0, 0, 0]); }); test('no change on no merge', () => { expect(moveRowLeft([2, 4, 8, 16])).toEqual([2, 4, 8, 16]); });运行
npx jest,红变绿那一刻,你会理解什么叫“代码有底气”。初级工程师和中级工程师的分界线,往往不是会不会写功能,而是敢不敢删掉旧代码、换新实现,因为你知道测试会拦住你。
5.4 一个血泪经验:永远在moveX()开头加状态守卫
我在某次加“撤销”功能时,忘了在moveUp()开头加if (gameState.isOver) return,结果游戏结束状态下用户还能按方向键,addRandomTile()往满格子里硬塞,emptyCells为空导致undefined报错,整个页面白屏。从此我所有可交互函数第一行都是:
if (gameState.isOver || gameState.isWon) return;哪怕现在还没实现胜利逻辑,也先占着——这是给未来自己留的后悔药。
希望帮到你。
本文还有配套的精品资源,点击获取