每日算法 — 使用java实现打砖块:AABB碰撞检测与反射向量计算

打砖块(Breakout)是街机黄金时代的经典之作,玩家操控底部挡板反弹小球击碎上方砖块。这款看似简单的游戏,其核心魅力在于物理碰撞的真实感——球与砖块、挡板、边界的每一次碰撞都需要精确的数学计算。本文将用Java完整实现带物理引擎的打砖块游戏,重点讲解AABB轴对齐边界框碰撞检测反射向量计算两大核心算法。

一、核心算法原理

1.1 AABB碰撞检测

AABB(Axis-Aligned Bounding Box,轴对齐边界框)是游戏开发中最常用的碰撞检测技术。由于砖块、挡板和小球的运动区域均与坐标轴对齐,AABB检测可以简化为区间重叠判断:

对于两个矩形 R1(x1, y1, w1, h1)R2(x2, y2, w2, h2),碰撞发生的充要条件是:

  • X轴区间重叠:x1 < x2 + w2x1 + w1 > x2
  • Y轴区间重叠:y1 < y2 + h2y1 + h1 > y2

该算法时间复杂度为 O(1),仅需4次比较运算,效率极高。

1.2 反射向量计算

小球碰撞后的反弹方向由入射向量碰撞面法向量共同决定。设小球入射速度向量为 v,碰撞面法向量为 n(单位向量),则反射向量 r 的计算公式为:

r = v - 2 * (v · n) * n

其中 v · n 表示向量点积。对于水平碰撞面(如砖块上下边界),法向量为 (0, 1)(0, -1),只需反转Y分量;对于垂直碰撞面,只需反转X分量。

1.3 挡板碰撞的角度控制

为增加游戏性,挡板碰撞采用相对位置映射法:根据小球击中挡板的水平位置,动态调整反射角度。击中挡板中心时垂直反弹,击中边缘时以较大角度斜向反弹。

二、完整Java实现

以下是可直接运行的完整项目结构,使用Swing进行渲染:

import javax.swing.*;
import java.awt.*;
import java.awt.event.*;
import java.util.ArrayList;
import java.util.List;

/**
 * 打砖块游戏主类
 * 核心算法:AABB碰撞检测 + 反射向量计算
 */
public class BreakoutGame extends JPanel implements ActionListener, KeyListener {
    // 游戏常量
    private static final int WIDTH = 800;
    private static final int HEIGHT = 600;
    private static final int BALL_RADIUS = 8;
    private static final int PADDLE_WIDTH = 100;
    private static final int PADDLE_HEIGHT = 12;
    private static final int BRICK_ROWS = 5;
    private static final int BRICK_COLS = 10;
    private static final int BRICK_WIDTH = 70;
    private static final int BRICK_HEIGHT = 25;
    private static final int BRICK_GAP = 5;
    private static final int TIMER_DELAY = 16; // ~60 FPS

    // 游戏状态
    private Ball ball;
    private Paddle paddle;
    private List<Brick> bricks;
    private Timer timer;
    private boolean gameRunning = true;
    private int score = 0;

    public BreakoutGame() {
        setPreferredSize(new Dimension(WIDTH, HEIGHT));
        setBackground(Color.BLACK);
        setFocusable(true);
        addKeyListener(this);
        initGame();
    }

    /** 初始化游戏对象 */
    private void initGame() {
        // 小球初始位置在挡板上方
        ball = new Ball(WIDTH / 2, HEIGHT - 60, 4, -4);
        paddle = new Paddle(WIDTH / 2 - PADDLE_WIDTH / 2, HEIGHT - 40);
        bricks = new ArrayList<>();

        // 生成砖块网格
        int startX = (WIDTH - (BRICK_COLS * (BRICK_WIDTH + BRICK_GAP))) / 2;
        int startY = 50;
        Color[] colors = {Color.RED, Color.ORANGE, Color.YELLOW, Color.GREEN, Color.CYAN};

        for (int row = 0; row < BRICK_ROWS; row++) {
            for (int col = 0; col < BRICK_COLS; col++) {
                int x = startX + col * (BRICK_WIDTH + BRICK_GAP);
                int y = startY + row * (BRICK_HEIGHT + BRICK_GAP);
                bricks.add(new Brick(x, y, colors[row]));
            }
        }

        timer = new Timer(TIMER_DELAY, this);
        timer.start();
    }

    /** 每帧更新游戏逻辑 */
    @Override
    public void actionPerformed(ActionEvent e) {
        if (!gameRunning) return;

        ball.move();
        checkWallCollision();
        checkPaddleCollision();
        checkBrickCollision();
        checkGameOver();
        repaint();
    }

