每日算法 — 使用java实现跳一跳:物理抛物线与贪心最优落点计算

游戏背景与问题建模

跳一跳是微信推出的一款极简休闲游戏,玩家通过按压屏幕控制小人从一个平台跳跃到下一个平台。按压时间越长,跳跃距离越远。核心挑战在于:如何根据当前平台与目标平台的距离,精确计算出所需的按压时长。

本文将跳一跳抽象为物理抛物线问题,用Java实现一个完整的跳跃模拟与最优落点计算系统,包含物理引擎、距离估计和贪心策略决策。

核心算法设计

1. 物理抛物线模型

假设小人起跳时获得水平初速度 $v_x$ 和垂直初速度 $v_y$,在重力加速度 $g$ 作用下做抛物线运动:

  • 水平位移:$x(t) = v_x \cdot t$
  • 垂直位移:$y(t) = v_y \cdot t – \frac{1}{2} g t^2$

小人落回起跳高度时,$y(t) = 0$,解得飞行时间 $T = \frac{2v_y}{g}$,水平射程 $R = v_x \cdot T = \frac{2v_x v_y}{g}$。

2. 按压时长与初速度映射

游戏中按压时长 $\Delta t$ 与初速度呈线性关系。设按压时长映射系数为 $k$,则:
– $v_x = k \cdot \Delta t \cdot \cos\theta$
– $v_y = k \cdot \Delta t \cdot \sin\theta$

其中 $\theta$ 为起跳角度(通常固定为45度以获得最远距离)。

3. 贪心最优落点策略

对于每个目标平台,我们需要:
1. 计算当前位置到目标平台中心的水平距离
2. 反推所需的按压时长
3. 考虑平台尺寸,选择平台中心作为最优落点以获得最高分数

Java实现

package jumpgame;

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

/**
 * 跳一跳物理模拟与最优落点计算系统
 * 核心算法:抛物线物理模型 + 贪心最优落点策略
 */
public class JumpOneJump {

    // 物理常量
    private static final double GRAVITY = 9.8;           // 重力加速度 (m/s^2)
    private static final double PRESS_FACTOR = 2.5;      // 按压时长到速度映射系数
    private static final double JUMP_ANGLE = Math.PI / 4; // 起跳角度 (45度)

    /**
     * 平台类,表示游戏中的落脚平台
     */
    static class Platform {
        double centerX, centerY;  // 平台中心坐标
        double width, depth;      // 平台宽度和深度
        int scoreBonus;           // 平台中心奖励分数

        public Platform(double centerX, double centerY, double width, double depth, int scoreBonus) {
            this.centerX = centerX;
            this.centerY = centerY;
            this.width = width;
            this.depth = depth;
            this.scoreBonus = scoreBonus;
        }

        /**
         * 判断落点是否在平台范围内
         */
        boolean contains(double x, double y) {
            return Math.abs(x - centerX) <= width / 2 && Math.abs(y - centerY) <= depth / 2;
        }
    }

    /**
     * 跳跃轨迹类,存储抛物线上的离散点
     */
    static class Trajectory {
        List<double[]> points = new ArrayList<>();  // [x, y, t] 三维点
        double flightTime;                           // 总飞行时间
        double landingX, landingY;                   // 落点坐标

        public Trajectory(double landingX, double landingY, double flightTime) {
            this.landingX = landingX;
            this.landingY = landingY;
            this.flightTime = flightTime;
        }
    }

