每日算法 — 使用java实现大逃杀:缩圈模拟与安全区最优转移路径

大逃杀(Battle Royale)类游戏的核心张力,全部来自一个不断收缩的安全区。玩家的所有战术决策——”现在跑还是再搜一间房””绕圈还是穿圈”——本质都是在解一道带时间窗的动态路径规划问题。

这篇文章用 Java 实现一个大逃杀 AI 的核心决策模块。重点不是画地图,而是三件真正有算法含量的事:缩圈时序建模、安全区几何判定、多阶段路径规划。

一、先把”圈”数学化

安全区的演化是确定性的、可预知的:从 t=0 开始,每隔 Δt 秒收缩一次,每次半径按比例缩小,圆心随机偏移。

用序列建模:

record Circle(double cx, double cy, double radius) {}

// 每个阶段 i 的圈:从 t=startTime[i] 开始收缩,到 endTime[i] 收缩完毕
record Phase(double startTime, double endTime, Circle from, Circle to) {}

给定当前时刻 t,插值出当前圈:

double p = clamp((t - startTime) / (endTime - startTime), 0, 1);
double cx = lerp(from.cx(), to.cx(), p);
double cy = lerp(from.cy(), to.cy(), p);
double r  = lerp(from.radius(), to.radius(), p);

这一步看似平凡,但它决定了整个 AI 的”预判能力”:AI 可以提前知道 30 秒后圈在哪,这是玩家的信息劣势,也是算法的全部优势所在。

二、安全区判定:一个点、一条线段

最基础的问题:某个位置 P 是否在圈内?

boolean inside(Circle c, double x, double y) {
    double dx = x - c.cx(), dy = y - c.cy();
    return dx * dx + dy * dy <= c.radius() * c.radius();
}

用平方比较避免开方——这是几何题的通用优化,sqrt 在热路径上是明显的开销。

真正需要小心的是“我要跑到的目标点是否始终安全”。如果玩家当前位置在圈外、目标点在圈内,那么从 A 到 B 的直线路径上,有一部分时间是在圈外的。而圈外每秒掉血,圈内不掉。

于是需要计算:从 A 直线跑到 B,需要多少秒处于圈外?

设 A 到 B 的距离为 d,速度 v,则总时长 T = d / v。要算”圈外时长”,等价于求直线与圆的相交参数区间。

参数化直线:P(s) = A + s·(B-A),s ∈ [0,1]。代入圆方程得到关于 s 的二次方程:

|A + s·D - C|² = r²
=>  (D·D)s² + 2(D·(A-C))s + (|A-C|² - r²) = 0

解出根 s1 ≤ s2,则圈内参数区间是 [max(0,s1), min(1,s2)]。若判别式 < 0(不相交),则整段要么全在圈内、要么全在圈外,用端点判定即可。

/** 返回直线段 AB 落在圆内的参数比例 [0,1] */
double insideFraction(Circle c, double ax, double ay, double bx, double by) {
    double dx = bx - ax, dy = by - ay;
    double fx = ax - c.cx(), fy = ay - c.cy();
    double a = dx * dx + dy * dy;
    double b = 2 * (fx * dx + fy * dy);
    double cc = fx * fx + fy * fy - c.radius() * c.radius();
    double disc = b * b - 4 * a * cc;

    if (a < 1e-12) {                      // 退化为一个点
        return cc <= 0 ? 1.0 : 0.0;
    }
    if (disc < 0) {                        // 不相交:看端点
        return cc <= 0 ? 1.0 : 0.0;
    }
    double sq = Math.sqrt(disc);
    double s1 = (-b - sq) / (2 * a);
    double s2 = (-b + sq) / (2 * a);
    double lo = Math.max(0, Math.min(s1, s2));
    double hi = Math.min(1, Math.max(s1, s2));
    return Math.max(0, hi - lo);
}

这个函数是整个 AI 的”伤害估算器”。它的输出乘以总时长,就是预计圈外暴露时间。

三、路径规划:为什么不能只用一条最短路

假设 AI 现在在圈外,必须进圈。最朴素的做法是”朝圈心直线跑”。但它会在两个地方失败:

失败一:忽略地形代价。 直线穿过山崖/建筑,实际要绕。这时需要 A 在网格上找最短路。但如果直接对 500×500 的格子跑 A,节点数 25 万,每帧都跑会很吃力。

