
简介一份用 Java 编写的俄罗斯方块游戏源码包从界面渲染到得分判定均有涉及专为 Java 初学者和游戏开发爱好者准备目标是还原经典玩法并解析背后算法。压缩包共6个文件包含5个Java源文件与1个可运行jar包整体大小约35KB结构轻量、便于阅读和调试。已有2049人学习/下载。源码模块划分细致涵盖方块类、游戏面板、控制逻辑、用户接口、得分系统与时间管理重点演示了方块旋转、碰撞检测、消行判定等核心流程可以帮助读者快速掌握面向对象编程、Swing界面开发以及基础数据结构的设计思路。在读懂现有代码的基础上还可以自行调整计分规则、增加新方块或改进界面特效甚至可尝试移植到其他平台适合用来做课程设计或课后练手是一份实用的Java小游戏学习资料。1. 一个Java俄罗斯方块项目为什么值得从零开始写不少人拿到eluosifangkuai---java.rar这样的压缩包解压后直接点开就能玩可一旦想改个方块颜色、加个消行动画就发现代码像个黑匣子。俄罗斯方块是Java入门最常见的练手项目也是不少公司笔试面试常客——它把二维数组、面向对象、线程调度、键盘事件全揉在一起。这篇文章不打算带你分析某个现成rar包而是把整个项目拆开从方块建模到界面循环一步步说清楚。适合刚学完Java基础的同学练手也给准备面试的人梳理一遍核心逻辑。2. 方块建模与旋转矩阵、预置表与坐标系的一次性选择先说一下这部分是整个项目里最容易返工的地方。很多人一上来就写界面结果写到旋转就卡住。我建议先把方块的数据结构和旋转逻辑定下来因为后面碰撞检测、渲染都要依赖它。做选择题的成本比改代码低得多。2.1 7种方块与旋转状态的数据结构俄罗斯方块标准形状有7种一般是I、J、L、O、S、T、Z。每种方块可以用二维数组表示1表示有格子0表示空。比如T块boolean[][] t { {false, true, false}, {true, true, true} };这种紧凑矩阵省空间但有个问题旋转后数组的宽高会变化。比如T块顺时针旋转一次变成boolean[][] t90 { {true, false}, {true, true}, {true, false} };如果你在代码里硬编码了数组的宽高旋转后就容易翻车。所以我更推荐用4x4的布尔矩阵统一表示。原因有三个一是旋转90度后矩阵尺寸不变边界判断简单二是I块和O块在4x4里天然居中三是很多开源项目都是这么写的你查资料时对得上。用4x4表示I块是这样boolean[][] iBlock { {false, false, false, false}, {true, true, true, true}, {false, false, false, false}, {false, false, false, false} };竖着时把它旋转90度就行。注意O块在4x4里仍然是左上角2x2旋转后不变这一点如果你后面做墙踢会用到。每个方块除了形状还要有颜色。我一般用枚举把类型、形状和颜色绑在一起public enum BlockType { I(new boolean[][] { {false, false, false, false}, {true, true, true, true}, {false, false, false, false}, {false, false, false, false} }, new Color(0, 240, 240)), O(new boolean[][] { {false, false, false, false}, {false, true, true, false}, {false, true, true, false}, {false, false, false, false} }, new Color(255, 255, 0)); // 其余J L S T Z省略 final boolean[][] shape; final Color color; BlockType(boolean[][] shape, Color color) { this.shape shape; this.color color; } }把颜色放进枚举里后面画板直接读shape和color不用再维护一个映射表。枚举里的shape是初始形态运行时需要保存一个当前形态的副本后面小节会讲为什么。2.2 旋转算法顺时针旋转矩阵 vs 查表旋转实现有两条路矩阵运算和预置状态表。矩阵运算就是写一个函数把一个4x4矩阵顺时针旋转90度。常见的写法需要新建一个目标矩阵避免原地修改导致读脏数据public static boolean[][] rotateClockwise(boolean[][] src) { boolean[][] dst new boolean[4][4]; for (int row 0; row 4; row) { for (int col 0; col 4; col) { dst[col][3 - row] src[row][col]; } } return dst; }这里的坐标约定是row做第一维、col做第二维。旋转的数学关系是原坐标(row, col)旋转90度后新坐标为(col, 3 - row)。如果你写成dst[3 - col][row]就会得到逆时针。很多人在这里反复试错最后靠打印矩阵才看出来。为避免玄学我建议开局先用一个简单图形测一下比如T块旋转前和旋转后都打印出来看看。矩阵运算的好处是代码少新增一个旋转形态非常自然。坏处是如果要做墙踢有时候需要拿到旋转前后的两种形态矩阵运算必须额外保存一份。而且旋转后数组内容总是动态计算调试时没法一眼看到所有形态。查表方式是预先把每种方块的所有旋转形态存成数组。比如T块存4个形态I块存两个独特形态但备4份。查表时按旋转索引切换。查表方式写起来繁琐但运行快而且可以在每个形态旁边手工标注预期坐标偏移。经典方块游戏里的墙踢就是查表解决的。我个人的意见学习项目用矩阵运算理解旋转本质如果做完整产品切到预置表避免在碰撞检测里现场算旋转。2.3 坐标系与边界方块坐标、场地坐标、墙场地一般是10列 x 20行我用一个int[20][10]数组存0表示空非0表示该格子的颜色ID。活动方块除了有自己的4x4矩阵外还要有一个位置offsetRow、offsetCol表示矩阵左上角在场地里的坐标。判断一个格子是否在场地内且未被占用先计算实际场地坐标int boardRow block.getOffsetRow() shapeRow; int boardCol block.getOffsetCol() shapeCol;形状坐标从0到3即使格子是false也可以直接算。很多人踩坑是在读取场地数组前没有检查边界导致数组越界异常。正确的检查顺序是先判断boardCol 0 boardCol 10 boardRow 20再判断board[boardRow][boardCol]不为0。boardRow 20时直接视为碰撞。注意boardRow 0的情况方块刚生成时一部分在顶部之上这不算碰撞否则新方块还没进来就游戏结束。所以顶部上方的格子要放行只有到了游戏结束时新方块完全被卡住才判负。这里有个细节场地数组只记录已固化的方块活动方块不写进场地。碰撞检测时活动方块之间不会互相重叠我们只拿活动方块和场地比以及和边界比。如果把活动方块也临时写进场地旋转检测就会把自己撞到自己这是常见错误。移动和旋转前的碰撞检测统一走一个方法public boolean canMove(int dr, int dc, boolean[][] shape) { int newRow offsetRow dr; int newCol offsetCol dc; for (int r 0; r 4; r) { for (int c 0; c 4; c) { if (!shape[r][c]) continue; int targetRow newRow r; int targetCol newCol c; if (targetCol 0 || targetCol 10 || targetRow 20) return false; if (targetRow 0 board[targetRow][targetCol] ! 0) return false; } } return true; }参数dr是行方向偏移dc是列方向偏移shape是当前旋转形态。注意targetRow 0时跳过场地占用判断因为顶部之上不算撞墙。这个方法同时处理移动和旋转后的碰撞检测是整个游戏正确性的关键。3. 碰撞检测与消行逻辑判定时机、落底处理与得分连锁这一章讲游戏的核心循环。移动、旋转、下落都要先过碰撞检测落底后固化然后消行。把这套逻辑理清后面加什么动作都顺。3.1 碰撞检测先尝试再移动不搞事后回退碰撞检测的原则任何对位置或形态的修改都先调用canMove成功才真正修改状态。不要先改了坐标再检查发现撞了又改回去——这样会引发很多难以追踪的状态错乱尤其在键盘事件里连按的时候。实际代码里左移右移就是if (canMove(0, -1, currentShape)) { offsetCol--; }下落一步就是if (canMove(1, 0, currentShape)) { offsetRow; } else { lockBlock(); // 落底固化到场地 }注意dr1是向下场地数组的行索引向下递增。如果你反过来后面所有逻辑都会镜像写的时候最好在注释里标明。有的实现会把canMove写在Block类里有的写在Game类里。我倾向于写在Game类里因为它要访问场地数组。Block类只保存形状、当前位置和旋转索引。这样可以保持单一职责Block负责我是谁、我在哪Game负责你能不能去那。3.2 落底与固化把活动方块写进场地图当向下碰撞时不再移动而是把活动方块的格子写入场地数组。写入后每个格子记录方块的颜色ID这样界面层可以根据ID画不同颜色。固化代码private void lockBlock() { for (int r 0; r 4; r) { for (int c 0; c 4; c) { if (!currentShape[r][c]) continue; int boardRow offsetRow r; int boardCol offsetCol c; if (boardRow 0) { gameOver true; // 方块在顶部之上游戏结束 return; } board[boardRow][boardCol] currentType.colorId; } } int lines clearLines(); score lines * 100; spawnBlock(); }注意这里检查boardRow 0是为了避免顶部越界。有些代码里这里不检查只在生成时判断那就会出现生成的方块有一部分在数组之外后面清除行时数组越界。血泪经验生成时判断一次不够固化时也要判断因为连续旋转可能把方块顶到顶部之外。固化后要立刻消行然后生成新方块。顺序不能反先消行新方块生成前场地是干净的否则新方块会压到旧方块上。生成新方块时要复制形状矩阵不能直接引用枚举里的shapeprivate void spawnBlock() { currentType BlockType.random(); currentShape copyOf(currentType.shape); offsetRow -1; // 让方块部分露出顶部 offsetCol 3; // 10列宽的场地居中 if (!canMove(0, 0, currentShape)) { gameOver true; } }offsetRow-1很多教程设置成0但那样方块刚出现就顶到顶部感觉很奇怪。设置成-1让方块先露出一行。canMove(0,0)检查是否和已有方块重叠如果重叠就结束。copyOf要深拷贝每一行否则旋转会污染枚举里的初始形状。3.3 消行判定整行扫描与连锁加分消行最简单的方式是从底往上扫描遇到满行就清除。但我刚开始写的时候用了一个从下往上遍历边找边删的循环结果一次消四行时只消了两行原因是删掉一行后上面的行下移行索引发生变化漏掉了原来紧挨着的另一行。更稳妥的做法是分两步先标记所有满行再一次性重建场地。代码private int clearLines() { boolean[] fullRow new boolean[20]; int count 0; for (int row 0; row 20; row) { boolean full true; for (int col 0; col 10; col) { if (board[row][col] 0) { full false; break; } } fullRow[row] full; if (full) count; } if (count 0) return 0; int target 19; for (int row 19; row 0; row--) { if (!fullRow[row]) { // 把这一行搬到底部的target位置 board[target] board[row]; target--; } } for (int row target; row 0; row--) { board[row] new int[10]; // 顶部空行填零 } return count; }这段代码解释一下。第一次扫描标记满行第二次从底部往上把非满行搬到底部target从19开始递减最后target以上都是空行。这样一次消多行不会漏。board[target] board[row]是引用赋值原数组还在fullRow里后面不再需要所以没问题。如果担心共享数组改成System.arraycopy。消行得分可以按单行100、双行300、三行500、四行800来设计也可以简单按lines乘100。面试的时候加分规则最好用常量表不要写魔法数字。下一章会给出一个计分常量数组。另外消行后如果有连锁比如四行消除后新方块落下又消一行代码里不需要特殊处理因为下一次下落自然触发。只要确保clearLines返回的行数正确。4. Swing界面与控制画板、键盘事件、游戏主循环逻辑层写完之后界面层要做的事其实很少画场地、画当前方块、响应按键、让方块按节奏下落。但这部分最容易出现看起来是个Java程序用起来像玩具的问题。4.1 用JPanel当画板双缓冲避免闪屏不要直接在JFrame上画应该用一个JPanel子类重写paintComponent(Graphics g)。JComponent默认是双缓冲的但前提是你正确使用。如果你在paint里面用getGraphics画到根窗格就会闪屏。一个简单的画法public class BoardPanel extends JPanel { private Game game; private final int cellSize 25; Override protected void paintComponent(Graphics g) { super.paintComponent(g); g.setColor(Color.BLACK); g.fillRect(0, 0, getWidth(), getHeight()); // 画场地中已固化的方块 for (int row 0; row 20; row) { for (int col 0; col 10; col) { int colorId game.getBoard()[row][col]; g.setColor(Game.COLORS[colorId]); g.fillRect(col * cellSize, row * cellSize, cellSize - 1, cellSize - 1); } } // 画活动方块 boolean[][] shape game.getCurrentShape(); int r0 game.getOffsetRow(); int c0 game.getOffsetCol(); for (int r 0; r 4; r) { for (int c 0; c 4; c) { if (shape[r][c]) { g.setColor(Game.COLORS[game.getCurrentType().colorId]); g.fillRect((c0 c) * cellSize, (r0 r) * cellSize, cellSize - 1, cellSize - 1); } } } } }注意这里cellSize是常量比如25像素。paintComponent每次都会把整个面板重画不需要做局部擦除。Swing自带的双缓冲会先把图形画在后台图像上再一次性贴到屏幕上所以闪屏问题不严重。你不需要再去拼接BufferedImage除非你要做复杂的动画。有一种常见错误是重写update(Graphics)然后手动清屏这在AWT时代有用在Swing里反而会破坏双缓冲。如果你照做了还闪先检查是不是在十毫秒级别的循环里调了repaint太多次。正常节奏只有下落一步和按键时才repaint不需要连续重画。4.2 键盘输入KeyListener的焦点问题与线程安全给JFrame加KeyListener是最简单的做法但它有个老问题只有焦点在JFrame上时才收得到事件。如果面板上有按钮或文本输入框焦点被抢走按方向键就失灵。新手最容易遇到代码看起来没问题但键盘没反应。解决办法有两个推荐第二个。方法一是强制抢焦点frame.setFocusable(true); frame.requestFocusInWindow();但这个方法不稳定尤其在其他组件出现后焦点还会被抢走。方法二是用键绑定把按键动作绑定到面板上不受焦点在子组件上的影响InputMap im panel.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW); ActionMap am panel.getActionMap(); im.put(KeyStroke.getKeyStroke(LEFT), moveLeft); am.put(moveLeft, new AbstractAction() { Override public void actionPerformed(ActionEvent e) { game.moveLeft(); panel.repaint(); } });WHEN_IN_FOCUSED_WINDOW表示只要窗口有焦点无论哪个子组件获得焦点面板都能收到这个按键事件。这是在实际项目里最省心的方案。注意KeyStroke.getKeyStroke(LEFT)的写法是从字符串解析也可以用KeyEvent.VK_LEFT构造但字符串可读性更高。键绑定还有一个好处你可以给同一个动作绑定多个按键比如把空格和上键都绑定到旋转。这比在KeyListener里写一堆if判断干净得多。4.3 下落节奏javax.swing.Timer还是Thread.sleep下落节奏有两种常见写法一个是Swing的Timer一个是自己开线程sleep。我建议用Timer因为Timer的回调在事件分发线程上执行可以直接操作Swing组件不需要额外的同步。而Thread.sleep写法需要小心你在子线程里改了游戏状态然后在EDT上repaint如果子线程和EDT同时访问同一个数组轻则看到撕裂的界面重则数组越界。用Timer控制下落int delay 500; // 毫秒 Timer timer new Timer(delay, e - { if (!game.isOver()) { game.stepDown(); // 尝试向下移动一步落底时自动固化 panel.repaint(); } else { ((Timer) e.getSource()).stop(); } }); timer.start();delay就是每步的间隔对应游戏速度。随着关卡等级提高把delay减小到400、300、200但要注意最小值别低于80否则Timer来不及处理输入游戏变得不可控。如果你用的是Thread.sleepsleep只能保证至少等这么多毫秒实际间隔可能更长而Timer同样不保证精确但俄罗斯方块不需要精确到毫秒只要能改变速度就行。如果要处理软降就在按键事件里调用stepDown并重置一帧的计时。硬降则循环调用stepDown直到落底然后立刻固化。硬降代码可以写成public void hardDrop() { while (canMove(1, 0, currentShape)) { offsetRow; } lockBlock(); panel.repaint(); }注意在循环中移动后直接固化然后会在lockBlock里生成新方块。这个逻辑是同步的界面不会显示中间过程正好符合硬降的效果。最后再强调一下线程纪律使用Timer时所有游戏逻辑都在EDT上发生所以不需要synchronized。如果在子线程里调用panel.repaint()Swing会发一个异步事件可能产生竞态。所以一般保持所有游戏状态修改只能在EDT上发生这一条原则就能避开很多线程坑。5. Java俄罗斯方块的家常坑穿墙、旋转、闪屏与按键失灵这章我把实际调试中遇到最多的几类问题列出来每条都按现象到原因再到解决写。如果你照着前面的代码写大部分坑可以提前绕开但万一出了回来对照一下。5.1 现象方块穿墙左移或右移时会有一半跑出场地原因碰撞检测里只检查了场地数组没检查边界。或者你检查了边界但用的是方块整体坐标没有按每个格子判断。解决在canMove里遍历当前形状的所有非空格子每个格子计算目标坐标后再判断。并且注意边界条件col0或者col10都算撞墙。row20算到底。row0时允许。错误写法是把整体坐标和矩阵宽度一起判断// 错误只看方块左上角 if (newCol 0 || newCol shape[0].length 10) return false;这在一行多格子的方块上会出问题尤其I块横着时左上角在0但矩阵宽度是4看起来没出界实际上最右边已经超出10。正确写法是逐格判断前面2.3小节的canMove就是标准答案。5.2 现象旋转后位置突变方块跳到左边或右边甚至卡进墙里原因旋转后形状的左上角可能和旋转前不一样如果你只改形状不改位置视觉效果就是方块跳动。比如用紧凑矩阵时T块横着是2x3旋转后变成3x2矩阵宽度变了左上角参考点自然变化。解决要么用4x4矩阵让左上角始终为(0,0)要么旋转后额外计算偏移让旋转中心保持不变。对于学习项目用4x4矩阵最简单。但要注意即使4x4旋转后有些方块在视觉上也会有偏移比如L块因为非零格子在矩阵中的位置不对称。如果你要更顺滑的手感可以给每种方块定义墙踢偏移表比如T块旋转90度后向左移动一格才能保持一致。这个属于手感范畴做出来不难调试起来略玄学。墙踢的最简实现旋转后如果canMove返回false尝试向左或向右偏移1格再判断。如果也失败才放弃旋转。代码boolean[][] rotated rotateClockwise(currentShape); int[] kicks {0, -1, 1, -2, 2}; for (int dc : kicks) { if (canMove(0, dc, rotated)) { currentShape rotated; offsetCol dc; return; } }参数kicks是列偏移的试探序列。这样可以把方块从墙边踢出来避免旋转卡死。5.3 现象画面闪得厉害拖动窗口时留下一截残影原因你在paint()或update()里用了非双缓冲的画法或者每次repaint都先填了全白背景导致旧图形没有被完全擦除。解决检查你是否重写的是paintComponent而不是paint。如果还在用getGraphics()画图改成paintComponent。另外不要自己开一个BufferedImage然后用drawImage贴到panel上除非你直接重写paintComponent。Swing的双缓冲已经处理了不要重复做。还有一个容易忽略的点paintComponent第一行要调用super.paintComponent(g)否则背景不会擦干净。我看到不少人忘了这行结果方块移动后留下一条条残影。5.4 现象按方向键没反应点一下按钮后键盘就失灵原因JFrame上有其他组件抢走了焦点KeyListener只在焦点组件上触发。如果在JFrame上加了JMenuBar、JButton或者JTextField焦点很容易跑到这些组件上。解决把按键逻辑从KeyListener改成键绑定绑定到面板的WHEN_IN_FOCUSED_WINDOW。这样焦点在按钮上也能触发。另外不要在KeyListener里写重耗时操作否则界面会卡。如果你坚持用KeyListener记得在JFrame上调用setFocusable(true)和requestFocus()但每次点击鼠标后焦点可能又丢所以键绑定才是终解。5.5 现象游戏速度越玩越快但明明没有调整等级原因你用了Thread.sleep来控制下落然后在新线程里循环。线程每次循环执行的其他逻辑耗时不一样导致实际间隔变短或者你误用了每调用一次stepDown就减少一点sleep但逻辑里多次调用却忘了恢复。解决用Swing Timer或者在主循环里用System.currentTimeMillis()计算已过去的时间只有超过阈值才下落一步。比如long lastDrop System.currentTimeMillis(); while (running) { long now System.currentTimeMillis(); if (now - lastDrop delay) { game.stepDown(); lastDrop now; } }注意如果使用这种循环需要配合SwingUtilities.invokeLater来更新UI复杂度比Timer高。所以我一般直接用Timer省得自己管线程。还有一种情况是你在按键事件里调了stepDown又没重置计时器结果下落后马上又自动下落一次体感就是变快。解决办法是在stepDown成功落下一格后把Timer的下次触发时间重置或者记录本帧已经移动过避免双重下落。6. 从能玩到能拿得出手计分、等级与代码重构方向到这里你已经有一个能正常玩的俄罗斯方块了。但要让这个项目能用来应付课程设计或者面试还需要在细节上下功夫。6.1 计分规则与等级速度计分不要写散落的魔法数字用常量数组private static final int[] LINE_SCORE {0, 100, 300, 500, 800};消除1到4行分别给100、300、500、800分。等级每消除10行升一级下落间隔delay Math.max(100, 500 - level * 50)。初始500毫秒上限是100毫秒这样等级越高越快但不会快到手抽筋。6.2 下一步预览与旋转阴影只画当前方块会显得界面很空。在面板右侧画一个下一个方块小窗会让整个界面专业很多。阴影是当前方块落到最低位置的投影硬降前能看到目标位置手感提升明显。阴影计算很简单复制当前方块循环往下移动直到碰撞然后用半透明颜色画出来。6.3 面向对象重构把GameEngine和Render分开我改完这个项目最大的教训是不要把所有代码都塞在面板类里。至少拆成Game、BoardPanel、BlockType三个类。逻辑层不依赖任何Swing这样你可以用JUnit测试碰撞检测和消行而不是靠肉眼看。我接手别人的代码最痛苦的就是看到几百行一个类里面混着UI和逻辑。如果你想让这份代码出现在简历上重构比加功能更重要。我早期写俄罗斯方块最丢人的一次是消行算法漏行玩到十几行时游戏卡死。后来我意识到这种小游戏最值得的投入不是界面炫技而是核心逻辑的严谨。把Game层和UI层分开之后我能在不打开窗口的情况下写完一个完整测试用例。希望这篇笔记能让你少走点这种弯路把它做成一个真正能拿出手的项目。本文还有配套的精品资源点击获取