    /** 检测小球与墙壁的碰撞 */
    private void checkWallCollision() {
        // 左右边界:反转X速度
        if (ball.x - BALL_RADIUS <= 0 || ball.x + BALL_RADIUS >= WIDTH) {
            ball.vx = -ball.vx;
            ball.x = Math.max(BALL_RADIUS, Math.min(ball.x, WIDTH - BALL_RADIUS));
        }
        // 上边界:反转Y速度
        if (ball.y - BALL_RADIUS <= 0) {
            ball.vy = -ball.vy;
            ball.y = BALL_RADIUS;
        }
        // 下边界:游戏结束
        if (ball.y + BALL_RADIUS >= HEIGHT) {
            gameRunning = false;
        }
    }

    /**
     * 检测小球与挡板的碰撞
     * 使用AABB检测 + 相对位置映射调整反射角度
     */
    private void checkPaddleCollision() {
        if (ball.y + BALL_RADIUS >= paddle.y &&
            ball.y - BALL_RADIUS <= paddle.y + PADDLE_HEIGHT &&
            ball.x >= paddle.x &&
            ball.x <= paddle.x + PADDLE_WIDTH) {

            // 计算小球击中挡板的相对位置 [-1.0, 1.0]
            double relativeIntersectX = (ball.x - (paddle.x + PADDLE_WIDTH / 2.0)) / (PADDLE_WIDTH / 2.0);

            // 映射为反弹角度(最大60度)
            double bounceAngle = relativeIntersectX * (Math.PI / 3);
            double speed = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy);

            ball.vx = speed * Math.sin(bounceAngle);
            ball.vy = -Math.abs(speed * Math.cos(bounceAngle));
            ball.y = paddle.y - BALL_RADIUS - 1; // 防止粘连
        }
    }

    /**
     * 检测小球与砖块的碰撞(AABB)
     * 对每个砖块执行O(1)的区间重叠检测
     */
    private void checkBrickCollision() {
        for (int i = bricks.size() - 1; i >= 0; i--) {
            Brick brick = bricks.get(i);
            if (!brick.alive) continue;

            // AABB碰撞检测:4次比较
            boolean collideX = ball.x + BALL_RADIUS > brick.x &&
                               ball.x - BALL_RADIUS < brick.x + BRICK_WIDTH;
            boolean collideY = ball.y + BALL_RADIUS > brick.y &&
                               ball.y - BALL_RADIUS < brick.y + BRICK_HEIGHT;

            if (collideX && collideY) {
                brick.alive = false;
                score += 10;

                // 判断碰撞面方向,计算反射
                double overlapLeft = (ball.x + BALL_RADIUS) - brick.x;
                double overlapRight = (brick.x + BRICK_WIDTH) - (ball.x - BALL_RADIUS);
                double overlapTop = (ball.y + BALL_RADIUS) - brick.y;
                double overlapBottom = (brick.y + BRICK_HEIGHT) - (ball.y - BALL_RADIUS);

                // 找到最小重叠方向,确定碰撞面
                double minOverlap = Math.min(Math.min(overlapLeft, overlapRight),
                                              Math.min(overlapTop, overlapBottom));

                if (minOverlap == overlapLeft || minOverlap == overlapRight) {
                    ball.vx = -ball.vx; // 垂直面碰撞,反转X
                } else {
                    ball.vy = -ball.vy; // 水平面碰撞,反转Y
                }

                bricks.remove(i);
                break; // 每帧最多处理一次砖块碰撞
            }
        }
    }

    private void checkGameOver() {
        if (bricks.isEmpty()) {
            gameRunning = false; // 胜利
        }
    }

    @Override
    protected void paintComponent(Graphics g) {
        super.paintComponent(g);
        Graphics2D g2d = (Graphics2D) g;
        g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);

        // 绘制砖块
        for (Brick brick : bricks) {
            g2d.setColor(brick.color);
            g2d.fillRect(brick.x, brick.y, BRICK_WIDTH, BRICK_HEIGHT);
            g2d.setColor(Color.WHITE);
            g2d.drawRect(brick.x, brick.y, BRICK_WIDTH, BRICK_HEIGHT);
        }

        // 绘制挡板
        g2d.setColor(Color.WHITE);
        g2d.fillRoundRect(paddle.x, paddle.y, PADDLE_WIDTH, PADDLE_HEIGHT, 8, 8);

        // 绘制小球
        g2d.setColor(Color.YELLOW);
        g2d.fillOval((int)(ball.x - BALL_RADIUS), (int)(ball.y - BALL_RADIUS),
                     BALL_RADIUS * 2, BALL_RADIUS * 2);

        // 绘制UI
        g2d.setColor(Color.WHITE);
        g2d.setFont(new Font("Arial", Font.BOLD, 18));
        g2d.drawString("Score: " + score, 10, 25);

        if (!gameRunning) {
            g2d.setFont(new Font("Arial", Font.BOLD, 48));
            String msg = bricks.isEmpty() ? "YOU WIN!" : "GAME OVER";
            int msgWidth = g2d.getFontMetrics().stringWidth(msg);
            g2d.drawString(msg, (WIDTH - msgWidth) / 2, HEIGHT / 2);
        }
    }

    @Override
    public void keyPressed(KeyEvent e) {
        int speed = 20;
        if (e.getKeyCode() == KeyEvent.VK_LEFT) {
            paddle.x = Math.max(0, paddle.x - speed);
        } else if (e.getKeyCode() == KeyEvent.VK_RIGHT) {
            paddle.x = Math.min(WIDTH - PADDLE_WIDTH, paddle.x + speed);
        } else if (e.getKeyCode() == KeyEvent.VK_R) {
            initGame(); // 按R重新开始
        }
    }

    @Override public void keyReleased(KeyEvent e) {}
    @Override public void keyTyped(KeyEvent e) {}

    public static void main(String[] args) {
        SwingUtilities.invokeLater(() -> {
            JFrame frame = new JFrame("Breakout - AABB碰撞检测与反射向量");
            frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
            frame.add(new BreakoutGame());
            frame.pack();
            frame.setLocationRelativeTo(null);
            frame.setVisible(true);
        });
    }
}

