Java Tetris
最后修改于 2023 年 1 月 10 日
在本章中,我们将用 Java Swing 创建一个 Tetris 游戏克隆。源代码和图像可以在作者的 Github Java-Tetris-Game 仓库中找到。
俄罗斯方块
Tetris 游戏是有史以来最受欢迎的计算机游戏之一。最初的游戏由俄罗斯程序员Alexey Pajitnov于 1985 年设计和编程。从那时起,Tetris 几乎在所有计算机平台上都有许多变种。甚至我的手机上也有一个修改版的 Tetris 游戏。
Tetris 是一款掉落方块益智游戏。在这个游戏中,我们有七种不同的形状,称为 tetrominoes
。S 形、Z 形、T 形、L 形、直线形、镜像 L 形和方形。每种形状都由四个正方形组成。这些形状会向下掉落到棋盘上。Tetris 游戏的目标是移动和旋转形状,以便它们尽可能地契合。如果我们成功形成一行,该行将被消除,我们就得分。我们玩 Tetris 游戏直到“顶出”。

开发
Tetrominoes 使用 Swing 绘图 API 绘制。我们使用 java.util.Timer
来创建游戏循环。形状是逐格(而不是逐像素)移动的。从数学上讲,游戏中的棋盘是一个简单的数字列表。
游戏在启动后立即开始。我们可以通过按 p 键暂停游戏。空格键会将 Tetris 方块立即下落到底部。d 键会将方块向下移动一行。(可用于稍微加速掉落。)游戏以恒定速度进行,未实现加速。得分是移除的行数。
package com.zetcode; import java.util.Random; public class Shape { protected enum Tetrominoe { NoShape, ZShape, SShape, LineShape, TShape, SquareShape, LShape, MirroredLShape } private Tetrominoe pieceShape; private int[][] coords; public Shape() { coords = new int[4][2]; setShape(Tetrominoe.NoShape); } void setShape(Tetrominoe shape) { int[][][] coordsTable = new int[][][]{ {{0, 0}, {0, 0}, {0, 0}, {0, 0}}, {{0, -1}, {0, 0}, {-1, 0}, {-1, 1}}, {{0, -1}, {0, 0}, {1, 0}, {1, 1}}, {{0, -1}, {0, 0}, {0, 1}, {0, 2}}, {{-1, 0}, {0, 0}, {1, 0}, {0, 1}}, {{0, 0}, {1, 0}, {0, 1}, {1, 1}}, {{-1, -1}, {0, -1}, {0, 0}, {0, 1}}, {{1, -1}, {0, -1}, {0, 0}, {0, 1}} }; for (int i = 0; i < 4; i++) { System.arraycopy(coordsTable[shape.ordinal()], 0, coords, 0, 4); } pieceShape = shape; } private void setX(int index, int x) { coords[index][0] = x; } private void setY(int index, int y) { coords[index][1] = y; } int x(int index) { return coords[index][0]; } int y(int index) { return coords[index][1]; } Tetrominoe getShape() { return pieceShape; } void setRandomShape() { var r = new Random(); int x = Math.abs(r.nextInt()) % 7 + 1; Tetrominoe[] values = Tetrominoe.values(); setShape(values[x]); } public int minX() { int m = coords[0][0]; for (int i = 0; i < 4; i++) { m = Math.min(m, coords[i][0]); } return m; } int minY() { int m = coords[0][1]; for (int i = 0; i < 4; i++) { m = Math.min(m, coords[i][1]); } return m; } Shape rotateLeft() { if (pieceShape == Tetrominoe.SquareShape) { return this; } var result = new Shape(); result.pieceShape = pieceShape; for (int i = 0; i < 4; i++) { result.setX(i, y(i)); result.setY(i, -x(i)); } return result; } Shape rotateRight() { if (pieceShape == Tetrominoe.SquareShape) { return this; } var result = new Shape(); result.pieceShape = pieceShape; for (int i = 0; i < 4; i++) { result.setX(i, -y(i)); result.setY(i, x(i)); } return result; } }
Shape
类提供了关于 Tetris 方块的信息。
protected enum Tetrominoe { NoShape, ZShape, SShape, LineShape, TShape, SquareShape, LShape, MirroredLShape }
Tetrominoe
枚举包含七种 Tetris 形状名称以及一个名为 NoShape
的空形状。
coords = new int[4][2]; setShape(Tetrominoe.NoShape);
coords
数组保存 Tetris 方块的实际坐标。
int[][][] coordsTable = new int[][][]{ {{0, 0}, {0, 0}, {0, 0}, {0, 0}}, {{0, -1}, {0, 0}, {-1, 0}, {-1, 1}}, {{0, -1}, {0, 0}, {1, 0}, {1, 1}}, {{0, -1}, {0, 0}, {0, 1}, {0, 2}}, {{-1, 0}, {0, 0}, {1, 0}, {0, 1}}, {{0, 0}, {1, 0}, {0, 1}, {1, 1}}, {{-1, -1}, {0, -1}, {0, 0}, {0, 1}}, {{1, -1}, {0, -1}, {0, 0}, {0, 1}} };
coordsTable
数组保存 Tetris 方块的所有可能坐标值。这是一个所有方块都从其中获取坐标值的模板。
for (int i = 0; i < 4; i++) { System.arraycopy(coordsTable[shape.ordinal()], 0, coords, 0, 4); }
我们将 coordsTable
中的一行坐标值放入 Tetris 方块的 coords
数组中。注意 ordinal()
方法的用法。在 C++ 中,枚举类型本质上是一个整数。与 C++ 不同,Java 枚举是完整的类,ordinal()
方法返回枚举类型在枚举对象中的当前位置。
下图有助于更好地理解坐标值。coords 数组保存 Tetris 方块的坐标。例如,数字 (-1, 1)、(-1, 0)、(0, 0) 和 (0, -1) 表示旋转的 S 形。下图说明了形状。

