每日算法 — 使用java实现切绳子:物理摆运动与最优切割时机计算

一、游戏背景与核心机制

“切绳子”(Cut the Rope)是一款风靡全球的物理解谜小游戏。玩家需要通过滑动屏幕在恰当时机切断绳子,让悬挂的糖果落入小怪兽Om Nom的口中。游戏融合了单摆运动、弹性碰撞、重力加速度和浮力等物理概念,每一关都是一道精心设计的物理应用题。

本文不追求完整的游戏引擎渲染,而是聚焦于核心算法:如何用Java建模绳摆运动,并计算出使糖果落入目标区域的最优切割时机。

二、核心算法原理

2.1 单摆运动学模型

被绳子悬挂的糖果可抽象为单摆模型。设绳长为L,摆角为θ,则糖果位置为:
– x = x₀ + L·sin(θ)
– y = y₀ – L·cos(θ)

角加速度由重力分量驱动:α = -(g/L)·sin(θ),其中g为重力加速度。

2.2 数值积分(欧拉/Verlet)

由于摆角方程无解析解,采用数值积分逐步更新状态。Verlet积分比欧拉法更稳定:
– θ(t+Δt) = 2θ(t) – θ(t-Δt) + α(t)·Δt²

2.3 最优切割时机判定

切割后糖果做抛体运动。给定目标区域,问题转化为:在摆动过程中,哪个时刻切割能让糖果轨迹经过目标点?

通过枚举时间步,对每个时刻模拟切割后的抛物线轨迹,计算与目标区域的最小距离,取距离最小的时刻作为最优解。

三、Java实现

3.1 物理引擎核心

import java.util.*;

/**
 * 切绳子物理引擎与最优切割求解器
 */
public class CutTheRopeSolver {
    // 物理常量
    public static final double GRAVITY = 9.8; // m/s²
    public static final double DT = 0.01; // 模拟时间步长(s)
    public static final double MAX_TIME = 10.0; // 最大模拟时长

    /**
     * 表示二维向量,支持基本向量运算
     */
    public static class Vec2 {
        public double x, y;
        public Vec2(double x, double y) { this.x = x; this.y = y; }
        public Vec2 add(Vec2 v) { return new Vec2(x + v.x, y + v.y); }
        public Vec2 sub(Vec2 v) { return new Vec2(x - v.x, y - v.y); }
        public Vec2 mul(double s) { return new Vec2(x * s, y * s); }
        public double len() { return Math.sqrt(x * x + y * y); }
        public Vec2 normalize() {
            double l = len();
            return l < 1e-9 ? new Vec2(0, 0) : new Vec2(x / l, y / l);
        }
        public double dot(Vec2 v) { return x * v.x + y * v.y; }
        public String toString() { return String.format("(%.3f, %.3f)", x, y); }
    }

    /**
     * 绳子连接点(悬挂点)
     */
    public static class Anchor {
        public Vec2 pos; // 固定位置
        public double length; // 绳长

        public Anchor(Vec2 pos, double length) {
            this.pos = pos;
            this.length = length;
        }
    }

    /**
     * 糖果(被悬挂的物体)
     */
    public static class Candy {
        public Vec2 pos; // 当前位置
        public Vec2 vel; // 当前速度
        public double mass; // 质量(kg)
        public double radius; // 碰撞半径

        public Candy(Vec2 pos, double mass, double radius) {
            this.pos = pos;
            this.vel = new Vec2(0, 0);
            this.mass = mass;
            this.radius = radius;
        }
    }

    /**
     * 目标区域(Om Nom的嘴)
     */
    public static class Target {
        public Vec2 center; // 中心位置
        public double radius; // 有效半径

        public Target(Vec2 center, double radius) {
            this.center = center;
            this.radius = radius;
        }

        /**
         * 判断点是否在目标区域内
         */
        public boolean contains(Vec2 p) {
            return p.sub(center).len() <= radius;
        }

        /**
         * 计算点到目标区域边界的距离(负数表示在内部)
         */
        public double distanceTo(Vec2 p) {
            return p.sub(center).len() - radius;
        }
    }

