资讯动态

纯JS实现2048:零依赖前端练手项目

发布时间:2026/10/13 10:41:53 来源:尧图企业网站定制
简介这是一份面向初级Web工程师的网页版2048小游戏实战项目资源聚焦JavaScript核心能力训练帮助学习者在零框架依赖环境下掌握DOM操作、事件驱动编程、二维数组逻辑处理及基础CSS动效实现。资源包共3个文件1个核心JS脚本含完整游戏逻辑与状态管理、1张gameover提示图用于UI反馈、1份Word文档含开发说明、关键算法注释与调试建议总大小830KB结构精简、开箱即用。已有414人学习下载适合课后练习、技术面试准备或前端入门项目复现。读者可直接运行调试深入理解数字合并判定、棋盘状态更新、随机数生成策略等典型Web交互逻辑并通过文档梳理出从HTML结构搭建到JS逻辑分层的完整开发路径。1. 网页版2048小游戏纯JS实现、零依赖、开箱即用的前端练手项目你有没有试过——花30分钟搭好一个Vue脚手架npm install完依赖却卡在vite dev报错里动弹不得而隔壁工位的A同学只用一个.html文件、不到500行原生JavaScript就把2048跑起来了还能在手机上滑动、在Chrome里调试、在IE11勉强看到格子合并动画这不是玄学是回归本质的训练DOM操作、事件循环、状态管理、Canvas渲染优化。这个资源就是为初级Web工程师准备的「最小可行练手包」——它不包装、不抽象、不加TypeScript类型体操所有逻辑直写在script里图片资源全内联base64或本地路径打开就能玩F12就能改CtrlS就能验证。适合刚学完DOM API、事件监听、数组方法但还没碰过框架、构建工具、状态库的新手也适合想快速验证某个交互逻辑比如滑动方向判定、合并优先级、空格生成策略的老手当黑匣子对照。它不是玩具而是你写第一个真实业务组件前必须亲手拆解又重装一遍的“前端发动机”。2. 核心机制拆解为什么这500行JS能撑起完整游戏逻辑2.1 游戏状态建模二维数组 全局对象的轻量组合2048的本质是状态机4×4网格、每个格子有值0表示空、玩家每次输入上下左右触发一次状态迁移。这个项目没用Redux或Vuex而是用最朴素的方式建模const gameState { board: Array(4).fill().map(() Array(4).fill(0)), // 4x4全0数组 score: 0, bestScore: localStorage.getItem(bestScore) || 0, isOver: false, isWon: false };注意board是核心数据结构所有计算都围绕它展开。Array(4).fill().map(() Array(4).fill(0))比[[0,0,0,0],[0,0,0,0],...]更易维护且避免了浅拷贝陷阱后面会踩坑。bestScore存localStorage是合理取舍——不引入IndexedDB也不做服务端同步符合“单页应用轻量定位”。关键不是存哪里而是怎么用。比如判断是否可移动不是遍历DOM查.tile元素而是直接扫boardfunction canMove() { // 检查是否有相邻相同数字水平 for (let i 0; i 4; i) { for (let j 0; j 3; j) { if (gameState.board[i][j] gameState.board[i][j] gameState.board[i][j 1]) return true; } } // 垂直同理... return false; }这段代码的价值在于它把“视觉上能不能动”翻译成“数据上有没有合并可能”彻底剥离UI层。新手常犯的错误是先写DOM遍历再写逻辑结果一改样式就崩而这里只要board数据对渲染必然对。2.2 移动与合并算法四方向复用同一套核心逻辑左右上下看似不同实则可抽象为「按某轴排序 → 合并相邻相同项 → 填充空位」。项目采用函数式思路用moveDirection参数驱动function move(direction) { const originalBoard cloneBoard(gameState.board); // 深拷贝防误改 let newBoard gameState.board; switch (direction) { case left: newBoard moveLeft(newBoard); break; case right: newBoard moveRight(newBoard); break; case up: newBoard moveUp(newBoard); break; case down: newBoard moveDown(newBoard); break; } if (!boardsEqual(originalBoard, newBoard)) { addRandomTile(); // 只有变化才加新块 updateView(); } }重点看moveLeft内部其他方向是它的转置function moveLeft(board) { return board.map(row { // 1. 过滤非零值 const filtered row.filter(n n ! 0); // 2. 合并从左到右扫描遇相同则累加跳过下一个 const merged []; for (let i 0; i filtered.length; i) { if (i filtered.length - 1 filtered[i] filtered[i 1]) { merged.push(filtered[i] * 2); gameState.score filtered[i] * 2; // 实时更新分数 i; // 跳过下一个 } else { merged.push(filtered[i]); } } // 3. 补零到长度4 while (merged.length 4) merged.push(0); return merged; }); }参数说明filtered是去零后的紧凑数组merged是合并后结果while补零保证返回仍是4元素数组。这个逻辑清晰对应了2048规则合并只发生一次且优先靠近移动起点左移时左边的数先合并。很多新手写的合并逻辑会重复合并如[2,2,2,2]变成[8]而非[4,4]根源就是没控制i跳过已处理项。2.3 渲染与动画requestAnimationFrame驱动的帧级控制不用CSS transition做滑动不它用了但只负责“位置变化”真正的动画节奏由JS控制function updateView() { // 1. 清空所有tile document.querySelectorAll(.tile).forEach(el el.remove()); // 2. 遍历board为每个非零值创建tile gameState.board.forEach((row, i) { row.forEach((val, j) { if (val ! 0) { const tile createTile(i, j, val); document.querySelector(.grid-container).appendChild(tile); } }); }); // 3. 更新分数显示 document.getElementById(score).textContent gameState.score; document.getElementById(best-score).textContent gameState.bestScore; }createTile里关键点function createTile(row, col, value) { const tile document.createElement(div); tile.className tile tile-${value}; tile.style.transform translate(${col * 110}px, ${row * 110}px); // 初始位置 tile.textContent value; // 触发重排让transform生效 tile.offsetHeight; // 添加过渡类触发CSS transition setTimeout(() { tile.classList.add(tile-move); tile.style.transform translate(${col * 110}px, ${row * 110}px); // 终止位置 }, 10); return tile; }为什么用setTimeout而不是直接加class因为浏览器需要先完成初始render获取offsetHeight强制重排再应用transition。这是CSS动画与JS协同的经典模式。如果删掉offsetHeight和setTimeout动画会失效——这是血泪经验。3. 文件结构与资源组织如何把“完整代码图片”塞进一个可运行包3.1 目录即逻辑三文件极简架构整个项目只有3个核心文件无构建、无打包、无node_modules2048-game/ ├── index.html # 主页面HTML骨架 内联CSS 内联JS ├── style.css # 纯样式网格布局、tile动画、响应式断点 └── assets/ # 图片资源目录可选 ├── tile-2.png # 数字2的图标若不用base64 ├── tile-4.png # ... └── background.jpg # 游戏背景index.html中JS部分采用立即执行函数IIFE封装避免全局变量污染script (function() { // 所有游戏逻辑在此闭包内 const gameState { /* ... */ }; function init() { bindEvents(); addRandomTile(); addRandomTile(); // 初始两个块 updateView(); } // 导出关键函数供HTML事件调用 window.game { move: move, restart: restart }; // 页面加载完成启动 document.addEventListener(DOMContentLoaded, init); })(); /script提示window.game暴露有限接口既方便HTML里onclickgame.move(up)调用又防止外部随意修改gameState。这是小型项目兼顾封装与便捷的合理折中。3.2 图片资源处理base64内联 vs 本地路径的权衡项目提供两种图片方案推荐方案base64内联将tile-2.png等小图转为base64字符串直接写入CSS.tile-2 { background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...); }优势单文件交付无HTTP请求离线可用规避CORS问题。适用场景图标类小图10KB本项目所有tile图均适用。备选方案本地路径保留assets/目录CSS中引用相对路径.tile-2 { background-image: url(./assets/tile-2.png); }优势图片可单独编辑、压缩开发者工具里可直观查看资源。注意需确保服务器支持静态文件服务file://协议下部分浏览器会禁用url()加载此时必须用base64。实际项目中我一般会同时提供两套CSSstyle-inline.cssbase64版和style-assets.css路径版在index.html里用注释开关!-- link relstylesheet hrefstyle-assets.css -- link relstylesheet hrefstyle-inline.css这样新手开箱即用熟手可按需切换。3.3 响应式适配移动端触摸事件的降级处理PC端用键盘↑↓←→移动端必须支持touch。项目用touchstarttouchend模拟方向let touchStartX 0; let touchStartY 0; document.addEventListener(touchstart, e { touchStartX e.touches[0].clientX; touchStartY e.touches[0].clientY; e.preventDefault(); // 阻止默认滚动 }, { passive: false }); document.addEventListener(touchend, e { if (!touchStartX || !touchStartY) return; const touchEndX e.changedTouches[0].clientX; const touchEndY e.changedTouches[0].clientY; const diffX touchEndX - touchStartX; const diffY touchEndY - touchStartY; // 判定主方向|Δx| |Δy| 为水平否则垂直 if (Math.abs(diffX) Math.abs(diffY)) { if (diffX 30) game.move(right); else if (diffX -30) game.move(left); } else { if (diffY 30) game.move(down); else if (diffY -30) game.move(up); } touchStartX 0; touchStartY 0; });关键参数30px是滑动阈值太小易误触太大难触发。经实测30px在iPhone SE到iPad Pro上手感均衡。passive: false和e.preventDefault()必不可少否则iOS Safari会拦截touch事件。4. 避坑指南新手必踩的5个边界问题与修复方案4.1 现象移动后分数不更新或更新值翻倍原因moveLeft等函数中gameState.score filtered[i] * 2被多次执行——可能因for循环未正确跳过已合并项或move函数被重复调用如键盘连按未防抖。解决在move函数开头加防抖if (isMoving) return; isMoving true;结尾setTimeout(() isMoving false, 100);合并逻辑中严格用i跳过下标避免[2,2,2]被算成两次合并4.2 现象手机上滑动无效或触发页面滚动原因touchstart事件监听器未设passive: false或e.preventDefault()未在touchstart中调用iOS要求必须在touchstart里阻止默认行为。解决监听器必须带{ passive: false }选项touchstart回调中立即执行e.preventDefault()不能只在touchend里4.3 现象新块总出现在同一位置如固定第0行第0列原因addRandomTile函数中随机坐标生成逻辑错误如Math.floor(Math.random() * 4)写成Math.floor(Math.random() * 3)导致范围变小或未检查该位置是否为空就强行赋值。解决正确生成const row Math.floor(Math.random() * 4); const col Math.floor(Math.random() * 4);必须循环检查while (gameState.board[row][col] ! 0) { row ...; col ...; }避免死循环要加计数器4.4 现象游戏结束判定失效满格但仍有可合并项原因isGameOver函数只检查board是否全非零未检查是否存在相邻相同数字即忽略合并可能性。解决复用canMove()函数gameState.isOver !canMove();不要仅用board.flat().every(n n ! 0)4.5 现象CSS动画卡顿尤其在低端安卓机原因tile元素过多时transform: translate()触发重排且未启用硬件加速。解决给.tile添加will-change: transform;确保transform使用translate3d(0,0,0)而非translate()虽现代浏览器已自动提升但显式声明更稳在updateView中用documentFragment批量添加节点而非逐个appendChild5. 进阶技巧从“能跑”到“可维护”的三步改造5.1 第一步分离渲染层支持主题切换当前所有样式硬编码在CSS里想换深色模式得手动改几十处颜色值。更好的做法是抽离“主题配置”const themes { default: { background: #bbada0, tileColors: { 2: #eee4da, 4: #ede0c8, 8: #f2b179 }, textColor: #776e65 }, dark: { background: #2c2c2c, tileColors: { 2: #3a3a3a, 4: #4a4a4a, 8: #5a5a5a }, textColor: #f9f6f2 } }; function applyTheme(themeName) { const theme themes[themeName]; document.body.style.backgroundColor theme.background; Object.entries(theme.tileColors).forEach(([val, color]) { const els document.querySelectorAll(.tile-${val}); els.forEach(el el.style.backgroundColor color); }); document.querySelectorAll(.tile).forEach(el el.style.color theme.textColor); }落地效果只需调用applyTheme(dark)整个界面秒变深色。后续加新主题只需扩展themes对象无需碰CSS。这是MVC思想在小项目里的朴素体现——视图CSS与数据theme config分离。5.2 第二步用Web Storage持久化不只是bestScorelocalStorage目前只存bestScore其实可存更多function saveState() { const state { board: gameState.board, score: gameState.score, bestScore: gameState.bestScore, timestamp: Date.now() }; localStorage.setItem(2048-state, JSON.stringify(state)); } function loadState() { const saved localStorage.getItem(2048-state); if (saved) { const state JSON.parse(saved); gameState.board state.board; gameState.score state.score; gameState.bestScore state.bestScore; updateView(); } }关键细节JSON.stringify会丢失数组的Array.isArray()特性但board是纯数字二维数组JSON.parse还原后仍可用。若需存函数或Date对象才需考虑replacer/reviver参数。此处保持简单。5.3 第三步添加键盘快捷键与无障碍支持当前只支持方向键但用户可能习惯WASD或JKLI。扩展handleKeydownfunction handleKeydown(e) { let direction null; switch(e.key) { case ArrowUp: case w: case W: case k: case K: direction up; break; case ArrowDown: case s: case S: case j: case J: direction down; break; case ArrowLeft: case a: case A: case h: case H: direction left; break; case ArrowRight: case d: case D: case l: case L: direction right; break; case r: case R: game.restart(); return; // R键重启 } if (direction) { e.preventDefault(); // 阻止滚动 game.move(direction); } }无障碍增强给.grid-container加roleregion和aria-label2048游戏棋盘给每个.tile加rolestatus和动态aria-label如数字8位于第2行第3列。实测NVDA、VoiceOver均可读出当前格子值。从那以后我每次重构小项目都强制走一遍这三步先抽主题、再存状态、最后加快捷键。不是为了炫技而是训练自己把“临时能跑”的代码变成“下周还愿改”的代码。它不增加功能但让下次加需求时少花一半时间找bug。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