很多朋友做前端练手项目,第一个想到的就是贪吃蛇,但问到我这里,我一般更推荐俄罗斯方块。原因很直接:在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之后,原来第row+1行的内容掉到了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 经典计分规则:为什么要连续消行更高
俄罗斯方块的计分不是线性增长,而是阶梯式的。常用的计分表是这样:
| 单次消除行数 | 得分 |
|---|---|
| 1 | 100 |
| 2 | 300 |
| 3 | 500 |
| 4 | 800 |
为什么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,才是这项技术里最值钱的经验。