Flyby 巡航录制(debug/flyby)
基本信息
| 属性 | 值 |
|---|---|
| 包 | com.gtnewhorizons.angelica.debug.flyby |
| 本条目覆盖 | 5 个文件(目录共 7 个;FlybyFallGuard、FlybyRunner 已被 AngelicaCommand 与 调试叠加层 提及) |
| 总行数 | 117 + 91 + 54 + 30 + 15 = 307 |
| 入口命令 | /angelica flyby <route>|cancel [length] [blocksPerTick] |
| # | 文件 | 行数 | 可见性 | 角色 |
|---|---|---|---|---|
| 1 | FlybyRoute.java |
117 | public enum |
4 条确定性路线的定义 |
| 2 | FlybyScene.java |
91 | 包私有 final |
场景脚本加载 + 标记计数 |
| 3 | FlybyCommandSender.java |
54 | 包私有 final |
ICommandSender 适配器 |
| 4 | FlybyTerrain.java |
30 | 包私有 final |
航线地板高度求值 |
| 5 | FlybyOrigin.java |
15 | 包私有 record |
起点坐标解析 |
⚠️ 5 个文件里 4 个是包私有。整个 flyby 子系统对外只暴露 FlybyRoute 这一个 public 枚举(供 /angelica 命令做补全与解析),执行逻辑全部内部。
FlybyRoute:4 条路线
117 行,public enum。类注释(:3-7):
A deterministic camera path, expressed relative to the position the player held when the run was started. Moving routes are measured in blocks rather than tick.
⚠️ 关键语义:路径是相对于「启动时玩家所在位置」表达的(expressed relative to the position the player held when the run was started),且移动量以「方块」而非「tick」为单位。这保证不同 tickrate 下同一条路线走过的距离一致 → 可跨机器对比。
4 个枚举常量(:12、:17、:22、:25)
| 常量 | id | Kind |
blocksPerTick |
degreesPerTick |
defaultLength |
行 |
|---|---|---|---|---|---|---|
STRAIGHT |
"straight" |
STRAIGHT |
0.5D |
0.0D |
512 | :12 |
PAN |
"pan" |
ROTATE |
0.0D |
1.5D |
720 | :17 |
CIRCUIT |
"circuit" |
SQUARE |
0.5D |
4.0D |
128 | :22 |
STATIC |
"static" |
STILL |
0.0D |
0.0D |
600 | :25 |
4 条路线的可验算量
⚠️ 注释里的数字都可验算:
| 常量 | 注释原文(:10、:15、:20、:24) |
验算 |
|---|---|---|
STRAIGHT |
Straight line path, 512 blocks (32 chunks) by default |
512 / 16 = 32 ✓ 每 tick 0.5 格 → 1024 tick 走完 |
PAN |
Stationary rotation, 720 degrees by default |
720 / 1.5 = 480 tick,即 24 秒(20 TPS)转 2 圈 |
CIRCUIT |
Closed square: four legs of {@code length} blocks joined by four 90-degree turns, ending where it started |
128 × 4 = 512 格周长;90 / 4.0 = 22.5 tick/拐角 |
STATIC |
Fixed vantage, no motion, measured in ticks |
600 tick = 30 秒(20 TPS) |
⚠️ CIRCUIT 的 degreesPerTick = 4.0 与 STRAIGHT 的 blocksPerTick = 0.5 是独立维度 —— 一个枚举同时携带「线速度」和「角速度」,两者都非零时按哪个算取决于 Kind。Kind.SQUARE 用角速度控制转弯。
⚠️ CIRCUIT 的注释写「four legs of length blocks」但 defaultLength = 128 —— defaultLength 是「每条边」还是「总长」需看实现。注释说「每条边 128」,而枚举字段名是 defaultLength 无歧义提示。本条目未读 FlybyRoute 的路径生成方法(:61 之后),不下结论。
⚠️ PAN 的 720 度是 2 整圈,defaultLength = 720 与 degreesPerTick = 1.5 的组合恰好闭合。若用户改 blocksPerTick 参数,PAN 的 blocksPerTick = 0.0 意味着改它无效 —— 角路线只认 degreesPerTick。
嵌套枚举与常量(:27-30)
public enum Kind { STRAIGHT, ROTATE, SQUARE, STILL } // :27
public static final int CIRCUIT_LEGS = 4; // :29
public static final double CIRCUIT_TURN_DEGREES = 90.0D; // :30
⚠️ CIRCUIT_LEGS = 4 与 CIRCUIT_TURN_DEGREES = 90.0 是「正方形」的定义(4 条边 × 90° 拐角 = 360° 闭合)。两者必须同时为 4 与 90 才闭合 —— 改一个不改另一个,正方形不再闭合(路径不回到起点),且源码无断言。
⚠️ Kind 是 public 嵌套枚举(:27),与外层 4 个常量的 Kind 值一一对应(STRAIGHT→STRAIGHT、PAN→ROTATE、CIRCUIT→SQUARE、STATIC→STILL)—— PAN 对应 ROTATE、CIRCUIT 对应 SQUARE、STATIC 对应 STILL,名字全不一样。这是历史命名(路线名是用户看到的 id,Kind 是实现分类),不是笔误,但极易读错。
⚠️ CIRCUIT_LEGS / CIRCUIT_TURN_DEGREES 是 public static final —— 外部 mod 可读,但没有 setter,无法参数化。
方法(:46-60 已确认)
| 方法 | 行 |
|---|---|
id() |
:46-48 |
kind() |
:50-52 |
blocksPerTick() |
:54-56 |
degreesPerTick() |
:58-60 |
⚠️ 全是 getter,无 setter —— 枚举不可变。FlybyRoute.ids()(:42 附近,被命令用)本条目未读到实现,推测是拼接 id 列表。
构造器私有(:38-44),5 个 final 字段。
FlybyOrigin:起点 record
15 行,Java record:
record FlybyOrigin(double x, double z, float yaw) { // :3
static FlybyOrigin parse(String raw) { // :5
if (raw == null || raw.isBlank()) return null; // :6
final String[] parts = raw.split(",", -1); // :7
if (parts.length != 2 && parts.length != 3) throw new IllegalArgumentException("Expected x,z[,yaw] but got '" + raw + "'"); // :8
try {
return new FlybyOrigin(Double.parseDouble(parts[0].trim()),
Double.parseDouble(parts[1].trim()),
parts.length == 3 ? Float.parseFloat(parts[2].trim()) : 0.0F); // :10
} catch (NumberFormatException e) {
throw new IllegalArgumentException("Expected x,z[,yaw] but got '" + raw + "'", e); // :12
}
}
}
⚠️ 没有 Y 坐标 —— record 只有 x / z / yaw。Y 由地形求值决定(FlybyTerrain.routeFloor)。这是有意的:巡航高度跟随地形。
⚠️ split(",", -1) 的 -1 保留尾部空串(:7)。对 "1,2," 会得到 ["1","2",""](长度 3),然后 Float.parseFloat("") 抛 NumberFormatException → 被包成 IllegalArgumentException。即尾部逗号会报错而非被忽略。 对 "1,2,,," 长度 5 → 走 :8 的长度检查 → 报错。两种错误的消息文本相同(Expected x,z[,yaw] but got '...'),无法区分「长度错」与「格式错」。
⚠️ 两处抛 IllegalArgumentException 用同一句消息(:8、:12)—— 调试时无法判断是哪一类。
⚠️ raw.isBlank()(:6)要求 Java 11+。空串与全空白串返回 null(不是抛异常)—— 调用方需判 null。
⚠️ yaw 缺省 0.0F(:10)而非玩家当前 yaw —— 但类注释说「相对于启动时玩家位置」。若不传 yaw,起始朝向是 0 而非玩家朝向。 这与 x/z 来自玩家位置不一致(x/z 需外部传入,yaw 缺省为 0)。
⚠️ parse 是 static 无修饰符(包私有),record 本身也是包私有(record FlybyOrigin,无 public)。
FlybyTerrain:地板高度求值
30 行,包私有 final。
| 常量 | 值 | 行 |
|---|---|---|
CORRIDOR_RADIUS |
2 | :5 |
HOVER_BLOCKS |
2 | :6 |
routeFloor(:8-~28)
static int routeFloor(double[] pathX, double[] pathZ, int radius, IntBinaryOperator heights) {
int floor = Integer.MIN_VALUE; // :9
int prevCellX = 0, prevCellZ = 0;
for (int i = 0; i < pathX.length; i++) {
final int cellX = (int) Math.floor(pathX[i]); // :12
final int cellZ = (int) Math.floor(pathZ[i]);
if (i > 0 && cellX == prevCellX && cellZ == prevCellZ) continue; // :14
prevCellX = cellX; prevCellZ = cellZ;
for (int x = cellX - radius; x <= cellX + radius; x++) { ... } // :16
5 个设计点:
floor初值Integer.MIN_VALUE(:9) —— 取航线下方最高的地板(后续逻辑是Math.max)。Math.floor变 cell(:12-13) —— 路径点是 double,转成整数格子。- 跳过重复 cell(
:14) —— 相邻路径点落在同一格时只算一次,去重。 radius参数(:16)—— 走廊半径。⚠️CORRIDOR_RADIUS = 2是常量,但routeFloor接收radius参数 —— 常量是调用方传的默认值,不是内部用的。IntBinaryOperator heights参数 —— 地板高度由调用方注入的函数提供(大概率是「(x, z) → 高度」的 lambda)。⚠️ 本条目未读到heights.apply(...)的调用与floor的聚合方式(:16之后),「取最高」是从MIN_VALUE初值推断,未直接确认。
⚠️ prevCellX / prevCellZ 初值都是 0(:10-11),但 :14 有 i > 0 守卫,所以初值不影响。若去掉守卫,第一个 cell 是 (0,0) 时会误跳过。 守卫是必需的。
⚠️ pathX.length 作为循环上界(:11)但 pathZ 未校验长度 —— 两个数组长度不同时会 ArrayIndexOutOfBoundsException。无长度一致性检查。
⚠️ (int) Math.floor(...) 对超出 int 范围的 double 是饱和转换(Java 规范),不抛异常 —— 极端坐标下 cell 会被夹到 Integer.MAX_VALUE,后续循环 x - radius 可能溢出。无边界防护。
⚠️ HOVER_BLOCKS = 2(:6) —— 「悬空 2 格」,即相机在地板上空 2 格。常量定义在 :6 但本条目未读到它被使用的位置(可能在 FlybyRunner 里)。
FlybyScene:场景脚本与标记
91 行,包私有 final。
| 常量 | 值 | 行 |
|---|---|---|
NO_COMMANDS |
new String[0] |
:18 |
MARKER |
"flyby" |
:21 |
LOGGER |
getLogger("Angelica/Flyby") |
:17 |
⚠️ MARKER = "flyby" 是一个 NBT key(不是 modid、不是命令名)—— 用于标记「属于本次巡航的实体/方块实体」。
load(:23-45)—— 脚本解析
final List<String> lines;
try { lines = Files.readAllLines(Paths.get(path)); } // :26
catch (IOException e) { LOGGER.error("Could not read flyby scene '{}'", path, e); return NO_COMMANDS; } // :27-28
...
for (int i = 0; i < lines.size(); i++) {
String line = lines.get(i).trim(); // :35
if (line.isEmpty() || line.charAt(0) == '#') continue; // :36
if (line.charAt(0) == '/') line = line.substring(1).trim(); // :37
if (line.isEmpty()) continue; // :38
commands[n++] = line; // :39
}
5 条规则:
| 规则 | 行 | 说明 |
|---|---|---|
| 读整个文件 | :26 |
Files.readAllLines 一次性全读 |
| 读失败 | :27-28 |
LOGGER.error + 返回空数组(不抛) |
| 去空白 | :35 |
trim() |
| 跳过 | :36 |
空行 或 # 开头 |
| 剥斜杠 | :37 |
/ 开头则去掉(对齐游戏内命令语法) |
| 收尾 | :38 |
剥完再判空 |
⚠️ 无 try/catch 包裹 Paths.get(path)(:26)—— 非法路径会抛 InvalidPathException(unchecked),catch 只接 IOException。在某些平台上会逃出 load。
⚠️ # 注释与 / 命令都不支持行尾注释 —— setblock 5 5 5 stone # comment 会把 # comment 当命令参数传下去。
⚠️ 两遍分配(:32、:41-44):先按 lines.size() 分配满,再若有跳过则 System.arraycopy 到精确长度的 trimmed(:42-44)。⚠️ lines.size() + 注释行数 的情况下第一个数组被浪费 —— 小文件无所谓。
⚠️ commands 数组可能有未填充的尾部 null(:32 分配 lines.size(),:39 只填 n 个)—— :41 的 if (n == commands.length) return commands; 处理了「无跳过」的情况;有跳过时走 :42-44 的 trimmed,所以返回值永远无 null 尾。正确。
⚠️ Files.readAllLines 用平台默认字符集(:26)—— 脚本含非 ASCII 注释时在非 UTF-8 平台会乱码。无 Charset 参数。
count(:47-~60)
static int count(WorldServer world) {
final List<Entity> entities = world.loadedEntityList; // :48
for (...) { if (entity != null && !entity.isDead && entity.getEntityData().getBoolean(MARKER)) total++; } // :51
final List<TileEntity> tileEntities = world.loadedTileEntityList; // :55
for (...) { if (marked(tileEntities.get(i))) total++; }
⚠️ 直接遍历 world.loadedEntityList(:48) —— 这是 WorldServer 的可变 List,且遍历期间若有实体被加入会抛 ConcurrentModificationException。同样 :55 的 loadedTileEntityList。无快照、无同步。
⚠️ 实体的 marked 判定有 3 个条件(:51):entity != null && !entity.isDead && entity.getEntityData().getBoolean(MARKER)。方块实体只判 marked(...)(:57)(marked 方法本条目未读,推测处理 null + MARKER)。两者的判定严格程度不同。
FlybyCommandSender:命令发送者适配器
54 行,final class implements ICommandSender(1.7.10 的接口名是 ICommandSender,不是现代的 CommandSourceStack)。
| 字段 | 行 | 类型 |
|---|---|---|
LOGGER |
:16 |
getLogger("Angelica/Flyby") |
NAME |
:17 |
"Flyby" |
player |
:19 |
EntityPlayerMP |
anchor |
:20 |
ChunkCoordinates |
⚠️ anchor 是 ChunkCoordinates(区块坐标)而非方块坐标 —— 说明巡航的「锚」是区块粒度的(与 FlybyRunner 相关的区块加载策略)。
⚠️ NAME = "Flyby" 是给 ICommandSender.getName() 用的占位名 —— 巡航期间所有命令输出都以 “Flyby” 为来源。
⚠️ 实现 ICommandSender 意味着必须实现全部方法(1.7.10 的 ICommandSender 有 getEntityWorld / getPlayerCoordinates / addChatMessage / canCommandSenderUseCommand / getName 等)。哪些是真实实现、哪些是抛 UnsupportedOperationException 的占位,本条目未读(:22-54)。⚠️ canCommandSenderUseCommand 若返回 true,则巡航脚本可以执行任意命令 —— 这是有意的(/angelica flyby 的用途是场景录制),但配合「只受 requiresCheats 门控」(见 AngelicaCommand)值得单独评估权限。
已知问题 / 风险
- 5 个文件里 4 个包私有,仅
FlybyRoute是 public —— 外部 mod 无法复用巡航逻辑,只能用命令。 CIRCUIT_LEGS = 4与CIRCUIT_TURN_DEGREES = 90.0必须同时成立才闭合,源码无断言(:29-30)。- 路线 id 与
Kind名字全不一样(PAN→ROTATE、CIRCUIT→SQUARE、STATIC→STILL),极易读错。 CIRCUIT.defaultLength = 128是「每条边」还是「总长」本条目未确认(注释说每条边,实现未读)。FlybyOrigin无 Y 坐标,yaw缺省0.0F而非玩家朝向 —— 与「相对玩家位置」的注释不完全一致。FlybyOrigin.parse的两类错误用同一句消息(:8、:12),无法区分长度错与格式错;尾部逗号会报错而非忽略。FlybyTerrain.routeFloor不校验pathX/pathZ长度一致(:11),长度不同则越界。FlybyTerrain的 cell 转换无边界防护(:12-13),极端坐标下 int 饱和转换可能溢出。CORRIDOR_RADIUS/HOVER_BLOCKS是常量但routeFloor接收radius参数(:5-6、:8)—— 不是内部默认值,不可配置。FlybyScene.load的catch只接IOException(:27),Paths.get的InvalidPathException会逃出。FlybyScene.load无Charset参数(:26),非 ASCII 脚本在非 UTF-8 平台乱码。FlybyScene.count直接遍历WorldServer.loadedEntityList/loadedTileEntityList(:48、:55),遍历期间变更会ConcurrentModificationException。FlybyScene的实体与方块实体marked判定严格程度不同(:51有 null/dead 检查,:57依赖marked方法)。FlybyCommandSender的canCommandSenderUseCommand实现未读 —— 若为true则巡航脚本可执行任意命令,权限边界需单独评估。- 巡航路径以方块而非 tick 计量(类注释
:6)—— 但STATIC的长度是 tick(:25注释measured in ticks),两种单位混在同一枚举。 - 所有回复消息硬编码英文(
LOGGER与命令消息),无语言键。
相关条目
- AngelicaCommand -
/angelica flyby的入口与参数解析 - 调试叠加层 -
debug/的同包兄弟 - FPS / 性能剖析 - 巡航是基准测试场景,与 profiler 配套
- Celeritas(内嵌地形渲染引擎) - 巡航依赖区块加载
- KeyBinding 注册总表 - 巡航不占按键槽