首页 / 资讯中心 / 文章详情

纯Java Swing实现俄罗斯方块:从数据结构到碰撞检测的完整实战

纯Java Swing实现俄罗斯方块:从数据结构到碰撞检测的完整实战 ★ FEATURED ARTICLE
先说个很多Java新手会问的问题学完基础语法之后到底做什么项目才算“练到手了”。我的答案一直是俄罗斯方块。它没有复杂业务流也没有高深算法但数据怎么设计、状态怎么管理、界面怎么刷新、碰撞怎么判定每一步都能让初学者实打实地踩一遍坑。这个项目是经典中的经典我这次为了完成一个阶段性的创作打卡就用纯Java和Swing从零到一实现了一遍没有引入任何第三方依赖。这篇文章把完整实现思路、核心代码逻辑和我实测中踩过的坑都摊开讲适合有一定Java基础——理解类、数组、继承、接口但还没独立完成过小游戏的人来读。1. 动手之前先把俄罗斯方块拆成四个独立模块很多人写小游戏容易一上来就写代码结果写到一半发现逻辑全缠在一起改一行坏三行。俄罗斯方块看起来只是“方块下落然后消行”但真正实现时至少要拆出四件事方块本身怎么定义、游戏网格怎么存储、下落和碰撞怎么控制、界面怎么刷新。这四件事彼此独立才能让代码可读、可测、可扩展。1.1 游戏规则先翻译成对象先把规则翻译成代码模型。俄罗斯方块的标准规则可以归纳为下面几点游戏区域是一张固定大小的网格通常 10 列 x 20 行我按这个尺寸来。方块一共有 7 种分别是 I、O、T、S、Z、J、L每种形状都由 4 个小方格组成可以原地旋转。方块会随时间自动下落玩家可以左右移动、旋转、加速下落、直接落底。方块落到底部或被已有方块挡住时会“粘”在网格上成为场上的固定方块。网格中任意一行填满时这一行消失上方所有行整体下移一行。新方块在出生点生成如果出生点已经无法放置游戏结束。这段翻译看起来简单但它直接决定了后面所有类的职责边界。比如“方块”和“网格”必须是两个不同的东西——网格是场地状态方块是正在运动的形状两者不能混在一个数组里。1.2 技术选型为什么用 Swing 而不是 JavaFX 或控制台这个问题几乎每个做俄罗斯方块的人都会纠结一下。我的结论是教学项目选 Swing没有悬念。控制台版本当然也能做但旋转、消行、动画全都看不到直观效果调试起来非常痛苦。JavaFX 功能更强还自带场景图、CSS 样式、动画时间线但新手会被 FXML、Stage、Scene 这些概念绕进去反而不利于把注意力放在游戏逻辑上。Swing 是 JDK 自带的图形界面工具包只需要一个 JFrame 和 JPanel重写 paintComponent 方法就能画出整个游戏界面。最终我选择了纯 Swing 方案整个项目只有标准库任何人拿到源码都能直接编译运行无需配置额外环境这对上手来说是最友好的。1.3 类结构划分一屏看懂我最终把代码拆成四个类类名职责Main 入口类创建窗口、装配各组件、启动游戏GamePanel 游戏面板游戏主循环、碰撞检测、消行、计分、绘制画面Tetromino 方块类定义 7 种形状、旋转逻辑、当前坐标SidePanel 侧边面板绘制下一个方块预览、分数、等级、操作提示这个划分不是拍脑袋的。GamePanel 里放的是“游戏规则”Tetromino 里放的是“形状数据”SidePanel 里放的是“附加信息展示”三者解耦之后比如想换一套计分规则只需要改 GamePanel 里的一个方法完全不需要碰方块类。这也算是我在这个项目里提前尝到的“接口隔离”甜头。2. 方块的数据结构设计二维数组与旋转的正确姿势方块是俄罗斯方块里最核心的数据结构没有之一。7 种形状看起来简单但它们的表示方式直接决定了旋转算法、碰撞检测、渲染代码的复杂度。我把这部分单独拎出来讲因为它就是整个项目的分水岭。2.1 用二维数组表示形状每块都是一个小矩阵最直观的方式是用二维数组表示每一种形状。我这里用 int 数组而不是 char 或 boolean因为 0 表示空、1 表示有格子后面渲染时可以直接用这个值去映射颜色。public class Tetromino { public static final int[][][] SHAPES { {{1, 1, 1, 1}}, // I {{1, 1}, {1, 1}}, // O {{0, 1, 0}, {1, 1, 1}}, // T {{0, 1, 1}, {1, 1, 0}}, // S {{1, 1, 0}, {0, 1, 1}}, // Z {{1, 1, 1}, {1, 0, 0}}, // J {{1, 1, 1}, {0, 0, 1}} // L }; private int[][] shape; private int row; private int col; public Tetromino(int type, int startRow, int startCol) { this.shape SHAPES[type]; this.row startRow; this.col startCol; } }这里有一个细节所有形状的方块格数加起来都必须是 4因为俄罗斯方块的名字就叫“四格骨牌”。I 形我用了 1 行 4 列O 形用了 2 行 2 列其他大部分用 3 行 2 列或 2 行 3 列。为了后面旋转方便最好保持每种形状都是一个矩形数组——哪怕像 T 形那样第一行的中间有空格也要用 0 补齐让它成为一个完整的 3 行 3 列矩阵。2.2 旋转的数学原理转置矩阵加行反转矩阵旋转是有现成公式的。顺时针旋转 90 度只需要两步先把矩阵转置——也就是把行列互换再把每一行的元素顺序反过来。public void rotate() { int rows shape.length; int cols shape[0].length; int[][] rotated new int[cols][rows]; for (int r 0; r rows; r) { for (int c 0; c cols; c) { rotated[c][rows - 1 - r] shape[r][c]; } } shape rotated; }注意这段代码用的是rotated[c][rows - 1 - r] shape[r][c]这比“先转置再反转”在代码上更紧凑本质是一样的。这里要特别提醒旋转后行数和列数会互换所以不能原地修改 shape 数组必须先创建一个新的二维数组否则会把原来的数据覆盖掉。我之前见过有人把 7 种方块的每个旋转状态都手写进数组一共 28 个静态数组看起来没什么问题但一旦改动某个形状所有状态都要跟着重排特别容易出错。用矩阵旋转就没有这种烦恼任何形状都能统一处理这也是为什么我强烈推荐直接用矩阵思路。2.3 旋转后的边界修正踢墙机制的简单实现旋转本身不难难的是旋转后怎么处理越界和碰撞。比如 I 形横着占 4 列如果靠左边界旋转 90 度变成竖着占 4 行横向只有 1 列宽按理说不会越界但 J 形这种 3 行 2 列的方块旋转后变成 2 行 3 列如果原本贴墙旋转后有一部分格子就直接跑到网格外面去了。最简单的处理方式是“墙踢”。旋转成功后先按当前坐标检查合法性不合法就依次尝试把方块往左偏移 1 格、往右偏移 1 格甚至偏移 2 格只要有一个位置合法就接受。public boolean rotateIfLegal(int[][] board, int rows, int cols) { int[][] oldShape shape; rotate(); // 先旋转 int[] offsets {0, -1, 1, -2, 2}; for (int offset : offsets) { if (isLegalAt(board, row, col offset, rows, cols)) { col offset; return true; } } shape oldShape; // 都不合法就回滚 return false; }这里isLegalAt是一个核心方法不光是旋转要用移动和下落也全都要靠它。所以把碰撞检测抽成“给定一个棋盘状态和方块位置判断当前形状在这个位置是否合法”的单方法是整段代码里性价比最高的一件事。3. 游戏推进的核心下落控制、碰撞检测与锁定界面和数据结构都准备好之后就要面对整场游戏的发动机逻辑了。游戏和普通程序最大的区别就是普通程序是你点一下它动一下游戏是它自己一直在往前跑。俄罗斯方块里“一直往前跑”的那股劲就是下落控制。3.1 为什么不建议用 while 循环控制下落新手最容易踩的第一个坑就是拿一个 while(true) 循环负责方块下落。表面上看没问题写一段死循环每 500 毫秒让方块往下挪一行实际上运行起来窗口会直接卡死键盘操作完全没反应。原因是 Swing 界面是事件驱动模型所有绘制和键盘事件都要在事件分发线程EDT上执行。如果主线程被死循环占住了事件队列里的重绘和按键消息就永远得不到处理窗口自然就假死了。正确的做法是使用javax.swing.Timer它有一个很实用的特性每次触发事件时都在 EDT 线程上执行回调。Timer timer new Timer(500, e - { if (state State.PLAYING) { if (canMove(current, current.row 1, current.col)) { current.row; } else { lockToBoard(); clearFullRows(); spawnNextPiece(); } } repaint(); }); timer.start();这段代码几乎就是整个游戏主循环的全部。Timer 每隔 500 毫秒触发一次检查当前方块能不能往下走能走就继续走不能走就锁进网格、消行、生成下一个方块。3.2 碰撞检测把“能不能走”翻译成矩阵判断上面提到的isLegalAt方法我拆开给你们看public boolean isLegalAt(int[][] board, int newRow, int newCol, int rows, int cols) { for (int r 0; r shape.length; r) { for (int c 0; c shape[r].length; c) { if (shape[r][c] 0) { continue; // 空格跳过 } int boardRow newRow r; int boardCol newCol c; if (boardRow 0 || boardRow rows || boardCol 0 || boardCol cols) { return false; // 越界 } if (board[boardRow][boardCol] ! 0) { return false; // 撞到已锁定方块 } } } return true; }这个方法的逻辑非常朴素把方块形状的每一个非空格子映射到棋盘数组的行列坐标上逐个判断是否越界、是否撞到已有方块。整个俄罗斯方块的碰撞检测就这一件事所谓左移、右移、下移、旋转全部只是改变newRow、newCol或者旋转后的 shape再调用这个方法检查一下而已。搞清楚这一点就不会再有“每个方向都要写一遍碰撞检测”的重复代码了。3.3 锁定方块让活动方块变成固定方块当方块再往下移动一格就会不合法时就说明它“落定”了。此时要把当前方块的所有非空格子写入棋盘数组这一步叫锁定。private void lockToBoard() { for (int r 0; r current.shape.length; r) { for (int c 0; c current.shape[r].length; c) { if (current.shape[r][c] ! 0) { int boardRow current.row r; int boardCol current.col c; board[boardRow][boardCol] current.type 1; } } } }这里我把棋盘数组的元素设计成 int0 表示空格1 到 7 表示各个方块的形状编号渲染的时候可以直接通过这个数字找到对应颜色。锁定之后别忘了马上检查是否有满行然后生成新方块。如果生成的新方块一开始就无法放置那就到了游戏结束的时候。3.4 键盘控制软降、硬降与按键焦点问题控制部分我用了键盘监听键位映射如下方向键左 / A向左移动一格方向键右 / D向右移动一格方向键下 / S软降也就是加快下落方向键上 / W旋转空格硬降方块直接落底P暂停R重新开始写键盘监听时最容易出的问题是非法键——窗口没有焦点按键没反应。JFrame 创建出来之后默认焦点不一定在游戏面板上必须在游戏面板初始化时调用setFocusable(true)然后在窗口显示后调用一次gamePanel.requestFocusInWindow()保证键盘事件能被面板接收到。这个坑能浪费不少时间我一开始就是写完了逻辑但键盘没反应排查半天才发现是焦点问题。硬降的实现也很直接不断尝试向下移动直到不能动为止。为了保证视觉效果连贯硬降之后要立即把方块锁进棋盘并重新渲染不然画面会停顿一下才更新。4. 消行、计分与游戏结束判定游戏节奏的节拍器当方块一个个落定、堆叠起来之后俄罗斯方块的“爽感”——一次消四行——就要靠消行逻辑来实现了。这部分不复杂但细节很多尤其是连续多行消除时的处理顺序写错了会出现行对不齐的现象。4.1 消行的标准写法从下往上扫描消行逻辑可以拆成三步检查哪些行满格、把满行的行去掉、把上方所有行整体下移。第一步最容易理解第二步和第三步要注意顺序和方向。private void clearFullRows() { 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) { removeRow(row); row; // 因为下移了一行当前位置需要重新检查 score 100; } } } private void removeRow(int targetRow) { for (int row targetRow; row 0; row--) { board[row] board[row - 1].clone(); } board[0] new int[COLS]; }细节在于外层循环必须从下往上而且消掉一行之后要把当前索引加回去。因为整行下移之后原本在上一行位置的方块现在到了这一行如果跳过这个位置直接检查上一行就会漏掉连续消行的判定。另一种更优雅的做法是先统计所有需要消除的行号一次性处理好不修改循环计数器。但对于教学项目我建议先从这段直观代码开始理解透彻了再优化。4.2 计分规则与等级控制计分规则我沿用了经典积分表消 1 行得 100 分2 行 300 分3 行 500 分4 行 800 分。四行全消在游戏里叫“Tetris”是值得额外给奖励的。我这里只做基础版没有加连击奖励但把等级曲线做了每累计消除 10 行等级加 1方块下落间隔缩短一段。等级下落间隔(ms)150024503400435053006250最低限速实现时注意一个硬坑Timer 的 delay 修改后要调用timer.restart()才会立刻生效。很多新手改了timer.setDelay()发现速度没变化就是因为 Timer 内部的等待时间没有重置。4.3 游戏结束判定出生点被占用的那一瞬间游戏结束的判定时机放在生成新方块之后最合理如果新方块在出生位置不合法说明场上堆积的方块已经顶到了出生点游戏结束。出生点我选在网格顶部中间偏上的位置也就是第 0 行相对目标列的位置。状态管理用一个枚举或者常量变量控制我用的是public enum GameState { PLAYING, PAUSED, GAME_OVER }PLAYING 状态下 Timer 正常运行并响应按键PAUSED 状态下 Timer 暂停但界面保留GAME_OVER 状态弹出结束文本等待按 R 重新开始。把状态切换画在界面上是非常直观的做法比用弹窗更顺手因为弹窗会抢焦点干扰后续按键。5. Swing 界面渲染让游戏“看得见”的最后一公里前面所有逻辑都是后台数据没有画面玩家什么都看不见。渲染这层说难不难但有几个细节会影响体验我在实际开发中踩过不少坑。5.1 paintComponent 与双缓冲机制Swing 的 JPanel 默认是双缓冲的也就是说绘图过程会先在内存里的缓冲区完成再一次性提交到屏幕这能显著减少闪烁。我们只需要正确重写paintComponent(Graphics g)方法在里面绘制当前游戏状态。Override protected void paintComponent(Graphics g) { super.paintComponent(g); drawBoard(g); drawCurrentPiece(g); drawNextPiece(g); drawScoreAndLevel(g); }绘制游戏板时我最开始是把每个格子画成二维循环里的小矩形。后来发现直接把整个面板的背景画成暗色网格线再在已有方块的位置绘制彩色格子视觉效果更好代码结构也更清晰。一个关键点所有与状态相关的字段改变之后都要调用repaint()。repaint 不是立即绘制而是向事件队列发送一个重绘请求Swing 会在合适时机统一处理。如果忘了调用界面上就会一直显示旧画面容易让人误以为是逻辑没跑。5.2 游戏面板的整体布局设计我用了BorderLayout做主布局。中间是游戏面板GridBagLayout 或者直接自定义绘制右侧放了一个侧边面板用来展示下一个方块预览、分数、等级和操作提示。游戏网格的实际尺寸我是这样控制的每个格子固定 30 像素面板宽就是 10 x 30 300 像素高是 20 x 30 600 像素。用固定像素的好处是不用考虑缩放逻辑简单缺点是在高分屏下会显得有点小。做完这版之后也可以考虑根据屏幕 DPI 动态计算格子大小但基础版本没必要太早做这种优化。5.3 下一个方块预览与分数显示现在市面上的俄罗斯方块几乎都有“下一个方块”预览这个功能对提升体验帮助很大。预览面板我单独写了一个SidePanel类每帧绘制时从游戏面板读取下一个方块的形状然后绘制在一个 4x4 的小网格里。为了实现这个GamePanel 里就需要维护一个next字段在生成当前方块后提前生成下一个并在锁定当前方块后把 next 赋值给 current再重新生成 next。分数和等级我用drawString直接绘制在侧边面板上。字体选择上中文提示最好用new Font(微软雅黑, Font.BOLD, 14)或者用系统默认支持中文的字体否则在 Linux 环境下可能显示成乱码方块。6. 实测里遇到的几个经典 bug以及我是怎么排查的写完第一版跑起来那一刻只能说“形似而神不似”——方块能下落、能旋转但手感怪异bug 频出。这一部分我挑了几个典型的、对新手杀伤力最大的问题逐一复盘排查过程。6.1 旋转后方块“跳”得很远第一个诡异问题是 I 形旋转之后横着变竖着时位置会突然跳好几列。排查时我把旋转前后的坐标都打印出来发现是旋转基准点的问题。形状矩阵从二维数组{{1,1,1,1}}旋转成{{1},{1},{1},{1}}之后原本第 0 行的格子会落到第 0 列但因为形状的行列数变化视觉中心也跟着移动了。解决方法是统一把旋转基准点定在“方块矩阵的左上角”旋转后不试图修正视觉中心位置而是接受“形状会以左上角为锚点旋转”的事实。这样代码是确定性的碰撞检测也只依赖这个锚点不会出现旋转后位置漂移的诡异现象。6.2 按键偶尔失灵尤其是刚开局就按现象是游戏窗口刚弹出来马上按键没反应要点击一下窗口才正常。第一反应以为是 KeyListener 没注册好后来发现是焦点问题。Swing 默认焦点在第一个组件上而游戏面板未必拿到焦点。处理办法是前面说的setFocusable(true)加requestFocusInWindow()并且在 JFrame 的windowOpened事件里再次请求焦点双保险。6.3 等级上去后 Timer delay 没变化我把timer.setDelay(newDelay)写在等级变化的代码里但运行起来发现节奏根本没变。后来查 Swing Timer 文档才意识到setDelay 会设置内部等待时间但如果 Timer 正在等待触发新的延迟要等下一次触发后才会重新计算。所以要在设置 delay 后调用timer.restart()问题立刻解决。6.4 消行后上方方块直接丢了一部分有个 bug 让我很困惑消除一行之后上面的方块有时会少一行。后来打印棋盘数组才发现问题出在我的board[targetRow] board[row - 1]这行代码。我没有 clone直接把数组引用赋值过去导致原本上方的行和下方的行指向同一个数组对象之后再给该行填充值时另一个行也被连带修改了。数组是引用类型赋值数组变量不会复制数据。改掉这层引用问题之后消行动画就正常了。6.5 旋转判定“时灵时不灵”最后一个让我头疼的是旋转有时合法有时不合法看起来毫无规律。后来我发现是isLegalAt里一个边界判断写错了我在判断列边界时用了 cols但忘记限制col 0。当方块处于左边界附近旋转后向右偏移为正数时没问题一旦偏移为负数就直接越界报错但问题没有在画面里显现出来因为形状被回滚了。修正边界条件把左右边界都判断完整旋转逻辑才稳定下来。7. 写完这个项目之后我更推荐新手继续做这几个方向的扩展从我个人的体验来看俄罗斯方块这个项目最有价值的不是“最终做出来一个能玩的游戏”而是排查上面这些 bug 的过程。每一个 bug 都在训练一种能力对内部状态进行推理。当你学会用日志打印棋盘数组、用状态机去思考游戏流程时再回头看业务系统里的复杂场景思路会清晰很多。如果基础版本已经完全跑通我建议按顺序做下面几个扩展性价比依次递增增加“下一个方块”预览很多游戏体验的提升不需要改核心逻辑只需要多维护一个 next 字段。增加“阴影落点”即在当前方块正下方绘制一个半透明落点帮助玩家预判。这个功能看起来复杂实际上只需要复制当前方块的位置循环向下尝试直到不能动再把该位置画成半透明效果。增加暂停和重新开始菜单这能让你真正理解游戏状态机的意义。最后可以考虑加入音效和动效或者把棋盘的宽高做成可配置项方便测试不同难度。做完这些扩展之后再回头看最初的代码你会发现自己对“状态”的理解已经从“能改字段”上升到了“能控制状态迁移”的层面。能力就是在这样一次次类似的项目里长出来的。希望这篇完整实现笔记能帮到正在做相同项目、或准备拿它练手的朋友少走几个我已经替你们蹚过的弯路。
阅读完成 · 觉得有帮助?
咨询建站