news 2026/10/16 7:06:33

91行JavaScript实现俄罗斯方块:核心逻辑与代码拆解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
91行JavaScript实现俄罗斯方块:核心逻辑与代码拆解

先聊个实际体验:我见过不少朋友一开始就想写一个“完整”的俄罗斯方块,结果堆了上千行代码,还处处是bug。反而后来我试着用91行JavaScript加上Canvas把它跑起来时,发现游戏的核心骨架其实非常紧凑,真正值钱的不是代码量,而是对“方块、场地、碰撞、消行”这四个概念的抽象方式。这篇文章就围绕这91行展开,讲清楚每一行背后的取舍、每个函数为什么这么拆,以及在压缩代码的过程中容易踩到的坑。适合刚学完前端基础、想用一个小项目验证自己逻辑能力的开发者,也适合想回头重新理解“如何用更少代码表达复杂逻辑”的从业者。

1. 整体设计与思路拆解:91行是一种约束,更是一种设计训练

1.1 为什么选91行这个规模

先明确一个前提:91行这个数字,不是为了炫技,而是一个设计约束。写代码这件事,一旦没有约束,很容易“这里多考虑一种情况、那里多加一个参数”,最后功能是完整了,但可读性和可维护性反而崩溃。把规模定在91行,本质上是在逼自己回答一个问题:这个游戏最核心的不可压缩逻辑是什么?

俄罗斯方块的核心,拆到最后只有四件事:

  • 有一个固定大小的棋盘,用来记录已经落定的方块。
  • 有一个当前正在下落的方块,它由一组格子的坐标组成。
  • 游戏循环每次往下移动当前方块,移动前判断是否撞到边界或已落定方块。
  • 方块落定后检查整行是否填满,如果填满就消除,并给上方所有行让位。

这四件事里,没有任何一件是需要“大量代码”才能完成的。真正让代码膨胀的,往往是一些外围需求:计分规则要几种模式、下一个方块预览、方块颜色渐变特效、操作动画、音效、暂停菜单、等级速度调节……这些全都很有价值,但它们不是俄罗斯方块的“核心”。91行的意义,就是先把核心做成一个可玩demo,外围功能后续再生长。

我个人的经验是,如果一个开发者能在不借助任何框架、不提前封装复杂类的前提下,用不到100行写出一份可玩的俄罗斯方块,说明他已经能把逻辑和渲染清晰地分层了。这不是投机取巧,反而是一种很有价值的底层训练。

1.2 方案选型:为什么用JavaScript、Canvas、setInterval

先说明一下技术栈的选型理由。俄罗斯方块可以用纯终端字符界面写,可以用Python的Pygame写,也可以用C++配合图形库写。但我推荐用“JavaScript + Canvas + setInterval”这套组合,原因有三:

第一,JS和Canvas天然适合二维网格渲染。俄罗斯方块的棋盘本质上就是一个二维数组,Canvas的fillRect可以很直观地把数组里的每个格子画成屏幕上的色块,中间不需要任何额外转换。相比之下,如果用DOM节点来渲染,每次刷新都要操作一堆元素,性能和管理成本都高得多。

第二,浏览器环境不需要搭建编译环境。把HTML和JS写到一个文件里,双击就能跑起来。对于新手来说,这个反馈速度非常重要,它能降低尝试的心理门槛。

第三,setInterval虽然粗糙,但对于俄罗斯方块这种“固定时间间隔推进一帧”的游戏来说,是最直观的驱动方式。requestAnimationFrame更平滑,但它把“按固定节奏下落”和“按帧渲染”混在一起,反而需要额外处理时间差。在91行的限制下,setInterval的简单性是最优解——它虽然在计时精度上不如requestAnimationFrame,但俄罗斯方块本来就不需要毫秒级精确,它的游戏节奏本身就是离散的。

2. 核心数据结构与算法:把俄罗斯方块抽象成数组运算

2.1 棋盘与方块的表示方法

在动手写任何一行渲染代码之前,先得把“数据怎么存”这件事定下来。这是整个91行代码的地基。

棋盘我用一个二维数组表示,比如10列20行:

