《空洞骑士》以其精美的 2D 平台关卡和复杂的地图连通结构闻名。本文用 Java 实现一个简化版平台引擎,重点讲解AABB 与像素级平台碰撞检测、速度-加速度积分模拟,以及房间连通图的 BFS/DFS 遍历与能力锁进度规划。读者将掌握平台游戏物理的核心算法,并理解 Metroidvania 类型游戏的地图设计原理。
一、平台物理核心问题
2D 平台游戏的物理系统需要解决三个核心问题:
- 运动学模拟:根据重力、加速度和玩家输入,更新角色的速度与位置。
- 碰撞检测:判断角色与地形、平台、敌人是否发生重叠。
- 碰撞响应:将角色推出碰撞体,实现站立、头顶碰撞和墙面滑行。
此外,空洞骑士的地图由多个房间通过通道连接,形成连通图。玩家需要获得特定能力(如二段跳、冲刺、爬墙)才能进入某些区域,这构成了能力锁(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 巡逻路径。