引言
保龄球是一项经典的室内运动,玩家通过投掷一个重球击倒排列成三角形的十个球瓶。看似简单的游戏规则背后,隐藏着丰富的物理碰撞与策略决策问题。本文将用 Java 实现一个带 AI 决策的保龄球模拟系统,核心聚焦在碰撞物理计算、球瓶连锁倒下判定以及贪心最优投球策略三大算法模块。
核心算法设计
1. 碰撞物理模型
保龄球与球瓶的碰撞可简化为二维弹性碰撞问题。设球的质量为 $M$,球瓶质量为 $m$,碰撞前后动量守恒、动能守恒。对于非对心碰撞,采用向量分解法:将速度分解为沿碰撞法线方向和切线方向,法线方向按一维弹性碰撞处理,切线方向速度不变。
碰撞响应公式:
– 法线方向相对速度:$v_{rel} = (v_1 – v_2) \cdot n$
– 冲量大小:$J = \frac{-(1+e) \cdot v_{rel}}{\frac{1}{M} + \frac{1}{m}}$
– 其中 $e$ 为恢复系数(保龄球约 0.3)
2. 球瓶连锁倒下判定
球瓶被击倒后可能撞击相邻球瓶,形成连锁反应。采用事件驱动模拟:维护一个按碰撞时间排序的优先队列,每次取出最早发生的碰撞事件进行处理,更新物体状态后重新计算可能的新碰撞。
倒下判定条件:球瓶质心水平位移超过底座半径,或倾斜角度超过阈值(约 15 度)。
3. 贪心最优投球策略
AI 投球决策的核心是选择最佳投球角度和力度,以最大化期望击倒球瓶数。采用贪心策略:
- 目标选择:计算球瓶三角形的几何中心(1号瓶与3号瓶之间偏右位置为最优入射点)
- 角度枚举:在 $[-5°, 5°]$ 范围内以 $0.5°$ 为步长枚举投球角度
- 模拟评估:对每个角度进行快速物理模拟,统计击倒球瓶数
- 选择最优:取击倒数最多的角度,若相同则选接近中心的角度
Java 实现
import java.util.*;
/**
* 保龄球碰撞物理与贪心策略模拟系统
*/
public class BowlingGame {
// 物理常量
private static final double BALL_MASS = 7.26; // 球质量(kg)
private static final double PIN_MASS = 1.586; // 球瓶质量(kg)
private static final double BALL_RADIUS = 0.108; // 球半径(m)
private static final double PIN_RADIUS = 0.057; // 球瓶底部半径(m)
private static final double PIN_HEIGHT = 0.381; // 球瓶高度(m)
private static final double RESTITUTION = 0.3; // 恢复系数
private static final double FRICTION = 0.12; // 球道摩擦系数
private static final double GRAVITY = 9.8; // 重力加速度
/**
* 二维向量类
*/
static class Vector2D {
double x, y;
Vector2D(double x, double y) { this.x = x; this.y = y; }
Vector2D add(Vector2D v) { return new Vector2D(x + v.x, y + v.y); }
Vector2D sub(Vector2D v) { return new Vector2D(x - v.x, y - v.y); }
Vector2D mul(double s) { return new Vector2D(x * s, y * s); }
double dot(Vector2D v) { return x * v.x + y * v.y; }
double length() { return Math.sqrt(x * x + y * y); }
Vector2D normalize() {
double len = length();
return len < 1e-9 ? new Vector2D(0, 0) : new Vector2D(x / len, y / len);
}
}
/**
* 物理物体基类
*/
static class Body {
Vector2D position; // 位置
Vector2D velocity; // 速度
double mass; // 质量
double radius; // 碰撞半径
boolean active; // 是否仍在运动
Body(Vector2D pos, Vector2D vel, double mass, double radius) {
this.position = pos;
this.velocity = vel;
this.mass = mass;
this.radius = radius;
this.active = true;
}
}
/**
* 保龄球
*/
static class Ball extends Body {
Ball(Vector2D pos, Vector2D vel) {
super(pos, vel, BALL_MASS, BALL_RADIUS);
}
}
/**
* 球瓶
*/
static class Pin extends Body {
boolean standing; // 是否站立
double tiltAngle; // 倾斜角度
Pin(Vector2D pos) {
super(pos, new Vector2D(0, 0), PIN_MASS, PIN_RADIUS);
this.standing = true;
this.tiltAngle = 0;
}
}
/**
* 碰撞事件
*/
static class Collision implements Comparable<Collision> {
double time; // 碰撞发生时间
Body a, b; // 碰撞双方
Collision(double time, Body a, Body b) {
this.time = time;
this.a = a;
this.b = b;
}
@Override
public int compareTo(Collision other) {
return Double.compare(this.time, other.time);
}
}
private Ball ball;
private List<Pin> pins;
private static final double LANE_LENGTH = 18.29; // 球道长度(m)
private static final double LANE_WIDTH = 1.07; // 球道宽度(m)
public BowlingGame() {
this.pins = new ArrayList<>();
initPins();
}
/**
* 初始化球瓶三角形排列
* 标准排列:第1排1个,第2排2个...第4排4个,共10个
*/
private void initPins() {
double pinSpacing = 0.305; // 球瓶间距(m)
double startY = LANE_LENGTH - 0.152; // 球瓶区起始Y坐标
double centerX = LANE_WIDTH / 2;
int pinCount = 0;
for (int row = 0; row < 4; row++) {
for (int col = 0; col <= row; col++) {
double x = centerX + (col - row / 2.0) * pinSpacing;
double y = startY - row * pinSpacing * Math.sqrt(3) / 2;
pins.add(new Pin(new Vector2D(x, y)));
pinCount++;
}
}
}
/**
* 设置投球参数
* @param startX 起始X位置(球道宽度比例,0.0-1.0)
* @param angle 投球角度(度,0为正前方,正值偏右)
* @param speed 初速度(m/s)
*/
public void throwBall(double startX, double angle, double speed) {
double rad = Math.toRadians(angle);
Vector2D pos = new Vector2D(startX * LANE_WIDTH, 0);
Vector2D vel = new Vector2D(speed * Math.sin(rad), speed * Math.cos(rad));
this.ball = new Ball(pos, vel);
}
/**
* 执行物理模拟,返回击倒球瓶数
*/
public int simulate() {
double dt = 0.001; // 时间步长1ms
double totalTime = 0;
List<Body> bodies = new ArrayList<>();
bodies.add(ball);
bodies.addAll(pins);
while (totalTime < 10.0) { // 最多模拟10秒
// 应用摩擦力减速
for (Body b : bodies) {
if (!b.active) continue;
double speed = b.velocity.length();
if (speed > 0.01) {
Vector2D friction = b.velocity.normalize().mul(-FRICTION * GRAVITY * dt);
b.velocity = b.velocity.add(friction);
if (b.velocity.length() < 0.01) {
b.velocity = new Vector2D(0, 0);
b.active = false;
}
}
}
// 检测并处理碰撞
PriorityQueue<Collision> collisions = new PriorityQueue<>();
for (int i = 0; i < bodies.size(); i++) {
for (int j = i + 1; j < bodies.size(); j++) {
Body a = bodies.get(i);
Body b = bodies.get(j);
if (!a.active || !b.active) continue;
double t = predictCollision(a, b);
if (t >= 0 && t < dt) {
collisions.add(new Collision(t, a, b));
}
}
}
// 处理碰撞事件
while (!collisions.isEmpty()) {
Collision col = collisions.poll();
resolveCollision(col.a, col.b);
}
// 更新位置
for (Body b : bodies) {
if (!b.active) continue;
b.position = b.position.add(b.velocity.mul(dt));
}
// 检查球瓶是否倒下
for (Pin pin : pins) {
if (!pin.standing) continue;
if (pin.velocity.length() > 0.5) {
pin.tiltAngle += pin.velocity.length() * dt * 10;
if (pin.tiltAngle > 15.0) {
pin.standing = false;
pin.active = false;
}
}
}
// 检查球是否出界或停止
if (!ball.active || ball.position.y > LANE_LENGTH ||
ball.position.x < 0 || ball.position.x > LANE_WIDTH) {
break;
}
totalTime += dt;
}
// 统计击倒数
int knocked = 0;
for (Pin pin : pins) {
if (!pin.standing) knocked++;
}
return knocked;
}
/**
* 预测两物体在dt时间内是否碰撞,返回碰撞时间(负值表示不碰撞)
*/
private double predictCollision(Body a, Body b) {
Vector2D relPos = b.position.sub(a.position);
Vector2D relVel = b.velocity.sub(a.velocity);
double r = a.radius + b.radius;
double c = relPos.dot(relPos) - r * r;
if (c < 0) return 0; // 已经重叠
double v = relVel.dot(relPos);
if (v >= 0) return -1; // 正在远离
double vv = relVel.dot(relVel);
double disc = v * v - vv * c;
if (disc < 0) return -1; // 无实数解
return (-v - Math.sqrt(disc)) / vv;
}
/**
* 解析碰撞,应用动量守恒
*/
private void resolveCollision(Body a, Body b) {
Vector2D normal = b.position.sub(a.position).normalize();
Vector2D relVel = b.velocity.sub(a.velocity);
double vRel = relVel.dot(normal);
if (vRel > 0) return; // 正在分离
double j = -(1 + RESTITUTION) * vRel / (1 / a.mass + 1 / b.mass);
Vector2D impulse = normal.mul(j);
a.velocity = a.velocity.sub(impulse.mul(1 / a.mass));
b.velocity = b.velocity.add(impulse.mul(1 / b.mass));
}
/**
* 贪心策略:寻找最优投球角度
* @return 最优角度和期望击倒数
*/
public double[] findOptimalAngle() {
double bestAngle = 0;
int bestScore = -1;
double startX = 0.5; // 从球道中央投掷
double speed = 7.0; // 初速度7m/s
for (double angle = -5.0; angle <= 5.0; angle += 0.5) {
// 重置游戏状态
pins.clear();
initPins();
throwBall(startX, angle, speed);
int score = simulate();
if (score > bestScore) {
bestScore = score;
bestAngle = angle;
}
}
return new double[]{bestAngle, bestScore};
}
public static void main(String[] args) {
BowlingGame game = new BowlingGame();
// 演示贪心策略
System.out.println("=== 保龄球贪心最优策略搜索 ===");
double[] result = game.findOptimalAngle();
System.out.printf("最优角度: %.1f°%n", result[0]);
System.out.printf("期望击倒: %.0f 个球瓶%n", result[1]);
// 演示单局投掷
System.out.println("\n=== 单局模拟演示 ===");
game = new BowlingGame();
game.throwBall(0.5, result[0], 7.0);
int score = game.simulate();
System.out.printf("实际击倒: %d 个球瓶%n", score);
System.out.print("球瓶状态: ");
for (int i = 0; i < game.pins.size(); i++) {
System.out.print(game.pins.get(i).standing ? "□" : "■");
}
System.out.println();
}
}
复杂度分析
| 模块 | 时间复杂度 | 空间复杂度 | 说明 |
|---|---|---|---|
| 碰撞检测 | $O(n^2)$ | $O(n)$ | 每帧检测所有物体对,$n$ 为物体总数 |
| 事件队列处理 | $O(k \log k)$ | $O(k)$ | $k$ 为单帧内碰撞事件数 |
| 贪心角度搜索 | $O(m \cdot T \cdot n^2)$ | $O(n)$ | $m$ 为角度枚举数,$T$ 为单局模拟步数 |
在实际保龄球场景中(1球+10瓶),$n=11$,贪心搜索约枚举 21 个角度,单局模拟约 5000 步,总计算量在 $10^6$ 量级,现代 CPU 可在毫秒级完成。
总结
本文实现了保龄球的核心物理模拟与 AI 决策系统。碰撞响应基于动量守恒定律,采用事件驱动机制处理连锁碰撞;贪心策略通过枚举投球角度并快速模拟评估,找出期望击倒数最大的方案。该框架可进一步扩展:引入旋转(hook)物理模型、使用蒙特卡洛方法评估随机因素、或通过强化学习训练更高级的投球策略。