
简介《俄罗斯方块源码》是一份用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 显示积分与等级。外层布局常见做法是一个 BorderLayoutGamePanel 占中央右侧竖排预览和信息面板。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 线程工作线程自己维护标志需要额外同步纯逻辑、无 UIScheduledExecutorService线程池灵活但代码多需要 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() 里每一帧只做一次“尝试下落”这是整个循环的核心。注意两个顺序不能颠倒先生成新方块再判断新方块是否碰撞。如果先判断再生成拿到的还是旧方块永远判不出“游戏结束”。计分乘了等级 levelREWARDS 是一个数组下标对应当次消掉的行数值分别是 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 取 8001 级下落间隔是 800ms2 级 680ms3 级 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 方法会写入时间戳注释如果你用文件比对做自动化测试记得忽略首行。我曾经有一版把旋转后的坐标直接写进原数组结果方块贴墙一转就嵌进墙里演示现场被老师截图说“开挂”。从那以后我每次改完碰撞、旋转、消行这三个点都强制跑一遍“贴墙连按旋转、底部连按硬降、连续消四行”的基准场景确认逻辑无误才敢继续往下写。希望帮到你。本文还有配套的精品资源点击获取