ZetCode

Breakout 游戏

最后修改于 2023 年 1 月 10 日

在 Java 2D 游戏教程的这一部分,我们创建一个简单的 Breakout 游戏克隆。源代码和图像可以在作者的 Github Java-Breakout-Game 存储库中找到。

Breakout 是雅达利公司(Atari Inc.)最初开发的一款街机游戏。该游戏于 1976 年创作。

开发

在我们的游戏中,有一个挡板,一个球和 30 个砖块。我用 Inkscape 创建了球、挡板和砖块的图像。我们使用计时器来创建游戏周期。我们不使用角度,而是简单地改变方向:上、下、左、右。我受到 pybreakout 游戏的启发,它是由 Nathan Dawson 在 PyGame 库中开发的。

在这款游戏中,玩家在屏幕上移动挡板,并反弹一个或多个球。目标是摧毁窗口顶部的砖块。

游戏包含七个文件:Commons.java, Sprite.java, Ball.java, Paddle.java, Brick.java, Board.java, 和 Breakout.java

com/zetcode/Commons.java
package com.zetcode;

public interface Commons {

    int WIDTH = 300;
    int HEIGHT = 400;
    int BOTTOM_EDGE = 390;
    int N_OF_BRICKS = 30;
    int INIT_PADDLE_X = 200;
    int INIT_PADDLE_Y = 360;
    int INIT_BALL_X = 230;
    int INIT_BALL_Y = 355;
    int PERIOD = 10;
}

Commons.java 文件包含一些通用常量。WIDTHHEIGHT 常量存储了棋盘的尺寸。当球越过 BOTTOM_EDGE 时,游戏结束。N_OF_BRICKS 是游戏中砖块的数量。INIT_PADDLE_XINIT_PADDLE_Y 是挡板对象的初始坐标。INIT_BALL_XINIT_BALL_Y 是球对象的初始坐标。DELAY 是任务执行前的初始延迟(毫秒),PERIOD 是构成游戏周期的连续任务执行之间的时间间隔(毫秒)。

com/zetcode/Sprite.java
package com.zetcode;

import java.awt.Image;
import java.awt.Rectangle;

public class Sprite {

    int x;
    int y;
    int imageWidth;
    int imageHeight;
    Image image;

    protected void setX(int x) {

        this.x = x;
    }

    int getX() {

        return x;
    }

    protected void setY(int y) {

        this.y = y;
    }

    int getY() {

        return y;
    }

    int getImageWidth() {

        return imageWidth;
    }

    int getImageHeight() {

        return imageHeight;
    }

    Image getImage() {

        return image;
    }

    Rectangle getRect() {

        return new Rectangle(x, y,
                image.getWidth(null), image.getHeight(null));
    }

    void getImageDimensions() {

        imageWidth = image.getWidth(null);
        imageHeight = image.getHeight(null);
    }
}

Sprite 类是 Board 中所有对象的基类。我们将所有在 Ball, Brick, 和 Paddle 对象中的方法和变量放在这里,例如 getImage()getX() 方法。

com/zetcode/Brick.java
package com.zetcode;

import javax.swing.ImageIcon;

public class Brick extends Sprite {

    private boolean destroyed;

    public Brick(int x, int y) {

        initBrick(x, y);
    }

    private void initBrick(int x, int y) {

        this.x = x;
        this.y = y;

        destroyed = false;

        loadImage();
        getImageDimensions();
    }

    private void loadImage() {

        var ii = new ImageIcon("src/resources/brick.png");
        image = ii.getImage();
    }

    boolean isDestroyed() {

        return destroyed;
    }

    void setDestroyed(boolean val) {

        destroyed = val;
    }
}

这是 Brick 类。

private boolean destroyed;

destroyed 变量中,我们保留了砖块的状态。

com/zetcode/Ball.java
package com.zetcode;

import javax.swing.ImageIcon;

public class Ball extends Sprite {

    private int xdir;
    private int ydir;

    public Ball() {

        initBall();
    }

    private void initBall() {

        xdir = 1;
        ydir = -1;

        loadImage();
        getImageDimensions();
        resetState();
    }

    private void loadImage() {

        var ii = new ImageIcon("src/resources/ball.png");
        image = ii.getImage();
    }

