ZetCode

俄罗斯方块

最后修改于 2023 年 7 月 17 日

在本章中,我们将用 Java Swing 创建一个俄罗斯方块游戏克隆。

俄罗斯方块

俄罗斯方块是有史以来最受欢迎的电脑游戏之一。最初的游戏是由俄罗斯程序员Alexey Pajitnov于 1985 年设计和编程的。从那时起,俄罗斯方块几乎在所有计算机平台上都有许多变体。我甚至手机上都有一个修改版的俄罗斯方块游戏。

俄罗斯方块被称为下落式方块益智游戏。在这个游戏中,我们有七种不同的形状,称为俄罗斯方块块。S 形、Z 形、T 形、L 形、直线形、镜像 L 形和方形。这些形状中的每一种都由四个方块组成。这些形状会向下掉落到棋盘上。俄罗斯方块游戏的目标是移动和旋转这些形状,使它们尽可能地契合。如果我们成功地形成一行,该行将被消除,我们就能得分。我们一直玩俄罗斯方块游戏,直到堆满顶部。

Tetrominoes
图:俄罗斯方块

开发

我们的俄罗斯方块游戏没有图像,我们使用 Swing 绘图 API 来绘制俄罗斯方块块。在每个电脑游戏背后,都有一个数学模型。俄罗斯方块也是如此。

一些关于游戏的想法。

为了更容易理解,我简化了一些游戏。游戏启动后立即开始。我们可以通过按 P 键来暂停游戏。空格键会将俄罗斯方块块立即下落到底部。D 键会将方块向下移动一行。(可用于稍微加速下落。)游戏以恒定速度进行,未实现加速。得分是我们移除的行数。

com/zetcode/Tetris.java
package com.zetcode;

import java.awt.BorderLayout;

import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.SwingUtilities;


public class Tetris extends JFrame {

    private JLabel statusbar;

    public Tetris() {

        initUI();
   }

   private void initUI() {

        statusbar = new JLabel(" 0");
        add(statusbar, BorderLayout.SOUTH);
        Board board = new Board(this);
        add(board);
        board.start();

        setSize(200, 400);
        setTitle("Tetris");
        setDefaultCloseOperation(EXIT_ON_CLOSE);
        setLocationRelativeTo(null);
   }

   public JLabel getStatusBar() {

       return statusbar;
   }

    public static void main(String[] args) {

        SwingUtilities.invokeLater(new Runnable() {

            @Override
            public void run() {

                Tetris game = new Tetris();
                game.setVisible(true);
            }
        });
    }
}

在 Tetris.java 文件中,我们设置了游戏。我们创建了一个我们玩游戏的棋盘。我们创建了一个状态栏。

board.start();

start方法启动俄罗斯方块游戏。在窗口出现在屏幕上后立即启动。

com/zetcode/Shape.java
package com.zetcode;

import java.util.Random;

public class Shape {

    protected enum Tetrominoes { NoShape, ZShape, SShape, LineShape,
               TShape, SquareShape, LShape, MirroredLShape };

    private Tetrominoes pieceShape;
    private int coords[][];
    private int[][][] coordsTable;


    public Shape() {

        coords = new int[4][2];
        setShape(Tetrominoes.NoShape);
    }

    public void setShape(Tetrominoes 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++) {

            for (int j = 0; j < 2; ++j) {

                coords[i][j] = coordsTable[shape.ordinal()][i][j];
            }
        }

