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

引言

弹珠台(Pinball)是街机厅中经典的物理游戏,玩家通过控制挡板让弹珠在倾斜的桌面上弹射,击中各种目标得分。弹珠台的魅力在于其复杂的物理交互:弹珠与边界的碰撞、与挡板的反弹、与缓冲器的弹性碰撞等。本文将用Java实现一个简化版弹珠台模拟器,重点讲解圆形碰撞检测与反射向量计算两大核心算法。

核心算法原理

1. 圆形与线段碰撞检测

弹珠可视为一个圆,桌面边界由若干线段构成。判断圆与线段是否碰撞,需要计算圆心到线段的最近距离:

  • 设线段端点为 A(x1,y1) 和 B(x2,y2),圆心为 C(cx,cy),半径为 r
  • 计算向量 AB = B - A,AC = C - A
  • 将 AC 投影到 AB 上,得到参数 t = (AC·AB) / (AB·AB)
  • 若 t < 0,最近点为 A;若 t > 1,最近点为 B;否则最近点为 A + t·AB
  • 若圆心到最近点的距离 ≤ r,则发生碰撞

2. 反射向量计算

碰撞后弹珠速度需要沿碰撞法线方向反射。设入射速度为 v,碰撞法线为单位向量 n:

  • 将速度分解为法向分量 v_n = (v·n) * n 和切向分量 v_t = v - v_n
  • 反射后速度 v' = v_t - v_n,即法向分量反向,切向分量不变
  • 考虑能量损失,乘以恢复系数 e(0 < e ≤ 1):v' = v_t - e * v_n

Java完整实现

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

/**
 * 简化版弹珠台物理模拟器
 * 核心算法:圆形碰撞检测 + 反射向量计算
 */
public class PinballSimulator {

    // 物理参数
    private static final double GRAVITY = 0.15;        // 重力加速度(向下)
    private static final double FRICTION = 0.998;      // 摩擦力衰减系数
    private static final double RESTITUTION = 0.85;    // 碰撞恢复系数(弹性)
    private static final double BALL_RADIUS = 6.0;     // 弹珠半径

    // 弹珠状态
    static class Ball {
        double x, y;      // 位置
        double vx, vy;    // 速度
        int score;        // 得分

        Ball(double x, double y) {
            this.x = x;
            this.y = y;
            this.vx = 3.0;
            this.vy = -2.0;
            this.score = 0;
        }
    }

    // 线段障碍物(桌面边界或挡板)
    static class LineSegment {
        double x1, y1, x2, y2;  // 端点坐标
        boolean isBumper;       // 是否为缓冲器(弹性更高)
        int pointValue;         // 击中得分

        LineSegment(double x1, double y1, double x2, double y2,
                    boolean isBumper, int pointValue) {
            this.x1 = x1; this.y1 = y1;
            this.x2 = x2; this.y2 = y2;
            this.isBumper = isBumper;
            this.pointValue = pointValue;
        }
    }

    // 圆形障碍物(缓冲柱)
    static class CircleBumper {
        double cx, cy, radius;
        int pointValue;

        CircleBumper(double cx, double cy, double radius, int pointValue) {
            this.cx = cx; this.cy = cy;
            this.radius = radius;
            this.pointValue = pointValue;
        }
    }

    private final Ball ball;
    private final List<LineSegment> walls;
    private final List<CircleBumper> bumpers;

    public PinballSimulator() {
        this.ball = new Ball(50, 100);
        this.walls = new ArrayList<>();
        this.bumpers = new ArrayList<>();
        initTable();
    }

    /**
     * 初始化弹珠台布局
     */
    private void initTable() {
        // 桌面外边界(梯形桌面)
        walls.add(new LineSegment(10, 10, 90, 10, false, 0));    // 顶边
        walls.add(new LineSegment(90, 10, 95, 200, false, 0));   // 右边
        walls.add(new LineSegment(95, 200, 5, 200, false, 0));   // 底边
        walls.add(new LineSegment(5, 200, 10, 10, false, 0));    // 左边

        // 左挡板(可活动)
        walls.add(new LineSegment(20, 180, 45, 170, true, 0));
        // 右挡板(可活动)
        walls.add(new LineSegment(55, 170, 80, 180, true, 0));

        // 中间障碍物
        walls.add(new LineSegment(40, 80, 60, 80, false, 50));   // 中间横杆
        walls.add(new LineSegment(30, 50, 30, 70, false, 30));   // 左立柱
        walls.add(new LineSegment(70, 50, 70, 70, false, 30));   // 右立柱

        // 圆形缓冲柱
        bumpers.add(new CircleBumper(50, 40, 8, 100));
        bumpers.add(new CircleBumper(35, 120, 6, 50));
        bumpers.add(new CircleBumper(65, 120, 6, 50));
    }

