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

引言

弹珠台(Pinball)是起源于18世纪法国的 classic 桌面游戏,玩家通过控制挡板弹射钢珠,使其在布满障碍、缓冲器和坡道的台面上穿梭,尽可能获得高分。弹珠台的核心魅力在于其物理引擎——精确的碰撞检测、真实的反弹轨迹和引人入胜的动力学效果。本文用Java实现一个完整的2D弹珠台物理引擎,核心讲解AABB碰撞检测、圆形与线段碰撞的向量反射计算,以及基于欧拉积分的运动学模拟。

物理建模基础

坐标系与基本物理量

弹珠台采用二维平面坐标系,Y轴向下为正(符合屏幕坐标习惯)。每个物理实体具有:

  • 位置向量 p = (x, y)
  • 速度向量 v = (vx, vy)
  • 加速度向量 a = (ax, ay)
  • 质量 m(弹珠质量归一化为1.0)
  • 弹性系数 e(0 ≤ e ≤ 1,e=1为完全弹性碰撞)

运动学更新(欧拉积分)

每一帧(时间步长 dt)按以下顺序更新:

a = F_gravity / m = (0, g)  // 重力加速度
v = v + a * dt              // 更新速度
p = p + v * dt              // 更新位置

其中 g 取值为 500.0(像素/秒²),dt 通常为 1/60 秒。

重力与摩擦力

/**
 * 弹珠物理实体类
 */
public class Ball {
    public double x, y;      // 位置
    public double vx, vy;    // 速度
    public double radius;    // 半径
    public double mass;      // 质量
    public double elasticity; // 弹性系数
    public double friction;   // 摩擦系数

    public Ball(double x, double y, double radius) {
        this.x = x;
        this.y = y;
        this.radius = radius;
        this.mass = 1.0;
        this.elasticity = 0.85; // 85%弹性保留
        this.friction = 0.995;  // 每帧速度衰减0.5%
    }

    /**
     * 物理更新:重力、速度、位置、摩擦
     * @param dt 时间步长(秒)
     * @param gravity 重力加速度
     */
    public void update(double dt, double gravity) {
        // 重力加速度
        double ax = 0;
        double ay = gravity;

        // 更新速度(欧拉积分)
        vx += ax * dt;
        vy += ay * dt;

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

        // 应用摩擦力
        vx *= friction;
        vy *= friction;

        // 速度阈值:防止数值漂移
        if (Math.abs(vx) < 0.01) vx = 0;
        if (Math.abs(vy) < 0.01) vy = 0;
    }

    /**
     * 获取速度大小
     */
    public double getSpeed() {
        return Math.sqrt(vx * vx + vy * vy);
    }

    /**
     * 获取速度方向(弧度)
     */
    public double getVelocityAngle() {
        return Math.atan2(vy, vx);
    }
}

碰撞检测系统

AABB包围盒粗检测

在进行精确碰撞计算前,先用AABB(轴对齐包围盒)快速排除不相交的物体:

/**
 * AABB包围盒
 */
public class AABB {
    public double minX, minY, maxX, maxY;

    public AABB(double minX, double minY, double maxX, double maxY) {
        this.minX = minX; this.minY = minY;
        this.maxX = maxX; this.maxY = maxY;
    }

    public boolean intersects(AABB other) {
        return this.maxX >= other.minX && this.minX <= other.maxX
            && this.maxY >= other.minY && this.minY <= other.maxY;
    }

    /**
     * 从球体构建AABB
     */
    public static AABB fromBall(Ball ball) {
        return new AABB(
            ball.x - ball.radius, ball.y - ball.radius,
            ball.x + ball.radius, ball.y + ball.radius
        );
    }
}

圆形与线段的精确碰撞检测

弹珠台中最常见的碰撞是弹珠(圆形)与挡板、墙壁(线段)的碰撞。检测算法如下:

/**
 * 线段类,表示弹珠台中的墙壁或挡板
 */
