每日算法 — 使用java实现台球:物理碰撞与最优击球路径规划

引言

台球是一项经典的桌面运动,其核心魅力在于通过精确控制击球力度和角度,使目标球落入袋中。在实际游戏中,高手不仅要考虑直接的直线击球,还需要计算库边反弹、多球碰撞等复杂路径。本文将用Java实现一个台球模拟系统,重点讲解二维刚体碰撞物理计算、反射向量求解,以及基于几何路径搜索的最优击球路线规划算法。

核心算法原理

1. 二维弹性碰撞物理模型

台球碰撞可简化为理想弹性碰撞。设两球质量相等,碰撞前后动量和动能守恒:

动量守恒:

v1 + v2 = v1' + v2'

动能守恒:

|v1|^2 + |v2|^2 = |v1'|^2 + |v2'|^2

通过将速度分解为沿碰撞法线方向和切线方向,可以得到碰撞后的速度:

v1_normal' = v2_normal   // 法线方向速度交换
v1_tangent' = v1_tangent // 切线方向速度不变

2. 库边反射计算

球撞击桌边时,入射角等于反射角。数学上即速度在碰撞边法线方向的分量取反:

if (撞击垂直边) vx = -vx
if (撞击水平边) vy = -vy

3. 最优击球路径搜索

对于不能直接击中的目标球,需要搜索经过库边反弹的间接路径。算法步骤:

  1. 镜像法:将目标袋口关于库边做镜像对称
  2. 直线路径:连接白球位置与镜像点,与库边的交点即为反弹点
  3. 多库反弹:对每增加一次库边反弹,递归做一次镜像变换
  4. 可行性验证:检查路径上是否有其他球阻挡

Java完整实现

import javax.swing.*;
import java.awt.*;
import java.awt.geom.*;
import java.util.*;
import java.util.List;

/**
 * 台球模拟 - 物理碰撞与最优击球路径规划
 * 
 * 核心算法:
 * 1. 二维弹性碰撞(动量守恒 + 动能守恒)
 * 2. 库边反射向量计算
 * 3. 镜像法最优路径搜索
 * 4. AABB碰撞检测
 */
public class BilliardGame extends JPanel {

    // 物理常量
    private static final double BALL_RADIUS = 12.0;
    private static final double TABLE_WIDTH = 800;
    private static final double TABLE_HEIGHT = 400;
    private static final double FRICTION = 0.985;  // 摩擦力减速系数
    private static final double MIN_SPEED = 0.15;   // 停止阈值
    private static final double MAX_POWER = 15.0;   // 最大击球力度

    // 球桌边界
    private static final double LEFT = 50;
    private static final double RIGHT = LEFT + TABLE_WIDTH;
    private static final double TOP = 50;
    private static final double BOTTOM = TOP + TABLE_HEIGHT;

    // 袋口位置
    private static final Point2D[] POCKETS = {
        new Point2D.Double(LEFT, TOP),           // 左上
        new Point2D.Double((LEFT + RIGHT) / 2, TOP),  // 中上
        new Point2D.Double(RIGHT, TOP),          // 右上
        new Point2D.Double(LEFT, BOTTOM),        // 左下
        new Point2D.Double((LEFT + RIGHT) / 2, BOTTOM), // 中下
        new Point2D.Double(RIGHT, BOTTOM)        // 右下
    };

    private final List<Ball> balls = new ArrayList<>();
    private final List<Path2D> suggestedPaths = new ArrayList<>();
    private Ball cueBall;  // 白球
    private Point2D aimPoint = null;
    private double aimPower = 8.0;
    private boolean showAIPath = true;

    /**
     * 球类,封装位置、速度、颜色等属性
     */
    static class Ball {
        Point2D position;
        Point2D velocity;
        Color color;
        boolean isCueBall;
        boolean potted;  // 是否已入袋
        int number;

        Ball(double x, double y, Color color, int number, boolean isCueBall) {
            this.position = new Point2D.Double(x, y);
            this.velocity = new Point2D.Double(0, 0);
            this.color = color;
            this.number = number;
            this.isCueBall = isCueBall;
            this.potted = false;
        }

