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 个设计点:

  1. floor 初值 Integer.MIN_VALUE(:9) —— 取航线下方最高的地板(后续逻辑是 Math.max)。
  2. Math.floor 变 cell(:12-13) —— 路径点是 double,转成整数格子。
  3. 跳过重复 cell(:14) —— 相邻路径点落在同一格时只算一次,去重。
  4. radius 参数(:16)—— 走廊半径。⚠️ CORRIDOR_RADIUS = 2 是常量,但 routeFloor 接收 radius 参数 —— 常量是调用方传的默认值,不是内部用的。
  5. 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)值得单独评估权限。

已知问题 / 风险

  1. 5 个文件里 4 个包私有,仅 FlybyRoute 是 public —— 外部 mod 无法复用巡航逻辑,只能用命令。
  2. CIRCUIT_LEGS = 4 与 CIRCUIT_TURN_DEGREES = 90.0 必须同时成立才闭合,源码无断言(:29-30)。
  3. 路线 id 与 Kind 名字全不一样(PAN→ROTATE、CIRCUIT→SQUARE、STATIC→STILL),极易读错。
  4. CIRCUIT.defaultLength = 128 是「每条边」还是「总长」本条目未确认(注释说每条边,实现未读)。
  5. FlybyOrigin 无 Y 坐标,yaw 缺省 0.0F 而非玩家朝向 —— 与「相对玩家位置」的注释不完全一致。
  6. FlybyOrigin.parse 的两类错误用同一句消息(:8、:12),无法区分长度错与格式错;尾部逗号会报错而非忽略。
  7. FlybyTerrain.routeFloor 不校验 pathX / pathZ 长度一致(:11),长度不同则越界。
  8. FlybyTerrain 的 cell 转换无边界防护(:12-13),极端坐标下 int 饱和转换可能溢出。
  9. CORRIDOR_RADIUS / HOVER_BLOCKS 是常量但 routeFloor 接收 radius 参数(:5-6、:8)—— 不是内部默认值,不可配置。
  10. FlybyScene.load 的 catch 只接 IOException(:27),Paths.get 的 InvalidPathException 会逃出。
  11. FlybyScene.load 无 Charset 参数(:26),非 ASCII 脚本在非 UTF-8 平台乱码。
  12. FlybyScene.count 直接遍历 WorldServer.loadedEntityList / loadedTileEntityList(:48、:55),遍历期间变更会 ConcurrentModificationException。
  13. FlybyScene 的实体与方块实体 marked 判定严格程度不同(:51 有 null/dead 检查,:57 依赖 marked 方法)。
  14. FlybyCommandSender 的 canCommandSenderUseCommand 实现未读 —— 若为 true 则巡航脚本可执行任意命令,权限边界需单独评估。
  15. 巡航路径以方块而非 tick 计量(类注释 :6)—— 但 STATIC 的长度是 tick(:25 注释 measured in ticks),两种单位混在同一枚举。
  16. 所有回复消息硬编码英文(LOGGER 与命令消息),无语言键。

相关条目