保龄球是一款经典的体育竞技游戏,玩家通过控制投球的角度和力度,将排列成三角形的球瓶击倒。本文将用Java实现一个完整的保龄球游戏,重点讲解物理碰撞检测、反射向量计算、瓶倒连锁反应传播以及AI最优投球策略。
核心算法概览
保龄球游戏的核心算法包含四个关键部分:
- 物理运动模型:球在球道上的直线运动,包含初速度、摩擦力和重力加速度
- 碰撞检测:球与球瓶、球瓶与球瓶之间的圆形碰撞检测
- 反射向量计算:碰撞后的速度方向与大小变化,基于动量守恒和能量损失
- 最优投球策略:AI计算最佳投球角度和力度,最大化击倒球瓶数量
物理模型与运动轨迹
保龄球在球道上的运动可以简化为二维平面运动。球受到初始推力后,在摩擦力作用下逐渐减速。我们用向量来表示球的位置和速度。
/**
* 二维向量类,用于表示位置和速度
*/
public class Vector2D {
public double x;
public double y;
public Vector2D(double x, double y) {
this.x = x;
this.y = y;
}
public Vector2D add(Vector2D other) {
return new Vector2D(this.x + other.x, this.y + other.y);
}
public Vector2D multiply(double scalar) {
return new Vector2D(this.x * scalar, this.y * scalar);
}
public double magnitude() {
return Math.sqrt(x * x + y * y);
}
public Vector2D normalize() {
double mag = magnitude();
if (mag < 1e-10) return new Vector2D(0, 0);
return new Vector2D(x / mag, y / mag);
}
public double dot(Vector2D other) {
return this.x * other.x + this.y * other.y;
}
public Vector2D subtract(Vector2D other) {
return new Vector2D(this.x - other.x, this.y - other.y);
}
}
球的物理状态更新逻辑如下:
/**
* 保龄球类,封装球的物理状态
*/
public class BowlingBall {
private Vector2D position; // 位置
private Vector2D velocity; // 速度
private double radius; // 球半径
private double mass; // 球质量
private static final double FRICTION = 0.015; // 摩擦系数
private static final double MIN_SPEED = 0.1; // 最小速度阈值
public BowlingBall(double x, double y, double radius, double mass) {
this.position = new Vector2D(x, y);
this.velocity = new Vector2D(0, 0);
this.radius = radius;
this.mass = mass;
}
/**
* 设置初始投球速度
* @param speed 速度大小
* @param angle 角度(弧度)
*/
public void throwBall(double speed, double angle) {
this.velocity = new Vector2D(
speed * Math.sin(angle),
speed * Math.cos(angle)
);
}
/**
* 更新物理状态,应用摩擦力
*/
public void update() {
double speed = velocity.magnitude();
if (speed < MIN_SPEED) {
velocity = new Vector2D(0, 0);
return;
}
// 摩擦力方向与速度方向相反
Vector2D friction = velocity.normalize().multiply(-FRICTION);
velocity = velocity.add(friction);
// 更新位置
position = position.add(velocity);
}
public Vector2D getPosition() { return position; }
public Vector2D getVelocity() { return velocity; }
public double getRadius() { return radius; }
public double getMass() { return mass; }
public boolean isMoving() { return velocity.magnitude() > MIN_SPEED; }
}
碰撞检测算法
保龄球和球瓶都近似为圆形,因此使用圆形碰撞检测最为高效。两个圆形物体发生碰撞的条件是:圆心距离小于两半径之和。
/**
* 球瓶类
*/
public class Pin {
private Vector2D position;
private Vector2D velocity;
private double radius;
private double mass;
private boolean isStanding; // 是否站立
private boolean isActive; // 是否仍在参与碰撞
private static final double PIN_RADIUS = 15.0;
private static final double PIN_MASS = 1.0;
private static final double FRICTION = 0.02;
public Pin(double x, double y) {
this.position = new Vector2D(x, y);
this.velocity = new Vector2D(0, 0);
this.radius = PIN_RADIUS;
this.mass = PIN_MASS;
this.isStanding = true;
this.isActive = true;
}
/**
* 检测与另一个物体的碰撞
* @param otherPos 另一物体位置
* @param otherRadius 另一物体半径
* @return 是否发生碰撞
*/
public boolean checkCollision(Vector2D otherPos, double otherRadius) {
double distance = position.subtract(otherPos).magnitude();
return distance < (this.radius + otherRadius);
}
/**
* 获取碰撞深度(用于分离重叠物体)
*/
public double getPenetrationDepth(Vector2D otherPos, double otherRadius) {
double distance = position.subtract(otherPos).magnitude();
return (this.radius + otherRadius) - distance;
}
public void update() {
if (!isStanding || !isActive) return;
double speed = velocity.magnitude();
if (speed < 0.05) {
velocity = new Vector2D(0, 0);
return;
}
Vector2D friction = velocity.normalize().multiply(-FRICTION);
velocity = velocity.add(friction);
position = position.add(velocity);
// 检测是否已倒下(速度方向偏离垂直轴超过45度或位移过大)
if (speed > 0.5 && position.subtract(new Vector2D(position.x, position.y)).magnitude() > 5) {
// 简化处理:移动距离过大则认为已倒下
}
}
public void knockDown() { this.isStanding = false; }
public boolean isStanding() { return isStanding; }
public Vector2D getPosition() { return position; }
public void setVelocity(Vector2D v) { this.velocity = v; }
public Vector2D getVelocity() { return velocity; }
public double getRadius() { return radius; }
public double getMass() { return mass; }
}
反射向量计算
碰撞后的速度变化基于动量守恒和能量守恒定律。对于完全弹性碰撞,我们使用以下公式计算碰撞后的速度:
/**
* 碰撞响应处理器
*/
public class CollisionResolver {
private static final double RESTITUTION = 0.7; // 弹性系数(0-1,1为完全弹性)
/**
* 处理两个物体之间的碰撞
* @param posA 物体A位置
* @param velA 物体A速度
* @param massA 物体A质量
* @param posB 物体B位置
* @param velB 物体B速度
* @param massB 物体B质量
* @return 包含碰撞后速度的数组 [newVelA, newVelB]
*/
public static Vector2D[] resolveCollision(
Vector2D posA, Vector2D velA, double massA,
Vector2D posB, Vector2D velB, double massB) {
// 碰撞法线方向(从A指向B)
Vector2D normal = posB.subtract(posA).normalize();
Vector2D tangent = new Vector2D(-normal.y, normal.x);
// 将速度分解到法线和切线方向
double vANormal = velA.dot(normal);
double vATangent = velA.dot(tangent);
double vBNormal = velB.dot(normal);
double vBTangent = velB.dot(tangent);
// 切线方向速度不变(忽略摩擦力)
// 法线方向使用一维弹性碰撞公式
double vANormalAfter = (vANormal * (massA - massB) + 2 * massB * vBNormal)
/ (massA + massB);
double vBNormalAfter = (vBNormal * (massB - massA) + 2 * massA * vANormal)
/ (massA + massB);
// 应用弹性系数模拟能量损失
vANormalAfter *= RESTITUTION;
vBNormalAfter *= RESTITUTION;
// 重新组合速度向量
Vector2D newVelA = normal.multiply(vANormalAfter).add(tangent.multiply(vATangent));
Vector2D newVelB = normal.multiply(vBNormalAfter).add(tangent.multiply(vBTangent));
return new Vector2D[]{newVelA, newVelB};
}
/**
* 分离重叠的物体,防止穿透
*/
public static void separateObjects(Vector2D posA, double radiusA,
Vector2D posB, double radiusB) {
Vector2D diff = posB.subtract(posA);
double distance = diff.magnitude();
double overlap = (radiusA + radiusB) - distance;
if (overlap > 0 && distance > 1e-10) {
Vector2D separation = diff.normalize().multiply(overlap / 2);
posA.x -= separation.x;
posA.y -= separation.y;
posB.x += separation.x;
posB.y += separation.y;
}
}
}
瓶倒连锁反应
当球击倒一个球瓶后,被击倒的球瓶可能与其他球瓶发生碰撞,形成连锁反应。我们需要在每帧更新时检测所有活跃的碰撞对。
/**
* 游戏主逻辑类
*/
public class BowlingGame {
private BowlingBall ball;
private List<Pin> pins;
private static final double LANE_WIDTH = 120.0;
private static final double LANE_LENGTH = 600.0;
private static final double PIN_SPACING = 30.0;
public BowlingGame() {
this.ball = new BowlingBall(LANE_WIDTH / 2, 50, 20, 7);
this.pins = initializePins();
}
/**
* 初始化10个球瓶,排列成三角形
* 第1行1个,第2行2个...第4行4个
*/
private List<Pin> initializePins() {
List<Pin> pinList = new ArrayList<>();
double startX = LANE_WIDTH / 2;
double startY = LANE_LENGTH - 150;
for (int row = 0; row < 4; row++) {
for (int col = 0; col <= row; col++) {
double x = startX - (row * PIN_SPACING / 2) + col * PIN_SPACING;
double y = startY - row * (PIN_SPACING * 0.866); // 0.866 = sqrt(3)/2
pinList.add(new Pin(x, y));
}
}
return pinList;
}
/**
* 执行投球
*/
public void throwBall(double speed, double angle) {
ball.throwBall(speed, angle);
}
/**
* 更新一帧游戏状态
* @return 当前帧倒下的球瓶数量
*/
public int updateFrame() {
int knockedDownThisFrame = 0;
// 更新球的位置
ball.update();
// 检测球与球瓶的碰撞
for (Pin pin : pins) {
if (!pin.isStanding()) continue;
if (pin.checkCollision(ball.getPosition(), ball.getRadius())) {
// 分离重叠物体
CollisionResolver.separateObjects(
ball.getPosition(), ball.getRadius(),
pin.getPosition(), pin.getRadius()
);
// 计算碰撞后的速度
Vector2D[] newVelocities = CollisionResolver.resolveCollision(
ball.getPosition(), ball.getVelocity(), ball.getMass(),
pin.getPosition(), pin.getVelocity(), pin.getMass()
);
ball.getVelocity().x = newVelocities[0].x;
ball.getVelocity().y = newVelocities[0].y;
pin.setVelocity(newVelocities[1]);
// 球瓶受到足够大的冲击则倒下
if (newVelocities[1].magnitude() > 1.5) {
pin.knockDown();
knockedDownThisFrame++;
}
}
}
// 检测球瓶之间的碰撞(连锁反应)
for (int i = 0; i < pins.size(); i++) {
Pin pinA = pins.get(i);
if (!pinA.isStanding()) continue;
for (int j = i + 1; j < pins.size(); j++) {
Pin pinB = pins.get(j);
if (!pinB.isStanding()) continue;
if (pinA.checkCollision(pinB.getPosition(), pinB.getRadius())) {
CollisionResolver.separateObjects(
pinA.getPosition(), pinA.getRadius(),
pinB.getPosition(), pinB.getRadius()
);
Vector2D[] newVelocities = CollisionResolver.resolveCollision(
pinA.getPosition(), pinA.getVelocity(), pinA.getMass(),
pinB.getPosition(), pinB.getVelocity(), pinB.getMass()
);
pinA.setVelocity(newVelocities[0]);
pinB.setVelocity(newVelocities[1]);
// 检测是否倒下
if (newVelocities[0].magnitude() > 1.5) {
pinA.knockDown();
knockedDownThisFrame++;
}
if (newVelocities[1].magnitude() > 1.5) {
pinB.knockDown();
knockedDownThisFrame++;
}
}
}
}
// 更新所有球瓶状态
for (Pin pin : pins) {
pin.update();
}
return knockedDownThisFrame;
}
/**
* 模拟直到球停止运动
* @return 总共击倒的球瓶数
*/
public int simulateThrow(double speed, double angle) {
throwBall(speed, angle);
int totalKnocked = 0;
int maxFrames = 1000; // 防止无限循环
int frame = 0;
while ((ball.isMoving() || pins.stream().anyMatch(p -> p.getVelocity().magnitude() > 0.05))
&& frame < maxFrames) {
totalKnocked += updateFrame();
frame++;
}
return (int) pins.stream().filter(p -> !p.isStanding()).count();
}
public int getStandingPinCount() {
return (int) pins.stream().filter(Pin::isStanding).count();
}
public void reset() {
ball = new BowlingBall(LANE_WIDTH / 2, 50, 20, 7);
pins = initializePins();
}
}
AI最优投球策略
为了让AI能够自动投球,我们需要计算最佳的投球角度和力度。核心思路是:找到能够击倒最多球瓶的投球参数。
/**
* AI投球策略计算器
*/
public class BowlingAI {
private static final double MAX_SPEED = 25.0;
private static final double MIN_SPEED = 10.0;
private static final double SPEED_STEP = 1.0;
private static final double ANGLE_RANGE = Math.toRadians(30); // ±30度
private static final double ANGLE_STEP = Math.toRadians(2);
/**
* 搜索最优投球参数
* 使用网格搜索遍历角度和力度的组合
* @param game 当前游戏状态
* @return 最优参数 [speed, angle]
*/
public static double[] findBestThrow(BowlingGame game) {
double bestSpeed = 0;
double bestAngle = 0;
int bestScore = -1;
for (double speed = MIN_SPEED; speed <= MAX_SPEED; speed += SPEED_STEP) {
for (double angle = -ANGLE_RANGE; angle <= ANGLE_RANGE; angle += ANGLE_STEP) {
// 创建游戏副本进行模拟
BowlingGame simulation = new BowlingGame();
int knocked = simulation.simulateThrow(speed, angle);
if (knocked > bestScore) {
bestScore = knocked;
bestSpeed = speed;
bestAngle = angle;
}
}
}
return new double[]{bestSpeed, bestAngle};
}
/**
* 快速启发式策略:瞄准三角形重心
* 计算球瓶排列的几何中心,直接瞄准该区域
*/
public static double[] heuristicThrow(BowlingGame game) {
// 标准三角形排列的重心大约在第2-3排之间
double targetX = 60.0; // 球道中心
double targetY = 480.0; // 球瓶区域中心
double startX = 60.0;
double startY = 50.0;
double dx = targetX - startX;
double dy = targetY - startY;
double angle = Math.atan2(dx, dy);
double distance = Math.sqrt(dx * dx + dy * dy);
// 根据距离计算合适的速度
double speed = Math.min(MAX_SPEED, Math.max(MIN_SPEED, distance * 0.05));
return new double[]{speed, angle};
}
/**
* 高级策略:考虑剩余球瓶分布
* 针对剩余球瓶的位置,计算最佳打击点
*/
public static double[] adaptiveThrow(BowlingGame game, List<Pin> remainingPins) {
if (remainingPins.isEmpty()) {
return new double[]{15.0, 0};
}
// 计算剩余球瓶的质心
double centerX = 0, centerY = 0;
for (Pin pin : remainingPins) {
centerX += pin.getPosition().x;
centerY += pin.getPosition().y;
}
centerX /= remainingPins.size();
centerY /= remainingPins.size();
// 添加微调偏移,利用连锁反应
// 如果球瓶集中在左侧,稍微偏左瞄准以产生更大散射
double spreadX = 0;
for (Pin pin : remainingPins) {
spreadX += Math.abs(pin.getPosition().x - centerX);
}
spreadX /= remainingPins.size();
double adjustment = spreadX > 15 ? Math.toRadians(3) : 0;
double startX = 60.0;
double startY = 50.0;
double dx = centerX - startX;
double dy = centerY - startY;
double baseAngle = Math.atan2(dx, dy);
double angle = baseAngle + adjustment;
double distance = Math.sqrt(dx * dx + dy * dy);
double speed = Math.min(MAX_SPEED, Math.max(MIN_SPEED, distance * 0.05));
return new double[]{speed, angle};
}
}
完整项目结构与演示
/**
* 主程序入口
*/
public class BowlingGameDemo {
public static void main(String[] args) {
System.out.println("=== 保龄球游戏演示 ===\n");
// 演示1:手动投球
BowlingGame game1 = new BowlingGame();
System.out.println("演示1:手动投球(速度20,角度0度)");
int knocked1 = game1.simulateThrow(20.0, 0);
System.out.println("击倒球瓶数:" + knocked1 + "/10\n");
// 演示2:AI启发式策略
BowlingGame game2 = new BowlingGame();
System.out.println("演示2:AI启发式策略投球");
double[] heuristic = BowlingAI.heuristicThrow(game2);
int knocked2 = game2.simulateThrow(heuristic[0], heuristic[1]);
System.out.println("投球参数:速度=" + String.format("%.2f", heuristic[0])
+ ", 角度=" + String.format("%.2f", Math.toDegrees(heuristic[1])) + "度");
System.out.println("击倒球瓶数:" + knocked2 + "/10\n");
// 演示3:AI最优搜索策略(较慢,仅用于演示)
BowlingGame game3 = new BowlingGame();
System.out.println("演示3:AI最优搜索策略(网格搜索)");
System.out.println("正在搜索最优参数...");
long startTime = System.currentTimeMillis();
double[] best = BowlingAI.findBestThrow(game3);
long endTime = System.currentTimeMillis();
int knocked3 = game3.simulateThrow(best[0], best[1]);
System.out.println("搜索耗时:" + (endTime - startTime) + "ms");
System.out.println("最优参数:速度=" + String.format("%.2f", best[0])
+ ", 角度=" + String.format("%.2f", Math.toDegrees(best[1])) + "度");
System.out.println("击倒球瓶数:" + knocked3 + "/10\n");
System.out.println("=== 演示结束 ===");
}
}
复杂度分析
| 模块 | 时间复杂度 | 空间复杂度 | 说明 |
|---|---|---|---|
| 碰撞检测 | O(n) | O(1) | 每帧检测球与n个球瓶的碰撞 |
| 瓶间碰撞 | O(n²) | O(1) | 双重循环检测球瓶对 |
| AI网格搜索 | O(k·m·n²) | O(1) | k为速度步数,m为角度步数,n²为模拟复杂度 |
| 启发式策略 | O(n) | O(1) | 仅需计算质心 |
其中n为球瓶数量(固定为10),因此实际运行效率很高。AI网格搜索虽然复杂度较高,但由于搜索空间可控(速度步数约15,角度步数约30),在普通计算机上可在数秒内完成。
扩展方向
- 3D物理扩展:引入Z轴高度、球的旋转(hook球效果),使用更复杂的物理引擎
- 机器学习策略:用强化学习训练AI,让AI通过大量对局学习最优投球策略
- 多玩家对战:实现轮流投球机制,支持完整10轮计分规则
- 视觉渲染:集成JavaFX或LibGDX实现3D球道渲染
总结
本文实现了保龄球游戏的核心物理引擎,包括运动模型、圆形碰撞检测、反射向量计算和连锁反应模拟。通过三种AI策略(网格搜索、启发式、自适应)展示了如何让AI自动找到最优投球方案。该实现将经典体育游戏与物理算法结合,为理解碰撞检测和动量守恒提供了直观的代码示例。