每日算法 — 使用java实现打水漂:抛体运动学与水面反弹条件判定

游戏背景与问题引入

打水漂是一项古老而有趣的户外游戏:将扁平的石片以适当的角度和速度抛向水面,石片会在水面上弹跳多次,激起一连串涟漪。专业的打水漂选手能让石片弹跳上百次。这一游戏背后涉及抛体运动学、流体力学和碰撞物理。本文将用Java实现一套完整的打水漂弹道模拟系统,重点讲解水面反弹条件的判定算法。

核心算法原理

1. 抛体运动学

石片在空中飞行时,受重力影响做抛物线运动。设初始速度为v₀,抛出角度为θ(与水平面的夹角):

  • 水平位移:x(t) = v₀·cos(θ)·t
  • 竖直位移:y(t) = v₀·sin(θ)·t – ½gt²
  • 水平速度:vx = v₀·cos(θ)(忽略空气阻力)
  • 竖直速度:vy(t) = v₀·sin(θ) – gt

2. 水面反弹条件判定

石片能否在水面弹跳,取决于两个关键条件:

条件一:速度阈值
石片必须具有足够的水平速度。当速度低于临界值v_min时,石片将直接沉入水中。

条件二:入射角阈值
根据流体力学研究,石片入射角(速度方向与水平面的夹角)必须在特定范围内。最佳入射角约为20°±5°。入射角过大(>45°)石片会扎入水中;过小(<10°)则无法产生足够的升力。

3. 反弹动力学

满足反弹条件时,石片的运动状态按以下规则更新:

  • 水平速度衰减:vx’ = vx · kx(kx ≈ 0.85-0.95,与水面张力有关)
  • 竖直速度反向衰减:vy’ = -vy · ky(ky ≈ 0.6-0.8,能量损失更大)
  • 旋转速度:每次反弹产生一个自旋角速度,帮助维持稳定

4. 沉入判定

当以下任一条件满足时,石片停止弹跳:
– 水平速度 < v_min(通常为2-3 m/s)
– 入射角 > θ_max(通常为45°)
– 弹跳次数超过物理极限

Java实现

import java.util.ArrayList;
import java.util.List;

/**
 * 打水漂物理模拟系统
 * 核心算法:抛体运动学 + 水面反弹条件判定 + 能量衰减
 */
public class StoneSkippingSimulation {

    // 物理常量
    private static final double GRAVITY = 9.8;      // 重力加速度 (m/s²)
    private static final double DT = 0.001;          // 仿真步长 (s),需要高精度
    private static final double WATER_LEVEL = 0.0;   // 水面高度

    // 石片参数
    private final double mass;           // 质量 (kg)
    private final double diameter;       // 直径 (m)
    private final double thickness;      // 厚度 (m)
    private final double density;        // 密度 (kg/m³)

    // 水面反弹参数(可通过实验数据拟合)
    private final double minSkipSpeed;   // 最小弹跳水平速度 (m/s)
    private final double optimalAngle;   // 最佳入射角 (rad)
    private final double maxSkipAngle;   // 最大可弹跳入射角 (rad)
    private final double vxDecay;        // 水平速度衰减系数
    private final double vyDecay;        // 竖直速度衰减系数
    private final double spinGain;       // 每次反弹增加的旋转速度

    // 轨迹记录
    public static class Point {
        public final double x, y, t, vx, vy;
        public final boolean isBounce;
        public Point(double x, double y, double t, double vx, double vy, boolean isBounce) {
            this.x = x; this.y = y; this.t = t; this.vx = vx; this.vy = vy; this.isBounce = isBounce;
        }
    }

    public StoneSkippingSimulation(double mass, double diameter, double thickness,
                                    double minSkipSpeed, double optimalAngleDeg,
                                    double maxSkipAngleDeg,
                                    double vxDecay, double vyDecay, double spinGain) {
        this.mass = mass;
        this.diameter = diameter;
        this.thickness = thickness;
        this.density = mass / (Math.PI * diameter * diameter * thickness / 4);
        this.minSkipSpeed = minSkipSpeed;
        this.optimalAngle = Math.toRadians(optimalAngleDeg);
        this.maxSkipAngle = Math.toRadians(maxSkipAngleDeg);
        this.vxDecay = vxDecay;
        this.vyDecay = vyDecay;
        this.spinGain = spinGain;
    }

