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

弹球(Pinball)是街机游戏中的经典类型,核心乐趣在于小球在挡板、弹簧和障碍物之间的弹跳。本文将用Java实现一个完整的弹球物理引擎,重点讲解圆形碰撞检测、反射向量计算以及重力加速度模拟三大核心算法。

一、游戏机制与算法目标

弹球游戏的核心物理规则:
– 小球受重力影响持续下落
– 与圆形挡板、边界、障碍物发生弹性碰撞
– 碰撞后速度方向按反射定律改变
– 挡板可通过按键控制左右移动,击打来球

算法层面需要解决三个问题:
1. 如何判断小球与圆形障碍物的碰撞时刻
2. 如何根据入射角和法向量计算反射速度
3. 如何模拟重力加速度对运动轨迹的影响

二、核心算法原理

2.1 圆形碰撞检测

两个圆发生碰撞的充要条件是:圆心距离小于等于半径之和。

设小球圆心为 $(x_1, y_1)$,半径 $r_1$;障碍物圆心为 $(x_2, y_2)$,半径 $r_2$。碰撞条件为:

$$
\sqrt{(x_1-x_2)^2 + (y_1-y_2)^2} \leq r_1 + r_2
$$

为减少开方运算,实际代码中比较距离的平方:

$$
(x_1-x_2)^2 + (y_1-y_2)^2 \leq (r_1+r_2)^2
$$

2.2 反射向量计算

碰撞发生后,小球速度需要沿法线方向反转。设入射速度向量为 $\vec{v}$,碰撞点处的法向量(从障碍物指向小球)为 $\vec{n}$,则反射速度 $\vec{v’}$ 的计算公式为:

$$
\vec{v’} = \vec{v} – 2(\vec{v} \cdot \vec{n})\vec{n}
$$

其中 $\vec{n}$ 需要单位化。该公式基于向量投影,将速度在法线方向的分量反向,切线方向分量保持不变,从而实现完美的弹性碰撞。

2.3 重力模拟与运动学更新

每帧更新时,先对垂直方向速度叠加重力加速度:

$$
v_y = v_y + g \cdot \Delta t
$$

再根据速度更新位置:

$$
x = x + v_x \cdot \Delta t \
y = y + v_y \cdot \Delta t
$$

其中 $g$ 为重力常数,$\Delta t$ 为帧时间间隔。

三、完整Java实现

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

/**
 * 弹球游戏主类:实现圆形碰撞检测、反射向量计算与重力物理模拟
 */
public class PinballGame extends JPanel implements ActionListener, KeyListener {

    // 游戏参数常量
    private static final int WIDTH = 600;
    private static final int HEIGHT = 800;
    private static final double GRAVITY = 0.15;      // 重力加速度(像素/帧²)
    private static final double FRICTION = 0.998;    // 空气阻力系数
    private static final double BOUNCE_DAMPING = 0.92; // 碰撞能量损耗
    private static final int BALL_RADIUS = 10;
    private static final int PADDLE_WIDTH = 100;
    private static final int PADDLE_HEIGHT = 15;
    private static final int OBSTACLE_RADIUS = 20;

    // 小球状态
    private double ballX = WIDTH / 2.0;
    private double ballY = 100;
    private double ballVx = 3.5;
    private double ballVy = 0;

    // 挡板状态
    private double paddleX = WIDTH / 2.0 - PADDLE_WIDTH / 2.0;
    private double paddleY = HEIGHT - 80;
    private boolean moveLeft = false;
    private boolean moveRight = false;
    private static final double PADDLE_SPEED = 8.0;

    // 障碍物列表(圆形缓冲器)
    private List<Obstacle> obstacles = new ArrayList<>();

    // 计时器驱动游戏循环
    private Timer timer;
    private int score = 0;

    /**
     * 圆形障碍物类,包含位置、半径和碰撞动画状态
     */
    static class Obstacle {
        double x, y;
        double radius;
        int flashTimer = 0; // 碰撞后闪烁帧数

        Obstacle(double x, double y, double radius) {
            this.x = x;
            this.y = y;
            this.radius = radius;
        }

        /**
         * 检测与该障碍物的碰撞,若碰撞则返回法向量,否则返回null
         */
        double[] checkCollision(double bx, double by, double br) {
            double dx = bx - x;
            double dy = by - y;
            double distSq = dx * dx + dy * dy;
            double minDist = br + radius;

            if (distSq <= minDist * minDist && distSq > 0) {
                double dist = Math.sqrt(distSq);
                // 返回单位法向量(从障碍物指向小球)
                return new double[]{dx / dist, dy / dist};
            }
            return null;
        }
    }