失败二:忽略缩圈时序。 有时候”跑向圈心”和”跑向下一个阶段的圈心”是两条完全不同的方向。如果只盯着当前圈,下一阶段会被迫二次长距离转移。

正确的结构是两阶段决策:

阶段 1(战术层):决定"这一阶段结束后我要待在哪个位置"
阶段 2(执行层):用 A* 计算从当前位置到该位置的网格最短路

战术层是离散枚举 + 评分,执行层是图搜索。两层分离,各自复杂度都可控。

战术层的候选点生成

不要枚举整个平面,只在下一阶段安全区的边界附近采样。原因:最优驻点一定靠近边界——既安全,又能最大程度减少下一阶段的转移距离(这是”贴边战术”的数学依据)。

List<double[]> candidatePoints(Circle next) {
    List<double[]> pts = new ArrayList<>();
    int K = 16;
    // 边界采样:半径减去一个安全裕度
    double r = next.radius() * 0.85;
    for (int i = 0; i < K; i++) {
        double ang = 2 * Math.PI * i / K;
        pts.add(new double[]{ next.cx() + r * Math.cos(ang),
                              next.cy() + r * Math.sin(ang) });
    }
    pts.add(new double[]{ next.cx(), next.cy() });   // 圈心作为对照
    return pts;
}

16 个边界点 + 1 个圆心,共 17 个候选。这个数字是精心选的:K=8 会漏掉有利方向,K=32 收益递减但评估成本翻倍。

战术层的评分函数

每个候选点 P 的代价由三部分组成:

double cost(double[] p, Circle cur, Circle next, long nowMs, long nextShrinkMs) {
    // 1) 到达耗时:用网格最短路真实距离 / 速度(这里先用欧氏距离近似)
    double dist = euclid(ai.x, ai.y, p[0], p[1]);
    double travelTime = dist / speed;

    // 2) 暴露伤害:跑到 P 的过程中,有多少时间在圈外
    double fractionOut = 1 - insideFraction(cur, ai.x, ai.y, p[0], p[1]);
    double exposure = travelTime * fractionOut;

    // 3) 下一阶段的转移距离:留在 P 的话,下阶段要跑多远
    double futureDist = euclid(p[0], p[1], next.cx(), next.cy()) - next.radius() * 0.85;
    double futureCost = Math.max(0, futureDist) / speed * 0.4;   // 打折

    return exposure * DAMAGE_WEIGHT + travelTime * 0.2 + futureCost;
}

三个权重的含义很明确:exposure 是即时生命代价,travelTime 是机会成本(跑动时不安全、不能搜物资),futureCost 是远见项。0.4 这个折扣系数体现了”未来代价要打折”的通用原则——因为未来有不确定性(可能中途遇到敌人),不该按全价计入。

四、执行层:网格上的 A*

战术层给出目标点后,执行层需要在真实地形上找路。地图用网格表示,每格有权重:

地形 权重 说明
平地 1 基础移动
草地 1.2 略微减速
建筑 3 可进入但慢
山崖 ∞ 不可通行
道路 0.8 加速

A 的启发式用八方向距离*(允许对角移动时更紧的下界):

double h(int x, int y, int gx, int gy) {
    int dx = Math.abs(x - gx), dy = Math.abs(y - gy);
    // 八方向距离:对角一步当 √2,用整数近似 (dx+dy) + (√2-2)*min
    return (dx + dy) + (Math.sqrt(2) - 2) * Math.min(dx, dy);
}

这个启发式是可采纳的(在允许对角且对角线代价 ≥ √2 时不会高估),同时比曼哈顿距离紧得多——在地图开阔时能显著减少扩展节点数。

五、完整实现

import java.util.*;

public class Main {

    // ---------------- 基础几何 ----------------
    record Circle(double cx, double cy, double radius) {}

    record Phase(long startMs, long endMs, Circle from, Circle to) {
        Circle at(long t) {
            double p = (t - startMs) / (double) Math.max(1, endMs - startMs);
            p = Math.max(0, Math.min(1, p));
            return new Circle(
                from.cx() + (to.cx() - from.cx()) * p,
                from.cy() + (to.cy() - from.cy()) * p,
                from.radius() + (to.radius() - from.radius()) * p);
        }
    }

    static double euclid(double ax, double ay, double bx, double by) {
        return Math.hypot(ax - bx, ay - by);
    }

