游戏简介与算法背景
投球机(Skee-Ball)是游乐场中经典的滚球得分游戏:玩家沿斜坡投掷球体,球体滚入不同分值的圆环孔洞中。看似简单,实则涉及抛体运动学建模、碰撞物理模拟与最优控制三大算法领域。本文用 Java 实现完整的物理引擎,并通过梯度搜索计算最优投掷角度与力度,使玩家获得最大期望得分。
核心算法设计
1. 抛体运动学模型
球体离开投掷区后做斜抛运动,受重力与空气阻力影响:
– 水平方向:x(t) = v₀·cos(θ)·t
– 垂直方向:y(t) = v₀·sin(θ)·t - ½gt²
引入空气阻力(与速度平方成正比)后,需数值积分求解轨迹。
2. 斜坡滚动模型
球体在斜坡上滚动时,重力分量提供加速度,同时受滚动摩擦减速:
– 沿斜坡加速度:a = g·sin(α) - μ·g·cos(α)
– 纯滚动条件:角速度与线速度满足 v = ω·r
3. 最优投掷参数搜索
目标:找到角度 θ 和初速度 v₀,使球体落入最高分洞的概率最大。采用网格搜索 + 局部梯度精细化策略。
Java 完整实现
import java.util.*;
/**
* 投球机游戏 — 抛体运动建模与最优投掷角度计算
*
* 核心算法:
* 1. 抛体运动数值积分:考虑重力与空气阻力
* 2. 斜坡滚动动力学:重力分量与摩擦力的平衡
* 3. 碰撞检测:球体与洞边界的精确判定
* 4. 最优参数搜索:网格搜索 + 梯度精细化
*/
public class SkeeBall {
// 物理常量
private static final double G = 9.81; // 重力加速度 (m/s²)
private static final double AIR_DENSITY = 1.225; // 空气密度 (kg/m³)
private static final double DRAG_COEFF = 0.47; // 球体阻力系数
private static final double BALL_MASS = 0.15; // 球质量 (kg)
private static final double BALL_RADIUS = 0.035; // 球半径 (m)
private static final double CROSS_AREA = Math.PI * BALL_RADIUS * BALL_RADIUS;
private static final double FRICTION_COEFF = 0.15; // 滚动摩擦系数
private static final double DT = 0.001; // 数值积分步长 (s)
// 场地参数
private final double rampLength; // 斜坡长度 (m)
private final double rampAngle; // 斜坡倾角 (rad)
private final double rampHeight; // 斜坡末端高度 (m)
private final List<Hole> holes; // 得分洞列表
public SkeeBall(double rampLength, double rampAngle, List<Hole> holes) {
this.rampLength = rampLength;
this.rampAngle = rampAngle;
this.rampHeight = rampLength * Math.sin(rampAngle);
this.holes = holes;
}
/**
* 得分洞定义:中心位置(x)和分值
*/
static class Hole {
final double centerX; // 洞中心距斜坡末端水平距离 (m)
final double width; // 洞宽度 (m)
final int score; // 分值
final String name;
Hole(double centerX, double width, int score, String name) {
this.centerX = centerX;
this.width = width;
this.score = score;
this.name = name;
}
boolean contains(double x) {
return Math.abs(x - centerX) <= width / 2;
}
@Override
public String toString() {
return String.format("%s(得分:%d, 中心:%.2fm, 宽度:%.2fm)",
name, score, centerX, width);
}
}
/**
* 球体状态向量
*/
static class BallState {
double x, y; // 位置 (m)
double vx, vy; // 速度 (m/s)
double t; // 时间 (s)
boolean onRamp; // 是否在斜坡上
BallState(double x, double y, double vx, double vy, boolean onRamp) {
this.x = x;
this.y = y;
this.vx = vx;
this.vy = vy;
this.onRamp = onRamp;
this.t = 0;
}
double speed() {
return Math.sqrt(vx * vx + vy * vy);
}
}
/**
* 模拟球体从斜坡起点以给定角度和速度投掷的完整轨迹
* 返回最终落点X坐标和落入的洞
*/
public ThrowResult simulate(double throwAngle, double initialSpeed) {
// 斜坡起点坐标(投掷点)
BallState state = new BallState(0, 0,
initialSpeed * Math.cos(throwAngle),
initialSpeed * Math.sin(throwAngle),
true);
List<double[]> trajectory = new ArrayList<>();
trajectory.add(new double[]{state.x, state.y});
// 阶段1:沿斜坡滚动
double rampEndX = rampLength * Math.cos(rampAngle);
while (state.onRamp) {
// 斜坡上的运动:沿斜坡方向受重力分量和摩擦力
double alongRampSpeed = state.vx * Math.cos(rampAngle)
+ state.vy * Math.sin(rampAngle);
// 重力沿斜坡分量加速
double gravityAccel = G * Math.sin(rampAngle);
// 摩擦力减速(始终与运动方向相反)
double frictionAccel = FRICTION_COEFF * G * Math.cos(rampAngle);
double netAccel = gravityAccel;
if (alongRampSpeed > 0) {
netAccel -= frictionAccel;
}
// 更新沿斜坡速度
alongRampSpeed += netAccel * DT;
if (alongRampSpeed < 0) alongRampSpeed = 0; // 不会倒退
// 分解为x,y分量
state.vx = alongRampSpeed * Math.cos(rampAngle);
state.vy = alongRampSpeed * Math.sin(rampAngle);
// 更新位置
state.x += state.vx * DT;
state.y += state.vy * DT;
state.t += DT;
// 检查是否离开斜坡
double distanceAlongRamp = state.x / Math.cos(rampAngle);
if (distanceAlongRamp >= rampLength) {
state.onRamp = false;
// 离开斜坡时,保持当前速度进入抛体运动
}
if (state.t > 10) break; // 安全上限
}
// 阶段2:抛体运动(离开斜坡后)
while (state.y >= 0 && state.t < 10) {
double speed = state.speed();
// 空气阻力:F_drag = 0.5 * ρ * v² * Cd * A,方向与速度相反
double dragForce = 0.5 * AIR_DENSITY * speed * speed * DRAG_COEFF * CROSS_AREA;
double dragAccel = dragForce / BALL_MASS;
double ax = 0;
double ay = -G;
if (speed > 0.001) {
ax -= dragAccel * (state.vx / speed);
ay -= dragAccel * (state.vy / speed);
}
// 欧拉积分更新
state.vx += ax * DT;
state.vy += ay * DT;
state.x += state.vx * DT;
state.y += state.vy * DT;
state.t += DT;
trajectory.add(new double[]{state.x, state.y});
}
// 判断落入哪个洞(以落点X坐标为准)
Hole landedHole = null;
for (Hole hole : holes) {
if (hole.contains(state.x - rampEndX)) {
landedHole = hole;
break;
}
}
return new ThrowResult(state.x - rampEndX, landedHole, state.t, trajectory);
}
static class ThrowResult {
final double landingX; // 落点距斜坡末端水平距离
final Hole hole; // 落入的洞
final double flightTime; // 飞行时间
final List<double[]> trajectory; // 完整轨迹点
ThrowResult(double landingX, Hole hole, double flightTime, List<double[]> trajectory) {
this.landingX = landingX;
this.hole = hole;
this.flightTime = flightTime;
this.trajectory = trajectory;
}
int getScore() {
return hole != null ? hole.score : 0;
}
}
/**
* 最优投掷参数搜索
* 策略:先粗粒度网格搜索,再在最优邻域精细化
*/
public OptimalThrow findOptimalThrow() {
double bestAngle = 0;
double bestSpeed = 0;
int bestScore = -1;
ThrowResult bestResult = null;
// 阶段1:粗粒度网格搜索
System.out.println("=== 粗粒度网格搜索 ===");
for (double angle = Math.toRadians(5); angle <= Math.toRadians(60); angle += Math.toRadians(2)) {
for (double speed = 1.0; speed <= 8.0; speed += 0.2) {
ThrowResult result = simulate(angle, speed);
int score = result.getScore();
if (score > bestScore) {
bestScore = score;
bestAngle = angle;
bestSpeed = speed;
bestResult = result;
}
}
}
System.out.printf("粗搜最优: 角度=%.1f°, 速度=%.2fm/s, 得分=%d%n",
Math.toDegrees(bestAngle), bestSpeed, bestScore);
// 阶段2:局部梯度精细化
System.out.println("=== 局部精细化搜索 ===");
double stepAngle = Math.toRadians(0.5);
double stepSpeed = 0.05;
boolean improved = true;
int iterations = 0;
while (improved && iterations < 50) {
improved = false;
iterations++;
double[][] neighbors = {
{bestAngle + stepAngle, bestSpeed},
{bestAngle - stepAngle, bestSpeed},
{bestAngle, bestSpeed + stepSpeed},
{bestAngle, bestSpeed - stepSpeed},
{bestAngle + stepAngle, bestSpeed + stepSpeed},
{bestAngle + stepAngle, bestSpeed - stepSpeed},
{bestAngle - stepAngle, bestSpeed + stepSpeed},
{bestAngle - stepAngle, bestSpeed - stepSpeed}
};
for (double[] neighbor : neighbors) {
double a = neighbor[0];
double s = neighbor[1];
if (a < Math.toRadians(0) || a > Math.toRadians(90)) continue;
if (s < 0.5 || s > 15) continue;
ThrowResult result = simulate(a, s);
if (result.getScore() > bestScore) {
bestScore = result.getScore();
bestAngle = a;
bestSpeed = s;
bestResult = result;
improved = true;
}
}
}
System.out.printf("精搜最优: 角度=%.2f°, 速度=%.3fm/s, 得分=%d (迭代%d次)%n",
Math.toDegrees(bestAngle), bestSpeed, bestScore, iterations);
return new OptimalThrow(bestAngle, bestSpeed, bestScore, bestResult);
}
static class OptimalThrow {
final double angle; // 最优角度 (rad)
final double speed; // 最优速度 (m/s)
final int score; // 预期得分
final ThrowResult result;
OptimalThrow(double angle, double speed, int score, ThrowResult result) {
this.angle = angle;
this.speed = speed;
this.score = score;
this.result = result;
}
}
/**
* 可视化打印轨迹
*/
public void printTrajectory(ThrowResult result) {
System.out.println("\n轨迹采样 (每100个点采样一次):");
System.out.println("X(m)\tY(m)");
for (int i = 0; i < result.trajectory.size(); i += 100) {
double[] p = result.trajectory.get(i);
System.out.printf("%.3f\t%.3f%n", p[0], p[1]);
}
}
public static void main(String[] args) {
// 创建标准投球机场地
// 斜坡长3m,倾角15°
// 洞分布:10分(近)、20分、30分、40分、50分(远)
List<Hole> holes = Arrays.asList(
new Hole(0.3, 0.15, 10, "近洞(10分)"),
new Hole(0.8, 0.12, 20, "左偏洞(20分)"),
new Hole(1.2, 0.10, 30, "中洞(30分)"),
new Hole(1.6, 0.08, 40, "右偏洞(40分)"),
new Hole(2.2, 0.06, 50, "远洞(50分)")
);
SkeeBall game = new SkeeBall(3.0, Math.toRadians(15), holes);
System.out.println("=== 投球机物理参数 ===");
System.out.printf("斜坡长度: %.1fm, 倾角: %.1f°, 末端高度: %.2fm%n",
game.rampLength, Math.toDegrees(game.rampAngle), game.rampHeight);
System.out.println("得分洞设置:");
for (Hole h : holes) {
System.out.println(" " + h);
}
// 演示几个投掷案例
System.out.println("\n=== 投掷案例演示 ===");
double[][] demos = {
{30, 3.0}, // 角度30°, 速度3m/s
{45, 4.5}, // 角度45°, 速度4.5m/s
{20, 5.0}, // 角度20°, 速度5m/s
};
for (double[] demo : demos) {
double angle = Math.toRadians(demo[0]);
double speed = demo[1];
ThrowResult result = game.simulate(angle, speed);
System.out.printf("投掷: 角度=%.0f°, 速度=%.1fm/s → 落点=%.2fm, %s%n",
demo[0], speed, result.landingX,
result.hole != null ? "落入" + result.hole.name : "未入洞");
}
// 搜索最优投掷参数
System.out.println("\n=== 最优投掷参数搜索 ===");
OptimalThrow optimal = game.findOptimalThrow();
System.out.printf("\n最优策略: 以 %.2f° 角度、%.3fm/s 初速度投掷%n",
Math.toDegrees(optimal.angle), optimal.speed);
System.out.printf("预期可获得 %d 分 (落入%s)%n",
optimal.score,
optimal.result.hole != null ? optimal.result.hole.name : "未入洞");
}
}
算法复杂度分析
| 模块 | 时间复杂度 | 空间复杂度 | 说明 |
|---|---|---|---|
| 单次模拟 | O(T/DT) |
O(T/DT) |
T为飞行时间,DT为积分步长 |
| 网格搜索 | O(A·S·T/DT) |
O(T/DT) |
A为角度网格数,S为速度网格数 |
| 梯度精细化 | O(K·8·T/DT) |
O(T/DT) |
K为迭代次数,每轮检查8邻域 |
典型参数下(DT=0.001s,飞行约2秒),单次模拟约2000步,现代CPU可在微秒级完成。
扩展思考
- 风速影响:引入水平风速项
v_wind,改变水平加速度ax = -drag_x + wind_force/m,可模拟户外投球场景。 - 旋转效应(马格努斯力):若球体带后旋,产生升力
F_M = ½ρv²A·C_L·(ωr/v),可显著延长飞行距离。 - 不确定性量化:实际投掷存在角度和力度误差,可通过蒙特卡洛模拟评估策略鲁棒性,选择”容错率最高”而非”理论最优”的参数。
- 强化学习方案:将连续投掷过程建模为 MDP,使用策略梯度方法学习适应不同场地条件的投掷策略。
总结
投球机游戏的算法实现展示了如何将经典物理(抛体运动、滚动摩擦)与优化算法(网格搜索、梯度精细化)相结合。通过精确的数值模拟,我们不仅能预测球体轨迹,还能反向求解最优投掷参数。这种”建模 → 模拟 → 优化”的方法论,广泛应用于机器人运动规划、航天轨道设计、体育科学分析等领域。