public class LineSegment {
    public double x1, y1, x2, y2; // 端点坐标
    public double nx, ny;         // 法线方向(单位向量)
    public double elasticity;     // 弹性系数

    public LineSegment(double x1, double y1, double x2, double y2, double elasticity) {
        this.x1 = x1; this.y1 = y1;
        this.x2 = x2; this.y2 = y2;
        this.elasticity = elasticity;

        // 计算法线(垂直于线段方向,指向碰撞侧)
        double dx = x2 - x1;
        double dy = y2 - y1;
        double len = Math.sqrt(dx * dx + dy * dy);
        // 法线:(dy, -dx) 或 (-dy, dx),这里取 (dy/len, -dx/len)
        this.nx = dy / len;
        this.ny = -dx / len;
    }

    /**
     * 计算点到线段的最近距离和投影点
     * @return [距离, 最近点x, 最近点y, 是否在线段范围内]
     */
    public double[] pointToSegmentDistance(double px, double py) {
        double dx = x2 - x1;
        double dy = y2 - y1;
        double len2 = dx * dx + dy * dy;

        double t = Math.max(0, Math.min(1, ((px - x1) * dx + (py - y1) * dy) / len2));
        double closestX = x1 + t * dx;
        double closestY = y1 + t * dy;

        double dist = Math.sqrt((px - closestX) * (px - closestX) + (py - closestY) * (py - closestY));
        return new double[]{dist, closestX, closestY, t};
    }
}

/**
 * 碰撞检测器
 */
public class CollisionDetector {

    /**
     * 检测球与线段的碰撞
     * @return 碰撞信息,无碰撞返回null
     */
    public static CollisionInfo checkBallLineCollision(Ball ball, LineSegment line) {
        // AABB粗检测
        AABB ballAABB = AABB.fromBall(ball);
        AABB lineAABB = new AABB(
            Math.min(line.x1, line.x2), Math.min(line.y1, line.y2),
            Math.max(line.x1, line.x2), Math.max(line.y1, line.y2)
        );
        if (!ballAABB.intersects(lineAABB)) return null;

        // 精确检测:球心到线段距离
        double[] result = line.pointToSegmentDistance(ball.x, ball.y);
        double dist = result[0];
        double closestX = result[1];
        double closestY = result[2];

        if (dist > ball.radius) return null;

        // 计算碰撞法线(从线段指向球心)
        double nx = ball.x - closestX;
        double ny = ball.y - closestY;
        double nLen = Math.sqrt(nx * nx + ny * ny);
        if (nLen > 0) {
            nx /= nLen;
            ny /= nLen;
        } else {
            // 球心恰好在线段上,使用线段预计算法线
            nx = line.nx;
            ny = line.ny;
        }

        // 计算穿透深度
        double penetration = ball.radius - dist;

        return new CollisionInfo(ball, line, nx, ny, penetration, closestX, closestY);
    }

    static class CollisionInfo {
        Ball ball;
        LineSegment line;
        double nx, ny;        // 碰撞法线
        double penetration;   // 穿透深度
        double contactX, contactY; // 碰撞点

        CollisionInfo(Ball ball, LineSegment line, double nx, double ny, 
                      double penetration, double contactX, double contactY) {
            this.ball = ball;
            this.line = line;
            this.nx = nx; this.ny = ny;
            this.penetration = penetration;
            this.contactX = contactX;
            this.contactY = contactY;
        }
    }
}

碰撞响应:向量反射

反射公式推导

当球以速度 v 碰撞到法线为 n 的表面时,反射速度 v' 由以下公式计算:

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

其中 v · n 是速度向量与法线的点积。如果表面有弹性系数 e,则:

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

碰撞响应实现

/**
 * 碰撞响应处理器
 */
public class CollisionResponse {

