每日算法 — 使用java实现马戏团:抛体运动学与最优轨迹规划

引言

马戏团表演中,空中飞人(Trapeze)和小丑抛球(Juggling)是最具观赏性的项目,其核心在于精确的抛体运动控制。本文将用Java实现马戏团抛体运动仿真系统,重点讲解抛体运动学建模、多物体碰撞预测以及最优轨迹规划算法。通过数学建模与数值计算,让读者理解如何在重力场中规划满足时空约束的精确轨迹。

核心算法概述

抛体运动学方程

在忽略空气阻力的理想条件下,抛体运动可分解为水平和垂直两个独立分量:

x(t) = x₀ + vₓ · t
y(t) = y₀ + vᵧ · t - 0.5 · g · t²

其中 g = 9.8 m/s² 为重力加速度。给定起点、终点和飞行时间,可以反推所需的初始速度向量。

多球抛接轨迹规划

小丑抛接N个球时,需要保证任意时刻空中最多有N个球,且每个球的落点与接球的时机精确匹配。这是一个典型的周期性调度问题,可以用离散化时间轴的方法求解。

空中飞人摆荡与脱手时机

空中飞人在摆荡过程中脱手,其运动可建模为单摆+抛体的复合运动。最优脱手角度使得飞人获得最大水平位移,从而成功落入对面飞人的手中。

Java实现

1. 二维向量与物理基础

/**
 * 二维向量类,支持基本的向量运算
 * 用于表示位置、速度、加速度等物理量
 */
public class Vector2D {
    public final double x, y;

    public Vector2D(double x, double y) {
        this.x = x;
        this.y = y;
    }

    public Vector2D add(Vector2D other) {
        return new Vector2D(x + other.x, y + other.y);
    }

    public Vector2D subtract(Vector2D other) {
        return new Vector2D(x - other.x, y - other.y);
    }

    public Vector2D multiply(double scalar) {
        return new Vector2D(x * scalar, 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 x * other.x + y * other.y;
    }

    @Override
    public String toString() {
        return String.format("(%.3f, %.3f)", x, y);
    }
}

/**
 * 物理常量与工具类
 */
public class PhysicsConstants {
    public static final double GRAVITY = 9.81;      // m/s²
    public static final double AIR_DENSITY = 1.225; // kg/m³
    public static final double TIME_STEP = 0.01;    // 仿真步长(秒)
}

2. 抛体运动仿真器

/**
 * 抛体运动仿真器
 * 支持理想抛体、带空气阻力的抛体以及多段轨迹
 */
public class ProjectileSimulator {

    /**
     * 抛体状态快照
     */
    public static class State {
        public final double time;
        public final Vector2D position;
        public final Vector2D velocity;

        public State(double time, Vector2D position, Vector2D velocity) {
            this.time = time;
            this.position = position;
            this.velocity = velocity;
        }

        @Override
        public String toString() {
            return String.format("t=%.3fs pos=%s vel=%s", time, position, velocity);
        }
    }

    /**
     * 理想抛体运动:忽略空气阻力
     * @param start 初始位置
     * @param velocity 初始速度
     * @param duration 仿真时长
     * @return 离散化的轨迹点列表
     */
    public static List<State> simulateIdeal(
            Vector2D start, Vector2D velocity, double duration) {
        List<State> trajectory = new ArrayList<>();
        double t = 0;
        Vector2D pos = start;
        Vector2D vel = velocity;

        while (t <= duration) {
            trajectory.add(new State(t, pos, vel));

            // 更新速度和位置
            vel = new Vector2D(vel.x, vel.y - PhysicsConstants.GRAVITY * PhysicsConstants.TIME_STEP);
            pos = new Vector2D(
                pos.x + vel.x * PhysicsConstants.TIME_STEP,
                pos.y + vel.y * PhysicsConstants.TIME_STEP
            );
            t += PhysicsConstants.TIME_STEP;
        }

        return trajectory;
    }

