☰
俄罗斯方块Java源码深度拆解:Swing游戏循环与碰撞检测实战
2026/10/21 14:28:01 网站建设 项目流程

简介:这份资源是面向Java初学者与游戏编程爱好者的俄罗斯方块完整源码包,以Java语言实现经典游戏核心逻辑,适合作为理解面向对象设计、游戏循环与事件处理的练手项目。压缩包共38个文件,约170KB,其中3个java源文件承载游戏主逻辑,14个class为编译后字节码,18个png提供界面素材,另有prefs、project、classpath等IDE配置便于在Eclipse中直接导入运行。源码按GameBoard、Shape、Controller、Main等类划分职责,涵盖方块生成与旋转、碰撞检测、消行计分、游戏结束判定及键盘交互等关键环节,目录结构清晰,便于按模块阅读与调试。目前已有2785人学习下载,读者可借此掌握游戏循环与状态管理思路,并在此基础上尝试多人对战、在线排行或性能优化等扩展,提升Java编程实践能力。

1. 从一份俄罗斯方块 Java 源码说起:为什么它值得你花时间拆一遍

很多人第一次接触游戏开发,都是从俄罗斯方块开始的。规则简单到一句话能说清——七种方块往下掉,填满一行就消掉——但真动手写起来,旋转碰撞、行消除、下落加速、暂停重开,每一个环节都藏着细节。这份俄罗斯方块 Java 源码,就是把这些细节全部摊开给你看的一个完整实现。它适合两类人:一类是刚学完 Java 基础语法,想找个能跑起来、能改得动的小项目练手的新手;另一类是想快速搭一个桌面小游戏骨架,拿去做二次开发或者当课程设计底座的熟手。源码本身不依赖复杂框架,纯 Java 实现,跑起来门槛低,但麻雀虽小五脏俱全,游戏循环、事件处理、状态管理这些通用套路都在里面。

2. 项目结构与技术选型:一个桌面小游戏的最小闭环

2.1 技术栈拆解:为什么是 Java + Swing

这份源码用的是 Java SE 标准库里的 Swing 做界面渲染,没有引入任何第三方依赖。这个选择很务实——俄罗斯方块的画面就是一堆方块,用 Swing 的JPanel配合paintComponent重绘完全够用,不需要上游戏引擎。整个项目跑起来只需要一个 JDK,连构建工具都可以不用,命令行javac加java就能启动。

常见做法是把游戏主类、方块定义、游戏面板、主窗口分成几个文件,各管一摊。我一般会这样拆:

  • Tetris.java:入口类,创建窗口、启动游戏循环
  • GamePanel.java:核心面板,负责绘制、键盘监听、游戏状态更新
  • Tetromino.java:方块定义,包含七种形状的坐标数据和旋转逻辑
  • GameState.java:枚举或常量类,管理运行、暂停、结束等状态

这样拆的好处是,改方块形状只动Tetromino,改渲染逻辑只动GamePanel,不会牵一发动全身。新手最容易犯的错是把所有代码塞进一个类里,写到后面自己都找不到哪行管哪块。

2.2 目录结构:文件不多,但职责要分清

一份典型的俄罗斯方块 Java 源码目录大概长这样:

Tetris/ ├── src/ │ ├── Tetris.java # 程序入口,main 方法 │ ├── GamePanel.java # 游戏主面板,继承 JPanel │ ├── Tetromino.java # 方块类,含形状与旋转 │ ├── GameState.java # 游戏状态枚举 │ └── ScoreBoard.java # 分数与等级管理 ├── res/ │ └── (可选) 音效或图标资源 └── README.md

如果你拿到的源码没有分这么细,也别慌,先找到main方法所在的类,顺着它往下读。入口类通常只做三件事:设置窗口大小、把GamePanel加进去、调用start()或启动定时器。读懂了入口,整个项目的脉络就清楚了。

2.3 游戏主循环:定时器驱动的下落逻辑

俄罗斯方块的核心是一个不断重复的循环:每隔一段时间,当前方块下移一格;如果下移后发生碰撞,就固定方块、检查消行、生成新方块。在 Swing 里,这个循环通常用javax.swing.Timer实现,而不是自己开线程。

// GamePanel.java 片段 private Timer gameTimer; private int dropInterval = 500; // 毫秒,初始下落间隔 public void startGame() { if (gameTimer != null && gameTimer.isRunning()) { gameTimer.stop(); } gameTimer = new Timer(dropInterval, e -> { if (currentState == GameState.RUNNING) { moveDown(); // 尝试下移一格 repaint(); // 触发重绘 } }); gameTimer.start(); }

