ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

用JavaScript与Canvas从零实现俄罗斯方块:核心算法与实战解析

用JavaScript与Canvas从零实现俄罗斯方块:核心算法与实战解析

1. 项目概述与整体设计拆解

1.1 为什么选择俄罗斯方块作为练手项目

如果你在游戏开发这条路上摸索过,大概率绕不过俄罗斯方块(Tetris)这个经典中的经典。它看起来简单,一个网格、几种形状、不断落下、一行行消除,但真正动手去实现的时候,你会发现它几乎涵盖了游戏开发最核心的基础概念:游戏循环、状态管理、碰撞检测、坐标变换、随机算法、计分系统。我见过不少新手一上来就想做大型RPG或者吃鸡类游戏,结果在资源加载、地图设计、多人同步这些地方直接被劝退。反过来,一个俄罗斯方块做得干净利落,说明你对逻辑、数据结构和游戏循环已经有了扎实的理解,之后再去做任何类型的游戏,底子都是稳的。

这个项目能做什么,一句话说清楚:用纯前端JavaScript配合HTML5 Canvas,从零实现一个可以在浏览器中直接运行的俄罗斯方块游戏,包含完整的7种方块、旋转、移动、消行、等级加速、计分、暂停/继续和游戏结束判定。适合谁?刚刚学完JavaScript基础语法、想找一个真实项目练手的新手,或者做前端开发想体验一下游戏逻辑与业务系统有多不一样的同学。就算你平时不写前端,用C语言和终端也能实现同样的逻辑,这篇文章里的算法部分完全是通用的,只是我选择了用网页版来讲,因为浏览器里能看到实时的画面反馈,排错和理解起来更直观。

我最初做这个项目的动机很简单,就是想验证一下自己能不能不用任何游戏框架,只靠浏览器自带的Canvas API和requestAnimationFrame把一款经典游戏完整跑起来。结果发现,真正的难度不在“写代码”,而在“想清楚逻辑关系”。举个最典型的例子:方块旋转,看起来就是把矩阵转个90度,但旋转后位置往哪偏移?如果周围有障碍物挤不进去怎么办?很多版本的游戏就在这一步做得特别生硬。再比如消行,你从底往上扫描整行,发现满了就删除,听起来很直接,但删除之后上面所有行都要往下平移,这个“平移”如果处理不好,画面就会出现瞬间的错位。这些细节,不亲自踩一遍坑,光看别人源码是体会不到的。

1.2 核心规则与基本名词速览

在开始写代码之前,先把俄罗斯方块的“领域语言”统一一下,后面所有章节我都会沿用这套名词。

  • 网格(Board):主流版本是10列 × 20行,我用的是这个经典尺寸。10列是方块的宽度能自然填满的最小值,20行则保证了从顶部生成到落底有足够的反应时间。
  • 方块(Tetromino):由4个方格组成的七种固定形状,分别用字母I、O、T、S、Z、J、L表示。
  • 当前块(Active Piece):正在下落、可以由玩家控制的那个方块。
  • 落定(Lock):当前方块碰撞后固定到网格上,无法再移动。
  • 幽灵块(Ghost Piece):当前方块垂直投影到底部位置的半透明预览,辅助玩家判断落点,属于可选功能但强烈建议加。
  • 消行(Line Clear):整行被填满后消除,上方所有行整体下移。
  • 软降(Soft Drop):按住下键加速下落,收获较低分数。
  • 硬降(Hard Drop):按空格键直接落到底,收获较高分数。
  • 等级(Level):下落速度随时间或消行数递增的难度层级。
  • 分数(Score):消行数和软/硬降操作累积的数值。

1.3 技术栈选型与理由

我选的是HTML5 Canvas + 原生JavaScript,没有引入任何框架或游戏引擎。原因是俄罗斯方块的逻辑足够简单,一个数组就是一张棋盘,一个Canvas就是一块画布,引入引擎反而增加学习负担。还有一个很实际的原因:你不需要配置任何环境,新建一个HTML文件,把JavaScript写在里面,浏览器打开就能跑,这对新手来说是最低门槛的复现路径。