    /**
     * 模拟单摆运动的一个时间步(Verlet积分)
     * @param candy 糖果对象
     * @param anchor 悬挂点
     * @param dt 时间步长
     */
    public static void simulatePendulumStep(Candy candy, Anchor anchor, double dt) {
        // 计算当前绳方向
        Vec2 toCandy = candy.pos.sub(anchor.pos);
        double currentLen = toCandy.len();

        // 如果绳松弛(仅在有弹性绳时发生),直接受重力
        if (currentLen > anchor.length * 1.01) {
            candy.vel.y += GRAVITY * dt;
            candy.pos = candy.pos.add(candy.vel.mul(dt));
            return;
        }

        // 绳张力约束:将糖果位置投影到绳长为L的圆上
        Vec2 dir = toCandy.normalize();
        Vec2 constrainedPos = anchor.pos.add(dir.mul(anchor.length));

        // 计算摆角和角速度
        double angle = Math.atan2(dir.x, -dir.y); // 以竖直向下为0

        // 计算切向速度分量(垂直于绳方向)
        Vec2 tangent = new Vec2(-dir.y, dir.x); // 切向单位向量
        double vTangent = candy.vel.dot(tangent);

        // 角加速度 = -(g/L) * sin(θ)
        double angularAccel = -(GRAVITY / anchor.length) * Math.sin(angle);

        // 更新切向速度
        vTangent += angularAccel * anchor.length * dt;

        // 计算新角速度对应的新位置(小角度近似积分)
        double angularVel = vTangent / anchor.length;
        double newAngle = angle + angularVel * dt;

        // 根据新角度更新位置
        Vec2 newDir = new Vec2(Math.sin(newAngle), -Math.cos(newAngle));
        candy.pos = anchor.pos.add(newDir.mul(anchor.length));

        // 更新速度(保持切向分量,径向分量由张力抵消)
        candy.vel = tangent.mul(vTangent);
    }

    /**
     * 模拟切割后的抛体运动
     * @param candy 糖果(需传入切割瞬间的位置和速度)
     * @param target 目标区域
     * @param maxTime 最大模拟时间
     * @return 轨迹结果,包含是否命中、命中时间和落点
     */
    public static TrajectoryResult simulateProjectile(Candy candy, Target target, double maxTime) {
        Vec2 pos = new Vec2(candy.pos.x, candy.pos.y);
        Vec2 vel = new Vec2(candy.vel.x, candy.vel.y);
        double t = 0;

        // 记录轨迹点用于分析
        List<Vec2> trajectory = new ArrayList<>();
        trajectory.add(new Vec2(pos.x, pos.y));

        while (t < maxTime && pos.y > -1.0) { // y < -1视为掉出屏幕
            // 欧拉积分更新抛体
            vel.y += GRAVITY * DT;
            pos = pos.add(vel.mul(DT));
            t += DT;
            trajectory.add(new Vec2(pos.x, pos.y));

            // 检查是否进入目标区域
            if (target.contains(pos)) {
                return new TrajectoryResult(true, t, pos, target.distanceTo(pos), trajectory);
            }
        }

        // 未命中,计算最小距离
        double minDist = Double.MAX_VALUE;
        Vec2 closest = null;
        for (Vec2 p : trajectory) {
            double d = target.distanceTo(p);
            if (d < minDist) {
                minDist = d;
                closest = p;
            }
        }
        return new TrajectoryResult(false, t, closest, minDist, trajectory);
    }

    /**
     * 轨迹模拟结果
     */
    public static class TrajectoryResult {
        public boolean hit; // 是否命中目标
        public double time; // 命中/落地时间
        public Vec2 finalPos; // 最终位置
        public double minDistance; // 到目标区域的最小距离
        public List<Vec2> trajectory; // 完整轨迹

        public TrajectoryResult(boolean hit, double time, Vec2 finalPos, double minDistance, List<Vec2> trajectory) {
            this.hit = hit;
            this.time = time;
            this.finalPos = finalPos;
            this.minDistance = minDistance;
            this.trajectory = trajectory;
        }
    }

3.2 最优切割时机求解器