    /**
     * 核心算法1:计算点到线段的最近点
     * @param px 点x坐标
     * @param py 点y坐标
     * @param seg 线段
     * @return 最近点坐标 [x, y]
     */
    public double[] closestPointOnSegment(double px, double py, LineSegment seg) {
        double abx = seg.x2 - seg.x1;
        double aby = seg.y2 - seg.y1;
        double acx = px - seg.x1;
        double acy = py - seg.y1;

        // 计算投影参数 t = (AC·AB) / |AB|^2
        double abLenSq = abx * abx + aby * aby;
        if (abLenSq < 1e-10) {
            return new double[]{seg.x1, seg.y1}; // 退化为点
        }

        double t = (acx * abx + acy * aby) / abLenSq;
        // 限制 t 在 [0, 1] 范围内
        t = Math.max(0.0, Math.min(1.0, t));

        double closestX = seg.x1 + t * abx;
        double closestY = seg.y1 + t * aby;
        return new double[]{closestX, closestY};
    }

    /**
     * 核心算法2:反射向量计算
     * @param vx 入射速度x分量
     * @param vy 入射速度y分量
     * @param nx 法线x分量(单位向量)
     * @param ny 法线y分量(单位向量)
     * @param restitution 恢复系数
     * @return 反射后的速度 [vx', vy']
     */
    public double[] reflect(double vx, double vy,
                            double nx, double ny, double restitution) {
        // 计算速度在法线方向的投影(点积)
        double dot = vx * nx + vy * ny;

        // 切向分量 = 原速度 - 法向分量
        double tx = vx - dot * nx;
        double ty = vy - dot * ny;

        // 法向分量反向并乘以恢复系数
        double rx = tx - restitution * dot * nx;
        double ry = ty - restitution * dot * ny;

        return new double[]{rx, ry};
    }

    /**
     * 检测并处理弹珠与所有线段的碰撞
     */
    public void handleLineCollisions() {
        for (LineSegment wall : walls) {
            double[] closest = closestPointOnSegment(ball.x, ball.y, wall);
            double dx = ball.x - closest[0];
            double dy = ball.y - closest[1];
            double distSq = dx * dx + dy * dy;

            if (distSq <= BALL_RADIUS * BALL_RADIUS) {
                // 计算碰撞法线(从最近点指向圆心)
                double dist = Math.sqrt(distSq);
                if (dist < 1e-10) continue; // 避免除零

                double nx = dx / dist;
                double ny = dy / dist;

                // 判断弹珠是否正在朝向线段运动(防止粘连)
                double approach = ball.vx * nx + ball.vy * ny;
                if (approach > 0) continue;

                // 计算反射速度
                double rest = wall.isBumper ? 0.95 : RESTITUTION;
                double[] reflected = reflect(ball.vx, ball.vy, nx, ny, rest);
                ball.vx = reflected[0];
                ball.vy = reflected[1];

                // 将弹珠推出碰撞区域,防止粘连
                double overlap = BALL_RADIUS - dist + 0.5;
                ball.x += nx * overlap;
                ball.y += ny * overlap;

                // 计分
                ball.score += wall.pointValue;
            }
        }
    }

    /**
     * 检测并处理弹珠与圆形缓冲柱的碰撞
     */
    public void handleCircleCollisions() {
        for (CircleBumper bumper : bumpers) {
            double dx = ball.x - bumper.cx;
            double dy = ball.y - bumper.cy;
            double dist = Math.sqrt(dx * dx + dy * dy);
            double minDist = BALL_RADIUS + bumper.radius;

            if (dist <= minDist) {
                // 碰撞法线
                double nx = dx / dist;
                double ny = dy / dist;

                double approach = ball.vx * nx + ball.vy * ny;
                if (approach > 0) continue;

                double[] reflected = reflect(ball.vx, ball.vy, nx, ny, 1.0);
                // 缓冲柱给予额外速度奖励
                ball.vx = reflected[0] * 1.05;
                ball.vy = reflected[1] * 1.05;

                double overlap = minDist - dist + 0.5;
                ball.x += nx * overlap;
                ball.y += ny * overlap;

                ball.score += bumper.pointValue;
            }
        }
    }