/** 小球类:位置 + 速度向量 */
class Ball {
    double x, y;    // 位置
    double vx, vy;  // 速度向量

    Ball(double x, double y, double vx, double vy) {
        this.x = x;
        this.y = y;
        this.vx = vx;
        this.vy = vy;
    }

    void move() {
        x += vx;
        y += vy;
    }
}

/** 挡板类 */
class Paddle {
    int x, y;

    Paddle(int x, int y) {
        this.x = x;
        this.y = y;
    }
}

/** 砖块类 */
class Brick {
    int x, y;
    Color color;
    boolean alive = true;

    Brick(int x, int y, Color color) {
        this.x = x;
        this.y = y;
        this.color = color;
    }
}

三、关键算法详解

3.1 碰撞面方向判定

砖块碰撞的核心难点在于判断小球从哪个方向击中砖块。本文采用最小重叠法:分别计算小球与砖块四个边的重叠深度,取最小值对应的边作为碰撞面。这种方法比简单的中心点判断更加精确,可以避免小球穿过砖角的错误反弹。

overlapLeft   = ball右边缘 - brick左边缘
overlapRight  = brick右边缘 - ball左边缘
overlapTop    = ball下边缘 - brick上边缘
overlapBottom = brick下边缘 - ball上边缘

3.2 挡板角度映射

挡板碰撞不采用简单的垂直反弹,而是根据击中位置映射反弹角度:

relativeIntersectX = (ball.x - paddle中心) / (paddle半宽)
bounceAngle = relativeIntersectX * 60°

这使得玩家可以通过控制挡板击球位置来引导小球方向,增加了策略深度。

四、算法复杂度分析

模块 时间复杂度 空间复杂度 说明
AABB碰撞检测(单对) O(1) O(1) 4次浮点比较
砖块碰撞遍历 O(n) O(1) n为存活砖块数
每帧总更新 O(n) O(n) 砖块列表存储
反射向量计算 O(1) O(1) 基本向量运算

在50块砖的典型场景下,每帧仅需约200次基本运算,在现代CPU上耗时不足1微秒,完全满足60 FPS的实时性要求。

五、扩展方向

  1. 多级砖块生命值:为砖块增加耐久度,使用不同颜色表示剩余生命,碰撞后更新而非直接销毁
  2. 道具系统:击碎特定砖块掉落道具(挡板加长、球分裂、激光射击),引入状态机管理道具效果
  3. 球分裂算法:实现多球同时运动,对每个球独立执行碰撞检测
  4. 关卡编辑器:基于二维数组描述砖块布局,支持从文件加载关卡配置
  5. 粒子特效:砖块击碎时生成粒子系统,使用简单的欧拉积分模拟粒子运动

六、总结

打砖块是理解物理碰撞检测的绝佳入门项目。本文实现的AABB碰撞检测与反射向量计算是2D游戏开发的基石算法,广泛应用于平台跳跃、射击、弹球等各类游戏。掌握这些基础后,你可以进一步学习OBB(定向边界框)、分离轴定理(SAT)等更高级的碰撞检测技术,向更复杂的物理引擎迈进。