俄罗斯方块
最后修改于 2023 年 1 月 10 日
在本章中,我们将使用 Java Swing 创建一个俄罗斯方块游戏的克隆。
俄罗斯方块
俄罗斯方块游戏是有史以来最受欢迎的电脑游戏之一。最初的游戏是由俄罗斯程序员 Alexey Pajitnov 在 1985 年设计和编程的。从那时起,俄罗斯方块就在几乎所有计算机平台上都有很多变种。
俄罗斯方块被称为下落方块益智游戏。在这个游戏中,我们有七种不同的形状,称为 tetrominoes(四格骨牌):S形,Z形,T形,L形,直线形,镜像L形和正方形。这些形状中的每一个都由四个正方形组成。这些形状从上方落下。俄罗斯方块游戏的目标是移动和旋转这些形状,使它们尽可能地吻合。如果我们设法形成一行,该行将被摧毁,我们得分。我们玩俄罗斯方块游戏直到游戏结束。

开发
我们没有为我们的俄罗斯方块游戏准备图像,我们使用 Swing 绘图 API 来绘制四格骨牌。在每个电脑游戏的背后,都有一个数学模型。俄罗斯方块也是如此。
一些关于游戏的想法。
- 我们使用
Timer
类来创建一个游戏循环 - 四格骨牌被绘制出来
- 形状以一个正方形一个正方形为基础移动(而不是逐像素)
- 从数学上讲,一个棋盘是一个简单的数字列表
游戏被简化了,以便更容易理解。游戏在启动后立即开始。我们可以通过按 p 键来暂停游戏。 空格键 将俄罗斯方块掉到底部。 d 键将方块下落一行。(它可以用来稍微加速下落。)游戏以恒定的速度进行,没有实现加速。分数是我们移除的行数。
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
中,我们设置游戏。我们创建一个游戏面板,在上面玩游戏。我们创建一个状态栏。
board.start();
start()
方法启动俄罗斯方块游戏。
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[][]; private int[][][] coordsTable; public Shape() { coords = new int[4][2]; setShape(Tetrominoe.NoShape); } void setShape(Tetrominoe shape) { 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
提供了关于俄罗斯方块的信息。
protected enum Tetrominoe { NoShape, ZShape, SShape, LineShape, TShape, SquareShape, LShape, MirroredLShape }
Tetrominoe
包含七个俄罗斯方块的形状名称和名为 NoShape
的空形状。
public Shape() { coords = new int[4][2]; setShape(Tetrominoe.NoShape); }
这是 Shape
类的构造函数。 coords
数组保存俄罗斯方块的实际坐标。
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
数组保存了我们的俄罗斯方块所有可能的坐标值。这是一个模板,所有方块都从中获取它们的坐标值。
for (int i = 0; i < 4; i++) { System.arraycopy(coordsTable[shape.ordinal()], 0, coords, 0, 4); }
在这里,我们从 coordsTable
复制一行坐标值到俄罗斯方块的 coords
数组。注意 ordinal()
方法的使用。在 C++ 中,枚举类型本质上是一个整数。与 C++ 不同的是,Java 枚举是完整的类,并且 ordinal()
方法返回枚举类型在枚举对象中的当前位置。

这张图片有助于更好地理解坐标值。 coords
数组保存了俄罗斯方块的坐标。下图说明了旋转后的 S 形。它具有以下坐标:(-1,1),(-1,0),(0,0)和(0,-1)。
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; }
此代码将方块向右旋转。正方形不需要旋转。这就是为什么在 Tetrominoe.SquareShape
的情况下,我们简单地返回对当前对象的引用。查看前面的图像将有助于更好地理解旋转。
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 implements ActionListener { private final int BOARD_WIDTH = 10; private final int BOARD_HEIGHT = 22; private Timer timer; private boolean isFallingFinished = false; private boolean isStarted = 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); curPiece = new Shape(); int DELAY = 400; timer = new Timer(DELAY, this); timer.start(); statusbar = parent.getStatusBar(); board = new Tetrominoe[BOARD_WIDTH * BOARD_HEIGHT]; addKeyListener(new TAdapter()); clearBoard(); } @Override public void actionPerformed(ActionEvent e) { if (isFallingFinished) { isFallingFinished = false; newPiece(); } else { oneLineDown(); } } 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() { if (isPaused) { return; } isStarted = true; isFallingFinished = false; numLinesRemoved = 0; clearBoard(); newPiece(); timer.start(); } private void pause() { if (!isStarted) { return; } isPaused = !isPaused; if (isPaused) { timer.stop(); statusbar.setText("paused"); } else { timer.start(); statusbar.setText(String.valueOf(numLinesRemoved)); } repaint(); } 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()); } } } @Override public void paintComponent(Graphics g) { super.paintComponent(g); doDrawing(g); } 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(); isStarted = false; statusbar.setText("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; } 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); repaint(); } } 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); } class TAdapter extends KeyAdapter { @Override public void keyPressed(KeyEvent e) { if (!isStarted || curPiece.getShape() == Tetrominoe.NoShape) { return; } int keycode = e.getKeyCode(); if (keycode == 'P') { pause(); return; } if (isPaused) { return; } switch (keycode) { case KeyEvent.VK_LEFT: tryMove(curPiece, curX - 1, curY); break; case KeyEvent.VK_RIGHT: tryMove(curPiece, curX + 1, curY); break; case KeyEvent.VK_DOWN: tryMove(curPiece.rotateRight(), curX, curY); break; case KeyEvent.VK_UP: tryMove(curPiece.rotateLeft(), curX, curY); break; case KeyEvent.VK_SPACE: dropDown(); break; case KeyEvent.VK_D: oneLineDown(); break; } } } }
游戏逻辑位于 Board
中。
private final int BOARD_WIDTH = 10; private final int BOARD_HEIGHT = 22;
BOARD_WIDTH
和 BOARD_HEIGHT
常量定义了游戏面板的大小。
... 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
变量确定俄罗斯方块的形状是否已完成下落,然后我们需要创建一个新形状。 numLinesRemoved
计算我们到目前为止移除的行数。 curX
和 curY
变量确定下落的俄罗斯方块的实际位置。
setFocusable(true);
我们必须显式地调用 setFocusable()
方法。从现在开始,游戏面板将接收键盘输入。
int DELAY = 400;
DELAY
常量定义了游戏的速度。
timer = new Timer(DELAY, this); timer.start();
Timer
对象在指定的延迟后触发一个或多个动作事件。在我们的例子中,计时器每 DELAY
毫秒调用一次 actionPerformed()
方法。
@Override public void actionPerformed(ActionEvent e) { if (isFallingFinished) { isFallingFinished = false; newPiece(); } else { oneLineDown(); } }
actionPerformed()
方法检查下落是否已完成。如果是,则创建一个新方块。如果不是,则下落的俄罗斯方块下降一行。
在 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, 0 + 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(); }
如果我们按 空格键,方块会掉到底部。我们只是尝试将方块下落一行,直到它到达底部或另一个已落下的俄罗斯方块的顶部。当俄罗斯方块完成下落时,将调用 pieceDropped()
。
private void clearBoard() { for (int i = 0; i < BOARD_HEIGHT * BOARD_WIDTH; ++i) { board[i] = Tetrominoe.NoShape; } }
clearBoard()
方法用空的 NoShapes
填充游戏面板。
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(); isStarted = false; statusbar.setText("game over"); } }
newPiece()
方法创建一个新的俄罗斯方块。方块获得一个新的随机形状。然后我们计算初始的 curX
和 curY
值。如果我们无法移动到初始位置,游戏就结束了。我们失败了。计时器停止。我们在状态栏上显示游戏结束字符串。
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()
方法尝试移动俄罗斯方块。如果它已到达游戏面板边界或与已经落下的俄罗斯方块相邻,则该方法返回 false。
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); } } }
在 removeFullLines()
方法内部,我们检查 board
中的所有行中是否有任何完整行。如果至少有一条完整的行,则将其删除。在找到一条完整行后,我们增加计数器。我们将完整行之上的所有行下移一行。通过这种方式,我们破坏了完整的行。请注意,在我们的俄罗斯方块游戏中,我们使用所谓的朴素重力。这意味着正方形可能会悬浮在空的间隙上方。
每个俄罗斯方块都有四个正方形。每个正方形都使用 drawSquare()
方法绘制。俄罗斯方块具有不同的颜色。
g.setColor(color.brighter()); g.drawLine(x, y + squareHeight() - 1, x, y); g.drawLine(x, y, x + squareWidth() - 1, y);
正方形的左侧和上侧用较亮的颜色绘制。类似地,底部和右侧用较暗的颜色绘制。这是为了模拟 3D 边缘。
我们使用键盘控制游戏。控制机制通过 KeyAdapter
实现。这是一个内部类,它覆盖了 keyPressed()
方法。
case KeyEvent.VK_LEFT: tryMove(curPiece, curX - 1, curY); break;
如果我们按下左箭头键,我们尝试将下落的方块向左移动一个正方形。

这就是俄罗斯方块游戏。