    /**
     * 寻找最优切割时机
     * 策略:枚举摆动周期内的离散时间点,对每个时刻模拟切割后的抛体轨迹,
     * 选择使糖果落入目标区域的最优时刻
     * 
     * @param candy 初始静止或已给初始速度的糖果
     * @param anchor 悬挂点
     * @param target 目标区域
     * @return 最优切割方案
     */
    public static CutSolution findOptimalCut(Candy candy, Anchor anchor, Target target) {
        Candy simCandy = new Candy(new Vec2(candy.pos.x, candy.pos.y), candy.mass, candy.radius);
        simCandy.vel = new Vec2(candy.vel.x, candy.vel.y);

        double bestTime = -1;
        double bestScore = Double.MAX_VALUE;
        TrajectoryResult bestResult = null;
        Vec2 bestCutPos = null;
        Vec2 bestCutVel = null;

        // 先模拟一个完整周期找稳定摆动
        double totalSimTime = 0;
        double periodEstimate = 2 * Math.PI * Math.sqrt(anchor.length / GRAVITY);
        double searchDuration = periodEstimate * 2; // 搜索两个周期

        // 第一阶段:让摆到达稳态(如果给定了初始偏移)
        while (totalSimTime < searchDuration) {
            simulatePendulumStep(simCandy, anchor, DT);
            totalSimTime += DT;

            // 每个时间步检查切割效果
            if (totalSimTime > 0.1) { // 跳过初始不稳定阶段
                // 保存当前状态作为切割点
                Vec2 cutPos = new Vec2(simCandy.pos.x, simCandy.pos.y);
                Vec2 cutVel = new Vec2(simCandy.vel.x, simCandy.vel.y);

                // 创建临时糖果模拟抛体
                Candy projCandy = new Candy(cutPos, simCandy.mass, simCandy.radius);
                projCandy.vel = cutVel;

                TrajectoryResult result = simulateProjectile(projCandy, target, MAX_TIME);

                // 评分:优先命中,其次最小距离
                double score;
                if (result.hit) {
                    score = result.time; // 命中则越快越好
                } else {
                    score = 1000 + result.minDistance; // 未命中惩罚
                }

                if (score < bestScore) {
                    bestScore = score;
                    bestTime = totalSimTime;
                    bestResult = result;
                    bestCutPos = cutPos;
                    bestCutVel = cutVel;
                }
            }
        }

        return new CutSolution(bestTime, bestCutPos, bestCutVel, bestResult);
    }

    /**
     * 切割方案结果
     */
    public static class CutSolution {
        public double cutTime; // 从模拟开始经过的时间(s)
        public Vec2 cutPosition; // 切割时糖果位置
        public Vec2 cutVelocity; // 切割时糖果速度
        public TrajectoryResult trajectoryResult; // 切割后轨迹结果

        public CutSolution(double cutTime, Vec2 cutPosition, Vec2 cutVelocity, TrajectoryResult result) {
            this.cutTime = cutTime;
            this.cutPosition = cutPosition;
            this.cutVelocity = cutVelocity;
            this.trajectoryResult = result;
        }

        @Override
        public String toString() {
            StringBuilder sb = new StringBuilder();
            sb.append("=== 最优切割方案 ===\n");
            sb.append(String.format("切割时机: t=%.3fs\n", cutTime));
            sb.append("切割位置: ").append(cutPosition).append("\n");
            sb.append("切割速度: ").append(cutVelocity).append("\n");
            if (trajectoryResult != null) {
                sb.append("命中目标: ").append(trajectoryResult.hit).append("\n");
                sb.append(String.format("最小距离: %.4f\n", trajectoryResult.minDistance));
                if (trajectoryResult.hit) {
                    sb.append(String.format("命中时间: %.3fs\n", trajectoryResult.time));
                }
            }
            return sb.toString();
        }
    }

3.3 多绳约束与气泡浮力扩展

    /**
     * 多绳约束求解器:用迭代法(松弛法)满足所有绳长约束
     * 当糖果被多根绳子悬挂时,每根绳子都施加一个约束
     */
    public static void solveMultiRopeConstraints(Candy candy, List<Anchor> anchors, int iterations) {
        for (int iter = 0; iter < iterations; iter++) {
            Vec2 correction = new Vec2(0, 0);
            int activeCount = 0;

            for (Anchor anchor : anchors) {
                Vec2 toCandy = candy.pos.sub(anchor.pos);
                double dist = toCandy.len();

                // 只有拉紧的绳子才施加约束
                if (dist > anchor.length) {
                    Vec2 dir = toCandy.normalize();
                    double overlap = dist - anchor.length;
                    correction = correction.add(dir.mul(-overlap));
                    activeCount++;
                }
            }

            if (activeCount > 0) {
                candy.pos = candy.pos.add(correction.mul(1.0 / activeCount));
            }
        }
    }

