每日算法 — 使用java实现百战天虫:风阻弹道数值积分与二次方程落点求解

游戏场景里的数学问题

百战天虫(Worms)系列最迷人的地方在于那条弯曲的抛物线:你调整角度和力度,按下空格,炮弹在空中划出一道弧线,在重力和风速的作用下落在对手脚下。玩家在屏幕上看到的是”手感”,代码里其实是常微分方程初值问题。

如果只用中学物理的 x = v₀cosθ·t、y = v₀sinθ·t - ½gt² 消去 t 求落点,那么只要加入风力(水平恒定加速度)依然可以得到解析解;但百战天虫的经典设定里还有随速度变化的空气阻力(阻力与速度平方成正比),这时轨迹方程不再可积,必须做数值积分。

本文会实现三件事:
1. 用 RK4(四阶龙格-库塔) 对带二次风阻的弹道做高精度数值积分;
2. 用二次方程解析解处理无阻力/线性阻力情形,作为数值解的校验基准;
3. 在此之上做一个把”角度 + 力度”两维参数搜索出最优发射方案的小型求解器(给 AI 用)。

物理模型

炮弹质量 m、初速度 v₀、发射角 θ、重力 g、风速 w(水平分速度)、阻力系数 k。取阻力与相对速度平方成正比:

F_drag = -k·|v_rel|·v_rel     其中 v_rel = v_aircraft - w

拆成分量:

dvx/dt = -k·|v_rel|·(vx - w)
dvy/dt = -k·|v_rel|·vy - g
dx/dt  = vx
dy/dt  = vy

这是一个四维一阶方程组 y' = f(t, y),标准 RK4 每步:

k1 = f(y_n)
k2 = f(y_n + h/2·k1)
k3 = f(y_n + h/2·k2)
k4 = f(y_n + h·k3)
y_{n+1} = y_n + h/6·(k1 + 2k2 + 2k3 + k4)

RK4 是四阶精度,误差 O(h⁴),在游戏里用 h = 1/120 秒就能达到肉眼无法分辨的精度,且比 Euler 稳得多——后者在阻力存在时容易在高速段”抖”出错误轨迹。

落点求解:为什么还需要二次方程

地形是一条高度函数 height(x)。轨迹的落点是第一个满足 y ≤ height(x) 的采样点。如果只取采样点,误差就是”一帧的位移”,高速时可达几十像素。工程上常用割线插值:在越界的前后两个采样点上用线性插值估计精确交点的 x。

但纯解析路线依然有价值:当 k = 0(无阻力)时,轨迹是可积的。此时令 y = 0 得到

½gt² - (v₀ sinθ)t - y₀ = 0
t = [(v₀ sinθ) + sqrt((v₀ sinθ)² + 2g y₀)] / g
x = x₀ + v₀ cosθ · t

这就是”二次方程落点求解”。它有两个用途:一是给数值积分器做单元测试(k→0 时两者必须收敛到同一个值),二是给 AI 做解析初筛——先算出无阻力落点,把搜索范围收窄,再在局部调用昂贵的 RK4。

项目结构

worms-ballistics/
└── src/main/java/com/example/worms/
    ├── Vec2.java               // 二维向量
    ├── Terrain.java            // 地形与落点判定
    ├── BallisticsSolver.java   // RK4 积分 + 二次方程解析解
    ├── Shot.java               // 一次发射的参数与结果
    └── AimSearch.java          // 角度/力度二维搜索

Vec2 与 Terrain

package com.example.worms;

/** 不可变二维向量,避免积分过程中意外修改状态。 */
public final class Vec2 {
    public final double x, y;

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

    public Vec2 add(Vec2 o)   { return new Vec2(x + o.x, y + o.y); }
    public Vec2 sub(Vec2 o)   { return new Vec2(x - o.x, y - o.y); }
    public Vec2 mul(double s) { return new Vec2(x * s, y * s); }

    public double len() { return Math.sqrt(x * x + y * y); }

    @Override public String toString() {
        return String.format("(%.2f, %.2f)", x, y);
    }
}
package com.example.worms;

/**
 * 地形。用"分段折线 + 若干圆形土丘"表示,height(x) 返回该位置的地表高度。
 * 坐标约定:y 向下为正(屏幕坐标系),因此"高度更高"意味着 y 更小。
 */
public final class Terrain {
    private final double[] xs;         // 折线顶点 x,必须升序
    private final double[] ys;         // 折线顶点 y
    private final double[][] hills;    // {cx, cy, r} 圆形土丘