    public PinballGame() {
        setPreferredSize(new Dimension(WIDTH, HEIGHT));
        setBackground(new Color(20, 20, 40));
        setFocusable(true);
        addKeyListener(this);

        // 初始化障碍物布局(经典弹球台布局)
        obstacles.add(new Obstacle(WIDTH / 2.0, HEIGHT / 3.0, OBSTACLE_RADIUS));
        obstacles.add(new Obstacle(WIDTH / 3.0, HEIGHT / 2.0, OBSTACLE_RADIUS * 0.8));
        obstacles.add(new Obstacle(WIDTH * 2.0 / 3.0, HEIGHT / 2.0, OBSTACLE_RADIUS * 0.8));
        obstacles.add(new Obstacle(WIDTH / 2.0, HEIGHT * 2.0 / 3.0, OBSTACLE_RADIUS * 1.2));

        // 16ms一帧,约60FPS
        timer = new Timer(16, this);
        timer.start();
    }

    /**
     * 核心物理更新:重力、运动学、碰撞检测与反射
     */
    @Override
    public void actionPerformed(ActionEvent e) {
        // 1. 挡板移动更新
        if (moveLeft && paddleX > 0) paddleX -= PADDLE_SPEED;
        if (moveRight && paddleX < WIDTH - PADDLE_WIDTH) paddleX += PADDLE_SPEED;

        // 2. 重力加速度(垂直方向)
        ballVy += GRAVITY;

        // 3. 空气阻力(微量减速)
        ballVx *= FRICTION;
        ballVy *= FRICTION;

        // 4. 根据速度更新位置
        ballX += ballVx;
        ballY += ballVy;

        // 5. 边界碰撞检测(左右墙壁和顶部)
        if (ballX - BALL_RADIUS < 0) {
            ballX = BALL_RADIUS;
            ballVx = -ballVx * BOUNCE_DAMPING;
        } else if (ballX + BALL_RADIUS > WIDTH) {
            ballX = WIDTH - BALL_RADIUS;
            ballVx = -ballVx * BOUNCE_DAMPING;
        }
        if (ballY - BALL_RADIUS < 0) {
            ballY = BALL_RADIUS;
            ballVy = -ballVy * BOUNCE_DAMPING;
        }

        // 6. 挡板碰撞检测(简化为矩形碰撞)
        if (ballY + BALL_RADIUS >= paddleY && 
            ballY - BALL_RADIUS <= paddleY + PADDLE_HEIGHT &&
            ballX + BALL_RADIUS >= paddleX && 
            ballX - BALL_RADIUS <= paddleX + PADDLE_WIDTH) {

            // 根据击中挡板的位置调整水平速度(增加操控感)
            double hitOffset = (ballX - (paddleX + PADDLE_WIDTH / 2.0)) / (PADDLE_WIDTH / 2.0);
            ballVx = hitOffset * 6.0; // 边缘击打给予更大水平速度
            ballVy = -Math.abs(ballVy) * 1.05; // 向上反弹并略微加速
            ballY = paddleY - BALL_RADIUS;
            score += 10;
        }

        // 7. 圆形障碍物碰撞检测与反射向量计算
        for (Obstacle obs : obstacles) {
            double[] normal = obs.checkCollision(ballX, ballY, BALL_RADIUS);
            if (normal != null) {
                // 反射公式: v' = v - 2(v·n)n
                double dotProduct = ballVx * normal[0] + ballVy * normal[1];
                ballVx = (ballVx - 2 * dotProduct * normal[0]) * BOUNCE_DAMPING;
                ballVy = (ballVy - 2 * dotProduct * normal[1]) * BOUNCE_DAMPING;

                // 防止小球陷入障碍物内部:沿法线方向推出
                double overlap = (BALL_RADIUS + obs.radius) - 
                    Math.hypot(ballX - obs.x, ballY - obs.y);
                ballX += normal[0] * overlap;
                ballY += normal[1] * overlap;

                obs.flashTimer = 10; // 触发闪烁动画
                score += 50;
            }
            if (obs.flashTimer > 0) obs.flashTimer--;
        }

        // 8. 落底检测(游戏重置)
        if (ballY > HEIGHT + BALL_RADIUS) {
            resetBall();
        }

        repaint();
    }

    /**
     * 重置小球到初始位置,保留部分水平速度
     */
    private void resetBall() {
        ballX = WIDTH / 2.0;
        ballY = 100;
        ballVx = (Math.random() - 0.5) * 6;
        ballVy = 0;
        score = Math.max(0, score - 100);
    }

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