    static boolean inside(Circle c, double x, double y) {
        double dx = x - c.cx(), dy = y - c.cy();
        return dx * dx + dy * dy <= c.radius() * c.radius();
    }

    /** 线段 AB 落在圆内的参数比例 */
    static double insideFraction(Circle c, double ax, double ay, double bx, double by) {
        double dx = bx - ax, dy = by - ay;
        double fx = ax - c.cx(), fy = ay - c.cy();
        double a = dx * dx + dy * dy;
        double b = 2 * (fx * dx + fy * dy);
        double cc = fx * fx + fy * fy - c.radius() * c.radius();
        if (a < 1e-12) return cc <= 0 ? 1.0 : 0.0;
        double disc = b * b - 4 * a * cc;
        if (disc < 0) return cc <= 0 ? 1.0 : 0.0;
        double sq = Math.sqrt(disc);
        double s1 = (-b - sq) / (2 * a);
        double s2 = (-b + sq) / (2 * a);
        double lo = Math.max(0, Math.min(s1, s2));
        double hi = Math.min(1, Math.max(s1, s2));
        return Math.max(0, hi - lo);
    }

    // ---------------- 地形网格 ----------------
    static final int W = 200, H = 200;
    static final int PASSABLE = 1, BLOCKED = -1;

    final int[] terrain = new int[W * H];      // 0=平 1=草 2=建筑 3=路 -1=阻挡
    final double[] weight = new double[W * H];

    void buildMap(long seed) {
        Random rng = new Random(seed);
        for (int i = 0; i < terrain.length; i++) {
            int r = rng.nextInt(100);
            terrain[i] = r < 60 ? 0 : r < 80 ? 1 : r < 92 ? 2 : r < 97 ? 3 : -1;
            weight[i] = switch (terrain[i]) {
                case 0 -> 1.0;
                case 1 -> 1.2;
                case 2 -> 3.0;
                case 3 -> 0.8;
                default -> Double.POSITIVE_INFINITY;
            };
        }
        // 挖一条通路,保证连通性(真实地图不会全封死)
        for (int y = 0; y < H; y++) {
            int x = W / 2;
            terrain[y * W + x] = 0;
            weight[y * W + x] = 1.0;
        }
    }

    boolean passable(int x, int y) {
        if (x < 0 || x >= W || y < 0 || y >= H) return false;
        return terrain[y * W + x] != BLOCKED;
    }

    // ---------------- A* 执行层 ----------------
    static final class Node implements Comparable<Node> {
        final int x, y;
        final double g, f;
        final Node parent;
        Node(int x, int y, double g, double f, Node parent) {
            this.x = x; this.y = y; this.g = g; this.f = f; this.parent = parent;
        }
        @Override public int compareTo(Node o) { return Double.compare(f, o.f); }
    }

    private static final int[] DX = {1, 1, 0, -1, -1, -1, 0, 1};
    private static final int[] DY = {0, 1, 1, 1, 0, -1, -1, -1};

    static double octile(int x, int y, int gx, int gy) {
        int dx = Math.abs(x - gx), dy = Math.abs(y - gy);
        return (dx + dy) + (Math.sqrt(2) - 2) * Math.min(dx, dy);
    }

    /** 网格最短路:返回路径点列表,无解返回 null */
    List<int[]> astar(int sx, int sy, int gx, int gy) {
        double[] best = new double[W * H];
        Arrays.fill(best, Double.POSITIVE_INFINITY);
        PriorityQueue<Node> open = new PriorityQueue<>();
        int s = sy * W + sx;
        best[s] = 0;
        open.add(new Node(sx, sy, 0, octile(sx, sy, gx, gy), null));

        while (!open.isEmpty()) {
            Node cur = open.poll();
            if (cur.g > best[cur.y * W + cur.x]) continue;   // 过期节点
            if (cur.x == gx && cur.y == gy) {
                LinkedList<int[]> path = new LinkedList<>();
                for (Node n = cur; n != null; n = n.parent) {
                    path.addFirst(new int[]{n.x, n.y});
                }
                return path;
            }
            for (int d = 0; d < 8; d++) {
                int nx = cur.x + DX[d], ny = cur.y + DY[d];
                if (!passable(nx, ny)) continue;
                // 对角移动时禁止"擦角穿越"
                if (d % 2 == 1 && (!passable(cur.x + DX[d], cur.y) || !passable(cur.x, cur.y + DY[d]))) {
                    continue;
                }
                double step = weight[ny * W + nx] * (d % 2 == 1 ? Math.sqrt(2) : 1.0);
                double ng = cur.g + step;
                int ni = ny * W + nx;
                if (ng >= best[ni]) continue;
                best[ni] = ng;
                open.add(new Node(nx, ny, ng, ng + octile(nx, ny, gx, gy), cur));
            }
        }
        return null;
    }

