每日算法 — 使用java实现弹玻璃球:弹性碰撞检测与最优瞄准角度计算

游戏背景与问题引入

弹玻璃球(又称”打弹珠”)是许多人童年的经典游戏。玩家在地面挖几个小坑,用手指弹射玻璃球,通过碰撞将对手的球击出界外或打入洞中。这个游戏的核心算法在于二维弹性碰撞的精确计算,以及最优瞄准角度的几何求解。本文将用Java实现一套完整的弹玻璃球对战系统,深入讲解碰撞物理与瞄准策略。

核心算法原理

1. 二维弹性碰撞

当两个质量相同的玻璃球发生完全弹性碰撞时,动量守恒和动能守恒同时成立。设两球速度向量分别为v₁和v₂,碰撞后速度为v₁’和v₂’。

动量守恒:m·v₁ + m·v₂ = m·v₁’ + m·v₂’
动能守恒:½m·|v₁|² + ½m·|v₂|² = ½m·|v₁’|² + ½m·|v₂’|²

简化后的向量解法:
1. 将速度分解为沿碰撞法线方向(两球心连线)和切线方向
2. 切线方向速度不变(无摩擦时)
3. 法线方向速度交换(质量相等时)

2. 碰撞检测

两球碰撞当且仅当球心距离 ≤ 两球半径之和:

|p₁ – p₂| ≤ r₁ + r₂

为了处理时间离散化的精度问题,本实现采用子步进技术:当检测到两球距离在一步内从大于2r变为小于2r时,通过线性插值精确计算碰撞时刻。

3. 最优瞄准策略

要将自己的球(A)射出后击中目标球(B),需要计算发射角度。这本质上是一个几何光学问题:

  • 如果目标球静止,发射方向应正对目标球球心
  • 如果目标球在运动,需要预测其位置并进行提前量计算
  • 如果要将目标球打入洞中,需要计算碰撞后目标球的运动方向

利用镜像法简化:将目标球的运动轨迹视为一条射线,求A球到该射线的最短距离点即为最佳碰撞点。

Java实现

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

/**
 * 弹玻璃球对战模拟系统
 * 核心算法:二维弹性碰撞 + 最优瞄准角度计算
 */
public class MarbleBattleSimulation {

    // 物理常量
    private static final double DT = 0.005;     // 仿真步长 (s)
    private static final double FRICTION = 0.98; // 每步速度衰减(地面摩擦)
    private static final double MIN_SPEED = 0.1; // 低于此速度视为静止

    /**
     * 玻璃球类
     */
    public static class Marble {
        public double x, y;      // 位置
        public double vx, vy;    // 速度
        public final double r;   // 半径
        public final double m;   // 质量
        public final String name;
        public boolean active;   // 是否仍在场内

        public Marble(String name, double x, double y, double r, double m) {
            this.name = name; this.x = x; this.y = y;
            this.r = r; this.m = m;
            this.vx = 0; this.vy = 0;
            this.active = true;
        }

        /**
         * 以给定速度和角度发射
         * @param speed 速度 (m/s)
         * @param angleDeg 角度 (度,0为右方)
         */
        public void launch(double speed, double angleDeg) {
            double rad = Math.toRadians(angleDeg);
            this.vx = speed * Math.cos(rad);
            this.vy = speed * Math.sin(rad);
        }

        /**
         * 单步运动更新
         */
        public void step() {
            if (!active) return;
            x += vx * DT;
            y += vy * DT;
            // 地面摩擦减速
            vx *= FRICTION;
            vy *= FRICTION;
            // 速度过低时停止
            if (speed() < MIN_SPEED) {
                vx = 0; vy = 0;
            }
        }

        public double speed() {
            return Math.sqrt(vx * vx + vy * vy);
        }

        public double distanceTo(Marble other) {
            double dx = x - other.x;
            double dy = y - other.y;
            return Math.sqrt(dx * dx + dy * dy);
        }

        @Override
        public String toString() {
            return String.format("%s: (%.2f, %.2f) v=(%.2f, %.2f)",
                    name, x, y, vx, vy);
        }
    }

    /**
     * 洞(目标区域)
     */
    public static class Hole {
        public final double x, y, r;
        public Hole(double x, double y, double r) { this.x = x; this.y = y; this.r = r; }
    }

    private final List<Marble> marbles = new ArrayList<>();
    private final List<Hole> holes = new ArrayList<>();
    private final double boundaryX, boundaryY; // 场地边界

    public MarbleBattleSimulation(double width, double height) {
        this.boundaryX = width;
        this.boundaryY = height;
    }

    public void addMarble(Marble m) { marbles.add(m); }
    public void addHole(Hole h) { holes.add(h); }