    /**
     * 处理球与线段的碰撞响应
     * @param info 碰撞信息
     * @param restitution 弹性系数(取球和线的较小值)
     */
    public static void resolveBallLineCollision(CollisionDetector.CollisionInfo info, double restitution) {
        Ball ball = info.ball;
        double nx = info.nx;
        double ny = info.ny;

        // 1. 位置修正:将球沿法线方向推出穿透深度
        // 加上小量epsilon防止数值误差导致下一帧再次碰撞
        double epsilon = 0.001;
        ball.x += nx * (info.penetration + epsilon);
        ball.y += ny * (info.penetration + epsilon);

        // 2. 速度反射
        double dot = ball.vx * nx + ball.vy * ny;

        // 只有当球朝向线段运动时(dot < 0)才需要响应
        if (dot < 0) {
            double e = Math.min(ball.elasticity, restitution);

            // v' = v - (1+e) * (v·n) * n
            double impulse = -(1 + e) * dot;
            ball.vx += impulse * nx;
            ball.vy += impulse * ny;
        }
    }

    /**
     * 处理弹珠与圆形缓冲器(bumper)的碰撞
     * 缓冲器通常是静止的圆形障碍物,碰撞后弹珠获得额外加速
     */
    public static void resolveBallBumperCollision(Ball ball, 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) return;

        // 法线方向
        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;
        if (dot < 0) {
            double e = Math.min(ball.elasticity, bumper.elasticity);
            double impulse = -(1 + e) * dot;
            ball.vx += impulse * nx;
            ball.vy += impulse * ny;

            // 缓冲器额外加速(弹珠台特色机制)
            double boostSpeed = bumper.boostFactor * ball.getSpeed();
            double currentSpeed = ball.getSpeed();
            if (currentSpeed > 0) {
                double scale = (currentSpeed + boostSpeed) / currentSpeed;
                ball.vx *= scale;
                ball.vy *= scale;
            }
        }
    }

    /**
     * 圆形缓冲器类
     */
    public static class Bumper {
        public double x, y;
        public double radius;
        public double elasticity;
        public double boostFactor; // 加速倍数

        public Bumper(double x, double y, double radius, double boostFactor) {
            this.x = x; this.y = y;
            this.radius = radius;
            this.elasticity = 1.0; // 缓冲器通常完全弹性
            this.boostFactor = boostFactor;
        }
    }
}

挡板(Flipper)交互系统

挡板运动学

挡板是弹珠台中玩家可控的关键部件,分为左挡板和右挡板,通过按键触发快速上摆:

/**
 * 挡板类,模拟真实的摆动物理
 */
public class Flipper {
    public double pivotX, pivotY;    // 旋转轴心
    public double length;            // 挡板长度
    public double restAngle;         // 静止角度(弧度)
    public double activeAngle;       // 触发角度(弧度)
    public double currentAngle;      // 当前角度
    public double angularVelocity;   // 角速度
    public boolean isActive;         // 是否被触发

    // 物理参数
    private static final double SPRING_STRENGTH = 20.0;  // 弹簧恢复系数
    private static final double DAMPING = 0.85;          // 阻尼
    private static final double ACTIVE_FORCE = 35.0;     // 触发驱动力

    public Flipper(double pivotX, double pivotY, double length, 
                   double restAngle, double activeAngle) {
        this.pivotX = pivotX;
        this.pivotY = pivotY;
        this.length = length;
        this.restAngle = restAngle;
        this.activeAngle = activeAngle;
        this.currentAngle = restAngle;
        this.angularVelocity = 0;
        this.isActive = false;
    }

    /**
     * 更新挡板角度(每帧调用)
     */
    public void update(double dt) {
        double targetAngle = isActive ? activeAngle : restAngle;

        // 弹簧-阻尼系统
        double displacement = currentAngle - targetAngle;
        double acceleration = -SPRING_STRENGTH * displacement;
        if (isActive) acceleration += ACTIVE_FORCE; // 触发时额外推力

        angularVelocity += acceleration * dt;
        angularVelocity *= DAMPING;
        currentAngle += angularVelocity * dt;

        // 角度限制
        double minAngle = Math.min(restAngle, activeAngle);
        double maxAngle = Math.max(restAngle, activeAngle);
        currentAngle = Math.max(minAngle, Math.min(maxAngle, currentAngle));
    }