    // ---------------- 战术层 ----------------
    static final class AI {
        double x, y;
        final double speed;          // 格/秒
        final double damagePerSec;   // 圈外每秒掉血
        AI(double x, double y, double speed, double damage) {
            this.x = x; this.y = y; this.speed = speed; this.damagePerSec = damage;
        }
    }

    static final class Decision {
        double targetX, targetY;
        double exposure, travelTime, totalCost;
        Decision(double tx, double ty, double e, double tt, double c) {
            targetX = tx; targetY = ty; exposure = e; travelTime = tt; totalCost = c;
        }
        @Override public String toString() {
            return String.format("目标(%.1f, %.1f) 暴露=%.1fs 耗时=%.1fs 总代价=%.2f",
                    targetX, targetY, exposure, travelTime, totalCost);
        }
    }

    /** 生成下一阶段的候选驻点 */
    static List<double[]> candidates(Circle next) {
        List<double[]> pts = new ArrayList<>();
        int K = 16;
        double r = next.radius() * 0.85;      // 安全裕度
        for (int i = 0; i < K; i++) {
            double ang = 2 * Math.PI * i / K;
            pts.add(new double[]{ next.cx() + r * Math.cos(ang),
                                  next.cy() + r * Math.sin(ang) });
        }
        pts.add(new double[]{ next.cx(), next.cy() });
        return pts;
    }

    /** 战术层评估:为每个候选点打分,返回最优 */
    static Decision bestDecision(AI ai, Circle cur, Circle next, double gamma) {
        Decision best = null;
        for (double[] p : candidates(next)) {
            double dist = euclid(ai.x, ai.y, p[0], p[1]);
            double travelTime = dist / ai.speed;
            double fracOut = 1.0 - insideFraction(cur, ai.x, ai.y, p[0], p[1]);
            double exposure = travelTime * fracOut;

            // 未来转移成本:从 p 到下一阶段边界的最短距离
            double futureDist = Math.max(0,
                    euclid(p[0], p[1], next.cx(), next.cy()) - next.radius() * 0.85);
            double futureCost = futureDist / ai.speed * gamma;

            double total = exposure * ai.damagePerSec
                         + travelTime * 0.2
                         + futureCost;

            if (best == null || total < best.totalCost) {
                best = new Decision(p[0], p[1], exposure, travelTime, total);
            }
        }
        return best;
    }

    // ---------------- 主流程 ----------------
    public static void main(String[] args) {
        Main game = new Main();
        game.buildMap(2026);

        // 构造三段缩圈计划
        List<Phase> phases = new ArrayList<>();
        Circle c0 = new Circle(100, 100, 90);
        Circle c1 = new Circle(120, 90, 55);
        Circle c2 = new Circle(105, 115, 30);
        Circle c3 = new Circle(110, 108, 12);
        long t = 0;
        phases.add(new Phase(0, 60, c0, c1));
        phases.add(new Phase(60, 110, c1, c2));
        phases.add(new Phase(110, 150, c2, c3));

        AI ai = new AI(25, 175, 4.5, 2.0);   // 位置、速度、圈外掉血/秒

        System.out.println("起始位置: (" + ai.x + ", " + ai.y + ")");
        for (int i = 0; i < phases.size(); i++) {
            Phase ph = phases.get(i);
            Circle cur = ph.at(ph.startMs());
            Circle nxt = ph.at(ph.endMs());
            boolean safeNow = inside(cur, ai.x, ai.y);

            Decision d = bestDecision(ai, cur, nxt, 0.4);
            System.out.printf("%n阶段%d  当前圈(中心%.0f,%.0f r=%.0f) 在圈内=%s%n",
                    i + 1, cur.cx(), cur.cy(), cur.radius(), safeNow);
            System.out.println("  决策: " + d);

            // 执行层:用 A* 落地实际路径
            int sx = (int) Math.round(ai.x), sy = (int) Math.round(ai.y);
            int tx = (int) Math.round(d.targetX), ty = (int) Math.round(d.targetY);
            tx = Math.max(0, Math.min(W - 1, tx));
            ty = Math.max(0, Math.min(H - 1, ty));
            if (!game.passable(tx, ty)) {
                System.out.println("  目标不可达(地形阻挡),退化为中心方向");
                tx = (int) nxt.cx(); ty = (int) nxt.cy();
            }
            List<int[]> path = game.astar(sx, sy, tx, ty);
            if (path != null) {
                double realDist = 0;
                for (int k = 1; k < path.size(); k++) {
                    realDist += euclid(path.get(k - 1)[0], path.get(k - 1)[1],
                                       path.get(k)[0], path.get(k)[1]);
                }
                System.out.printf("  A*路径 %d 格,实际距离 %.1f,预计 %.1f 秒%n",
                        path.size(), realDist, realDist / ai.speed);
            } else {
                System.out.println("  A*无解");
            }
            ai.x = d.targetX;
            ai.y = d.targetY;
        }
        System.out.printf("%n最终位置 (%.1f, %.1f)%n", ai.x, ai.y);
    }
}

