移动目标拦截:有限弹速下该选哪一个命中时间

炮台位于 (0,0),目标从 (0,30) 以每秒 4 米向右移动,弹速为每秒 5 米。若把枪口朝向目标当前位置,弹丸 6 秒后抵达 (0,30),目标却已在 (24,30)。瞄准方向在发射瞬间没有偏,命中仍然差了 24 米:双方经过同一个位置的时间不一致。

固定炮台、横向目标与提前瞄准方向的示意

主视觉只表达提前瞄准的几何关系,精确轨迹见下图。本篇用独立 C# 工程求解二维点目标拦截;位置单位是米,时间单位是秒,发射原点固定,目标匀速,弹丸出膛后不转向、不受重力。验证在 .NET 控制台完成,不把它称作引擎物理或实际武器命中测试。

提前量要和飞行时间一起求

设发射瞬间目标相对炮口的位置为 r,目标速度为 v,弹速为 s。经过 t 秒,目标相对初始炮口的位置是 r + vt;弹丸能到达的点与炮口距离为 st。两者相遇要求:

|r + vt| = st,  t > 0
(v·v - s²)t² + 2(r·v)t + r·r = 0

把三个系数记为 a、b、c。开篇输入得到 a=-9、b=0、c=900,正时间解为 10 秒,瞄准点是 (40,30),单位方向是 (0.8,0.6)。弹丸走过 50 米,目标向右走过 40 米,二者恰好在同一时间到达同一点。

用当前距离除以弹速得到 6 秒,再让目标向前走 6 秒,并不能闭合这个约束。那样得到的 (24,30) 距炮口超过 30 米,弹丸仍需更长时间。无需靠多轮猜测提前量,当前假设下可以直接求方程的合法根。

固定输入下的直瞄失配与十秒拦截轨迹

两个正根各自对应一种发射方向

当目标比弹丸快,也不一定无解。目标从 (10,0) 以速度 (-6,0) 接近炮台,弹速仍为 5,方程得到 10/11 秒和 10 秒两个正根。较早一根让弹丸向右迎击;较晚一根让弹丸向左,在目标越过炮台之后相遇。这是两种发射方向的候选,不是一颗弹丸命中两次。

接口选择最早正时间,把“尽早拦截”固定为明确策略。负根描述过去,不参与选择。若目标以 (6,0) 远离 (0,0),两个根都在过去,应返回无解;若从 (0,10) 横向以速度 (6,0) 移动,判别式为负,也没有可用方向。

等速时 a=0,继续套二次公式会除以零。只要目标不与炮口重合,c>0;此时仅在 b<0 时才有正根 -c/b。初始重合则单独返回零,表示已经接触,调用方不能再除以 st 求发射方向。

研究工程的 Intercept.cs 包含完整关键实现,入口为 Intercept.Earliest:

public readonly record struct D2(double X, double Y)
{
    public static D2 operator +(D2 a, D2 b) => new(a.X + b.X, a.Y + b.Y);
    public static D2 operator *(D2 a, double k) => new(a.X * k, a.Y * k);
    public double Dot(D2 b) => X * b.X + Y * b.Y;
}

public static class Intercept
{
    public static double? Earliest(D2 r, D2 v, double speed)
    {
        static bool Valid(double x) => double.IsFinite(x) && Math.Abs(x) <= 1e6;
        if (!Valid(r.X) || !Valid(r.Y) || !Valid(v.X) || !Valid(v.Y)
            || !Valid(speed) || speed < 1e-6)
            throw new ArgumentOutOfRangeException(nameof(speed));
        double c = r.Dot(r);
        if (c == 0) return 0;
        double a = v.Dot(v) - speed * speed;
        double b = 2 * r.Dot(v);
        if (a == 0)
        {
            if (b >= 0) return null;
            return -c / b;
        }
        double d = b * b - 4 * a * c;
        if (d < 0) return null;
        // 避免两个接近的大数相减,用根的乘积恢复另一根。
        double q = -0.5 * (b + Math.CopySign(Math.Sqrt(d), b));
        double t0 = q / a;
        double t1 = q == 0 ? double.NaN : c / q;
        double best = double.PositiveInfinity;
        if (double.IsFinite(t0) && t0 > 0) best = t0;
        if (double.IsFinite(t1) && t1 > 0) best = Math.Min(best, t1);
        return double.IsFinite(best) ? best : null;
    }
}

这里没有把“接近零的 a”统一截成零。比如一个略慢于弹丸的远离目标,可能仍有很晚的有效交点;随意的阈值会改变有解性。代码只处理浮点计算所得的精确零,并使用 q/a 与 c/q 取根,减少直接相减造成的有效位损失。

Program.cs 的 NearEqual 固定 r=(10,0)、v=(-1,0)、s=1+1e-14。直接计算 (-b-sqrt(d))/(2a) 得到 4.977777777777778 秒,稳定实现得到 4.999999999999975 秒,一维参照式 10/(1+s) 为 4.999999999999976 秒。这组输入专门检验数值退化,不代表游戏必须使用如此细的弹速差。

用相遇距离检查返回时间

Program.cs 除固定案例外,还让位置两个分量遍历整数 [-10,10],速度两个分量遍历整数 [-6,6],弹速保持 5。GridGeometryAndRootPolicy 用 decimal 系数判定有解性,避免把被测函数原样抄成参照;对每个返回时间检查 abs(|r+vt|-st),并在两个正根区域检查所选根位于抛物线顶点之前或恰在顶点。

2026-09-27 的运行环境为 .NET 9.0.3,Release 构建零警告、零错误。将研究工程位置设为 $LAB_ROOT 后执行:

dotnet build "$LAB_ROOT/InterceptLab.csproj" -c Release --no-restore
dotnet run --project "$LAB_ROOT/InterceptLab.csproj" -c Release --no-build
build_exit=0 run_exit=0
passed=17 failed=0 skipped=0 elapsed_ms=43.7115
grid_samples=74529 solutions=45153 no_solution=29376
max_distance_residual_m=1.4210854715202004e-14
crossing_time_s=10 aim_m=(40,30) direction=(0.8,0.6)
earlier_root_s=0.9090909090909091 later_root_s=10

首次建立工程需先完成还原。17 项检查包含静止、接近、远离、横向、相切、两个正根、等速、初始重合及非法输入;网格扫描是其中一项,不能把 74,529 组样本再说成同等数量的独立测试。耗时只覆盖测试段,不包含构建和进程启动,也不作为武器系统吞吐结论。

这组小整数扫查不能证明所有近相切浮点输入都分类正确。实现限制位置和速度分量绝对值不超过一百万,弹速范围为百万分之一到一百万,以限制输入尺度;它仍不是任意尺度的鲁棒几何库。判别式在浮点误差内越过零时可能改变分类,当前代码不把负值强行钳成零。需要覆盖擦边命中时,应把目标半径和误差预算写入碰撞合同,补充相应验证。

炮台现在拿到的是“在匀速假设下最早何时能碰到目标”,是否开火仍属于武器系统。弹丸寿命若只有 8 秒,开篇的 10 秒解就必须被拒绝;目标速度取自旧快照时,也应先把位置推进到共同的发射时刻,再交给求解器。障碍物、炮口转动时间、目标加速和弹丸重力都没有进入当前方程,不能凭一个正根宣布真实命中。把求解出的时间和瞄准点交给寿命、可达性与碰撞检查,提前量才有清楚的使用边界。