每日算法 — 使用java实现弹球:物理碰撞检测与向量反射计算

弹球(Pinball)是一款经典的街机游戏,玩家通过控制左右挡板,让金属小球在倾斜的桌面上弹跳,撞击各种障碍物、缓冲器和目标以获取分数。本文用Java实现一个简化版弹球物理引擎,核心讲解二维空间中的AABB碰撞检测、圆形与线段的几何碰撞、以及向量反射计算。

一、物理模型与坐标系

1.1 游戏对象定义

弹球桌面中的核心对象包括:
– 球(Ball):圆形刚体,具有位置、速度、半径
– 挡板(Flipper):线段,可绕一端旋转,提供弹射力
– 缓冲器(Bumper):圆形静止障碍物,碰撞时给予球额外速度
– 墙壁(Wall):线段边界,球碰撞后发生反射
– 斜坡/重力(Gravity):沿桌面倾斜方向的恒定加速度

1.2 物理参数

重力加速度 g = 300 px/s²(沿桌面倾斜方向)
弹性系数 e = 0.7(碰撞后速度保留比例)
摩擦系数 f = 0.995(每帧速度衰减)
时间步长 dt = 1/60 s

二、核心算法:碰撞检测与响应

2.1 圆形与线段的碰撞检测

球与墙壁/挡板的碰撞可抽象为点(圆心)到线段的最短距离问题:

给定点P和线段AB,求P到AB的最短距离:
1. 计算向量 AP = P - A, AB = B - A
2. 投影参数 t = (AP · AB) / |AB|²
3. 若 t < 0,最近点为A;若 t > 1,最近点为B;否则最近点为 A + t * AB
4. 若 |P - 最近点| < 球半径,则发生碰撞

2.2 碰撞响应:向量反射

当球碰撞到法向量为 n(单位向量)的表面时,反射速度 v' 计算公式为:

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

考虑弹性系数 e 后:

v' = v - (1 + e) * (v · n) * n

2.3 圆形与圆形的碰撞

球与缓冲器(均为圆形)的碰撞:

1. 检测:若两圆心距离 < r1 + r2,则碰撞
2. 法向量:n = (P2 - P1) / |P2 - P1|
3. 对球应用反射公式
4. 缓冲器额外给予球速度加成:v' += n * boostSpeed

2.4 时间步进与连续碰撞

为避免高速球穿透薄墙,采用子步进(Sub-stepping):每帧将dt拆分为4~8个子步长,在每个子步内更新位置和检测碰撞。

三、Java完整实现

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

/**
 * 弹球物理引擎 — AABB碰撞检测与向量反射计算
 */
public class PinballPhysics extends JPanel implements ActionListener, KeyListener {

    private static final int WIDTH = 400;
    private static final int HEIGHT = 600;
    private static final double DT = 1.0 / 60.0;
    private static final int SUB_STEPS = 6; // 子步进次数
    private static final double GRAVITY = 400.0;
    private static final double FRICTION = 0.998;
    private static final double ELASTICITY = 0.75;

    // 弹球
    private Ball ball = new Ball(200, 100, 8);

    // 游戏对象
    private ArrayList<Wall> walls = new ArrayList<>();
    private ArrayList<Bumper> bumpers = new ArrayList<>();
    private Flipper leftFlipper, rightFlipper;

    private Timer timer;
    private Random rand = new Random();

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

        initLayout();

