简介:一份基于Java实现的俄罗斯方块完整源码包,面向Java入门与游戏编程爱好者,用于理解经典游戏背后的核心逻辑与面向对象设计。源码围绕GameBoard、Shape、Controller、Main等关键类展开,清晰呈现游戏循环、方块生成与旋转、碰撞检测、消行得分及结束判定等实现方式;同时对IDE工程配置与编译产物也做了归类说明,便于直接导入项目阅读调试。资源共38个文件,以Java源码、class编译文件及png界面截图为主,附带工程配置文件,压缩包体积约170KB,轻量紧凑,适合快速下载学习。已有2785人浏览学习。读者可以借助源码解析掌握Java游戏程序的基本架构,也可在此基础上继续扩展多人模式、排行榜或交互优化,加深对事件处理、状态管理与模块协作的理解。
1. 从一段循环代码说起:这份 Java 俄罗斯方块源码为什么值得你拆一遍
提起俄罗斯方块,你想到的可能是童年掌机,也可能是手机里随手点开的消除小游戏。但作为练手项目,它的含金量远超画面:旋转、碰撞、消行、计分、加速,几乎每个程序员的入门路上都会碰一次。这份用纯 Java 实现的源码,不是课程设计里那种“跑起来就算完事”的一次性代码,而是把游戏状态、控制逻辑和界面渲染拆得清清楚楚的完整工程。它适合正在学 Java 面向对象设计的开发者,也适合拿一份源码想改造成自己风格的二次开发选手——入手门槛低,改起来又不会伤筋动骨。
纯 Java 的意思是零第三方依赖,不需要 Maven 拉包,也不用跑数据库,JDK 自带的 Swing 就能搞定窗口、画布和键盘监听。这也是我拆它的第一个理由:只要装好 JDK,就能把整个游戏从启动到结束完整跟下来。第二个理由是它的分层方式——方块形状、棋盘状态、落定消行、界面绘制全是独立类在协作,没有把逻辑塞进同一个监听器里,这种写法对想改功能的人尤其友好。
2. 技术选型与工程结构:为什么是 Swing 而不是 JavaFX
2.1 技术栈选择:零依赖的 Swing 有它自己的生态位
我见过一些人拿到这类源码会问:都什么年代了,为什么不用 JavaFX?这个问题的答案在于项目定位。JavaFX 需要单独引入模块或初始化运行环境,对一个给开发者研究游戏主逻辑的项目来说,凭空多出环境负担。Swing 的优势是可以直接用javax.swing.Timer驱动游戏循环,用JPanel覆写paintComponent做画布渲染,用KeyListener接键盘事件,三件套就能撑起整个应用。只要 JDK 8 以上就能编译运行,这是它作为练习项目最扎实的地方。
Swing 的定时器是单线程 UI 框架的好搭档。javax.swing.Timer的事件回调会回到 EDT,天然避开了线程安全问题。和Thread.sleep加while(true)的方案相比,它不会独占事件线程;如果你看过 JavaScript 里setTimeout驱动俄罗斯方块的写法,会发现思路几乎一致。学习 Swing 版本的意义在于,这套回调模型能无缝迁移到其他 UI 框架,你在这里建立的主循环概念,放到 Android 或网页游戏里依然成立。
另外,Swing 的绘制模型是组件主动 repaint,恰好契合俄罗斯方块的节奏。下落一格就重绘一次面板,消行一次就重绘一次,事件频率很低,完全不需要 60 帧渲染引擎。如果你把这份源码与 Unity 或网页版实现放在一起对比,会发现 Swing 版更像是在表达“游戏逻辑本身”,而不是在表达渲染管线和动画系统。这种纯粹感对学习者是很大的优势。
2.2 源码目录与类职责:一眼能找到改哪里的布局
拿到源码包解开之后,你会看到一个 src 根目录下的包结构,常见组织方式大致是这样:
src/ com/tetris/ GameLauncher.java # 启动入口,负责创建主窗口 model/ BlockType.java # 7 种方块形状枚举 ShapeFactory.java # 按类型生成方块矩阵 GameBoard.java # 10x20 棋盘,碰撞与消行判定 Tetromino.java # 正在运动的当前方块 controller/ GameController.java # 游戏主循环与状态机 KeyHandler.java # 键盘事件绑定 view/ GameWindow.java # 窗口与布局 GamePanel.java # 棋盘绘制 ScorePanel.java # 分数、等级、消除行数展示这个结构把经典 MVC 缩到了最精简的形态。model 层完全不知道界面的存在,它只处理二维数组、坐标和形状;view 层只做读取状态和绘制;controller 层在中间充当调度者,它持有 Timer、棋盘和面板。你在改代码时只需要按层定位:方块形状不对,去看BlockType;碰撞判定出错,去看GameBoard;按键没反应,去看KeyHandler;渲染不清楚,去看GamePanel。
很多人好奇 10x20 的棋盘数据在内存里到底是什么。本质上它就是一个int[][](或者boolean[][]),棋盘矩阵中的 1 或 true 表示该位置已经有方块定格,0 表示空位。所有移动、旋转、碰撞检测都抽象成对这个矩阵的查询,而不是对界面像素的查询。这样设计的好处是,你可以单独为模型写单元测试——甚至可以完全不启动窗口,只用 main 方法打印棋盘就验证消行逻辑。我自己拆代码时就把 mergeShape 和 clearLines 复制到一个临时 main 里跑过,这是游戏逻辑最容易测试的切入点。
2.3 玩法参数在哪里调:难度曲线、计分规则和消行判定
俄罗斯方块这种游戏,玩法参数高度集中在一处非常关键。拆这份源码时最值得称赞的一点是:初始下落间隔、每级加速的缩减量、每行消行分数,都被抽成了顶部常量,而不是散布在业务代码里。常见的参数表长这样:
| 参数 | 典型值 | 作用位置 |
|---|---|---|
| 棋盘宽高 | 10 列 x 20 行 | GameBoard 初始化 |
| 初始下落间隔 | 800ms | GameController 的 Timer 初始值 |
| 每级加速 | -70ms | 消满 10 行后调用 setDelay |
| 单行消除 | 100 分 | ScorePanel 累加 |
| 双行消除 | 300 分 | 同上 |
| 三行消除 | 600 分 | 同上 |
| 四行消除 | 1000 分 | 同上 |
这些数值没有一个写死在按键响应里。你要调难度,直接改初始间隔和加速步长;要改计分规则,去GameController里归一化处理消除行数。比如说,你觉得 800ms 的初始速度太慢,把它改成 600ms,新手玩起来就会明显紧张一档。别小看这些参数,很多移植到手机端的作品把操作手感做崩了,就是因为把速度参数写进了绘制逻辑里。改完参数之后,最好的验证方式是开局后盯着右下角的等级数字看,等级升到 2 时手动掐一次秒表,确认两次连续下落的时间差和设定值一致。
3. 运行指南:从文件落地到 7 种方块全部转起来的完整步骤
3.1 运行前置检查:先确认 JDK 版本和文件完整性
这份源码的运行门槛极低,但有两个前置条件需要确认。第一,本机 JDK 版本建议在 8 以上,命令行里用java -version看一眼,如果是 8、11 或 17 都可以直接跑。第二,确认源码目录里真的包含了src和必要的资源目录,不要只拷贝了几个 .java 文件就盲目编译。
源码包是否完整,其实有个快速判断方法:文件列表里应该至少包含GameLauncher.java、GameController.java、GameBoard.java、GamePanel.java这 4 个文件。如果你只看到单个 .java 文件,那多半不是完整的工程;如果看到 Maven 的pom.xml,那这份源码和我们前面说的纯 JDK 定位就不符。我一般会在导入 IDE 前先用命令行编译一次,把环境问题提前暴露掉,比在 IDEA 里对着满屏红浪排除半天要快很多。
如果你习惯用 IDEA,直接File -> Open选择源码根目录,IDEA 会自动识别这个工程。等它右侧的文件树里出现 Java 文件图标后,找到GameLauncher.java,右键选Run 'GameLauncher.main()'就能启动。这类项目不需要联网拉依赖,只要 IDEA 识别成普通 Java 工程即可。如果弹出 Maven 或 Gradle 的导入窗口,说明工程类型识别错了,直接把窗口关掉,手动 Open 源码目录即可。
3.2 命令行跑法:不用 IDE 也能玩起来
如果你手头没有 IDEA,或者想确认这份源码不依赖任何编辑器,命令行编译运行是更干净的验证方式。进入源码根目录后,先编译全部源码:
cd /path/to/tetris javac -encoding UTF-8 -d out $(find src -name "*.java") java -cp out com.tetris.GameLauncher-encoding UTF-8是因为源码注释里通常带中文,不加在 Windows 环境容易报编码错误;-d out把编译产物放进 out 目录,避免与源码混在一起;find src -name "*.java"把所有源码文件一次性传给 javac,不用手动逐个列举。如果你是 Windows 且用的不是 Git Bash,可以把第二行换成 PowerShell 里对应的Get-ChildItem拼接方式。
提示:出现
Error: A JNI error has occurred通常不是源码问题,而是你用 Java 8 编译却用 Java 17 运行,或者反过来。版本不匹配会导致主类签名对不上,检查java和javac是否指向同一个 JDK。
3.3 界面与按键:没有触屏,键盘就是全部输入
游戏启动后,你会看到一个 10x20 的棋盘和右侧的分数面板。操作集中在键盘方向键和几个功能键上,常见映射如下:
| 按键 | 游戏行为 |
|---|---|
| 左/右方向键 | 左右移动当前方块 |
| 上方向键 | 旋转方块(顺时针 90 度) |
| 下方向键 | 软降一格 |
| 空格 | 硬降,立即落定 |
| P 键 | 暂停/继续 |
| R 键 | 重新开始 |
| Enter | 开局确认 |
整局流程是一条清晰的事件链:开局生成一个方块,它每隔一段间隔自动下落一格;玩家在它着地前用按键调整位置和角度;方块一旦无法继续下移,就会落定进棋盘矩阵;落定后立刻检查是否凑满整行,凑满则消行得分,同时生成下一个方块。当方块在生成点就无法落位时,游戏结束。这套流程循环往复,状态机只有 idle、running、paused、over 四个,理解之后改任何功能都有底。
有一个体验上的细节:窗口建议保持在 480x640 这个比例附近,太窄棋盘格子会挤压变形,太宽下拉距离又太长。面板的最小尺寸可以在GameWindow里通过setPreferredSize或setMinimumSize控制。你要是想把棋盘画得更精致,GamePanel的paintComponent内部按格子像素绘制,改大了并不会影响判定逻辑,因为判定只看模型里的坐标,不看像素。
4. 核心模块拆解:一个方块从按键到落定刷分的完整链路
4.1 方块形状的数据结构:用矩阵表达 7 种标准形态
先看方块从哪来。BlockType枚举是这套实现的数据地基,7 种俄罗斯方块(I、O、T、S、Z、L、J)都用二维数组表示。每个数组元素为 1 代表有格子,0 代表空位,例如 T 型块:
public enum BlockType { I(new int[][]{ {1, 1, 1, 1} }), T(new int[][]{ {0, 1, 0}, {1, 1, 1} }), L(new int[][]{ {1, 0, 0}, {1, 1, 1} }); private final int[][] cells; BlockType(int[][] cells) { this.cells = cells; } public int[][] getCells() { return cells; } }用枚举的好处有两个。一是形状定义和逻辑处理分离,以后想加一种自定义形状,只需要加一个枚举常量,不需要动判定逻辑;二是二维数组天然对应棋盘坐标系,旋转运算可以直接落到矩阵转置上。ShapeFactory在这个基础上做成单例,每次createRandom()从枚举值里随机取一个,并且给当前方块返回独立的矩阵副本,防止多个方块引用同一块数组导致相互污染。这一点在形状刷新时很容易踩中:如果你直接把枚举里的数组引用交给方块对象,方块旋转时会改动枚举内部的原始数据,下一局的形状就直接乱了。
4.2 移动与碰撞检测:先检测再移动,别让方块越界
方块在棋盘上的每次位移都要经过canMove()这道关卡。它的检测逻辑并不复杂:遍历形状矩阵里的有效格子,把每个格子变换到棋盘坐标,再检查是否越界或与已落定方块冲突:
public boolean canMove(int x, int y, int[][] shape) { for (int r = 0; r < shape.length; r++) { for (int c = 0; c < shape[r].length; c++) { if (shape[r][c] == 1) { int boardX = x + c; int boardY = y + r; // 超出左右边界或超出底部,直接拒绝 if (boardX < 0 || boardX >= COLS || boardY >= ROWS) { return false; } // 棋盘顶部允许负数高度,但撞上已落定格子则拒绝 if (boardY >= 0 && board[boardY][boardX] != 0) { return false; } } } } return true; }这段代码里x、y是当前方块左上角在棋盘坐标系中的位置,shape[r][c]是相对坐标。之所以允许boardY < 0,是因为新方块在生成点还没有完全进入可视区,部分格子可以悬在棋盘顶部之上;一旦落定阶段发现某个格子仍处于负高度,就直接判定游戏结束。这个细节容易被人忽略,但它正是“方块到底算不算出界”的分界线。初学者最容易在这里犯的错是把boardY < 0也当成非法,导致新方块一出生就被判死,游戏永远撑不过第一帧。
4.3 落定与消行:改矩阵、清整行、刷分数一气呵成
当方块无法继续下落,控制器调用棋盘对象的合并方法,把当前形状写入棋盘矩阵,紧接着做消行清理:
public int mergeShape(int x, int y, int[][] shape) { for (int r = 0; r < shape.length; r++) { for (int c = 0; c < shape[r].length; c++) { if (shape[r][c] == 1) { int boardY = y + r; int boardX = x + c; if (boardY < 0) { gameOver = true; return 0; } board[boardY][boardX] = 1; } } } return clearLines(); } private int clearLines() { int cleared = 0; for (int r = ROWS - 1; r >= 0; ) { boolean full = true; for (int c = 0; c < COLS; c++) { if (board[r][c] == 0) { full = false; break; } } if (full) { for (int i = r; i > 0; i--) { board[i] = board[i - 1]; } board[0] = new int[COLS]; cleared++; } else { r--; } } return cleared; }注意到clearLines()里用了一个小技巧:只有整行被打通时r才不递减,这保证下一轮循环继续检查被下移后的同行。board[i] = board[i - 1]是让上一行的引用覆盖当前行,比双层 for 逐格复制更快;最后在顶部补一张空行矩阵。cleared返回值交给控制器去换算分数、决定是否升级。这个链路就是整份源码的心脏:检测落定、合并、清行、计分、生成下一个方块,五个动作一条线串下来。
4.4 旋转与踢墙:为什么转一下就要试三个位置
旋转是俄罗斯方块逻辑里最容易翻车的部分。直接对矩阵做顺时针转置很简单,但旋转后形状包围盒会变,紧贴左墙或右墙时经常出现一格偏差。合格实现会在原位置旋转失败后,依次尝试左移一格、右移一格,再不行才判定旋转失败:
public boolean tryRotate(Tetromino block) { int[][] rotated = rotateMatrix(block.getCells()); int x = block.getX(); int y = block.getY(); if (canMove(x, y, rotated)) { block.setCells(rotated); return true; } if (canMove(x - 1, y, rotated)) { block.setX(x - 1); block.setCells(rotated); return true; } if (canMove(x + 1, y, rotated)) { block.setX(x + 1); block.setCells(rotated); return true; } return false; }rotateMatrix做的是标准矩阵转置加行逆序操作,得到顺时针旋转后的形状。踢墙策略只保留了最基础的左右各 1 格试探,和商业版俄罗斯方块的 SRS 规则还有差距,但作为源码学习已经足够:它让你理解“旋转不是瞬移,得先验证目标位置是否合法,再决定是否提交”。这套逻辑的难点不是矩阵运算,而是“先试哪、后试哪”的顺序,以及旋转失败后如何保持原状。把tryRotate封装成独立方法之后,所有按键路径统一调用,可以有效杜绝 50 行代码里出现第二处直接改坐标的地方。
5. 避坑手记:状态同步、定时器和键盘响应的几个翻车现场
5.1 翻车现场:方块卡住不动,按左右键没反应
现象:游戏刚开始能正常下落,按下方向键却完全没有反应,方块像被冻结了一样。新手经常怀疑是键盘监听写错了,但真正的问题往往在线程:有些人实现下落时用的是while(true)加Thread.sleep,把它塞进主线程或某个后台线程里,而 Swing 的键盘事件分发是在 EDT 上执行的,监听回调被阻塞,界面自然失去响应。
解决:游戏主循环不要自己创建线程,改用javax.swing.Timer。它回调与 EDT 兼容,既能驱动自动下落,又不会阻塞键盘事件:
Timer timer = new Timer(interval, e -> { if (board.moveDown(current)) { panel.repaint(); } else { board.mergeShape(current); panel.refreshScore(); } }); timer.start();interval 参数单位是毫秒,代表两次下落动作之间的间隔。调这个值就是调速度,游戏里每个等级切换只动timer.setDelay一个参数。还有一个容易被误判的现象:按下方向键后,画面确实会动一下,但松开按键后又弹回去。这其实不是监听问题,而是KeyListener同时监听了 press 和 release,release 事件的默认行为没被覆盖,导致状态被还原。解决方法是只处理KEY_PRESSED,或者在 release 里不做任何逻辑分支。
5.2 翻车现场:下落速度越玩越快,最后快到没法操作
现象:每消 10 行升一级,速度确实应该加快,但改动之后变成每消一行都快一截,十行之后整个游戏几乎在“瞬移”。翻车原因通常是升级逻辑里直接new Timer(更小间隔),却没有把旧的 Timer 停掉。界面上看起来是速度变快了,实际是两个 Timer 在同时驱动 game loop,下落频率叠加,表现就是离谱的加速。
解决:全局只保留一个 Timer 实例,升级时修改它的间隔,而不是重建。代码上,把 timer 提为GameController的字段,初始化和状态切换都操作同一个引用,禁止在clearLines返回值变化时触发new Timer。这个坑本质上是对象生命周期管理问题,游戏项目里尤其常见。每次加速后,你可以故意停 10 秒再按方向键,如果方块在没有任何操作的情况下下落了两步,说明还有多余计时器在跑。
5.3 翻车现场:方块贴墙旋转时直接穿墙或卡进已落定区域
现象:方块在棋盘最左侧贴着边按上方向键,旋转后有一列格子跑到棋盘外或和旁边落定的方块重叠,画面出现“穿模”。原因在于旋转逻辑只计算了新矩阵,没有做合法性校验就替换了当前方块坐标,或者校验时用的是旧坐标而不是新坐标,导致旋转后的位置没有经过canMove全面检查。
解决:无论旋转还是移动,统一走“先检测后提交”的流程。旋转矩阵算出来之后,先用canMove(原x, 原y, 新矩阵)检查;失败再依次试探x-1、x+1两个位置;全部失败就返回 false,保持原坐标原形状。严格来说,游戏结束的判定也属于这类状态同步问题:有些版本把结束条件写在mergeShape里,但结束标记位置只要放错一步,就会出现方块落定后还能移动的幽灵状态。如果你发现游戏结束后还能按出移动动作,检查状态机切换逻辑,结束状态一旦进入,所有 KeyHandler 分支都必须短路。
5.4 翻车现场:消行成功但分数和界面没变
现象:游戏逻辑明显已经消了一行,白块消失了,但右侧分数面板纹丝不动。这类问题的根因通常不在计分计算,而在界面刷新。Swing 的 repaint 并不是同步绘制,它只是向组件标记“需要重绘”。模型数据变了、repaint 没调用,画面就不会更新;或者 controller 与 scorePanel 之间没有事件通知,分数面板拿的还是旧值。
解决:在mergeShape成功、clearLines返回值大于 0 之后,统一调用scorePanel.update(score, level, lines),再执行boardPanel.repaint()。不要只在某个按键分支里刷新,而要放在状态切换的公共出口。这也算一个设计原则:视图刷新永远跟着业务动作走,而不是跟着输入事件走。你在二次开发时新增任何一个会改变棋盘状态的函数,都要先问一句:这个函数走完之后,有没有人负责调 repaint?
6. 二次开发:两个好上手又容易出效果的改造点
6.1 改造一:给方块加“幽灵落点”预测线
幽灵方块是很多现代版本俄罗斯方块都有的辅助功能,它把当前方块直接落到终点的位置用半透明轮廓显示出来,玩家可以提前判断落点。实现步骤很简单。第一步,在 GameController 里写一个落点计算函数,不改变任何状态,只循环调用 canMove 向下试探:
public int getDropDistance(int curX, int curY, int[][] shape) { int d = 0; while (canMove(curX, curY + d + 1, shape)) { d++; } return d; }第二步,在GamePanel的绘制方法里,先画棋盘和当前方块,再把同一形状画在预测位置,用半透明颜色区分。注意:paintComponent会被反复调用,预测位置每次都要重新计算,不要缓存,因为当前方块每移动一格,幽灵位置都会变化。只要在方块落定后调用一次 repaint,整条预测线就会自然更新。这个功能改完,你立刻能感觉到操作时的“预判感”提升了一个档次,也是拿给朋友看最直观的改动。
6.2 改造二:空格键从“瞬移”改成“硬降加分”
目前空格键是硬降即落定,按一次立刻到底。很多版本里硬降会按下降格数计算额外分数,让玩家在风险操作里获得额外回报。改造思路是:拆出一个hardDrop(block)函数,先计算连续下落的格数,再调用一次落定逻辑,最后把格数乘以 2 加到 score:
public void hardDrop(Tetromino block) { int d = getDropDistance(block.getX(), block.getY(), block.getCells()); score += d * 2; block.setY(block.getY() + d); board.mergeShape(block.getX(), block.getY(), block.getCells()); regenerateBlock(); panel.refreshScore(); }这段代码里block.setY直接跳到目标高度,getDropDistance的返回值既用来跳位置,也用来算奖励分,一个函数干了两件事。做完这步,你可以在记分面板加一行“最近一次硬降高度”,玩家立刻能感受到操作和反馈之间的对应关系。它也能帮你测试getDropDistance有没有算漏——按下空格后落点和幽灵线位置一致才算通过。
说到这想提一句大实话:我刚开始拆游戏源码时,总爱盯着花哨的画面和复杂的渲染逻辑看,后来发现真正值钱的是这些可感知的游戏规则参数和它们背后的状态机设计。从那以后我每次拿到别人的游戏项目,第一件事永远是找主循环、计时器和状态入口,把这三个点摸清了,剩下的功能都只是在这个框架上加东西。这份源码恰好把这几样东西放在最显眼的位置,改起来舒服,跑起来也不玄学。希望帮到你。
本文还有配套的精品资源,点击获取