简介:这是一份用Java实现的俄罗斯方块游戏源码,基于Swing图形界面开发,适合正在学习Java编程、想通过完整小项目巩固面向对象与事件处理知识的初学者和进阶者。压缩包共19个文件,约23KB,包含7个java源文件、9个class编译文件,以及classpath、prefs、project等Eclipse工程配置,目录结构清晰,可直接导入IDE运行调试。资源围绕方块生成、旋转、消行判定、计分与键盘响应等核心逻辑展开,代码组织完整,便于读者理解游戏主循环与界面刷新的实现方式。目前已有810人学习下载,作者dkm123456在配套文章中详细讲解了实现思路,并附有视频演示,读者可结合源码逐行对照,快速掌握Swing绘图、定时器驱动与状态管理等关键技能,也可在此基础上扩展难度、音效或网络对战功能。
1. 一个周末,用 Java 把中学时代的俄罗斯方块重新写一遍
整理旧物时翻出一台屏幕已经发黄的掌机,按下电源键,那个只有黑白像素的方块游戏居然还能跑。那一刻我决定用 Java 把它重新实现一遍——不是为了怀旧,而是想找一个足够小、又足够完整的项目,把面向对象编程、Swing 事件模型、定时器调度和二维数组操作串成一条线。俄罗斯方块看起来简单,但真正动手写才会发现,它几乎覆盖了 Java 基础里所有关键知识点:类与对象、继承与多态、集合框架、线程与定时器、图形绘制、键盘事件监听。对正在啃 Java 基础、准备蓝桥杯或者面试八股文的同学来说,这是一个比“学生管理系统”有意思得多的练手项目。这篇文章会从零开始,把整个实现路径、参数设置和踩过的坑讲清楚,代码可以直接抄,思路可以照着改。
2. 先把游戏骨架搭起来:Java 俄罗斯方块的核心类设计
2.1 为什么选 Swing 而不是 JavaFX
做桌面小游戏,Java 里常见的选择有两个:Swing 和 JavaFX。JavaFX 的图形渲染能力更强,支持 CSS 样式和硬件加速,但它的模块化设计在 JDK 11 之后需要额外引入依赖,对新手来说环境配置本身就是一道坎。Swing 虽然老,但它是 JDK 标准库的一部分,不需要任何额外依赖,javax.swing.JPanel配合java.awt.Graphics就能完成全部绘制工作。俄罗斯方块的画面元素非常简单——只有方块和网格线,Swing 的性能完全够用。我一般会建议刚入门 Java 的同学先用 Swing 把逻辑跑通,等对事件模型和绘制流程熟悉了,再考虑用 JavaFX 做更复杂的界面。
另一个实际考虑是资料丰富度。Swing 存在了二十多年,遇到问题几乎都能搜到答案;JavaFX 的社区相对小一些,某些冷门问题可能需要翻官方文档。对于“一个周末写完”这个目标来说,少踩环境坑比多几个炫酷特效重要得多。
2.2 四个核心类的职责划分
整个项目我拆成四个类,每个类只做一件事:
| 类名 | 职责 | 关键方法 |
|---|---|---|
TetrisApp | 程序入口,创建窗口和游戏面板 | main |
GamePanel | 游戏主循环、绘制、键盘事件处理 | paintComponent、actionPerformed、keyPressed |
Tetromino | 方块的数据结构,包括形状矩阵和旋转逻辑 | rotate、getCells |
Board | 游戏区域的网格状态管理,碰撞检测和消行 | isValidPosition、clearLines |
这种划分方式的好处是:Tetromino和Board完全不依赖 Swing,可以单独写单元测试。比如你想验证旋转逻辑对不对,直接 new 一个Tetromino对象调用rotate()就行,不需要启动图形界面。这就是面向对象编程里“关注点分离”的实际价值——面试时被问到“什么是高内聚低耦合”,你可以拿这个项目当例子讲。
2.3 用二维数组表示方块和游戏区域
俄罗斯方块的本质是二维数组操作。每个方块用一个 4×4 的二维数组表示,游戏区域用一个 20×10 的二维数组表示。方块下落就是数组下标的行号递增,左右移动就是列号增减,旋转就是矩阵转置加翻转。
// Tetromino.java - 方块形状定义 public class Tetromino { // 7 种标准方块,每种用 4x4 矩阵表示 private static final int[][][] 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}, {0,1,0,0}, {1,1,1,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}}, // L 型 {{0,0,0,0}, {0,0,1,0}, {1,1,1,0}, {0,0,0,0}}, // J 型 {{0,0,0,0}, {1,0,0,0}, {1,1,1,0}, {0,0,0,0}} }; private int[][] shape; // 当前形状矩阵 private int row; // 在游戏区域中的行位置 private int col; // 在游戏区域中的列位置 public Tetromino(int type) { // 深拷贝,避免修改 SHAPES 常量 this.shape = new int[4][4]; for (int i = 0; i < 4; i++) { System.arraycopy(SHAPES[type][i], 0, shape[i], 0, 4); } this.row = 0; this.col = 3; // 从中间偏左开始 } // 顺时针旋转 90 度:矩阵转置后左右翻转 public void rotate() { int[][] rotated = new int[4][4]; for (int i = 0; i < 4; i++) { for (int j = 0; j < 4; j++) { rotated[j][3 - i] = shape[i][j]; } } this.shape = rotated; } }这里有几个参数需要说明。row初始为 0 表示方块从顶部出现,col初始为 3 是因为游戏区域宽 10 列,4×4 矩阵居中放置时左边缘在第 3 列。旋转逻辑用的是“转置加翻转”的经典写法:先沿主对角线交换shape[i][j]和shape[j][i],再左右翻转列索引。注意SHAPES常量必须深拷贝,否则旋转操作会污染原始形状数据,下次生成同类型方块时形状就乱了——这个坑我在第一次写的时候踩过,调试了半小时才发现。
3. 让方块动起来:定时器、碰撞检测与消行逻辑
3.1 用 javax.swing.Timer 驱动游戏主循环
游戏循环是俄罗斯方块的心脏。方块需要每隔一段时间自动下落一格,同时还要响应键盘事件。Java 里实现定时任务常见的有三种方式:Thread.sleep循环、ScheduledExecutorService、javax.swing.Timer。前两种是在后台线程执行的,而 Swing 的绘制必须在事件调度线程(EDT)上进行,跨线程更新 UI 会导致界面闪烁甚至崩溃。javax.swing.Timer的回调就是在 EDT 上执行的,天然避免了线程安全问题。
// GamePanel.java - 游戏主循环初始化 public class GamePanel extends JPanel implements ActionListener { private Timer timer; private Board board; private Tetromino currentPiece; private int score; private int level; private int dropInterval; // 下落间隔,单位毫秒 public GamePanel() { this.board = new Board(20, 10); this.score = 0; this.level = 1; this.dropInterval = 500; // 初始 500ms 下落一格 this.timer = new Timer(dropInterval, this); this.timer.start(); setFocusable(true); addKeyListener(new KeyAdapter() { @Override public void keyPressed(KeyEvent e) { handleKeyPress(e); } }); spawnNewPiece(); } @Override public void actionPerformed(ActionEvent e) { // 定时器每次触发,尝试向下移动一格 if (canMove(currentPiece, 1, 0)) { currentPiece.moveDown(); } else { // 无法下落,固定方块到游戏区域 board.placePiece(currentPiece); int linesCleared = board.clearLines(); updateScore(linesCleared); spawnNewPiece(); if (!canMove(currentPiece, 0, 0)) { gameOver(); } } repaint(); // 触发重绘 } }dropInterval初始设为 500 毫秒,意味着方块每半秒下落一格。这个值不是随便定的——太快玩家来不及反应,太慢游戏节奏拖沓。我试过 300ms 和 800ms,最终 500ms 对新手比较友好。随着等级提升,这个值会递减,每消 10 行升一级,dropInterval减少 50ms,最低不低于 100ms。repaint()调用后 Swing 会在合适的时机触发paintComponent,不要手动调用paintComponent,否则会打乱绘制流程。
3.2 碰撞检测:边界判断和已固定方块的冲突
碰撞检测是俄罗斯方块里最容易出 bug 的地方。每次移动或旋转之前,都要先判断新位置是否合法。判断逻辑分两步:先检查是否超出左右下边界,再检查是否与游戏区域中已固定的方块重叠。
// GamePanel.java - 碰撞检测 private boolean canMove(Tetromino piece, int deltaRow, int deltaCol) { int[][] shape = piece.getShape(); int newRow = piece.getRow() + deltaRow; int newCol = piece.getCol() + deltaCol; for (int i = 0; i < 4; i++) { for (int j = 0; j < 4; j++) { if (shape[i][j] == 0) continue; // 空格子跳过 int boardRow = newRow + i; int boardCol = newCol + j; // 边界检查 if (boardCol < 0 || boardCol >= 10) return false; if (boardRow >= 20) return false; // 与已固定方块冲突检查 if (boardRow >= 0 && board.getCell(boardRow, boardCol) != 0) { return false; } } } return true; }注意boardRow >= 0这个条件。方块刚生成时,4×4 矩阵的顶部两行可能是空的,实际方块部分从第 0 行开始。如果矩阵顶部有空行,boardRow可能为负数,这时候不需要检查游戏区域,因为方块还在“屏幕上方”。这个细节如果不处理,方块生成时会直接触发碰撞检测失败,游戏一启动就结束。
旋转的碰撞检测更麻烦一些。因为旋转后的形状可能超出边界,常见做法是加一个“踢墙”机制:如果旋转后碰撞,尝试向左或向右偏移一格再检测,最多尝试两次。完整实现踢墙需要记录每种方块的偏移表,但简化版只做左右各一格尝试,已经能覆盖大部分场景。
3.3 消行与计分:一行代码背后的数组操作
消行的逻辑是:从下往上遍历每一行,如果某一行所有格子都不为 0,就把这一行删掉,上面的行整体下移。用二维数组操作就是:把该行以上的所有行复制到下一行,最顶行清零。
// Board.java - 消行逻辑 public int clearLines() { int linesCleared = 0; for (int row = rows - 1; row >= 0; row--) { if (isFullRow(row)) { // 将 row 以上的所有行下移一行 for (int r = row; r > 0; r--) { System.arraycopy(grid[r - 1], 0, grid[r], 0, cols); } // 最顶行清零 Arrays.fill(grid[0], 0); linesCleared++; row++; // 重新检查当前行,因为上面的行已经下移 } } return linesCleared; } private boolean isFullRow(int row) { for (int col = 0; col < cols; col++) { if (grid[row][col] == 0) return false; } return true; }row++这行看起来有点反直觉,但很关键。当第row行被消除后,原来的第row-1行下移到了第row行,所以需要重新检查同一位置。如果不加这行,连续满行的情况会漏消。计分规则我参考了经典版本:消 1 行 100 分,消 2 行 300 分,消 3 行 500 分,消 4 行 800 分。分数乘以当前等级,等级越高单次消行得分越多。
4. 绘制与交互:把逻辑变成看得见的画面
4.1 paintComponent 里到底该画什么
Swing 的绘制入口是paintComponent(Graphics g),每次repaint()调用后由 EDT 触发。这个方法里要做三件事:画游戏区域的背景网格、画已固定的方块、画当前正在下落的方块。顺序不能乱,否则当前方块会被背景覆盖。
// GamePanel.java - 绘制逻辑 @Override protected void paintComponent(Graphics g) { super.paintComponent(g); // 必须调用,清除上一帧 Graphics2D g2d = (Graphics2D) g; g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); int cellSize = 30; // 每个格子 30x30 像素 int offsetX = 50; // 游戏区域左边距 int offsetY = 50; // 游戏区域上边距 // 画背景网格 g2d.setColor(new Color(40, 40, 40)); g2d.fillRect(offsetX, offsetY, 10 * cellSize, 20 * cellSize); g2d.setColor(new Color(60, 60, 60)); for (int i = 0; i <= 10; i++) { g2d.drawLine(offsetX + i * cellSize, offsetY, offsetX + i * cellSize, offsetY + 20 * cellSize); } for (int j = 0; j <= 20; j++) { g2d.drawLine(offsetX, offsetY + j * cellSize, offsetX + 10 * cellSize, offsetY + j * cellSize); } // 画已固定的方块 for (int row = 0; row < 20; row++) { for (int col = 0; col < 10; col++) { int value = board.getCell(row, col); if (value != 0) { g2d.setColor(getColorByType(value)); g2d.fillRect(offsetX + col * cellSize, offsetY + row * cellSize, cellSize - 1, cellSize - 1); } } } // 画当前方块 int[][] shape = currentPiece.getShape(); g2d.setColor(getColorByType(currentPiece.getType())); for (int i = 0; i < 4; i++) { for (int j = 0; j < 4; j++) { if (shape[i][j] != 0) { int x = offsetX + (currentPiece.getCol() + j) * cellSize; int y = offsetY + (currentPiece.getRow() + i) * cellSize; g2d.fillRect(x, y, cellSize - 1, cellSize - 1); } } } // 画分数和等级 g2d.setColor(Color.WHITE); g2d.setFont(new Font("SansSerif", Font.BOLD, 16)); g2d.drawString("Score: " + score, offsetX + 10 * cellSize + 30, offsetY + 30); g2d.drawString("Level: " + level, offsetX + 10 * cellSize + 30, offsetY + 60); }cellSize设为 30 像素,游戏区域就是 300×600 像素,加上边距和右侧信息栏,窗口设为 500×700 比较合适。fillRect的宽高减 1 是为了留出网格线间隙,视觉上每个方块有独立边框。getColorByType根据方块类型返回不同颜色,I 型青色、O 型黄色、T 型紫色,这是经典配色方案。
4.2 键盘事件:为什么你的方块不响应按键
键盘事件不响应是新手最常见的问题,原因几乎都是焦点问题。Swing 组件必须获得焦点才能接收键盘事件,而JPanel默认不可聚焦。解决办法是调用setFocusable(true)并在窗口显示后调用requestFocusInWindow()。
// GamePanel.java - 键盘事件处理 private void handleKeyPress(KeyEvent e) { switch (e.getKeyCode()) { case KeyEvent.VK_LEFT: if (canMove(currentPiece, 0, -1)) { currentPiece.moveLeft(); } break; case KeyEvent.VK_RIGHT: if (canMove(currentPiece, 0, 1)) { currentPiece.moveRight(); } break; case KeyEvent.VK_DOWN: if (canMove(currentPiece, 1, 0)) { currentPiece.moveDown(); score += 1; // 加速下落额外加分 } break; case KeyEvent.VK_UP: // 旋转前先备份状态,旋转失败要回滚 int oldRow = currentPiece.getRow(); int oldCol = currentPiece.getCol(); int[][] oldShape = currentPiece.getShapeCopy(); currentPiece.rotate(); if (!canMove(currentPiece, 0, 0)) { // 尝试左移一格 if (canMove(currentPiece, 0, -1)) { currentPiece.moveLeft(); } else if (canMove(currentPiece, 0, 1)) { // 尝试右移一格 currentPiece.moveRight(); } else { // 都不行,回滚旋转 currentPiece.setShape(oldShape); currentPiece.setPosition(oldRow, oldCol); } } break; case KeyEvent.VK_SPACE: // 硬降:一直下落到不能动为止 while (canMove(currentPiece, 1, 0)) { currentPiece.moveDown(); score += 2; } break; } repaint(); }旋转回滚是必须的。如果不备份直接旋转,旋转后碰撞检测失败时方块形状已经变了,没法恢复。getShapeCopy()返回深拷贝,setShape恢复时也要深拷贝。硬降用while循环而不是一次移动多格,是因为要逐格检查碰撞,不能跳过中间状态。
4.3 游戏结束的判断与重新开始
游戏结束的条件是:新方块生成时,初始位置就发生碰撞。这时候停止定时器,在画面中央显示“Game Over”,并监听回车键重新开始。
private void gameOver() { timer.stop(); isGameOver = true; repaint(); } // 在 paintComponent 末尾添加 if (isGameOver) { g2d.setColor(new Color(0, 0, 0, 180)); g2d.fillRect(0, 0, getWidth(), getHeight()); g2d.setColor(Color.WHITE); g2d.setFont(new Font("SansSerif", Font.BOLD, 32)); String msg = "Game Over"; int msgWidth = g2d.getFontMetrics().stringWidth(msg); g2d.drawString(msg, (getWidth() - msgWidth) / 2, getHeight() / 2); g2d.setFont(new Font("SansSerif", Font.PLAIN, 16)); String hint = "Press ENTER to restart"; int hintWidth = g2d.getFontMetrics().stringWidth(hint); g2d.drawString(hint, (getWidth() - hintWidth) / 2, getHeight() / 2 + 40); }重新开始时重置board、score、level、dropInterval,然后timer.start()。注意isGameOver标志位要重置,否则键盘事件还会被拦截。
5. 避坑指南:写 Java 俄罗斯方块时我踩过的五个坑
5.1 方块旋转后形状错乱——浅拷贝惹的祸
现象:同一种方块第一次旋转正常,第二次旋转后形状变得完全不对,甚至出现不该有的格子。
原因:SHAPES常量是static final的二维数组,如果直接赋值this.shape = SHAPES[type],shape和SHAPES[type]指向同一块内存。旋转操作修改shape时,实际上把常量池里的原始形状也改了。下次生成同类型方块时,拿到的已经是旋转过的形状。
解决:构造Tetromino时必须深拷贝,用System.arraycopy逐行复制。getShapeCopy()方法也要返回深拷贝,不能返回内部数组引用。
5.2 按键没反应——焦点不在游戏面板上
现象:程序启动后画面正常,方块自动下落,但按方向键毫无反应。
原因:JPanel默认focusable为false,键盘事件被窗口的其他组件截获了。即使调用了setFocusable(true),如果窗口还没显示就调用requestFocusInWindow(),也会失败。
解决:构造函数里setFocusable(true),然后在main方法中窗口setVisible(true)之后调用gamePanel.requestFocusInWindow()。如果还是不行,检查是否有其他组件抢焦点,可以用gamePanel.requestFocus()强制获取。
5.3 消行后分数不对——重复消行或漏消
现象:一次消四行,分数只加了 100 而不是 800;或者连续满行时只消了最下面一行。
原因:遍历方向搞反了。如果从下往上遍历,消掉一行后上面的行下移,但循环变量继续往上走,会跳过下移后的行。另外计分时没有根据消行数查表,而是固定加 100。
解决:从下往上遍历,消行后row++重新检查当前位置。计分用switch或数组映射:int[] SCORES = {0, 100, 300, 500, 800},score += SCORES[linesCleared] * level。
5.4 方块卡在底部不固定——碰撞检测漏了下边界
现象:方块落到底部后继续下落,直接穿出游戏区域消失,没有固定到网格上。
原因:canMove里只检查了boardRow >= 20,但方块 4×4 矩阵中有效格子可能不在最底行。比如 I 型方块有效格在第 1 行,当piece.getRow()为 19 时,boardRow = 19 + 1 = 20,应该触发碰撞。但如果矩阵有效格在第 2 行,boardRow = 19 + 2 = 21,只检查>= 20也能捕获。真正的问题是漏了boardRow >= 0的检查,导致方块生成时顶部空行被误判为碰撞。
解决:边界检查写全:boardCol < 0 || boardCol >= 10 || boardRow >= 20,同时加上boardRow >= 0才检查游戏区域网格。
5.5 游戏越玩越卡——repaint 调用过于频繁
现象:刚开始流畅,玩了几分钟后画面明显卡顿,按键响应延迟。
原因:在actionPerformed里多次调用repaint(),或者在键盘事件里每次移动都repaint(),导致 EDT 队列堆积大量重绘请求。另外paintComponent里创建了太多临时对象,比如每次绘制都 new Font 和 new Color。
解决:repaint()每次事件只调一次,放在方法末尾。Font和Color对象在构造函数里创建好,作为成员变量复用。如果方块数量多,考虑用BufferedImage做双缓冲,但俄罗斯方块这个量级通常不需要。
6. 进阶技巧:让这个周末项目更有嚼头
代码跑通只是起点。如果你想让这个项目在面试或蓝桥杯里拿得出手,可以加几个进阶功能。第一个是“下一个方块预览”,在右侧信息栏画一个 4×4 的小网格,提前显示下一个方块形状。实现上用一个Queue<Tetromino>存接下来三个方块,每次spawnNewPiece时从队列取,同时生成新方块入队。第二个是“暂停/继续”,监听 P 键切换timer.stop()和timer.start(),暂停时在画面中央显示半透明遮罩。第三个是“最高分记录”,用java.util.prefs.Preferences把最高分持久化到本地,重启程序后还能读到。
还有一个容易被忽略的优化:方块的“锁定延迟”。经典俄罗斯方块里,方块触底后不会立刻固定,而是给玩家大约 500ms 的缓冲时间做最后调整。实现方式是在actionPerformed里加一个lockDelay计数器,触底后开始倒计时,期间如果玩家移动了方块且新位置可以下落,就重置计数器。这个机制能显著提升手感,但逻辑复杂度也会上升,建议先把基础版本跑稳再尝试。
我自己写这个项目时,最大的收获不是代码本身,而是重新理解了“面向对象”四个字。刚开始我把所有逻辑塞在一个类里,写到 300 行时已经改不动了。后来拆成Tetromino、Board、GamePanel三个类,每个类不到 150 行,改起来清清楚楚。如果你正在学 Java,别急着刷面试题,先找一个像俄罗斯方块这样的小项目从头写到尾,遇到问题查文档、调参数、改结构,这一圈走下来比看十篇八股文管用。希望帮到你。
本文还有配套的精品资源,点击获取