简介:这份俄罗斯方块Java源码包面向Java初学者与游戏编程入门者,提供一套可直接导入IDE运行的经典小游戏工程,用于理解桌面游戏从界面绘制到逻辑控制的完整实现。压缩包共38个文件、约170KB,以18张png图像素材、14个class编译产物和3个java源文件为主,另含项目配置项,源码与编译结果、资源文件分层存放,便于对照阅读和复用开发环境配置。已有2785人学习下载,说明其在同类入门项目中具有一定参考价值。工程围绕游戏面板、方块形状、流程控制和程序入口等核心类组织,覆盖游戏循环、方块随机生成与下落、旋转移动、边界及堆叠碰撞检测、满行消除与得分更新、结束条件判断和键盘交互等关键环节,体现面向对象封装与模块协作思路,适合用来梳理Java小游戏的代码结构并在此基础上尝试功能扩展。
1. 一个用 Java 手搓的俄罗斯方块,为什么值得下载下来读一遍
很多人下载俄罗斯方块源码,第一反应是「就一个方块下落游戏,能有多少东西」。真把这份 Java 源码摊开读,七八百行的量级里塞着七种方块的坐标表、旋转修正、消行扫描、重力计时、键盘连发这些实打实的机制,每一块都能单独拎出来改成别的网格游戏。这份资源适合两类人:刚学完 Java 语法、想找个能编译能跑、不依赖数据库的完整小项目练手的;以及想给自己项目加方块网格渲染、或者研究游戏主循环节奏的老手。它不是教材,是能直接跑起来的骨架,改起来比读一百页教程快。
2. 项目跑通:目录结构、技术选型和第一次编译
2.1 目录结构与类的职责边界
先看骨架。这套源码的包结构不复杂,但分层意识是有的:UI 层只管画和收键,core 层只管规则和状态,两层之间靠一个 GameEngine 做中介。这个界限守住了,后面加功能才不会牵一发动全身。
tetris-java/ ├── pom.xml # 只依赖 JDK,无第三方包 └── src/main/java/com/example/tetris/ ├── TetrisApp.java # 入口,装配 JFrame ├── ui/ │ ├── GamePanel.java # 渲染 + 键盘 + 主循环 Timer │ ├── NextPiecePanel.java # 下一个方块预览 │ └── ScorePanel.java # 分数 / 等级 / 已消行数 ├── core/ │ ├── Board.java # 20x10 棋盘状态与碰撞判定 │ ├── Tetromino.java # 方块实例:类型 + 行 + 列 + 形状矩阵 │ ├── TetrominoType.java # 七种方块的初始形状表与配色 │ └── GameEngine.java # 消行、计分、等级、游戏状态机 └── util/ └── Constants.java # 格子像素、行列数、速度表职责划分的关键在于:Board完全不知道JPanel的存在,它只接受「某行某列是否被占」的查询;GamePanel也不实现任何游戏规则,它只做三件事——把Board的二维状态翻译成像素、把按键翻译成对GameEngine的方法调用、用一个javax.swing.Timer推动时间前进。这种拆法看起来啰嗦,但改起来极其舒服:想把终端版、控制台版、甚至服务端校验复用到同一套规则上,直接拿core包走人就行,一行 UI 代码都不用带。
Constants里通常就几个常量:COLS = 10、ROWS = 20、CELL = 30。其中CELL是每格边长,30 意味着棋盘面板的preferredSize是 300x600,加上侧栏的 150 宽,整个窗口大概 470x630。这个数值不是随便定的,它决定了后面所有坐标换算的基准,也是踩坑高发区,第 5 章会细说。
2.2 为什么是 Swing 而不是 JavaFX 或者游戏引擎
这个选型值得单独说一句,因为很多人读源码第一眼会问「都什么年代了还用 Swing」。放在这个项目场景里,Swing 反而是最合理的答案。
第一是零依赖。javax.swing从 JDK 1.2 就在标准库里,一份源码拷到任何装了 JDK 8 及以上的机器上,javac就能编译,不需要配 Maven 仓库、不需要下几百兆的运行时。对于「下载下来就想跑」的诉求,这是决定性优势。
第二是渲染模型简单。俄罗斯方块本质是一张 10x20 的格子图,每帧全量重绘也就 200 个矩形,性能开销可以忽略。Swing 的paintComponent加内置双缓冲,足够撑住这个量级,完全用不上 JavaFX 的 Scene Graph 和硬件加速。引入 JavaFX 反而要面对模块化配置、--module-path参数、打包jlink这些额外麻烦。
第三是事件模型的确定性。Swing 的所有 UI 操作强制在事件分发线程(EDT)上执行,游戏 Timer 的回调也在 EDT 里跑,这意味着渲染和状态更新天然串行,不用加锁。代价是任何阻塞操作都会卡界面,但俄罗斯方块没有 IO,这个代价等于零。
入口类的写法基本是这个套路:
public final class TetrisApp { public static void main(String[] args) { // Swing 的组件创建与修改必须落在事件分发线程,否则行为未定义 SwingUtilities.invokeLater(() -> { JFrame frame = new JFrame("Tetris"); frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); frame.setResizable(false); // 关掉缩放,避免格子像素错位 GamePanel gamePanel = new GamePanel(); frame.add(gamePanel, BorderLayout.CENTER); frame.add(new ScorePanel(gamePanel.getEngine()), BorderLayout.EAST); frame.pack(); // 依据各组件的 preferredSize 收缩窗口 frame.setLocationRelativeTo(null); // 屏幕居中,null 表示相对屏幕而非父组件 frame.setVisible(true); gamePanel.start(); // 启动 Timer,游戏从这里开始跑 }); } }几个参数值得留意。invokeLater把整段构建逻辑丢进 EDT 队列,这是 Swing 的铁律,漏了会在某些平台上出现组件闪烁或者干脆不显示。setResizable(false)必须放在pack()之前,否则窗口可能先按缩放态布局一次。pack()依赖每个子面板重写getPreferredSize()返回正确尺寸,如果GamePanel忘了重写,窗口会被压成一条线,这是新手最常见的第一个翻车点。gamePanel.start()放在setVisible(true)之后,是为了让第一帧的绘制和 Timer 的启动顺序确定,避免首帧空白。
2.3 从源码到可运行程序的三种方式
拿到源码后,跑起来有三条路,按环境干净程度选。
最直接的是原生javac,适合只想看一眼效果的场景:
# -d 指定 class 输出根目录,javac 会自动按包名创建子目录 javac -d out $(find src/main/java -name "*.java") # -cp 指向的是 class 文件根目录(不是源码目录),主类必须写全限定名 java -cp out com.example.tetris.TetrisApp这里有两个高频错误。一是把-cp指向src/main/java,那是源码路径,JVM 找不到.class文件,会报Could not find or load main class。二是主类只写TetrisApp,漏掉包名,同样找不到。find在 Windows 的 Git Bash 里能跑,纯 CMD 环境请换成 PowerShell 的Get-ChildItem -Recurse -Filter *.java。
第二条路是 Maven,适合要二次开发、需要 IDE 索引和依赖管理的场景:
mvn -q clean package java -jar target/tetris-java-1.0.jar打成可执行 jar 需要在pom.xml里给maven-jar-plugin配manifest的mainClass,否则java -jar会报no main manifest attribute。这一步源码里通常已经配好,但如果你改了包名或者主类名,记得同步改这里。
第三条路是直接在 IntelliJ IDEA 或 Eclipse 里打开项目根目录,等索引建完,右键TetrisApp运行。IDE 会自动处理 classpath,出错信息也更友好。我一般推荐新手先走这条路把游戏跑起来,确认环境没问题,再去折腾命令行,这样能把「代码问题」和「环境问题」分开排查,省掉很多无谓的纠结。
3. 核心逻辑:七种方块的坐标表示、旋转和消行
3.1 棋盘的数据结构:二维数组与越界即碰撞
棋盘就是一个int[20][10],0 表示空,非 0 表示已被固定方块占据,值本身存的是颜色索引或者 ARGB。这个设计省掉了额外的「已占用」布尔数组,也让渲染时直接读格子颜色,一次遍历搞定。
public final class Board { public static final int COLS = 10; public static final int ROWS = 20; // cells[row][col],row 0 是顶部,row 19 是底部 private final int[][] cells = new int[ROWS][COLS]; /** 越界一律视为已占用,把边界检测和碰撞检测合并成一次判断 */ public boolean isOccupied(int row, int col) { if (row < 0 || row >= ROWS || col < 0 || col >= COLS) { return true; } return cells[row][col] != 0; } }isOccupied里越界返回true这个写法是整个碰撞系统的基础。因为方块在尝试左移、右移、旋转时,形状矩阵里总有一部分坐标会落到棋盘外,如果每次都先判断范围再判断占用,代码会写成一堆嵌套 if。把「墙」和「已占格」统一成同一种语义,碰撞检测就退化成一个二重循环,清爽得多。
需要提醒的是,row < 0也返回true,这意味着方块不能从棋盘上方「进入」。有些实现允许方块出生在row = -2的位置然后逐格下落,那种写法就需要对负数行做特殊放行。这份源码选择让方块直接出生在row = 0,视觉上会看到方块凭空出现在顶部,但逻辑更干净,不需要处理负索引。
3.2 七种方块的形状表与颜色定义
七种方块(I、O、T、S、Z、J、L)的形状在TetrominoType枚举里硬编码成二维矩阵。这里有个细节容易被忽略:矩阵边长不一致。O 只需要 2x2,T/S/Z/J/L 是 3x3,I 是 4x4。为什么统一用方阵?因为旋转算法要对矩阵做转置,非方阵转置后尺寸会变,后续的行列偏移全乱套。
public enum TetrominoType { I(new int[][]{{0,0,0,0}, {1,1,1,1}, {0,0,0,0}, {0,0,0,0}}, 0xFF00F0F0), O(new int[][]{{1,1}, {1,1}}, 0xFFF0F000), T(new int[][]{{0,1,0}, {1,1,1}, {0,0,0}}, 0xFFA000F0), S(new int[][]{{0,1,1}, {1,1,0}, {0,0,0}}, 0xFF00F000), Z(new int[][]{{1,1,0}, {0,1,1}, {0,0,0}}, 0xFFF00000), J(new int[][]{{1,0,0}, {1,1,1}, {0,0,0}}, 0xFF0000F0), L(new int[][]{{0,0,1}, {1,1,1}, {0,0,0}}, 0xFFF0A000); public final int[][] shape; // 1 表示实心格,0 表示空白 public final int rgb; // 0xAARRGGBB 格式,A 固定 FF 不透明 TetrominoType(int[][] shape, int rgb) { this.shape = shape; this.rgb = rgb; } }颜色值用的0xAARRGGBB,Alpha 位固定FF表示完全不透明。渲染时用new Color(rgb, true)解析,Swing 直接认这个格式。选这套配色是因为它在深色背景(0x1A1A1A)上对比度足够,七个色相也拉得开,不会出现两个方块看起来一样的情况。
形状矩阵里那些全 0 的行列不是浪费。以 T 型为例,3x3 矩阵的第一行是{0,1,0}、第三行全 0,看起来第三行纯属占位。但如果去掉它改成 2x3,旋转后的形状就对不上了——SRS(超级旋转系统)的四种旋转态本质上是围绕 3x3 中心格旋转的,矩阵必须是方的才能用统一的转置算法。这一点在改造时特别重要,如果你打算加自定义方块,先保证形状矩阵是N x N。
3.3 旋转的实现:矩阵转置加反向,以及墙踢回退
旋转的核心三行代码:
/** 顺时针旋转 90 度:新矩阵的 [c][n-1-r] 等于原矩阵的 [r][c] */ public static int[][] rotateClockwise(int[][] m) { int n = m.length; int[][] out = new int[n][n]; for (int r = 0; r < n; r++) { for (int c = 0; c < n; c++) { out[c][n - 1 - r] = m[r][c]; } } return out; }推导过程就是「第 r 行变成第 n-1-r 列」。以 3x3 为例,原矩阵第 0 行的三个元素,旋转后分别落在新矩阵第 2 列的第 0、1、2 行。这个映射写死之后,逆时针旋转只要把公式换成out[n-1-c][r] = m[r][c],不需要再写一套新逻辑。
但光有旋转不够,因为方块贴着墙或者贴着已固定的方块时,一旋转就会撞上。真实游戏里这一步叫「墙踢」(Wall Kick),标准实现有一张复杂的偏移表,简化版通常只试几个水平偏移:
/** 尝试旋转,失败返回 false;成功则就地修改方块状态 */ public boolean tryRotate() { int[][] rotated = rotateClockwise(piece.shape()); // 偏移尝试顺序很关键:先试原位,再试左右各一格,最后试两格 int[] kicks = {0, -1, 1, -2, 2}; for (int dx : kicks) { if (!collides(rotated, piece.row(), piece.col() + dx)) { piece.setShape(rotated); piece.setCol(piece.col() + dx); return true; } } return false; }kicks数组的顺序决定了「手感」。0排第一,能原位旋转就绝不移动,这符合玩家直觉。-1在1前面,是因为贴左墙的情况比贴右墙更常见——方块从顶部左侧生成,玩家习惯往左堆。-2和2只对 I 型有意义,因为 I 型横竖切换时占用宽度从 4 变 1,需要两格偏移才能贴墙旋转成功。如果要更贴近标准 SRS,可以把这个数组换成「偏移向量对」,同时考虑上下移动,实现量会翻倍,但对普通玩家来说手感提升有限。
3.4 消行扫描与计分公式
消行这段代码短,但坑最多,是典型的一行 bug 能让整个游戏卡死或者少消一行的地方:
/** 从下往上扫描,消掉所有满行,返回本次消掉的行数 */ public int clearFullRows() { int cleared = 0; for (int r = ROWS - 1; r >= 0; r--) { if (isRowFull(r)) { removeRow(r); // 把 r 以上的所有行整体下移一格 r++; // 关键:删除后 r 位置是一行新的、尚未检查的数据 cleared++; } } return cleared; } private void removeRow(int row) { for (int r = row; r > 0; r--) { System.arraycopy(cells[r - 1], 0, cells[r], 0, COLS); } Arrays.fill(cells[0], 0); // 顶部补一行空 }r++那一行不是笔误。假设第 18 行和第 17 行同时满,循环到 18 时删掉,原来的 17 行下移到 18 位置,如果不把r加回去,下一轮循环变成 17,检查的是已经上移的旧 16 行,原来的 17 行就被跳过了,这一行永远消不掉。很多实现里改用while循环加手动索引,逻辑更清晰,但for加r++是最紧凑的写法。
计分走的是经典公式:1 行 100 分、2 行 300 分、3 行 500 分、4 行(Tetris)800 分,再乘以当前等级。等级通常由已消行数除以 10 得到,等级越高,重力间隔越短。这个公式的妙处在于 4 行连消的收益远超 4 次单行(800 对 400),鼓励玩家堆叠起来一次清,这也是俄罗斯方块最核心的策略深度来源。改分数的时候注意别破坏这个激励结构。
3.5 重力节奏:Timer 驱动的下落与速度表
整个游戏的时间推进就靠一个javax.swing.Timer:
private void startTimer() { timer = new Timer(dropIntervalMs(), e -> { if (engine.isGameOver()) { timer.stop(); // 状态检查必须在最前面,否则结束后还会继续下落 return; } if (!engine.moveDown()) { // 下一格被占,说明落地了 engine.lockPiece(); // 固定到棋盘、消行、生成新块 if (engine.collidesCurrent()) { engine.setGameOver(true); } } repaint(); // 每 tick 只重绘一次,避免重复触发 }); timer.start(); } /** 等级越高间隔越短,上限 1000ms,下限 80ms 防止不可玩 */ private int dropIntervalMs() { int level = engine.getLevel(); return Math.max(80, 1000 - (level - 1) * 80); }dropIntervalMs用的是线性递减,从 1000ms 起步,每升一级减 80ms,到 12 级左右触底 80ms。也有实现用指数衰减(比如1000 * Math.pow(0.85, level)),前期减速快、后期平稳,手感更接近商业版本。想调难度曲线,改这一个方法就够了,不用碰其他任何地方。
moveDown返回布尔值这个设计值得学一下:它既表示「移动成功」,又能驱动「落地锁定」的分支,一个返回值干了两件事,调用处不需要额外查询。lockPiece内部依次做四件事——把当前方块写入Board、调用clearFullRows、按返回值累加分数、从队列里取下一个方块重新出生。这个顺序不能乱,先写棋盘再消行,否则消行时看不到刚落下的方块。
4. 渲染与输入:paintComponent 的重绘链路和手感调参
4.1 棋盘坐标到屏幕像素的映射
渲染就是把Board的二维状态翻译成矩形。整个paintComponent只做三件事:铺背景、画已固定的格子、画当前下落块。
@Override protected void paintComponent(Graphics g) { super.paintComponent(g); // 必须先调用,清掉上一帧 Graphics2D g2 = (Graphics2D) g; // 棋盘底色 g2.setColor(new Color(0x1A1A1A)); g2.fillRect(0, 0, COLS * CELL, ROWS * CELL); // 已固定方块:直接读 Board 里存的颜色值 for (int r = 0; r < ROWS; r++) { for (int c = 0; c < COLS; c++) { int rgb = board.cell(r, c); if (rgb != 0) { drawCell(g2, c * CELL, r * CELL, rgb); } } } // 当前下落块:行列为负时跳过,避免画出界 int[][] shape = piece.shape(); for (int r = 0; r < shape.length; r++) { for (int c = 0; c < shape[r].length; c++) { if (shape[r][c] == 1) { int px = (piece.col() + c) * CELL; int py = (piece.row() + r) * CELL; if (py + CELL > 0) { // 只画露出棋盘顶的部分 drawCell(g2, px, py, piece.type().rgb); } } } } }CELL = 30这个常量在这里贯穿全场:格子的逻辑坐标(row, col)乘以 30 得到像素坐标,面板的preferredSize也必须是COLS * CELL和ROWS * CELL,三处必须一致。改格子大小只改CELL一个地方,如果哪处写死了 30,就是埋雷。
drawCell里通常还会画一个比外框小 1 像素的内矩形,形成网格线效果;高级一点会画高光边(左上角亮、右下角暗),让方块有立体感。这些是纯视觉润色,不影响逻辑,但对「看起来像个游戏」这件事影响很大。
4.2 双缓冲与 repaint 的调用时机
Swing 的JPanel默认doubleBuffered = true,也就是说paintComponent画的内容先进内存图像,再一次性刷到屏幕,不会出现「画到一半被看到」的撕裂。这一点不需要自己实现BufferedImage,但要知道它的存在,因为如果你在paintComponent里做了耗时操作(比如每帧重新计算方块形状),仍然会卡,双缓冲只解决闪烁不解决性能。
repaint()不是立刻重绘,它只是向 EDT 提交一个重绘请求,多个请求会被合并成一次。所以在一个 Timer tick 里调用一次就够了,不需要在moveLeft、moveRight、rotate的每个方法里都调。这份源码的做法是在 Timer 回调末尾统一调一次,键盘操作只改状态不调repaint——但如果键盘操作不触发 Timer(比如暂停状态下移动),就会看到状态变了画面没变。稳妥的写法是在按键 Action 里也调一次repaint(),代价可以忽略。
另外一个常见误区是用Thread.sleep加while(true)做主循环。那样写会阻塞 EDT,界面直接假死。Swing 的正确姿势永远是javax.swing.Timer或者SwingWorker,前者用于周期性任务,后者用于一次性后台计算。
4.3 KeyListener 的坑与 InputMap/ActionMap 的替代方案
很多入门实现用addKeyListener,然后被「按键没反应」折磨半天。原因是KeyListener要求组件有焦点,而焦点可能被侧栏面板抢走,或者窗口刚打开时焦点还没落到游戏面板上。修法是在GamePanel构造里加setFocusable(true)和requestFocusInWindow(),但这仍然脆弱——用户点一下别的地方,焦点又跑了。
更稳的方案是InputMap+ActionMap,把按键绑定到窗口级别:
// WHEN_IN_FOCUSED_WINDOW 表示只要窗口有焦点就响应,不要求面板自身有焦点 getInputMap(WHEN_IN_FOCUSED_WINDOW).put(KeyStroke.getKeyStroke("LEFT"), "moveLeft"); getActionMap().put("moveLeft", new AbstractAction() { @Override public void actionPerformed(ActionEvent e) { engine.moveLeft(); repaint(); } });这套机制的好处是键位集中配置、不依赖焦点、也容易做成可重映射的。缺点是它只处理「按下」事件,长按的连发要靠操作系统的键盘重复,而系统的重复速率是玄学——不同操作系统、不同键盘设置下差异巨大,有的延迟 500ms 才开始重复,有的重复间隔只有 30ms,玩起来手感完全不一样。
追求手感就得自己做 DAS(Delayed Auto Shift,首次重复延迟)和 ARR(Auto Repeat Rate,重复间隔)。思路是:按下左键时记录「左键处于按下状态」,启动一个短周期 Timer(比如 16ms),在 Timer 里判断如果按下时间超过 DAS(常见 150ms),就按 ARR(常见 50ms)的节奏持续左移。这套逻辑放在GamePanel里大约三十行,是「能玩」和「好玩」之间的分水岭,值得花时间补上。
5. 避坑排查:跑起来之后最容易翻车的五个点
5.1 现象、原因与解决
方块贴底后不消失,越堆越高直到溢出。现象是方块落到最底部就停住不动,新方块继续生成,几轮之后棋盘被塞满。原因通常是lockPiece里的坐标写错了——用了旋转前的行列值,或者把「当前方块写入棋盘」和「生成新方块」的顺序搞反,新方块覆盖了刚落下的。解决方式是在lockPiece开头先把piece.row()和piece.col()存到局部变量,写入棋盘时只读局部变量,写完再重置piece。
消行之后总有一行残留。这是最经典的索引 bug,前面 3.4 节已经提过。现象是明明看到两行填满,只消掉一行,另一行留在原地。原因是从下往上遍历时删除当前行,上面的行整体下移,下一轮循环的索引已经指向了被移下来的那一行之前的位置。解决就是removeRow后把循环索引加回来,或者改用while手动控制索引。
窗口最大化或者拖动边框后,格子被拉长成矩形。现象是方块变成竖条或者横条,比例完全不对。原因有两个层面:一是面板的preferredSize返回固定值但窗口被缩放,Swing 会拉伸绘制区域;二是绘制时用了getWidth() / COLS动态算格子大小,而宽高比不一致导致横竖格子尺寸不同。解决要么在JFrame上setResizable(false),要么在paintComponent里算出cell = Math.min(getWidth() / COLS, getHeight() / ROWS),并居中偏移。前者简单,后者更灵活。
游戏结束后画面还在刷新,或者分数还在涨。现象是 Game Over 之后按方向键还能移动,或者 Timer 还在 tick。原因是状态检查漏了,moveDown和各个按键处理里没有判断isGameOver()。解决是把状态判断收口到GameEngine的每个公开方法入口,游戏结束时直接return,同时在 Timer 回调第一行timer.stop()。这类 bug 不难修,难在想到——因为正常玩到 Game Over 之前不会触发,测试时容易漏。
编译通过但运行报NoClassDefFoundError或者版本不匹配。现象是javac一切正常,java一跑就崩。多半是-cp指向错误,或者源码用了 JDK 11 的语法(比如var)但运行在 JDK 8 上。解决办法是先java -version确认版本,再用javac -source 8 -target 8强制编译期报错。用 Maven 的话,在pom.xml的maven-compiler-plugin里锁死source和target,避免 IDE 改了配置导致命令行构建失败。
提示:这五个问题里,前三个是逻辑 bug,后两个是环境或状态管理问题。排查顺序建议先看异常堆栈,再看状态,最后怀疑环境——大部分人反过来做,浪费很多时间。
5.2 加一个可开关的调试叠加层
逻辑 bug 光靠看屏幕很难定位,加一层调试渲染能省掉大量猜测。在GamePanel里加一个布尔开关,按 F1 切换,开启时把方块的逻辑坐标、当前 tick 序号、消行计数直接画在屏幕角落:
private boolean debug = false; // 在 paintComponent 末尾追加 if (debug) { g2.setColor(Color.YELLOW); g2.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12)); g2.drawString(String.format("r=%d c=%d type=%s", piece.row(), piece.col(), piece.type().name()), 8, 16); g2.drawString(String.format("level=%d lines=%d score=%d", engine.getLevel(), engine.getLines(), engine.getScore()), 8, 32); }String.format的格式串里%d对应整型、%s对应字符串,name()返回枚举常量名。这个叠加层不影响任何游戏逻辑,发布前把默认值设成false就行。我习惯在调试生命周期的任何项目里都留这么个口子,比每改一次都加System.out.println然后翻控制台强得多。
6. 二次开发:从单机 Demo 到带落点评估的版本
这套源码最值钱的地方在于它是「可拆的」。三个方向的改造投入产出比最高,按难度排:
| 改造方向 | 主要动的地方 | 工作量 | 效果 |
|---|---|---|---|
| 加 AI 自动落点 | GameEngine+ 新增AiPlayer | 半天到一天 | 能看它自己玩,方便验证规则 |
| 加暂停 / 重开 / 最高分 | GamePanel+ 持久化一行 | 两三小时 | 立刻像个完整游戏 |
| 改成双人对战 | Board拆成两个 + 输入分流 | 一到两天 | 可玩性翻倍 |
AI 落点评估是最推荐的练手项目,因为它逼你把游戏规则吃透。思路是枚举当前方块的所有旋转态和所有水平落点(最多 4 x 10 = 40 种),对每种情况模拟落到底,用启发式函数打分,选最高分的那个执行。评估函数通常是四项加权:
/** 分数越高越好:奖励消行,重罚空洞,轻微惩罚高度和表面起伏 */ private int evaluate(int[][] grid) { return clearedLines(grid) * 100 // 消行是主要收益 - countHoles(grid) * 12 // 空洞(下方有格、上方为空)最难补 - maxHeight(grid) * 2 // 堆太高容易死 - roughness(grid) * 1; // 相邻列高度差,越大越难平 }countHoles的统计方式是从每一列往下扫,遇到第一个实心格之后,下面所有的空格都算空洞。权重可以调,但要注意一个约束:countHoles的权重必须显著大于clearedLines的收益,否则 AI 会为了消一行而挖出一堆洞,很快就死。我试过把空洞权重降到 5,结果 AI 活得比随机落子还短,血泪经验就是这四个数的比例比绝对值重要得多。
改对战版本的坑在Board的职责。现在Board只管一个棋盘,改成两个之后,GameEngine要同时持有玩家 A 和 B 的棋盘,clearFullRows产生的「攻击行」需要塞到对方的底部并清空一行。这里的边界情况特别多——攻击行会不会直接让对方溢出、双方同时消行怎么算、暂停时两边的 Timer 怎么同步。我的建议是先把单机版的所有 bug 清干净再动手,否则两边的问题会混在一起,排查成本翻倍。
从那以后我每次拿一份小游戏源码做二次开发,都强制先跑通一次原版、手动玩到 Game Over、确认分数和消行都正常,再动第一行代码。这个习惯帮我避开了好几次「以为是自己的 bug,其实是原版本来就有问题」的冤枉路。希望帮到你。
本文还有配套的精品资源,点击获取