每日算法 — 使用java实现空洞骑士:平台物理碰撞与房间连通图遍历

《空洞骑士》以其精美的 2D 平台关卡和复杂的地图连通结构闻名。本文用 Java 实现一个简化版平台引擎,重点讲解AABB 与像素级平台碰撞检测、速度-加速度积分模拟,以及房间连通图的 BFS/DFS 遍历与能力锁进度规划。读者将掌握平台游戏物理的核心算法,并理解 Metroidvania 类型游戏的地图设计原理。

一、平台物理核心问题

2D 平台游戏的物理系统需要解决三个核心问题:

  1. 运动学模拟:根据重力、加速度和玩家输入,更新角色的速度与位置。
  2. 碰撞检测:判断角色与地形、平台、敌人是否发生重叠。
  3. 碰撞响应:将角色推出碰撞体,实现站立、头顶碰撞和墙面滑行。

此外,空洞骑士的地图由多个房间通过通道连接,形成连通图。玩家需要获得特定能力(如二段跳、冲刺、爬墙)才能进入某些区域,这构成了能力锁(Ability Lock)机制。

二、数据模型

import java.util.*;

/**
 * 2D 向量
 */
class Vec2 {
    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 mul(double s) { return new Vec2(x * s, y * s); }
}

/**
 * 轴对齐包围盒(AABB)
 */
class AABB {
    double x, y, w, h; // 左上角 x, y 与宽高
    public AABB(double x, double y, double w, double h) {
        this.x = x; this.y = y; this.w = w; this.h = h;
    }
    public double left()   { return x; }
    public double right()  { return x + w; }
    public double top()    { return y; }
    public double bottom() { return y + h; }

    public boolean intersects(AABB other) {
        return this.left() < other.right() && this.right() > other.left()
            && this.top() < other.bottom() && this.bottom() > other.top();
    }
}

/**
 * 平台瓦片/碰撞体
 */
class Platform {
    AABB bounds;
    boolean isOneWay; // 单向平台(只能从上往下穿过)
    public Platform(double x, double y, double w, double h, boolean isOneWay) {
        this.bounds = new AABB(x, y, w, h);
        this.isOneWay = isOneWay;
    }
}

/**
 * 玩家角色
 */
class Player {
    AABB bounds = new AABB(50, 100, 20, 30);
    Vec2 velocity = new Vec2(0, 0);
    boolean onGround = false;
    boolean canDoubleJump = false;
    boolean hasDoubleJump = false; // 能力锁:是否已获得二段跳
    boolean hasDash = false;       // 能力锁:是否已获得冲刺
}

/**
 * 房间
 */
class Room {
    int id;
    String name;
    List<Platform> platforms = new ArrayList<>();
    List<RoomExit> exits = new ArrayList<>(); // 通往其他房间的出口

    public Room(int id, String name) {
        this.id = id; this.name = name;
    }
}

/**
 * 房间出口
 */
class RoomExit {
    Room targetRoom;
    AABB triggerZone; // 触发区域
    Set<String> requiredAbilities = new HashSet<>(); // 需要的能力

    public RoomExit(Room targetRoom, AABB triggerZone) {
        this.targetRoom = targetRoom;
        this.triggerZone = triggerZone;
    }
}

三、平台物理与碰撞检测

3.1 运动学更新

每帧根据重力和输入更新速度,再用积分更新位置:

class PlatformPhysics {
    private static final double GRAVITY = 0.5;
    private static final double MAX_FALL_SPEED = 12.0;
    private static final double MOVE_ACCEL = 0.8;
    private static final double FRICTION = 0.7;

    /**
     * 更新玩家物理状态
     * @param player 玩家对象
     * @param inputX 水平输入(-1 左, 0 无, 1 右)
     * @param platforms 当前房间的所有平台
     */
    public void update(Player player, int inputX, List<Platform> platforms) {
        // 水平速度更新
        if (inputX != 0) {
            player.velocity.x += inputX * MOVE_ACCEL;
        } else {
            player.velocity.x *= FRICTION;
        }

        // 重力
        player.velocity.y += GRAVITY;
        if (player.velocity.y > MAX_FALL_SPEED) {
            player.velocity.y = MAX_FALL_SPEED;
        }

        // 位置积分(先 X 后 Y,分开处理以减少卡墙)
        moveX(player, platforms);
        moveY(player, platforms);
    }