        pieceShape = shape;
    }

    private void setX(int index, int x) { coords[index][0] = x; }
    private void setY(int index, int y) { coords[index][1] = y; }
    public int x(int index) { return coords[index][0]; }
    public int y(int index) { return coords[index][1]; }
    public Tetrominoes getShape()  { return pieceShape; }

    public void setRandomShape() {

        Random r = new Random();
        int x = Math.abs(r.nextInt()) % 7 + 1;
        Tetrominoes[] values = Tetrominoes.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;
    }


    public int minY() {

      int m = coords[0][1];

      for (int i=0; i < 4; i++) {

          m = Math.min(m, coords[i][1]);
      }

      return m;
    }

    public Shape rotateLeft() {

        if (pieceShape == Tetrominoes.SquareShape)
            return this;

        Shape 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;
    }

    public Shape rotateRight() {

        if (pieceShape == Tetrominoes.SquareShape)
            return this;

        Shape 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 Tetrominoes { NoShape, ZShape, SShape, LineShape,
            TShape, SquareShape, LShape, MirroredLShape };

Tetrominoes枚举包含了所有七种俄罗斯方块块形状。加上这里称为NoShape的空形状。

public Shape() {

    coords = new int[4][2];
    setShape(Tetrominoes.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++) {

    for (int j = 0; j < 2; ++j) {

        coords[i][j] = coordsTable[shape.ordinal()][i][j];
    }
}

在这里,我们将coordsTable中的一行坐标值放入俄罗斯方块块的coords数组中。请注意ordinal方法的用法。在 C++ 中,枚举类型本质上是一个整数。与 C++ 不同,Java 枚举是完整的类。ordinal()方法返回枚举对象中枚举类型的当前位置。

下图将有助于更好地理解坐标值。coords 数组保存俄罗斯方块块的坐标。例如,数字 { 0, -1 }、{ 0, 0 }、{ -1, 0 }、{ -1, -1 },表示一个旋转的 S 形。下图说明了该形状。

Coordinates
图:坐标
public Shape rotateLeft() {

    if (pieceShape == Tetrominoes.SquareShape)
        return this;

    Shape 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;
}

此代码将方块向左旋转。方形不需要旋转。这就是为什么我们只返回当前对象的引用。查看上图有助于理解旋转。

com/zetcode/Board.java
package com.zetcode;

import java.awt.Color;
import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.KeyAdapter;
import java.awt.event.KeyEvent;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.Timer;

import com.zetcode.Shape.Tetrominoes;

public class Board extends JPanel
        implements ActionListener {

    private final int BoardWidth = 10;
    private final int BoardHeight = 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 Tetrominoes[] board;

    public Board(Tetris parent) {

        initBoard(parent);
    }

    private void initBoard(Tetris parent) {

       setFocusable(true);
       curPiece = new Shape();
       timer = new Timer(400, this);
       timer.start();

       statusbar =  parent.getStatusBar();
       board = new Tetrominoes[BoardWidth * BoardHeight];
       addKeyListener(new TAdapter());
       clearBoard();
    }

    @Override
    public void actionPerformed(ActionEvent e) {

        if (isFallingFinished) {

            isFallingFinished = false;
            newPiece();
        } else {

            oneLineDown();
        }
    }

    private int squareWidth() { return (int) getSize().getWidth() / BoardWidth; }
    private int squareHeight() { return (int) getSize().getHeight() / BoardHeight; }
    private Tetrominoes shapeAt(int x, int y) { return board[(y * BoardWidth) + x]; }


    public 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) {

        Dimension size = getSize();
        int boardTop = (int) size.getHeight() - BoardHeight * squareHeight();

        for (int i = 0; i < BoardHeight; ++i) {

            for (int j = 0; j < BoardWidth; ++j) {

                Tetrominoes shape = shapeAt(j, BoardHeight - i - 1);

                if (shape != Tetrominoes.NoShape)
                    drawSquare(g, 0 + j * squareWidth(),
                               boardTop + i * squareHeight(), shape);
            }
        }

        if (curPiece.getShape() != Tetrominoes.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 + (BoardHeight - 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 < BoardHeight * BoardWidth; ++i)
            board[i] = Tetrominoes.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 * BoardWidth) + x] = curPiece.getShape();
        }

        removeFullLines();

        if (!isFallingFinished)
            newPiece();
    }

    private void newPiece()  {

        curPiece.setRandomShape();
        curX = BoardWidth / 2 + 1;
        curY = BoardHeight - 1 + curPiece.minY();

        if (!tryMove(curPiece, curX, curY)) {

            curPiece.setShape(Tetrominoes.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 >= BoardWidth || y < 0 || y >= BoardHeight)
                return false;

            if (shapeAt(x, y) != Tetrominoes.NoShape)
                return false;
        }

        curPiece = newPiece;
        curX = newX;
        curY = newY;

        repaint();

        return true;
    }

    private void removeFullLines() {

        int numFullLines = 0;

        for (int i = BoardHeight - 1; i >= 0; --i) {
            boolean lineIsFull = true;

            for (int j = 0; j < BoardWidth; ++j) {
                if (shapeAt(j, i) == Tetrominoes.NoShape) {
                    lineIsFull = false;
                    break;
                }
            }

            if (lineIsFull) {
                ++numFullLines;
                for (int k = i; k < BoardHeight - 1; ++k) {
                    for (int j = 0; j < BoardWidth; ++j)
                         board[(k * BoardWidth) + j] = shapeAt(j, k + 1);
                }
            }
        }

        if (numFullLines > 0) {

            numLinesRemoved += numFullLines;
            statusbar.setText(String.valueOf(numLinesRemoved));
            isFallingFinished = true;
            curPiece.setShape(Tetrominoes.NoShape);
            repaint();
        }
     }

    private void drawSquare(Graphics g, int x, int y, Tetrominoes 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)
        };

        Color 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() == Tetrominoes.NoShape) {
                 return;
             }

             int keycode = e.getKeyCode();

             if (keycode == 'p' || 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 'd':
                 oneLineDown();
                 break;

             case 'D':
                 oneLineDown();
                 break;
             }
         }
     }
}

