资讯详情

资讯详情

俄罗斯方块Java源码拆解:Swing主循环、SRS踢墙与二次开发

简介面向 Java 初学者与游戏编程入门者的俄罗斯方块完整源码以经典游戏机制为线索呈现从界面绘制、方块生成到游戏循环的实现思路适合课程设计参考、自学练手与面向对象编程练习。压缩包共 38 个文件约 170KB其中 3 个 java 源文件承载主面板、方块形状、流程控制与程序入口等核心逻辑14 个 class 为编译后可运行的字节码18 个 png 提供界面与方块素材另有 prefs、project、classpath 等 Eclipse 工程配置文件导入 IDE 即可复用构建路径。已有 2785 人学习下载。读者可从中梳理游戏主循环、七种方块的旋转与下落、边界及堆叠碰撞检测、满行消除与得分结算、结束判定与键盘交互等关键环节并观察各组件如何按职责拆分协作进而在其基础上尝试加入多人对战、排行榜或性能优化作为进阶练习的起点。1. 一份俄罗斯方块 Java 源码能改动的地方比想象中多有个朋友拿到一份俄罗斯方块 Java源码双击跑了半天只弹出一个空白窗口差点以为文件是坏的。实际上文件没问题缺的是启动参数和入口类。这类源码包的价值从来不在“能玩”而在于它把棋盘、方块、碰撞、消行、计分这条链路完整摊开给你看是一个能实际动手改造的最小游戏内核。适合三种人想练 Swing 事件与绘图的学生、想找一个能二次开发的小项目做课程设计的开发者、以及想搞明白 SRS 旋转和踢墙到底怎么算的人。下面按“读骨架—跑起来—拆机制—躲坑—加功能”的顺序走一遍你能照着复现也能知道哪里该改、哪里最好别动。2. 读源码的顺序7 种方块、10×20 棋盘与一条 tick 主循环拿到一份陌生的游戏源码最糟糕的读法是从 main 方法一路往下顺。游戏代码的调用链是循环驱动的顺着读很容易在第一屏就掉进重绘逻辑里出不来。稳妥的顺序是先找“状态存在哪”再找“谁在改状态”最后才看“怎么画出来”。2.1 类与职责先把“谁改谁”摊在纸上绝大多数 Java 版俄罗斯方块会拆成四到六个类命名五花八门但职责边界高度一致。下面这张表是我读这类源码时固定要填的填完基本就知道能不能改得动类/文件职责你以后改动它的频率Tetromino/Shape7 种方块的形状、颜色、四个旋转态低除非要加方块Board/Grid10×20 的格子数组、碰撞判定、固化与消行中规则改动都在这GamePanel绘制、键盘监听、定时器、状态机运行/暂停/结束高交互和视觉效果都在这Tetris/Main建窗口、拼装面板、启动低几乎不动ScoreBoard/Level分数、等级、消行总数中数值平衡在这调职责清楚之后找那个“每帧被调一次”的方法。名字多半叫tick、update、step或者actionPerformed。它是整个程序的心脏其余代码都是它的下游。一段典型的 tick 骨架长这样// GamePanel.java —— 每次定时器触发走一遍顺序不能换 public void tick() { if (!running || paused) return; // 暂停时不推进重力否则恢复后会瞬移 if (!board.move(0, 1)) { // 尝试下移一格返回 false 说明踩到东西了 board.lockPiece(); // 1. 把当前方块固化进网格 int cleared board.clearLines(); // 2. 消行返回消掉的行数 score.add(cleared, level); // 3. 结算分数可能触发升级 if (!board.spawnNext()) { // 4. 生成下一个放不下就是 game over running false; gameOver true; } } repaint(); // 5. 状态改完再重绘 }这段代码里唯一容易写错的是 1、2、3、4 的顺序。先固化再消行是因为消行要基于已经写入网格的数据先消行再生成新方块是因为新方块可能正好落在被消掉的那几行上方顺序颠倒会让出生点判定出错。repaint()放最后是习惯问题但放中间会导致画面比状态慢一帧快速下落时能看到轻微撕裂。2.2 4×4 方块盒旋转态为什么不推荐运行时算新手写俄罗斯方块最容易踩的一个坑是试图用旋转矩阵在运行时实时计算方块坐标。数学上没错但绕着一个非中心的轴转稍微取整不当就会出现半格偏移最后表现为“方块旋转后和墙重叠一格”这种很难查的玄学问题。常见的做法是预存旋转态每个方块在 4×4 的盒子里预先存好 4 组坐标// Tetromino.java —— 坐标是 4×4 盒内的相对格y 轴向下 public enum Tetromino { T(Color.ORANGE, new int[][][]{ {{0,1},{1,1},{2,1},{1,0}}, // 0朝上 {{1,0},{1,1},{1,2},{2,1}}, // 1朝右 {{1,2},{0,1},{1,1},{2,1}}, // 2朝下 {{1,0},{0,1},{1,1},{1,2}} // 3朝左 }), I(Color.CYAN, new int[][][]{ {{0,1},{1,1},{2,1},{3,1}}, {{2,0},{2,1},{2,2},{2,3}}, {{0,2},{1,2},{2,2},{3,2}}, {{1,0},{1,1},{1,2},{1,3}} }); // O、S、Z、J、L 同理 public final Color color; public final int[][][] cells; // [旋转态][格子序号][x 或 y] Tetromino(Color color, int[][][] cells) { this.color color; this.cells cells; } }用 4×4 盒子而不是贴着方块实际外接矩形是为了让四种旋转态共用同一个原点。这样做的好处是旋转时只需要把rotation (rotation 1) % 4不用重新计算包围盒也不用担心 I 型和 O 型的行为不一致。代价是碰撞检测要遍历盒子里全部 16 格实际只对 4 个有效格判定多几次循环而已对 10×20 的棋盘完全无感。坐标里 y 轴向下这一点要提前确认清楚。有些源码用数学坐标系y 向上画的时候再翻转有些直接按屏幕坐标。这两种写法混用就会出现“方块下落时往上跑”的翻车现场而且因为画面还在动很容易被误判成定时器的问题。2.3 主循环Swing Timer 的 delay 就是重力Swing 版的俄罗斯方块基本都走javax.swing.Timer。它的回调在事件分发线程EDT上执行和repaint()、键盘事件是同一个线程所以状态读写天然没有并发问题不需要加锁。这是选它而不选自建Thread的主要理由——新手加锁最容易加出死锁和界面卡顿。// GamePanel.java 构造里的初始化 private final Timer timer new Timer(800, e - tick()); // 800ms 一格约等于 1 级 public void startGame() { board.reset(); score.reset(); level 1; running true; timer.setDelay(gravityDelay(level)); // delay 就是重力间隔 timer.start(); } // 等级越高延迟越小但要给下限否则会瞬间到底 private int gravityDelay(int level) { return Math.max(80, 800 - (level - 1) * 70); }Timer的第一个参数是毫秒延迟第二个是监听器。用 lambda 之后不用再写ActionListener的样板代码但要注意 lambda 里捕获的变量必须 effectively final改速度只能通过timer.setDelay()而不是重新 new 一个 Timer——重新 new 而不stop()旧的会同时跑两个定时器方块下落的加速度直接翻倍这是我在别人的代码里见过不止一次的坑。gravityDelay里的Math.max(80, ...)是保命的。如果按800 - (level-1)*70一路算下去13 级左右延迟就归零Timer会在 1 秒内触发上千次表现是方块瞬间落地、画面卡死、CPU 占满一个核。给一个 80ms 的下限等于给整条速度曲线留了后悔药。3. 把 Java 源码跑起来JDK 版本、javac 命令与一份 pom源码跑不起来九成不是代码的问题是环境和目录结构对不上。这一章按“先确认语言级别、再对齐包名路径、最后选启动方式”的顺序来。3.1 先确认 JDK 与编码别死在第一行先看源码里有没有var、文本块、switch表达式、record这些新语法。有var至少需要 JDK 10有record或文本块需要 JDK 15 以上。用grep -rn var \|record \|\\\ src/扫一遍最快。如果作者只用了泛型和 SwingJDK 8 也能跑。编码问题更隐蔽。源码里有中文注释、或者窗口标题是中文用 GBK 编译再用 UTF-8 运行就会看到标题变成方块字。统一做法是编译和运行都显式指定 UTF-8# 先看当前环境 java -version javac -version # 建议显式设置不要依赖系统默认 export JAVA_TOOL_OPTIONS-Dfile.encodingUTF-8JAVA_TOOL_OPTIONS会被 JVM 自动读取并打印一行提示不影响程序。这条设置对 Swing 的字体渲染和字符串输出都有效。Windows 上如果控制台是 GBK 代码页中文日志仍可能显示异常但窗口里的文字是对的不用管。提示如果源码目录里有.class或out/、bin/目录先删掉再编译。旧版本 class 文件残留会导致javac提示“找不到符号”或者说类重复。3.2 命令行编译把 -d、-cp 和包名一次对齐先看一眼每个 java 文件头部的package声明比如package com.example.tetris;。这条声明决定了文件必须放在com/example/tetris/目录下差一层都会编译不过。按下面的目录结构整理一遍tetris/ ├── src/ │ └── com/example/tetris/ │ ├── Tetris.java │ ├── GamePanel.java │ ├── Board.java │ ├── Tetromino.java │ └── ScoreBoard.java └── out/ # 空目录编译产物放这然后编译并运行cd tetris # Linux / macOS一次性收集所有源文件 javac -encoding UTF-8 -d out $(find src -name *.java) # Windows cmd先写文件清单再编译避免命令行过长 # dir /s /b src\*.java sources.txt # javac -encoding UTF-8 -d out sources.txt # 运行-cp 指向 class 输出根目录后面跟全限定类名 java -cp out com.example.tetris.Tetris三个参数各有分工。-encoding UTF-8告诉编译器源文件的字符集-d out让编译产物按包名生成子目录out/com/example/tetris/Tetris.class这一步省了的话所有 class 会平铺到当前目录运行时就会报NoClassDefFoundError-cp out是运行时的类路径根指向out而不是out/com/example/tetris因为它会自己拼上包名路径。如果启动后报找不到或无法加载主类按这个顺序查类名拼写和大小写Java 区分大小写、package声明是否和目录一致、-cp是否指到了包的上一级、main方法签名是不是public static void main(String[] args)。3.3 用 Maven 托管一份能直接抄的 pom如果要做二次开发、加依赖比如日志、单元测试命令行编译很快就会不够用。在项目根目录加一份pom.xml源文件仍然是src/main/java/com/example/tetris/这套标准结构project xmlnshttp://maven.apache.org/POM/4.0.0 modelVersion4.0.0/modelVersion groupIdcom.example/groupId artifactIdtetris/artifactId version1.0.0/version properties maven.compiler.source17/maven.compiler.source maven.compiler.target17/maven.compiler.target project.build.sourceEncodingUTF-8/project.build.sourceEncoding /properties build plugins !-- 打包成可执行 jar指定入口类 -- plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-jar-plugin/artifactId version3.3.0/version configuration archive manifest mainClasscom.example.tetris.Tetris/mainClass /manifest /archive /configuration /plugin /plugins /build /projectmaven.compiler.source/target要和你本地 JDK 匹配本地是 8 却写 17 会直接报错project.build.sourceEncoding不写的话中文注释在高版本 JDK 上会触发编码警告。打完包用java -jar target/tetris-1.0.0.jar就能跑发给别人也不用再教他怎么设 classpath。3.4 IDE 里跑以及断点该下在哪导入 IDE 时选“从现有源码创建项目”把src标记为源码根。如果 IDE 提示包名不匹配多半是它把src当成了包的起点改成src/main/java或直接用src作为源码根即可。调试时断点不要下在paintComponent里——那个方法一秒被调几十次断不住还卡死。有效的三个位置是tick()的第一行看状态机有没有正常推进、clearLines()的循环里看消行判定准不准、rotate()里尝试踢墙的那个 for 循环看偏移表有没有生效。4. 机制拆解SRS 踢墙表、消行重扫与重力毫秒曲线能跑起来之后真正决定操作手感的是三件事旋转时贴墙能不能转过去、消行之后上方的方块有没有错位、等级越高下落速度是不是线性可控。这三块也是二次开发时改动最频繁的地方。4.1 旋转与踢墙一张偏移表决定手感如果方块贴着左墙时按旋转键毫无反应那就是没做踢墙wall kick。现代规则里通用的是 SRS做法是旋转失败时依次尝试一组候选偏移第一个不碰撞的就采用。// Board.java —— 只列 JLSTZ 从朝上转到朝右的 5 个候选偏移 // 注意这里是屏幕坐标y 向下所以符号和官方文档的数学坐标相反 private static final int[][] KICK_0_TO_R { { 0, 0}, // 原地转 {-1, 0}, // 往左挪一格 {-1, -1}, // 往左上 { 0, 2}, // 往下两格I 型贴顶时用得到 {-1, 2} }; public boolean rotate(int dir) { int next (rotation dir 4) % 4; for (int[] kick : KICK_0_TO_R) { if (!collides(piece, next, x kick[0], y kick[1])) { rotation next; x kick[0]; y kick[1]; return true; } } return false; // 五次都撞这次旋转作废 }候选偏移的顺序不能乱SRS 规定了优先级顺序换了会出现“明明能转过去却转不过去”的怪现象。另外 0→R、R→2、2→L、L→0 四个方向各有一张表共四张对称方向不能简单取负号复用I 型还有单独一套更宽的偏移。如果源码里只有一张表通用所有情况那它大概率是简化实现贴墙旋转的手感会比标准规则差一些改的时候要有心理准备。collides的判定要同时检查三件事是否超出左右边界、是否超出底部、是否与已固化的格子重叠。少查一项就会漏格。顶部一般不做硬性判定允许方块部分露出画面外否则出生点附近的旋转会被莫名其妙地拒绝。4.2 消行那句y是整段代码的灵魂消行看着简单实际是最容易写出幽灵行的地方。核心循环从底行往上扫一旦发现满行就整体下移然后把循环变量加回来重扫同一行// Board.java —— grid[y][x] 中 true 表示该格已固化 public int clearLines() { int cleared 0; for (int y ROWS - 1; y 0; y--) { if (!isFull(y)) continue; // 把 y 以上的所有行整体下移一行 for (int row y; row 0; row--) { System.arraycopy(grid[row - 1], 0, grid[row], 0, COLS); } Arrays.fill(grid[0], false); // 最顶行清空 cleared; y; // 关键下移之后这一行是新内容必须重新检查 } return cleared; }y配合 for 循环的y--效果是原地重扫当前行。不写这一句一次四行连消只会计到两行因为下移后的新内容被跳过了。System.arraycopy的五个参数依次是源数组、源起始位置、目标数组、目标起始位置、拷贝长度这里整行拷贝所以是0和COLS。手写 for 循环逐格赋值也行但拷贝方向必须从下往上反过来会把上面的行覆盖掉。计分按一次消掉的行数分档常见的一档配置是一次消除行数基础分说明1100单消2300双消性价比开始体现3500三消4800四消标准规则里的最高档实际得分再乘当前等级。有的实现额外加了连消奖励连续几次落子都消行这部分属于玩法扩展源码里没有也正常想加就在ScoreBoard里记一个计数器落地未消行就归零。4.3 重力曲线按毫秒算别按帧算老式实现里常见“每 N 帧下落一格”的写法靠一个帧计数器累加。这在固定 60Hz 的机器上没问题但在高刷新率屏幕或者系统负载变化时就会失真表现为同样的等级在不同机器上下落速度不一样。用Timer.setDelay()直接控制毫秒间隔就没有这个问题等级下落间隔ms一格耗时感受1800慢够思考5520中等10170需要预判1580触底基本靠肌肉记忆升级的触发点一般挂在总消行数上比如每消 10 行升一级而不是每消一次升一级。用后者的话一次四消直接跳四级速度曲线会突然变得不可控玩起来像被坑了一样。改这段的时候建议把等级和延迟做成可配置的常量数组而不是写死公式方便你按自己的手感调。5. 避坑与排查六个让源码跑不对的现场5.1 窗口出来了但是一片纯色方块不显示现象程序能启动窗口大小正常键盘也有反应能听到自己心里的音效但画面上什么都看不到。原因面板的首选尺寸是 0×0。JFrame.add(panel)之后没有调用pack()或者面板没有重写getPreferredSize()布局管理器就把面板压成了零高度。解决在面板里返回固定尺寸或者直接在窗口上设大小。两种都行别同时用// 方式一面板自己报尺寸 Override public Dimension getPreferredSize() { return new Dimension(COLS * CELL, ROWS * CELL); } // 然后 frame.pack(); // 方式二窗口写死 frame.setSize(COLS * CELL 16, ROWS * CELL 39); // 补上边框和标题栏5.2 键盘完全没反应鼠标点一下才行现象启动后方块自己往下掉按方向键毫无响应用鼠标点一下游戏区域之后突然就好了。原因JPanel默认不可获得焦点键盘事件被窗口里的其他组件吃掉了。用KeyListener时这个问题尤其明显因为它依赖组件持有焦点。解决优先改用键绑定Key Binding它不依赖焦点只要窗口在前台就能触发// 用 InputMap ActionMap 替代 KeyListener避免焦点问题 InputMap im getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW); ActionMap am getActionMap(); im.put(KeyStroke.getKeyStroke(LEFT), moveLeft); am.put(moveLeft, new AbstractAction() { Override public void actionPerformed(ActionEvent e) { if (running !paused) { board.move(-1, 0); repaint(); } } }); // 右移、下移、旋转、硬降同理如果一定要保留KeyListener那就补上setFocusable(true)和requestFocusInWindow()并且注意后者要在窗口setVisible(true)之后调用才有效。5.3 方块能穿墙或者贴着墙就转不动现象按住左键方块一路滑出画面左边再也回不来或者方块靠墙时按旋转键完全没反应。原因碰撞检测里只比较了x cellX COLS漏了 0这一侧旋转则根本没实现踢墙撞了就放弃。解决把边界检查写全然后按 4.1 的偏移表补上踢墙。检查函数建议单独抽出来所有移动、旋转、生成都走同一个入口避免某处漏判private boolean collides(Tetromino p, int rot, int px, int py) { for (int[] c : p.cells[rot]) { int gx px c[0], gy py c[1]; if (gx 0 || gx COLS) return true; // 左右越界 if (gy ROWS) return true; // 触底 if (gy 0 grid[gy][gx]) return true; // 与已固化格重叠 } return false; }注意gy 0那个判断允许方块在顶部以上存在负 y但不能拿负下标去访问数组否则直接抛数组越界异常。5.4 消行之后上方方块错位或凭空多出一行现象消掉一行之后上面几行的内容整体下移了但最顶上多出一行不该存在的方块或者原本对齐的图案错开一格。原因行拷贝用了System.arraycopy但源和目标偏移写反或者从下往上拷变成了从上往下拷把还没读的数据覆盖掉了。解决先把拷贝方向想清楚——要让row得到row-1的内容就必须从下往上遍历。另外每次拷贝完记得把第 0 行清空否则最顶行会残留上一轮的数据。调试时可以在消行前后各打印一次网格的字符串表示对比一眼就能看出是哪一行出的问题。5.5 编译报“找不到符号”但类明明写了现象IDE 里不报错命令行javac一堆cannot find symbol指到的都是自己写的类。原因package声明和目录结构不匹配或者用-d指定输出目录后运行时-cp指到了错误层级。解决find . -name *.java列一遍路径逐个核对包名。com.example.tetris必须位于com/example/tetris/下一字不差。运行时-cp指向的应该是包名路径的上一级。5.6 分数暴涨、等级乱跳、下落速度失控现象随便消几行分数就上万或者等级一路往上飙方块下落快到看不清。原因多半是level根据“本次消除行数”自增而不是根据累计行数计算也可能是gravityDelay没有下限等级高了之后延迟变成 0 甚至负数Timer直接以最高频率触发。解决等级统一从累计消行数推导别用自增public int level() { return Math.min(20, 1 totalLines / 10); // 每 10 行升一级封顶 20 } private int gravityDelay(int level) { return Math.max(80, 800 - (level - 1) * 50); // 硬下限 80ms防止归零 }封顶和下限这两个保护看着多余实际是唯一能防止程序在异常输入下彻底卡死的手段。6. 二次开发Hold、幽灵方块与一局可复现的回放验证源码能吃透之后最有价值的改造是加一个“暂存”Hold功能和一个幽灵方块这两个改动能让你真正理解状态机怎么扩展。Hold 的关键是引入一个held字段和一个canHold布尔量。按下暂存键时如果held为空就把当前方块存进去并生成新的如果不为空就交换当前方块和held无论哪种都把canHold置为false直到下一次lockPiece()才恢复。少了这个标志位玩家可以反复按暂存键让方块无限期不下落直接破坏游戏平衡。幽灵方块更简单本质是一次“预演下落”从当前坐标开始循环调用碰撞检测往下探记下最后一个合法位置画的时候用半透明色填充即可。// 找到当前方块的落地位置只用于绘制不改真实状态 int ghostY y; while (!collides(piece, rotation, x, ghostY 1)) { ghostY; } drawPiece(g, piece, x, ghostY, new Color(255, 255, 255, 60)); // 半透明改完之后怎么确认没改坏我的做法是加一个固定种子的回放把Random换成new Random(20240101L)然后把每一局的按键序列和方块序列打印成一行文本。同一份种子加同一串按键跑出来的终局分数必须完全一致。这个方法比手动玩一百遍靠谱得多尤其是在调重力曲线和踢墙表的时候——人的手感会骗你回放不会。血泪经验是每次改完核心逻辑别急着测新功能先把回放跑一遍。我吃过一次亏加幽灵方块时顺手“优化”了collides结果把顶部边界判断删了正常玩看不出来只有回放里那种连续贴顶旋转的序列才会触发数组越界。从那以后我每次动Board里的任何一行都强制走一遍固定种子回放对不上就先回退再查。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →