
很多朋友做前端练手项目第一个想到的就是贪吃蛇但问到我这里我一般更推荐俄罗斯方块。原因很直接在WEB小游戏开发这个领域里俄罗斯方块是“麻雀虽小、五脏俱全”的典型代表。它的规则看起来只有一句话——方块落下来、填满一行、消掉——但实现它需要你同时处理好数据结构、渲染、输入响应、碰撞检测、计分、状态机这些游戏开发的基础环节一样都不少。这篇文章不是从零讲语法而是把我做这个项目时的完整思路沉淀一下从棋盘和方块的抽象、Canvas渲染、主循环控制到旋转算法、碰撞检测、消行逻辑再到计分、等级、状态机最后把我真实调试时踩过的坑整理出来。如果你打算用HTML加JavaScript做一个能直接在浏览器里跑的俄罗斯方块或者只是想看看一个经典游戏项目是怎么组织的这篇应该能给你一个比较完整的参考。先把话说明白这篇内容适合谁来读刚学完JavaScript基础、想做一个“有模有样”实战项目的同学这个项目非常合适已经做过几个小游戏、想优化项目结构的朋友后面关于碰撞检测和状态机的内容也能提供一些思路。我不会把完整代码从头贴到尾但核心函数都会给出配合说明你完全可以按这个思路自己凑出一个能玩的版本。1. 为什么我最终选了俄罗斯方块而不是贪吃蛇、扫雷1.1 它的复杂程度刚好卡在“能学东西但不劝退”的位置俄罗斯方块的规则太简单了简单到全世界的人不用看说明书就能玩。但作为项目来开发它一点儿不简单。棋盘的抽象、方块形状的表示、旋转的数学运算、碰撞检测的边界条件、消行时数组的增删操作这些全是真刀真枪的编程问题。更妙的是这些问题都处在“稍微动脑就能解决”的区间不会让你卡到怀疑人生又足够让你学到东西。1.2 一个项目能练到游戏开发的大部分基础能力我个人的体会是如果直接去写一个带角色动画、物理引擎、复杂关卡的游戏新人很容易在某个环节被折磨到放弃。俄罗斯方块把“玩法内核”完整保留下来却不需要美术资源不需要音频不需要物理引擎。你要做的核心工作很纯粹把二维数组、矩阵转置、碰撞检测、定时器、键盘事件这些基础知识组合成一个完整可运行的程序。这套能力可以平移到任何游戏开发场景里再往后做其他小游戏、甚至做产品后台里的交互模块底子都是通的。1.3 从零到能玩的时间很短正反馈来得快我第一次做这个项目大概一个晚上就把核心部分写完了看到方块能下落、能旋转、能消行的时候那种成就感是实打实的。而且它特别适合迭代开发先让方块自由下落再加左右移动再加旋转再处理碰撞最后补上计分和音效。每加一个功能游戏就变得更完整一点这种正反馈是支撑你把手头项目做完最重要的动力。后面我把代码整理成单个HTML文件保存成tetris.html浏览器一打开就能玩发给朋友也方便。2. 动手写代码前先把棋盘和方块的数据结构定下来2.1 棋盘用二维数组10列乘20行0代表空格棋盘是整个游戏的“地图”我选10列20行这个经典尺寸。用二维数组表示数组第0行对应屏幕最上方第19行对应底部每个位置存一个数字0表示空格非0表示已经有方块。这样设计的好处很直接后面做碰撞检测时只需要访问board[row][col]就能知道某个位置有没有被占用不需要额外维护一套格子状态。const COLS 10; const ROWS 20; let board Array.from({ length: ROWS }, () Array(COLS).fill(0));这里有一个非常经典的坑必须提醒一下不要用new Array(ROWS).fill(new Array(COLS).fill(0))来初始化棋盘因为fill如果传入的是同一个数组引用那么所有行会指向同一块内存你改一行等于改全部。我见过不少新手在这个地方debug一整天最后发现棋盘整个乱掉就是这个引用问题导致的。2.2 七种方块全部用形状矩阵表达俄罗斯方块有七种基础方块I、J、L、O、S、T、Z。它们的共同特点都是由四个小方格组成也就是四格方块Tetromino。每个人的第一反应可能是记录每个方块的四对坐标比如[[0,0], [0,1], [0,2], [0,3]]但我更推荐用二维矩阵表达形状1表示有格子0表示空。为什么因为矩阵天然支持旋转运算——顺时针旋转90度等于先转置再水平翻转逆时针则为先转置再垂直翻转这比逐个坐标去换算省事得多也几乎不会出错。const SHAPES { I: [[1, 1, 1, 1]], J: [[1, 0, 0], [1, 1, 1]], L: [[0, 0, 1], [1, 1, 1]], O: [[1, 1], [1, 1]], S: [[0, 1, 1], [1, 1, 0]], T: [[0, 1, 0], [1, 1, 1]], Z: [[1, 1, 0], [0, 1, 1]] };实际项目里可以把矩阵里的1替换成颜色编号比如1代表青色、2代表蓝色、3代表紫色。这样在渲染的时候可以方便地读取矩阵值和棋盘值来映射颜色不需要额外维护“方块类型到颜色”的映射表。我这里的写法是SHAPES只负责形状颜色由另一个表负责如果你追求代码精简直接让SHAPES里的数字表示颜色编号也行。2.3 当前活动方块只需要一个对象来跟踪棋盘是静态数据正在移动的那一块是动态数据需要单独跟踪。我习惯用一个current对象保存三样东西形状矩阵、在棋盘坐标系中的列坐标x、行坐标y。let current { shape: SHAPES.T, x: 3, y: 0 };为什么x初始值是3因为棋盘只有10列大多数方块宽度是2或3放中间偏左一点视觉上比较舒服。之后每次移动、旋转、下落都先基于这个对象算出新的位置再做碰撞检查通过就更新位置不通过就放弃操作。记住一个原则永远不要直接修改棋盘的二维数组来表示活动方块否则碰撞检测会把“自己”当成“障碍物”这是初学者最容易绕进去的死胡同。3. 渲染方案我用Canvas绘制而不用DOM拼格子3.1 DOM格子方案的问题在哪里最直观的渲染方案是给每个格子生成一个div再用CSS Grid排布。10乘20就是200个格子数量确实不多但问题在于每次方块下落一格你要把旧位置的样式清掉、新位置的样式加上相关DOM操作会非常频繁。如果后面还想加“消行闪动”这类动画DOM频繁更新会引发重排重绘在低端手机和性能一般的笔记本上能明显感觉到卡顿。3.2 Canvas绘制的核心实现Canvas方案则更像“画画”把整个游戏区域当成一块画布每次需要更新时把棋盘上的所有格子重新画一遍。因为格子数量少全量重绘的开销其实很小代码也更好组织。我通常只保留一个核心函数drawCell负责在指定行列位置画一个带边框的小矩形。const GRID_SIZE 30; function drawCell(ctx, col, row, color) { ctx.fillStyle color; ctx.fillRect(col * GRID_SIZE, row * GRID_SIZE, GRID_SIZE - 1, GRID_SIZE - 1); }每次重绘时先遍历board有方块就画对应颜色再遍历当前活动方块按对应颜色画。注意GRID_SIZE - 1这个细节减掉1像素是为了让格子之间留出一条细缝游戏画面看起来更清晰。如果不减所有格子会连成一片边界很难区分。3.3 全量重绘就够了别急着做“脏矩形”优化有人会想能不能只重绘变化的那几个格子做性能优化我直接说结论对俄罗斯方块来说没有这个必要。整个棋盘最多200个格子加上活动方块不到4个格子每次重绘循环200多次对现代浏览器就是个零头。与其优化渲染不如把精力放在逻辑正确性上。如果哪一天你发现帧率不对先去检查是不是别的地方阻塞了主线程比如在tick里做了大量字符串拼接或者DOM查询而不是怀疑Canvas绘制太慢。3.4 背景网格的绘制技巧为了让画面更精致可以在绘制方块之前先画浅色网格线。我习惯先设置ctx.strokeStyle #ddd然后逐行逐列绘制strokeRect最后再画方块。网格线颜色尽量淡一些不要和方块颜色冲突否则玩家分辨不清边界容易误判。如果你想偷懒不画网格线也可以方块本身的1像素缝隙已经能提供足够的视觉参考。4. 游戏主循环下落节奏到底由谁控制4.1 setInterval和requestAnimationFrame我为什么选setInterval俄罗斯方块有一个核心概念叫“下落节奏”方块静止不动经过固定的时间间隔后往下落一格。这个机制用setInterval表达最自然每次定时器触发就执行一次tick把当前方块往下移动一格。而requestAnimationFrame更适合需要每一帧都更新画面的游戏比如动作游戏和跑酷游戏它会在浏览器即将重绘时回调保证动画流畅。俄罗斯方格在视觉上不是平滑的位移而是“一步一顿”的位移所以setInterval完全够用也不用担心60FPS的概念。let timer null; function startLoop(speed) { if (timer) clearInterval(timer); timer setInterval(tick, speed); }这里补充一个进阶思路如果后面想做“方块落定后闪一下再消行”这类动画可以把setInterval只当作逻辑节拍器渲染部分再放到requestAnimationFrame里做插值。但初始版本不要引入这种复杂度先把核心逻辑跑通再说。4.2 等级速度表比公式换算更可控为了让游戏越玩越快等级越高下落间隔越短。我维护一个速度表而不是用一个公式去换算因为速度在低等级时要让人适应高等级时要给人压迫感表格数值更直观、更容易调整平衡性。const LEVEL_SPEED [800, 700, 600, 500, 400, 300, 200, 150, 120, 100, 80];LEVEL_SPEED[level - 1]就是当前等级的下落间隔单位毫秒。等级1时800毫秒下落一格等级10时100毫秒下落一格。注意当等级超过表格长度时取最后一个值不要越界。每次升级时调用startLoop重新设置定时器速度就生效了。4.3 tick函数里到底要做哪几件事每一步tick的逻辑顺序非常重要我总结成三步把当前方块向下尝试移动一格如果遇到碰撞就把当前方块固定到棋盘上固定之后检查是否有满行有就消行并加分生成下一个新方块。这三步的顺序不能乱。尤其注意必须先固定、再消行、最后生成新方块。如果先消行再固定新方块可能刚生成就被消行逻辑误伤如果先生成再固定还没落地就先被判定碰撞游戏直接结束。5. 方块操作移动、旋转、加速下落和硬降的细节5.1 旋转算法矩阵转置加行反转方块旋转是俄罗斯方块最需要数学功底的部分。顺时针旋转90度的公式是先把矩阵的行列下标交换也就是转置再把每一行倒序。写成代码非常精简function rotateMatrix(matrix) { return matrix[0].map((_, index) matrix.map(row row[index]).reverse() ); }这段代码虽然短但建议你自己手写推导一遍。拿T方块举例旋转前是3行2列旋转后是2行3列形状会从“凸”变成“凸”的另一种方向。注意方向问题玩家按上箭头时游戏通常默认顺时针旋转不要搞反。如果你实现的是逆时针旋转很多方块的手感会完全不一样。5.2 Wall Kick旋转时撞到边界要会“找补”方块旋转后形状矩阵的宽度和高度可能发生变化。比如I方块从横向变成纵向如果它当时贴靠右边界旋转后有一部分会超出棋盘。标准的俄罗斯方块实现中有一个叫Wall Kick的规则旋转后如果发生碰撞尝试把方块向左或向右平移一列最多试几个偏移量能塞进去就采用。function tryRotate() { const rotated rotateMatrix(current.shape); const kicks [0, -1, 1, -2, 2]; for (let offset of kicks) { if (!collides(rotated, current.x offset, current.y)) { current.shape rotated; current.x offset; return; } } }不要小看这几行代码。没有Wall Kick玩家靠近左右边界时按旋转键会直接没反应手感非常憋屈加上之后游戏体验立刻上一个台阶。这也是为什么很多人的“俄罗斯方块”能跑但玩起来总觉得僵硬而正版或者更好的克隆版却非常顺滑——很多手感差异就藏在这种细节里。5.3 键盘事件与长按重复的“手感控制”监听keydown事件就够了但有一个坑按住方向键时浏览器会自动连续触发keydown导致方块快速连续移动。这不是我们想要的手感。我一般先判断e.repeat如果是重复事件就直接忽略只处理第一次按下document.addEventListener(keydown, (e) { if (e.repeat) return; switch (e.key) { case ArrowLeft: moveLeft(); break; case ArrowRight: moveRight(); break; case ArrowDown: softDrop(); break; case ArrowUp: tryRotate(); break; case : hardDrop(); break; case p: togglePause(); break; } e.preventDefault(); });注意ArrowDown是软降按一次走一格空格才是硬降直接落到底。这两个概念要分清楚很多教程把它们混在一起导致玩家没法精确控制下落。e.preventDefault()的作用是防止页面随方向键滚动谁不想在浏览器里手滑把页面滚上去。6. 碰撞检测与消行这两个函数写对了游戏就成了大半6.1 碰撞检测的三层检查这是整款游戏最重要的函数没有之一。它接收方块形状矩阵和目标坐标(x, y)返回布尔值表示是否碰撞。逻辑上要检查三层内容列坐标是否超出左边界或右边界行坐标是否到达棋盘底部目标位置是否已经被已有方块占用。function collides(shape, x, y) { for (let r 0; r shape.length; r) { for (let c 0; c shape[r].length; c) { if (!shape[r][c]) continue; const nx x c; const ny y r; if (nx 0 || nx COLS || ny ROWS) return true; if (ny 0 board[ny][nx]) return true; } } return false; }特别要注意ny 0的情况方块刚生成时有一部分可能在棋盘顶部之外。此时只检查左右边界和底部不检查已有方块。否则方块刚出现就被判定碰到顶部游戏根本没法开始。6.2 消行逻辑从底部往上扫splice加unshift消行是整个游戏里最容易写错的地方我看过太多版本在消行后留下“幽灵空格”。错误写法是发现满行后直接把这行清零这样会导致上方行不下落棋盘出现空洞。正确做法是把满行从数组中剪切掉再在头部补一行空行。function clearLines() { let lines 0; for (let row ROWS - 1; row 0; row--) { if (board[row].every(cell cell ! 0)) { board.splice(row, 1); board.unshift(Array(COLS).fill(0)); lines; row; } } return lines; }为什么要row因为splice之后原来第row1行的内容掉到了row这个位置我们要重新检查这一行所以把循环变量往回拨一格否则会漏掉连续消两行的情况。这个细节非常关键建议你写完这个函数后构造一个两行同时满的测试用例跑一遍立刻就能体会到它的重要性。提示消行时一定要从底部往上扫而不是从上往下。因为splice之后行会往下掉从上往下扫会漏掉刚刚下沉的那一行。6.3 方块固定和消行的联动当方块落定后先把它写入board再调用clearLines。写入时注意坐标系转换当前方块的shape[r][c]对应棋盘的第y r行、第x c列。function lockPiece() { for (let r 0; r current.shape.length; r) { for (let c 0; c current.shape[r].length; c) { if (current.shape[r][c]) { board[current.y r][current.x c] current.colorIndex; } } } }写完固定逻辑后顺手把clearLines的返回值算进计分。这里如果不小心把固定和消行的顺序写反会出现“方块已经堆到顶部但游戏还在出块”的诡异现象排查起来特别费劲。7. 计分、等级与状态机把一个Demo做成完整产品7.1 经典计分规则为什么要连续消行更高俄罗斯方块的计分不是线性增长而是阶梯式的。常用的计分表是这样单次消除行数得分1100230035004800为什么1行100、2行300而不是200因为游戏奖励的是连续消除的能力。一次消四行说明玩家通过一次硬降和精准摆放创造了极大价值分数理应远超四行单独的累加。这套数值不是写死的你可以根据自己游戏的难度来做平衡。想让游戏更硬核可以提高连续消除的加成权重比如2行350、3行700、4行1200。7.2 等级提升与速度联动我通常每消10行升一级每升一级从速度表里取更小的下落间隔。等级可以用累计消行数直接算出来const level Math.floor(totalLines / 10) 1; const speed LEVEL_SPEED[Math.min(level - 1, LEVEL_SPEED.length - 1)];升级之后不要重新初始化棋盘只需要调用startLoop(speed)重新设置定时器即可。建议升级时在界面上做一个明显的提示哪怕只是闪烁一下“Level 2”的字样否则玩家会突然觉得“怎么变快了”而不知所措。7.3 状态机开始、暂停、结束不能乱一个完整的游戏至少有四种状态准备中、游戏中、已暂停、已结束。我用一个枚举量管理const GameState { READY: ready, PLAYING: playing, PAUSED: paused, OVER: over }; let state GameState.READY;切换规则是这样的准备状态按开始进入游戏状态游戏状态按P进入暂停状态暂停状态按P回到游戏状态游戏过程中新方块一生成就碰撞则进入结束状态结束状态按重新开始回到准备状态。把状态判断写进各个事件处理函数里能避免很多低级bug键盘处理函数第一行就检查state ! GameState.PLAYING时直接返回定时器在PLAYING状态才执行下落暂停时停止定时器恢复时重新启动。7.4 游戏结束判定新方块一生成就检查当lockPiece之后生成新方块时先检查新方块的位置是否与棋盘碰撞如果碰撞就直接进入结束状态。注意生成新方块的y通常是0但棋盘上方可能已经被堆满这时该位置可能已经被占用。标准做法是function spawnPiece() { const type randomType(); current { shape: SHAPES[type], x: 3, y: 0, colorIndex: typeColor(type) }; if (collides(current.shape, current.x, current.y)) { state GameState.OVER; stopLoop(); } }这里有一个容易忽略的点如果方块生成时已经部分超出顶部因为矩阵里包含了0空位碰撞检测应该忽略ny 0的部分。这就是第6.1节里那句ny 0判断如此重要的原因。8. 真实调试中踩过的坑不跑几遍真的发现不了8.1 旋转后坐标要修正否则方块会“漂移”我最早做旋转时直接current.shape rotateMatrix(current.shape)结果方块贴着右侧旋转时经常有一半露在棋盘外。原因很简单旋转后矩阵的宽度变了但current.x没有跟着变。前面的Wall Kick方案本质上就是解决这个问题。调试时有个实用技巧每次旋转后打印current.shape和current.x肉眼对比边界值很快就能发现规律。8.2 硬降千万不要一步到位穿模就是这么来的最开始我把硬降写成current.y ROWS - current.shape.length结果方块经常穿过已有方块卡进地图里因为我没有考虑碰撞检测纯粹按底部高度直接算。正确的做法是用一个while循环每次向下尝试移动一格做碰撞检测直到撞到为止再让方块落到这个位置。虽然看起来代码长了一点但逻辑完全正确永远不会穿模。function hardDrop() { while (!collides(current.shape, current.x, current.y 1)) { current.y; } lockPiece(); }8.3 把e.repeat的判断加上手感立刻就干净了不加e.repeat判断之前玩家按住空格想硬降方块会在一秒内连续硬降多次直接冲到最底部甚至触发多次消行游戏体验非常混乱。加上e.repeat判断后每次按键只触发一次逻辑手感干净利落。还有人会用keyup做更细腻的输入缓冲但对俄罗斯方块来说keydown加e.repeat判断已经足够。8.4 移动端最少要做的三件事现在的游戏不能只在电脑上跑手机浏览器也要能用。如果你打算加移动端支持至少要做三件事加几个虚拟按钮左、右、旋转、软降、硬降分别绑定对应函数给按钮加touchstart和touchend在touchstart里调用e.preventDefault()防止手指滑动触发页面滚动设置viewport之后让Canvas按devicePixelRatio缩放否则移动端屏幕会显示模糊。canvas.width CANVAS_WIDTH * window.devicePixelRatio; canvas.height CANVAS_HEIGHT * window.devicePixelRatio; canvas.style.width CANVAS_WIDTH px; canvas.style.height CANVAS_HEIGHT px; ctx.scale(window.devicePixelRatio, window.devicePixelRatio);这块是很多移动版小游戏容易忽略的。我已经不止一次看到游戏在手机浏览器上被“缩放”得乱七八糟就是没处理DPR导致的。8.5 浏览器切后台定时器会被“降频”浏览器切到后台后setInterval会被降频甚至暂停等切回来时方块可能“瞬移”好几格游戏直接崩了。最简单的缓解方案是在页面重新可见时重置计时器或者直接强制游戏暂停。如果你想要更精细的体验可以在切回时用时间戳差值补算方块下落了几格但初始版本先暂停就足够安全。document.addEventListener(visibilitychange, () { if (document.hidden state GameState.PLAYING) { togglePause(); } });最后说点个人体会。俄罗斯方块这个项目我先后做过三个版本纯逻辑版、Canvas版、最后加上移动端适配版。每一次重构都有新收获尤其是当你把碰撞检测和消行逻辑写得足够干净时后面加什么功能都顺手。如果你也想尝试WEB小游戏开发我建议你亲手敲一遍代码而不是只看教程里别人贴出来的东西。跑通第一版时遇到的每个bug才是这项技术里最值钱的经验。