        double getX() { return position.getX(); }
        double getY() { return position.getY(); }
        double getVX() { return velocity.getX(); }
        double getVY() { return velocity.getY(); }
        double getSpeed() {
            return Math.hypot(getVX(), getVY());
        }
    }

    /**
     * 路径规划结果
     */
    static class ShotSolution {
        Point2D targetPoint;      // 击球目标点
        double power;             // 建议力度
        int cushionCount;         // 库边反弹次数
        double confidence;        // 置信度(0-1)
        List<Point2D> pathPoints; // 路径关键点

        ShotSolution(Point2D target, double power, int cushions, 
                     double confidence, List<Point2D> path) {
            this.targetPoint = target;
            this.power = power;
            this.cushionCount = cushions;
            this.confidence = confidence;
            this.pathPoints = path;
        }
    }

    public BilliardGame() {
        setPreferredSize(new Dimension((int) (RIGHT + 50), (int) (BOTTOM + 50)));
        setBackground(new Color(30, 100, 50));
        initBalls();

        // 鼠标监听:设置瞄准点
        addMouseListener(new java.awt.event.MouseAdapter() {
            @Override
            public void mousePressed(java.awt.event.MouseEvent e) {
                aimPoint = new Point2D.Double(e.getX(), e.getY());
                if (showAIPath) {
                    findBestShot();
                }
                repaint();
            }

            @Override
            public void mouseReleased(java.awt.event.MouseEvent e) {
                if (aimPoint != null && allBallsStopped()) {
                    shoot(aimPoint, aimPower);
                    aimPoint = null;
                }
            }
        });

        // 定时更新物理
        Timer timer = new Timer(16, e -> {
            updatePhysics();
            repaint();
        });
        timer.start();
    }

    /**
     * 初始化球的位置(三角形摆球)
     */
    private void initBalls() {
        balls.clear();

        // 白球位置
        cueBall = new Ball(LEFT + TABLE_WIDTH * 0.25, 
                          TOP + TABLE_HEIGHT / 2, 
                          Color.WHITE, 0, true);
        balls.add(cueBall);

        // 彩色球三角形摆法
        Color[] colors = {
            Color.YELLOW, Color.BLUE, Color.RED, Color.MAGENTA,
            Color.ORANGE, Color.GREEN, Color.CYAN, Color.PINK,
            Color.DARK_GRAY, Color.YELLOW, Color.BLUE, Color.RED,
            Color.MAGENTA, Color.ORANGE, Color.GREEN
        };

        double startX = LEFT + TABLE_WIDTH * 0.75;
        double startY = TOP + TABLE_HEIGHT / 2;
        double spacing = BALL_RADIUS * 2.05;
        int idx = 1;

        for (int row = 0; row < 5; row++) {
            for (int col = 0; col <= row; col++) {
                double x = startX + row * spacing * 0.866;
                double y = startY - row * spacing / 2 + col * spacing;
                if (idx <= 15) {
                    balls.add(new Ball(x, y, colors[idx - 1], idx, false));
                    idx++;
                }
            }
        }
    }

    /**
     * 物理更新主循环
     * 
     * 每帧执行:
     * 1. 应用摩擦力减速
     * 2. 更新位置
     * 3. 检测并处理碰撞
     * 4. 检测入袋
     */
    private void updatePhysics() {
        // 应用摩擦力
        for (Ball ball : balls) {
            if (ball.potted) continue;

            double vx = ball.getVX() * FRICTION;
            double vy = ball.getVY() * FRICTION;

            // 速度低于阈值则停止
            if (Math.hypot(vx, vy) < MIN_SPEED) {
                vx = 0;
                vy = 0;
            }

            ball.velocity.setLocation(vx, vy);
        }

        // 更新位置
        for (Ball ball : balls) {
            if (ball.potted) continue;

            double newX = ball.getX() + ball.getVX();
            double newY = ball.getY() + ball.getVY();
            ball.position.setLocation(newX, newY);
        }

        // 库边碰撞检测
        checkCushionCollisions();

        // 球间碰撞检测
        checkBallCollisions();

        // 入袋检测
        checkPockets();
    }