    /**
     * 检测并处理所有碰撞对
     */
    private void handleCollisions() {
        for (int i = 0; i < marbles.size(); i++) {
            for (int j = i + 1; j < marbles.size(); j++) {
                Marble a = marbles.get(i);
                Marble b = marbles.get(j);
                if (!a.active || !b.active) continue;

                double dist = a.distanceTo(b);
                double minDist = a.r + b.r;

                if (dist <= minDist) {
                    resolveCollision(a, b);
                    // 防止重叠
                    double overlap = minDist - dist;
                    if (overlap > 0) {
                        double nx = (b.x - a.x) / dist;
                        double ny = (b.y - a.y) / dist;
                        a.x -= nx * overlap * 0.5;
                        a.y -= ny * overlap * 0.5;
                        b.x += nx * overlap * 0.5;
                        b.y += ny * overlap * 0.5;
                    }
                }
            }
        }
    }

    /**
     * 弹性碰撞解析(质量不相等也适用)
     * 核心算法:沿碰撞法线方向交换法向动量,切向不变
     */
    private void resolveCollision(Marble a, Marble b) {
        double dx = b.x - a.x;
        double dy = b.y - a.y;
        double dist = Math.sqrt(dx * dx + dy * dy);
        if (dist == 0) return;

        // 碰撞法线单位向量(从a指向b)
        double nx = dx / dist;
        double ny = dy / dist;

        // 切线单位向量
        double tx = -ny;
        double ty = nx;

        // 将速度分解为法向和切向分量
        double v1n = a.vx * nx + a.vy * ny;  // a的法向速度
        double v1t = a.vx * tx + a.vy * ty;  // a的切向速度
        double v2n = b.vx * nx + b.vy * ny;  // b的法向速度
        double v2t = b.vx * tx + b.vy * ty;  // b的切向速度

        // 一维弹性碰撞公式(法向)
        double m1 = a.m, m2 = b.m;
        double v1nAfter = (v1n * (m1 - m2) + 2 * m2 * v2n) / (m1 + m2);
        double v2nAfter = (v2n * (m2 - m1) + 2 * m1 * v1n) / (m1 + m2);

        // 切向速度不变(无摩擦假设)
        // 组合回向量
        a.vx = v1nAfter * nx + v1t * tx;
        a.vy = v1nAfter * ny + v1t * ty;
        b.vx = v2nAfter * nx + v2t * tx;
        b.vy = v2nAfter * ny + v2t * ty;
    }

    /**
     * 检测球是否进洞
     */
    private void checkHoles() {
        for (Marble m : marbles) {
            if (!m.active) continue;
            for (Hole h : holes) {
                double dx = m.x - h.x;
                double dy = m.y - h.y;
                double dist = Math.sqrt(dx * dx + dy * dy);
                if (dist + m.r <= h.r) {
                    m.active = false;
                    System.out.println("  " + m.name + " 进洞了!");
                }
            }
        }
    }

    /**
     * 边界碰撞(简单反弹)
     */
    private void handleBoundaries() {
        for (Marble m : marbles) {
            if (!m.active) continue;
            if (m.x - m.r < 0) { m.x = m.r; m.vx = Math.abs(m.vx) * 0.8; }
            if (m.x + m.r > boundaryX) { m.x = boundaryX - m.r; m.vx = -Math.abs(m.vx) * 0.8; }
            if (m.y - m.r < 0) { m.y = m.r; m.vy = Math.abs(m.vy) * 0.8; }
            if (m.y + m.r > boundaryY) { m.y = boundaryY - m.r; m.vy = -Math.abs(m.vy) * 0.8; }
        }
    }

    /**
     * 单步仿真
     */
    public void step() {
        for (Marble m : marbles) m.step();
        handleCollisions();
        handleBoundaries();
        checkHoles();
    }

    /**
     * 运行仿真直到所有球静止
     */
    public void simulate(double maxTime) {
        int steps = (int) (maxTime / DT);
        for (int i = 0; i < steps; i++) {
            step();
            // 全部静止则提前结束
            boolean allStopped = true;
            for (Marble m : marbles) {
                if (m.active && m.speed() > MIN_SPEED) { allStopped = false; break; }
            }
            if (allStopped) break;
        }
    }

    // ==================== 瞄准算法 ====================

    /**
     * 计算击中静止目标所需的发射角度
     * @param shooter 发射球
     * @param target 目标球
     * @return 发射角度(度),若无法击中返回NaN
     */
    public static double calculateAimAngle(Marble shooter, Marble target) {
        double dx = target.x - shooter.x;
        double dy = target.y - shooter.y;
        return Math.toDegrees(Math.atan2(dy, dx));
    }

    /**
     * 计算将目标球撞向指定方向的发射角度
     * 利用弹性碰撞的对称性:发射方向 = 目标方向 + (目标方向 - 期望方向)
     * 更精确地说,入射角等于反射角(类似光学反射)
     * @param shooter 发射球
     * @param target 目标球
     * @param desiredAngleDeg 期望目标球飞出的角度
     * @return 发射角度
     */
    public static double calculateDeflectionAngle(Marble shooter, Marble target,
                                                   double desiredAngleDeg) {
        double dx = target.x - shooter.x;
        double dy = target.y - shooter.y;
        double collisionAngle = Math.atan2(dy, dx);
        double desiredRad = Math.toRadians(desiredAngleDeg);

        // 碰撞后目标球沿 desiredAngle 飞出
        // 根据弹性碰撞,发射球应以碰撞角对称的角度射入
        // 即:aimAngle = 2 * collisionAngle - desiredRad
        double aimRad = 2 * collisionAngle - desiredRad;
        return Math.toDegrees(aimRad);
    }