    /**
     * 获取挡板末端坐标
     */
    public double getTipX() {
        return pivotX + length * Math.cos(currentAngle);
    }

    public double getTipY() {
        return pivotY + length * Math.sin(currentAngle);
    }

    /**
     * 获取当前线段的碰撞几何信息
     */
    public LineSegment getLineSegment() {
        return new LineSegment(pivotX, pivotY, getTipX(), getTipY(), 0.95);
    }

    /**
     * 计算挡板击球时对弹珠的额外冲量
     * 基于挡板末端线速度:v = ω × r
     */
    public double[] getImpulseAtTip() {
        double tipVx = -angularVelocity * length * Math.sin(currentAngle);
        double tipVy = angularVelocity * length * Math.cos(currentAngle);
        return new double[]{tipVx, tipVy};
    }
}

完整物理引擎主循环

/**
 * 弹珠台物理引擎主类
 */
public class PinballPhysicsEngine {

    private Ball ball;
    private List<LineSegment> walls;
    private List<CollisionResponse.Bumper> bumpers;
    private List<Flipper> flippers;
    private double gravity;
    private double dt;

    public PinballPhysicsEngine() {
        this.gravity = 500.0;    // 像素/秒²
        this.dt = 1.0 / 60.0;    // 60FPS
        this.walls = new ArrayList<>();
        this.bumpers = new ArrayList<>();
        this.flippers = new ArrayList<>();
    }

    /**
     * 初始化弹珠台场景
     */
    public void initTable() {
        // 创建弹珠
        ball = new Ball(400, 100, 8);
        ball.vx = 200;
        ball.vy = 100;

        // 添加边界墙壁
        walls.add(new LineSegment(50, 50, 750, 50, 0.8));       // 顶墙
        walls.add(new LineSegment(50, 50, 50, 550, 0.8));       // 左墙
        walls.add(new LineSegment(750, 50, 750, 550, 0.8));     // 右墙
        walls.add(new LineSegment(50, 550, 300, 550, 0.8));     // 底左
        walls.add(new LineSegment(500, 550, 750, 550, 0.8));    // 底右

        // 添加倾斜导向板
        walls.add(new LineSegment(50, 300, 150, 450, 0.7));
        walls.add(new LineSegment(750, 300, 650, 450, 0.7));

        // 添加圆形缓冲器
        bumpers.add(new CollisionResponse.Bumper(400, 250, 25, 0.3));
        bumpers.add(new CollisionResponse.Bumper(300, 200, 20, 0.25));
        bumpers.add(new CollisionResponse.Bumper(500, 200, 20, 0.25));

        // 添加挡板(左、右)
        flippers.add(new Flipper(300, 550, 80, Math.PI * 0.8, Math.PI * 0.3));
        flippers.add(new Flipper(500, 550, 80, Math.PI * 0.2, Math.PI * 0.7));
    }