这段代码里,dropInterval控制下落速度,数值越小方块掉得越快。moveDown()是自定义方法,内部会判断下移后是否越界或撞到已固定的方块。如果碰撞,就调用lockPiece()把当前方块写入棋盘数组,然后clearLines()检查满行,最后spawnNewPiece()生成下一个。repaint()通知 Swing 重绘面板,paintComponent里再根据棋盘数组和当前方块坐标画出来。

参数说明:dropInterval初始值一般设 500 毫秒左右,随着等级提升可以逐步减小,比如每消 10 行减 50 毫秒,最低不低于 100 毫秒,否则人眼跟不上。Timer的第二个参数是ActionListener,这里用 lambda 写得简洁,老版本 Java 得写成匿名内部类。

2.4 方块表示与旋转:用二维数组还是坐标列表

方块怎么存,直接决定了旋转和碰撞检测好不好写。常见两种做法:一种是用 4x4 的二维数组表示每种方块的每个旋转态,另一种是用一组相对坐标点表示。这份源码大概率用的是二维数组,因为直观、好调试。

// Tetromino.java 片段:用 4x4 矩阵表示 T 型方块的四个旋转态 private static final int[][][] T_SHAPES = { { // 旋转态 0 {0, 0, 0, 0}, {1, 1, 1, 0}, {0, 1, 0, 0}, {0, 0, 0, 0} }, { // 旋转态 1 {0, 1, 0, 0}, {1, 1, 0, 0}, {0, 1, 0, 0}, {0, 0, 0, 0} }, // ... 另外两个旋转态 };

每个方块有四个旋转态,旋转时就是把当前态索引加一取模。碰撞检测时,遍历这个 4x4 矩阵,把值为 1 的格子映射到棋盘坐标上,看是否越界或与已有方块重叠。这种写法的好处是旋转逻辑简单,坏处是矩阵里有很多 0,遍历时稍微浪费一点,但对俄罗斯方块这个规模来说完全无所谓。

注意:旋转时如果新位置发生碰撞,常见做法是尝试左右平移一格再旋转,也就是所谓的“踢墙”。这份源码如果没实现踢墙,贴边旋转会失败,手感会差一些,二次开发时可以补上。

3. 核心模块拆解:从键盘事件到消行计分的完整链路

3.1 键盘监听:方向键怎么控制方块

Swing 里监听键盘有两种方式:KeyListener和Key Bindings。这份源码大概率用的是KeyListener,因为写起来直接。在GamePanel的构造函数里加上addKeyListener(this),然后实现keyPressed方法。