    private void moveX(Player player, List<Platform> platforms) {
        player.bounds.x += player.velocity.x;
        for (Platform p : platforms) {
            if (player.bounds.intersects(p.bounds)) {
                if (player.velocity.x > 0) {
                    player.bounds.x = p.bounds.left() - player.bounds.w;
                } else if (player.velocity.x < 0) {
                    player.bounds.x = p.bounds.right();
                }
                player.velocity.x = 0;
            }
        }
    }

    private void moveY(Player player, List<Platform> platforms) {
        player.bounds.y += player.velocity.y;
        player.onGround = false;
        for (Platform p : platforms) {
            if (player.bounds.intersects(p.bounds)) {
                if (player.velocity.y > 0) { // 下落时碰撞
                    // 单向平台:只有当玩家之前在其上方才站立
                    if (p.isOneWay) {
                        double prevBottom = player.bounds.bottom() - player.velocity.y;
                        if (prevBottom > p.bounds.top() + 1) continue; // 从下方穿过
                    }
                    player.bounds.y = p.bounds.top() - player.bounds.h;
                    player.velocity.y = 0;
                    player.onGround = true;
                    player.canDoubleJump = player.hasDoubleJump;
                } else if (player.velocity.y < 0) { // 上升时碰撞(头顶)
                    player.bounds.y = p.bounds.bottom();
                    player.velocity.y = 0;
                }
            }
        }
    }

    /**
     * 跳跃
     */
    public void jump(Player player) {
        if (player.onGround) {
            player.velocity.y = -10;
            player.onGround = false;
        } else if (player.canDoubleJump) {
            player.velocity.y = -10;
            player.canDoubleJump = false;
        }
    }

    /**
     * 冲刺
     */
    public void dash(Player player, int direction) {
        if (player.hasDash) {
            player.velocity.x = direction * 15;
            player.velocity.y = 0; // 冲刺时忽略重力一帧
        }
    }
}

四、房间连通图与能力锁遍历

空洞骑士的地图本质上是一个房间连通图,某些通道需要特定能力(如”螳螂爪”爬墙、”帝王之翼”二段跳)才能通过。我们将其建模为带约束的图遍历问题:

class WorldMap {
    Map<Integer, Room> rooms = new HashMap<>();

    /**
     * 根据玩家当前已解锁的能力,计算所有可达房间
     */
    public Set<Room> getReachableRooms(Room start, Set<String> abilities) {
        Set<Room> visited = new HashSet<>();
        Queue<Room> queue = new LinkedList<>();
        queue.add(start);
        visited.add(start);

        while (!queue.isEmpty()) {
            Room current = queue.poll();
            for (RoomExit exit : current.exits) {
                // 检查是否满足能力要求
                boolean canPass = abilities.containsAll(exit.requiredAbilities);
                if (canPass && !visited.contains(exit.targetRoom)) {
                    visited.add(exit.targetRoom);
                    queue.add(exit.targetRoom);
                }
            }
        }
        return visited;
    }

    /**
     * 计算到达目标房间所需的最少能力集合(简化版)
     * 实际游戏中可通过逆向 BFS + 能力集合的位掩码 DP 求解
     */
    public List<String> findRequiredAbilities(Room start, Room target) {
        // BFS,状态为 (当前房间, 已收集能力集合)
        // 为简化,这里仅做正向搜索直到找到目标
        Set<String> allAbilities = new HashSet<>(Arrays.asList("DASH", "DOUBLE_JUMP", "WALL_JUMP", "SUPER_DASH"));

        Queue<Map.Entry<Room, Set<String>>> queue = new LinkedList<>();
        queue.add(new AbstractMap.SimpleEntry<>(start, new HashSet<>()));
        Set<String> visitedStates = new HashSet<>();
        visitedStates.add(stateKey(start, new HashSet<>()));

        while (!queue.isEmpty()) {
            Map.Entry<Room, Set<String>> state = queue.poll();
            Room room = state.getKey();
            Set<String> abils = state.getValue();

            if (room == target) {
                return new ArrayList<>(abils);
            }

            for (RoomExit exit : room.exits) {
                Set<String> nextAbils = new HashSet<>(abils);
                nextAbils.addAll(exit.requiredAbilities);
                String key = stateKey(exit.targetRoom, nextAbils);
                if (!visitedStates.contains(key)) {
                    visitedStates.add(key);
                    queue.add(new AbstractMap.SimpleEntry<>(exit.targetRoom, nextAbils));
                }
            }
        }
        return null; // 不可达
    }

