跳转至

4.2 类型安全扩展机制

BFDamageContext 的核心字段只覆盖协议中最稳定、最通用的信息:伤害来源、标称伤害、命中速度、命中点、法线和穿深。但真实弹道系统经常需要携带额外数据,例如弹体质量、口径、引信延迟、弹种、爆炸当量或自定义标签。扩展机制就是为这些"核心字段之外,但仍属于本次命中上下文"的数据准备的。

为什么不用 Map

最直接的做法是给上下文塞一个 Map<String, Object>。协议没有这样做,因为它会把错误推迟到运行时很深的位置:武器侧写入 "mass",护甲侧读 "bullet_mass";武器侧写入 Float,护甲侧强转 Double;某个键没设置,读出来是 null,然后在管线中炸掉。

BallisticsFramework 使用 BFDamageExtensionKey<T> 作为扩展键。每个 key 同时携带三件信息:全局唯一的 ResourceLocation、值的 Java 类型、默认值工厂。容器的读写方法都以这个 key 为入口:

public <T> T get(BFDamageExtensionKey<T> key)
public <T> void set(BFDamageExtensionKey<T> key, T value)

这样一来,编译器能在调用点约束值类型。BFDamageExtensionKey<Float> 只能写入 Float,读取时也直接得到 Float,调用方不需要手动强转。

注册一个扩展 Key

扩展 key 通过 BFDamageExtensions.register 注册。推荐在你的 mod API 类或常量类中声明为 public static final,让武器侧和护甲侧共享同一个 key 实例。

public final class MyBallisticsKeys {
    public static final BFDamageExtensionKey<String> AMMO_TYPE =
        BFDamageExtensions.register(
            ResourceLocation.fromNamespaceAndPath("my_mod", "ammo_type"),
            String.class,
            () -> "unknown"
        );

    public static final BFDamageExtensionKey<Float> EXPLOSIVE_MASS =
        BFDamageExtensions.register(
            ResourceLocation.fromNamespaceAndPath("my_mod", "explosive_mass"),
            Float.class,
            () -> 0f
        );

    private MyBallisticsKeys() {}
}

ResourceLocation 的命名空间应该使用你的 mod id,路径使用语义清晰的小写名称。不要注册到 ballistics_framework 命名空间下,除非这个 key 是协议本身提供的标准字段。

默认值工厂

注册 key 时需要提供一个 Supplier<T> 作为默认值工厂。容器没有显式写入某个 key 时,get(key) 会调用这个工厂返回默认值。

float mass = ctx.extensions().get(BFDamageExtensions.MASS);

上面这行代码永远不会因为未设置而返回 null。若武器侧没有写入 MASS,协议预定义 key 会返回默认的 10f

默认值工厂每次读取缺省值时都会被调用,因此可变对象应谨慎使用。如果你注册的是自定义配置对象或其他可变类型,默认值工厂应返回新实例,而不是共享一个全局对象:

public record AmmoTags(String primary, String secondary) {}

public static final BFDamageExtensionKey<AmmoTags> TAGS =
    BFDamageExtensions.register(
        ResourceLocation.fromNamespaceAndPath("my_mod", "tags"),
        AmmoTags.class,
        () -> new AmmoTags("unknown", "none")
    );

在多数场景下,扩展值建议使用不可变或值语义清晰的类型,例如 FloatIntegerBooleanString、枚举或 record。

写入与读取

武器侧通常在构造上下文前创建扩展容器,写入本次命中的额外数据,再传给 Builder:

BFDamageExtensions exts = new BFDamageExtensions();
exts.set(BFDamageExtensions.CALIBER, 0.125f);
exts.set(BFDamageExtensions.MASS, 9.8f);
exts.set(MyBallisticsKeys.AMMO_TYPE, "apfsds");

BFDamageContext ctx = BFDamageContext.builder()
    .source(source)
    .baseDamage(40f)
    .penetration(360f)
    .extensions(exts)
    .build();

护甲侧在管线方法中读取:

@Override
public float modifyPenetration(BFDamageContext ctx) {
    String ammoType = ctx.extensions().get(MyBallisticsKeys.AMMO_TYPE);
    float pen = ctx.penetration();

    if ("heat".equals(ammoType) && hasReactiveArmor(ctx.hitPoint())) {
        return pen * 0.45f;
    }
    return pen;
}

这个读写过程不需要双方约定字符串常量,也不需要手动检查 null。真正需要共享的是 key 的定义本身。

协议预定义 Key

协议目前预定义了三个扩展 key:

Key 类型 默认值 含义
BFDamageExtensions.FUSE_DELAY Float 0f 引信延迟,单位秒。0 表示瞬发
BFDamageExtensions.CALIBER Float 0.1f 弹体口径,单位米。默认 100mm
BFDamageExtensions.MASS Float 10f 弹体质量,单位 kg。默认典型坦克炮穿甲弹量级