        timer = new Timer(16, this); // ~60 FPS
        timer.start();
    }

    /**
     * 初始化弹球桌面布局
     */
    private void initLayout() {
        // 外边界墙壁
        walls.add(new Wall(20, 50, 20, 500));   // 左墙
        walls.add(new Wall(380, 50, 380, 500)); // 右墙
        walls.add(new Wall(20, 50, 380, 50));   // 顶墙

        // 三角形障碍物
        walls.add(new Wall(100, 200, 150, 250));
        walls.add(new Wall(150, 250, 100, 300));
        walls.add(new Wall(250, 200, 300, 250));
        walls.add(new Wall(300, 250, 250, 300));

        // 圆形缓冲器
        bumpers.add(new Bumper(200, 150, 15, 250)); // 中心缓冲器
        bumpers.add(new Bumper(120, 400, 12, 200)); // 左下
        bumpers.add(new Bumper(280, 400, 12, 200)); // 右下

        // 挡板(底部左右)
        leftFlipper = new Flipper(80, 520, 150, 520, false);
        rightFlipper = new Flipper(250, 520, 320, 520, true);
    }

    /**
     * 每帧更新物理状态
     */
    @Override
    public void actionPerformed(ActionEvent e) {
        double subDt = DT / SUB_STEPS;
        for (int step = 0; step < SUB_STEPS; step++) {
            updatePhysics(subDt);
        }
        repaint();
    }

    private void updatePhysics(double dt) {
        // 1. 施加重力(沿y轴向下)
        ball.vy += GRAVITY * dt;

        // 2. 应用摩擦
        ball.vx *= FRICTION;
        ball.vy *= FRICTION;

        // 3. 更新位置
        ball.x += ball.vx * dt;
        ball.y += ball.vy * dt;

        // 4. 检测碰撞
        for (Wall wall : walls) {
            resolveWallCollision(wall);
        }
        for (Bumper bumper : bumpers) {
            resolveBumperCollision(bumper);
        }
        resolveFlipperCollision(leftFlipper);
        resolveFlipperCollision(rightFlipper);

        // 5. 更新挡板动画
        leftFlipper.update(dt);
        rightFlipper.update(dt);

        // 6. 出界重置
        if (ball.y > HEIGHT + 50) {
            resetBall();
        }
    }

    /**
     * 圆形与线段碰撞检测与响应
     */
    private void resolveWallCollision(Wall wall) {
        double[] closest = closestPointOnSegment(ball.x, ball.y, wall.x1, wall.y1, wall.x2, wall.y2);
        double dx = ball.x - closest[0];
        double dy = ball.y - closest[1];
        double dist = Math.sqrt(dx * dx + dy * dy);

        if (dist < ball.radius && dist > 0) {
            // 法向量(从墙指向球)
            double nx = dx / dist;
            double ny = dy / dist;

            // 将球推出墙
            double overlap = ball.radius - dist;
            ball.x += nx * overlap;
            ball.y += ny * overlap;

            // 反射速度
            double dot = ball.vx * nx + ball.vy * ny;
            ball.vx -= (1 + ELASTICITY) * dot * nx;
            ball.vy -= (1 + ELASTICITY) * dot * ny;
        }
    }

    /**
     * 圆形与圆形(缓冲器)碰撞
     */
    private void resolveBumperCollision(Bumper bumper) {
        double dx = ball.x - bumper.x;
        double dy = ball.y - bumper.y;
        double dist = Math.sqrt(dx * dx + dy * dy);

        if (dist < ball.radius + bumper.radius && dist > 0) {
            double nx = dx / dist;
            double ny = dy / dist;

            // 推出
            double overlap = (ball.radius + bumper.radius) - dist;
            ball.x += nx * overlap;
            ball.y += ny * overlap;

            // 反射 + 额外加速
            double dot = ball.vx * nx + ball.vy * ny;
            ball.vx -= (1 + ELASTICITY) * dot * nx + nx * bumper.boost;
            ball.vy -= (1 + ELASTICITY) * dot * ny + ny * bumper.boost;
        }
    }

    /**
     * 挡板碰撞(动态线段)
     */
    private void resolveFlipperCollision(Flipper flipper) {
        double[] end = flipper.getCurrentEnd();
        resolveWallCollision(new Wall(flipper.pivotX, flipper.pivotY, end[0], end[1]));
    }

    /**
     * 计算点到线段的最短距离点
     */
    private double[] closestPointOnSegment(double px, double py,
                                           double ax, double ay, double bx, double by) {
        double abx = bx - ax;
        double aby = by - ay;
        double apx = px - ax;
        double apy = py - ay;

        double abLenSq = abx * abx + aby * aby;
        double t = (abLenSq > 0) ? (apx * abx + apy * aby) / abLenSq : 0;
        t = Math.max(0, Math.min(1, t));

        return new double[]{ax + t * abx, ay + t * aby};
    }

    private void resetBall() {
        ball.x = 200;
        ball.y = 100;
        ball.vx = (rand.nextDouble() - 0.5) * 200;
        ball.vy = 0;
    }

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

        // 绘制墙壁
        g2d.setColor(Color.GRAY);
        for (Wall wall : walls) {
            g2d.drawLine((int)wall.x1, (int)wall.y1, (int)wall.x2, (int)wall.y2);
        }

        // 绘制缓冲器
        g2d.setColor(Color.RED);
        for (Bumper b : bumpers) {
            g2d.fillOval((int)(b.x - b.radius), (int)(b.y - b.radius),
                        (int)(b.radius * 2), (int)(b.radius * 2));
        }

        // 绘制挡板
        g2d.setColor(Color.GREEN);
        drawFlipper(g2d, leftFlipper);
        drawFlipper(g2d, rightFlipper);

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

    private void drawFlipper(Graphics2D g2d, Flipper f) {
        double[] end = f.getCurrentEnd();
        g2d.drawLine((int)f.pivotX, (int)f.pivotY, (int)end[0], (int)end[1]);
    }

    // ====== 键盘控制 ======
    @Override public void keyPressed(KeyEvent e) {
        if (e.getKeyCode() == KeyEvent.VK_LEFT) leftFlipper.activate();
        if (e.getKeyCode() == KeyEvent.VK_RIGHT) rightFlipper.activate();
    }
    @Override public void keyReleased(KeyEvent e) {}
    @Override public void keyTyped(KeyEvent e) {}

    // ====== 数据类 ======
    static class Ball {
        double x, y, vx, vy, radius;
        Ball(double x, double y, double r) {
            this.x = x; this.y = y; this.radius = r;
        }
    }

    static class Wall {
        double x1, y1, x2, y2;
        Wall(double x1, double y1, double x2, double y2) {
            this.x1 = x1; this.y1 = y1; this.x2 = x2; this.y2 = y2;
        }
    }

    static class Bumper {
        double x, y, radius, boost;
        Bumper(double x, double y, double r, double b) {
            this.x = x; this.y = y; this.radius = r; this.boost = b;
        }
    }

    static class Flipper {
        double pivotX, pivotY, restX, restY;
        boolean isRight;
        double angle = 0; // 当前角度(0=静止)
        double targetAngle = 0;
        double angularVelocity = 0;
        static final double MAX_ANGLE = Math.toRadians(45);
        static final double ANGULAR_SPEED = 15;
        static final double RESTORE_SPEED = 8;

        Flipper(double px, double py, double rx, double ry, boolean right) {
            pivotX = px; pivotY = py; restX = rx; restY = ry; isRight = right;
        }

        void activate() {
            targetAngle = isRight ? -MAX_ANGLE : MAX_ANGLE;
        }

        void update(double dt) {
            if (Math.abs(angle - targetAngle) > 0.01) {
                double dir = targetAngle > angle ? 1 : -1;
                angle += dir * ANGULAR_SPEED * dt;
                if ((dir > 0 && angle > targetAngle) || (dir < 0 && angle < targetAngle)) {
                    angle = targetAngle;
                }
            } else {
                targetAngle = 0;
                if (Math.abs(angle) > 0.01) {
                    double dir = angle > 0 ? -1 : 1;
                    angle += dir * RESTORE_SPEED * dt;
                    if ((dir > 0 && angle > 0) || (dir < 0 && angle < 0)) angle = 0;
                }
            }
        }

        double[] getCurrentEnd() {
            double len = Math.hypot(restX - pivotX, restY - pivotY);
            double baseAngle = Math.atan2(restY - pivotY, restX - pivotX);
            double curAngle = baseAngle + angle;
            return new double[]{pivotX + len * Math.cos(curAngle),
                               pivotY + len * Math.sin(curAngle)};
        }
    }

    // ====== 主程序 ======
    public static void main(String[] args) {
        JFrame frame = new JFrame("Pinball Physics");
        frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        frame.add(new PinballPhysics());
        frame.pack();
        frame.setLocationRelativeTo(null);
        frame.setVisible(true);
    }
}