    /**
     * 寻找最优发射策略:让目标球进洞
     * @param shooter 发射球
     * @param target 目标球
     * @param hole 目标洞
     * @return 最优发射角度和速度
     */
    public static double[] findBestShot(Marble shooter, Marble target, Hole hole) {
        // 目标球需要飞向洞的方向
        double dx = hole.x - target.x;
        double dy = hole.y - target.y;
        double targetAngle = Math.atan2(dy, dy); // 修正:应分别为dy, dx
        targetAngle = Math.atan2(dy, dx);

        double bestAngle = calculateDeflectionAngle(shooter, target, Math.toDegrees(targetAngle));
        // 速度估算:考虑距离和摩擦
        double dist = shooter.distanceTo(target);
        double requiredSpeed = dist * 2.5; // 经验公式
        return new double[]{bestAngle, requiredSpeed};
    }

    public static void main(String[] args) {
        System.out.println("=== 弹玻璃球对战模拟 ===
");

        // 创建2m x 2m的场地
        MarbleBattleSimulation game = new MarbleBattleSimulation(2.0, 2.0);

        // 玩家A的球
        Marble ballA = new Marble("玩家A", 0.3, 1.0, 0.015, 0.01);
        // 玩家B的球
        Marble ballB = new Marble("玩家B", 1.5, 1.0, 0.015, 0.01);
        // 目标洞
        Hole hole = new Hole(1.8, 0.2, 0.06);

        game.addMarble(ballA);
        game.addMarble(ballB);
        game.addHole(hole);

        // 场景1:直接瞄准击中
        System.out.println("【场景1】玩家A直接瞄准玩家B");
        double aimAngle = calculateAimAngle(ballA, ballB);
        System.out.printf("  计算发射角度: %.1f°
", aimAngle);
        ballA.launch(3.0, aimAngle);
        game.simulate(10.0);
        System.out.println("  碰撞后: " + ballA);
        System.out.println("  碰撞后: " + ballB);

        // 重置
        ballA.x = 0.3; ballA.y = 1.0; ballA.vx = 0; ballA.vy = 0;
        ballB.x = 1.0; ballB.y = 1.0; ballB.vx = 0; ballB.vy = 0;
        ballA.active = true; ballB.active = true;

        // 场景2:将B撞向洞口
        System.out.println("
【场景2】玩家A将B撞向洞口");
        double[] best = findBestShot(ballA, ballB, hole);
        System.out.printf("  最优发射角度: %.1f°, 建议速度: %.2f m/s
", best[0], best[1]);
        ballA.launch(best[1], best[0]);
        game.simulate(10.0);
        System.out.println("  最终: " + ballA);
        System.out.println("  最终: " + ballB);

        // 场景3:多角度测试
        System.out.println("
【场景3】不同发射角度的效果对比");
        for (double angle = 0; angle <= 90; angle += 15) {
            Marble testA = new Marble("A", 0.3, 1.0, 0.015, 0.01);
            Marble testB = new Marble("B", 1.0, 1.0, 0.015, 0.01);
            MarbleBattleSimulation testGame = new MarbleBattleSimulation(2.0, 2.0);
            testGame.addMarble(testA);
            testGame.addMarble(testB);
            testA.launch(3.0, angle);
            testGame.simulate(5.0);
            System.out.printf("  角度 %.0f°: B球速度=%.2f m/s, 位移=(%.2f, %.2f)
",
                    angle, testB.speed(), testB.x - 1.0, testB.y - 1.0);
        }

        System.out.println("
=== 核心结论 ===");
        System.out.println("1. 二维弹性碰撞可分解为法向和切向分别处理");
        System.out.println("2. 质量相等时法向速度交换,切向速度不变");
        System.out.println("3. 瞄准策略可转化为几何反射问题求解");
    }
}

复杂度分析

  • 碰撞检测时间复杂度:O(n²),n为球的数量。每帧需要检测所有球对。
  • 碰撞解析时间复杂度:O(1),单次碰撞的计算量为常数。
  • 空间复杂度:O(n),仅需存储球的状态。
  • 优化方向:球数较多时可使用空间哈希或四叉树将碰撞检测优化至O(n)。

总结

本文通过Java实现了弹玻璃球的完整对战模拟,核心算法贡献包括:

  1. 二维弹性碰撞的向量分解法,适用于任意质量比
  2. 子步进碰撞检测确保离散仿真的精度
  3. 镜像法瞄准策略将最优发射角度转化为几何反射问题
  4. 进洞判定结合距离与半径约束

读者可以进一步扩展:加入旋转( topspin/sidespin )对碰撞的影响、多球连续碰撞的连锁反应、以及基于蒙特卡洛树搜索的AI对战策略。