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

引言

弹球游戏(Pinball)融合了物理碰撞、精确反射与得分机制,核心难点在于球体与各种挡板、障碍物之间的弹性碰撞与向量反射计算。本文用Java实现弹球物理引擎,重点讲解向量反射公式、碰撞响应以及刚体碰撞检测算法。

核心算法概览

  1. 向量反射:球碰到挡板或墙壁时,按入射角等于反射角反弹
  2. 弹性碰撞:考虑恢复系数,模拟能量损失与反弹衰减
  3. 碰撞检测:球与圆形/矩形障碍物的相交检测

向量数学基础

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

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

    public Vector2D add(Vector2D v) {
        return new Vector2D(x + v.x, y + v.y);
    }

    public Vector2D sub(Vector2D v) {
        return new Vector2D(x - v.x, y - v.y);
    }

    public Vector2D mul(double s) {
        return new Vector2D(x * s, y * s);
    }

    public double dot(Vector2D v) {
        return x * v.x + y * v.y;
    }

    public double length() {
        return Math.sqrt(x * x + y * y);
    }

    public Vector2D normalize() {
        double len = length();
        if (len == 0) return new Vector2D(0, 0);
        return new Vector2D(x / len, y / len);
    }

    /** 向量在法线方向上的反射:r = v - 2(v·n)n */
    public Vector2D reflect(Vector2D normal) {
        Vector2D n = normal.normalize();
        double dot = this.dot(n);
        return this.sub(n.mul(2 * dot));
    }

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

弹球物理实体

/**
 * 弹球实体,包含位置、速度、半径等物理属性
 */
public class Ball {
    private Vector2D position;  // 位置
    private Vector2D velocity;  // 速度向量
    private double radius;      // 半径
    private double mass;        // 质量
    private double restitution; // 恢复系数(0=完全非弹性,1=完全弹性)

    public Ball(double x, double y, double radius, double vx, double vy) {
        this.position = new Vector2D(x, y);
        this.velocity = new Vector2D(vx, vy);
        this.radius = radius;
        this.mass = radius * radius; // 质量与面积成正比
        this.restitution = 0.85;     // 85%弹性恢复
    }

    /**
     * 每帧更新位置(欧拉积分)
     */
    public void update(double dt) {
        position = position.add(velocity.mul(dt));
    }

    /**
     * 应用重力加速度
     */
    public void applyGravity(double g, double dt) {
        velocity.y += g * dt;
    }

    /**
     * 沿法线方向反射速度向量
     */
    public void bounce(Vector2D normal) {
        velocity = velocity.reflect(normal).mul(restitution);
    }

    public Vector2D getPosition() { return position; }
    public Vector2D getVelocity() { return velocity; }
    public double getRadius() { return radius; }
    public double getMass() { return mass; }

    public void setPosition(Vector2D p) { this.position = p; }
    public void setVelocity(Vector2D v) { this.velocity = v; }
}

碰撞检测器

球与线段(挡板/墙壁)的碰撞

public class CollisionDetector {

    /**
     * 检测球与线段的碰撞
     * @param ball 弹球
     * @param p1, p2 线段两端点
     * @return 碰撞信息,无碰撞返回null
     */
    public CollisionResult checkBallSegment(Ball ball, Vector2D p1, Vector2D p2) {
        Vector2D center = ball.getPosition();
        double r = ball.getRadius();

        // 线段方向向量
        Vector2D seg = p2.sub(p1);
        // 球心到线段起点的向量
        Vector2D toCenter = center.sub(p1);

        // 投影系数 t = (toCenter · seg) / (seg · seg)
        double segLenSq = seg.dot(seg);
        if (segLenSq == 0) return null; // p1==p2,退化为点

        double t = Math.max(0, Math.min(1, toCenter.dot(seg) / segLenSq));

        // 线段上离球心最近的点
        Vector2D closest = p1.add(seg.mul(t));
        Vector2D distVec = center.sub(closest);
        double dist = distVec.length();

        if (dist < r) {
            // 计算法线方向(从最近点指向球心)
            Vector2D normal = distVec.normalize();
            // 穿透深度
            double penetration = r - dist;
            return new CollisionResult(normal, penetration, closest);
        }

        return null;
    }

    /**
     * 检测球与圆的碰撞(圆形障碍物/缓冲器)
     */
    public CollisionResult checkBallCircle(Ball ball, Vector2D center, double radius) {
        Vector2D distVec = ball.getPosition().sub(center);
        double dist = distVec.length();
        double minDist = ball.getRadius() + radius;

        if (dist < minDist) {
            Vector2D normal = distVec.normalize();
            double penetration = minDist - dist;
            return new CollisionResult(normal, penetration, center.add(normal.mul(radius)));
        }

        return null;
    }