    /**
     * 计算入射角(速度向量与水平面的夹角,取绝对值)
     */
    private double incidenceAngle(double vx, double vy) {
        return Math.abs(Math.atan2(vy, vx));
    }

    /**
     * 判定是否能水面弹跳
     * @param vx 水平速度
     * @param vy 竖直速度(向下为负,此处取接触瞬间)
     * @return true表示可以弹跳
     */
    private boolean canSkip(double vx, double vy) {
        // 条件1:水平速度足够
        if (Math.abs(vx) < minSkipSpeed) {
            return false;
        }
        // 条件2:入射角在允许范围内
        double angle = incidenceAngle(vx, vy);
        if (angle > maxSkipAngle) {
            return false;
        }
        // 条件3:石片密度小于水(否则直接沉)
        if (density >= 1000) {
            return false;
        }
        return true;
    }

    /**
     * 执行水面反弹
     * @param vx 当前水平速度
     * @param vy 当前竖直速度(应为负值,向下)
     * @param spin 当前旋转速度 (rad/s)
     * @return 反弹后的速度数组 [vx', vy', spin']
     */
    private double[] bounce(double vx, double vy, double spin) {
        // 速度衰减
        double newVx = vx * vxDecay;
        // 竖直速度反向并衰减
        double newVy = -vy * vyDecay;
        // 马格努斯效应:旋转产生升力,略微增加竖直速度
        double magnusLift = spin * diameter * 0.001;
        newVy += magnusLift;
        // 旋转加速(石片与水面摩擦产生旋转)
        double newSpin = spin + spinGain;
        return new double[]{newVx, newVy, newSpin};
    }

    /**
     * 运行完整打水漂仿真
     * @param initSpeed 初始速度 (m/s)
     * @param initAngleDeg 初始抛射角 (度,水平面上方为证)
     * @param initHeight 初始高度 (m)
     * @param initSpin 初始旋转速度 (rad/s)
     * @return 完整轨迹点列表
     */
    public List<Point> simulate(double initSpeed, double initAngleDeg,
                                 double initHeight, double initSpin) {
        List<Point> trajectory = new ArrayList<>();

        double angleRad = Math.toRadians(initAngleDeg);
        double vx = initSpeed * Math.cos(angleRad);
        double vy = initSpeed * Math.sin(angleRad);
        double x = 0.0;
        double y = initHeight;
        double t = 0.0;
        double spin = initSpin;
        int bounceCount = 0;

        // 记录初始点
        trajectory.add(new Point(x, y, t, vx, vy, false));

        // 仿真主循环
        while (y > -1.0) { // 允许稍微入水,直到确定沉没
            // 欧拉法积分
            x += vx * DT;
            y += vy * DT;
            vy -= GRAVITY * DT; // 重力加速度
            t += DT;

            // 检测是否接触水面
            if (y <= WATER_LEVEL && vy < 0) {
                // 计算入射角
                double incAngle = incidenceAngle(vx, -vy);
                System.out.printf("  接触水面: x=%.2fm, v=%.2fm/s, 入射角=%.1f°
",
                        x, Math.sqrt(vx*vx+vy*vy), Math.toDegrees(incAngle));

                if (canSkip(vx, -vy)) {
                    // 执行反弹
                    double[] after = bounce(vx, vy, spin);
                    vx = after[0];
                    vy = after[1];
                    spin = after[2];
                    bounceCount++;
                    y = WATER_LEVEL + 0.001; // 防止重复检测
                    trajectory.add(new Point(x, y, t, vx, vy, true));
                    System.out.printf("  → 第%d次弹跳! 反弹后 v=%.2fm/s
",
                            bounceCount, Math.sqrt(vx*vx+vy*vy));
                } else {
                    // 沉入水中,结束仿真
                    System.out.printf("  → 沉入水中 (原因: %s)
",
                            Math.abs(vx) < minSkipSpeed ? "速度不足" : "入射角过大");
                    trajectory.add(new Point(x, y, t, vx, vy, false));
                    break;
                }
            }

            // 每10ms记录一个点用于可视化
            if (trajectory.size() < (int)(t / 0.01) + 2) {
                trajectory.add(new Point(x, y, t, vx, vy, false));
            }
        }

        System.out.printf("
仿真结果: 总弹跳次数 = %d, 总水平距离 = %.2f m
",
                bounceCount, x);
        return trajectory;
    }

