引言
台球是一项经典的桌面运动,其核心魅力在于通过精确控制击球力度和角度,使目标球落入袋中。在实际游戏中,高手不仅要考虑直接的直线击球,还需要计算库边反弹、多球碰撞等复杂路径。本文将用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. 最优击球路径搜索
对于不能直接击中的目标球,需要搜索经过库边反弹的间接路径。算法步骤:
- 镜像法:将目标袋口关于库边做镜像对称
- 直线路径:连接白球位置与镜像点,与库边的交点即为反弹点
- 多库反弹:对每增加一次库边反弹,递归做一次镜像变换
- 可行性验证:检查路径上是否有其他球阻挡
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) | 每条路径检测一次 |
核心算法优化点
- 动量守恒分解:将速度沿碰撞法线方向分解后交换法线分量,避免了复杂的联立方程求解。
- 镜像法路径搜索:通过对目标袋口做对称变换,将库边反弹路径问题转化为直线路径问题,大幅降低计算复杂度。
- 球重叠分离:碰撞后将两球沿法线方向各移动半个重叠距离,避免球卡在一起。
- 能量损失模拟:库边反弹时速度乘以0.9的衰减系数,更贴近真实物理。
扩展思考
- 旋转效果:引入 topspin(上旋)和 backspin(下旋),通过 Magnus 效应影响球的运动轨迹。
- 进阶路径搜索:使用A*算法在状态空间中搜索考虑多球互动的最优击球序列,而非单次击球。
- 神经网络策略:训练深度网络根据桌面状态直接输出最佳击球角度和力度,替代基于几何规则的路径搜索。
总结
本文通过Java实现了一个完整的台球物理模拟系统,涵盖弹性碰撞、库边反射和镜像法路径搜索三大核心算法。该实现不仅演示了经典物理引擎的基础架构,也为理解二维碰撞检测和几何路径规划提供了实用的代码参考。