const COLS = 10; const ROWS = 20; let board = Array.from({ length: ROWS }, () => Array(COLS).fill(0));

这里0代表空格,1代表已经落定的方块。为什么用1不用布尔值?因为这个值后面还可以扩展成颜色索引:如果1是红色、2是蓝色,渲染时就能直接按值取色。在91行版本里不扩展颜色,但设计时留出这种可能,不会增加任何成本。

再定义“当前方块”。标准的俄罗斯方块有七种形状,每种形状都可以用一个坐标数组或矩阵来表示。矩阵表示法更直观,我用二维数组:

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

每个数组元素是1的位置,表示该方块占据的格子。把方块形状和棋盘都抽象成“格子数组”之后,很多操作就变成统一的数组运算了,这是后面省代码的关键。尤其要注意T、L、J、S、Z这几种形状的宽度都是3格,I是4格,O是2格,所以在旋转时需要用不同的偏移策略,这块细节后面会详细说。

2.2 旋转的实现:从数学公式到代码中的两种思路

旋转是俄罗斯方块里最容易写乱的部分。网上很多长篇实现里,旋转函数动辄三五十行,还经常在类似“贴墙旋转”的细节上出乱子。实际上,旋转可以做得非常干净。

旋转的本质,是对形状矩阵做转置加翻转。逆时针旋转等于先上下翻转再转置,顺时针旋转等于先转置再上下翻转。用JS数组方法可以写得很短:

function rotate(matrix) { const n = matrix.length; const rotated = Array.from({ length: n }, () => Array(n).fill(0)); for (let i = 0; i < n; i++) for (let j = 0; j < n; j++) rotated[i][j] = matrix[n - 1 - j][i]; return rotated; }

但这个写法有个问题:如果把T形方块当成3x3矩阵旋转,必须保证矩阵是正方形的,因为非正方形矩阵没法做转置运算。所以我前面定义SHAPES的时候,特意把I形方块的宽度写成4行1列矩阵(虽然它实际只占一格高度),把O形方块保持2x2,其余形状统一为3x3。这样一来,旋转函数就只需要处理nxn矩阵,不需要为每个形状单独写旋转逻辑。

第二种思路是预处理旋转结果。也就是在初始化的时候,把每种方块的四个旋转状态都生成好存起来,按需取用:

const rotateStates = SHAPES.map(s => { let states = [s]; for (let i = 0; i < 3; i++) states.push(rotate(states[i])); return states; });

这样游戏运行时完全不需要调用矩阵旋转,直接查表,速度最快。91行版本里我用的是第一种写法,因为从代码简洁度和理解成本来看都更合适;但如果后续要做更完整的版本,建议换成预处理方案。

3. 游戏循环与渲染:让数据流动起来

3.1 游戏主循环与下落节奏

主循环的设计决定了整个游戏的骨架。我选择的方案是:用一个全局计时器驱动“下落一格”,每次下落前先试探新位置是否合法,合法就更新坐标,不合法就落定并检查消行:

setInterval(() => { const newY = current.y + 1; if (!collides(board, current.shape, newY, current.x)) { current.y = newY; } else { merge(board, current.shape, current.y, current.x); clearLines(board); spawn(); } draw(); }, 500);

这里有一个关键设计:collides函数不修改任何状态,它只回答“如果我把这个形状放到这个位置,是否非法”这个问题。很多人写俄罗斯方块容易把碰撞检测和位置更新混在一起,一边移动一边检测,结果状态在多次调用之间互相污染,bug非常难查。

把碰撞检测设计成纯函数之后,移动、旋转、硬降都变成了同一套逻辑:先试探目标位置,是否合法;合法就提交,非法就放弃。这个“试探-提交”的模式在游戏开发里叫“先验证后操作”,虽然听起来基础,但我见过很多同行在逻辑更复杂的项目里反而把这个原则丢了,导致出现各种幽灵碰撞。

3.2 Canvas渲染的最小实现

渲染部分的核心思路是:把棋盘数组和当前方块合并成一个完整的“画面数组”,再逐格绘制。

