
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 01表示有方块的格子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×4O方块是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因为前者会自动匹配屏幕刷新率并且当浏览器标签页切换到后台时会自动暂停省去大量节流逻辑。循环每一帧做的事情抽象出来只有三件检查是否到了该自动下落的时间点如果到了就让当前方块下移一格。把整张棋盘和当前方块画到Canvas上。更新画面上的分数、等级、下一块预览等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刷新率的屏幕上动作节奏也一样。这个“按住后延迟触发固定间隔重复”的机制正好对应游戏手柄和街机里的DASDelayed 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: 03×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自动玩到最高分那就完全是一个新的项目了。所有这些扩展入口都在你现在的核心代码里这正是一个结构清晰的项目最值得骄傲的地方。最后说一句实在话把这个项目从头到尾完整写一遍、调一遍、玩烂一遍之后你再看其他游戏的源码心里会踏实很多。很多所谓“高级”的技巧无非是在这些基础逻辑之上做更精细的控制而已。如果你做完后感觉自己的代码写得不够漂亮不用急——先让它跑起来玩上几局再回来重构那种“终于知道当时那段代码哪里不对”的感觉才是这个项目留给你最值钱的收获。