    /**
     * 计算给定按压时长的跳跃轨迹
     * @param pressTime 按压时长 (毫秒)
     * @param startX 起跳点X坐标
     * @param startY 起跳点Y坐标
     * @param angle 跳跃方向角度 (弧度)
     * @return 跳跃轨迹对象
     */
    public Trajectory calculateTrajectory(double pressTime, double startX, double startY, double angle) {
        // 根据按压时长计算初速度
        double velocity = PRESS_FACTOR * pressTime / 1000.0;  // 转换为秒
        double vx = velocity * Math.cos(JUMP_ANGLE) * Math.cos(angle);
        double vy = velocity * Math.cos(JUMP_ANGLE) * Math.sin(angle);
        double vz = velocity * Math.sin(JUMP_ANGLE);  // 垂直方向初速度

        // 计算飞行时间:落回同一高度时 vz * t - 0.5 * g * t^2 = 0
        double flightTime = 2 * vz / GRAVITY;

        // 计算落点
        double landingX = startX + vx * flightTime;
        double landingY = startY + vy * flightTime;

        Trajectory traj = new Trajectory(landingX, landingY, flightTime);

        // 采样轨迹点用于可视化
        int samples = 50;
        for (int i = 0; i <= samples; i++) {
            double t = flightTime * i / samples;
            double x = startX + vx * t;
            double y = startY + vy * t;
            double z = vz * t - 0.5 * GRAVITY * t * t;
            traj.points.add(new double[]{x, y, z, t});
        }

        return traj;
    }

    /**
     * 贪心策略:计算跳到目标平台中心所需的最优按压时长
     * @param startX 当前X坐标
     * @param startY 当前Y坐标
     * @param target 目标平台
     * @return 最优按压时长 (毫秒),若无法到达返回 -1
     */
    public double calculateOptimalPressTime(double startX, double startY, Platform target) {
        // 计算到平台中心的水平距离
        double dx = target.centerX - startX;
        double dy = target.centerY - startY;
        double horizontalDistance = Math.sqrt(dx * dx + dy * dy);

        // 计算跳跃方向角
        double angle = Math.atan2(dy, dx);

        // 反推按压时长:R = 2 * v^2 * cos(θ) * sin(θ) / g = v^2 * sin(2θ) / g
        // v = PRESS_FACTOR * pressTime / 1000
        // 解得 pressTime = 1000 * sqrt(R * g / sin(2θ)) / PRESS_FACTOR
        double sin2Theta = Math.sin(2 * JUMP_ANGLE);
        if (sin2Theta < 0.001) return -1;  // 避免除零

        double velocityNeeded = Math.sqrt(horizontalDistance * GRAVITY / sin2Theta);
        double pressTime = velocityNeeded * 1000.0 / PRESS_FACTOR;

        // 验证落点是否在平台范围内(考虑平台边缘容错)
        Trajectory traj = calculateTrajectory(pressTime, startX, startY, angle);
        if (target.contains(traj.landingX, traj.landingY)) {
            return pressTime;
        }

        // 如果中心落点失败,尝试在平台范围内找到最佳落点
        return findBestLandingPressTime(startX, startY, target, angle, horizontalDistance);
    }

    /**
     * 在平台范围内搜索最佳落点对应的按压时长
     * 使用二分搜索在平台边缘附近寻找可行解
     */
    private double findBestLandingPressTime(double startX, double startY, 
                                              Platform target, double angle, double distance) {
        double low = 0, high = 5000;  // 按压时长范围 0~5000ms
        double bestPressTime = -1;
        double minError = Double.MAX_VALUE;

        // 二分搜索100次精化
        for (int i = 0; i < 100; i++) {
            double mid = (low + high) / 2;
            Trajectory traj = calculateTrajectory(mid, startX, startY, angle);

            double error = Math.sqrt(
                Math.pow(traj.landingX - target.centerX, 2) + 
                Math.pow(traj.landingY - target.centerY, 2)
            );

            if (target.contains(traj.landingX, traj.landingY)) {
                // 在平台内,记录更优解(更接近中心)
                if (error < minError) {
                    minError = error;
                    bestPressTime = mid;
                }
                high = mid;  // 尝试更短的按压
            } else {
                // 不在平台内,判断是跳近了还是跳远了
                double landingDist = Math.sqrt(
                    Math.pow(traj.landingX - startX, 2) + 
                    Math.pow(traj.landingY - startY, 2)
                );
                if (landingDist < distance) {
                    low = mid;  // 跳近了,增加按压
                } else {
                    high = mid; // 跳远了,减少按压
                }
            }
        }

        return bestPressTime;
    }