function draw() { ctx.clearRect(0, 0, canvas.width, canvas.height); // 先画棋盘上已经落定的格子 for (let y = 0; y < ROWS; y++) for (let x = 0; x < COLS; x++) if (board[y][x] > 0) fillCell(x, y); // 再画当前方块 for (let r = 0; r < current.shape.length; r++) for (let c = 0; c < current.shape[r].length; c++) if (current.shape[r][c] > 0) fillCell(current.x + c, current.y + r); }

fillCell就是简单的矩形填充:

function fillCell(x, y) { ctx.fillStyle = '#4a6fa5'; ctx.fillRect(x * CELL_SIZE + 1, y * CELL_SIZE + 1, CELL_SIZE - 2, CELL_SIZE - 2); }

给每个格子留1像素内边距,是为了让方块之间有分隔线,视觉效果会干净不少。这里想提醒一个细节:+1和-2看起来微不足道,但实际体验差异很大。如果不留边距,方块之间会完全粘连,玩家很难分辨边界;留了1像素,整个棋盘的辨识度立刻提升。这是过去做像素风界面时经常被忽略的一个点。

3.3 输入处理:方向键与快速下落

输入处理方面,我用键盘事件监听。需要处理的按键只有四个:左、右、下、旋转。快速落到底的硬降功能,在91行版本里可以不加,也可以顺手用空格键实现,但要做的话必须在“硬降”时逐格检测,而不是直接跳到底部,否则会穿模。

document.addEventListener('keydown', e => { if (e.key === 'ArrowLeft') move(-1); if (e.key === 'ArrowRight') move(1); if (e.key === 'ArrowDown') current.y++; if (e.key === 'ArrowUp') rotate(); });

这里要注意一个浏览器行为:方向键默认会滚动页面,必须调用e.preventDefault()。如果忘了这行代码,玩家一按方向键,页面跟着上下滚动,游戏体验直接毁掉。这个问题在91行版本里遇到过好几回,因为代码太紧凑,特别容易忽略这行“看起来不影响逻辑”的语句。

4. 完整代码拆解与实现过程

4.1 核心函数逐个过

为了让这篇拆解不空谈,我直接把91行版本的核心函数列出来,逐个讲解它们各自承担的责任。需要注意,实际开发时这些函数有紧凑写法,我在这里为了可读性保留了变量名和注释位,逻辑不变。

首先是碰撞检测函数:

function collides(board, shape, y, x) { for (let r = 0; r < shape.length; r++) for (let c = 0; c < shape[r].length; c++) if (shape[r][c] && (board[y + r]?.[x + c] || x + c < 0 || x + c >= COLS)) return true; return false; }

这个函数里有一个我特别想强调的写法:board[y + r]?.[x + c]。可选链语法在这里同时处理了两种情况——如果y + r超出了棋盘总行数(方块到了底部以下),数组访问会返回undefined,就被判断为碰撞;如果没超出但那个格子已经被占据,返回1,也判断为碰撞。这一行相当于同时完成了“越界检测”和“重叠检测”两个任务。

然后是落定并合并:

function merge(board, shape, y, x) { shape.forEach((row, r) => row.forEach((val, c) => { if (val) board[y + r][x + c] = 1; }) ); }

这里要注意,merge必须发生在碰撞检测已经返回false之后,否则会把方块写进非法位置。在91行代码里,调用顺序是写死在主循环里的,所以不会出问题。如果后面扩展代码,把merge作为一个独立函数暴露出来,就一定要在调用侧保持这个顺序约定。

消行检测是整个游戏里最讲究“怎么写得短又对”的地方:

function clearLines(board) { for (let y = ROWS - 1; y >= 0; y--) { if (board[y].every(cell => cell > 0)) { board.splice(y, 1); board.unshift(Array(COLS).fill(0)); y++; // 消除后当前索引先回退,再被for循环自增 } } }

从最底层往上扫描,遇到满行就删除这一行,在最顶部补一个全0行。这里必须从底往上扫,因为splice会改变数组索引,如果从上往下扫,会因为索引偏移错过一些行。y++这个技巧正是用来抵消for循环里的y--,确保消除一行后,下一轮仍然检查当前这个索引位置——因为整体下移了一行,原来上一行的内容现在到了这一行,必须再检查一次。

最后是生成新方块:

function spawn() { current = { shape: SHAPES[Math.floor(Math.random() * SHAPES.length)], x: Math.floor(COLS / 2) - 1, y: 0 }; }

坐标x取COLS/2 - 1,是为了让宽度为3的方块大体居中。如果把I形方块(宽度4)也放同一个位置,最右侧会有一格超出棋盘,但我在旋转处理中把I的初始SUPPORT设计为4x4,x坐标需要单独微调,或者直接用Math.floor((COLS - width) / 2)来处理。这个细节看着小,不影响正常游戏时的碰撞,但会让出块位置不居中,强迫症玩家一眼就能看出来。

4.2 代码组织的边界:91行里的“必须”与“可省”

把91行版本全部过一遍后,我重新整理了一份“哪些功能必须写进核心,哪些功能可以牺牲掉”的清单:

模块91行版本是否包含说明
棋盘数据与碰撞检测必须包含游戏的逻辑骨架
方块旋转必须包含玩法核心操作,不能省
消行与计分消行必须,计分可省没有消行就没有游戏进程
下一个方块预览可省增加策略性,但逻辑独立
等级加速可省用一行setInterval延时变化可实现
暂停/重新开始可省纯外围功能,却消耗不少代码
音效/动画可省锦上添花,不属于核心

这个清单的参考价值在于:它帮你厘清了俄罗斯方块这个游戏“游戏性”的地基到底是什么。先做地基,再谈装饰,这个顺序在工程实践里永远是对的。

4.3 尺寸与坐标计算的三个细节

俄罗斯方块里有一类bug属于“坐标偏移类”,非常隐蔽,我在这里集中说明实际踩过的三类问题。

第一类:旋转框中心偏移。3x3矩阵绕中心旋转没问题,但I形方块的4x4矩阵,旋转时如果中心点计算不对,方块会左右漂移。标准的处理方式是旋转后做墙踢(wall kick),也就是在旋转后检测碰撞,如果碰撞就往左或往右微调。91行版本为了省代码,可以不加墙踢,但至少要做“旋转后碰撞检测,非法则不旋转”:

function tryRotate() { const rotated = rotate(current.shape); if (!collides(board, rotated, current.y, current.x)) current.shape = rotated; }

第二类:消行后坐标偏移。消行后如果紧接着还用原来的y值循环,会漏行。解决方案就是前面在clearLines里展示的y++技巧。

第三类:快速下落时的越界。按下硬降键时,如果直接用while (!collides(...)) y++,最后会多移动一格,因为循环退出时最后一次移动其实是非法的。正确的做法是用while循环试探,但只有collides返回false才提交坐标,或者先减掉试探多出来的那一格。

5. 实际运行的常见问题与调试速查

5.1 方向键导致页面滚动

这个问题在网页版俄罗斯方块里出现频率极高。根本原因就一句话:方向键的默认行为没被阻止。解决时机有两个,一是监听事件时直接去拦截,二是把它挂在全局的preventDefault里。推荐前者:

document.addEventListener('keydown', e => { if (['ArrowLeft','ArrowRight','ArrowDown','ArrowUp'].includes(e.key)) e.preventDefault(); // 然后处理游戏逻辑 });

这个坑最烦人的一点是:如果你打开的页面没有滚动条,按方向键根本看不出问题;但只要页面高度超过视口,玩家一按方向键,整个页面就上下飘,非常出戏。

5.2 旋转后方块飞出边界

前面提到旋转矩阵必须是正方形,但如果某个形状定义的矩阵不是正方形,旋转后行列数会互换,导致坐标范围完全错乱,方块直接瞬移出棋盘。排查这类问题最快的方法是:在控制台打印旋转前后的数组形状,人眼对比一下行列数是否一致。这个方法效率极高,不要瞎猜。

5.3 setInterval的丢帧与不精准问题

setInterval在浏览器标签页切到后台时会自动节流,间隔会放宽到1秒甚至更久,导致游戏暂停。这在91行版本里不算bug,但如果做成正式产品,就必须改用requestAnimationFrame配合时间戳,利用accumulator累加时间差来控制落子节奏。

这也是为什么我始终强调:91行版本适合做原型和验证思路,但如果要发布、甚至上架应用市场,游戏循环必须换一套更可靠的驱动。技术上,切换的成本并不高,但要理解其中的差异,否则会出现“后台挂机回来游戏直接崩掉”的问题。

5.4 快速按同一键导致动作过快

键盘事件默认是支持长按连发的,浏览器会以重复触发keydown的方式响应长按。这会带来一个体验问题:长按左移时移动速度可能比预期快一倍,严重影响对操作手感的控制。改进做法是维护一个lastTime对象,记录每个按键上次生效的时间,在事件回调里判断是否已超过最短间隔:

let lastDirTime = 0; function move(dir) { const now = performance.now(); if (now - lastDirTime < 120) return; lastDirTime = now; // 移动逻辑 }

这个节流值我试过很多组,120到150毫秒之间手感最佳。小于100毫秒会过于灵敏,容易误触;大于180毫秒会显得迟钝,跟不上手速。

6. 从91行出发:可扩展的方向与优化建议

6.1 常见功能扩展:下一个方块预览与七连形状

当核心逻辑稳定以后,增长代码的最自然方向就是把外围功能一个个加回来。第一个建议加的是“下一个方块预览”。它的实现不需要改动核心逻辑,只需要在生成方块时额外生成一个随机索引,每次spawn时把“备用”形状赋给“当前”,再生成新的“备用”。代码量大约增加10行,但可玩性的提升非常明显。

还要注意七种方块的出现概率。直接用Math.floor(Math.random() * 7)时,真正的俄罗斯方块规则里还有一个“7-bag”随机系统,也就是把七种方块各放一个袋子里,全部取完后再生成新一轮,保证玩家不会连续几次拿到同一种方块。这个规则能让游戏更公平。91行版本完全没有这个机制,所以在长时间游戏时,会出现连续出同一个形状的“方差爆表”现象。扩展时建议优先加这个,比加炫酷特效更实在。

6.2 游戏循环替换:从setInterval到requestAnimationFrame

把setInterval换成requestAnimationFrame,是代码重构里最有价值的练习之一。核心方案是把下落速度定义成moveInterval常量,在每帧渲染时累加时间差:

let lastTime = 0; let accumulator = 0; const MOVE_INTERVAL = 500; function loop(time) { accumulator += time - lastTime; lastTime = time; while (accumulator >= MOVE_INTERVAL) { update(); // 下落逻辑 accumulator -= MOVE_INTERVAL; } draw(); requestAnimationFrame(loop); }

accumulator用while循环累加的好处是:即使某一帧卡顿到积累了多个时间段的下落量,游戏逻辑也会一次性追平,不会因为渲染卡顿导致节奏错乱。这个模式在游戏开发里叫固定时间步长(fixed timestep),是比简单requestAnimationFrame更可靠的游戏循环写法。

扩展到这里,代码量大概会从91行增长到150行左右。但你会发现,真正扩展的不是“功能”,而是“系统”。91行版本把一切都写在一个作用域里,靠约定维持顺序;扩展版本的代码逐步拆分出游戏状态、渲染函数、输入处理模块,这才是从demo走向可维护应用的关键一步。

6.3 移动端适配与触摸操作的一个替换思路

如果想把游戏放到手机上玩,需要面对的问题是:没有方向键。一个非常实用的替代方案是用触摸手势模拟四方向操作:左滑、右滑、下滑、上滑(旋转)。代码上只需要在监听touchstart和touchend时,计算起点和终点的坐标差,根据差值在某一个轴上的绝对值决定触发了哪个动作。

我实际测量的经验值是:水平滑动差超过30像素判定为左右移动,垂直滑动差超过40像素判定为下移或旋转。阈值太小容易误触,阈值太大操作反应慢。这个设置在91行版本的基础上增加大约20行,改动量依然很小,但游戏的可触达面立刻从桌面扩展到移动端。

6.4 记分、关卡速度和颜色的增量实现

最后,如果91行版本想“完整化”,我建议按以下顺序增量添加功能:

  1. 消行计分:每消1行加100分,连续消4行(Tetris)加800分。这个逻辑在clearLines里已经能看到消除了几行,只需要把返回值接住。
  2. 速度递增:每消除10行,把MOVE_INTERVAL减少30毫秒,但设一个下限,比如100毫秒,避免快得没法玩。
  3. 形状配色:给SHAPES里的每种形状加一个颜色索引,渲染时根据索引取色,视觉效果立刻不一样。

这三个功能加完后,代码量大概会到200行左右,但玩起来已经有完整商业游戏的初步质感了。

我的实际体验与最后想说的一点

这91行代码写完之后,我有一个很深的感受:很多看起来“简陋”的代码,恰恰因为规模小,反而把逻辑边界逼得非常清晰。你没法再往函数里塞一堆“以防万一”的判断,必须保证每一个判断都有实际用途。这种训练,对我后来写大项目的影响远超预期——它会让你养成一种“这个功能真的有必要吗”的直觉,而不是“管它的,先加着再说”的囤积心态。

如果你也动手写这个91行版本,我建议你把代码抄完跑通之后,不要急着扩展,先把collides、merge、clearLines这三个函数各自的输入输出重新读一遍,然后合上代码,自己在纸上画出这个游戏循环的数据流。能画出这个数据流,才是真正吃透了这段91行代码。最后再分享一个小技巧:调试俄罗斯方块时,强烈建议在控制台暴露一个debugBoard()方法,直接打印棋盘数组的字符画版本,很多视觉上看不出来的边界问题,在字符画里会立刻现形。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 7:06:32

VC++ CAD系统开发:OpenGL渲染与CAD交互链路实战

简介&#xff1a;本资源是《CAD应用程序开发详解——Visual C与OpenGL综合应用》一书的配套源码DEMO包&#xff0c;面向具备C基础、希望深入掌握CAD系统图形渲染与交互开发的中高级工程师及高校相关专业学习者。内容聚焦于MFC框架下OpenGL集成、3D几何建模、光照纹理映射、鼠标…

作者头像 李华
网站建设 2026/10/16 7:05:52

静态库与动态库:链接原理、运行时查找与排错指南

每次在编译链接阶段看到一屏又一屏的 undefined reference&#xff0c;或者运行程序时被 cannot open shared object file 弹一脸&#xff0c;你就知道&#xff0c;自己又跟静态库、动态库杠上了。我们写的代码几乎不可能脱离库单独存在&#xff0c;而链接器怎么处理这些库&…

作者头像 李华
网站建设 2026/10/16 7:05:12

二自由度车辆动力学模型求解与Simulink仿真实践指南

简介&#xff1a;面向车辆工程学生及使用Simulink的仿真初学者&#xff0c;这份二自由度动力学模型聚焦车辆侧向与垂直运动&#xff0c;忽略俯仰、偏航等复杂维度&#xff0c;适合快速掌握侧滑角、车身姿态等关键特性&#xff0c;可用于课程设计、控制策略验证或稳定性分析。资…

作者头像 李华
网站建设 2026/10/16 7:04:38

TensorFlow多模型加载实战:独立Graph与Session方案

做图像服务的同学应该都遇到过这种需求&#xff1a;两三个模型一起上线&#xff0c;业务要求它们同时常驻内存&#xff0c;随叫随到。我之前写过两篇TensorFlow推理相关的东西&#xff0c;这篇算是第三篇&#xff0c;专门聊一个很实际的问题——同一个进程里&#xff0c;同时加…

作者头像 李华
网站建设 2026/10/16 7:04:30

TCP可靠传输与流量控制:确认重传、滑动窗口与拥塞控制详解

计网学到传输层&#xff0c;TCP的可靠传输和流量控制是绝大多数人绕不过去的两个硬骨头。它们不像下层协议那样只负责“发出去”&#xff0c;TCP的职责是把一段字节流完整、按序、可控地送到对端进程手上。这篇主要围绕可靠传输里的确认与重传、滑动窗口&#xff0c;以及流量控…

作者头像 李华