    /**
     * 带空气阻力的抛体运动
     * 阻力模型:F_drag = -0.5 * ρ * Cd * A * |v|² * v̂
     * @param dragCoefficient 阻力系数
     * @param crossSectionArea 迎风截面积
     * @param mass 物体质量
     */
    public static List<State> simulateWithDrag(
            Vector2D start, Vector2D velocity, double duration,
            double dragCoefficient, double crossSectionArea, double mass) {

        List<State> trajectory = new ArrayList<>();
        double t = 0;
        Vector2D pos = start;
        Vector2D vel = velocity;

        while (t <= duration) {
            trajectory.add(new State(t, pos, vel));

            // 计算空气阻力加速度
            double speed = vel.magnitude();
            double dragForce = 0.5 * PhysicsConstants.AIR_DENSITY * dragCoefficient 
                             * crossSectionArea * speed * speed;
            Vector2D dragAccel = vel.normalize().multiply(-dragForce / mass);

            // 总加速度 = 重力 + 阻力
            Vector2D accel = new Vector2D(
                dragAccel.x,
                dragAccel.y - PhysicsConstants.GRAVITY
            );

            // 半隐式欧拉积分
            vel = vel.add(accel.multiply(PhysicsConstants.TIME_STEP));
            pos = new Vector2D(
                pos.x + vel.x * PhysicsConstants.TIME_STEP,
                pos.y + vel.y * PhysicsConstants.TIME_STEP
            );
            t += PhysicsConstants.TIME_STEP;
        }

        return trajectory;
    }

    /**
     * 根据起点、终点和飞行时间,反推初始速度
     * 这是轨迹规划的核心:已知目标,求解发射参数
     * 
     * @param start 起点
     * @param target 目标点
     * @param flightTime 期望飞行时间
     * @return 所需的初始速度向量,若不可达返回null
     */
    public static Vector2D calculateLaunchVelocity(
            Vector2D start, Vector2D target, double flightTime) {

        if (flightTime <= 0) return null;

        double dx = target.x - start.x;
        double dy = target.y - start.y;

        // 水平速度 = 水平位移 / 时间
        double vx = dx / flightTime;

        // 垂直速度由运动学方程反推:
        // dy = vy * t - 0.5 * g * t²
        // vy = (dy + 0.5 * g * t²) / t
        double vy = (dy + 0.5 * PhysicsConstants.GRAVITY * flightTime * flightTime) / flightTime;

        return new Vector2D(vx, vy);
    }

    /**
     * 计算给定初始速度和角度下的射程
     * @param speed 初速度大小
     * @param angle 发射角度(弧度,水平为0)
     * @param startHeight 初始高度
     * @return 水平射程
     */
    public static double calculateRange(double speed, double angle, double startHeight) {
        double vx = speed * Math.cos(angle);
        double vy = speed * Math.sin(angle);

        // 落地时间:y(t) = 0 = h + vy*t - 0.5*g*t²
        // 解二次方程:0.5*g*t² - vy*t - h = 0
        double a = 0.5 * PhysicsConstants.GRAVITY;
        double b = -vy;
        double c = -startHeight;
        double discriminant = b * b - 4 * a * c;

        if (discriminant < 0) return 0;
        double t = (-b + Math.sqrt(discriminant)) / (2 * a);
        return vx * t;
    }
}

3. 小丑抛接球轨迹规划

/**
 * 小丑抛接球(Juggling)轨迹规划器
 * 确保N个球在周期性模式中不碰撞且可被顺利接住
 */
public class JugglingPatternPlanner {

    /**
     * 抛接球模式中的"抛掷事件"
     */
    public static class ThrowEvent {
        public final double throwTime;     // 抛球时间
        public final double catchTime;     // 接球时间
        public final int fromHand;         // 抛球手(0=左手,1=右手)
        public final int toHand;           // 接球手
        public final Vector2D throwPos;    // 抛球位置
        public final Vector2D catchPos;    // 接球位置
        public final double flightTime;    // 飞行时间
        public final int ballIndex;        // 球编号

        public ThrowEvent(double throwTime, double catchTime, int fromHand, int toHand,
                         Vector2D throwPos, Vector2D catchPos, int ballIndex) {
            this.throwTime = throwTime;
            this.catchTime = catchTime;
            this.fromHand = fromHand;
            this.toHand = toHand;
            this.throwPos = throwPos;
            this.catchPos = catchPos;
            this.flightTime = catchTime - throwTime;
            this.ballIndex = ballIndex;
        }

        public Vector2D getLaunchVelocity() {
            return ProjectileSimulator.calculateLaunchVelocity(
                throwPos, catchPos, flightTime);
        }
    }