    /**
     * 连续跳跃序列规划:贪心选择下一个最优平台
     * @param platforms 所有可用平台列表
     * @param startIdx 起始平台索引
     * @return 跳跃序列(按压时长列表)
     */
    public List<Double> planJumpSequence(List<Platform> platforms, int startIdx) {
        List<Double> sequence = new ArrayList<>();
        int currentIdx = startIdx;
        double currentX = platforms.get(currentIdx).centerX;
        double currentY = platforms.get(currentIdx).centerY;

        // 贪心策略:每次选择距离适中且奖励最高的可达平台
        boolean[] visited = new boolean[platforms.size()];
        visited[currentIdx] = true;

        while (true) {
            int bestNext = -1;
            double bestScore = -1;
            double bestPressTime = -1;

            for (int i = 0; i < platforms.size(); i++) {
                if (visited[i]) continue;

                Platform next = platforms.get(i);
                double pressTime = calculateOptimalPressTime(currentX, currentY, next);

                if (pressTime > 0) {
                    // 评分函数:奖励分数 / (按压时长 + 距离惩罚)
                    double dist = Math.sqrt(
                        Math.pow(next.centerX - currentX, 2) + 
                        Math.pow(next.centerY - currentY, 2)
                    );
                    double score = next.scoreBonus / (1 + pressTime / 1000 + dist / 10);

                    if (score > bestScore) {
                        bestScore = score;
                        bestNext = i;
                        bestPressTime = pressTime;
                    }
                }
            }

            if (bestNext == -1) break;  // 无处可跳

            sequence.add(bestPressTime);
            visited[bestNext] = true;
            currentIdx = bestNext;
            currentX = platforms.get(currentIdx).centerX;
            currentY = platforms.get(currentIdx).centerY;
        }

        return sequence;
    }

    public static void main(String[] args) {
        JumpOneJump game = new JumpOneJump();

        // 创建测试平台序列
        List<Platform> platforms = new ArrayList<>();
        platforms.add(new Platform(0, 0, 2, 2, 1));      // 起点
        platforms.add(new Platform(3, 1, 2, 2, 2));      // 近距离
        platforms.add(new Platform(7, 2, 1.5, 1.5, 3));  // 中距离
        platforms.add(new Platform(12, 0, 2, 2, 2));     // 远距离
        platforms.add(new Platform(15, 3, 1, 1, 5));     // 高难度高分

        // 计算跳到第二个平台的最优按压时长
        double pressTime = game.calculateOptimalPressTime(0, 0, platforms.get(1));
        System.out.printf("跳到第2个平台的最优按压时长: %.2f ms%n", pressTime);

        // 显示轨迹
        Trajectory traj = game.calculateTrajectory(pressTime, 0, 0, 
            Math.atan2(1, 3));
        System.out.printf("飞行时间: %.2f s, 落点: (%.2f, %.2f)%n", 
            traj.flightTime, traj.landingX, traj.landingY);

        // 规划完整跳跃序列
        List<Double> sequence = game.planJumpSequence(platforms, 0);
        System.out.println("\n最优跳跃序列(按压时长 ms):");
        for (int i = 0; i < sequence.size(); i++) {
            System.out.printf("  第%d跳: %.2f ms%n", i + 1, sequence.get(i));
        }
    }
}

算法复杂度分析

操作 时间复杂度 空间复杂度
单轨迹计算 $O(n)$,$n$ 为采样点数 $O(n)$
最优按压时长(二分搜索) $O(k \cdot n)$,$k$ 为二分迭代次数 $O(n)$
序列规划(贪心) $O(m^2 \cdot k \cdot n)$,$m$ 为平台数 $O(m)$

扩展思考

  1. 视觉距离估计:实际游戏中需要通过屏幕图像识别来估算平台距离,可使用边缘检测或深度学习方法。
  2. 动态障碍物:加入移动平台后,需要预测平台位置,引入时间维度进行预判跳跃。
  3. 强化学习优化:使用DQN等算法让AI自主学习最优按压策略,适应不同平台布局。

总结

本文通过物理抛物线模型将跳一跳游戏抽象为数学优化问题,使用Java实现了轨迹计算、最优落点搜索和贪心序列规划。核心算法简单高效,时间复杂度可控,适合作为物理模拟与贪心策略的经典教学案例。