这些 key 只是标准化的常用字段,不参与协议核心管线的默认判定。也就是说,默认穿甲判定不会因为 MASS 更大就自动提高伤害,也不会因为 FUSE_DELAY 非零就自动延迟爆炸。它们的作用是让不同模组在需要时用同一种字段交换信息。

例如,武器侧写入口径和质量,护甲侧可以在 calculateFinalDamage 中根据质量估算残余动能;或者 handler 在 onPenetrated 中根据 FUSE_DELAY 决定是否生成延迟爆炸。

注册表如何防止冲突

BFDamageExtensions.register 内部会把 key 交给 TBExtensionKeyRegistry。注册表按 ResourceLocation#toString() 保存已经注册过的 id,如果重复注册同一个 id,会抛出 IllegalArgumentException

这能防止两个模组意外使用同一个 id 表示不同含义。例如,my_mod:ammo_type 只能注册一次。如果你把 key 定义为静态常量,正常类加载只会注册一次;如果多个类里复制粘贴了相同 id,就会在启动或首次加载时暴露错误。

重复注册检查只检查 id,不检查类型。因此不要在不同版本之间随意改变同一个 key 的类型。my_mod:explosive_mass 如果已经发布为 Float,后续版本就不应改成 Double 或自定义对象。需要新语义时,注册一个新 id。

类型安全的边界

扩展机制提供的是调用点的泛型约束,而不是完整的运行时序列化系统。BFDamageExtensionKey 保存了 Class<T> type,但当前容器的 set 方法主要依赖 Java 泛型在编译期约束类型。正常使用公共 API 时,错误类型很难写进去;如果通过原始类型、反射或未经检查的强转绕过泛型,仍然可能制造运行时错误。

因此最佳实践很简单:不要使用 raw type,不要把 BFDamageExtensionKey<?> 随意强转为别的类型,也不要把扩展容器当作通用动态对象系统。把 key 定义成清晰的常量,围绕这些常量读写即可。

与核心字段的分工

扩展字段不应该替代核心字段。凡是协议已经有明确字段的数据,都应写入对应字段:

数据 推荐位置
标称伤害 baseDamage
穿深 penetration
命中速度 hitVelocity
命中点 hitPoint
命中面法线 hitNormal
弹体质量、口径、弹种、引信 extensions

这样做能让所有协议参与者都读到最基础的信息。扩展字段适合表达"不是每个模组都需要,但愿意理解的模组可以额外利用"的数据。

生命周期与不可变性

BFDamageContext 是不可变 record,但 BFDamageExtensions 容器本身是可变的。通常推荐在 build() 之前完成扩展写入,之后把它当作本次命中的只读数据使用。

BFDamageExtensions exts = new BFDamageExtensions();
exts.set(MyKeys.AMMO_TYPE, "heat");

BFDamageContext ctx = BFDamageContext.builder()
    .source(source)
    .extensions(exts)
    .build();

// 推荐:此后不要再修改 exts

管线执行期间继续修改扩展容器虽然在技术上可行,但会让判定结果依赖调用顺序。例如 resolvePenetration 改了某个值,calculateFinalDamage 又读到修改后的值,其他开发者很难推断发生了什么。除非你在自己的模组内部完全控制读写顺序,否则应把扩展视为输入数据,而不是管线中的可变状态。

如果你需要复用一组基础扩展数据,可以使用 copy() 或拷贝构造器:

BFDamageExtensions base = new BFDamageExtensions();
base.set(MyBallisticsKeys.AMMO_TYPE, "apfsds");
base.set(BFDamageExtensions.CALIBER, 0.125f);
base.set(BFDamageExtensions.MASS, 9.8f);

BFDamageExtensions perHit = base.copy();
perHit.set(BFDamageExtensions.FUSE_DELAY, 0.05f);

BFDamageExtensions anotherHit = new BFDamageExtensions(base);

这两种写法都是浅拷贝。它们会复制容器内部的 key-value 映射,让 perHit.set(...) 不影响 base;但不会深拷贝扩展值对象本身。对于可变对象,这意味着多个容器可能共享同一个值实例。为了避免这种隐蔽耦合,跨模组扩展字段最好使用不可变值。

设计建议

扩展 key 的粒度应贴近稳定语义。ammo_typeexplosive_massfuse_delay 这类字段容易被其他模组理解;magic_number_1custom_state 这类字段则几乎无法互通。

如果某个扩展只在你的武器侧 handler 内部使用,不需要护甲侧理解,也可以不放进 extensions,直接作为 handler 的字段保存。扩展机制最适合跨模组交换数据,而不是替代你自己的类结构。

最后,默认值应代表"缺省情况下最保守、最兼容的行为"。缺少弹种时返回 "unknown",缺少爆炸当量时返回 0f,缺少布尔标记时返回 false,通常比返回一个会触发特殊逻辑的值更安全。