    /**
     * 生成经典的"叠级联"(Cascade)抛接模式
     * N个球在两只手之间交替抛接,每个球遵循相同的抛物线轨迹
     * 
     * @param numBalls 球的数量
     * @param handSpacing 两手间距(米)
     * @param throwHeight 抛球高度(米)
     * @param beatPeriod 每拍的周期(秒)
     * @return 所有抛掷事件列表
     */
    public static List<ThrowEvent> generateCascadePattern(
            int numBalls, double handSpacing, double throwHeight, double beatPeriod) {

        List<ThrowEvent> events = new ArrayList<>();

        // 两手位置
        Vector2D leftHand = new Vector2D(-handSpacing / 2, 1.5);  // 左手高度1.5米
        Vector2D rightHand = new Vector2D(handSpacing / 2, 1.5);

        // Cascade模式的周期特性:每个球每隔N拍被抛一次
        // 飞行时间 = N * beatPeriod(N个球时)
        double flightTime = numBalls * beatPeriod;

        // 计算接球位置(稍微偏移以形成弧线)
        double catchOffset = handSpacing * 0.3;

        // 生成前若干个周期的抛掷事件
        int totalBeats = numBalls * 4; // 生成4个完整周期

        for (int beat = 0; beat < totalBeats; beat++) {
            double throwTime = beat * beatPeriod;
            double catchTime = throwTime + flightTime;
            int fromHand = beat % 2; // 左右手交替
            int toHand = 1 - fromHand;
            int ballIndex = beat % numBalls;

            Vector2D throwPos = (fromHand == 0) ? leftHand : rightHand;
            Vector2D catchPos = (toHand == 0) ? 
                new Vector2D(leftHand.x - catchOffset, leftHand.y) :
                new Vector2D(rightHand.x + catchOffset, rightHand.y);

            events.add(new ThrowEvent(throwTime, catchTime, fromHand, toHand,
                                     throwPos, catchPos, ballIndex));
        }

        return events;
    }

    /**
     * 验证抛接模式是否可行
     * 检查条件:
     * 1. 任意时刻空中球数不超过总球数
     * 2. 任意时刻每只手最多持球一个
     * 3. 抛球和接球不发生在同一时间同一位置
     */
    public static boolean validatePattern(
            List<ThrowEvent> events, int numBalls, double totalTime) {

        double dt = 0.05; // 时间采样步长

        for (double t = 0; t < totalTime; t += dt) {
            int ballsInAir = 0;
            int[] handsOccupied = new int[2];

            for (ThrowEvent e : events) {
                // 球在空中
                if (e.throwTime <= t && t < e.catchTime) {
                    ballsInAir++;
                }
                // 手中有球(刚接住到下一次抛出之间)
                if (e.catchTime <= t && t < e.catchTime + 0.5) {
                    handsOccupied[e.toHand]++;
                }
            }

            if (ballsInAir > numBalls) {
                System.out.printf("时间 %.2f: 空中球数 %d > 总球数 %d\n", 
                    t, ballsInAir, numBalls);
                return false;
            }
            if (handsOccupied[0] > 1 || handsOccupied[1] > 1) {
                System.out.printf("时间 %.2f: 单手持球过多 L=%d R=%d\n", 
                    t, handsOccupied[0], handsOccupied[1]);
                return false;
            }
        }

        return true;
    }

    /**
     * 计算每个球的完整轨迹
     */
    public static List<List<ProjectileSimulator.State>> simulateBallTrajectories(
            List<ThrowEvent> events) {

        Map<Integer, List<ProjectileSimulator.State>> ballTrajectories = new HashMap<>();

        for (ThrowEvent e : events) {
            Vector2D velocity = e.getLaunchVelocity();
            if (velocity == null) continue;

            List<ProjectileSimulator.State> traj = ProjectileSimulator.simulateIdeal(
                e.throwPos, velocity, e.flightTime);

            ballTrajectories.computeIfAbsent(e.ballIndex, k -> new ArrayList<>())
                           .addAll(traj);
        }

        return new ArrayList<>(ballTrajectories.values());
    }
}

4. 空中飞人摆荡与脱手时机

/**
 * 空中飞人(Trapeze)摆荡运动与最优脱手时机计算
 * 将摆荡建模为单摆运动,脱手后转为抛体运动
 */
public class TrapezeSimulator {

    /**
     * 单摆状态
     */
    public static class PendulumState {
        public final double angle;      // 摆角(弧度,0为竖直向下)
        public final double angularVel; // 角速度
        public final double time;

        public PendulumState(double angle, double angularVel, double time) {
            this.angle = angle;
            this.angularVel = angularVel;
            this.time = time;
        }
    }