    /**
     * 模拟一帧物理更新
     */
    public void update() {
        // 应用重力(桌面倾斜,重力有x和y分量)
        ball.vy += GRAVITY * 0.3;  // 沿桌面下滑分量
        ball.vx += GRAVITY * 0.8;  // 向底部滚动的分量

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

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

        // 碰撞检测与响应
        handleLineCollisions();
        handleCircleCollisions();
    }

    /**
     * 控制台可视化输出
     */
    public void render() {
        int width = 100, height = 40;
        char[][] canvas = new char[height][width];
        for (int i = 0; i < height; i++) {
            for (int j = 0; j < width; j++) {
                canvas[i][j] = ' ';
            }
        }

        // 绘制边界
        for (LineSegment wall : walls) {
            drawLine(canvas, wall.x1, wall.y1, wall.x2, wall.y2, wall.isBumper ? '=' : '#');
        }

        // 绘制缓冲柱
        for (CircleBumper b : bumpers) {
            int cx = (int) b.cx;
            int cy = (int) b.cy;
            int r = (int) b.radius;
            for (int dy = -r; dy <= r; dy++) {
                for (int dx = -r; dx <= r; dx++) {
                    if (dx * dx + dy * dy <= r * r) {
                        int px = cx + dx, py = cy + dy;
                        if (px >= 0 && px < width && py >= 0 && py < height) {
                            canvas[py][px] = 'O';
                        }
                    }
                }
            }
        }

        // 绘制弹珠
        int bx = (int) ball.x;
        int by = (int) ball.y;
        if (bx >= 0 && bx < width && by >= 0 && by < height) {
            canvas[by][bx] = '*';
        }

        // 输出画面
        StringBuilder sb = new StringBuilder();
        sb.append("┌" + "─".repeat(width) + "┐\n");
        for (int i = 0; i < height; i++) {
            sb.append("│");
            for (int j = 0; j < width; j++) {
                sb.append(canvas[i][j]);
            }
            sb.append("│\n");
        }
        sb.append("└" + "─".repeat(width) + "┘\n");
        sb.append(String.format("得分: %d | 速度: (%.2f, %.2f) | 位置: (%.1f, %.1f)\n",
                ball.score, ball.vx, ball.vy, ball.x, ball.y));
        System.out.print("\033[H\033[2J"); // 清屏
        System.out.println(sb);
    }

    private void drawLine(char[][] canvas, double x1, double y1, double x2, double y2, char ch) {
        int steps = (int) (Math.max(Math.abs(x2 - x1), Math.abs(y2 - y1)) * 2);
        for (int i = 0; i <= steps; i++) {
            double t = (double) i / steps;
            int x = (int) (x1 + t * (x2 - x1));
            int y = (int) (y1 + t * (y2 - y1));
            if (x >= 0 && x < canvas[0].length && y >= 0 && y < canvas.length) {
                canvas[y][x] = ch;
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        PinballSimulator sim = new PinballSimulator();
        System.out.println("=== 弹珠台物理模拟器 ===");
        System.out.println("* = 弹珠, O = 缓冲柱, # = 边界, = = 挡板");
        Thread.sleep(1500);

        for (int frame = 0; frame < 500; frame++) {
            sim.update();
            sim.render();
            Thread.sleep(50);

            // 弹珠掉出底部则重置
            if (sim.ball.y > 210) {
                sim.ball.x = 50;
                sim.ball.y = 100;
                sim.ball.vx = 2 + Math.random() * 3;
                sim.ball.vy = -1 - Math.random() * 2;
            }
        }
    }
}

算法复杂度分析

操作 时间复杂度 空间复杂度 说明
点到线段最近点计算 O(1) O(1) 向量投影运算
反射向量计算 O(1) O(1) 简单的向量分解与合成
单帧碰撞检测 O(N+M) O(1) N为线段数,M为圆形缓冲柱数
整体模拟 O(T×(N+M)) O(1) T为模拟帧数

总结

本文通过Java实现了一个简化版弹珠台物理模拟器,重点实现了两个核心算法:

  1. 圆形与线段碰撞检测:利用向量投影计算圆心到线段的最近距离,判断碰撞并计算碰撞法线。
  2. 反射向量计算:将速度分解为法向和切向分量,法向分量反向实现反弹,并引入恢复系数模拟能量损失。

这两个算法不仅是弹珠台的基础,也广泛应用于游戏物理引擎、光线追踪和刚体碰撞模拟等领域。理解反射向量的数学本质,对掌握计算机图形学和物理模拟至关重要。