Shape rotateLeft() { if (pieceShape == Tetrominoe.SquareShape) { return this; } var result = new Shape(); result.pieceShape = pieceShape; for (int i = 0; i < 4; i++) { result.setX(i, y(i)); result.setY(i, -x(i)); } return result; }
这段代码将方块向左旋转。正方形不需要旋转。因此,我们只需返回当前对象的引用。查看上图有助于理解旋转。
package com.zetcode; import com.zetcode.Shape.Tetrominoe; import javax.swing.JLabel; import javax.swing.JPanel; import javax.swing.Timer; import java.awt.Color; import java.awt.Graphics; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import java.awt.event.KeyAdapter; import java.awt.event.KeyEvent; public class Board extends JPanel { private final int BOARD_WIDTH = 10; private final int BOARD_HEIGHT = 22; private final int PERIOD_INTERVAL = 300; private Timer timer; private boolean isFallingFinished = false; private boolean isPaused = false; private int numLinesRemoved = 0; private int curX = 0; private int curY = 0; private JLabel statusbar; private Shape curPiece; private Tetrominoe[] board; public Board(Tetris parent) { initBoard(parent); } private void initBoard(Tetris parent) { setFocusable(true); statusbar = parent.getStatusBar(); addKeyListener(new TAdapter()); } private int squareWidth() { return (int) getSize().getWidth() / BOARD_WIDTH; } private int squareHeight() { return (int) getSize().getHeight() / BOARD_HEIGHT; } private Tetrominoe shapeAt(int x, int y) { return board[(y * BOARD_WIDTH) + x]; } void start() { curPiece = new Shape(); board = new Tetrominoe[BOARD_WIDTH * BOARD_HEIGHT]; clearBoard(); newPiece(); timer = new Timer(PERIOD_INTERVAL, new GameCycle()); timer.start(); } private void pause() { isPaused = !isPaused; if (isPaused) { statusbar.setText("paused"); } else { statusbar.setText(String.valueOf(numLinesRemoved)); } repaint(); } @Override public void paintComponent(Graphics g) { super.paintComponent(g); doDrawing(g); } private void doDrawing(Graphics g) { var size = getSize(); int boardTop = (int) size.getHeight() - BOARD_HEIGHT * squareHeight(); for (int i = 0; i < BOARD_HEIGHT; i++) { for (int j = 0; j < BOARD_WIDTH; j++) { Tetrominoe shape = shapeAt(j, BOARD_HEIGHT - i - 1); if (shape != Tetrominoe.NoShape) { drawSquare(g, j * squareWidth(), boardTop + i * squareHeight(), shape); } } } if (curPiece.getShape() != Tetrominoe.NoShape) { for (int i = 0; i < 4; i++) { int x = curX + curPiece.x(i); int y = curY - curPiece.y(i); drawSquare(g, x * squareWidth(), boardTop + (BOARD_HEIGHT - y - 1) * squareHeight(), curPiece.getShape()); } } } private void dropDown() { int newY = curY; while (newY > 0) { if (!tryMove(curPiece, curX, newY - 1)) { break; } newY--; } pieceDropped(); } private void oneLineDown() { if (!tryMove(curPiece, curX, curY - 1)) { pieceDropped(); } } private void clearBoard() { for (int i = 0; i < BOARD_HEIGHT * BOARD_WIDTH; i++) { board[i] = Tetrominoe.NoShape; } } private void pieceDropped() { for (int i = 0; i < 4; i++) { int x = curX + curPiece.x(i); int y = curY - curPiece.y(i); board[(y * BOARD_WIDTH) + x] = curPiece.getShape(); } removeFullLines(); if (!isFallingFinished) { newPiece(); } } private void newPiece() { curPiece.setRandomShape(); curX = BOARD_WIDTH / 2 + 1; curY = BOARD_HEIGHT - 1 + curPiece.minY(); if (!tryMove(curPiece, curX, curY)) { curPiece.setShape(Tetrominoe.NoShape); timer.stop(); var msg = String.format("Game over. Score: %d", numLinesRemoved); statusbar.setText(msg); } } private boolean tryMove(Shape newPiece, int newX, int newY) { for (int i = 0; i < 4; i++) { int x = newX + newPiece.x(i); int y = newY - newPiece.y(i); if (x < 0 || x >= BOARD_WIDTH || y < 0 || y >= BOARD_HEIGHT) { return false; } if (shapeAt(x, y) != Tetrominoe.NoShape) { return false; } } curPiece = newPiece; curX = newX; curY = newY; repaint(); return true; } private void removeFullLines() { int numFullLines = 0; for (int i = BOARD_HEIGHT - 1; i >= 0; i--) { boolean lineIsFull = true; for (int j = 0; j < BOARD_WIDTH; j++) { if (shapeAt(j, i) == Tetrominoe.NoShape) { lineIsFull = false; break; } } if (lineIsFull) { numFullLines++; for (int k = i; k < BOARD_HEIGHT - 1; k++) { for (int j = 0; j < BOARD_WIDTH; j++) { board[(k * BOARD_WIDTH) + j] = shapeAt(j, k + 1); } } } } if (numFullLines > 0) { numLinesRemoved += numFullLines; statusbar.setText(String.valueOf(numLinesRemoved)); isFallingFinished = true; curPiece.setShape(Tetrominoe.NoShape); } } private void drawSquare(Graphics g, int x, int y, Tetrominoe shape) { Color colors[] = {new Color(0, 0, 0), new Color(204, 102, 102), new Color(102, 204, 102), new Color(102, 102, 204), new Color(204, 204, 102), new Color(204, 102, 204), new Color(102, 204, 204), new Color(218, 170, 0) }; var color = colors[shape.ordinal()]; g.setColor(color); g.fillRect(x + 1, y + 1, squareWidth() - 2, squareHeight() - 2); g.setColor(color.brighter()); g.drawLine(x, y + squareHeight() - 1, x, y); g.drawLine(x, y, x + squareWidth() - 1, y); g.setColor(color.darker()); g.drawLine(x + 1, y + squareHeight() - 1, x + squareWidth() - 1, y + squareHeight() - 1); g.drawLine(x + squareWidth() - 1, y + squareHeight() - 1, x + squareWidth() - 1, y + 1); } private class GameCycle implements ActionListener { @Override public void actionPerformed(ActionEvent e) { doGameCycle(); } } private void doGameCycle() { update(); repaint(); } private void update() { if (isPaused) { return; } if (isFallingFinished) { isFallingFinished = false; newPiece(); } else { oneLineDown(); } } class TAdapter extends KeyAdapter { @Override public void keyPressed(KeyEvent e) { if (curPiece.getShape() == Tetrominoe.NoShape) { return; } int keycode = e.getKeyCode(); // Java 12 switch expressions switch (keycode) { case KeyEvent.VK_P -> pause(); case KeyEvent.VK_LEFT -> tryMove(curPiece, curX - 1, curY); case KeyEvent.VK_RIGHT -> tryMove(curPiece, curX + 1, curY); case KeyEvent.VK_DOWN -> tryMove(curPiece.rotateRight(), curX, curY); case KeyEvent.VK_UP -> tryMove(curPiece.rotateLeft(), curX, curY); case KeyEvent.VK_SPACE -> dropDown(); case KeyEvent.VK_D -> oneLineDown(); } } } }
最后,我们有了 Board.java
文件。游戏逻辑就位于这里。
private final int BOARD_WIDTH = 10; private final int BOARD_HEIGHT = 22; private final int PERIOD_INTERVAL = 300;
我们有四个常量。BOARD_WIDTH
和 BOARD_HEIGHT
定义了棋盘的大小。PERIOD_INTERVAL
常量定义了游戏的速度。
... private boolean isFallingFinished = false; private boolean isStarted = false; private boolean isPaused = false; private int numLinesRemoved = 0; private int curX = 0; private int curY = 0; ...
初始化了一些重要变量。isFallingFinished
确定 Tetris 方块是否已完成掉落,然后我们需要创建一个新方块。isStarted
用于检查游戏是否已开始。同样,isPaused
用于检查游戏是否已暂停。numLinesRemoved
计算到目前为止我们已移除的行数。curX
和 curY
确定掉落的 Tetris 方块的实际位置。
private int squareWidth() { return (int) getSize().getWidth() / BOARD_WIDTH; } private int squareHeight() { return (int) getSize().getHeight() / BOARD_HEIGHT; }
这些行决定了单个 Tetrominoe 方块的宽度和高度。
private Tetrominoe shapeAt(int x, int y) { return board[(y * BOARD_WIDTH) + x]; }
我们确定给定坐标处的形状。形状存储在 board
数组中。
void start() { curPiece = new Shape(); board = new Tetrominoe[BOARD_WIDTH * BOARD_HEIGHT]; ...
我们创建一个新的当前形状和一个新的棋盘。
clearBoard(); newPiece();
棋盘被清空,并初始化了新的掉落方块。
timer = new Timer(PERIOD_INTERVAL, new GameCycle()); timer.start();
我们创建了一个定时器。定时器以 PERIOD_INTERVAL
的间隔执行,创建游戏循环。
private void pause() { isPaused = !isPaused; if (isPaused) { statusbar.setText("paused"); } else { statusbar.setText(String.valueOf(numLinesRemoved)); } repaint(); }
pause()
方法暂停或恢复游戏。当游戏暂停时,我们在状态栏中显示 paused
消息。
在 doDrawing()
方法中,我们在棋盘上绘制所有对象。绘图分为两个步骤。
for (int i = 0; i < BOARD_HEIGHT; i++) { for (int j = 0; j < BOARD_WIDTH; j++) { Tetrominoe shape = shapeAt(j, BOARD_HEIGHT - i - 1); if (shape != Tetrominoe.NoShape) { drawSquare(g, j * squareWidth(), boardTop + i * squareHeight(), shape); } } }
第一步,我们绘制所有已掉落到棋盘底部的形状或形状的剩余部分。所有方块都存储在棋盘数组中。我们使用 shapeAt()
方法访问它。
if (curPiece.getShape() != Tetrominoe.NoShape) { for (int i = 0; i < 4; i++) { int x = curX + curPiece.x(i); int y = curY - curPiece.y(i); drawSquare(g, x * squareWidth(), boardTop + (BOARD_HEIGHT - y - 1) * squareHeight(), curPiece.getShape()); } }
第二步,我们绘制实际下落的方块。
private void dropDown() { int newY = curY; while (newY > 0) { if (!tryMove(curPiece, curX, newY - 1)) { break; } newY--; } pieceDropped(); }
如果我们按下 Space
键,方块将掉落到底部。我们只需尝试将方块向下移动一行,直到它到达底部或另一个已掉落的 Tetris 方块的顶部。当 Tetris 方块完成掉落时,会调用 pieceDropped()
。
private void oneLineDown() { if (!tryMove(curPiece, curX, curY - 1)) { pieceDropped(); } }
在 oneLineDown()
方法中,我们尝试将掉落的方块向下移动一行,直到它完全掉落。
private void clearBoard() { for (int i = 0; i < BOARD_HEIGHT * BOARD_WIDTH; i++) { board[i] = Tetrominoe.NoShape; } }
clearBoard()
方法用空的 Tetrominoe.NoShape
填充棋盘。这稍后用于碰撞检测。
private void pieceDropped() { for (int i = 0; i < 4; i++) { int x = curX + curPiece.x(i); int y = curY - curPiece.y(i); board[(y * BOARD_WIDTH) + x] = curPiece.getShape(); } removeFullLines(); if (!isFallingFinished) { newPiece(); } }
pieceDropped()
方法将掉落的方块放入 board
数组。再次说明,棋盘保存了所有方块以及已完成掉落的方块的剩余部分。当方块完成掉落后,就该检查我们是否可以移除棋盘上的某些行了。这是 removeFullLines()
方法的工作。然后我们创建一个新方块,或者更确切地说,我们尝试创建一个新方块。
private void newPiece() { curPiece.setRandomShape(); curX = BOARD_WIDTH / 2 + 1; curY = BOARD_HEIGHT - 1 + curPiece.minY(); if (!tryMove(curPiece, curX, curY)) { curPiece.setShape(Tetrominoe.NoShape); timer.stop(); var msg = String.format("Game over. Score: %d", numLinesRemoved); statusbar.setText(msg); } }
newPiece()
方法创建一个新的 Tetris 方块。方块获得一个新的随机形状。然后我们计算初始 curX
和 curY
值。如果我们无法移动到初始位置,游戏就结束了——我们“顶出”了。定时器停止,我们在状态栏中显示包含得分的 Game over
字符串。
private boolean tryMove(Shape newPiece, int newX, int newY) { for (int i = 0; i < 4; i++) { int x = newX + newPiece.x(i); int y = newY - newPiece.y(i); if (x < 0 || x >= BOARD_WIDTH || y < 0 || y >= BOARD_HEIGHT) { return false; } if (shapeAt(x, y) != Tetrominoe.NoShape) { return false; } } curPiece = newPiece; curX = newX; curY = newY; repaint(); return true; }
tryMove()
方法尝试移动 Tetris 方块。如果方块到达棋盘边界或与已掉落的 Tetris 方块相邻,则该方法返回 false
。
private void removeFullLines() { int numFullLines = 0; for (int i = BOARD_HEIGHT - 1; i >= 0; i--) { boolean lineIsFull = true; for (int j = 0; j < BOARD_WIDTH; j++) { if (shapeAt(j, i) == Tetrominoe.NoShape) { lineIsFull = false; break; } } if (lineIsFull) { numFullLines++; for (int k = i; k < BOARD_HEIGHT - 1; k++) { for (int j = 0; j < BOARD_WIDTH; j++) { board[(k * BOARD_WIDTH) + j] = shapeAt(j, k + 1); } } } } if (numFullLines > 0) { numLinesRemoved += numFullLines; statusbar.setText(String.valueOf(numLinesRemoved)); isFallingFinished = true; curPiece.setShape(Tetrominoe.NoShape); } }
在 removeFullLines()
方法中,我们检查棋盘上的所有行中是否有任何满行。如果至少有一行已满,则将其移除。在找到满行后,我们增加计数器。我们将满行上方的所有行向下移动一行。这样我们就消除了满行。请注意,在我们的 Tetris 游戏中,我们使用所谓的“朴素重力”。这意味着方块可能会漂浮在空隙上方。
private void drawSquare(Graphics g, int x, int y, Tetrominoe shape) { Color colors[] = {new Color(0, 0, 0), new Color(204, 102, 102), new Color(102, 204, 102), new Color(102, 102, 204), new Color(204, 204, 102), new Color(204, 102, 204), new Color(102, 204, 204), new Color(218, 170, 0) }; var color = colors[shape.ordinal()]; g.setColor(color); g.fillRect(x + 1, y + 1, squareWidth() - 2, squareHeight() - 2); g.setColor(color.brighter()); g.drawLine(x, y + squareHeight() - 1, x, y); g.drawLine(x, y, x + squareWidth() - 1, y); g.setColor(color.darker()); g.drawLine(x + 1, y + squareHeight() - 1, x + squareWidth() - 1, y + squareHeight() - 1); g.drawLine(x + squareWidth() - 1, y + squareHeight() - 1, x + squareWidth() - 1, y + 1); }
每个 Tetris 方块有四个方块。每个方块都通过 drawSquare()
方法绘制。Tetris 方块有不同的颜色。方块的左侧和顶侧用更亮的颜色绘制。类似地,底部和右侧用更暗的颜色绘制。这是为了模拟 3D 边缘。
private class GameCycle implements ActionListener { @Override public void actionPerformed(ActionEvent e) { doGameCycle(); } }
在 GameCycle
中,我们调用 doGameCycle()
方法,创建游戏循环。
private void doGameCycle() { update(); repaint(); }
游戏被分成游戏周期。每个周期都会更新游戏并重绘棋盘。
private void update() { if (isPaused) { return; } if (isFallingFinished) { isFallingFinished = false; newPiece(); } else { oneLineDown(); } }
update()
代表游戏的一个步骤。掉落的方块向下移动一行,或者在上一个方块完成掉落时创建一个新方块。
private class TAdapter extends KeyAdapter { @Override public void keyPressed(KeyEvent e) { ...
游戏由光标键控制。我们在 KeyAdapter
中检查按键事件。
int keycode = e.getKeyCode();
我们使用 getKeyCode()
方法获取按键代码。
// Java 12 switch expressions switch (keycode) { case KeyEvent.VK_P -> pause(); case KeyEvent.VK_LEFT -> tryMove(curPiece, curX - 1, curY); case KeyEvent.VK_RIGHT -> tryMove(curPiece, curX + 1, curY); case KeyEvent.VK_DOWN -> tryMove(curPiece.rotateRight(), curX, curY); case KeyEvent.VK_UP -> tryMove(curPiece.rotateLeft(), curX, curY); case KeyEvent.VK_SPACE -> dropDown(); case KeyEvent.VK_D -> oneLineDown(); }
使用 Java 12 的 switch 表达式,我们将按键事件绑定到方法。例如,使用 Space 键,我们可以将掉落的 Tetris 方块向下移动。
package com.zetcode; import java.awt.BorderLayout; import java.awt.EventQueue; import javax.swing.JFrame; import javax.swing.JLabel; /* Java Tetris game clone Author: Jan Bodnar Website: https://zetcode.cn */ public class Tetris extends JFrame { private JLabel statusbar; public Tetris() { initUI(); } private void initUI() { statusbar = new JLabel(" 0"); add(statusbar, BorderLayout.SOUTH); var board = new Board(this); add(board); board.start(); setTitle("Tetris"); setSize(200, 400); setDefaultCloseOperation(EXIT_ON_CLOSE); setLocationRelativeTo(null); } JLabel getStatusBar() { return statusbar; } public static void main(String[] args) { EventQueue.invokeLater(() -> { var game = new Tetris(); game.setVisible(true); }); } }
在 Tetris.java
文件中,我们设置了游戏。我们创建了一个用于玩游戏的棋盘。我们创建了一个状态栏。
statusbar = new JLabel(" 0"); add(statusbar, BorderLayout.SOUTH);
得分显示在一个标签中,该标签位于棋盘的底部。
var board = new Board(this); add(board); board.start();
创建棋盘并将其添加到容器中。start()
方法启动 Tetris 游戏。

这就是俄罗斯方块游戏。