如果你更熟悉Python,也可以用pygame库来复现同样的逻辑,但核心的算法部分完全一样。我会在实操章节里以JavaScript为主,遇到可以对比的地方会顺手提一句pygame的实现差异,方便两种技术栈的读者对照理解。

2. 数据结构与核心算法拆解

2.1 棋盘与方块的数据表示

棋盘用二维数组表示,是我在这个项目里第一个做出来的决定。形状定义成board[row][col],行数20,列数10,元素值为0表示空格,非0表示已经落定的方块。为什么不用对象或者一维数组?因为二维数组的行和列恰好和屏幕的坐标系统一一对应,判断某个位置是否被占用时,只需要一次索引访问,没有任何中间换算。

方块的表示则需要多一点思考。七种方块中最省事的方式是“矩阵模板”。比如T型方块可以写成3行3列的矩阵:

0 1 0 1 1 1 0 0 0

1表示有方块的格子,0是空位。每次移动时,我操作的不是网格里的数据,而是这个模板加一个方块当前的左上角坐标。这样还有一个好处:当你需要做旋转时,本质上就是对这个矩阵做顺时针90度变换,模板本身是独立的,不直接污染棋盘数据。只有落定之后,才把方块的形状“写”进棋盘数组。

这里有一个新手常见的误区:试图让方块对象自身记录四个小方块的绝对坐标(比如[{x:4,y:0},{x:5,y:0},...]),然后每次移动和旋转都直接修改坐标。这样做在初期看起来直观,但后面你会发现自己要写很多重复的判断逻辑,尤其在旋转、墙踢(Wall Kick)和旋转点计算时变得特别麻烦。用矩阵 + 原点坐标的方式,旋转只是矩阵操作,碰撞检测只是检查和,所有变换都能统一处理。

棋盘数组初始化代码:

const COLS = 10; const ROWS = 20; let board = []; for (let row = 0; row < ROWS; row++) { board[row] = []; for (let col = 0; col < COLS; col++) { board[row][col] = 0; } }

主要方块模板的定义:

const SHAPES = { I: [ [0,0,0,0], [1,1,1,1], [0,0,0,0], [0,0,0,0] ], O: [ [1,1], [1,1] ], T: [ [0,1,0], [1,1,1], [0,0,0] ], S: [ [0,1,1], [1,1,0], [0,0,0] ], Z: [ [1,1,0], [0,1,1], [0,0,0] ], J: [ [1,0,0], [1,1,1], [0,0,0] ], L: [ [0,0,1], [1,1,1], [0,0,0] ] };

注意I方块的模板是4×4,O方块是2×2,其余都是3×3,这是为了旋转时保持中心点一致。经典俄罗斯方块的旋转系统(SRS)里,I和O的旋转行为与其他块不同,用不同尺寸的矩阵模板是第一步。

2.2 方块旋转的数学原理

旋转是俄罗斯方块里最需要数学思维的部分。思路不复杂:把一个矩阵顺时针旋转90度,新的矩阵的第i行第j列的值,等于原矩阵倒数第i列第j行的值。写成代码就是:

function rotateMatrix(matrix) { const N = matrix.length; const rotated = []; for (let i = 0; i < N; i++) { rotated[i] = []; for (let j = 0; j < N; j++) { rotated[i][j] = matrix[N - 1 - j][i]; } } return rotated; }

用这个函数对模板做旋转之后,新的方块形状就出来了。但这里有一个很容易被忽略的问题:旋转后形状变了,但方块的左上角坐标没变,原来在棋盘靠左位置的方块,旋转之后可能会有一部分跑到棋盘外面去,或者直接穿进已经落定的方块里。所以旋转之后必须做一次碰撞检测,如果碰撞了,就要尝试把方块向左右平移几个格子,看能不能找到一个合法位置,这套操作在专业术语里叫“墙踢”(Wall Kick)。

