每日算法 — 使用java实现投球机:抛体运动建模与最优投掷角度计算

游戏简介与算法背景

投球机(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可在微秒级完成。

扩展思考

  1. 风速影响:引入水平风速项 v_wind,改变水平加速度 ax = -drag_x + wind_force/m,可模拟户外投球场景。
  2. 旋转效应(马格努斯力):若球体带后旋,产生升力 F_M = ½ρv²A·C_L·(ωr/v),可显著延长飞行距离。
  3. 不确定性量化:实际投掷存在角度和力度误差,可通过蒙特卡洛模拟评估策略鲁棒性,选择”容错率最高”而非”理论最优”的参数。
  4. 强化学习方案:将连续投掷过程建模为 MDP,使用策略梯度方法学习适应不同场地条件的投掷策略。

总结

投球机游戏的算法实现展示了如何将经典物理(抛体运动、滚动摩擦)与优化算法(网格搜索、梯度精细化)相结合。通过精确的数值模拟,我们不仅能预测球体轨迹,还能反向求解最优投掷参数。这种”建模 → 模拟 → 优化”的方法论,广泛应用于机器人运动规划、航天轨道设计、体育科学分析等领域。