    void move() {

        x += xdir;
        y += ydir;

        if (x == 0) {

            setXDir(1);
        }

        if (x == Commons.WIDTH - imageWidth) {

            System.out.println(imageWidth);
            setXDir(-1);
        }

        if (y == 0) {

            setYDir(1);
        }
    }

    private void resetState() {

        x = Commons.INIT_BALL_X;
        y = Commons.INIT_BALL_Y;
    }

    void setXDir(int x) {

        xdir = x;
    }

    void setYDir(int y) {

        ydir = y;
    }

    int getYDir() {

        return ydir;
    }
}

这是 Ball 类。

void move() {

    x += xdir;
    y += ydir;

    if (x == 0) {

        setXDir(1);
    }

    if (x == Commons.WIDTH - imageWidth) {

        System.out.println(imageWidth);
        setXDir(-1);
    }

    if (y == 0) {

        setYDir(1);
    }
}

move() 方法在 Board 上移动球。如果球碰到边界,方向会相应改变。

void setXDir(int x) {

    xdir = x;
}

void setYDir(int y) {

    ydir = y;
}

这两个方法在球碰到挡板或砖块时被调用。

com/zetcode/Paddle.java
package com.zetcode;

import java.awt.event.KeyEvent;
import javax.swing.ImageIcon;

public class Paddle extends Sprite  {

    private int dx;

    public Paddle() {

        initPaddle();
    }

    private void initPaddle() {

        loadImage();
        getImageDimensions();

        resetState();
    }

    private void loadImage() {

        var ii = new ImageIcon("src/resources/paddle.png");
        image = ii.getImage();
    }

    void move() {

        x += dx;

        if (x <= 0) {

            x = 0;
        }

        if (x >= Commons.WIDTH - imageWidth) {

            x = Commons.WIDTH - imageWidth;
        }
    }

    void keyPressed(KeyEvent e) {

        int key = e.getKeyCode();

        if (key == KeyEvent.VK_LEFT) {

            dx = -1;
        }

        if (key == KeyEvent.VK_RIGHT) {

            dx = 1;
        }
    }

    void keyReleased(KeyEvent e) {

        int key = e.getKeyCode();

        if (key == KeyEvent.VK_LEFT) {

            dx = 0;
        }

        if (key == KeyEvent.VK_RIGHT) {

            dx = 0;
        }
    }

    private void resetState() {

        x = Commons.INIT_PADDLE_X;
        y = Commons.INIT_PADDLE_Y;
    }
}

这是 Paddle 类。它封装了 Breakout 游戏中的挡板对象。挡板通过左右箭头键进行控制。按下箭头键时,我们设置方向变量。释放箭头键时,我们将 dx 变量设置为零。这样挡板就会停止移动。

void move() {

    x += dx;

    if (x <= 0) {

        x = 0;
    }

    if (x >= Commons.WIDTH - imageWidth) {

        x = Commons.WIDTH - imageWidth;
    }
}

挡板只在水平方向移动,所以我们只更新 x 坐标。if 条件确保挡板不会穿过窗口边缘。

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

