ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

俄罗斯方块Java源码解析:游戏循环与Swing开发的骨架

俄罗斯方块Java源码解析:游戏循环与Swing开发的骨架

简介:《俄罗斯方块源码》是一份用Java实现的经典游戏完整工程,适合初学Java或对游戏编程感兴趣的读者,通过阅读源码掌握游戏循环、方块生成、碰撞检测、消行计分等核心机制,并学习面向对象的模块划分方式。压缩包共38个文件,其中3个java源文件是主体,涵盖游戏面板、方块形状、控制流程、程序入口等关键类;14个class为编译后的字节码,另有18张png图片与Eclipse项目配置,整体约170KB,结构清晰便于查看。已有2785人学习浏览,有较高参考价值。阅读时建议重点关注游戏循环与用户交互的实现,理解随机方块生成、旋转与边界碰撞的处理逻辑;也可以尝试在此基础上增加音效、多人模式或排行榜,将经典玩法扩展为更具个人特色的练习项目,加深对Java语法和程序设计实践的理解。

1. 俄罗斯方块Java源码:不是玩具项目,而是读懂游戏循环的最小骨架

拿到一份俄罗斯方块 Java 源码时,很多人第一反应是:一个课程设计而已,有什么可拆的。我当时也是这么想的,直到自己动笔拆完才发现,这个“玩具”把游戏开发最核心的骨架全占齐了:方块用数组建模,地图是二维矩阵,下落由定时器驱动,操作走键盘事件,画面靠重绘刷新。它没有物理引擎、没有网络同步,却把一个实时程序从“输入—更新—渲染—再更新”的循环讲得明明白白。适合两类人:一类是正在做 Java 课程设计、需要一份能讲清楚设计思路的参考实现的学生;另一类是刚学完 Swing、想用一个完整小项目把事件分发和绘图机制串起来的初学者。拆完它,你再看任何 2D 游戏源码,都不会再被“游戏循环到底在哪”问住。

2. 源码结构与方块定义:用坐标数组把七种方块写到一眼能看懂

2.1 源码包结构:四个类加一个地图矩阵,职责分得很清楚

一份典型的 Swing 版俄罗斯方块,源码包大概由三到五个 Java 文件组成。主入口 Tetris.java 负责建 JFrame、摆布局;GamePanel.java 是核心面板,持有地图矩阵、当前方块、下落定时器和全部绘制逻辑;Block.java 描述方块形状、位置与旋转。有些实现还会拆出 NextBlockPanel 做下一个方块预览、ScorePanel 显示积分与等级。外层布局常见做法是一个 BorderLayout:GamePanel 占中央,右侧竖排预览和信息面板。

src/ ├── Tetris.java // 主窗口,负责启动、布局、游戏开始/结束 ├── GamePanel.java // 游戏面板:地图矩阵、方块操作、定时器、绘制 ├── Block.java // 方块形状定义、坐标数组、旋转方法 └── NextBlockPanel.java // 可选:右侧“下一个方块”预览面板

如果你拿到的源码是单文件,不必急着跑,多半是全部逻辑堆在一个类里。这种写法能跑,但答辩时不好讲。我自己一般建议课程设计最少拆成三个类:Block 管“形状是什么”,GamePanel 管“怎么动、怎么画”,Tetris 管“窗口怎么摆”。后面你要加排行榜、暂停、音效,改起来边界也清楚。

2.2 七种方块的定义方式:矩阵法和坐标数组法我都用过

定义方块有两种主流写法。第一种是 4x4 矩阵法,每个方块用一个 4x4 的 int 数组表示,1 的位置就是格子,比如 T 形写成 {{0,1,0,0},{1,1,1,0},{0,0,0,0},{0,0,0,0}}。优点是绘图和旋转检查都直观;缺点是为每种方块手动枚举多个旋转姿态,光 I 形就要写 4 个矩阵,代码冗长还容易漏写。

