每日算法 — 使用java实现弹球游戏:反射定律与向量碰撞

弹球游戏是街机时代的经典作品,玩家操控挡板反弹小球击碎砖块。其核心算法在于精确的碰撞检测与物理反弹计算。本文用Java实现一个带物理引擎的弹球游戏,重点讲解二维向量运算、边界反射定律以及球体与矩形碰撞检测算法,包含可直接运行的完整代码与复杂度分析。

一、问题分析与物理模型

弹球游戏的物理模型包含三个核心要素:小球运动、边界反射和碰撞检测。小球在二维平面上运动,速度用向量 (vx, vy) 表示。当小球碰到边界或挡板/砖块时,需要根据入射角计算反射方向。碰撞检测则需要判断圆形与矩形的相交关系。

反射定律的核心是:入射角等于反射角。在向量表示中,若碰撞面的法向量为 n,入射速度为 v,则反射速度 v' 为:

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

其中 · 表示点积运算。当碰撞面为水平面时,法向量为 (0, 1) 或 (0, -1),反射仅改变 vy 符号;垂直面则仅改变 vx 符号。

二、二维向量工具类

首先实现一个通用的二维向量类,封装向量运算:

/**
 * 二维向量类,封装向量运算
 */
public class Vector2D {
    public double x;
    public double y;

    public Vector2D(double x, double y) {
        this.x = x;
        this.y = y;
    }

    /** 向量加法 */
    public Vector2D add(Vector2D other) {
        return new Vector2D(this.x + other.x, this.y + other.y);
    }

    /** 向量减法 */
    public Vector2D subtract(Vector2D other) {
        return new Vector2D(this.x - other.x, this.y - other.y);
    }

    /** 数乘 */
    public Vector2D scale(double scalar) {
        return new Vector2D(this.x * scalar, this.y * scalar);
    }

    /** 点积 */
    public double dot(Vector2D other) {
        return this.x * other.x + this.y * other.y;
    }

    /** 向量长度 */
    public double length() {
        return Math.sqrt(x * x + y * y);
    }

    /** 单位向量 */
    public Vector2D normalize() {
        double len = length();
        if (len < 1e-9) return new Vector2D(0, 0);
        return new Vector2D(x / len, y / len);
    }

    /** 沿法向量反射 */
    public Vector2D reflect(Vector2D normal) {
        Vector2D n = normal.normalize();
        double dot = this.dot(n);
        return this.subtract(n.scale(2 * dot));
    }

    @Override
    public String toString() {
        return String.format("(%.2f, %.2f)", x, y);
    }
}

三、小球与碰撞体定义

/**
 * 小球实体,包含位置、速度、半径
 */
public class Ball {
    public Vector2D position;  // 球心位置
    public Vector2D velocity;  // 速度向量
    public double radius;      // 半径
    public double speed;       // 基础速度大小

    public Ball(double x, double y, double radius, double speed) {
        this.position = new Vector2D(x, y);
        this.radius = radius;
        this.speed = speed;
        // 初始速度向右上45度
        this.velocity = new Vector2D(speed / Math.sqrt(2), -speed / Math.sqrt(2));
    }

    /** 更新位置 */
    public void update(double dt) {
        position = position.add(velocity.scale(dt));
    }
}

/**
 * 矩形碰撞体,用于挡板和砖块
 */
public class Rectangle {
    public double x, y, width, height;

    public Rectangle(double x, double y, double width, double height) {
        this.x = x;
        this.y = y;
        this.width = width;
        this.height = height;
    }

    /** 获取矩形中心点 */
    public Vector2D center() {
        return new Vector2D(x + width / 2, y + height / 2);
    }

    /** 判断点是否在矩形内 */
    public boolean contains(Vector2D p) {
        return p.x >= x && p.x <= x + width && p.y >= y && p.y <= y + height;
    }
}

四、碰撞检测算法

4.1 圆与矩形碰撞检测

圆与矩形的碰撞检测是弹球游戏的核心。标准算法是:找到矩形上距离圆心最近的点,若该点到圆心的距离小于半径,则发生碰撞。

public class CollisionDetector {