import javax.swing.JPanel;
import javax.swing.Timer;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.Font;
import java.awt.FontMetrics;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.Point;
import java.awt.RenderingHints;
import java.awt.Toolkit;
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 Timer timer;
    private String message = "Game Over";
    private Ball ball;
    private Paddle paddle;
    private Brick[] bricks;
    private boolean inGame = true;

    public Board() {

        initBoard();
    }

    private void initBoard() {

        addKeyListener(new TAdapter());
        setFocusable(true);
        setPreferredSize(new Dimension(Commons.WIDTH, Commons.HEIGHT));

        gameInit();
    }

    private void gameInit() {

        bricks = new Brick[Commons.N_OF_BRICKS];

        ball = new Ball();
        paddle = new Paddle();

        int k = 0;

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

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

                bricks[k] = new Brick(j * 40 + 30, i * 10 + 50);
                k++;
            }
        }

        timer = new Timer(Commons.PERIOD, new GameCycle());
        timer.start();
    }

    @Override
    public void paintComponent(Graphics g) {
        super.paintComponent(g);

        var g2d = (Graphics2D) g;

        g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
                RenderingHints.VALUE_ANTIALIAS_ON);

        g2d.setRenderingHint(RenderingHints.KEY_RENDERING,
                RenderingHints.VALUE_RENDER_QUALITY);

        if (inGame) {

            drawObjects(g2d);
        } else {

            gameFinished(g2d);
        }

        Toolkit.getDefaultToolkit().sync();
    }

    private void drawObjects(Graphics2D g2d) {

        g2d.drawImage(ball.getImage(), ball.getX(), ball.getY(),
                ball.getImageWidth(), ball.getImageHeight(), this);
        g2d.drawImage(paddle.getImage(), paddle.getX(), paddle.getY(),
                paddle.getImageWidth(), paddle.getImageHeight(), this);

        for (int i = 0; i < Commons.N_OF_BRICKS; i++) {

            if (!bricks[i].isDestroyed()) {

                g2d.drawImage(bricks[i].getImage(), bricks[i].getX(),
                        bricks[i].getY(), bricks[i].getImageWidth(),
                        bricks[i].getImageHeight(), this);
            }
        }
    }

    private void gameFinished(Graphics2D g2d) {

        var font = new Font("Verdana", Font.BOLD, 18);
        FontMetrics fontMetrics = this.getFontMetrics(font);

        g2d.setColor(Color.BLACK);
        g2d.setFont(font);
        g2d.drawString(message,
                (Commons.WIDTH - fontMetrics.stringWidth(message)) / 2,
                Commons.WIDTH / 2);
    }

    private class TAdapter extends KeyAdapter {

        @Override
        public void keyReleased(KeyEvent e) {

            paddle.keyReleased(e);
        }

        @Override
        public void keyPressed(KeyEvent e) {

            paddle.keyPressed(e);
        }
    }

    private class GameCycle implements ActionListener {

        @Override
        public void actionPerformed(ActionEvent e) {

            doGameCycle();
        }
    }

    private void doGameCycle() {

        ball.move();
        paddle.move();
        checkCollision();
        repaint();
    }

    private void stopGame() {

        inGame = false;
        timer.stop();
    }

    private void checkCollision() {

        if (ball.getRect().getMaxY() > Commons.BOTTOM_EDGE) {

            stopGame();
        }

        for (int i = 0, j = 0; i < Commons.N_OF_BRICKS; i++) {

            if (bricks[i].isDestroyed()) {

                j++;
            }

            if (j == Commons.N_OF_BRICKS) {

                message = "Victory";
                stopGame();
            }
        }

        if ((ball.getRect()).intersects(paddle.getRect())) {

            int paddleLPos = (int) paddle.getRect().getMinX();
            int ballLPos = (int) ball.getRect().getMinX();

            int first = paddleLPos + 8;
            int second = paddleLPos + 16;
            int third = paddleLPos + 24;
            int fourth = paddleLPos + 32;

            if (ballLPos < first) {

                ball.setXDir(-1);
                ball.setYDir(-1);
            }

            if (ballLPos >= first && ballLPos < second) {

                ball.setXDir(-1);
                ball.setYDir(-1 * ball.getYDir());
            }

            if (ballLPos >= second && ballLPos < third) {

                ball.setXDir(0);
                ball.setYDir(-1);
            }

            if (ballLPos >= third && ballLPos < fourth) {

                ball.setXDir(1);
                ball.setYDir(-1 * ball.getYDir());
            }

            if (ballLPos > fourth) {

                ball.setXDir(1);
                ball.setYDir(-1);
            }
        }

        for (int i = 0; i < Commons.N_OF_BRICKS; i++) {

            if ((ball.getRect()).intersects(bricks[i].getRect())) {

                int ballLeft = (int) ball.getRect().getMinX();
                int ballHeight = (int) ball.getRect().getHeight();
                int ballWidth = (int) ball.getRect().getWidth();
                int ballTop = (int) ball.getRect().getMinY();

                var pointRight = new Point(ballLeft + ballWidth + 1, ballTop);
                var pointLeft = new Point(ballLeft - 1, ballTop);
                var pointTop = new Point(ballLeft, ballTop - 1);
                var pointBottom = new Point(ballLeft, ballTop + ballHeight + 1);

                if (!bricks[i].isDestroyed()) {

                    if (bricks[i].getRect().contains(pointRight)) {

                        ball.setXDir(-1);
                    } else if (bricks[i].getRect().contains(pointLeft)) {

                        ball.setXDir(1);
                    }

                    if (bricks[i].getRect().contains(pointTop)) {

                        ball.setYDir(1);
                    } else if (bricks[i].getRect().contains(pointBottom)) {

                        ball.setYDir(-1);
                    }

                    bricks[i].setDestroyed(true);
                }
            }
        }
    }
}