    /**
     * 库边碰撞检测与反射
     * 
     * 算法:
     * 1. 检测球是否超出左右边界
     * 2. 检测球是否超出上下边界
     * 3. 将速度在对应方向取反
     * 4. 将球位置修正回边界内
     */
    private void checkCushionCollisions() {
        for (Ball ball : balls) {
            if (ball.potted) continue;

            double x = ball.getX();
            double y = ball.getY();
            double vx = ball.getVX();
            double vy = ball.getVY();
            boolean bounced = false;

            // 左右库边
            if (x - BALL_RADIUS < LEFT) {
                x = LEFT + BALL_RADIUS;
                vx = Math.abs(vx);
                bounced = true;
            } else if (x + BALL_RADIUS > RIGHT) {
                x = RIGHT - BALL_RADIUS;
                vx = -Math.abs(vx);
                bounced = true;
            }

            // 上下库边
            if (y - BALL_RADIUS < TOP) {
                y = TOP + BALL_RADIUS;
                vy = Math.abs(vy);
                bounced = true;
            } else if (y + BALL_RADIUS > BOTTOM) {
                y = BOTTOM - BALL_RADIUS;
                vy = -Math.abs(vy);
                bounced = true;
            }

            if (bounced) {
                ball.position.setLocation(x, y);
                ball.velocity.setLocation(vx * 0.9, vy * 0.9); // 库边反弹能量损失
            }
        }
    }

    /**
     * 球间弹性碰撞计算
     * 
     * 核心算法(动量守恒 + 动能守恒):
     * 1. 计算两球连线方向(碰撞法线)
     * 2. 将速度分解为法线和切线分量
     * 3. 法线方向速度交换(质量相等时)
     * 4. 切线方向速度不变
     * 5. 合成新速度
     */
    private void checkBallCollisions() {
        for (int i = 0; i < balls.size(); i++) {
            for (int j = i + 1; j < balls.size(); j++) {
                Ball b1 = balls.get(i);
                Ball b2 = balls.get(j);

                if (b1.potted || b2.potted) continue;

                double dx = b2.getX() - b1.getX();
                double dy = b2.getY() - b1.getY();
                double dist = Math.hypot(dx, dy);

                // 碰撞检测
                if (dist < BALL_RADIUS * 2 && dist > 0) {
                    // 碰撞法线方向(单位向量)
                    double nx = dx / dist;
                    double ny = dy / dist;

                    // 相对速度
                    double dvx = b1.getVX() - b2.getVX();
                    double dvy = b1.getVY() - b2.getVY();

                    // 沿法线方向的相对速度
                    double velAlongNormal = dvx * nx + dvy * ny;

                    // 如果球正在分离,不处理
                    if (velAlongNormal > 0) continue;

                    // 弹性碰撞:质量相等时法线方向速度交换
                    double impulse = velAlongNormal;

                    double v1n = b1.getVX() * nx + b1.getVY() * ny;
                    double v1t = -b1.getVX() * ny + b1.getVY() * nx;
                    double v2n = b2.getVX() * nx + b2.getVY() * ny;
                    double v2t = -b2.getVX() * ny + b2.getVY() * nx;

                    // 交换法线速度
                    double newV1n = v2n;
                    double newV2n = v1n;

                    // 合成新速度
                    b1.velocity.setLocation(
                        newV1n * nx - v1t * ny,
                        newV1n * ny + v1t * nx
                    );
                    b2.velocity.setLocation(
                        newV2n * nx - v2t * ny,
                        newV2n * ny + v2t * nx
                    );

                    // 防止球重叠:将球沿法线方向分离
                    double overlap = BALL_RADIUS * 2 - dist;
                    double separationX = nx * overlap / 2;
                    double separationY = ny * overlap / 2;

                    b1.position.setLocation(b1.getX() - separationX, b1.getY() - separationY);
                    b2.position.setLocation(b2.getX() + separationX, b2.getY() + separationY);
                }
            }
        }
    }

    /**
     * 入袋检测
     */
    private void checkPockets() {
        for (Ball ball : balls) {
            if (ball.potted) continue;

            for (Point2D pocket : POCKETS) {
                double dist = ball.position.distance(pocket);
                if (dist < BALL_RADIUS * 1.5) {
                    ball.potted = true;
                    ball.velocity.setLocation(0, 0);

                    // 将入袋球移出画面
                    ball.position.setLocation(-100, -100);
                    break;
                }
            }
        }
    }