    /**
     * 气泡浮力效果:糖果进入气泡后受到向上的净力
     * 模拟方式:在气泡区域内,重力被部分或完全抵消
     */
    public static void applyBubbleForce(Candy candy, Vec2 bubbleCenter, double bubbleRadius, double liftRatio) {
        double dist = candy.pos.sub(bubbleCenter).len();
        if (dist < bubbleRadius) {
            // 在气泡内部,施加向上的补偿力(抵消部分重力)
            double factor = 1.0 - (dist / bubbleRadius); // 中心最强
            double lift = -GRAVITY * liftRatio * factor; // 向上为负
            candy.vel.y += lift * DT;
        }
    }

    /**
     * 完整关卡求解:包含多绳、气泡、目标
     */
    public static CutSolution solveLevel(Candy candy, List<Anchor> anchors, 
                                          List<Vec2> bubbleCenters, List<Double> bubbleRadii,
                                          Target target) {
        Candy sim = new Candy(new Vec2(candy.pos.x, candy.pos.y), candy.mass, candy.radius);
        sim.vel = new Vec2(candy.vel.x, candy.vel.y);

        double bestTime = -1;
        double bestScore = Double.MAX_VALUE;
        CutSolution bestSolution = null;

        double simTime = 0;
        while (simTime < MAX_TIME) {
            // 多绳约束步
            solveMultiRopeConstraints(sim, anchors, 5);

            // 重力步
            sim.vel.y += GRAVITY * DT;
            sim.pos = sim.pos.add(sim.vel.mul(DT));

            // 气泡浮力
            for (int i = 0; i < bubbleCenters.size(); i++) {
                applyBubbleForce(sim, bubbleCenters.get(i), bubbleRadii.get(i), 0.8);
            }

            // 再次约束(速度积分后可能破坏约束)
            solveMultiRopeConstraints(sim, anchors, 3);

            simTime += DT;

            // 每隔一定步长评估切割效果
            if ((int)(simTime / DT) % 5 == 0) {
                // 临时解除约束模拟切割
                Candy proj = new Candy(new Vec2(sim.pos.x, sim.pos.y), sim.mass, sim.radius);
                proj.vel = new Vec2(sim.vel.x, sim.vel.y);

                TrajectoryResult traj = simulateProjectile(proj, target, MAX_TIME);
                double score = traj.hit ? traj.time : 1000 + traj.minDistance;

                if (score < bestScore) {
                    bestScore = score;
                    bestTime = simTime;
                    bestSolution = new CutSolution(simTime, new Vec2(sim.pos.x, sim.pos.y), 
                                                   new Vec2(sim.vel.x, sim.vel.y), traj);
                }
            }
        }

        return bestSolution;
    }

3.4 主程序与演示