    /**
     * 计算圆与矩形的碰撞信息
     * @return 碰撞信息,若无碰撞返回null
     */
    public static CollisionInfo circleRectangle(Ball ball, Rectangle rect) {
        // 找到矩形边界上距离圆心最近的点( Clamp投影法)
        double closestX = clamp(ball.position.x, rect.x, rect.x + rect.width);
        double closestY = clamp(ball.position.y, rect.y, rect.y + rect.height);
        Vector2D closestPoint = new Vector2D(closestX, closestY);

        Vector2D diff = ball.position.subtract(closestPoint);
        double distSq = diff.x * diff.x + diff.y * diff.y;

        // 圆心在矩形内部(特殊处理:找到最近边)
        if (rect.contains(ball.position)) {
            // 计算到四条边的距离
            double distLeft = ball.position.x - rect.x;
            double distRight = rect.x + rect.width - ball.position.x;
            double distTop = ball.position.y - rect.y;
            double distBottom = rect.y + rect.height - ball.position.y;

            double minDist = Math.min(Math.min(distLeft, distRight),
                                      Math.min(distTop, distBottom));

            Vector2D normal;
            if (minDist == distLeft) normal = new Vector2D(-1, 0);
            else if (minDist == distRight) normal = new Vector2D(1, 0);
            else if (minDist == distTop) normal = new Vector2D(0, -1);
            else normal = new Vector2D(0, 1);

            return new CollisionInfo(true, normal, ball.radius + minDist);
        }

        // 无碰撞
        if (distSq > ball.radius * ball.radius) {
            return null;
        }

        // 计算碰撞法向量
        double dist = Math.sqrt(distSq);
        Vector2D normal;
        if (dist < 1e-9) {
            // 圆心恰好投影到角点,法向量指向圆心到矩形中心
            normal = ball.position.subtract(rect.center()).normalize();
        } else {
            normal = diff.scale(-1.0 / dist).normalize();
        }

        double penetration = ball.radius - dist;
        return new CollisionInfo(true, normal, penetration);
    }

    private static double clamp(double value, double min, double max) {
        return Math.max(min, Math.min(max, value));
    }
}

/** 碰撞信息记录 */
public class CollisionInfo {
    public boolean collided;      // 是否碰撞
    public Vector2D normal;       // 碰撞法向量(指向球体外)
    public double penetration;    // 穿透深度

    public CollisionInfo(boolean collided, Vector2D normal, double penetration) {
        this.collided = collided;
        this.normal = normal;
        this.penetration = penetration;
    }
}

4.2 边界碰撞处理

/**
 * 处理小球与游戏边界的碰撞
 * @param ball 小球
 * @param gameWidth 游戏区域宽度
 * @param gameHeight 游戏区域高度
 * @return 若底部边界被穿出(球掉落),返回true
 */
public static boolean handleBoundaryCollision(Ball ball, double gameWidth, double gameHeight) {
    boolean lost = false;

    // 左边界
    if (ball.position.x - ball.radius < 0) {
        ball.position.x = ball.radius;
        ball.velocity.x = Math.abs(ball.velocity.x);
    }
    // 右边界
    if (ball.position.x + ball.radius > gameWidth) {
        ball.position.x = gameWidth - ball.radius;
        ball.velocity.x = -Math.abs(ball.velocity.x);
    }
    // 上边界
    if (ball.position.y - ball.radius < 0) {
        ball.position.y = ball.radius;
        ball.velocity.y = Math.abs(ball.velocity.y);
    }
    // 下边界(球掉落判定)
    if (ball.position.y - ball.radius > gameHeight) {
        lost = true;
    }

    return lost;
}

五、反射物理与挡板交互

当小球碰撞挡板时,除了基本的反射,还可以根据碰撞位置改变反射角度,增加游戏操控性:击中挡板中心时垂直反弹,击中边缘时大角度反弹。

/**
 * 处理小球与挡板的碰撞,支持根据碰撞位置调整反射角度
 */
public static void handlePaddleCollision(Ball ball, Rectangle paddle) {
    CollisionInfo info = CollisionDetector.circleRectangle(ball, paddle);
    if (info == null || !info.collided) return;

    // 防止球陷入挡板:沿法向量推出
    ball.position = ball.position.add(info.normal.scale(info.penetration));

    // 计算碰撞点在挡板上的相对位置 [-1, 1]
    double relativeIntersectX = (ball.position.x - paddle.center().x) / (paddle.width / 2);
    relativeIntersectX = Math.max(-0.99, Math.min(0.99, relativeIntersectX));

    // 根据碰撞位置调整反射角度,最大75度
    double bounceAngle = relativeIntersectX * (Math.PI / 2 * 0.83);

    double speed = ball.velocity.length();
    ball.velocity = new Vector2D(
        speed * Math.sin(bounceAngle),
        -speed * Math.cos(bounceAngle)
    );
}