第二种是坐标数组法,每个方块用若干二维坐标点描述,I 形就是 {(0,0),(1,0),(2,0),(3,0)}。旋转不需要枚举姿态,套一个坐标变换公式就行。缺点是新手要先理解“相对坐标”和“锚点”这两个概念。对比起来:

对比项4x4 矩阵法坐标数组法
代码量手工枚举旋转姿态,代码长旋转公式生成,代码短
可读性直观,适合新手需要理解锚点与偏移
旋转实现换一个预置矩阵坐标变换公式
常见问题姿态表漏写,旋转不连贯锚点选错导致旋转偏移

下面这份源码里的做法是坐标数组。每种方块用一组二维坐标写死,锚点就是方块自己的 (0,0) 位置,之后所有旋转、碰撞检测都基于这套相对坐标去换算。

public class Block { // 每个元素是 {x, y},x 向右为正,y 向下为正 public static final int[][][] SHAPES = { {{0,0}, {1,0}, {2,0}, {3,0}}, // I {{0,0}, {1,0}, {0,1}, {1,1}}, // O {{0,0}, {1,0}, {2,0}, {1,1}}, // T {{1,0}, {2,0}, {0,1}, {1,1}}, // S {{0,0}, {1,0}, {1,1}, {2,1}}, // Z {{0,0}, {1,0}, {2,0}, {0,1}}, // J {{0,0}, {1,0}, {2,0}, {2,1}}, // L }; private int[][] points; // 当前方块的坐标数组 private int type; // 形状下标,对应 SHAPES 里的索引 private int row, col; // 锚点在地图矩阵中的行、列 public Block(int type) { this.type = type; this.points = SHAPES[type]; } }

这里每个坐标都是相对锚点的偏移,比如 T 形四个点是 {(0,0),(1,0),(2,0),(1,1)},意思是以当前锚点为原点,向右 0 格、向右 1 格、向右 2 格,以及向右 1 格再向下一格。row、col 是锚点在整个地图里的实际位置。后续所有碰撞检测都统一用“锚点行列 + 相对偏移”计算,避免在绘制和逻辑里各算各的。

2.3 旋转实现:旋转变换加碰撞回退,顺手把坑一起填了

旋转的核心是坐标变换公式。绕原点顺时针旋转 90 度,把每个点 (x, y) 变成 (-y, x)。这是线性变换,对整个方块所有点统一处理即可。但只做变换远远不够——旋转后的方块可能转出地图边界,也可能压到已经固定的格子里。所以常见做法是:先备份当前坐标,变换后跑一次碰撞检测,撞了就把数组恢复。

public void rotate() { int[][] backup = new int[points.length][2]; for (int i = 0; i < points.length; i++) { backup[i][0] = points[i][0]; backup[i][1] = points[i][1]; } for (int[] p : points) { int x = p[0]; int y = p[1]; p[0] = -y; p[1] = x; } if (gamePanel.checkCollision(this)) { // 碰墙或压到已固定格子,恢复原状 for (int i = 0; i < points.length; i++) { points[i][0] = backup[i][0]; points[i][1] = backup[i][1]; } } }

备份数组在旋转变换前深拷贝一份,这句很关键。直接用另一个引用指向 points,恢复时改的其实是同一个对象,等于没恢复。我见过不少翻车现场都是这样。

还有一个参数细节:锚点选在哪,直接决定旋转后的视觉位置。比如 I 形写成 {(0,0),(1,0),(2,0),(3,0)},锚点在左端,旋转换出来的形状会整体上下跳。想让旋转更自然,应把锚点尽量放在几何中心附近,比如 T 形锚点放在中间那列。更进阶的 Wall Kick 是碰撞后不直接回退,而是尝试左移一格、右移一格再检测,模拟出“贴墙挤过去”的手感。课程设计阶段做到“碰撞即回退”已经够用。

2.4 地图矩阵的约定:行从 0 到 19,列从 0 到 9

地图在源码里一般是一个二维数组 board[y][x],第一维是行、第二维是列,更贴近“第几行第几列”的直觉。标准俄罗斯方块是 20 行 10 列,我用常量 ROWS = 20、COLS = 10 定义。数组里存方块类型 type 或固定颜色值,空位存 0。绘制时按 type 从颜色表里取色即可。

private int[][] board = new int[ROWS][COLS];

这份源码的其余部分都以这个约定为前提:行往下递增、列往右递增、锚点 (row, col) 对应 board[row][col]。如果你拿到手的源码是 board[x][y],那所有绘制、碰撞、消行的下标都要跟着换,混用必然数组越界。

3. 游戏循环与下落逻辑:用 Timer 把方块的下落节奏写在 EDT 里

3.1 三种定时方案对比:为什么 Swing 项目里 Timer 最顺手

游戏循环说白了就是“每隔一段时间让当前方块下移一行”。初学常见写法是开一个线程:

// 反面教材:直接在子线程里改 UI new Thread(() -> { while (running) { moveDown(); Thread.sleep(interval); repaint(); } }).start();

这段代码的问题在于子线程里直接调用 repaint() 和移动方块,Swing 不是线程安全的,偶发界面卡住、重绘异常、位置竞态都是它引发的。暂停、恢复、变速还得自己维护 running 标志。

ScheduledExecutorService 是第二选项,调度灵活、定时更准,但每次改完 UI 还得用 SwingUtilities.invokeLater() 把操作塞回 EDT,代码量明显上去。javax.swing.Timer 是第三选项,也是当下 Swing 小游戏最常用的方案:回调自动跑在 EDT 上,暂停调 stop(),恢复调 start(),变速调 setDelay(),三行代码解决。三种方案对比:

方案线程模型暂停/变速Swing 集成适合场景
Thread.sleep 线程工作线程自己维护标志需要额外同步纯逻辑、无 UI
ScheduledExecutorService线程池灵活但代码多需要 invokeLater服务端或复杂调度
javax.swing.TimerEDTstop/start/setDelay原生支持Swing 小游戏

3.2 一帧 step() 里到底发生了什么

用 Timer 之后,整个游戏逻辑集中在“每帧回调”里执行。我习惯把这个回调命名为 step():先复制当前方块并下移一行,碰撞就固定并消行计分,没碰撞就更新方块位置,最后统一 repaint()。

private void step() { Block moved = currentBlock.copy(); moved.row++; if (checkCollision(moved)) { // 不能下落:固定到地图,消行计分,生成新方块 fixToBoard(); int cleared = clearFullRows(); score += REWARDS[cleared] * level; if (score >= level * LEVEL_THRESHOLD) { level++; timer.setDelay(computeInterval(level)); } spawnBlock(); if (checkCollision(currentBlock)) { gameOver(); // 新方块一出生就撞,说明堆到顶了 } } else { currentBlock = moved; } repaint(); }

step() 里每一帧只做一次“尝试下落”,这是整个循环的核心。注意两个顺序不能颠倒:先生成新方块,再判断新方块是否碰撞。如果先判断再生成,拿到的还是旧方块,永远判不出“游戏结束”。计分乘了等级 level,REWARDS 是一个数组,下标对应当次消掉的行数,值分别是 0、100、300、500、800。

3.3 碰撞检测:边界检查和地图占用,两个判定一个都不能少

碰撞检测要考虑两个维度:一是出界,二是压到已固定格子。上面 checkCollision 的完整实现如下:

private boolean checkCollision(Block b) { for (int[] p : b.points) { int r = b.row + p[1]; // p[1] 是 y,也就是行偏移 int c = b.col + p[0]; // p[0] 是 x,也就是列偏移 if (c < 0 || c >= COLS || r >= ROWS) { return true; // 左右出界或落到底部以下 } if (r >= 0 && board[r][c] != 0) { return true; // 压到已固定格子 } } return false; }

r < 0 的情况不做占用判断,是为了允许新方块在顶部“悬空”出现——很多实现里方块出生点高于可视区第一行,此时负行的格子还没真正进入地图,不该判定碰撞。底部只判 r >= ROWS,因为方块不能落出画面,但允许它贴底。

与检查配套的是写入。fixToBoard() 把当前方块的每个点写到 board 数组,写之前也要做越界判断:

private void fixToBoard() { for (int[] p : currentBlock.points) { int r = currentBlock.row + p[1]; int c = currentBlock.col + p[0]; if (r >= 0 && r < ROWS && c >= 0 && c < COLS) { board[r][c] = currentBlock.type; } } }

这里漏掉越界判断的话,顶部悬空的方块固定时会把负数下标写成越界异常,程序直接崩。检查与写入的边界条件要完全对称,这是最容易踩的一个点。

3.4 下落速度公式:别写死,用等级换算

很多源码用 if-else 手工列速度表:level 1 用 800ms、level 2 用 700ms。等级一多,代码就很蠢。我一般用一个衰减公式计算间隔:

private int computeInterval(int level) { // 基础间隔 800ms,每升一级间隔乘 0.85 return (int) (BASE_INTERVAL * Math.pow(0.85, level - 1)); }

参数说明:BASE_INTERVAL 取 800,1 级下落间隔是 800ms,2 级 680ms,3 级 578ms,递减趋于平缓,不会出现高等级快得没法玩。每次等级变化后调 timer.setDelay(computeInterval(level)),定时器会自动按新间隔跑,不需要重启。

4. Swing 渲染与键盘控制:重绘顺序、双缓冲与按键映射的实战细节

4.1 paintComponent 的绘制顺序:先底图、再固定格、最后活动方块

渲染集中在 GamePanel 的 paintComponent 里,顺序反了会出现视觉错误。正确逻辑是:先画已固定格子和网格线,再画当前活动方块,最后画下一个方块预览。活动方块必须后画,否则会被固定格子盖住,看起来像“方块消失了一半”。

@Override protected void paintComponent(Graphics g) { super.paintComponent(g); // 先清掉上一帧内容,不清会有拖影 Graphics2D g2 = (Graphics2D) g; drawBackground(g2); // 网格与底图 drawBoard(g2); // 已固定的方块格子 drawBlock(g2, currentBlock); // 当前活动方块 }

先调 super.paintComponent() 是必须的,它把面板重置为背景色。绘制格子时,每个单元格之间留跟 1 像素的间隙,否则相邻方块连成一片,看不出边界。颜色建议在类初始化时建好一个数组,不同 type 对应不同颜色,不要在 paintComponent 里反复 new Color 对象。

4.2 双缓冲:为什么 JPanel 默认不闪,而 Canvas 上画就闪

闪烁问题的根源是绘制过程暴露在了屏幕上。Swing 对 JPanel 默认开启双缓冲:先在内存里画完整帧,再一次提交到屏幕,所以单纯继承 JPanel 不会闪。但如果你为了“快”直接用 JFrame 的 Graphics 绘制,或改用 AWT 的 Canvas,就绕过了双缓冲,每次移动都闪白。

提示:判断闪烁时先确认所有绘制是否都在 paintComponent 里完成。任何在事件回调里直接拿 Graphics 画的代码,都会破坏双缓冲机制。

还有一个常见误用是“哪里需要更新就 repaint 哪里”。在 Swing 里,repaint() 只做标记,真正绘制由 EDT 统一调度,所以不用怕频繁调 repaint()。反之,千万别在 paintComponent 里做文件读取、new 大对象这类耗时操作,那一帧的绘制会被拖住,视觉上就是顿卡。

4.3 按键映射:InputMap/ActionMap 替代 KeyListener 的三个理由

键盘控制有两条路:一是给面板挂 KeyListener,二是用 Swing 的 InputMap + ActionMap。KeyListener 有三个问题:面板要手动 requestFocus 才能接收事件;按住方向键会有系统自动重复,方块移动节奏不可控;窗口失焦再回来,焦点容易丢。用 InputMap/ActionMap 可以把按键和动作解耦,一行绑定一个快捷键:

setFocusable(true); InputMap im = getInputMap(WHEN_IN_FOCUSED_WINDOW); im.put(KeyStroke.getKeyStroke("LEFT"), "moveLeft"); im.put(KeyStroke.getKeyStroke("RIGHT"), "moveRight"); im.put(KeyStroke.getKeyStroke("UP"), "rotate"); im.put(KeyStroke.getKeyStroke("DOWN"), "softDrop"); im.put(KeyStroke.getKeyStroke("SPACE"), "hardDrop"); im.put(KeyStroke.getKeyStroke("P"), "pause"); ActionMap am = getActionMap(); am.put("moveLeft", new AbstractAction() { @Override public void actionPerformed(ActionEvent e) { if (running && !paused) moveLeft(); } }); // 其他动作同理

WHEN_IN_FOCUSED_WINDOW 的意思是窗口内任意组件持有焦点都能触发,省去焦点管理。动作里统一判断 running 和 paused,避免游戏结束后还能按方向键乱动,也避免暂停状态继续消行。按键布局一般按玩家习惯来:左右方向键移动,上键旋转,下键软降,空格硬降,P 暂停。每个 AbstractAction 里只做一件事,别把移动和旋转混在一个动作里。

软降的实现不要另写一套下落逻辑,直接调用 step() 即可,让手动按键和定时下落共用同一帧逻辑,行为保持一致。硬降则是把方块一路落到底再走一步固定逻辑:

public void hardDrop() { Block b = currentBlock.copy(); while (!checkCollision(b)) { b.row++; } b.row--; // 回退到最后一个不碰撞的位置 currentBlock = b; step(); // 走固定、消行、重新生成的完整流程 }

这里 while 循环从当前行试到碰撞为止,碰撞后回退一行,就是方块能到达的最低位置。直接调 step() 让接下来的固定和消行复用已有逻辑,不会出现硬降后地图状态和分数不匹配的问题。

5. 避坑指南:旋转穿墙、消行漏检和键盘失灵的排查记录

5.1 编译报错:公共类名与文件名不一致,中文注释乱码

现象:javac 报“类 Tetris 是公共的,应在名称为 Tetris.java 的文件中声明”,或者编译通过但运行时所有中文界面文字变成乱码。

原因:Java 对公共类名和文件名的一致性有硬性要求,文件名改了类名没改,或者类名改了文件没改名都会触发;中文乱码是源码文件用 UTF-8 保存,而命令行窗口默认按 GBK 读取,javac 编译时把中文字节按错误编码解析进了字符串常量。

解决:先把文件另存为与公共类同名的文件名,再用 -encoding 参数强制指定源码编码重新编译:

javac -encoding UTF-8 Tetris.java

这个参数不加,在中文 Windows 里早晚踩一次。

5.2 方块旋转穿墙:旋转改了坐标,没跑碰撞检测

现象:方块贴左墙或右墙时按上键,方块的一部分转到地图外,甚至嵌进已固定的方块堆里,移动时穿帮。

原因:多数初版代码的 rotate() 只做坐标变换,忘了把变换结果交给碰撞检测判一次。检测到碰撞后,代码选择直接回退,但没有尝试任何位移补偿,贴墙时旋转永远失败。

解决:旋转方法里先深拷贝备份,变换后调用 checkCollision,撞了再恢复。想要手感更好,改成 Wall Kick 策略:碰撞后依次尝试左移一格、右移一格,都失败才恢复原状。这会让贴墙旋转的成功率高很多,玩家体验完全不同。

5.3 消行漏检:删掉一行后循环游标没回退

现象:底部连续两行同时满,却只消掉一行,积分也少算。排错时用 System.out.println 打印 cleared,发现值比预期小。

原因:for 循环从下往上扫,遇到满行删除后,上方所有行整体下沉,当前下标位置变成了原先的上一行。循环继续向上走,直接把这行跳过了,于是即便有两行满,也只消了一行。

解决:删除后让 row++ 回退一个位置,让外层循环重新检查当前下标:

for (int row = ROWS - 1; row >= 0; row--) { if (isRowFull(row)) { removeRow(row); row++; // 关键一行:删除后上行下沉,必须原地重查 cleared++; } }

这个回退是消行逻辑里最容易漏的细节,漏掉就是积分错乱。

5.4 键盘没反应:焦点根本不在 JPanel 上

现象:窗口正常显示,鼠标点击也有响应,就是按方向键没反应。点一下游戏面板又能控制,再点其他按钮又失灵。

原因:KeyListener 挂在面板上,但窗口启动后焦点不在它身上,键盘事件全部发给了别的组件。JPanel 默认是不接受焦点的。

解决:优先换用 WHEN_IN_FOCUSED_WINDOW 的 InputMap,只要窗口在前台就能收按键。如果坚持用 KeyListener,就在窗口显示后调用 panel.requestFocusInWindow(),并把面板 setFocusable(true)。但焦点管理是个长期麻烦,InputMap 是更干净的路子。

5.5 画面闪烁:绘制路径绕过了双缓冲

现象:方块移动时整个面板闪白,视觉上像一直在刷新残留。

原因:绘制代码绕过了 JPanel 的双缓冲,比如直接在 JFrame 的 Graphics 上画,或用了 AWT Canvas。另一个原因是 paintComponent 里做了耗时操作,比如每次画格子都 new Color、读文件,重绘帧率被拖到肉眼可见的闪烁。

解决:主面板继承 JPanel,所有绘制收进 paintComponent;颜色数组和笔刷对象在构造时准备好,绘制只做计算和画图。Swing 默认双缓冲会在内存里合成好整帧再提交,闪烁随之消失。

6. 从能玩到好玩:给源码加预览、暂停与本地存档的三个扩展点

6.1 下一个方块预览

在 GamePanel 里加一个 nextBlock 字段。spawnBlock() 时先把 nextBlock 赋给 currentBlock,再随机生成一个新的 nextBlock 存储,右侧面板只负责把 nextBlock 画出来。注意预览面板的坐标要用它自己的单元格尺寸重新计算,别直接复用主面板的 drawBlock,否则会画到地图坐标上。

6.2 暂停与硬降的按键预留

暂停就是 timer.stop() 和 timer.start() 交替。但要在所有按键动作里加 paused 判断,否则暂停时还能移动方块就露馅了。另一个习惯是退出游戏时先 timer.stop() 再关窗口,避免 CPU 白跑。每次改完下落速度,用 timer.setDelay() 更新而不是重启 Timer,能让变速无缝衔接。

6.3 本地存档:Properties 记录最高分

最高分用 Properties 写入本地文件即可,不需要引 JSON 库。每次 gameOver 后比较当前分数和 highScore,超过就覆盖写回:

Properties props = new Properties(); props.setProperty("highScore", String.valueOf(highScore)); try (OutputStream out = Files.newOutputStream(Paths.get("tetris.properties"))) { props.store(out, "tetris save"); } catch (IOException e) { // 存档失败不影响游戏运行,忽略即可 }

读取时用 Files.newInputStream 按同样的 key 加载,文件不存在就当作 0 分处理。Properties 存储有个小坑:store 方法会写入时间戳注释,如果你用文件比对做自动化测试,记得忽略首行。

我曾经有一版把旋转后的坐标直接写进原数组,结果方块贴墙一转就嵌进墙里,演示现场被老师截图说“开挂”。从那以后,我每次改完碰撞、旋转、消行这三个点,都强制跑一遍“贴墙连按旋转、底部连按硬降、连续消四行”的基准场景,确认逻辑无误才敢继续往下写。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进