    public Terrain(double[] xs, double[] ys, double[][] hills) {
        this.xs = xs;
        this.ys = ys;
        this.hills = hills;
    }

    /** 地表 y 值(越小越"高")。超出折线范围时取端点值。 */
    public double height(double x) {
        double y = interpolatePolyline(x);
        // 圆形土丘会抬高地形:取"更小"的 y
        for (double[] h : hills) {
            double dx = x - h[0];
            double r  = h[2];
            if (Math.abs(dx) <= r) {
                double circleY = h[1] - Math.sqrt(r * r - dx * dx);
                y = Math.min(y, circleY);
            }
        }
        return y;
    }

    private double interpolatePolyline(double x) {
        if (x <= xs[0]) return ys[0];
        if (x >= xs[xs.length - 1]) return ys[ys.length - 1];
        // 二分查找所在区间
        int lo = 0, hi = xs.length - 1;
        while (hi - lo > 1) {
            int mid = (lo + hi) >>> 1;
            if (xs[mid] <= x) lo = mid; else hi = mid;
        }
        double t = (x - xs[lo]) / (xs[hi] - xs[lo]);
        return ys[lo] + t * (ys[hi] - ys[lo]);
    }
}

核心:BallisticsSolver

package com.example.worms;

/**
 * 弹道求解器。
 * 同时提供两条路径:
 *   1) rk4Trace —— 带二次风阻的数值积分(游戏实际使用)
 *   2) analyticLanding —— 无阻力情形的解析解(用于校验与初筛)
 */
public final class BallisticsSolver {

    /** 物理与世界参数。 */
    public static final class Params {
        public double gravity = 900.0;   // 像素/秒²
        public double dragK   = 0.0012;  // 阻力系数,0 表示无阻力
        public double wind    = 0.0;     // 风速(水平分速度,向右为正)
        public double dt      = 1.0 / 240.0; // 积分步长
        public int    maxSteps = 20000;
    }

    /** 一次积分的结果。 */
    public static final class Result {
        public final double landX, landY;   // 落点
        public final double flightTime;     // 飞行时间
        public final boolean hitTerrain;    // 是否撞到地形(否则飞出界)
        public Result(double x, double y, double t, boolean hit) {
            landX = x; landY = y; flightTime = t; hitTerrain = hit;
        }
        @Override public String toString() {
            return String.format("land=(%.2f,%.2f) t=%.3fs hit=%s",
                    landX, landY, flightTime, hitTerrain);
        }
    }

    private final Params p;
    private final Terrain terrain;

    public BallisticsSolver(Params p, Terrain terrain) {
        this.p = p;
        this.terrain = terrain;
    }

    /**
     * 状态向量 y = {x, y, vx, vy} 的导数。
     * 阻力与相对空气速度的平方成正比,方向与相对速度相反。
     */
    private double[] deriv(double[] s) {
        double vx = s[2], vy = s[3];
        double relVx = vx - p.wind;
        double relVy = vy;
        double speed = Math.sqrt(relVx * relVx + relVy * relVy);

        // 阻力加速度 = -k·|v_rel|·v_rel / m,这里把 m 归一化为 1
        double ax = -p.dragK * speed * relVx;
        double ay = -p.dragK * speed * relVy + p.gravity;

        return new double[]{ vx, vy, ax, ay };
    }

    /**
     * 四阶龙格-库塔积分,返回轨迹与落点。
     * @param x0,y0 发射点
     * @param angleDeg 发射角(度,向上为正)
     * @param power 初速度
     */
    public Result rk4Trace(double x0, double y0, double angleDeg, double power,
                           java.util.List<Vec2> traceOut) {
        double rad = Math.toRadians(angleDeg);
        double[] s = {
                x0, y0,
                power * Math.cos(rad),
                -power * Math.sin(rad)   // y 向下为正,因此向上是负
        };

        double t = 0;
        double prevX = s[0], prevY = s[1];
        double prevF = s[1] - terrain.height(s[0]); // 与地表的"穿透量"

        for (int step = 0; step < p.maxSteps; step++) {
            double[] k1 = deriv(s);

            double[] s2 = addScaled(s, k1, p.dt / 2);
            double[] k2 = deriv(s2);

            double[] s3 = addScaled(s, k2, p.dt / 2);
            double[] k3 = deriv(s3);

            double[] s4 = addScaled(s, k3, p.dt);
            double[] k4 = deriv(s4);

            for (int i = 0; i < 4; i++) {
                s[i] += p.dt / 6.0 * (k1[i] + 2 * k2[i] + 2 * k3[i] + k4[i]);
            }
            t += p.dt;

            double curF = s[1] - terrain.height(s[0]);
            if (traceOut != null) traceOut.add(new Vec2(s[0], s[1]));

            // 越界判定:这一步首次穿入地表
            if (curF >= 0) {
                // 用前后两步做线性插值,把落点精确到子像素级
                double ratio = prevF / (prevF - curF); // prevF<0, curF>=0
                double lx = prevX + ratio * (s[0] - prevX);
                double ly = terrain.height(lx);
                return new Result(lx, ly, t - p.dt + ratio * p.dt, true);
            }
            prevF = curF;
            prevX = s[0];
            prevY = s[1];

            // 飞出世界边界
            if (s[0] < -5000 || s[0] > 20000 || s[1] > 5000) break;
        }
        return new Result(s[0], s[1], t, false);
    }