    /**
     * 击球操作
     */
    private void shoot(Point2D target, double power) {
        if (cueBall.potted) return;

        double dx = target.getX() - cueBall.getX();
        double dy = target.getY() - cueBall.getY();
        double dist = Math.hypot(dx, dy);

        if (dist > 0) {
            double scale = Math.min(power, MAX_POWER) / dist;
            cueBall.velocity.setLocation(dx * scale, dy * scale);
        }
    }

    /**
     * 最优击球路径搜索算法
     * 
     * 使用镜像法搜索经过0次、1次、2次库边反弹的最优路径:
     * 1. 直接击球路径(0次反弹)
     * 2. 单库反弹路径(1次反弹)
     * 3. 双库反弹路径(2次反弹)
     * 
     * 对每个目标球和每个袋口组合,计算最佳路径
     */
    private void findBestShot() {
        suggestedPaths.clear();
        if (cueBall.potted) return;

        ShotSolution best = null;
        double bestScore = -1;

        // 遍历每个非白球
        for (Ball target : balls) {
            if (target.potted || target.isCueBall) continue;

            // 遍历每个袋口
            for (Point2D pocket : POCKETS) {
                // 尝试0次、1次、2次库边反弹
                for (int cushions = 0; cushions <= 2; cushions++) {
                    ShotSolution sol = findPathWithCushions(
                        cueBall.position, target.position, pocket, cushions);

                    if (sol != null && sol.confidence > bestScore) {
                        bestScore = sol.confidence;
                        best = sol;
                    }
                }
            }
        }

        // 绘制最优路径
        if (best != null) {
            drawPath(best);
        }
    }

    /**
     * 镜像法路径搜索
     * 
     * @param cuePos 白球位置
     * @param targetPos 目标球位置
     * @param pocket 袋口位置
     * @param cushionCount 库边反弹次数
     * @return 路径解决方案,无解返回null
     */
    private ShotSolution findPathWithCushions(Point2D cuePos, Point2D targetPos, 
                                               Point2D pocket, int cushionCount) {
        List<Point2D> path = new ArrayList<>();
        path.add(cuePos);

        Point2D mirroredPocket = pocket;

        // 对袋口做镜像变换
        for (int i = 0; i < cushionCount; i++) {
            if (i % 2 == 0) {
                mirroredPocket = mirrorY(mirroredPocket);
            } else {
                mirroredPocket = mirrorX(mirroredPocket);
            }
        }

        // 计算目标球的接触点:目标球中心到镜像袋口方向的反方向偏移一个半径
        double dx = mirroredPocket.getX() - targetPos.getX();
        double dy = mirroredPocket.getY() - targetPos.getY();
        double dist = Math.hypot(dx, dy);

        if (dist < BALL_RADIUS * 2) return null;

        // 目标球的接触点(白球需要击中的位置)
        double contactX = targetPos.getX() - dx / dist * BALL_RADIUS * 2;
        double contactY = targetPos.getY() - dy / dist * BALL_RADIUS * 2;
        Point2D contactPoint = new Point2D.Double(contactX, contactY);

        // 检查白球到接触点路径是否被阻挡
        if (isPathBlocked(cuePos, contactPoint, null)) {
            return null;
        }

        path.add(contactPoint);
        path.add(targetPos);

        // 如果有库边反弹,计算反弹点
        if (cushionCount > 0) {
            Point2D reflectionPoint = calculateReflectionPoint(contactPoint, targetPos, 
                                                                mirroredPocket, cushionCount);
            if (reflectionPoint == null) return null;
            path.add(reflectionPoint);
        }

        path.add(pocket);

        // 计算置信度(路径越短、反弹越少,置信度越高)
        double totalDist = 0;
        for (int i = 0; i < path.size() - 1; i++) {
            totalDist += path.get(i).distance(path.get(i + 1));
        }
        double confidence = 1.0 / (1.0 + totalDist / 1000 + cushionCount * 0.3);

        // 计算建议力度
        double power = Math.min(MAX_POWER, totalDist / 80);

        return new ShotSolution(contactPoint, power, cushionCount, confidence, path);
    }