    /**
     * 检测球与矩形AABB的碰撞
     */
    public CollisionResult checkBallAABB(Ball ball, double minX, double minY,
                                          double maxX, double maxY) {
        Vector2D center = ball.getPosition();
        double r = ball.getRadius();

        // 矩形上离球心最近的点
        double closestX = Math.max(minX, Math.min(center.x, maxX));
        double closestY = Math.max(minY, Math.min(center.y, maxY));

        Vector2D closest = new Vector2D(closestX, closestY);
        Vector2D distVec = center.sub(closest);
        double dist = distVec.length();

        if (dist < r) {
            Vector2D normal = distVec.normalize();
            // 若球心在矩形内部,法线取反
            if (center.x >= minX && center.x <= maxX && 
                center.y >= minY && center.y <= maxY) {
                normal = new Vector2D(-normal.x, -normal.y);
            }
            double penetration = r - dist;
            return new CollisionResult(normal, penetration, closest);
        }

        return null;
    }

    static class CollisionResult {
        Vector2D normal;      // 碰撞法线(指向球外)
        double penetration;   // 穿透深度
        Vector2D contactPoint; // 接触点

        CollisionResult(Vector2D normal, double penetration, Vector2D contactPoint) {
            this.normal = normal;
            this.penetration = penetration;
            this.contactPoint = contactPoint;
        }
    }
}

碰撞响应与反射

public class PhysicsEngine {

    private CollisionDetector detector = new CollisionDetector();

    /**
     * 处理球与静态障碍物的碰撞(墙壁、挡板)
     * 核心:沿碰撞法线反射速度向量,并修正位置消除穿透
     */
    public void resolveStaticCollision(Ball ball, CollisionDetector.CollisionResult result) {
        // 1. 位置修正:将球沿法线方向推出穿透深度
        Vector2D correction = result.normal.mul(result.penetration);
        ball.setPosition(ball.getPosition().add(correction));

        // 2. 速度反射:v' = v - 2(v·n)n
        ball.bounce(result.normal);
    }

    /**
     * 处理挡板击球:给球施加额外速度增量
     * @param flipperVelocity 挡板末端的线速度向量
     */
    public void resolveFlipperHit(Ball ball, Vector2D flipperVelocity,
                                   CollisionDetector.CollisionResult result) {
        // 先执行标准反射
        resolveStaticCollision(ball, result);

        // 再叠加挡板动量(击球加速效果)
        Vector2D newVel = ball.getVelocity().add(flipperVelocity.mul(0.3));
        // 限制最大速度
        if (newVel.length() > 15) {
            newVel = newVel.normalize().mul(15);
        }
        ball.setVelocity(newVel);
    }

    /**
     * 处理球与圆形缓冲器(bumper)的碰撞,给予额外加速
     */
    public void resolveBumperHit(Ball ball, CollisionDetector.CollisionResult result) {
        resolveStaticCollision(ball, result);
        // 缓冲器额外加速20%
        ball.setVelocity(ball.getVelocity().mul(1.2));
    }
}

弹球台场景与挡板控制

import java.util.*;

public class PinballTable {

    private double width = 400, height = 700; // 弹球台尺寸
    private Ball ball;
    private List<Obstacle> obstacles;
    private Flipper leftFlipper, rightFlipper;
    private PhysicsEngine physics = new PhysicsEngine();
    private CollisionDetector detector = new CollisionDetector();

    // 障碍物基类
    static abstract class Obstacle {
        abstract CollisionDetector.CollisionResult checkCollision(Ball ball);
        abstract void onHit(Ball ball, PhysicsEngine physics, 
                           CollisionDetector.CollisionResult result);
    }

    // 墙壁
    static class Wall extends Obstacle {
        Vector2D p1, p2;
        Wall(Vector2D p1, Vector2D p2) { this.p1 = p1; this.p2 = p2; }

        @Override
        CollisionDetector.CollisionResult checkCollision(Ball ball) {
            return new CollisionDetector().checkBallSegment(ball, p1, p2);
        }

        @Override
        void onHit(Ball ball, PhysicsEngine physics, 
                  CollisionDetector.CollisionResult result) {
            physics.resolveStaticCollision(ball, result);
        }
    }

    // 圆形缓冲器
    static class Bumper extends Obstacle {
        Vector2D center;
        double radius;
        Bumper(Vector2D c, double r) { this.center = c; this.radius = r; }

        @Override
        CollisionDetector.CollisionResult checkCollision(Ball ball) {
            return new CollisionDetector().checkBallCircle(ball, center, radius);
        }

        @Override
        void onHit(Ball ball, PhysicsEngine physics, 
                  CollisionDetector.CollisionResult result) {
            physics.resolveBumperHit(ball, result);
        }
    }

    // 挡板
    static class Flipper {
        Vector2D pivot;      // 旋转轴心
        double length;       // 挡板长度
        double angle;        // 当前角度(弧度)
        double restAngle;    // 静止角度
        double activeAngle;  // 击发角度
        boolean isActive;    // 是否正在击发
        double angularVelocity; // 角速度

        Flipper(Vector2D pivot, double length, double restAngle, double activeAngle) {
            this.pivot = pivot;
            this.length = length;
            this.restAngle = restAngle;
            this.activeAngle = activeAngle;
            this.angle = restAngle;
            this.isActive = false;
        }

        /** 激活挡板(按键触发) */
        void activate() { isActive = true; }
        void deactivate() { isActive = false; }