四、算法核心:向量反射的数学推导

为什么反射公式是 v' = v - 2(v·n)n?这源于向量在法线方向上的分解:

任意速度向量 v 可分解为法向分量 v_n = (v·n)n 和切向分量 v_t = v - v_n。

碰撞时切向分量不变,法向分量反向:

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

引入弹性系数 e 后,法向分量仅保留 e 倍:

v' = v_t - e*v_n = v - v_n - e*v_n = v - (1+e)(v·n)n

五、复杂度分析

  • 碰撞检测:每帧对 N 个墙壁、M 个缓冲器、K 个挡板进行检测,时间复杂度 O(N + M + K)。
  • 子步进:将每帧分为 S 个子步,保证碰撞检测精度,总复杂度 O(S × (N + M + K))。
  • 空间复杂度:O(1),仅存储对象状态。

六、扩展方向

  1. 多球系统:扩展为同时追踪多个球,引入球与球之间的碰撞(动量守恒)。
  2. 曲线路径:使用贝塞尔曲线定义 ramps 和 curves,碰撞检测升级为曲线距离计算。
  3. 触发器区域(Trigger Zone):检测球进入特定区域以触发加分、多球等事件。
  4. 旋转体碰撞:挡板不仅是线段,而是具有厚度的旋转矩形,需要更复杂的OBB碰撞检测。

弹球游戏的物理引擎是二维刚体碰撞的经典入门案例,其核心向量运算广泛应用于游戏开发、机器人路径规划和物理仿真领域。