    /**
     * 关于水平中线的镜像
     */
    private Point2D mirrorY(Point2D p) {
        double centerY = (TOP + BOTTOM) / 2;
        return new Point2D.Double(p.getX(), 2 * centerY - p.getY());
    }

    /**
     * 关于垂直中线的镜像
     */
    private Point2D mirrorX(Point2D p) {
        double centerX = (LEFT + RIGHT) / 2;
        return new Point2D.Double(2 * centerX - p.getX(), p.getY());
    }

    /**
     * 计算库边反弹点
     */
    private Point2D calculateReflectionPoint(Point2D from, Point2D target, 
                                              Point2D mirroredDest, int cushions) {
        // 简化为直线与边界的交点
        double dx = mirroredDest.getX() - from.getX();
        double dy = mirroredDest.getY() - from.getY();

        // 找到与最近库边的交点
        double tTop = (TOP - from.getY()) / dy;
        double tBottom = (BOTTOM - from.getY()) / dy;
        double tLeft = (LEFT - from.getX()) / dx;
        double tRight = (RIGHT - from.getX()) / dx;

        double minT = Double.MAX_VALUE;

        if (tTop > 0.001 && tTop < minT) minT = tTop;
        if (tBottom > 0.001 && tBottom < minT) minT = tBottom;
        if (tLeft > 0.001 && tLeft < minT) minT = tLeft;
        if (tRight > 0.001 && tRight < minT) minT = tRight;

        if (minT == Double.MAX_VALUE || minT > 10) return null;

        return new Point2D.Double(from.getX() + dx * minT, from.getY() + dy * minT);
    }

    /**
     * 检查路径是否被其他球阻挡
     */
    private boolean isPathBlocked(Point2D from, Point2D to, Ball exclude) {
        double dx = to.getX() - from.getX();
        double dy = to.getY() - from.getY();
        double dist = Math.hypot(dx, dy);

        if (dist == 0) return false;

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

        for (Ball ball : balls) {
            if (ball.potted || ball == exclude) continue;
            if (ball == cueBall) continue;

            // 计算球到线段的最短距离
            double bx = ball.getX() - from.getX();
            double by = ball.getY() - from.getY();
            double proj = bx * nx + by * ny;

            if (proj < 0 || proj > dist) continue;

            double perpX = bx - proj * nx;
            double perpY = by - proj * ny;
            double perpDist = Math.hypot(perpX, perpY);

            if (perpDist < BALL_RADIUS * 2.1) {
                return true;
            }
        }

        return false;
    }

    /**
     * 将路径转换为可绘制对象
     */
    private void drawPath(ShotSolution sol) {
        if (sol.pathPoints.size() < 2) return;

        Path2D path = new Path2D.Double();
        Point2D first = sol.pathPoints.get(0);
        path.moveTo(first.getX(), first.getY());

        for (int i = 1; i < sol.pathPoints.size(); i++) {
            path.lineTo(sol.pathPoints.get(i).getX(), sol.pathPoints.get(i).getY());
        }

        suggestedPaths.add(path);
    }

    /**
     * 检查所有球是否已停止
     */
    private boolean allBallsStopped() {
        for (Ball ball : balls) {
            if (!ball.potted && ball.getSpeed() > MIN_SPEED) {
                return false;
            }
        }
        return true;
    }

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

        // 绘制球桌边框
        g2d.setColor(new Color(101, 67, 33));
        g2d.fillRect((int) LEFT - 20, (int) TOP - 20, 
                    (int) TABLE_WIDTH + 40, (int) TABLE_HEIGHT + 40);

        // 绘制球桌面
        g2d.setColor(new Color(0, 128, 0));
        g2d.fillRect((int) LEFT, (int) TOP, (int) TABLE_WIDTH, (int) TABLE_HEIGHT);

        // 绘制袋口
        g2d.setColor(Color.BLACK);
        for (Point2D pocket : POCKETS) {
            g2d.fillOval((int) (pocket.getX() - 15), (int) (pocket.getY() - 15), 30, 30);
        }