    /**
     * 使用RK4数值积分模拟单摆运动
     * 考虑重力矩:α'' = -(g/L) * sin(α)
     * @param length 摆长(米)
     * @param initialAngle 初始摆角
     * @param initialVel 初始角速度
     * @param duration 仿真时长
     */
    public static List<PendulumState> simulatePendulum(
            double length, double initialAngle, double initialVel, double duration) {

        List<PendulumState> states = new ArrayList<>();
        double angle = initialAngle;
        double angularVel = initialVel;
        double t = 0;
        double dt = PhysicsConstants.TIME_STEP;

        while (t <= duration) {
            states.add(new PendulumState(angle, angularVel, t));

            // RK4积分
            double k1a = angularVel;
            double k1v = -(PhysicsConstants.GRAVITY / length) * Math.sin(angle);

            double k2a = angularVel + 0.5 * dt * k1v;
            double k2v = -(PhysicsConstants.GRAVITY / length) * Math.sin(angle + 0.5 * dt * k1a);

            double k3a = angularVel + 0.5 * dt * k2v;
            double k3v = -(PhysicsConstants.GRAVITY / length) * Math.sin(angle + 0.5 * dt * k2a);

            double k4a = angularVel + dt * k3v;
            double k4v = -(PhysicsConstants.GRAVITY / length) * Math.sin(angle + dt * k3a);

            angle += (dt / 6.0) * (k1a + 2*k2a + 2*k3a + k4a);
            angularVel += (dt / 6.0) * (k1v + 2*k2v + 2*k3v + k4v);
            t += dt;
        }

        return states;
    }

    /**
     * 计算最优脱手时机
     * 目标:使飞人获得最大水平位移,成功落入对面接收点
     * 
     * @param length 摆长
     * @param pivotX 悬挂点X坐标
     * @param pivotY 悬挂点Y坐标
     * @param targetX 目标接收点X坐标
     * @param targetY 目标接收点Y坐标
     * @return 最优脱手状态,包含角度、速度和预计落点
     */
    public static OptimalRelease findOptimalRelease(
            double length, double pivotX, double pivotY,
            double targetX, double targetY) {

        double bestError = Double.MAX_VALUE;
        OptimalRelease best = null;

        // 遍历可能的初始角度,寻找最优脱手点
        for (double initAngle = -Math.PI * 0.8; initAngle < Math.PI * 0.8; initAngle += 0.01) {
            // 模拟单摆运动
            List<PendulumState> states = simulatePendulum(length, initAngle, 0, 5.0);

            for (PendulumState state : states) {
                if (state.angularVel <= 0) continue; // 只考虑向前摆动时脱手

                // 脱手位置
                double releaseX = pivotX + length * Math.sin(state.angle);
                double releaseY = pivotY - length * Math.cos(state.angle);

                // 脱手速度 = 切向速度
                double tangentSpeed = length * state.angularVel;
                double vx = tangentSpeed * Math.cos(state.angle);
                double vy = tangentSpeed * Math.sin(state.angle);

                // 预测落点
                Vector2D landing = predictLanding(
                    new Vector2D(releaseX, releaseY), 
                    new Vector2D(vx, vy),
                    targetY
                );

                double error = Math.abs(landing.x - targetX);
                if (error < bestError) {
                    bestError = error;
                    best = new OptimalRelease(state, releaseX, releaseY, vx, vy, landing.x, error);
                }
            }
        }

        return best;
    }

    /**
     * 预测抛体落点(给定落地高度)
     */
    private static Vector2D predictLanding(Vector2D start, Vector2D velocity, double groundY) {
        // 解:groundY = start.y + vy*t - 0.5*g*t²
        double a = -0.5 * PhysicsConstants.GRAVITY;
        double b = velocity.y;
        double c = start.y - groundY;
        double disc = b*b - 4*a*c;

        if (disc < 0) return new Vector2D(start.x, groundY);
        double t = (-b - Math.sqrt(disc)) / (2*a); // 取第一次落地
        double x = start.x + velocity.x * t;
        return new Vector2D(x, groundY);
    }

    /**
     * 最优脱手参数
     */
    public static class OptimalRelease {
        public final PendulumState state;
        public final double releaseX, releaseY;
        public final double velocityX, velocityY;
        public final double predictedLandingX;
        public final double error;

