资讯动态

面向对象编程练手项目:基于jQuery的JavaScript俄罗斯方块实现解析

发布时间:2026/10/20 8:51:40 来源:尧图企业网站定制
这两天整理硬盘翻出刚学前端那会儿写的JavaScript俄罗斯方块。项目本身不大技术栈是jQuery但整个代码我是按面向对象编程的思路来组织的。当时拿它练手目标很明确把经典游戏逻辑、对象建模和DOM操作一次性串起来。对于刚过完JS语法、想看看真实项目怎么组织代码的开发者来说这个课题特别合适——游戏不大但状态多、逻辑紧、交互反馈快练完之后再回头看自己那些函数堆函数的小项目思路会清楚很多。网盘链接的事放到最后说先把整个项目从设计到落地完整拆一遍。1. 项目定位与面向对象建模思路1.1 为什么拿俄罗斯方块练面向对象很多人在学完JavaScript基础之后都会卡在同一个地方语法都认识函数也能写但一遇到稍微完整的程序就不知道怎么组织。我当年也一样。写俄罗斯方块之前尝试过做待办事项、轮播图之类的练手项目结果全是函数拼在一起加一个功能就要改一片代码。俄罗斯方块非常适合练面向对象原因很实际它的“对象”是天然存在的。方块是一个对象棋盘是一个对象计分规则是一个对象游戏流程控制又是一个对象。每个对象都有自己的数据和操作彼此之间有明确的边界。这种项目不需要你刻意去抽象名词需求本身就把对象摆在你面前了。更关键的是这个游戏有真实的交互反馈。你写得好不好跑一局马上就能感觉到移动跟不跟手、旋转会不会卡墙、消行时会不会闪顿。面向对象解决的不只是“代码能跑”而是“改起来不痛”。比如后面我想加一个新方块类型只需要在数据表里加一行配置想把div格子改成Canvas渲染核心逻辑一行都不用动。这种好处只有项目稍微复杂一点才体会得到。1.2 类结构与模块划分做这个项目时我没有一上来就写代码而是先把程序里出现的对象列了出来最终拆成四个核心类。类核心职责主要属性和方法Block当前正在下落的活动方块type、matrix、x、yrotate()、move()Board棋盘数据和已锁定方块的存储cols、rows、gridcanPlace()、lock()、clearLines()Game游戏主流程调度board、block、score、timerstart()、update()、spawn()UI负责一切DOM渲染和事件绑定cells缓存、render()、bindEvents()最开始我把所有逻辑都塞在Game里面后来发现不行方块自己的旋转算法、棋盘的行列判断、DOM渲染混在一起改一个地方要小心翼翼的。拆开之后舒服多了。Block只管自己长什么样、能不能移动Board只管网格数据对外提供判断和锁定的接口UI层用jQuery操作界面渲染只看数据不关心游戏逻辑。这里要强调一个原则不要用jQuery去改游戏数据。当时我犯过顺手$(#score).text()写在Game逻辑里的问题看起来没什么但一旦UI结构调整逻辑代码也要跟着动。正确做法是Game只负责计算改UI的事全部丢给渲染层处理。后面扩展功能时你会发现这个边界有多省心。2. 核心玩法实现与算法拆解2.1 七种方块的数据建模俄罗斯方块一共有七种基本形状I、O、T、S、Z、J、L。每种形状我用一个4x4矩阵来表示矩阵里1的地方代表方块占位0代表空白。这样选择的原因是矩阵做旋转和边界判断都统一不需要为每种方块单独写逻辑。const SHAPES { I: [ [0, 0, 0, 0], [1, 1, 1, 1], [0, 0, 0, 0], [0, 0, 0, 0] ], O: [ [0, 0, 0, 0], [0, 1, 1, 0], [0, 1, 1, 0], [0, 0, 0, 0] ], T: [ [0, 0, 0, 0], [1, 1, 1, 0], [0, 1, 0, 0], [0, 0, 0, 0] ], S: [ [0, 0, 0, 0], [0, 1, 1, 0], [1, 1, 0, 0], [0, 0, 0, 0] ], Z: [ [0, 0, 0, 0], [1, 1, 0, 0], [0, 1, 1, 0], [0, 0, 0, 0] ], J: [ [0, 0, 0, 0], [1, 0, 0, 0], [1, 1, 1, 0], [0, 0, 0, 0] ], L: [ [0, 0, 0, 0], [0, 0, 1, 0], [1, 1, 1, 0], [0, 0, 0, 0] ] };用4x4矩阵确实会浪费一些空间但换来的是旋转算法和碰撞检测的写法完全统一。颜色映射单独用对象维护const COLORS { I: #00bcd4, O: #ffc107, T: #9c27b0, S: #4caf50, Z: #f44336, J: #2196f3, L: #ff5722 };2.2 碰撞检测与锁定逻辑碰撞检测是整个游戏最核心的方法作用是在方块移动或旋转之前先问一句目标位置能不能待不能只看方块自身坐标还要结合Block的矩阵和Board的现有格子一起判断。canMove(board, dx, dy) { const m this.matrix; for (let r 0; r m.length; r) { for (let c 0; c m[r].length; c) { if (!m[r][c]) continue; const nx this.x c dx; const ny this.y r dy; if (nx 0 || nx board.cols) return false; if (ny board.rows) return false; if (ny 0 board.grid[ny][nx]) return false; } } return true; }这里有三个边界条件要处理左右墙壁、底部边界、以及是否和已锁定格子重叠。有个小坑是ny 0的情况——方块刚生成时可能在棋盘顶部外面这时候如果直接if (ny 0 board.grid[ny][nx])就正常放行否则新方块还没进来就会被判断成碰撞。当方块无法继续下落时就把当前方块“按”进棋盘数据里这就是锁定。我用了一个很直白的写法遍历Block的matrix把有1的位置写入Board的grid数组值为当前方块类型。锁定之后立刻清行、生成新方块。2.3 消行与计分机制消行逻辑本身不复杂但处理顺序有讲究。每行都扫一遍如果一整行没有任何空格就把这一行从数组里删掉然后在数组顶部补一个空白行。clearLines() { let lines 0; for (let row this.rows - 1; row 0; row--) { if (this.grid[row].every(v v ! 0)) { this.grid.splice(row, 1); this.grid.unshift(new Array(this.cols).fill(0)); row; lines; } } return lines; }为什么要从底部往上扫因为splice之后下面的行会顶上来如果从上往下扫很容易跳过一行。这里的row就是补偿操作让循环重新检查当前位置。我当时就是没注意到这一点消行后留下了一排“幽灵行”。计分规则我参照了经典玩法消1行100分2行300分3行500分4行800分。每消除10行升一级等级越高下落间隔越短。等级和分数都属于Game层的状态UI渲染时读取显示即可。2.4 渲染层与jQuery的角色渲染方案我选的是div网格——理由是这本来就是练jQuery的项目用Canvas反而少了很多DOM操作练习机会。棋盘10列20行初始化时循环生成200个小格子关键是把这个DOM查询结果缓存住initBoard() { this.cells []; for (let i 0; i this.board.rows * this.board.cols; i) { const $cell $(div, { class: cell }); $(#board).append($cell); this.cells.push($cell); } }每次渲染时只需要把Board的网格数据和当前活动方块合并成一个临时二维数组然后按下标更新格子的背景色。这个过程中只操作缓存的jQuery对象不再重新查DOM。如果不用缓存每次渲染都要$(.cell).eq(index)200个格子跑起来就会出现肉眼可见的卡顿。3. 实操过程与关键步骤3.1 页面搭建与棋盘初始化页面结构很朴素一个容器、一个分数显示、一个棋盘、一个按钮。HTML大概长这样。div idgame div idinfo p得分span idscore0/span/p p等级span idlevel1/span/p button idstartBtn开始游戏/button /div div idboard/div /divCSS方面给棋盘设置固定宽高格子用20px × 20px的正方形加1px边框。整个棋盘10 × 20 200个格子容器尺寸就是200px × 400px。这个尺寸不是随便定的每格20px在屏幕上有足够的视觉辨识度整体也不占太大面积。初始化Game对象时先创建Board实例再生成DOM格子最后绑定键盘事件。棋盘单元格全部用jQuery动态创建避免写200个div的模板代码。3.2 Block类的具体实现Block构造函数做的事情很少随机选一个形状初始化4x4矩阵设定初始坐标。I形初始x坐标我设为3其他形状设为4这样基本能保证方块出现在棋盘中间偏左的位置。class Block { constructor() { const keys Object.keys(SHAPES); this.type keys[Math.floor(Math.random() * keys.length)]; this.matrix SHAPES[this.type].map(row row.slice()); this.x this.type I ? 3 : 4; this.y 0; } rotate() { const n 4; for (let i 0; i n; i) { for (let j i 1; j n; j) { [this.matrix[i][j], this.matrix[j][i]] [this.matrix[j][i], this.matrix[i][j]]; } } for (let i 0; i n; i) { this.matrix[i].reverse(); } } }旋转用的是矩阵转置再加左右翻转实现的是顺时针90度旋转。O形是正方形旋转后长一样没关系I形横变竖、竖变横效果完全正确。这里有个细节要注意this.matrix必须做深拷贝不能直接引用SHAPES[this.type]否则旋转会污染原始形状数据下一块同类型的方块生成时形状就错了。这算是新手特别容易踩的坑。3.3 Game流程与主循环Game类的职责是把Block、Board、UI串起来。启动游戏后用一个setInterval驱动方块每帧下落一格。start() { this.score 0; this.level 1; this.timer setInterval(() this.tick(), this.getSpeed()); } tick() { if (this.block.canMove(this.board, 0, 1)) { this.block.y; } else { this.lockBlock(); } this.render(); }getSpeed()返回当前等级的间隔毫秒数我用了一个简单的递减公式每升一级缩短30ms最低不低于100ms。每当方块落定就走锁定流程把当前方块写入棋盘数据消除完整行更新分数生成下一个方块。生成新方块后要马上检查新方块是否能放到初始位置如果放不进去说明游戏堆满了结束游戏。lockBlock() { this.board.lock(this.block); const lines this.board.clearLines(); this.addScore(lines); this.spawnBlock(); if (!this.block.canMove(this.board, 0, 0)) { this.gameOver(); } this.render(); }这个流程顺序是固定的别把gameOver判断放到锁定之前否则堆满时会弹出结束提示太早。3.4 键盘控制和防抖处理键盘控制是游戏手感的重要一环。我用jQuery在document上绑定keydown事件按键映射如下。按键键码动作左方向键37向左移动右方向键39向右移动上方向键38旋转下方向键40软降一格空格键32硬降到底$(document).on(keydown, (e) { switch (e.keyCode) { case 37: e.preventDefault(); this.moveLeft(); break; case 39: e.preventDefault(); this.moveRight(); break; case 40: e.preventDefault(); this.softDrop(); break; case 38: e.preventDefault(); this.tryRotate(); break; case 32: e.preventDefault(); this.hardDrop(); break; } });preventDefault()必须调用尤其是空格键否则按空格会触发页面滚动。按键长按会连续触发keydown原生的俄罗斯方块其实支持按住连续移动但直接写在keydown里会走得过快。我的处理是给左右移动加一个简单的节流时间戳两次移动间隔小于120ms就直接忽略。4. 常见问题与排查实录4.1 定时器叠加导致游戏越跑越快这是我最开始遇到的第一个明显bug。测到第4级时发现方块下落速度快得离谱根本不是预期的间隔。打开控制台发现tick被调用得越来越频繁原因是我在升级时直接又调了一次setInterval老定时器没有清掉。修复方法很简单每次要修改速度前先调clearInterval(this.timer)再重新设置。记住一个经验任何全局级别的重复定时器在新建之前必须确认旧的已经销毁。4.2 旋转越界与踢墙处理I形方块贴右边墙旋转时会有一半插到墙外面。直接不让旋转当然可行但游戏体验很差玩家会觉得操作被吞了。传统俄罗斯方块的解决方案是踢墙Wall Kick旋转后如果位置非法尝试左右挪动1到2格看能不能找到一个合法位置。tryRotate() { const test this.block.getRotatedCopy(); const offsets [0, -1, 1, -2, 2]; for (const offset of offsets) { if (this.board.canPlace(test, this.block.x offset, this.block.y)) { this.block.applyRotated(test); this.block.x offset; return true; } } return false; }偏移的顺序建议是原地、左一、右一、左二、右二。实际体验中2格以内的踢墙成功概率非常高超过2格就会显得很玄学。这个修复大幅提升了旋转的手感属于必须做的优化。4.3 消行后渲染对不上消行表现异常时我检查渲染代码发现格子位置对不上明明数组第3行已经变成了新空白行界面显示还是旧数据。问题在于我用行号直接从缓存cells里取格子时假设了索引是row * cols col这个映射本身没错错的在消行逻辑从上往下扫行时splice导致行索引提前变化渲染时旧数据串行了。统一修正后所有行操作都从底部往上处理渲染时严格按row * cols col计算索引。这样数组和DOM之间就是一对一的关系排查起来非常快。4.4 空格硬降后的误触发按下空格执行硬降结果新方块出现后马上又往下掉了一格给人感觉“跳了一下”。追踪后发现空格键keydown触发硬降后同一事件还顺着流程触发了软降逻辑相当于一次按键干了两件事。解决方式是在硬降结束后加一个标志位比如lastAction hardDrop在tick里判断如果这次是由硬降触发锁定就跳过本次下落。也可以用setTimeout把硬降动作包一层让当前事件循环先走完。4.5 渲染性能与jQuery对象缓存项目早期渲染方式是每次都用$(#board .cell).eq(index)取格子200个格子重新查DOM再设置样式明显卡。后来把200个jQuery对象在初始化时缓存成一个数组渲染时直接按下标取对象性能立刻恢复正常。这个优化点很简单但很能体现jQuery的实际使用习惯DOM查询是很贵的能用缓存绝不明目张胆重复查。5. 项目扩展与优化空间5.1 从jQuery迁移到现代方案这个项目的核心逻辑是面向对象的好处在后续迁移时体现得非常明显。如果现在想改成Canvas渲染只需要替换UI层Block和Board完全不用动。Game类里所有和DOM有关的部分抽出来后剩下的就是纯数据计算。要是换到现在我可能会用Canvas渲染棋盘或者用类似Vue/React的框架管理格子状态但核心的碰撞检测、旋转、消行算法不会变。面向对象的分层让这个迁移成本变得很低这是当时没有料到的重要回报。5.2 玩法增强与算法改进很多经典玩法都可以在这个骨架上加。当前的新方块是完全随机的高手玩起来容易因为连续出同一个形状而难受。可以改成7-bag随机算法把七种形状先打乱成一个包每次按顺序取完一整包后再重新打乱这样保证任何连续7块里七种形状各出现一次体验公平很多。还有几个常见增强一个简单的功能是显示下一块预览做法是生成两个Block对象一个在场上下落一个在预览区显示另一个是影子方块把当前方块垂直投射到落地位置用半透明色渲染。这些功能都不复杂但做完之后项目会从“能玩”变成“像样”。5.3 个人体会与源码说明这次重看我做的这个经典小游戏印象最深的反而不是算法本身而是面向对象编程带给我的流畅感。写之前觉得“面向对象是什么”是个概念题写完之后才理解它是组织代码的工具——核心不是类关键字而是职责边界。俄罗斯方块这个项目规模恰好对象不太多但每个对象边界都清清楚楚练一遍比看十篇教程都管用。jQuery现在虽然不再是新项目的主流选择但当时用它写DOM操作的思路放到今天依然有价值缓存DOM、操作周期内合批更新、事件绑定的局部性选择这些经验迁移到原生JS或者框架里完全通用。源码包放到百度网盘里了里面有完整的HTML、CSS、JS文件jQuery是本地引入的下载解压后直接双击index.html就能跑。这个版本保留了我最初的命名习惯和注释没有做代码层面的美化拿去参考或者二次修改都很方便。当时写代码用的编辑器是VS Code浏览器用Chrome调试所有代码在原生环境下运行不需要额外构建工具。如果是想练手的朋友强烈建议先自己写一遍卡住的时候再对照源码来看效果会好得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