    public static void main(String[] args) {
        System.out.println("=== 切绳子最优切割求解器 ===\n");

        // 场景1:单摆基础关卡
        System.out.println("【场景1】单摆基础关卡");
        Anchor anchor1 = new Anchor(new Vec2(0, 5), 3.0); // 悬挂点在(0,5),绳长3米
        Candy candy1 = new Candy(new Vec2(2, 2), 0.1, 0.15); // 初始偏右下方
        Target target1 = new Target(new Vec2(4, 0), 0.3); // 目标在右侧地面

        CutSolution sol1 = findOptimalCut(candy1, anchor1, target1);
        System.out.println(sol1);

        // 场景2:需要等待摆到最高点反向时切割
        System.out.println("\n【场景2】逆向切割关卡");
        Anchor anchor2 = new Anchor(new Vec2(0, 4), 2.5);
        Candy candy2 = new Candy(new Vec2(-1.5, 1.5), 0.1, 0.15);
        Target target2 = new Target(new Vec2(-3, 0), 0.3);

        CutSolution sol2 = findOptimalCut(candy2, anchor2, target2);
        System.out.println(sol2);

        // 场景3:多绳+气泡复杂关卡
        System.out.println("\n【场景3】多绳气泡复杂关卡");
        List<Anchor> anchors = Arrays.asList(
            new Anchor(new Vec2(-1, 5), 2.0),
            new Anchor(new Vec2(1, 5), 2.0)
        );
        Candy candy3 = new Candy(new Vec2(0, 3), 0.1, 0.15);
        List<Vec2> bubbles = Arrays.asList(new Vec2(2, 2));
        List<Double> bubbleR = Arrays.asList(1.0);
        Target target3 = new Target(new Vec2(3, 4), 0.3);

        CutSolution sol3 = solveLevel(candy3, anchors, bubbles, bubbleR, target3);
        System.out.println(sol3);

        // 打印轨迹可视化(ASCII简化)
        if (sol3 != null && sol3.trajectoryResult != null) {
            System.out.println("\n【轨迹预览】(20x10网格)");
            printTrajectory(sol3.trajectoryResult.trajectory, anchors.get(0).pos, target3);
        }
    }

    /**
     * ASCII轨迹可视化
     */
    private static void printTrajectory(List<Vec2> traj, Vec2 anchor, Target target) {
        int w = 40, h = 15;
        char[][] grid = new char[h][w];
        for (char[] row : grid) Arrays.fill(row, ' ');

        // 坐标映射到网格
        double minX = -3, maxX = 6, minY = -1, maxY = 6;

        // 画目标
        int tx = (int)((target.center.x - minX) / (maxX - minX) * w);
        int ty = (int)((maxY - target.center.y) / (maxY - minY) * h);
        if (tx >= 0 && tx < w && ty >= 0 && ty < h) grid[ty][tx] = 'O';

        // 画轨迹
        for (Vec2 p : traj) {
            int px = (int)((p.x - minX) / (maxX - minX) * w);
            int py = (int)((maxY - p.y) / (maxY - minY) * h);
            if (px >= 0 && px < w && py >= 0 && py < h) grid[py][px] = '*';
        }

        // 画悬挂点
        int ax = (int)((anchor.x - minX) / (maxX - minX) * w);
        int ay = (int)((maxY - anchor.y) / (maxY - minY) * h);
        if (ax >= 0 && ax < w && ay >= 0 && ay < h) grid[ay][ax] = 'A';

        // 画地面
        int groundY = (int)((maxY - 0) / (maxY - minY) * h);
        for (int x = 0; x < w; x++) if (groundY >= 0 && groundY < h) grid[groundY][x] = '-';

        for (char[] row : grid) System.out.println(new String(row));
    }
}

四、复杂度分析

模块 时间复杂度 空间复杂度 说明
单摆模拟 O(T/Δt) O(1) T为模拟时长,Δt为步长
抛体轨迹 O(T_max/Δt) O(T_max/Δt) 存储轨迹点用于距离计算
最优切割搜索 O(P·T_max/Δt) O(T_max/Δt) P为摆动周期数
多绳约束 O(k·n) O(1) k为迭代次数,n为绳子数

在典型关卡中(周期约2秒,步长0.01秒),单场景求解在毫秒级完成。若需更高精度,可减小Δt或采用自适应步长。

五、总结

本文用Java实现了”切绳子”游戏的核心物理引擎与最优切割求解算法,涵盖以下技术要点:

  1. 单摆建模:使用Verlet积分稳定模拟绳摆运动,正确处理绳张力约束
  2. 抛体预测:切割后糖果按抛物线轨迹运动,通过离散时间积分预测落点
  3. 最优时机搜索:枚举摆动周期内的候选时刻,通过模拟评分选取最佳切割点
  4. 多绳与浮力扩展:支持多绳悬挂约束求解和气泡浮力效果,适配复杂关卡

读者可在此基础上接入图形渲染库(如JavaFX或LibGDX),添加弹性绳、气球、吹风机等更多物理元素,构建完整的物理解谜游戏体验。同时,也可将最优求解器改为实时AI提示系统,在玩家卡关时给出切割建议。