
2048自定义实战:前端避坑指南与完整代码
看了一堆教程还是不会写项目?别急,这不是你的问题,是教程没讲透。
很多刚入行的朋友,盯着屏幕上的代码看了三遍,手一敲就报错,或者逻辑跑不通。其实前端开发里,2048自定义是个绝佳的练手项目。它逻辑清晰,代码量适中,但坑不少。
今天这篇避坑指南,我不讲虚的,直接带你从环境搭建到代码实现,把2048游戏的核心逻辑拆碎了揉烂了讲给你听。哪怕你基础薄弱,跟着敲完这篇,也能真正理解前端状态管理和DOM操作。
概念速懂:2048到底在考什么
很多人以为2048就是画几个格子,其实它的核心在于状态管理和碰撞检测。
你可以把2048想象成一个4x4的矩阵。每一步操作(上、下、左、右),本质上是把矩阵里的数字往一个方向压缩。如果有相同数字相邻,就合并并翻倍。
这里有个关键概念:不可变数据更新。在React或Vue里,我们不能直接修改原数组,而是生成一个新的数组状态。如果你直接修改board[i][j],页面可能不会刷新,或者出现状态不同步的Bug。
对于在职转行的朋友来说,理解这个“状态驱动UI”的思维,比背代码重要得多。你在工地画图,讲究的是图纸规范,代码也一样,逻辑规范了,Bug自然少。
环境准备:别让工具卡住你
别去装那些花里胡哨的全家桶,咱们用最轻量的方式。编辑器:VS Code。装个Live Server插件,写完代码自动刷新浏览器,省得你手动F5。
语言:纯JavaScript (ES6+)。不需要Node.js,不需要Webpack,不需要React。一个index.html文件搞定。
参考:MDN Web Docs。这是前端开发的圣经。遇到不懂的API,比如Array.prototype.map或者event.preventDefault,直接去MDN查,那是最权威的来源,别信百度上的那些过时的教程。避坑点:
很多人喜欢一上来就用Vue或React。但对于学习核心逻辑来说,框架会掩盖底层原理。就像你刚学砌墙,得先知道砖怎么放,别一上来就用脚手架。先写原生JS,通了逻辑,再套框架,事半功倍。
核心语法:矩阵操作与事件监听
2048的代码核心就两块:移动逻辑和渲染逻辑。
1. 矩阵初始化
我们要维护一个二维数组board,初始全是0。
// 创建一个4x4的二维数组,初始值为0
let board = [[0, 0, 0, 0],[0, 0, 0, 0],[0, 0, 0, 0],[0, 0, 0, 0]
];2. 监听键盘事件
用户按方向键,我们要捕获这个事件。注意,keydown事件会重复触发,比如你按住一个键不放,它会疯狂触发。所以我们要加一个isMoving标志位,防止逻辑混乱。
// 监听键盘按键
document.addEventListener('keydown', (e) = {// 如果是方向键,阻止默认行为(防止页面滚动)if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {e.preventDefault();// 如果正在移动中,忽略本次输入if (isMoving) return;isMoving = true;// 根据方向调用对应的移动函数switch (e.key) {case 'ArrowLeft':moveLeft();break;case 'ArrowRight':moveRight();break;case 'ArrowUp':moveUp();break;case 'ArrowDown':moveDown();break;}// 延迟重置标志位,给动画一点时间setTimeout(() = {isMoving = false;}, 100);}
});完整代码示例:从零到一
下面是一个可运行的完整示例。我把它分成几个模块,每个模块都加了注释,你复制粘贴到index.html里就能跑。
HTML结构
!DOCTYPE html
html lang=en
headmeta charset=UTF-8meta name=viewport content=width=device-width, initial-scale=1.0title2048 Custom/titlestylebody {display: flex;justify-content: center;align-items: center;height: 100vh;margin: 0;background-color: #faf8ef;font-family: sans-serif;}#game-container {background-color: #bbada0;padding: 10px;border-radius: 6px;}.grid {display: grid;grid-template-columns: repeat(4, 100px);grid-gap: 10px;}.cell {width: 100px;height: 100px;background-color: #cdc1b4;border-radius: 3px;display: flex;justify-content: center;align-items: center;font-size: 40px;font-weight: bold;color: #fff;}.cell[data-value=2] { background-color: #eee4da; color: #776e65; }.cell[data-value=4] { background-color: #ede0c8; color: #776e65; }.cell[data-value=8] { background-color: #f2b179; }.cell[data-value=16] { background-color: #f59563; }.cell[data-value=32] { background-color: #f67c5f; }.cell[data-value=64] { background-color: #f65e3b; }.cell[data-value=128] { background-color: #edcf72; }.cell[data-value=256] { background-color: #edcc61; }.cell[data-value=512] { background-color: #edc850; }.cell[data-value=1024] { background-color: #edc22e; font-size: 30px; }.cell[data-value=2048] { background-color: #edc22e; font-size: 30px; }.score {margin-top: 10px;text-align: center;font-size: 20px;color: #776e65;}/style
/head
bodydiv id=game-containerdiv class=scoreScore: span id=score0/span/divdiv class=grid id=grid/div/divscript src=game.js/script
/body
/htmlJavaScript核心逻辑 (game.js)
这是最关键的部分。我用了通用化的写法,把“左移”的逻辑写好,其他三个方向通过旋转矩阵来实现。这样代码量少,且逻辑统一。
// 全局状态
let board = Array(4).fill().map(() = Array(4).fill(0));
let score = 0;
let isMoving = false;// DOM元素
const gridEl = document.getElementById('grid');
const scoreEl = document.getElementById('score');// 初始化游戏
function initGame() {// 生成两个随机块addRandomTile();addRandomTile();render();
}// 渲染游戏界面
function render() {gridEl.innerHTML = '';for (let i = 0; i 4; i++) {for (let j = 0; j 4; j++) {const cell = document.createElement('div');cell.classList.add('cell');const value = board[i][j];if (value 0) {cell.textContent = value;cell.setAttribute('data-value', value);}gridEl.appendChild(cell);}}scoreEl.textContent = score;
}// 添加随机块
function addRandomTile() {const emptyCells = [];for (let i = 0; i 4; i++) {for (let j = 0; j 4; j++) {if (board[i][j] === 0) {emptyCells.push([i, j]);}}}if (emptyCells.length === 0) return;const [i, j] = emptyCells[Math.floor(Math.random() * emptyCells.length)];// 90%概率是2,10%概率是4board[i][j] = Math.random() 0.9 ? 4 : 2;
}// 核心:向左移动逻辑
function moveLeft() {let moved = false;for (let i = 0; i 4; i++) {// 过滤出非零元素let row = board[i].filter(val = val !== 0);// 合并相邻相同元素let mergedRow = [];for (let j = 0; j row.length; j++) {if (row[j] === row[j + 1]) {mergedRow.push(row[j] * 2);score += row[j] * 2;moved = true;j++; // 跳过下一个,因为已经合并了} else {mergedRow.push(row[j]);}}// 补零,保持数组长度为4while (mergedRow.length 4) {mergedRow.push(0);}// 检查是否真的移动了for (let j = 0; j 4; j++) {if (board[i][j] !== mergedRow[j]) {moved = true;break;}}board[i] = mergedRow;}return moved;
}// 通用移动处理:通过旋转矩阵将其他方向转化为“左移”
function move(direction) {// 保存原始状态,用于判断是否移动let originalBoard = JSON.parse(JSON.stringify(board));// 旋转矩阵,使得当前方向变为“左”if (direction === 'up') {board = rotateMatrix(board, 270);} else if (direction === 'right') {board = rotateMatrix(board, 180);} else if (direction === 'down') {board = rotateMatrix(board, 90);}// 执行左移逻辑let moved = moveLeft();// 旋转回原方向if (direction === 'up') {board = rotateMatrix(board, 90);} else if (direction === 'right') {board = rotateMatrix(board, 180);} else if (direction === 'down') {board = rotateMatrix(board, 270);}// 如果移动了,添加新块并渲染if (moved) {addRandomTile();render();checkGameOver();} else {// 如果没移动,恢复原状态(虽然这里逻辑上没变,但为了严谨)board = originalBoard;}
}// 矩阵旋转辅助函数
function rotateMatrix(matrix, degrees) {// 简化版:只支持90的倍数let result = matrix;for (let i = 0; i degrees / 90; i++) {result = result[0].map((_, colIndex) =result.map(row = row[colIndex])).reverse();}return result;
}// 检查游戏是否结束
function checkGameOver() {// 如果还有空格,或者还有可合并的,就没结束for (let i = 0; i 4; i++) {for (let j = 0; j 4; j++) {if (board[i][j] === 0) return;if (j 3 board[i][j] === board[i][j + 1]) return;if (i 3 board[i][j] === board[i + 1][j]) return;}}alert(Game Over! Score: + score);initGame();
}// 事件监听
document.addEventListener('keydown', (e) = {if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {e.preventDefault();if (isMoving) return;isMoving = true;let direction = e.key.replace('Arrow', '').toLowerCase();move(direction);setTimeout(() = {isMoving = false;}, 100);}
});// 启动游戏
initGame();重点解析:rotateMatrix:这是为了减少代码重复。如果直接写四个方向的逻辑,代码会很长且容易出错。通过旋转,我们把问题简化为单一方向的“左移”。
JSON.parse(JSON.stringify(board)):这是深拷贝的一种简单方式。在判断是否移动前,我们需要保留原状态。虽然性能稍差,但对于4x4的小矩阵,完全够用。
checkGameOver:简单的遍历检查。只要有一个空格,或者有两个相邻相同的数字,游戏就没结束。常见报错与避坑指南
在实际运行中,你可能会遇到以下几个问题:
1. 浏览器滚动条跳动
现象:按方向键时,页面跟着滚动。
原因:方向键默认行为是滚动页面。
解决:在keydown事件中,务必加上e.preventDefault()。我在上面的代码里已经加上了,但你自己写的时候容易漏。
2. 状态不同步,页面不刷新
现象:逻辑变了,但界面上的数字没变。
原因:你直接修改了DOM,而不是通过render()函数重新渲染。或者你修改了board数组,但没触发渲染。
解决:确保每次状态改变后,都调用render()。这是单向数据流的核心原则:数据变 - 视图变。
3. 连续快速按键导致Bug
现象:快速按两次方向键,数字合并逻辑错乱。
原因:动画还没结束,下一次移动就开始了。
解决:使用isMoving标志位。在移动开始时设为true,结束(或延迟一小段时间)后设为false。在keydown中检查这个标志,如果为true,直接忽略本次输入。
4. 旋转逻辑错误
现象:按“上”键,数字往“下”跑。
原因:旋转角度搞反了。
解决:拿张纸画一下。顺时针旋转90度,第一列变成第一行。逆时针旋转90度(即270度),第一列变成最后一行。多测试几次,画出来对比。
小结:从代码到思维
写2048,不仅仅是为了做一个小游戏,更是为了锻炼你的逻辑思维和调试能力。
对于在职转行的朋友,我建议你:不要抄代码:看着我的代码,先自己试着写,写不出来再看。
多打印Log:在moveLeft函数的关键步骤加console.log,看看数组是怎么变化的。
理解MDN:遇到不懂的API,去MDN Web Docs查文档。这是最可靠的学习方式。前端开发,入门容易,精通难。但只要你能把2048这个逻辑吃透,你就已经迈出了坚实的一步。
还有什么不懂的?评论区留言挨个回。 无论是报错截图,还是逻辑疑问,都欢迎交流。咱们一起把这坑填平。