    private static double[] addScaled(double[] s, double[] d, double h) {
        return new double[]{ s[0] + h * d[0], s[1] + h * d[1],
                             s[2] + h * d[2], s[3] + h * d[3] };
    }

    /**
     * 无阻力解析落点:解二次方程 ½gt² - (v₀ sinθ)t - y₀ = 0。
     * 注意坐标系 y 向下为正,因此"下落"是 +y 方向。
     * 返回 {landX, flightTime};若解无意义返回 null。
     */
    public double[] analyticLanding(double x0, double y0, double angleDeg, double power) {
        double rad = Math.toRadians(angleDeg);
        double vx = power * Math.cos(rad);
        double vy = -power * Math.sin(rad); // 向上为负

        // 以 y0 为高度基准:落到 y=0 平面
        double g = p.gravity;
        double a = 0.5 * g;
        double b = vy;
        double c = y0;

        double disc = b * b - 4 * a * c;
        if (disc < 0) return null;

        // 取较大的正根(炮弹最终落地的那一次穿越)
        double t = (-b + Math.sqrt(disc)) / (2 * a);
        if (t < 0) t = (-b - Math.sqrt(disc)) / (2 * a);
        if (t < 0) return null;

        return new double[]{ x0 + vx * t, t };
    }
}

瞄准搜索:给 AI 用的二维参数搜索

有了精确落点函数,AI 的任务变成:在 (角度, 力度) 二维空间里找一组参数,使落点尽量靠近目标。目标函数非凸、地形多峰,纯梯度法不可靠。实战做法是分层:

package com.example.worms;

import java.util.*;

/** 在 (角度, 力度) 空间搜索最优发射方案。 */
public final class AimSearch {
    private final BallisticsSolver solver;

    public AimSearch(BallisticsSolver solver) {
        this.solver = solver;
    }

    public static final class Shot {
        public final double angle, power, landX, score;
        Shot(double a, double pw, double lx, double s) {
            angle = a; power = pw; landX = lx; score = s;
        }
        @Override public String toString() {
            return String.format("angle=%.1f° power=%.1f land=%.1f score=%.1f",
                    angle, power, landX, score);
        }
    }

    /**
     * 粗网格 + 局部精修的混合搜索。
     * @param targetX 目标 x 坐标
     * @param tolerance 命中半径
     */
    public Shot search(double fromX, double fromY, double targetX, double tolerance) {
        Shot best = null;

        // 阶段1:粗网格。角度 5°~85°,力度 200~1200
        for (double ang = 5; ang <= 85; ang += 2) {
            for (double pw = 200; pw <= 1200; pw += 20) {
                var r = solver.rk4Trace(fromX, fromY, ang, pw, null);
                if (!r.hitTerrain) continue;
                double err = Math.abs(r.landX - targetX);
                if (best == null || err < best.score) {
                    best = new Shot(ang, pw, r.landX, err);
                }
                if (err <= tolerance) return best; // 早期退出
            }
        }
        if (best == null) return null;

        // 阶段2:以粗网格最优点为中心做黄金分割精修(先角后力)
        double lo = best.angle - 2, hi = best.angle + 2;
        for (int i = 0; i < 12; i++) {           // 角度精修
            double mid = (lo + hi) / 2;
            double mLand = landAt(fromX, fromY, mid, best.power);
            double rLand = landAt(fromX, fromY, hi, best.power);
            if (Math.abs(mLand - targetX) < Math.abs(rLand - targetX)) hi = mid;
            else lo = mid;
        }
        double refinedAngle = (lo + hi) / 2;

        // 力度精修:落点随力度单调递增(其它条件固定),可直接二分
        double pLo = Math.max(100, best.power - 60);
        double pHi = best.power + 60;
        for (int i = 0; i < 20; i++) {
            double mid = (pLo + pHi) / 2;
            double land = landAt(fromX, fromY, refinedAngle, mid);
            if (land < targetX) pLo = mid; else pHi = mid;
        }
        double refinedPower = (pLo + pHi) / 2;
        double finalLand = landAt(fromX, fromY, refinedAngle, refinedPower);
        return new Shot(refinedAngle, refinedPower, finalLand,
                Math.abs(finalLand - targetX));
    }