墙踢的经典策略是依次尝试偏移量[0, -1, 1, -2, 2],意思就是先试原地不动,不行就左移一格,还不行就右移一格,再不行就左移两格,最后右移两格。这个顺序不是拍脑袋定的,而是从玩家直觉出发的:旋转超出边界时,通常方块只需要很小的水平偏移就能绕开障碍。如果你不实现墙踢,玩家在墙边旋转方块会频繁感到“怎么转不动”,体验会大打折扣。

还有一个细节是I方块的旋转。I是4×4矩阵,旋转中心其实是矩阵中心,转起来之后位置会和3×3方块非常不同。所以墙踢的偏移量对于I方块来说需要额外调整,否则旋转时会明显跳位。如果你追求极致的还原度,可以查阅SRS的标准墙踢数据表,里面针对每个旋转方向和每种方块都有精确的偏移表。我在初版代码里把墙踢偏移量统一写成[0, -1, 1, -2, 2],实测下来O和I以外的方块手感已经可以,I有一点奇怪,但不至于不可玩。后来我按SRS表修正了I的偏移,手感就自然多了。

旋转逻辑的完整流程:

function tryRotate(piece) { const rotated = rotateMatrix(piece.shape); const kicks = [0, -1, 1, -2, 2]; for (let offset of kicks) { const testX = piece.x + offset; if (isValidPosition(piece.y, testX, rotated)) { piece.shape = rotated; piece.x = testX; return true; } } return false; }

isValidPosition是检查某个方块形状放在棋盘指定坐标是否合法:首先,所有非0格子对应的行列坐标不能超出棋盘左、右、下边界,其次也不能与棋盘上已有的落定方块重叠。上边界不需要卡死,因为新方块生成时有一部分可以刚好在顶部之外,这是正常现象。完整的isValidPosition实现我放到下一节一起说。

2.3 碰撞检测与移动边界判断

碰撞检测是俄罗斯方块游戏循环里被调用最频繁的函数,性能必须做到干净利落。我写的方式是这样的:

function isValidPosition(row, col, shape) { for (let r = 0; r < shape.length; r++) { for (let c = 0; c < shape[r].length; c++) { if (shape[r][c] === 0) continue; const targetRow = row + r; const targetCol = col + c; // 左右和下边界 if (targetCol < 0 || targetCol >= COLS || targetRow >= ROWS) { return false; } // 上边界单独处理:允许在顶部之上,但不允许和已有块重叠 if (targetRow >= 0 && board[targetRow][targetCol] !== 0) { return false; } } } return true; }

需要注意一个细节:为什么上边界不直接判断targetRow < 0就返回false?因为新生成的方块的初始位置在棋盘可见区域之上,比如T型方块的模板是3×3,初始行坐标设为-1,这样只有第二行在棋盘内,这是完全正常的。如果上边界直接判负,新方块一生成就报碰撞,游戏根本没法开始。所以上边界只要保证不做数组越界访问、不检查棋盘数据就行。

另一个容易被忽略的地方是左边界和右边界判断用的是< 0和>= COLS,而不是<=和>,因为格子坐标是从0开始的。我调试的时候栽过一次:把右边界误写成targetCol > COLS,结果方块能超出边界一格,看起来就像方块有一半悬在屏幕外。这是一个非常典型的边界差一错误。

移动逻辑本身很简单:左移就是把当前块的x减1,右移加1,下降就是y加1。每次操作前调用isValidPosition检查目标位置,合法则移动,不合法则保持原状。

落定逻辑发生在“方块向下移动但目标位置不合法”时,这时就说明方块下面已经没有空间了,需要把这个方块“冻结”到棋盘上。冻结操作就是把方块模板里的1逐个写入棋盘的对应格子。

function freezePiece(piece) { for (let r = 0; r < piece.shape.length; r++) { for (let c = 0; c < piece.shape[r].length; c++) { if (piece.shape[r][c] === 0) continue; const targetRow = piece.y + r; const targetCol = piece.x + c; if (targetRow < 0) { // 方块冻结时有一部分在顶部之外,游戏结束 gameOver(); return; } board[targetRow][targetCol] = piece.colorIndex; } } }

冻结函数里还要检查一个边界情况:如果方块冻结时有些格子还位于棋盘顶部之外(targetRow < 0),说明新方块已经没有完整进入棋盘的空间,下一块方块必然无法生成,游戏就应该结束了。这个判断放这里,比在生成新方块时判断要简洁很多。

2.4 消行与得分算法

消行的过程看起来简单,但处理顺序有讲究。我的做法是:从棋盘最底行开始向上扫描,如果某一行所有格子都不为0,就把这一行数据删除,然后在数组顶部插入一行新的空行。

function clearLines() { let linesCleared = 0; for (let row = ROWS - 1; row >= 0; row--) { const isFull = board[row].every(cell => cell !== 0); if (isFull) { board.splice(row, 1); board.unshift(new Array(COLS).fill(0)); // 因为删除了一行,所以当前行的索引要再加一 row++; linesCleared++; } } return linesCleared; }

这里有一个很小的但影响很大的细节:为什么从下往上扫?因为unshift插入新行后,所有行的行号都变了,如果你从上往下扫,实际上会漏掉一些原本应该消除的行。从下往上扫,每消除一行后行号的变化刚好被你row++抵消,逻辑上最稳妥。很多新手版本就是在这个细节上出现“明明消了行,但上面有整行没消掉”的bug。

说完消行,再说得分。如果你去看各种俄罗斯方块版本的计分规则,会发现细节五花八门,但基础逻辑是通用的:一次消1行得100分,消2行得300分,消3行得500分,消4行得800分。这里不是随意定的值——它遵循了一个“连续消除奖励递增”的设计逻辑:一次消4行的收益远大于四次消1行的收益,鼓励玩家去堆叠方块寻找一次性消除多行的机会。

得分代码:

const LINE_SCORES = [0, 100, 300, 500, 800]; function addScore(linesCleared) { if (linesCleared > 0) { score += LINE_SCORES[Math.min(linesCleared, 4)] * (level + 1); } }

最后乘以(level + 1)是让高等级下消行更有回报。等级提升的逻辑也简单:每消除10行等级加1,或者每得一定分数加1级。我在实现里选择了按行数提升,因为行数比分数更可控,也更容易给玩家明确的进度反馈。

function updateLevel(linesCleared) { linesCount += linesCleared; level = Math.floor(linesCount / 10); dropInterval = Math.max(100, BASE_INTERVAL * Math.pow(0.85, level)); }

dropInterval是方块每下落一格所隔的毫秒数。等级越高,间隔越短。这里用Math.pow(0.85, level)做指数衰减,保证速度增长是先快后慢的:前期手感变化明显,后期虽然越来越快但不会加速到完全没法反应。下限设为100毫秒,也就是每秒最多10格,再快就不适合人类玩了。

3. 实操过程与核心环节实现

3.1 初始化游戏与渲染循环

渲染循环是整个游戏的心脏。我选用requestAnimationFrame而不是setInterval,因为前者会自动匹配屏幕刷新率,并且当浏览器标签页切换到后台时会自动暂停,省去大量节流逻辑。循环每一帧做的事情,抽象出来只有三件:

  1. 检查是否到了该自动下落的时间点,如果到了,就让当前方块下移一格。
  2. 把整张棋盘和当前方块画到Canvas上。
  3. 更新画面上的分数、等级、下一块预览等UI信息。

渲染的先后顺序也很讲究:先画网格背景线,再画已经落定的方块,最后画当前下落的方块,不然当前方块会被已落定的方块挡住。

function gameLoop(timestamp) { if (gameState === 'playing') { // 控制下落节奏 if (timestamp - lastDropTime >= dropInterval) { moveDown(); lastDropTime = timestamp; } } draw(); requestAnimationFrame(gameLoop); }

draw函数里最关键的是坐标换算:一个棋盘格子对应的Canvas像素大小是多少。我的棋盘是10×20,每个格子的大小是30像素,那么画布就是300×600像素。每个格子画两个元素:一个填充矩形和一个稍小的内部矩形,做出简单的立体浮雕效果。这一步虽然简单,但对整体观感影响很大,空心的扁平方块和带内缩矩形的方块,玩起来的视觉反馈完全不同。

颜色我觉得可以直接用经典配色:I青色、O黄色、T紫色、S绿色、Z红色、J蓝色、L橙色。每种颜色存一个数组,渲染时按方块的颜色索引取色。颜色值选饱和度高的版本,因为纯色比渐变更容易区分相邻方块。

3.2 输入控制与按键逻辑

键盘控制是俄罗斯方块手感的重中之重。我绑定的是经典操作方案:左右方向键移动,上方向键旋转,下方向键软降,空格键硬降,P键暂停。

绑定按键有一个需要注意的问题:浏览器对键盘事件的处理和游戏循环是异步的,如果你按下方向键后立刻读取了键盘状态,再在游戏循环里移动,会出现两个问题:一是长按不自动重复(键盘事件只在第一次按下时触发一次),二是按键延迟不稳定。我用的方案是维护一个keysPressed对象,记录当前哪些键处于按下状态,然后在gameLoop里检查这个状态来持续执行移动操作。

const keys = {}; document.addEventListener('keydown', (e) => { keys[e.code] = true; if (e.code === 'ArrowUp') { e.preventDefault(); tryRotate(currentPiece); } if (e.code === 'Space') { e.preventDefault(); hardDrop(); } if (e.code === 'KeyP') { togglePause(); } }); document.addEventListener('keyup', (e) => { keys[e.code] = false; });

然后在游戏循环的下落检查之前,增加一段按键状态处理:

// 左右移动和软降,按住可重复触发 if (keys['ArrowLeft']) { movePiece(-1, 0); } if (keys['ArrowRight']) { movePiece(1, 0); } if (keys['ArrowDown']) { moveDown(); }

这里有一个桌面端特有的坑:直接用setInterval或者每一帧检查按键状态来做重复位移,速度会直接绑定帧率,高刷新率屏幕上方块会飞快移动。我的解决方案是给移动操作也加一个内部计时器,左右移动每150毫秒触发一次,软降每50毫秒触发一次。这样即便在120Hz刷新率的屏幕上,动作节奏也一样。

这个“按住后延迟触发+固定间隔重复”的机制,正好对应游戏手柄和街机里的DAS(Delayed Auto Shift)参数。做过硬核玩家向游戏的人应该知道我说的这个术语,它对老俄罗斯方块玩家的手感几乎是决定性的:延迟太长会黏,延迟太短会飘。150毫秒是我试下来比较舒服的中间值。如果你复现后觉得不合适,可以直接在这个常量上调整。

3.3 新方块生成与“7袋”随机算法

方块生成只靠Math.random()挑一个形状,在前期可能看不出来问题,但几十局之后就会出现明显的“诅咒感”:连续出同一个形状,或者很长一段时间不出某个形状。这是因为Math.random()本质上是不带记忆的,它无法确保一段区间内的分布均衡。我在第二版里改成了经典的“7袋”(7-Bag)算法,思路是这样的:

把七种方块放进一个袋子里,洗牌,然后每次从袋子里取一块,取完七块后再重新洗牌填充。这样保证每七个方块中必定包含七种形状各一次,从数学上消除连续出现同一形状的可能,又不至于让出块顺序太机械。

let bag = []; function getNextShape() { if (bag.length === 0) { bag = ['I','O','T','S','Z','J','L']; // 洗牌 for (let i = bag.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [bag[i], bag[j]] = [bag[j], bag[i]]; } } return bag.pop(); }

洗牌算法用的是Fisher-Yates,这是确保均匀分布的标准做法。用pop取最后一个,是因为这一步操作的时间复杂度是O(1),而shift是O(n)。

生成新方块时,除了选择形状,还要设置初始坐标。我的策略是让方块出生在棋盘顶部之上:行坐标为-2(具体值根据模板尺寸微调),列坐标为Math.floor((COLS - shape[0].length) / 2),也就是水平居中。

function spawnPiece() { const shapeKey = getNextShape(); currentPiece = { shape: SHAPES[shapeKey], x: Math.floor((COLS - SHAPES[shapeKey][0].length) / 2), y: -2, colorIndex: SHAPE_COLORS[shapeKey] }; // 如果出生位置直接碰撞,游戏结束 if (!isValidPosition(currentPiece.y, currentPiece.x, currentPiece.shape)) { gameOver(); } }

y: -2不是随手写的。对于3×3矩阵,模板有效内容集中在第二行,那么初始行坐标-2刚好让方块的有效格子落在-1和0行,正好在顶部的边缘。如果设为-1,部分2×2方块和I方块会显示得太靠上;设为-3,会多出一段看不到的空白。我实测过几组数值,-2对3×3和2×2方块都友好,I方块由于是4×4矩阵且有效行是第二行,-2会让I方块的有效行落在-1到2行,相当于上面两行在屏幕外,看起来比标准位置高一行,但在实际玩法里这个微差几乎感知不到。如果你想要标准SRS的位置,主角初始y应该设为-1或者根据形状单独配置,我在代码里为了避免分支复杂化,所有形状统一用-2,实测是OK的。

3.4 等级、速度与界面优化

下落速度直接决定游戏的难度曲线。第一版我把dropInterval从1000毫秒开始,每升一级减50毫秒,减到100毫秒封顶。但实测发现到等级8左右速度就变得特别快,新手基本反应不过来。后来改成指数衰减:dropInterval = Math.max(100, 1000 * Math.pow(0.85, level)),同样是从1000毫秒开始,但等级提升带来的加速度会逐渐放缓。这样前5级有明显的难度递进,后面虽然越来越快但至少不会瞬间就不可玩。

界面优化部分,我加了一个“下一块预览”(Next Preview),显示下一个要出现的方块。这个功能怎么强调都不过分,它让玩家在做策略规划时有了关键信息,玩起来的感觉从“被动接招”变成了“主动布局”。实现上很简单:引入一个nextPiece变量,生成当前块时提前从袋子里取下一块,每次游戏循环里把它绘制在棋盘右侧的专用区域。

幽灵块的实现稍微复杂一点:从当前方块的当前位置开始,一次次向下试探,找到能落定的最远位置,然后绘制一个边框线或者半透明方块。

function getGhostY(piece) { let ghostY = piece.y; while (isValidPosition(ghostY + 1, piece.x, piece.shape)) { ghostY++; } return ghostY; }

这个函数调用次数不少,但每帧调用的成本也就几十次坐标检查,完全不会成为性能瓶颈。绘制幽灵块时,我把颜色设为当前方块颜色的半透明版本,同时不填充内部,只画边框。这样墙和幽灵块能清晰区分,不会造成视觉干扰。

3.5 状态管理与游戏结束判定

游戏不可能永远处于“正在下落”状态,至少有这些状态:开始、暂停、游戏中、结束。我用一个字符串变量gameState管理,取值分别为'menu'、'paused'、'playing'、'over'。

状态切换的逻辑集中在几个函数里:

  • startGame():重置棋盘、计分、等级、出块袋,生成第一个方块,设置gameState = 'playing'。
  • togglePause():仅在playing和paused之间切换,切换时记录当前时间戳,避免暂停导致下落时间跳变。
  • gameOver():把gameState设为'over',显示结束画面和最终得分,等待玩家重新开始。

这里有一个我踩过坑的细节:游戏结束后,如果不清空keysPressed里残留的按键状态,玩家按空格键重开时会立刻触发硬降操作,新一局开局就砸到底。我的解决方案是在gameOver()里执行keys = {},把键盘状态全部重置。同样,暂停恢复时也最好重置一次,防止暂停期间按键遗留在“按下”状态。

游戏结束的另一个触发场景是上面讲过的freezePiece检测到targetRow < 0,意味着方块还没完全进入棋盘就已经被卡住,说明上面的空间已经堆满了,游戏确实结束了。

4. 踩坑实录与常见问题速查

4.1 方块卡在墙里,旋转后穿模

症状:方块在靠墙位置旋转后,有一部分显示在棋盘外面或者重叠在已有方块里。

原因分析与修复:绝大多数情况是缺少墙踢逻辑。我在2.2节讲过,旋转后要依次尝试向右/向左偏移1到2格。如果你没有做这一步,那么只要方块离墙小于某个距离,旋转就永远失败,玩家会感觉“怎么转不动”。另一个容易漏的点是旋转后没有检查新矩阵是否与棋盘已有格子重叠。就算没有墙,方块旁边有障碍时旋转也可能被堵住。正确的流程是:旋转矩阵 → 尝试偏移 → 每次偏移都走isValidPosition检查,只要有一次通过就采用,否则保持原状。

顺带说一句,我做适配时遇到过键盘按键冲突的情况:浏览器里方向键上下滚动页面、空格键触发按钮焦点,这些默认行为必须用e.preventDefault()禁掉。不然你按空格硬降的同时,页面可能把自己的按钮也点了,界面上会出现奇怪的高亮框。

4.2 消行逻辑偶发漏消

症状:明明一行已经满了,但消除后上面某一行还在,或者底部出现空行。

原因分析与修复:这是消行扫描方向导致的问题。如果你从第一行向下扫描,每次删除并用unshift插入新行后,数组的所有行号都变了,但你还在按原来的索引继续往下扫,必然会跳过一些行。解决办法要么从最后一行向上扫描,像我那样配合row++做一个补偿;要么先收集所有需要消掉的行索引,再从高到低统一删除。后者的代码逻辑更清晰,但需要多一次数组操作。

// 另一种实现:先收集再统一删除 function clearLines() { const rowsToClear = []; for (let row = 0; row < ROWS; row++) { if (board[row].every(cell => cell !== 0)) { rowsToClear.push(row); } } if (rowsToClear.length === 0) return 0; for (const row of rowsToClear) { board.splice(row, 1); board.unshift(new Array(COLS).fill(0)); } return rowsToClear.length; }

这个版本里,即使unshift改变了行号,只要rowsToClear里的索引是从小到大排列的,删除时从高往低删除就不会出错。两种写法我都测过,在实际体验上没有任何区别,选你读得顺眼的一种就好。

4.3 新方块生成后立即“死亡”或者部分不可见

症状:游戏开始后第一个方块生成,但没有任何操作空间,画面显示方块只露出一半,或者游戏直接判负。

原因分析与修复:这几乎可以肯定是初始坐标或出生碰撞检查的问题。初始行坐标如果设置得太低(比如y: 0),3×3方块其实在棋盘第一行,看起来会像是被“顶”在顶部无法操作。我实测的y: -2在所有标准形状下表现都还好,但如果你用SRS的精确标准,I方块可能需要额外的偏移。再看看isValidPosition的上边界检查,确认上边界不直接判负。如果你把targetRow >= 0 && board[targetRow][targetCol] !== 0这行代码误写成了targetRow >= 0 || ...,那么几乎所有未落定的方块都会被判定为非法位置,游戏开局即结束。

4.4 帧率不同导致下落速度不一致

症状:同一份代码在高刷新率显示器(144Hz)上比在60Hz显示器上跑得快得多。

原因分析与修复:这是典型的把“帧”和“时间”混为一谈引发的误差。如果你的下落逻辑绑定在requestAnimationFrame里,每一帧都让方块下降一格,那144Hz的屏幕每秒会下落144格,60Hz每秒只下落60格,速度差异肉眼可见。正确做法是记录时间戳,只在时间差超过dropInterval时才让方块下落。我在3.1节给出的gameLoop版本已经包含了lastDropTime的比较,就是为此服务的。如果你用setInterval实现,理论上没有这个问题,但setInterval在浏览器切后台时会积累大量回调,也不推荐。统一用时间戳判断是跨帧率最稳妥的做法。

4.5 暂停功能导致下落时间跳跃

症状:按下暂停键后过了五分钟再恢复,方块瞬间从屏幕中间跳到底部。

原因分析与修复:因为没有在暂停时修正时间基准。lastDropTime记录的是暂停前的时间戳,恢复时这个时间戳已经过期了,下一次gameLoop运行时,时间差远超dropInterval,方块立刻触发了一次“补落”。修复方式是在恢复暂停时把lastDropTime重置为当前时间戳:

function togglePause() { if (gameState === 'playing') { gameState = 'paused'; } else if (gameState === 'paused') { gameState = 'playing'; lastDropTime = performance.now(); } }

这类问题在含暂停环节的实时游戏里都会出现,不只是俄罗斯方块。养成“切换状态时重置关键时间戳”的习惯,能帮你省下很多类似的排错时间。

4.6 一个提升手感的细节:移动的DAS处理

再分享一个我体验过多种实现后觉得非常有价值的细节。如果你让按住方向键的移动完全依赖浏览器原生的按键重复,你会发现移动节奏很难控制:第一次响应需要等待一段操作系统级的延迟,后续重复的节奏又和游戏帧率不对齐。更好的做法是,在keydown被触发时立刻执行第一次移动,然后自己记录一个nextMoveTime,之后每隔固定间隔(上/下键分开设置)再触发一次移动,让玩家得到可预测的、与帧率无关的移动行为。

// 每次 keydown 时给 timeSinceMove 归零,立即触发第一次移动 function handleMoveAutoRepeat() { const now = performance.now(); if (now - lastMoveTime >= MOVE_REPEAT_INTERVAL) { movePiece(1, 0); // 或者 -1 / 下 lastMoveTime = now; } }

为什么这样更接近街机原版手感?因为原版街机的DAS参数是固定的,玩家可以通过“按住—松开—按住”的微操作来精确控制方块移动一格格数,而不是依赖操作系统随机的按键重复延迟。你要是想让自己的版本玩起来更“专业”,这个细节值得花十分钟调一调。

5. 从单机到扩展:后续可以玩的方向

这个项目做完之后,我最大的感受是:俄罗斯方块像一个打不完的地基,你能往上面加的东西远比想象的要多。比如给方块下落时加上“瞬时旋转动画”,也就是旋转时不仅切换矩阵,还插入一个旋转过程中的中间帧,看起来会顺滑得多;或者增加“连击”奖励系统,在一定时间内连续消行获得额外分数倍率;也可以接入本地排行榜,用localStorage保存历史最高分,给玩家一个反复挑战的目标。

我后来还给这个项目加过一个“回放”功能:在游戏进行的同时记录每帧的玩家操作序列,结束后用固定随机种子重新执行一遍,就能完整复盘整局游戏。这个功能用来测试AI策略特别有用。你再往深走,把同样的逻辑包装成Web Worker跑后台AI,让AI自动玩到最高分,那就完全是一个新的项目了。所有这些扩展,入口都在你现在的核心代码里,这正是一个结构清晰的项目最值得骄傲的地方。

最后说一句实在话:把这个项目从头到尾完整写一遍、调一遍、玩烂一遍之后,你再看其他游戏的源码,心里会踏实很多。很多所谓“高级”的技巧,无非是在这些基础逻辑之上做更精细的控制而已。如果你做完后感觉自己的代码写得不够漂亮,不用急——先让它跑起来,玩上几局,再回来重构,那种“终于知道当时那段代码哪里不对”的感觉,才是这个项目留给你最值钱的收获。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进