简介:一份用 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(); // 落底,固化到场地 }注意dr=1是向下,场地数组的行索引向下递增。如果你反过来,后面所有逻辑都会镜像,写的时候最好在注释里标明。
有的实现会把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是为了避免顶部越界。有些代码里这里不检查,只在生成时判断,那就会出现生成的方块有一部分在数组之外,后面清除行时数组越界。血泪经验:生成时判断一次不够,固化时也要判断,因为连续旋转可能把方块顶到顶部之外。
固化后要立刻消行,然后生成新方块。顺序不能反:先消行,新方块生成前场地是干净的,否则新方块会压到旧方块上。
生成新方块时,要复制形状矩阵,不能直接引用枚举里的shape:
private 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.sleep,sleep只能保证至少等这么多毫秒,实际间隔可能更长;而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里遍历当前形状的所有非空格子,每个格子计算目标坐标后再判断。并且注意边界条件:col<0或者col>=10都算撞墙。row>=20算到底。row<0时允许。
错误写法是把整体坐标和矩阵宽度一起判断:
// 错误:只看方块左上角 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层分开之后,我能在不打开窗口的情况下写完一个完整测试用例。希望这篇笔记能让你少走点这种弯路,把它做成一个真正能拿出手的项目。
本文还有配套的精品资源,点击获取