        /** 更新挡板角度 */
        void update(double dt) {
            double target = isActive ? activeAngle : restAngle;
            double diff = target - angle;
            angle += diff * 10 * dt; // 弹性恢复到目标角度
        }

        /** 获取挡板末端坐标 */
        Vector2D getTip() {
            return new Vector2D(
                pivot.x + length * Math.cos(angle),
                pivot.y + length * Math.sin(angle)
            );
        }

        /** 获取末端线速度(用于击球加速计算) */
        Vector2D getTipVelocity() {
            // v = ω × r,简化计算
            double omega = isActive ? 15 : -10; // 击发/收回角速度
            return new Vector2D(
                -omega * length * Math.sin(angle),
                omega * length * Math.cos(angle)
            );
        }
    }

    public PinballTable() {
        // 初始化弹球
        ball = new Ball(200, 600, 8, 3, -8);

        // 初始化障碍物
        obstacles = new ArrayList<>();
        // 左侧墙
        obstacles.add(new Wall(new Vector2D(20, 50), new Vector2D(20, 650)));
        // 右侧墙
        obstacles.add(new Wall(new Vector2D(380, 50), new Vector2D(380, 650)));
        // 顶部墙
        obstacles.add(new Wall(new Vector2D(20, 50), new Vector2D(380, 50)));
        // 圆形缓冲器
        obstacles.add(new Bumper(new Vector2D(200, 200), 25));
        obstacles.add(new Bumper(new Vector2D(150, 300), 20));
        obstacles.add(new Bumper(new Vector2D(250, 300), 20));

        // 初始化挡板
        leftFlipper = new Flipper(new Vector2D(80, 620), 80, 0.3, -0.5);
        rightFlipper = new Flipper(new Vector2D(320, 620), 80, Math.PI - 0.3, Math.PI + 0.5);
    }

    /**
     * 游戏主循环,每帧调用
     */
    public void update(double dt) {
        // 1. 应用重力
        ball.applyGravity(500, dt); // 向下的重力加速度

        // 2. 更新位置
        ball.update(dt);

        // 3. 更新挡板
        leftFlipper.update(dt);
        rightFlipper.update(dt);

        // 4. 检测并处理所有碰撞
        checkCollisions();

        // 5. 检测是否出界(球掉到底部)
        if (ball.getPosition().y > height) {
            resetBall();
        }
    }

    private void checkCollisions() {
        // 与静态障碍物碰撞
        for (Obstacle obs : obstacles) {
            CollisionDetector.CollisionResult result = obs.checkCollision(ball);
            if (result != null) {
                obs.onHit(ball, physics, result);
            }
        }

        // 与左挡板碰撞
        Vector2D leftTip = leftFlipper.getTip();
        CollisionDetector.CollisionResult leftHit = 
            detector.checkBallSegment(ball, leftFlipper.pivot, leftTip);
        if (leftHit != null) {
            physics.resolveFlipperHit(ball, leftFlipper.getTipVelocity(), leftHit);
        }

        // 与右挡板碰撞
        Vector2D rightTip = rightFlipper.getTip();
        CollisionDetector.CollisionResult rightHit = 
            detector.checkBallSegment(ball, rightFlipper.pivot, rightTip);
        if (rightHit != null) {
            physics.resolveFlipperHit(ball, rightFlipper.getTipVelocity(), rightHit);
        }
    }

    private void resetBall() {
        ball.setPosition(new Vector2D(200, 600));
        ball.setVelocity(new Vector2D(3, -8));
    }

    public Ball getBall() { return ball; }
    public void activateLeftFlipper() { leftFlipper.activate(); }
    public void deactivateLeftFlipper() { leftFlipper.deactivate(); }
    public void activateRightFlipper() { rightFlipper.activate(); }
    public void deactivateRightFlipper() { rightFlipper.deactivate(); }
}

主程序入口

public class PinballGame {
    public static void main(String[] args) {
        PinballTable table = new PinballTable();
        double dt = 1.0 / 60.0; // 60FPS

        // 模拟120帧
        for (int i = 0; i < 120; i++) {
            // 模拟按键:第30-40帧按左挡板
            if (i == 30) table.activateLeftFlipper();
            if (i == 40) table.deactivateLeftFlipper();

            table.update(dt);

            Vector2D pos = table.getBall().getPosition();
            Vector2D vel = table.getBall().getVelocity();
            if (i % 20 == 0) {
                System.out.printf("Frame %d: pos=%s, vel=%s%n", i, pos, vel);
            }
        }
    }
}

复杂度分析

模块 时间复杂度 说明
球与线段碰撞 O(1) 向量投影计算,常数时间
球与圆碰撞 O(1) 距离比较
球与AABB碰撞 O(1) 坐标范围比较
每帧全碰撞检测 O(n) n为障碍物数量
向量反射计算 O(1) 点积与向量加减

总结

本文以弹球游戏为载体,深入讲解了向量反射公式、弹性碰撞响应以及多种几何体的碰撞检测。读者可以扩展:
– 实现更精确的物理积分(如Verlet积分或RK4)
– 加入倾斜台板(tilt)机制,通过重力分量控制球路
– 实现多球并发与球-球碰撞处理