@Override public void keyPressed(KeyEvent e) { if (currentState != GameState.RUNNING) { return; // 暂停或结束时忽略按键 } switch (e.getKeyCode()) { case KeyEvent.VK_LEFT: moveLeft(); break; case KeyEvent.VK_RIGHT: moveRight(); break; case KeyEvent.VK_DOWN: moveDown(); break; case KeyEvent.VK_UP: rotate(); break; case KeyEvent.VK_SPACE: hardDrop(); // 直接落底 break; case KeyEvent.VK_P: togglePause(); break; } repaint(); }

moveLeft和moveRight内部先计算新坐标,判断不越界且不碰撞后再更新当前方块位置。hardDrop是循环调用moveDown直到碰撞,然后立刻固定方块。togglePause切换currentState并在面板上绘制“暂停”字样。

这里有个血泪经验:KeyListener要求面板必须获得焦点,如果窗口里还有其他组件抢焦点,按键会没反应。解决办法是在GamePanel构造里加setFocusable(true)并调用requestFocusInWindow()。如果还是不行,就换成Key Bindings,把按键绑定到WHEN_IN_FOCUSED_WINDOW条件上,一劳永逸。

3.2 碰撞检测:什么时候该固定方块

碰撞检测是整个游戏里最核心的判断逻辑。每次移动或旋转前,都要先算一下新位置是否合法。合法就更新,不合法就保持原样;如果是下移不合法,就说明方块触底了,该固定了。

private boolean isValidPosition(int[][] shape, int newX, int newY) { for (int row = 0; row < 4; row++) { for (int col = 0; col < 4; col++) { if (shape[row][col] == 0) { continue; // 空格子不参与检测 } int boardX = newX + col; int boardY = newY + row; // 左右越界或底部越界 if (boardX < 0 || boardX >= COLS || boardY >= ROWS) { return false; } // 与已固定方块重叠 if (boardY >= 0 && board[boardY][boardX] != 0) { return false; } } } return true; }

参数说明:shape是当前方块的 4x4 矩阵,newX和newY是方块左上角在棋盘上的目标坐标。COLS和ROWS是棋盘的列数和行数,经典俄罗斯方块是 10 列 20 行。board是二维数组,0 表示空,非 0 表示已固定方块的颜色索引。

这段逻辑看着简单,但有个容易翻车的地方:boardY >= 0这个判断。因为方块生成时可能有一部分在棋盘上方(负坐标),这部分不应该参与重叠检测,否则新方块一出来就判定碰撞。很多新手写的版本一启动就卡死,就是漏了这个判断。

3.3 消行与计分:满行判断和分数递增

方块固定后,立刻检查每一行是否填满。填满就删掉这一行,上面的行整体下移,分数按消行数递增。

private int clearLines() { int linesCleared = 0; for (int row = ROWS - 1; row >= 0; row--) { boolean full = true; for (int col = 0; col < COLS; col++) { if (board[row][col] == 0) { full = false; break; } } if (full) { linesCleared++; // 把这一行以上的所有行下移一行 for (int r = row; r > 0; r--) { System.arraycopy(board[r - 1], 0, board[r], 0, COLS); } // 最上面一行清空 Arrays.fill(board[0], 0); row++; // 因为下移了,需要重新检查当前行 } } // 计分:一次消 1 行 100 分,2 行 300 分,3 行 500 分,4 行 800 分 switch (linesCleared) { case 1: score += 100; break; case 2: score += 300; break; case 3: score += 500; break; case 4: score += 800; break; } return linesCleared; }

System.arraycopy是 Java 里做数组拷贝的标准做法,比手写循环快,也更简洁。row++那行是关键:因为当前行被上面的行覆盖了,所以需要重新检查同一位置,否则会漏掉连续满行的情况。计分规则是经典俄罗斯方块的常见设定,一次消四行(Tetris)给 800 分,鼓励玩家攒着一次消。

等级提升通常跟总消行数挂钩,比如每消 10 行升一级,dropInterval减少 50 毫秒。这部分逻辑可以放在clearLines返回后处理,也可以单独写一个updateLevel方法。

3.4 渲染绘制:paintComponent 里画什么

Swing 的绘制入口是paintComponent(Graphics g),所有画面都在这里画出来。顺序很重要:先画背景和已固定方块,再画当前方块,最后画分数和状态文字。

@Override protected void paintComponent(Graphics g) { super.paintComponent(g); // 画棋盘背景 g.setColor(Color.BLACK); g.fillRect(0, 0, COLS * CELL_SIZE, ROWS * CELL_SIZE); // 画已固定的方块 for (int row = 0; row < ROWS; row++) { for (int col = 0; col < COLS; col++) { if (board[row][col] != 0) { drawCell(g, col, row, board[row][col]); } } } // 画当前方块 if (currentPiece != null) { for (int row = 0; row < 4; row++) { for (int col = 0; col < 4; col++) { if (currentPiece.getShape()[row][col] != 0) { drawCell(g, currentPiece.getX() + col, currentPiece.getY() + row, currentPiece.getColorIndex()); } } } } // 画分数和等级 g.setColor(Color.WHITE); g.drawString("Score: " + score, 10, 20); g.drawString("Level: " + level, 10, 40); if (currentState == GameState.PAUSED) { g.drawString("PAUSED", COLS * CELL_SIZE / 2 - 30, ROWS * CELL_SIZE / 2); } }

CELL_SIZE是每个格子的像素大小,一般设 25 到 30。drawCell是自定义方法,根据颜色索引选颜色,画一个带边框的矩形。super.paintComponent(g)必须第一行调用,否则背景擦不干净,会出现残影。

提示:如果画面闪烁严重,可以在GamePanel构造里加setDoubleBuffered(true)。Swing 默认是双缓冲的,但有些老版本或者嵌套面板里需要手动开。

4. 避坑与常见问题:跑不起来先看这几条

4.1 窗口一片黑,方块不显示

现象:程序启动后窗口出来了,但里面全黑,按键也没反应。

原因:最常见的是GamePanel没有加到JFrame里,或者加了但没设置大小和可见性。另一个可能是paintComponent里忘了调super.paintComponent(g),导致绘制异常。

解决:检查入口类里是否有frame.add(gamePanel)、frame.pack()或frame.setSize()、frame.setVisible(true)。确认GamePanel的preferredSize设置正确,pack()才会按预期大小显示。

4.2 按键没反应,方块不动

现象:窗口正常显示,方块也在,但按方向键没任何反应。

原因:KeyListener没生效,通常是焦点问题。面板没有获得键盘焦点,或者被其他组件抢走了。

解决:在GamePanel构造里加setFocusable(true)和requestFocusInWindow()。如果还不行,改用Key Bindings,把按键绑定到WHEN_IN_FOCUSED_WINDOW输入映射上。另外检查keyPressed里是否因为currentState判断直接 return 了。

4.3 方块一出来就卡死或重叠

现象:新方块生成后立刻判定碰撞,游戏结束,或者方块叠在一起。

原因:新方块的初始坐标设在了棋盘内部,与已有方块重叠;或者碰撞检测里没排除棋盘上方(负坐标)的区域。

解决:新方块生成时y坐标设为负数,让方块从顶部外面进入。碰撞检测里加boardY >= 0判断,负坐标只检查左右越界,不检查重叠。

4.4 消行后分数不涨或行没消干净

现象:明明填满了行,但行没消失,分数也没变。

原因:消行逻辑里row++漏了,导致连续满行只消了最下面一行;或者计分 switch 没写全。

解决:确认clearLines里在Arrays.fill之后有row++。检查计分逻辑是否覆盖了 1 到 4 行的情况。如果用的是List存行,注意删除时的索引变化。

4.5 游戏速度越来越快,但停不下来

现象:等级提升后下落间隔越来越小,最后快到没法玩。

原因:dropInterval没有设下限,或者等级提升逻辑没有封顶。

解决:在更新dropInterval时加Math.max(100, dropInterval - 50),保证最快不低于 100 毫秒。等级也可以设个上限,比如 20 级封顶。

5. 二次开发与改造:从能跑到好用,我一般会动这几处

拿到一份能跑的俄罗斯方块源码,下一步就是按自己的需求改。我一般会从三个方向入手:手感优化、功能扩展、代码重构。

手感优化最立竿见影的是加“踢墙”和“软降”。踢墙就是在旋转碰撞时尝试左右平移一格再旋转,贴边时也能转,玩起来顺畅很多。软降是按住下键时方块加速下落,松开恢复,比一下一下按舒服。实现上,软降可以在keyPressed里把dropInterval临时改小,keyReleased里恢复;或者单独维护一个softDrop标志,在定时器回调里判断。

// 软降实现片段 @Override public void keyPressed(KeyEvent e) { if (e.getKeyCode() == KeyEvent.VK_DOWN) { softDrop = true; } // ... 其他按键 } @Override public void keyReleased(KeyEvent e) { if (e.getKeyCode() == KeyEvent.VK_DOWN) { softDrop = false; } } // 定时器回调里 int interval = softDrop ? 50 : dropInterval; // 用 interval 控制下落节奏

功能扩展方面,常见的有“下一个方块预览”“暂存方块”“幽灵方块(落点提示)”。预览就是在面板右侧画一个小区域,显示nextPiece的形状。暂存是加一个holdPiece变量,按 C 键交换当前方块和暂存方块,每轮只能暂存一次。幽灵方块是在当前方块正下方画一个半透明的轮廓,提示落点,实现上就是循环调用isValidPosition找到最底部的合法位置,然后画个灰色边框。

代码重构方面,如果源码把所有逻辑塞在一个类里,我会先把方块定义抽成独立的Tetromino类,把棋盘操作抽成Board类,把计分和等级抽成ScoreManager。这样改起来互不影响,也方便加单元测试。测试碰撞检测和消行逻辑用 JUnit 写几个用例,比每次跑起来手动试快得多。

还有一个容易忽略的点是资源释放。Timer在窗口关闭时要stop(),否则后台还在跑,反复打开关闭会泄漏。在JFrame的windowClosing事件里调一下gameTimer.stop()就行。

从那以后我每次拆这种小游戏源码,都强制自己先跑通默认版本,再动第一行代码。跑不通就先查焦点、查坐标、查数组越界,这三处能解决八成问题。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询