资讯动态

Java手搓俄罗斯方块源码解析:Swing主循环与碰撞旋转机制

发布时间:2026/10/29 15:04:36 来源:尧图企业网站定制
简介这份俄罗斯方块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 只需要 2x2T/S/Z/J/L 是 3x3I 是 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; } }颜色值用的0xAARRGGBBAlpha 位固定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 行Tetris800 分再乘以当前等级。等级通常由已消行数除以 10 得到等级越高重力间隔越短。这个公式的妙处在于 4 行连消的收益远超 4 次单行800 对 400鼓励玩家堆叠起来一次清这也是俄罗斯方块最核心的策略深度来源。改分数的时候注意别破坏这个激励结构。3.5 重力节奏Timer 驱动的下落与速度表整个游戏的时间推进就靠一个javax.swing.Timerprivate 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()但这仍然脆弱——用户点一下别的地方焦点又跑了。更稳的方案是InputMapActionMap把按键绑定到窗口级别// 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玩起来手感完全不一样。追求手感就得自己做 DASDelayed Auto Shift首次重复延迟和 ARRAuto 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其实是原版本来就有问题」的冤枉路。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