
简介一份Java版俄罗斯方块小游戏完整源码适合Java初学者、课程设计或对经典游戏实现感兴趣的学习者。项目基于JRE运行包含完整菜单系统支持自定义控制键、单色或彩色显示、网格开关以及标准、速度、复杂性三组不同关卡关卡机制涵盖障碍物填充、复杂形状等变化体现出较完整的游戏逻辑与界面控制思路。资源共49个文件以32个Java源文件为主另有8个wav音效、3个res资源、3个xml配置、1个png图片及工程配置文件结构清晰便于阅读和二次开发。包体仅224KB轻量易部署已有1900余人学习参考。除运行所需的jar构建文件外源码还提供配置信息持久化功能设置保存至jar同级.cfg文件并附带已知Bug说明可帮助理解视图刷新机制、键盘事件等问题适合拆解、修改或作为课设基础。1. 一个经典小游戏为什么值得动手写源码俄罗斯方块几乎每个程序员都玩过但真正能独立把一版可运行的 Java 源码写出来的人并不多。常见的做法是打开 IDE 新建一个 Swing 窗口画网格、随机掉方块、按方向键移动听起来不难真正动手时会发现旋转矩阵、碰撞检测、消行判定这些细节比预想复杂得多。对于 Java 基础扎实与否这其实是一道很好的检验题有没有真正理解二维数组、状态机、事件分发和主循环的关系。无论你是准备知识星球里的 java 小游戏练手还是为 java 面试八股文里常问的 Swing 与多线程问题找案例把俄罗斯方块源码从头写一遍比背十道概念题都管用。这篇博文顺着一个可复现的源码设计思路展开从数据模型到渲染循环再到参数调优和重构方向讲清楚每个设计和为什么。2. 数据模型先行七种方块、旋转矩阵与碰撞检测2.1 方块的表达方式用二维数组还是坐标列表常见的设计有两种用 4x4 的 boolean 二维数组表示一个方块的四个旋转形态或者用一组相对坐标点表示。我一般会选择后者原因是旋转计算更直接碰撞检测也容易写。每个方块由四个小方格的坐标组成坐标以 (row, col) 表达原点在方块自身的包围盒左上角。public class Tetromino { // 四种旋转状态每个状态存四个点的相对坐标 private final int[][][] SHAPES { // I 方块横向和竖向 {{0,0},{0,1},{0,2},{0,3}}, {{0,0},{1,0},{2,0},{3,0}}, {{0,0},{0,1},{0,2},{0,3}}, {{0,0},{1,0},{2,0},{3,0}}, }; private int rotation; // 当前旋转状态0~3 private int row, col; // 方块在游戏板上的位置 private final int type; // 方块类型0~6 }坐标列表写法的好处是每个方块只需定义四个点不浪费内存旋转时也不用做矩阵乘法直接查表切换状态。用 4x4 数组的优势是视觉直观但 I 方块和其他方块的包围盒不一致碰撞检测要额外处理空洞后期反而麻烦。2.2 旋转表与踢墙处理旋转不是简单的坐标变换俄罗斯方块有一个经典问题叫 wall kick方块贴墙时旋转会越过边界玩家会期望方块“挤”进墙里。简单实现里可以不做踢墙但在源码设计中加一个偏移尝试循环非常便宜。public boolean tryRotate(int[][] board, int newRotation) { int[] dr {0, -1, 1, 0}; // 偏移顺序不动、上移、下移 int[] dc {0, 0, 0, 0}; for (int i 0; i dr.length; i) { int newRow row dr[i]; int newCol col dc[i]; if (!collides(board, type, newRotation, newRow, newCol)) { rotation newRotation; row newRow; col newCol; return true; } } return false; }这段代码尝试在原位旋转失败则分别向上或向下错位一格再试。参数里的dr和dc是偏移向量表实际项目可以扩展成 SRS 标准的五组偏移但对初学者来说先试三个位置已经能解决九成卡墙问题。2.3 碰撞检测的边界条件碰撞检测是整份源码中最容易出 bug 的地方常见错误是只检查左右边界忽略了方块形状内部的空洞。逐格检测时必须把方块当前旋转形态中实际有颜色的格子映射到游戏板上。public boolean collides(int[][] board, int type, int rot, int r, int c) { int[][] shape getShape(type, rot); for (int[] p : shape) { int rr r p[0]; int cc c p[1]; if (cc 0 || cc COLS) return true; // 左右越界 if (rr ROWS) return true; // 触底 if (rr 0 board[rr][cc] ! 0) return true; // 与已有方块重叠 } return false; }注意rr 0的情况不视为碰撞允许方块在顶部尚未完全进入游戏板时的初始状态。有些实现会把顶部越界也判为碰撞导致出生点坐标计算变得极其别扭。边界场景判定条件正确处理左墙cc 0禁止移动右墙cc COLS禁止移动底部rr ROWS触底进入固定逻辑顶部出生区rr 0允许存在不判碰撞与已有块重叠board[rr][cc] ! 0禁止移动/旋转3. Swing 渲染、键盘监听与游戏主循环3.1 双缓冲与 JPanel 重绘机制Swing 渲染这个环节新手最常见的坑是直接用Graphics.drawRect在组件上画然后发现画面闪烁。正确的做法是重写JPanel.paintComponent配合 Swing 默认的双缓冲机制工作。游戏板建议用一个独立的BoardPanel extends JPanel持有游戏状态引用。Override protected void paintComponent(Graphics g) { super.paintComponent(g); Graphics2D g2 (Graphics2D) g; // 计算每个单元格的像素尺寸 int cellW getWidth() / Cols; int cellH getHeight() / Rows; // 画网格线 g2.setColor(new Color(40, 40, 40)); for (int i 0; i Cols; i) { g2.drawLine(i * cellW, 0, i * cellW, getHeight()); } for (int j 0; j Rows; j) { g2.drawLine(0, j * cellH, getWidth(), j * cellH); } // 画已固定的方块 for (int r 0; r Rows; r) { for (int c 0; c Cols; c) { if (board[r][c] ! 0) { drawCell(g2, c, r, Tetromino.COLORS[board[r][c]]); } } } // 画当前活动方块 drawActiveTetromino(g2, cellW, cellH); }paintComponent里不能做游戏逻辑计算只做绘制。从board[r][c]取颜色时board数组里存的是方块类型编号0 表示空格1~7 表示不同颜色这样渲染层不关心业务逻辑。3.2 键盘控制与事件分发线程Swing 的键盘事件监听要绑定到焦点组件上常见错误是监听器加在 JFrame 上但焦点落在某个子组件导致按键无响应。用KeyListener时要调用setFocusable(true)或者在 JFrame 上用Key Bindings机制。对于一个低延迟的游戏KeyListener够用但注意按键扫描间隔问题。addKeyListener(new KeyAdapter() { Override public void keyPressed(KeyEvent e) { switch (e.getKeyCode()) { case KeyEvent.VK_LEFT: game.moveLeft(); break; case KeyEvent.VK_RIGHT: game.moveRight(); break; case KeyEvent.VK_DOWN: game.softDrop(); break; case KeyEvent.VK_UP: game.rotate(); break; case KeyEvent.VK_SPACE: game.hardDrop(); break; case KeyEvent.VK_P: game.togglePause(); break; } } });注意这里的game对象和渲染线程的关系。按键触发的是对游戏状态的修改而不是直接调用repaint()。每次状态修改后调用boardPanel.repaint()让事件分发线程在下一轮重绘。这里不把逻辑放进keyPressed是合理的因为游戏主循环会通过屏幕刷新频率比如 60 FPS统一驱动画面。3.3 用 Swing Timer 还是独立线程游戏主循环有两个选择javax.swing.Timer或ScheduledExecutorService。Swing Timer的回调在事件分发线程内执行更新完状态后可以安全调用repaint()不会出现并发修改。缺点是定时精度一般对于下落速度这种对精度不敏感的场景完全够用。Timer timer new Timer(dropInterval, e - { if (!game.isPaused() !game.isGameOver()) { game.tick(); // 方块下移一格检测固定、消行 boardPanel.repaint(); } }); timer.start();dropInterval的单位是毫秒初始值可以设为 800。game.tick()是主循环的心脏尝试移动方块成功则返回失败则执行固定逻辑并生成下一个方块。我这里不用独立线程跑while(true)循环原因是要避免Thread.sleep造成的精度抖动和线程安全问题。4. 消行计分、连击加成与难度递进4.1 tick 方法的流程设计游戏主循环的tick方法必须严格按顺序处理下落、固定、消行、生成方块、结束判定。顺序颠倒会出现方块还没落地就触发消行的逻辑错误。public void tick() { if (collides(board, current.type, current.rotation, current.row 1, current.col)) { lockCurrentPiece(); // 固定当前方块到 board int lines clearLines(); // 消行返回消除的行数 if (lines 0) { score scoreTable[lines]; // 1行100, 2行300, 3行500, 4行800 linesCleared lines; updateLevel(); } spawnNextPiece(); // 产生新方块 if (collides(board, current.type, current.rotation, current.row, current.col)) { gameOver true; // 新方块出生即碰撞 timer.stop(); } } else { current.row; } }这里的scoreTable建议用数组查表而不是写 if-else 链因为四行消除的分数不是线性叠加一行 100、两行 300、三行 500、四行 800 的曲线是有意设计的——单次消四行的收益远大于拆成两次消两行。4.2 消行实现的两种思路从下往上扫还是逐列拷贝消行最直观的思路是遍历每一行检查是否全满是则清除。清除时注意必须从下往上处理否则行号会错位。常规实现private int clearLines() { int cleared 0; for (int r ROWS - 1; r 0; r--) { boolean full true; for (int c 0; c COLS; c) { if (board[r][c] 0) { full false; break; } } if (full) { cleared; // 把该行以上的所有行下移一格 for (int k r; k 0; k--) { board[k] board[k - 1].clone(); } board[0] new int[COLS]; r; // 继续检查当前行因为上面下移了 } } return cleared; }board[k] board[k-1].clone()这行用的是一维数组引用赋值然后把board[k-1]的引用赋给当前行本质上是整行覆盖。这里有个容易踩的坑如果直接 for 循环逐列赋值性能差异不大但代码可读性差而如果漏掉r连续消两行时会跳行。4.3 软降、硬降与下落速度的参数关系硬降hard drop是一次性落到最低点软降soft drop是按向下键加速下落。它们的分数结算方式在很多街机版本中是不同的但在学习版源码里可以统一处理。关键参数是下落间隔dropInterval和等级提升曲线。等级下落间隔(ms)消行需求单行得分08000100172010120264020140356030160448040180540050200等级提升不是单纯的加速很多实现还附带颜色变化。这里用linesCleared / 10计算等级即可注意控制最大等级否则下落间隔趋近于零会变成不可玩状态。5. 源码验证、重构方向与面试追问盘点5.1 最小可运行验证流程写完源码后的验证步骤值得固化成脚本化的检查清单。第一关是编译javac应该无警告通过第二关是自动运行一局观察是否存在状态残留问题。这里提供一个简单的主类入口设计public class TetrisGame { public static void main(String[] args) { SwingUtilities.invokeLater(() - { JFrame frame new JFrame(Java Tetris); frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); frame.setResizable(false); GamePanel panel new GamePanel(); frame.add(panel); frame.pack(); frame.setLocationRelativeTo(null); frame.setVisible(true); panel.startGame(); }); } }SwingUtilities.invokeLater是强制要求不能省。把 UI 创建放在事件分发线程里否则在 macOS 和 Linux 上会出现随机的不稳定问题。验证时设置一个测试模式把随机生成方块的逻辑替换成固定序列能快速复现特定的消行和旋转场景。5.2 三个值得做的重构方向第一把游戏逻辑从 UI 中彻底分离。当前代码里GamePanel如果同时管状态和渲染后续移植到 Android 或 Web 时需要重写全部逻辑。常见做法是抽出GameEngine类只暴露tick/left/right/rotate/hardDrop方法给 UI 调用。第二引入方块队列7-bag randomizer。经典实现是每次随机生成下一个方块但这会导致连续出现多个 I 方块的极端情况。7-bag 算法把七种方块放进一个袋子里打乱顺序全部用完再生成下一袋。这能显著提升游戏公平性。第三把渲染层换成双缓冲的绘制缓存。当游戏板尺寸变大比如从 10x20 扩展到 14x24时逐格绘制网格线的性能瓶颈会显现可以用BufferedImage做背景缓存只在格子状态变化时局部重绘。面试时被问到Swing和AWT的区别、repaint与paint的关系、多线程下JComponent状态不一致的问题都可以拿着这份源码当案例回答。比如repaint只是标记区域为 dirty实际重绘发生在下一次事件循环这个机制在游戏里表现为按键后画面不是立即刷新理解这一点对排查卡顿问题直接有帮助。5.3 一个值得保留的调试技巧在游戏板上叠加一个半透明的调试网格覆盖层将每个方块的真实碰撞盒显示出来。具体做法是给GamePanel增加一个showHitbox布尔开关在paintComponent末尾用带透明度的Graphics2D画出当前方块四个格子的边框。打开这个模式后旋转、踢墙、边界判定哪里有问题一目了然比打日志排查快得多。if (showHitbox) { g2.setStroke(new BasicStroke(2f)); g2.setColor(new Color(255, 0, 0, 180)); int[][] shape Tetromino.getShape(current.type, current.rotation); for (int[] p : shape) { g2.drawRect((current.col p[1]) * cellW 2, (current.row p[0]) * cellH 2, cellW - 4, cellH - 4); } }这个调试开关在按键监听里用一个未占用的键位绑定的方式留到最后都不删后续扩展旋转系统或做延迟移动DAS调整时会反复用到保留它会让测试成本大幅下降。本文还有配套的精品资源点击获取