        public OptimalRelease(PendulumState state, double releaseX, double releaseY,
                             double velocityX, double velocityY, 
                             double predictedLandingX, double error) {
            this.state = state;
            this.releaseX = releaseX;
            this.releaseY = releaseY;
            this.velocityX = velocityX;
            this.velocityY = velocityY;
            this.predictedLandingX = predictedLandingX;
            this.error = error;
        }
    }
}

5. 主程序演示

/**
 * 马戏团抛体运动系统演示
 */
public class CircusPhysicsDemo {
    public static void main(String[] args) {
        System.out.println("=== 马戏团抛体运动与轨迹规划系统 ===\n");

        // 演示1:理想抛体运动
        System.out.println("--- 演示1:小丑抛球轨迹 ---\n");
        Vector2D start = new Vector2D(0, 1.5);
        Vector2D target = new Vector2D(3.0, 1.5);
        double flightTime = 1.2;

        Vector2D velocity = ProjectileSimulator.calculateLaunchVelocity(
            start, target, flightTime);
        System.out.printf("从 %s 抛到 %s,飞行 %.1f 秒\n", start, target, flightTime);
        System.out.printf("所需初速度: %s (大小 %.2f m/s)\n", 
            velocity, velocity.magnitude());
        System.out.printf("发射角度: %.1f°\n\n", 
            Math.toDegrees(Math.atan2(velocity.y, velocity.x)));

        // 仿真并输出轨迹高点
        List<ProjectileSimulator.State> traj = ProjectileSimulator.simulateIdeal(
            start, velocity, flightTime);
        double maxHeight = 0;
        for (ProjectileSimulator.State s : traj) {
            maxHeight = Math.max(maxHeight, s.position.y);
        }
        System.out.printf("轨迹最高点: %.2f 米\n\n", maxHeight);

        // 演示2:抛接球模式
        System.out.println("--- 演示2:三球叠级联抛接模式 ---\n");
        List<JugglingPatternPlanner.ThrowEvent> pattern = 
            JugglingPatternPlanner.generateCascadePattern(3, 1.5, 2.5, 0.5);

        System.out.println("抛掷事件列表:");
        for (JugglingPatternPlanner.ThrowEvent e : pattern.subList(0, 6)) {
            Vector2D vel = e.getLaunchVelocity();
            System.out.printf("球%d: %s手 @ t=%.1fs -> %s手 @ t=%.1fs, 速度=%.2f m/s\n",
                e.ballIndex, e.fromHand == 0 ? "左" : "右", e.throwTime,
                e.toHand == 0 ? "左" : "右", e.catchTime, vel.magnitude());
        }

        boolean valid = JugglingPatternPlanner.validatePattern(pattern, 3, 6.0);
        System.out.printf("\n模式验证: %s\n\n", valid ? "通过" : "失败");

        // 演示3:空中飞人最优脱手
        System.out.println("--- 演示3:空中飞人最优脱手时机 ---\n");
        TrapezeSimulator.OptimalRelease optimal = TrapezeSimulator.findOptimalRelease(
            5.0,   // 摆长5米
            0, 10, // 悬挂点(0, 10)
            8.0, 2.5 // 目标接收点(8, 2.5)
        );

        if (optimal != null) {
            System.out.printf("最优脱手角度: %.2f°\n", Math.toDegrees(optimal.state.angle));
            System.out.printf("脱手位置: (%.2f, %.2f)\n", optimal.releaseX, optimal.releaseY);
            System.out.printf("脱手速度: (%.2f, %.2f) m/s\n", optimal.velocityX, optimal.velocityY);
            System.out.printf("预测落点X: %.2f 米\n", optimal.predictedLandingX);
            System.out.printf("落点误差: %.3f 米\n", optimal.error);
        }
    }
}

算法复杂度分析

模块 时间复杂度 空间复杂度 说明
理想抛体仿真 O(T/Δt) O(T/Δt) 线性于仿真时长
带阻力抛体 O(T/Δt) O(T/Δt) 每步计算阻力方向
发射速度反推 O(1) O(1) 解析公式直接计算
抛接模式生成 O(N × beats) O(N × beats) N为球数
单摆RK4仿真 O(T/Δt) O(T/Δt) 四阶精度数值积分
最优脱手搜索 O(θ_range / dθ × T/Δt) O(T/Δt) 遍历角度空间

扩展方向

  1. 风场干扰建模:引入时变风场向量,实时调整抛体轨迹
  2. 人体运动学约束:考虑演员关节角度限制和发力能力边界
  3. 多飞人协同:三飞人接力时的时机协调与动量传递计算
  4. 机器学习优化:使用强化学习训练最优抛接策略,处理复杂多球模式

总结

本文从基础抛体运动学出发,构建了完整的马戏团物理仿真系统:解析法反推发射参数实现精确轨迹规划,RK4数值积分保证单摆运动精度,离散时间验证确保多球抛接模式可行。核心学习要点包括:运动学方程的逆问题求解、周期性事件的时序约束检查、以及物理仿真中数值积分方法的选择原则。这些算法不仅适用于马戏团表演,也广泛应用于弹道计算、机器人投掷和体育动作分析等领域。