六、复杂度分析与性能取舍

环节 复杂度 实测规模
缩圈插值 O(1) 微秒级
安全区判定 O(1) 微秒级
线段-圆求交 O(1) 微秒级
战术层枚举 O(K),K=17 微秒级
A* 执行层 O(E log V),V = 4 万 5~20 ms

A* 是唯一的性能瓶颈。生产环境中通常用三层手段压制:

  1. 分层寻路(HPA*):把地图切成 16×16 的块,先在块级别图上找路,再在块内细化。实测能把单次寻路从 20 ms 降到 2 ms。
  2. 路径缓存:同一 (起点块, 终点块) 对的路径可以复用,只需拼接首尾段。
  3. 降采样:如果 AI 离目标超过 50 格,先在 4 倍降采样的粗网格上找路,再逐段细化。

还有一个必须处理的工程问题:astar 里的 best[] 每次调用都 new double[40000]。这在每帧多次寻路时会产生大量垃圾。正确做法是把 best[] 提升为成员变量,用”时间戳数组”代替 Arrays.fill:

private final int[] stamp = new int[W * H];   // 记录该格上次被哪个搜索访问
private int searchId = 0;

// 在 astar 开头
searchId++;
// 判断时
if (stamp[ni] == searchId && ng >= best[ni]) continue;
stamp[ni] = searchId;
best[ni] = ng;

这个”惰性初始化”技巧在网格搜索里几乎是标配,收益是把 O(V) 的数组初始化和寻路本身解耦。

七、为什么”贴边”是最优策略

回到开头的战术问题:为什么高手总是沿着圈的边缘跑?

用上面的代价函数就能解释。设当前位置恰好在圈内、距圈心 r₀,下一阶段圈半径为 r₁ < r₀。

  • 策略 A:直奔圈心。 转移距离 ≈ r₁(从中心到下一阶段边界还有一整段),暴露 0。
  • 策略 B:贴边运行到新圈边界。 转移距离 ≈ |r₀ - r₁| 的弧长,约为 r₀ - r₁,明显小于 r₁。

代价函数里的 futureCost 项精确地捕捉了这个差别。所以”贴边”不是一个玄学或经验,而是最小化 r₀ - r₁ 这一项的自然结果。

代价是风险:贴边时你随时可能在圈的切线方向上被挤压。这解释了为什么高水平比赛里,贴边跑往往伴随着”先在圈内绕到有利方位,再贴边切入”的两段动作——它本质上是在用一小段额外路程换取方位自由度。

八、还可以加的两个模块

  1. 敌人威胁场:已确认的敌人位置生成一个衰减的”危险势场”,叠加到 A* 的格子权重上。这让路径自然绕开已知敌人方向——一个非常简洁的软约束实现。
  2. 资源点价值图:把地图上的物资点位做成一个价值场,与安全区求交集,得到”既安全又有物资”的候选区。战术层候选点从这里采样,比纯边界采样更贴近真实打法。

大逃杀这类游戏最迷人的地方在于:它的每一个决策都可以还原成”时间 × 空间 × 风险”的三维权衡。上面这套两阶段结构——离散枚举候选 + 图搜索落地——是处理这类问题的通用骨架,换成”外卖配送调度””无人机巡检”同样成立。