ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

Java俄罗斯方块开发实战:从基础语法到面试通关

Java俄罗斯方块开发实战:从基础语法到面试通关 简介压缩包内是用 Java 编写的俄罗斯方块小游戏完整源码定位为 Java 游戏开发入门资料适合初学 Java、想通过经典小游戏理解面向对象编程、事件处理与游戏循环思想的学生和自学开发者。包内仅有 6 个文件包含 5 个 .java 源文件和 1 个已打包好的 .jar 可执行程序源文件按方块类、游戏面板、控制逻辑、时间管理、用户接口、得分系统等核心模块划分结构清楚便于对照运行结果逐段阅读。整个压缩包只有 35KB十分轻量下载后即可解压浏览。该资源已有 2049 人学习/下载在小游戏源码学习圈中有一定热度。借助这份源码读者能完整梳理方块生成、下落、旋转、碰撞检测与整行消除的算法流程熟悉 Swing/AWT 界面绘制和键盘事件处理也能体会经典俄罗斯方块玩法在 Java 中的实现思路为后续扩展玩法或自研小游戏打下基础。1. 一个「eluosifangkuai---java.rar」压缩包把 JAVA 俄罗斯方块从入门到面试串起来网上下载的 Java 俄罗斯方块资源包大多长这样eluosifangkuai---java.rar解压后可能有.java文件、.class文件甚至只给你一个.jar。很多人第一反应是找现成代码运行但真正能让你成长的是自己把游戏从零写出来。JAVA 俄罗斯方块是典型的数组 面向对象 事件驱动练习不需要 Spring不需要数据库却能把二维数组、状态管理、键盘监听、界面刷新全部过一遍。它适合刚学完 Java 基础语法、想做点能运行项目的人也适合准备 Java 开发面试前用两三个小时复习基础知识点的人。与其在压缩包里找答案不如把这个标题当成作业题动手重写一遍。2. 游戏模型先立住方块数据、旋转公式与碰撞消行的最小实现2.1 用坐标点集代替二维数组方块建模会清爽很多俄罗斯方块最核心的数据是当前方块和棋盘。常见做法有两种一种是用int[][]表示 0/1 矩阵另一种是用坐标点集记录每个格子的 (列, 行)。我一般选坐标点集因为旋转公式非常简单碰撞检测也能统一处理。棋盘常量先定义出来宽 10 格、高 20 格这是经典 Tetris 的尺寸。格子的坐标我统一用column列和row行原点在左上角列向右增长行向下增长。方块相对坐标也用同一套规则这样合并到棋盘时不需要转换。public class Shape { private int[][] cells; // 每行是一个格子的相对坐标 {col, row} private int x; // 方块左上角在棋盘上的列 private int y; // 方块左上角在棋盘上的行 public Shape(int[][] cells, int x, int y) { this.cells cells; this.x x; this.y y; } public int[][] getCells() { return cells; } }这里cells存的是相对坐标例如 I 形状可以写成{{0,0},{1,0},{2,0},{3,0}}T 形状写成{{0,0},{1,0},{2,0},{1,1}}。注意不是矩阵所以每个方块的长度可能不同旋转后也不需要重新开辟固定大小的矩形。七种方块建议集中定义在一个工厂里否则散落在各处很难维护。用静态数组存所有形状的初始坐标配合颜色字段一个Shape实例就能完整表示一个方块。public class ShapeFactory { private static final int[][][] SHAPES { {{0,0},{1,0},{2,0},{3,0}}, // I {{0,0},{1,0},{0,1},{1,1}}, // O {{0,0},{1,0},{2,0},{2,1}}, // L {{0,0},{0,1},{1,1},{2,1}}, // J {{0,0},{1,0},{1,1},{2,1}}, // S {{1,0},{2,0},{0,1},{1,1}}, // Z {{1,0},{0,1},{1,1},{2,1}} // T }; public static Shape randomShape() { int index ThreadLocalRandom.current().nextInt(SHAPES.length); // 3 是初始列0 是初始行方便从棋盘顶部上方生成 return new Shape(SHAPES[index], 3, 0); } }randomShape里把初始列写成了 3这是给 I 形状留的空间其他形状也会落在这个范围内。更稳妥的写法是拿到形状后根据宽度计算初始列但先固定一个安全列也能跑。2.2 旋转公式只改相对坐标不碰方块位置点集旋转的公式很固定顺时针 90 度(col, row)变成(-row, col)。因为棋盘坐标系中行向下为正所以这个变换不会翻转错方向。变换后会出现负列需要对整组坐标做一次平移让最小列变成 0。public void rotateClockwise() { int[][] rotated new int[cells.length][2]; int minCol 0; for (int i 0; i cells.length; i) { rotated[i][0] -cells[i][1]; rotated[i][1] cells[i][0]; minCol Math.min(minCol, rotated[i][0]); } for (int i 0; i rotated.length; i) { rotated[i][0] - minCol; } cells rotated; }需要注意这里只修改了cells没有改x和y。旋转后再用碰撞检测判断是否合法合法才保留否则用“踢墙”处理。另一个需要注意的点是 O 方块四个格子绕中心旋转后图形完全一样但坐标会整体漂移一格。常见做法是给 O 方块特判旋转时直接返回避免它在原地跳一下的视觉问题。2.3 碰撞检测三个边界条件缺一不可移动或旋转前都要做碰撞检测。检测方式是遍历当前方块的每一个格子把相对坐标加上dx、dy以及x、y得到目标坐标然后检查它是否越界或撞到已固定方块。public boolean collides(GameBoard board, int dx, int dy) { for (int i 0; i cells.length; i) { int col x dx cells[i][0]; int row y dy cells[i][1]; if (col 0 || col board.getCols()) return true; if (row board.getRows()) return true; if (row 0 board.isOccupied(row, col)) return true; } return false; }第一条判断左右边界第二条判断下落是否触底第三条判断是否撞到已经固定住的方块。为什么单独判断row 0因为方块生成时允许从棋盘顶部上方出现负行是合法的临时状态不该被当成占用。这一点很多新手会漏导致第一块方块一出生就报碰撞。旋转时不要直接替换坐标要先试转。试转后如果不碰撞就采用如果碰撞常见的简单做法是尝试左右平移一格这叫 wall kick。入门项目左右各试一格就够了专业版会要不同的踢墙表。public boolean tryRotate(GameBoard board) { Shape rotated cloneShape(); rotated.rotateClockwise(); if (!rotated.collides(board, 0, 0)) { cells rotated.getCells(); return true; } if (!rotated.collides(board, -1, 0)) { x--; cells rotated.getCells(); return true; } if (!rotated.collides(board, 1, 0)) { x; cells rotated.getCells(); return true; } return false; }这里cloneShape必须把cells的每一行都复制一份否则得到的 Shape 只是浅拷贝旋转后原方块的坐标也会被改掉。深拷贝完成后再去试碰撞失败时原形状保持不变游戏逻辑才不会乱。2.4 消行数组引用和扫描顺序是两个隐藏坑消行逻辑看起来很简单找到满行移除该行上方所有行下降一行。但落地时容易翻车。我从底向上扫遇到满行就removeRow(row)然后让row再检查一次当前行因为上面掉下来的一行可能是满的。public int clearFullRows() { int count 0; for (int row rows - 1; row 0; row--) { if (isFull(row)) { removeRow(row); count; row; // 抵消循环里的 --继续检查同一索引 } } return count; } private void removeRow(int row) { for (int r row; r 0; r--) { grid[r] grid[r - 1].clone(); } grid[0] new int[cols]; }如果grid是二维数组grid[r] grid[r-1]不做clone()会导致上面若干行共享同一个一维数组改一行全部跟着变。这是消行后整个棋盘颜色错乱的头号原因。用clone()是这里最省事的深拷贝虽然性能一般但对 10x20 的棋盘完全够用。2.5 方块移动、合并和游戏结束把它们接起来移动方法很简单先检测检测通过才更新坐标。合并方法把当前方块的格子写进棋盘注意忽略row 0的格子。public void move(int dx, int dy) { if (!collides(board, dx, dy)) { x dx; y dy; } } public void merge(GameBoard board) { for (int i 0; i cells.length; i) { int col x cells[i][0]; int row y cells[i][1]; if (row 0) { board.setCell(row, col, 1); } } }move里直接判断碰撞所以外部不需要再写第二遍。merge里row 0说明这个格子还在屏幕外写进棋盘会破坏数组下界必须跳过。合并完成后如果新方块在初始位置就已经碰撞说明堆到了顶部游戏该结束了。3. 用 Swing 搭一个能玩的 Java 俄罗斯方块主循环、键盘事件与界面刷新3.1 为什么选 Swing 而不是 JavaFX对于“Java 俄罗斯方块”这个标题多数场景是课程作业或面试手写选 Swing 是成本最低的方案。Swing 在 JDK 8 里自带不用额外安装插件JavaFX 从 JDK 11 开始和 JDK 分离要单独引入依赖对一个入门项目环境配置就够劝退。另外 Java 面试里问桌面程序时Swing 的JFrame、KeyAdapter出现频率远高于 JavaFX。我一般用JFrame作为主窗口里面放一个自定义JPanel画棋盘。主窗口负责处理键盘和定时器画板只负责根据棋盘数据渲染职责分开。这样后面要换成别的 UI 框架业务逻辑不用动。3.2 Timer 驱动下落别再写 while Thread.sleep很多初版代码会写while (true) { moveDown(); Thread.sleep(500); }原因是直觉上觉得游戏循环就该这样。但它会卡死 Swing 的事件线程窗口无法响应关闭和键盘。正确做法是用javax.swing.Timer它的事件回调也跑在 EDT 上和 repaint 天然同步。public class TetrisWindow extends JFrame { private GameBoard board new GameBoard(10, 20); private Shape current; private Timer timer; private int level 1; public TetrisWindow() { setTitle(JAVA 俄罗斯方块); setDefaultCloseOperation(EXIT_ON_CLOSE); setResizable(false); add(new BoardPanel(board), BorderLayout.CENTER); pack(); setVisible(true); timer new Timer(delayForLevel(level), e - gameTick()); timer.start(); current ShapeFactory.randomShape(); } private void gameTick() { if (current.collides(board, 0, 1)) { board.merge(current); int rows board.clearFullRows(); if (rows 0) { level; timer.setDelay(delayForLevel(level)); } current ShapeFactory.randomShape(); } else { current.move(0, 1); } repaint(); } private int delayForLevel(int level) { return Math.max(80, 500 - (level - 1) * 30); } }delayForLevel让每消一轮就快一点Timer 的回调间隔就是方块下落的间隔。调整它就能控制难度不需要碰业务逻辑。Timer的构造器接收一个延时毫秒数和一个ActionListener这里用 Lambda 直接绑定gameTick简洁且不容易丢行。3.3 键盘监听聚焦、旋转、加速和暂停Swing 的监听器要加到能获得焦点的组件上。JFrame.addKeyListener不是不行但必须调用setFocusable(true)和requestFocus()否则按键可能没反应。addKeyListener(new KeyAdapter() { Override public void keyPressed(KeyEvent e) { switch (e.getKeyCode()) { case KeyEvent.VK_LEFT: current.move(-1, 0); break; case KeyEvent.VK_RIGHT: current.move(1, 0); break; case KeyEvent.VK_DOWN: current.move(0, 1); break; case KeyEvent.VK_UP: current.tryRotate(board); break; case KeyEvent.VK_SPACE: timer.setDelay(50); break; case KeyEvent.VK_P: if (timer.isRunning()) timer.stop(); else timer.start(); break; } repaint(); } }); setFocusable(true); requestFocus();VK_DOWN和 Timer 每 tick 都会下落一次长按会触发重复按键导致方块一下掉好几行。这个在入门阶段可以被接受但如果你希望手感更稳应该在keyPressed里只设置方向标志位然后在gameTick里统一消费。加速键VK_SPACE这里直接setDelay(50)真实游戏应该记录原始 delay在keyReleased里恢复否则松开后还保持高速。3.4 画板把棋盘数组画成彩色格子BoardPanel 继承 JPanel重写paintComponent。绘制时遍历棋盘和当前方块用fillRect画格子。为了让消行和移动有视觉反馈每次 gameTick 后调repaint()Swing 会自动合并多次请求不会闪烁到没法看。public class BoardPanel extends JPanel { private static final int CELL_SIZE 30; Override protected void paintComponent(Graphics g) { super.paintComponent(g); // 画棋盘上的固定格子 for (int row 0; row board.getRows(); row) { for (int col 0; col board.getCols(); col) { if (board.getCell(row, col) ! 0) { g.setColor(Color.GRAY); g.fillRect(col * CELL_SIZE, row * CELL_SIZE, CELL_SIZE, CELL_SIZE); } } } // 画当前下落方块 for (int i 0; i current.getCells().length; i) { int col current.getX() current.getCells()[i][0]; int row current.getY() current.getCells()[i][1]; if (row 0) { g.setColor(Color.BLUE); g.fillRect(col * CELL_SIZE, row * CELL_SIZE, CELL_SIZE, CELL_SIZE); } } } }CELL_SIZE取 30 是让 10 格宽变成 300 像素窗口不会太小也不会占满屏幕。如果你要适配高分屏可以用Graphics2D的 scale 再统一变换但入门阶段先保持整数像素最简单。board和current可以通过构造器传入或者把 BoardPanel 定义成 TetrisWindow 的内部类两种方式都行保持引用清晰就可以。3.5 游戏结束生成新方块时判断初始位置当方块不能再下移时它会 merge 进棋盘紧接着生成新方块。如果新方块的初始位置已经和棋盘碰撞说明堆到顶部游戏结束。private void spawnNewShape() { Shape next ShapeFactory.randomShape(); if (next.collides(board, 0, 0)) { timer.stop(); JOptionPane.showMessageDialog(this, Game Over); } else { current next; } }collides(board, 0, 0)是在原地检测不移动任何坐标。如果返回 true说明棋盘顶部已经堆满继续玩没有意义。用JOptionPane弹窗会阻塞 EDT但此时 timer 已经停止所以不会有下落事件挤进来。这一章做完你已经有一个能下落、能旋转、能左右移动、能消行的 Swing 版本。下一步就是整理工程结构避免代码越写越乱。4. 从散文件到工程化面向对象拆分、目录结构和启动入口的安排4.1 拆类标准谁管数据、谁管界面、谁管启动写过一段时间后你会发现把俄罗斯方块塞进一个Main.java也能跑但一旦想加计分、音效、方块预览单文件就会变得没法维护。常见做法是拆出这几个类类职责不负责GameBoard棋盘数组、合并、消行、边界判断界面绘制Shape方块坐标、旋转、碰撞检测键盘控制ShapeFactory随机生成七种方块存储当前状态TetrisWindow主窗口、Timer、键盘事件游戏逻辑Main启动入口业务逻辑这种拆分正好命中 Java 基础里“面向对象编程”的考点每个类只做一件事类之间通过方法调用协作。面试官问你怎么设计俄罗斯方块你把这五类一摆再讲清楚GameBoard不引用任何 Swing 组件就是一次很加分的回答。GameBoard理想状态下只暴露getCell、isOccupied、merge、clearFullRows这些方法内部怎么存数据是它自己的事。这样的好处是你后续想用命令行版、网页版只要复用GameBoard和Shape界面层整体换掉即可。4.2 裸 javac 跑通不用 IDE 也能编译运行你拿到的eluosifangkuai---java.rar未必是 Maven 工程可能只是几个.java文件。用命令行编译能加深印象也方便在没有 IDE 的机器上快速验证。目录结构按包名组织java-tetris/ src/ org/example/tetris/ Main.java TetrisWindow.java GameBoard.java Shape.java ShapeFactory.java BoardPanel.java编译和运行命令cd java-tetris javac -encoding UTF-8 -d out -sourcepath src src/org/example/tetris/Main.java java -cp out org.example.tetris.Main-sourcepath src让 javac 在编译 Main 时自动往 src 目录找它依赖的类-d out把 class 文件输出到 out 目录-encoding UTF-8是为了避免 Windows 控制台默认编码导致注释或字符串乱码。如果编译时报“找不到符号”先检查 import 和包名再检查目录层级这是新手最常见的两个编译错误。对比一下 HTML JS 里直接把代码复制进一个tetris.html就能用浏览器打开Java 版多了一步编译但这一步也是你理解 JDK、classpath、main 方法最好的入口。等哪一天你拿到别人的 rar 包第一件事应该是看源码而不是直接去点.class文件。4.3 用 Maven 管理的可移植写法如果你打算继续往这个项目加 JUnit、打包 jarMaven 是更省事的选择。一个最小的pom.xml不需要依赖也能编译出 jar关键是把mainClass指定到启动类。project modelVersion4.0.0/modelVersion groupIdcom.example/groupId artifactIdjava-tetris/artifactId version1.0/version packagingjar/packaging properties maven.compiler.source1.8/maven.compiler.source maven.compiler.target1.8/maven.compiler.target /properties build plugins plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-jar-plugin/artifactId configuration archive manifest mainClassorg.example.tetris.Main/mainClass /manifest /archive /configuration /plugin /plugins /build /project这个 pom 没有写插件版本号Maven 会用仓库里的默认版本。等你执行mvn clean packagetarget 目录里会生成一个可运行的 jar。如果你需要测试再加 JUnit 依赖版本号按你本地 Maven 仓库里能拉到的最新稳定版填即可。Maven 不是这个项目的必需品但它会强迫你理解“源码目录”和“构建产物”的分离。很多人拿到一个 rar 包时里面只有 class 文件就是因为原作者没有把源码放进构建目录。自己用 Maven 管理后这个问题不会发生。4.4 main 入口用 SwingUtilities.invokeLater 启动很多入门代码直接在main里new TetrisWindow()在大多数机器上能跑但不规范。Swing 组件不是完全线程安全的main线程和 EDT 是两个线程直接创建窗口可能导致偶发的不稳定。标准写法是用SwingUtilities.invokeLater把初始化任务丢到 EDT。public class Main { public static void main(String[] args) { SwingUtilities.invokeLater(() - { new TetrisWindow(); }); } }invokeLater会等 EDT 空闲后执行传进去的 Runnable。对俄罗斯方块来说它保证所有 Swing 操作都在同一线程里发生后续 repaint 和 keyPressed 就不会互相竞争。配合第 3 章的 Timer整个程序的线程模型就干净了Timer 回调、键盘回调、paintComponent 都在 EDTGameBoard 在同一个线程里被读写不需要加锁。工程结构到位后你会发现加功能变成了“填方法”而不是“全局翻代码”。比如想让方块有不同的颜色只需要在 Shape 里加一个 Color 字段想统计分数只需要在 TetrisWindow 里加一个成员变量。这也是面向对象这个热词在这个项目里最实际的体现。5. 避坑排查俄罗斯方块从能编译到能玩最容易踩的 5 个坑这一章不是讲新功能而是把最常见的编译时报错和运行期翻车整理成清单。以下每条都是实际调试中反复出现的现象按“现象、原因、解决”的顺序写你在自己的代码里照着排查就行。5.1 方块一出生就撞墙初始坐标算错现象程序启动后第一块方块立刻消失或者直接弹 Game Over。原因随机生成方块的初始坐标是写死的(0,0)忽略了棋盘宽度和方块宽度或者碰撞检测把row 0也算撞墙。解决初始坐标设置成x COLS / 2 - shape.getCells()[0].length / 2y 0。同时按下落碰撞判断负行只允许出现在顶部不判占用。如果没有开放setCell可以用Shape的移动方法把方块移到安全位置再 merge。5.2 旋转后图形瞬移修改了绝对坐标现象按上箭头后方块不但旋转了还跳到棋盘最左边或最下面。原因在rotateClockwise里把旋转后的坐标直接加上了x、y导致相对坐标和绝对坐标混在一起。解决旋转方法只改相对坐标cells旋转后调用tryRotate判断碰撞。如果碰撞再用 wall kick 左右平移x。绝对坐标x,y只被移动方法修改。检查时你可以在旋转前后打印x和y它们不应该变化。5.3 消行后棋盘出现整列同色或数据丢失现象消掉一行后上面几行的所有格子变成同一个颜色或者某些行消失。原因二维数组各行引用了同一个一维数组。比如grid[1] grid[0]没有clone()后面修改grid[2][3]等于同时改多个行。解决removeRow里写grid[r] grid[r - 1].clone()确保每行独立。消行扫描从最后一行向上遇到满行调用removeRow(row)后让索引回退一格防止连续满行被跳过。你可以用ThreadLocalRandom给每行填充不同数字消行后打印检查数据错乱一眼就能看出来。5.4 按键一下跑几格系统键盘重复和双重移动现象按一下左键方块直接移到最左边按住上键旋转停不下来。原因Swing 的keyPressed会响应系统键盘重复你按一次可能被触发多次另外VK_DOWN和 Timer 同时下落一次 tick 里方块移动了两次。解决不在keyPressed里直接改棋盘只记录标志位在gameTick里统一处理。boolean[] keyState new boolean[256]; // keyPressed 里 keyState[e.getKeyCode()] true; // keyReleased 里 keyState[e.getKeyCode()] false; // gameTick 里 if (keyState[KeyEvent.VK_LEFT]) current.move(-1, 0); if (keyState[KeyEvent.VK_RIGHT]) current.move(1, 0); if (keyState[KeyEvent.VK_UP]) current.tryRotate(board);即使系统连续触发重复按键keyState只是被重复赋值为 truetick 里一次只移动一格。这样方块的手感会更接近街机。5.5 窗口点关闭没反应CPU 占满主线程被 while 阻塞现象运行后窗口能显示但点最小化和关闭都没有响应任务管理器显示 Java 进程 CPU 接近 100%。原因在main里写了while (true) { move(); Thread.sleep(100); }。这个循环阻塞了 main 线程但 Swing 的事件分派线程也被频繁的重绘请求堵住窗口事件得不到处理。解决把游戏循环改成javax.swing.Timer让每个 tick 在 EDT 上执行。如果已经写了 while至少把它放进单独的Thread但仍要避免直接操作 Swing 组件。对俄罗斯方块来说Timer 是唯一合法的下落驱动。这五条覆盖了坐标、旋转、消行、键盘、线程五个方向。你如果按第 2、3 章的代码实现基本不会踩如果是从别人 rar 里抄来的代码问题大多集中在这几处。6. 进阶玩法方块投影、七色配色、计分规则和自动化验证6.1 给下落方块加一个投影投影就是当前方块直接落到底部时应该出现的位置通常画成虚线轮廓。计算逻辑很简单复制一个当前方块不断下移直到碰撞得到一组坐标。在BoardPanel.paintComponent里额外画一遍投影用浅色边框。private Shape getProjection(GameBoard board) { Shape p current.cloneShape(); while (!p.collides(board, 0, 1)) { p.move(0, 1); } return p; }注意cloneShape要深拷贝否则 while 里的p.move会改掉当前方块自身。画投影时可以用drawRect画空心格子和实心方块区分开。这个技巧能明显提升手感玩家可以提前判断落点。6.2 用 JUnit 验证消行逻辑不再靠肉眼试错消行是最容易出隐蔽 bug 的部分写一个 JUnit 测试能让你以后改难度、改计分都放心。测试里构造一个GameBoard手动把某一行填满调用clearFullRows断言返回值和上方行数据。Test public void clearFullRowsShouldShiftRowsDown() throws Exception { GameBoard board new GameBoard(10, 20); // 先放一个格子到第 5 行再填满第 19 行 // 具体怎么塞取决于 GameBoard 是否开放 setCell int removed board.clearFullRows(); assertEquals(1, removed); // 断言第 5 行的格子现在到了第 19 行 }如果GameBoard没有setCell可以临时加一个包内可见的setCellForTest或者通过merge一个一字形方块来填满。测试跑通后游戏逻辑的回归成本就低很多。配色和计分是让游戏“有味道”的两个点。七种方块各自用不同颜色可以把颜色字段放进Shape而不是写死在画板里计分规则一般用“消 1 行 100 分2 行 300 分3 行 500 分4 行 800 分”阶梯加分配合等级加速。我个人的血泪教训是永远先把消行测试写了再去调颜色、加音效否则改一次难度消行 bug 就回来一次。这个项目做下来你收获的不只是一个能玩的游戏还有一套“拆类、测逻辑、再调界面”的做事顺序。希望帮到你。本文还有配套的精品资源点击获取
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表