    /**
     * 分析最佳抛射参数(二分搜索找最大弹跳次数)
     */
    public void findOptimalAngle(double initSpeed, double initHeight) {
        System.out.println("
=== 最佳入射角分析 ===");
        int bestBounces = 0;
        double bestAngle = 0;
        for (double angle = 5; angle <= 60; angle += 1) {
            // 快速仿真,不记录轨迹
            int bounces = quickSimulate(initSpeed, angle, initHeight);
            if (bounces > bestBounces) {
                bestBounces = bounces;
                bestAngle = angle;
            }
        }
        System.out.printf("最佳抛射角: %.0f°, 最大弹跳次数: %d
", bestAngle, bestBounces);
    }

    /**
     * 快速仿真,只返回弹跳次数
     */
    private int quickSimulate(double initSpeed, double initAngleDeg,
                               double initHeight) {
        double angleRad = Math.toRadians(initAngleDeg);
        double vx = initSpeed * Math.cos(angleRad);
        double vy = initSpeed * Math.sin(angleRad);
        double y = initHeight;
        int bounceCount = 0;
        double spin = 10.0;

        while (y > -1.0) {
            y += vy * DT;
            vy -= GRAVITY * DT;
            if (y <= WATER_LEVEL && vy < 0) {
                if (canSkip(vx, -vy)) {
                    double[] after = bounce(vx, vy, spin);
                    vx = after[0]; vy = after[1]; spin = after[2];
                    bounceCount++;
                    y = WATER_LEVEL + 0.001;
                } else {
                    break;
                }
            }
            // 防止无限循环
            if (bounceCount > 1000) break;
        }
        return bounceCount;
    }

    public static void main(String[] args) {
        System.out.println("=== 打水漂物理模拟 ===
");

        // 标准石片参数:质量0.1kg,直径0.1m,厚度0.005m
        StoneSkippingSimulation sim = new StoneSkippingSimulation(
                0.1, 0.1, 0.005,
                2.5,    // 最小弹跳速度
                20,     // 最佳入射角
                45,     // 最大入射角
                0.92,   // 水平衰减
                0.70,   // 竖直衰减
                5.0     // 旋转增益
        );

        // 场景1:标准投掷
        System.out.println("【场景1】标准投掷: 速度=15m/s, 角度=15°, 高度=1.5m");
        sim.simulate(15.0, 15.0, 1.5, 10.0);

        // 场景2:高速低角度(专业选手)
        System.out.println("
【场景2】高速低角度: 速度=25m/s, 角度=10°, 高度=1.0m");
        StoneSkippingSimulation sim2 = new StoneSkippingSimulation(
                0.1, 0.1, 0.005, 2.5, 20, 45, 0.95, 0.75, 8.0);
        sim2.simulate(25.0, 10.0, 1.0, 20.0);

        // 场景3:最佳角度分析
        System.out.println("
【场景3】角度敏感性分析");
        StoneSkippingSimulation sim3 = new StoneSkippingSimulation(
                0.1, 0.1, 0.005, 2.5, 20, 45, 0.92, 0.70, 5.0);
        sim3.findOptimalAngle(15.0, 1.5);

        System.out.println("
=== 核心结论 ===");
        System.out.println("1. 入射角是关键:20°左右最佳,>45°直接沉没");
        System.out.println("2. 速度必须足够:水平速度<2.5m/s时无法继续弹跳");
        System.out.println("3. 石片形状很重要:扁平、薄、密度小的石片表现更好");
    }
}

复杂度分析

  • 时间复杂度:O(T/Δt),T为总飞行时间,Δt为仿真步长。单步计算为O(1)。
  • 空间复杂度:O(T/Δt),用于存储轨迹点。
  • 物理精度:步长越小精度越高,但计算量线性增加。实际应用中可采用自适应步长。

总结

本文通过Java实现了打水漂的完整物理模拟,核心算法亮点包括:

  1. 抛体运动学精确描述石片在空中的轨迹
  2. 反弹条件判定综合速度阈值和入射角阈值两个约束
  3. 能量衰减模型模拟每次弹跳后的速度损失
  4. 马格努斯效应引入旋转产生的升力修正

读者可以进一步研究:不同石片形状(圆形/椭圆)的影响、水面波纹对后续弹道的扰动、以及通过机器学习优化投掷参数。