    private String stateKey(Room room, Set<String> abils) {
        List<String> sorted = new ArrayList<>(abils);
        Collections.sort(sorted);
        return room.id + "|" + String.join(",", sorted);
    }
}

五、完整运行示例

public class HollowKnightDemo {
    public static void main(String[] args) {
        // 创建房间
        Room hall = new Room(0, "Forgotten Crossroads");
        Room pit = new Room(1, "Ancestral Mound");
        Room peak = new Room(2, "Crystal Peak");

        // 构建平台
        hall.platforms.add(new Platform(0, 200, 400, 20, false));
        hall.platforms.add(new Platform(150, 150, 80, 10, true));
        pit.platforms.add(new Platform(0, 200, 300, 20, false));
        peak.platforms.add(new Platform(0, 200, 500, 20, false));

        // 构建连通图
        RoomExit toPit = new RoomExit(pit, new AABB(380, 180, 20, 20));
        toPit.requiredAbilities.add("DOUBLE_JUMP"); // 需要二段跳才能到达深坑
        hall.exits.add(toPit);

        RoomExit toPeak = new RoomExit(peak, new AABB(0, 180, 20, 20));
        toPeak.requiredAbilities.add("DASH");
        hall.exits.add(toPeak);

        WorldMap map = new WorldMap();
        map.rooms.put(0, hall);
        map.rooms.put(1, pit);
        map.rooms.put(2, peak);

        // 模拟 1:无能力时
        Set<String> noAbils = new HashSet<>();
        System.out.println("Reachable without abilities: " +
            map.getReachableRooms(hall, noAbils).stream().map(r -> r.name).toList());

        // 模拟 2:拥有二段跳
        Set<String> withDJ = new HashSet<>(Collections.singletonList("DOUBLE_JUMP"));
        System.out.println("Reachable with double jump: " +
            map.getReachableRooms(hall, withDJ).stream().map(r -> r.name).toList());

        // 物理演示
        Player player = new Player();
        player.hasDoubleJump = true;
        PlatformPhysics physics = new PlatformPhysics();

        for (int frame = 0; frame < 60; frame++) {
            physics.update(player, 0, hall.platforms);
            if (frame == 10) physics.jump(player);
            if (frame == 25 && !player.onGround) physics.jump(player); // 二段跳
            if (frame % 10 == 0) {
                System.out.printf("Frame %d: pos=(%.1f,%.1f) vel=(%.1f,%.1f) ground=%b%n",
                    frame, player.bounds.x, player.bounds.y,
                    player.velocity.x, player.velocity.y, player.onGround);
            }
        }
    }
}

六、复杂度分析

  • 碰撞检测:每帧 $O(P)$,$P$ 为当前房间平台数。可用空间哈希(如均匀网格)优化至 $O(1)$ 均摊。
  • 房间图遍历:标准 BFS $O(R + E)$,$R$ 为房间数,$E$ 为出口数。
  • 能力锁 BFS:状态空间为 $R \times 2^A$,$A$ 为能力种类数。对少量能力可用位掩码优化。

七、总结

本文实现了空洞骑士的核心平台物理与地图遍历系统。关键算法在于分离轴碰撞响应(先 X 后 Y)和带能力约束的图 BFS。平台游戏的物理精度直接影响手感,而 Metroidvania 的地图设计本质上是有向图上带约束的可达性问题。读者可以在此基础上扩展斜坡移动、藤蔓摆动、以及更复杂的敌人 AI 巡逻路径。