基于面向对象与jQuery的俄罗斯方块实现:从重构到核心算法

发布时间:2026/10/20 5:25:29
基于面向对象与jQuery的俄罗斯方块实现:从重构到核心算法
写俄罗斯方块这个练手项目大概是所有前端学习者都绕不过的一座山。我早期写的第一个版本代码里全是全局变量和小兵一样的函数移动、旋转、消行、计分散落在十几个方法里后来想加个“下一块预告”功能改了整整一个晚上都理不清状态。后来用JavaScript的面向对象思路结合jQuery重构了一版代码从一团乱麻变成了几个职责清楚的类整个项目瞬间就通透多了。这篇文章就把这套基于面向对象结合jQuery的俄罗斯方块实现拆开讲清楚从设计思路到核心算法再到开发顺序和排坑经验适合刚学完JS基础、想要一个完整练手项目的前端初学者也适合想用面向对象思路重新整理旧项目的同行。1. 为什么俄罗斯方块和面向对象是天生一对1.1 传统写法的头痛之处很多初学者写俄罗斯方块第一反应是用函数加全局变量currentShape存当前方块形状currentX和currentY存位置rotation存旋转状态board存整个面板另外还有score、level、timer一堆状态。功能一多函数之间互相传参、互相修改状态一多就很容易出问题。比如我想让“下一个方块预告”和“当前方块”用同一套旋转逻辑传统写法里就只能是复制粘贴一套极其相似的代码改一个地方还要记得改另外三处。由于俄罗斯方块的业务状态很多而且方块的行为高度相似它天然就是一个适合面向对象建模的场景把所有状态收拢到对象里把相同行为抽到原型上后续加功能才好接住。这个项目学到的一个关键领悟就是不是所有项目都要强行面向对象但俄罗斯方块这种“很多同类型对象在一个规则场景里活动”的项目对象的收益非常明显。1.2 拆对象把游戏拆成几张“卡牌”以我重构后的版本为例整个游戏拆成了三个核心对象Game游戏进程和规则、Actor当前正在落下的方块组、Renderer负责把数据绘制到页面上。棋盘数据我直接用二维数组挂在Game里而不是单独开一个对象。这里的核心原则是“谁掌握规则谁就掌握数据”棋盘只是被Game操作的普通数据结构没必要过度设计成类。拆完对象之后每个对象的职责非常清晰。Actor只关心自己矩阵里的形状、当前位置、移动和旋转Game只关心下落节奏、碰撞后的固定逻辑、消行计分、游戏结束Renderer只负责把Game里的数据画成DOM。后面想加“幽灵块”下落位置的投影只需要在Renderer里多画一遍预测位置完全不用改Actor和Game的规则这种扩展体验就是面向对象带来的实际好处。1.3 边界划分谁管数据谁管界面面向对象设计最容易走偏的点是把所有东西都塞进一个巨大的Game对象里结果Game又变成了新的“全局变量集合”。我自己的教训是凡是涉及数据计算和游戏规则的方法一律不碰DOM凡是涉及DOM操作的方法不包含任何游戏逻辑判断。判碰撞、算旋转、数消行都属于前者由Game和Actor负责拼DOM、改class、绑定键盘事件都属于后者由Renderer负责。打个比方这就像后厨和前台的分工后厨决定一道菜怎么做游戏数据前台只负责把菜端上桌DOM展示。如果你在端菜的时候还顺带决定了菜品内容那菜单改个价格都得重新培训服务员。把这个边界定死了俄罗斯方块后期加特技、加音效、加AI框架都不用动。2. 核心结构与关键类设计2.1 棋盘数据与坐标约定这个项目里标准的俄罗斯方块棋盘是10列乘20行。我用一个二维数组grid来存grid[行][列]值为null表示空格值为字符串表示已固定的方块类型比如T、“L”、“I”。这里有一个特别重要的约定行用y表示方向向下列用x表示方向向右。坐标系一旦定义清楚后面旋转、碰撞、渲染都不会乱。方块形状我选用矩阵来定义。比如T形方块它的矩阵就是const SHAPES { T: [ [0, 1, 0], [1, 1, 1], [0, 0, 0] ], I: [ [0, 0, 0, 0], [1, 1, 1, 1], [0, 0, 0, 0], [0, 0, 0, 0] ], O: [ [1, 1], [1, 1] ] };用矩阵的好处是后续旋转算法天然好实现其他S、Z、J、L方块同理。Actor对象创建时会把自己的起始坐标设在棋盘顶部中间附近x为4或5看矩阵宽度y为0。注意y为0表示在棋盘第一行但不能让方块直接穿出顶面所以生成时要保证矩阵里非零部分落在可见区域内。2.2 方块组Actor的设计Actor是“违法块”的绝对主角它的核心属性是type方块类型、matrix形状矩阵、x和y整个矩阵左上角在棋盘上的坐标。它最重要的三个方法move、rotate、cellAt。function Actor(type) { this.type type; this.matrix SHAPES[type]; this.x Math.floor((COLS - this.matrix[0].length) / 2); this.y 0; }move方法负责水平或垂直移动。它不是直接改坐标而是先“试算”新坐标再交给碰撞检测函数验证通过了才真正落地。我用了一个很朴素的模式Actor.prototype.tryMove function(dx, dy, board) { const fake Object.create(Actor.prototype); fake.type this.type; fake.matrix this.matrix; fake.x this.x dx; fake.y this.y dy; if (!collides(fake, board)) { this.x fake.x; this.y fake.y; return true; } return false; };这里的习惯是不构造真实的新对象而是用一个轻量的fame对象去试碰撞。因为碰撞检测只关心“位置和矩阵”并不关心完整对象状态。这样写的好处是不会在失败移动时留下任何副作用。2.3 Game类的职责Game负责全局节奏和规则判定持有棋盘grid、当前Actor、计分、等级、定时器ID、游戏结束标记。它的step方法是每帧或每个定时器周期执行的逻辑入口Game.prototype.step function() { if (this.gameOver) return; if (!this.current.tryMove(0, 1, this.board)) { this.lockActor(); const lines this.clearLines(); this.score [0, 100, 300, 500, 800][lines]; this.spawnActor(); if (collides(this.current, this.board)) { this.gameOver true; this.renderer.showGameOver(this.score); return; } } this.renderer.paint(this); };lockActor把当前Actor的矩阵数据按坐标写入grid然后检查消行。这一切的调度都集中在Game里而具体的移动和旋转逻辑在Actor里界面绘制在Renderer里。后面加“下一块预告”时我只需要给Game加一个nextActor属性和生成下一个方块的逻辑再让Renderer多画一个面板没有任何一个旧方法的签名需要改动。3. 旋转、碰撞与消行这三个算法吃透了就赢了一半3.1 旋转矩阵转置与行反转俄罗斯方块的旋转如果用的是矩阵定义法实际就是矩阵的顺时针90度旋转。顺时针旋转一个N x N矩阵的方法是先做一次转置行列互换再逐行反转。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; }“转置加逐行反转”听起来抽象画个2x2的矩阵手推一遍就懂了。比如1 0 0 1 1 1 0 1这个变换其实就是数学课上学过的坐标系旋转但用矩阵视角来看代码一行都不需要解释边界情况。重点在于旋转之后Actor的x和y要不要调整。我给的经验法则是如果矩阵的维度没变都是n x n保持x和y不变基本够用。旋转后如果与已固定的方块重叠或者撞到左右墙就要做“预碰撞检测”检测失败则保持原旋转状态不过更进阶一点可以尝试左右平移后再旋转这就是后面说的踢墙算法。3.2 碰撞检测的“预试移”思想俄罗斯方块里所有的碰撞判断都可以用一个collides函数统一处理。判断条件只有三条目标方格是否超出了水平边界是否超出了底部边界是否与棋盘grid里已固定的格子重叠。function collides(actor, board) { for (let dy 0; dy actor.matrix.length; dy) { for (let dx 0; dx actor.matrix[dy].length; dx) { if (!actor.matrix[dy][dx]) continue; const px actor.x dx; const py actor.y dy; if (px 0 || px COLS || py ROWS) return true; if (py 0 board[py][px]) return true; } } return false; }关键思想是“先试后动”移动、旋转、下落之前先把假象状态构造出来调用一次collides不撞就执行撞了就放弃。这个模式避免了移动后需要回退的麻烦也避免了“已经穿墙又拉回来”的抖动。因为俄罗斯方块没有物理引擎所有移动都是瞬移预检测是唯一可靠的方案。3.3 消行与游戏结束判定消行的逻辑很直接从下往上扫每一行判断这一行是否全部非空。如果是从grid里删掉这一行再在数组头部补一个全空行。这里有一个细节一定是从下往上扫而不是从上往下否则行号会乱。Game.prototype.clearLines function() { let lines 0; for (let y this.board.length - 1; y 0; ) { if (this.board[y].every(cell !!cell)) { this.board.splice(y, 1); this.board.unshift(new Array(COLS).fill(null)); lines; } else { y--; } } return lines; };每次消行后刷新分数这几乎已经成为俄罗斯方块的固定套路。然后要立刻生成新的Actor并检查新Actor和已固定方块是否碰撞如果碰撞就说明棋盘已经被顶到没有安全空间了游戏结束。我做的版本里游戏结束只是弹一个遮罩层把结束画面交给Renderer去展现Game本身只更新gameOver状态。4. jQuery在项目里到底干哪些活4.1 渲染层拼HTML字符串一次性上屏说实话现在写前端项目大多数人已经用框架了但jQuery在这个项目里的作用是清晰的它负责DOM查询、事件绑定和局部更新。因为棋盘是一个10x20的网格最笨的做法是选中全部200个div逐个改class那样做页面会频繁重排。我用的是更高效的做法构建完整的HTML字符串一次赋值给父容器。Renderer.prototype.paint function(game) { let html ; for (let y 0; y game.ROWS; y) { for (let x 0; x game.COLS; x) { let cls game.board[y][x] || ; const cell game.current.cellAt(x - game.current.x, y - game.current.y); if (cell) cls cell; html div classcell cls /div; } } this.$box.html(html); };整个棋盘一次innerHTML更新实测下来非常流畅。边框和网格样式全部由CSS的.cell和颜色类控制数据层完全不掺和样式。这种“数据到HTML字符串”的渲染方式要比逐个选择器更新快得多是我在实际开发中比较喜欢用的jQuery渲染模式。4.2 事件层键盘绑定与默认行为jQuery在事件处理方面的优势很明显$(document).on(keydown, handler)可以统一管理键盘监听不需要关心老版浏览器之间的事件绑定差异。方向键左移、右移、下移软降上键旋转空格硬降P键暂停这些都是一套handler里的分支$(document).on(keydown, function(e) { if (game.gameOver) return; switch (e.key) { case ArrowLeft: game.current.tryMove(-1, 0, game.board); game.renderer.paint(game); break; case ArrowRight: game.current.tryMove(1, 0, game.board); game.renderer.paint(game); break; case ArrowDown: game.stepBySoftDrop(); break; case ArrowUp: game.current.tryRotate(game.board); game.renderer.paint(game); break; case : game.hardDrop(); game.renderer.paint(game); break; } e.preventDefault(); });因为这里的按键处理会立即触发渲染所以玩家的操作反馈几乎是实时的。这个过程中jQuery解决的问题只是“事件绑定”真正的逻辑还是调Game和Actor的方法分得很清。4.3 哪些地方不要用jQuery我在实际编写过程中发现jQuery的链式操作和选择器能力很强但游戏的核心逻辑一旦依赖了DOM查询就会陷入“状态和界面互相纠缠”的泥潭。比如不要在Game里写$(‘#score’).text(score)也不要让Actor知道任何选择器。这些界面调用全部放到Renderer里Game只提供数据和事件回调。如果你想增删CSS类来控制当前方块的样式可以保留选择器局部更新但如果每次绘制都要用$(.cell[data-x1][data-y2])来精确定位格子性能就不好代码也会很啰嗦。我在渲染阶段坚持“一次性生成全部HTML”只保留在诸如显示下一块面板、分数面板时才用单独的容器选择器这样既保留了jQuery的便利又不拖累关键渲染路径。5. 从零复现的开发顺序与调试思路5.1 分阶段推进先数据后界面我强烈建议不要一头扎进DOM和样式里先把纯数据逻辑写出来。我当时按照这样的顺序推进先定义SHAPES和棋盘数据结构再写Actor和Game的纯逻辑方法移动、旋转、碰撞、消行再用一个简单的控制台输出验证逻辑最后才写Renderer和CSS。这样每一个阶段都能独立测试出了问题也知道是哪一层。特别是消行和碰撞逻辑完全可以脱离浏览器单独验证。我在项目早期阶段直接在控制台里手动调用Game的方法观察grid数组的变化。建议你也在控制台准备一个打印函数把grid里非空格子用#和空格表示打印出来什么都在下方显示。你很快就能发现初始化、移动、落下这一整条链路是否正常。5.2 用console.table排查行列表错乱我实际踩过最大的坑之一就是坐标方向搞反。因为矩阵的行列和棋盘的x/y坐标很容易混。有一版代码方块明明在向左移动页面却显示往下跑。排查了半天发现是渲染时把x当成了行、y当成了列参数顺序调了回来就好了。调试这类问题我推荐直接用console.table(game.board)打印二维数组。浏览器控制台会渲染成一个表格哪里多了一格、哪里空了一目了然。如果你还在用console.log(string)把二维数组转成字符串打印也可行但用console.table确实更直观。把这个技巧收好你在后面写贪吃蛇、扫雷这类网格游戏时也用得上。5.3 每隔一个里程碑跑一次完整流程很多人写俄罗斯方块喜欢把所有功能全写完再测试结果球到最后一测到处是bug根本分不清哪里出错。我重构时规定自己每完成一个小功能就跑一遍完整流程能生成方块并自动下落了先停下来看看下落速度是否正常能左右移动了就反复按方向键试边界能旋转了就在四面墙旁边连续转几次看会不会穿模或者越转越飘。这样做还有一个额外好处每跑一个完整流程就能顺手检查一下游戏手感。手感和速度没关系但如果你做完旋转才想起来“方块在贴墙时应该可以踢一下墙再旋转”再回去改碰撞逻辑就比全部写完后推倒更麻烦。6. 我踩过的坑和几个重要细节6.1 setInterval叠加与清不掉旧时钟定时器是这个项目最容易出暗坑的地方。如果start函数里直接setInterval而在restart时又调用了一次start旧的定时器没有清理就会出现两个时钟同时跑方块下落速度直接翻倍。解决方法是把定时器ID存到Game对象的属性里每次启动前先clearInterval一下Game.prototype.start function() { this.stop(); this.timer setInterval(() this.step(), this.getSpeed()); }; Game.prototype.stop function() { if (this.timer) { clearInterval(this.timer); this.timer null; } };这个教训同样适用于任何需要“重启”的功能轮播图、自动播放、文字滚动。接管定时器ID并且统一清理应该成为一种习惯。6.2 快速按键与自动下落撞车方向键按住不放时浏览器会连续触发keydown事件导致方块快速连续移动。而与此同时定时器里的step还在自动下落。两个机制叠加方块移动速度可能突然变得特别快甚至会出现一次操作移动两格的诡异手感。我当时的处理方式不算完美但足够用在keydown事件里如果检测到两次按键间隔小于50ms就丢弃这次移动事件只处理用户真正的连续点击。或者更标准一点给移动加一个简单的时间戳限制比如300ms内只响应一次水平移动。旋转和硬降则可以每次都响应因为它们本身是独立操作连按也没有太大危害。6.3 旋转中心偏移问题用矩阵法定义方块后旋转逻辑本身很稳定。但我遇到过这样的问题旋转之后Actor的矩阵尺寸变了比如长条I在竖着和横着时矩阵占用的宽度不一样如果生成位置和x坐标基于旧矩阵计算就会导致方块旋转后整体向一边偏移视觉上像“跳了一下”。我采用的方案比较直接所有形状统一放到4x4或3x3矩阵里定义尺寸固定旋转前后矩阵维度一致x和y完全不用调整。长条I的竖版和横版都在4x4矩阵里呈现只是非零格子的位置不同这样一来旋转起来重心变化很小不需要额外偏移逻辑。代价是方块矩阵里有不少空白格子但这正是整个项目里最简单可靠的处理方式。6.4 关于下一步扩展写到这一步一个可以稳定运行的基于面向对象和jQuery的俄罗斯方块已经成型了。我觉得这个项目最好的地方在于它是很多进阶机制的天然试验场可以给Game加一个nextActor实现“下一块预告”可以把Math.random改成7-bag随机发牌让所有方块按固定周期循环出现可以加“幽灵块”把当前方块的落点投影画在棋盘底部。这些全部都在面向对象框架下做增量扩展不需要大改。我个人的源码包现在就是按这个结构整理的HTML页面、CSS样式、javascript文件里的SHAPES定义、Actor构造函数与原型方法、Game逻辑、Renderer渲染、入口初始化各个文件职责分明。完整源码我已经打包放到了网盘链接里需要的自取。运行方式很简单的把整个文件夹下载下来浏览器打开index.html不需要编译不需要Node环境直接就能玩。说实话回头看这套项目最能让人成长的部分不是写了多少行代码而是体验了一次“先用函数硬写、再用对象重构”的完整过程。你会慢慢明白面向对象并不是什么高深概念它只不过是把游戏世界里本身存在的人和事用代码诚实地表达出来。