        // 绘制建议路径
        g2d.setColor(new Color(255, 255, 0, 128));
        g2d.setStroke(new BasicStroke(2, BasicStroke.CAP_ROUND, 
                                     BasicStroke.JOIN_ROUND, 0, new float[]{5, 5}, 0));
        for (Path2D path : suggestedPaths) {
            g2d.draw(path);
        }

        // 绘制球
        for (Ball ball : balls) {
            if (ball.potted) continue;

            int x = (int) (ball.getX() - BALL_RADIUS);
            int y = (int) (ball.getY() - BALL_RADIUS);
            int d = (int) (BALL_RADIUS * 2);

            // 球体渐变效果
            RadialGradientPaint gradient = new RadialGradientPaint(
                (float) ball.getX(), (float) ball.getY(), (float) BALL_RADIUS,
                new float[]{0.0f, 1.0f},
                new Color[]{ball.color.brighter(), ball.color.darker()}
            );
            g2d.setPaint(gradient);
            g2d.fillOval(x, y, d, d);

            // 球边框
            g2d.setColor(Color.WHITE);
            g2d.drawOval(x, y, d, d);

            // 球号
            if (ball.number > 0) {
                g2d.setColor(Color.WHITE);
                g2d.setFont(new Font("Arial", Font.BOLD, 10));
                String num = String.valueOf(ball.number);
                FontMetrics fm = g2d.getFontMetrics();
                int textX = (int) ball.getX() - fm.stringWidth(num) / 2;
                int textY = (int) ball.getY() + fm.getAscent() / 2 - 2;
                g2d.drawString(num, textX, textY);
            }
        }

        // 绘制瞄准线
        if (aimPoint != null && !cueBall.potted) {
            g2d.setColor(Color.YELLOW);
            g2d.setStroke(new BasicStroke(1));
            g2d.drawLine((int) cueBall.getX(), (int) cueBall.getY(),
                        (int) aimPoint.getX(), (int) aimPoint.getY());
        }

        // 绘制信息
        g2d.setColor(Color.WHITE);
        g2d.setFont(new Font("Arial", Font.BOLD, 14));
        g2d.drawString("台球物理模拟 - 点击并拖动击球", 10, 20);
        g2d.drawString("AI路径规划: " + (showAIPath ? "开启" : "关闭"), 10, 40);
    }

    public static void main(String[] args) {
        SwingUtilities.invokeLater(() -> {
            JFrame frame = new JFrame("台球 - 物理碰撞与最优击球路径规划");
            frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
            frame.add(new BilliardGame());
            frame.pack();
            frame.setLocationRelativeTo(null);
            frame.setVisible(true);
        });
    }
}

算法复杂度分析

操作 时间复杂度 空间复杂度 说明
碰撞检测(所有球对) O(N²) O(1) N为球数,每帧执行
库边碰撞检测 O(N) O(1) 每帧执行
入袋检测 O(N×P) O(1) P为袋口数
最优路径搜索 O(T×P×C) O(C) T为目标球数,C为反弹次数
路径阻挡检测 O(N) O(1) 每条路径检测一次

核心算法优化点

  1. 动量守恒分解:将速度沿碰撞法线方向分解后交换法线分量,避免了复杂的联立方程求解。
  2. 镜像法路径搜索:通过对目标袋口做对称变换,将库边反弹路径问题转化为直线路径问题,大幅降低计算复杂度。
  3. 球重叠分离:碰撞后将两球沿法线方向各移动半个重叠距离,避免球卡在一起。
  4. 能量损失模拟:库边反弹时速度乘以0.9的衰减系数,更贴近真实物理。

扩展思考

  1. 旋转效果:引入 topspin(上旋)和 backspin(下旋),通过 Magnus 效应影响球的运动轨迹。
  2. 进阶路径搜索:使用A*算法在状态空间中搜索考虑多球互动的最优击球序列,而非单次击球。
  3. 神经网络策略:训练深度网络根据桌面状态直接输出最佳击球角度和力度,替代基于几何规则的路径搜索。

总结

本文通过Java实现了一个完整的台球物理模拟系统,涵盖弹性碰撞、库边反射和镜像法路径搜索三大核心算法。该实现不仅演示了经典物理引擎的基础架构,也为理解二维碰撞检测和几何路径规划提供了实用的代码参考。