    /**
     * 物理更新主循环(每帧调用)
     */
    public void update() {
        // 1. 更新弹珠运动
        ball.update(dt, gravity);

        // 2. 更新挡板
        for (Flipper flipper : flippers) {
            flipper.update(dt);
        }

        // 3. 碰撞检测与响应 - 墙壁
        for (LineSegment wall : walls) {
            CollisionDetector.CollisionInfo info = 
                CollisionDetector.checkBallLineCollision(ball, wall);
            if (info != null) {
                CollisionResponse.resolveBallLineCollision(info, wall.elasticity);
            }
        }

        // 4. 碰撞检测与响应 - 挡板(动态线段)
        for (Flipper flipper : flippers) {
            LineSegment flipperLine = flipper.getLineSegment();
            CollisionDetector.CollisionInfo info = 
                CollisionDetector.checkBallLineCollision(ball, flipperLine);
            if (info != null) {
                CollisionResponse.resolveBallLineCollision(info, flipperLine.elasticity);

                // 挡板击球额外冲量
                if (flipper.isActive) {
                    double[] impulse = flipper.getImpulseAtTip();
                    ball.vx += impulse[0] * 0.3; // 传递部分动量
                    ball.vy += impulse[1] * 0.3;
                }
            }
        }

        // 5. 碰撞检测与响应 - 缓冲器
        for (CollisionResponse.Bumper bumper : bumpers) {
            CollisionResponse.resolveBallBumperCollision(ball, bumper);
        }

        // 6. 球落入底部区域判定(游戏结束/重新发射)
        if (ball.y > 600) {
            resetBall();
        }
    }

    private void resetBall() {
        ball.x = 400;
        ball.y = 100;
        ball.vx = (Math.random() - 0.5) * 300;
        ball.vy = 100;
    }

    public void setFlipperActive(boolean left, boolean active) {
        if (left && flippers.size() > 0) flippers.get(0).isActive = active;
        if (!left && flippers.size() > 1) flippers.get(1).isActive = active;
    }

    public static void main(String[] args) {
        PinballPhysicsEngine engine = new PinballPhysicsEngine();
        engine.initTable();

        // 模拟运行1000帧
        for (int frame = 0; frame < 1000; frame++) {
            // 模拟按键(每200帧触发一次左挡板)
            if (frame % 200 == 0) {
                engine.setFlipperActive(true, true);
            }
            if (frame % 200 == 10) {
                engine.setFlipperActive(true, false);
            }

            engine.update();

            if (frame % 60 == 0) {
                System.out.printf("Frame %d: Ball at (%.1f, %.1f), Speed=%.1f%n",
                    frame, engine.ball.x, engine.ball.y, engine.ball.getSpeed());
            }
        }
    }
}

高级特性:多步碰撞与CCD

当弹珠速度过快时,单帧内可能穿透薄墙壁(隧道效应)。采用连续碰撞检测(CCD)解决:

/**
 * 连续碰撞检测(简化版)
 * 通过限制单帧最大移动距离,或采用射线投射检测
 */
public void updateWithCCD() {
    double maxStep = ball.radius * 0.8; // 每子步最大移动距离
    double remainingDist = ball.getSpeed() * dt;
    int subSteps = (int) Math.ceil(remainingDist / maxStep);
    subSteps = Math.max(1, Math.min(subSteps, 10)); // 限制1-10个子步

    double subDt = dt / subSteps;
    for (int i = 0; i < subSteps; i++) {
        ball.update(subDt, gravity);
        // 碰撞检测...
    }
}

性能优化总结

技术 效果 适用场景
AABB粗检测 排除90%以上无碰撞对 所有碰撞检测
空间哈希/网格 O(1)邻域查询 大量缓冲器场景
子步积分 防止高速穿透 弹珠速度 > 半径/帧
休眠机制 静止弹珠停止更新 弹珠长时间静止

总结

本文构建了一个完整的2D弹珠台物理引擎,核心算法包括:

  1. 欧拉积分:基于离散时间步的位置-速度更新
  2. AABB粗检测 + 精确距离检测:高效且准确的碰撞检测流水线
  3. 向量反射公式:基于法线的精确碰撞响应
  4. 弹簧-阻尼系统:挡板的逼真摆动物理
  5. 连续碰撞检测:防止高速物体的隧道穿透

读者可以在此基础上扩展:加入旋转障碍物(力矩计算)、多球碰撞(动量守恒)、磁场/风力等外力场,以及完整的计分与关卡系统。弹珠台物理引擎的核心思想同样适用于 breakout、台球、弹球等众多经典游戏。