最后,我们有了 Board.java 文件。游戏逻辑就在这里。

...
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计算我们到目前为止移除的行数。curXcurY变量确定了下落的俄罗斯方块块的实际位置。

setFocusable(true);

我们必须显式调用setFocusable方法。从现在开始,棋盘具有键盘输入。

timer = new Timer(400, this);
timer.start();

Timer对象在指定的延迟后触发一个或多个动作事件。在我们的例子中,计时器每 400 毫秒调用一次actionPerformed方法。

@Override
public void actionPerformed(ActionEvent e) {

    if (isFallingFinished) {

        isFallingFinished = false;
        newPiece();
    } else {

        oneLineDown();
    }
}

actionPerformed方法检查下落是否完成。如果完成,则创建一个新方块。如果没有,则下落的俄罗斯方块块向下移动一行。

在 doDrawing() 方法中,我们在棋盘上绘制所有对象。绘制分为两个步骤。

for (int i = 0; i < BoardHeight; ++i) {

    for (int j = 0; j < BoardWidth; ++j) {

        Tetrominoes shape = shapeAt(j, BoardHeight - i - 1);

        if (shape != Tetrominoes.NoShape)
            drawSquare(g, 0 + j * squareWidth(),
                        boardTop + i * squareHeight(), shape);
    }
}

在第一步中,我们绘制所有已经落到棋盘底部的形状,或其剩余部分。所有方块都保存在棋盘数组中。我们使用shapeAt方法访问它。

if (curPiece.getShape() != Tetrominoes.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 + (BoardHeight - 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 clearBoard() {

    for (int i = 0; i < BoardHeight * BoardWidth; ++i)
        board[i] = Tetrominoes.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 * BoardWidth) + x] = curPiece.getShape();
    }

    removeFullLines();

    if (!isFallingFinished)
        newPiece();
}

pieceDropped方法将下落的方块放入board数组中。再次强调,棋盘保存了所有方块的方块以及已完成下落的方块的剩余部分。当方块完成下落时,就需要检查我们是否可以移除棋盘上的一些行。这是removeFullLines方法的任务。然后我们创建一个新方块。更准确地说,我们尝试创建一个新方块。

private void newPiece()  {

    curPiece.setRandomShape();
    curX = BoardWidth / 2 + 1;
    curY = BoardHeight - 1 + curPiece.minY();

    if (!tryMove(curPiece, curX, curY)) {

        curPiece.setShape(Tetrominoes.NoShape);
        timer.stop();
        isStarted = false;
        statusbar.setText("game over");
    }
}

newPiece方法创建一个新的俄罗斯方块块。该方块获得一个新的随机形状。然后我们计算初始的curXcurY值。如果我们无法移动到初始位置,则游戏结束。我们堆满了顶部。计时器停止。我们在状态栏上显示游戏结束字符串。

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 >= BoardWidth || y < 0 || y >= BoardHeight)
            return false;

        if (shapeAt(x, y) != Tetrominoes.NoShape)
            return false;
    }

    curPiece = newPiece;
    curX = newX;
    curY = newY;

    repaint();

    return true;
}

tryMove方法尝试移动俄罗斯方块块。如果它到达棋盘边界或与已落下的俄罗斯方块块相邻,则该方法返回 false。

int numFullLines = 0;

for (int i = BoardHeight - 1; i >= 0; --i) {
    boolean lineIsFull = true;

    for (int j = 0; j < BoardWidth; ++j) {
        if (shapeAt(j, i) == Tetrominoes.NoShape) {
            lineIsFull = false;
            break;
        }
    }

    if (lineIsFull) {
        ++numFullLines;
        for (int k = i; k < BoardHeight - 1; ++k) {
            for (int j = 0; j < BoardWidth; ++j)
                    board[(k * BoardWidth) + 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;

如果我们按下左箭头键,我们会尝试将下落的方块向左移动一个方块。

Tetris
图:俄罗斯方块

这就是俄罗斯方块游戏。