这是 Board 类。我们在其中放置了游戏逻辑。

private void gameInit() {

    bricks = new Brick[Commons.N_OF_BRICKS];

    ball = new Ball();
    paddle = new Paddle();

    int k = 0;

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

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

            bricks[k] = new Brick(j * 40 + 30, i * 10 + 50);
            k++;
        }
    }

    timer = new Timer(Commons.PERIOD, new GameCycle());
    timer.start();
}

gameInit() 方法中,我们创建了一个球、一个挡板和三十个砖块。然后我们创建并启动一个计时器。

if (inGame) {

    drawObjects(g2d);
} else {

    gameFinished(g2d);
}

根据 inGame 变量,我们在 drawObjects() 方法中绘制所有对象,或者在 gameFinished() 方法中结束游戏。

private void drawObjects(Graphics2D g2d) {

    g2d.drawImage(ball.getImage(), ball.getX(), ball.getY(),
            ball.getImageWidth(), ball.getImageHeight(), this);
    g2d.drawImage(paddle.getImage(), paddle.getX(), paddle.getY(),
            paddle.getImageWidth(), paddle.getImageHeight(), this);

    for (int i = 0; i < Commons.N_OF_BRICKS; i++) {

        if (!bricks[i].isDestroyed()) {

            g2d.drawImage(bricks[i].getImage(), bricks[i].getX(),
                    bricks[i].getY(), bricks[i].getImageWidth(),
                    bricks[i].getImageHeight(), this);
        }
    }
}

drawObjects() 方法绘制游戏的所有对象。精灵图像使用 drawImage() 方法绘制。

private void gameFinished(Graphics2D g2d) {

    var font = new Font("Verdana", Font.BOLD, 18);
    FontMetrics fontMetrics = this.getFontMetrics(font);

    g2d.setColor(Color.BLACK);
    g2d.setFont(font);
    g2d.drawString(message,
            (Commons.WIDTH - fontMetrics.stringWidth(message)) / 2,
            Commons.WIDTH / 2);
}

gameFinished() 方法在窗口中间绘制“游戏结束”或“胜利”。

private class GameCycle implements ActionListener {

    @Override
    public void actionPerformed(ActionEvent e) {

        doGameCycle();
    }
}

计时器会周期性地调用 actionPerformed() 方法,该方法又调用 doGameCycle() 方法,从而创建一个游戏周期。

private void doGameCycle() {

    ball.move();
    paddle.move();
    checkCollision();
    repaint();
}

doGameCycle() 方法移动球和挡板。我们检查可能的碰撞并重新绘制屏幕。

private void checkCollision() {

    if (ball.getRect().getMaxY() > Commons.BOTTOM_EDGE) {

        stopGame();
    }
...

如果球碰到底部,我们就停止游戏。

for (int i = 0, j = 0; i < Commons.N_OF_BRICKS; i++) {

    if (bricks[i].isDestroyed()) {

        j++;
    }

    if (j == Commons.N_OF_BRICKS) {

        message = "Victory";
        stopGame();
    }
}

我们检查被摧毁的砖块数量。如果我们摧毁了所有 N_OF_BRICKS 块砖,我们就赢得了游戏。

if (ballLPos < first) {

    ball.setXDir(-1);
    ball.setYDir(-1);
}

如果球碰到挡板的第一部分,我们将球的方向改变为西北。

if (bricks[i].getRect().contains(pointTop)) {

    ball.setYDir(1);
}...

如果球击中砖块的底部,我们改变球的 y 方向;它向下移动。

com/zetcode/Breakout.java
package com.zetcode;

import javax.swing.JFrame;
import java.awt.EventQueue;

public class Breakout extends JFrame {

    public Breakout() {

        initUI();
    }

    private void initUI() {

        add(new Board());
        setTitle("Breakout");

        setDefaultCloseOperation(EXIT_ON_CLOSE);
        setLocationRelativeTo(null);
        setResizable(false);
        pack();
    }

    public static void main(String[] args) {

        EventQueue.invokeLater(() -> {

            var game = new Breakout();
            game.setVisible(true);
        });
    }
}

这是 Breakout 类,它包含主入口方法。

The Breakout game
图:Breakout 游戏

这就是 Breakout 游戏。