六、完整游戏引擎

import java.util.ArrayList;
import java.util.List;

/**
 * 弹球游戏核心引擎
 */
public class PinballEngine {
    private static final double GAME_WIDTH = 800;
    private static final double GAME_HEIGHT = 600;
    private static final double BALL_RADIUS = 8;
    private static final double PADDLE_WIDTH = 120;
    private static final double PADDLE_HEIGHT = 16;
    private static final double BALL_SPEED = 400;
    private static final double DT = 1.0 / 60.0; // 60FPS

    private Ball ball;
    private Rectangle paddle;
    private List<Rectangle> bricks;
    private int score;
    private boolean gameOver;

    public PinballEngine() {
        reset();
    }

    public void reset() {
        ball = new Ball(GAME_WIDTH / 2, GAME_HEIGHT / 2, BALL_RADIUS, BALL_SPEED);
        paddle = new Rectangle(
            (GAME_WIDTH - PADDLE_WIDTH) / 2,
            GAME_HEIGHT - 50,
            PADDLE_WIDTH, PADDLE_HEIGHT
        );
        bricks = new ArrayList<>();
        initBricks();
        score = 0;
        gameOver = false;
    }

    /** 初始化砖块阵列 */
    private void initBricks() {
        int rows = 5;
        int cols = 10;
        double brickWidth = (GAME_WIDTH - 40) / cols;
        double brickHeight = 25;
        for (int r = 0; r < rows; r++) {
            for (int c = 0; c < cols; c++) {
                bricks.add(new Rectangle(
                    20 + c * brickWidth,
                    40 + r * brickHeight,
                    brickWidth - 4,
                    brickHeight - 4
                ));
            }
        }
    }

    /** 主更新循环,每帧调用一次 */
    public void update() {
        if (gameOver) return;

        ball.update(DT);

        // 边界碰撞
        if (CollisionUtil.handleBoundaryCollision(ball, GAME_WIDTH, GAME_HEIGHT)) {
            gameOver = true;
            return;
        }

        // 挡板碰撞
        CollisionUtil.handlePaddleCollision(ball, paddle);

        // 砖块碰撞检测与消除
        for (int i = bricks.size() - 1; i >= 0; i--) {
            Rectangle brick = bricks.get(i);
            CollisionInfo info = CollisionDetector.circleRectangle(ball, brick);
            if (info != null && info.collided) {
                // 反射速度
                ball.velocity = ball.velocity.reflect(info.normal);
                // 推出穿透部分
                ball.position = ball.position.add(info.normal.scale(info.penetration));
                bricks.remove(i);
                score += 10;
                break; // 每帧最多消除一个砖块
            }
        }
    }

    /** 移动挡板 */
    public void movePaddle(double mouseX) {
        paddle.x = mouseX - paddle.width / 2;
        paddle.x = Math.max(0, Math.min(GAME_WIDTH - paddle.width, paddle.x));
    }

    public boolean isGameOver() { return gameOver; }
    public int getScore() { return score; }
    public int getRemainingBricks() { return bricks.size(); }
    public Ball getBall() { return ball; }
    public Rectangle getPaddle() { return paddle; }
    public List<Rectangle> getBricks() { return bricks; }
    public double getWidth() { return GAME_WIDTH; }
    public double getHeight() { return GAME_HEIGHT; }
}

七、复杂度分析

操作 时间复杂度 空间复杂度 说明
向量运算 O(1) O(1) 基础数学运算
圆-矩形碰撞检测 O(1) O(1) Clamp投影法,常数时间
砖块碰撞检测 O(n) O(1) n为砖块数量,每帧线性扫描
反射计算 O(1) O(1) 向量点积与数乘

优化建议:当砖块数量极大时,可使用 空间哈希 或 四叉树 将碰撞检测优化至平均 O(log n) 或 O(1)。

八、总结

本文完整实现了弹球游戏的物理引擎核心算法,重点包括:

  • 向量运算体系:用 Vector2D 类封装二维向量的加减、点积、数乘、归一化与反射运算
  • 圆-矩形碰撞检测:利用 Clamp 投影法找到最近点,判断相交并计算法向量与穿透深度
  • 反射定律实现:通过 v' = v - 2(v·n)n 公式精确计算反射速度
  • 挡板交互增强:根据碰撞相对位置调整反射角度,提升操控感

这些技术不仅适用于弹球游戏,也可推广到任意需要物理碰撞的2D游戏开发中。