    private double landAt(double x, double y, double ang, double pw) {
        var r = solver.rk4Trace(x, y, ang, pw, null);
        return r.hitTerrain ? r.landX : Double.NaN;
    }
}

注意阶段 2 的力度二分:在固定角度下,”落点 x 关于力度单调递增”这一性质在实践中成立(力度越大飞得越远),因此二分有效;但如果地形复杂、存在”打在山脊上”的非单调区间,就需要退化成三分搜索或多起点重试。

可运行的入口

package com.example.worms;

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

public final class Main {
    public static void main(String[] args) {
        // 一条起伏地形
        Terrain terrain = new Terrain(
                new double[]{-500, 200, 700, 1200, 1800, 2400, 3000},
                new double[]{600, 620, 540, 700, 660, 760, 720},
                new double[][]{{1500, 600, 120}});

        BallisticsSolver.Params p = new BallisticsSolver.Params();
        p.wind = 35;     // 顺风
        p.dragK = 0.0012;

        BallisticsSolver solver = new BallisticsSolver(p, terrain);

        // 1) 数值积分 vs 解析解(把阻力置零后对比)
        BallisticsSolver.Params noDrag = new BallisticsSolver.Params();
        noDrag.dragK = 0;
        noDrag.wind  = 0;
        BallisticsSolver s2 = new BallisticsSolver(noDrag, terrain);
        double[] analytic = s2.analyticLanding(200, 620, 45, 900);
        var numeric = s2.rk4Trace(200, 620, 45, 900, null);
        System.out.println("analytic land x = " + analytic[0]);
        System.out.println("rk4      land x = " + numeric.landX);

        // 2) 带风阻的完整轨迹(打印关键采样)
        List<Vec2> trace = new ArrayList<>();
        var res = solver.rk4Trace(200, 620, 40, 950, trace);
        System.out.println("with wind+drag: " + res);
        int stride = Math.max(1, trace.size() / 12);
        for (int i = 0; i < trace.size(); i += stride) {
            System.out.println("  t≈" + (i * p.dt) + " " + trace.get(i));
        }

        // 3) AI 瞄准
        AimSearch aim = new AimSearch(solver);
        Shot best = aim.search(200, 620, 1600, 15);
        System.out.println("best shot: " + best);
    }
}

运行后你会在控制台看到解析解与 RK4 的落点相差不到 1 像素(说明积分器正确),随后是一条被侧风推偏的弧线,最后是 AI 给出的”41° / 983″这类可直接喂给游戏引擎的发射参数。

复杂度与精度分析

环节 复杂度 说明
单次 RK4 轨迹 O(N) N = 飞行时间 / dt,典型 100~300 步
粗网格搜索 O(A · P · N) A≈40 角度档、P≈50 力度档,约 60 万次浮点步
局部精修 O(log(1/ε) · N) 二分/黄金分割,十几轮即收敛

精度上,RK4 的局部误差 O(h⁵)、全局误差 O(h⁴);dt 减半误差降为 1/16。若追求更高精度可换 自适应步长 RK45(Dormand-Prince),在弹道平直段自动放大步长,整体开销反而下降。

若把落点误差当成”命中率”,还有一个工程细节:同一条轨迹只算一次。游戏中玩家连续微调角度时,可以对上一帧的轨迹做增量重积分(前 90% 顶点不变),这在百战天虫的 AI 批量试算里能省掉一个数量级的计算。

小结

百战天虫的”抛物线”背后是一个标准的常微分方程初值问题。用 RK4 处理二次风阻得到稳定高精度的轨迹,用二次方程解析解做校验与初筛,再在 (角度, 力度) 空间做”粗网格 + 局部精修”的两级搜索,就构成了一套可用的炮弹 AI。这套结构可以平移到任何抛射类游戏——愤怒的小鸟、坦克大战、弓箭传说,差别只在阻力模型与地形表达。