        // 绘制障碍物
        for (Obstacle obs : obstacles) {
            if (obs.flashTimer > 0) {
                g2d.setColor(new Color(255, 255, 100));
            } else {
                g2d.setColor(new Color(100, 200, 255));
            }
            g2d.fillOval((int)(obs.x - obs.radius), (int)(obs.y - obs.radius),
                        (int)(obs.radius * 2), (int)(obs.radius * 2));
            g2d.setColor(Color.WHITE);
            g2d.drawOval((int)(obs.x - obs.radius), (int)(obs.y - obs.radius),
                        (int)(obs.radius * 2), (int)(obs.radius * 2));
        }

        // 绘制挡板
        g2d.setColor(new Color(255, 100, 100));
        g2d.fillRoundRect((int)paddleX, (int)paddleY, PADDLE_WIDTH, PADDLE_HEIGHT, 10, 10);

        // 绘制小球及速度向量指示
        g2d.setColor(new Color(255, 220, 100));
        g2d.fillOval((int)(ballX - BALL_RADIUS), (int)(ballY - BALL_RADIUS),
                    BALL_RADIUS * 2, BALL_RADIUS * 2);
        // 绘制速度方向线
        g2d.setColor(new Color(255, 255, 255, 150));
        g2d.drawLine((int)ballX, (int)ballY, 
                    (int)(ballX + ballVx * 5), (int)(ballY + ballVy * 5));

        // 绘制分数
        g2d.setColor(Color.WHITE);
        g2d.setFont(new Font("Arial", Font.BOLD, 20));
        g2d.drawString("Score: " + score, 20, 30);
        g2d.drawString("Controls: ← → Move Paddle", 20, 55);
    }

    @Override public void keyPressed(KeyEvent e) {
        if (e.getKeyCode() == KeyEvent.VK_LEFT) moveLeft = true;
        if (e.getKeyCode() == KeyEvent.VK_RIGHT) moveRight = true;
    }
    @Override public void keyReleased(KeyEvent e) {
        if (e.getKeyCode() == KeyEvent.VK_LEFT) moveLeft = false;
        if (e.getKeyCode() == KeyEvent.VK_RIGHT) moveRight = false;
    }
    @Override public void keyTyped(KeyEvent e) {}

    public static void main(String[] args) {
        SwingUtilities.invokeLater(() -> {
            JFrame frame = new JFrame("弹球游戏 - 圆形碰撞与反射向量");
            frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
            frame.add(new PinballGame());
            frame.pack();
            frame.setLocationRelativeTo(null);
            frame.setVisible(true);
        });
    }
}

四、关键算法详解

4.1 反射向量的几何推导

反射的核心是将速度向量分解为法线分量和切线分量,仅反转法线分量。

设单位法向量 $\vec{n}$,入射速度 $\vec{v}$:
– 法线方向投影长度:$v_n = \vec{v} \cdot \vec{n}$
– 法线分量:$\vec{v_n} = v_n \cdot \vec{n}$
– 切线分量:$\vec{v_t} = \vec{v} – \vec{v_n}$

反射后:切线分量不变,法线分量反向:

$$
\vec{v’} = \vec{v_t} – \vec{v_n} = (\vec{v} – \vec{v_n}) – \vec{v_n} = \vec{v} – 2\vec{v_n} = \vec{v} – 2(\vec{v}\cdot\vec{n})\vec{n}
$$

4.2 碰撞穿透处理

由于离散时间步进,小球可能在某一帧”穿透”障碍物。检测到碰撞后,需要沿法线方向将小球推出重叠区域:

double overlap = (r1 + r2) - distance;
ballX += normalX * overlap;
ballY += normalY * overlap;

这保证了碰撞后的物理正确性,避免小球”卡”在障碍物内部。

五、复杂度分析

操作 时间复杂度 空间复杂度
单帧物理更新 $O(N)$,$N$ 为障碍物数量 $O(1)$
圆形碰撞检测 $O(1)$ 单次检测 $O(1)$
反射向量计算 $O(1)$ $O(1)$

每帧需要遍历所有障碍物进行碰撞检测,当障碍物数量很大时,可考虑使用空间哈希或四叉树进行加速,将时间复杂度降至 $O(\log N)$ 或 $O(1)$ 平均。

六、扩展思路

  1. 非完全弹性碰撞:引入恢复系数,让不同障碍物有不同的弹性
  2. 旋转挡板:挡板带有角度,碰撞时根据挡板倾斜角计算新的法向量
  3. 多球同屏:使用空间分割数据结构管理大量小球的碰撞检测
  4. 轨迹预测:基于当前速度和重力,用抛物线方程预测小球落点,辅助AI操控

弹球游戏的物理引擎虽然简单,却完整覆盖了碰撞检测、向量运算和运动学模拟三大基础物理算法,是理解游戏物理的绝佳入门案例。