2.5 侧信道扩展¶
上下文的六个核心字段(source、baseDamage、hitVelocity、hitPoint、hitNormal、penetration)覆盖了最通用的弹道信息。但在实际的模组协作场景中,武器侧和护甲侧可能需要交换更多自定义数据——弹药类型、炸药装药量、引信延迟、特殊效应标记等。如果将这些数据全部塞入核心字段,BFDamageContext 的字段列表会不断膨胀,且每个模组关心的子集不同,难以统一。
扩展机制(BFDamageExtensions + BFDamageExtensionKey)正是为此设计的——它是一个类型安全的、基于泛型 key 的键值存储,挂在每个上下文实例上,允许武器侧携带任意结构化数据,护甲侧按需读取。
为什么叫"侧信道"¶
扩展数据被协议核心管线完全忽略。五步管线(getRHA → modifyPenetration → resolvePenetration → calculateFinalDamage → hurt)不读取扩展容器中的任何内容,不基于扩展值做任何判定。扩展数据的消费者永远是护甲侧(在管线方法的覆写中读取)或武器侧(在回调中读取已写入的值用于后续效果)。
这意味着扩展是纯侧信道——协议的运行不依赖它们,但它们为武器侧和护甲侧提供了一个标准化的"留言板"。武器的设计者可以在这个留言板上写下弹药的元信息,护甲的实现者可以读取这些信息来做更精细的判定(例如爆反对 HEAT 弹和 APFSDS 弹的区别对待)。
扩展容器的基本操作¶
BFDamageExtensions 提供了两个核心方法:set(key, value) 和 get(key)。
BFDamageExtensions exts = new BFDamageExtensions();
// 写入
exts.set(BFDamageExtensions.FUSE_DELAY, 0.05f); // 引信延迟 50ms
exts.set(BFDamageExtensions.CALIBER, 0.12f); // 120mm 口径
// 读取
float fuseDelay = exts.get(BFDamageExtensions.FUSE_DELAY); // 0.05
float caliber = exts.get(BFDamageExtensions.CALIBER); // 0.12
float mass = exts.get(BFDamageExtensions.MASS); // 10.0(未设置,退回默认值)
set 要求 key 和 value 均不能为 null——写入 null 值没有意义,想表示"未设置"就不要调用 set。get 总是返回非 null——如果之前没有通过 set 写入,则返回 key 注册时指定的默认值。这个保证使得护甲侧在读取扩展时永远不需要判空,简化了管线覆写中的代码。
如果多次命中共享同一组基础扩展数据,可以使用 copy() 创建浅拷贝,再写入本次命中的特定字段:
BFDamageExtensions base = new BFDamageExtensions();
base.set(BFDamageExtensions.CALIBER, 0.12f);
base.set(BFDamageExtensions.MASS, 8.5f);
BFDamageExtensions perHit = base.copy();
perHit.set(BFDamageExtensions.FUSE_DELAY, 0.05f);
也可以使用拷贝构造器:new BFDamageExtensions(base)。两种方式都会复制容器内部的 key-value 映射,但不会深拷贝扩展值对象本身。因此如果扩展值是可变对象,多个容器可能仍然共享同一个值实例;跨模组扩展字段建议使用不可变值。
协议预定义的扩展 Key¶
协议自身定义了三个标准扩展 key,均为 Float 类型,已注册在 ballistics_framework 命名空间下:
| 常量 | 默认值 | 含义 |
|---|---|---|
BFDamageExtensions.FUSE_DELAY |
0f |
引信延迟(秒)。0 表示瞬发引信(命中即炸);正值表示延迟引爆(如穿甲后在内部爆炸) |
BFDamageExtensions.CALIBER |
0.1f |
弹体口径(米)。默认 0.1(100mm),典型坦克炮口径 |
BFDamageExtensions.MASS |
10f |
弹体质量(千克)。默认 10kg,典型坦克炮穿甲弹质量 |
FUSE_DELAY 是最典型的"侧信道"扩展——协议核心不关心什么时间引爆,但武器侧在回调中可以根据此值决定何时(或是否)触发爆炸。护甲侧也可以通过读取此值来判断是否为延迟引信弹药,从而决定爆反的拦截时机。
CALIBER 和 MASS 则为护甲侧提供了弹药的基础物理属性。例如,间隙装甲的衰减系数可能取决于弹丸口径——口径越小,间隙衰减越明显。护甲侧在 modifyPenetration 中读取 CALIBER 来做这种判定。
注册自定义扩展 Key¶
如果你的模组需要携带协议预定义之外的扩展数据,可以通过 BFDamageExtensions.register() 注册自己的 key。注册操作建议放在模组初始化阶段(@Mod 构造器或静态初始化块),并保存为 public static final 常量,供武器侧和护甲侧共享:
// 在你的 @Mod 类中
public static final BFDamageExtensionKey<String> AMMO_TYPE =
BFDamageExtensions.register(
ResourceLocation.fromNamespaceAndPath("your_mod_id", "ammo_type"),
String.class,
() -> "unknown" // 默认值
);
public static final BFDamageExtensionKey<Integer> PROPELLANT_CHARGE =
BFDamageExtensions.register(
ResourceLocation.fromNamespaceAndPath("your_mod_id", "propellant_charge"),
Integer.class,
() -> 0
);
注册 key 的三个参数分别是:唯一标识符(ResourceLocation,全局唯一,不允许重名)、值类型(Class<T>,用于编译期类型检查)、默认值工厂(Supplier<T>,每次 get 未命中时调用)。
Key 注册后是全局单例——整个 JVM 中同一个 id 只能注册一次。如果两个模组意外注册了相同 id 的 key,第二个注册会抛出异常。因此建议 key 的命名空间使用你自己的 mod id,避免与其他模组冲突。
注册的时机与生命周期¶
扩展 key 的注册时机必须早于任何对 get() 或 set() 的调用。建议在以下位置注册:
- @Mod 主类的构造函数中:NeoForge 保证在模组初始化阶段调用,此时其他模组尚未开始游戏逻辑。
- 静态初始化块中:类的
<clinit>在第一次使用时触发,同样早于游戏逻辑。
不要在游戏运行中动态注册 key——不仅因为注册表会检查重名,更因为动态注册的 key 在并发场景下可能尚未传播到其他线程。
如果你的 key 只在你的模组内部使用(武器侧写入,自己的护甲侧读取),注册在你的模组类中即可。如果你希望其他模组也能读取你的扩展数据,你需要将 key 常量公开——通常是暴露为 public static final 字段,其他模组在编译期引用。
护甲侧读取扩展¶
护甲侧在管线方法内通过 ctx.extensions().get(key) 读取扩展数据。由于 get 保证非 null 返回,读取代码可以写得很简洁:
@Override
public float modifyPenetration(BFDamageContext ctx) {
float base = ctx.penetration();
// 读取弹药口径
float caliber = ctx.extensions().get(BFDamageExtensions.CALIBER);
// 口径小于 75mm 的全口径弹对间隙装甲衰减明显
if (caliber < 0.075f) {
return base * 0.7f;
}
return base;
}
如果护甲侧需要读取其他模组注册的自定义 key,需要先将该 key 的常量引入自己的编译依赖。这也意味着扩展 key 的常量定义类必须是一个稳定的公共 API——如果武器模组改变了 key 的 id 或默认值,依赖它的护甲模组也需要重新编译。
扩展与核心字段的区别¶
核心字段(penetration、baseDamage 等)和扩展字段的设计哲学有本质区别。核心字段由协议设计师定义,是整个协议的"语言"——管线的每个步骤都依赖它们,所有参与协议的模组都必须理解它们的语义。扩展字段由各模组自行定义,是各方之间的"方言"——只有声明了 key 的模组和使用该 key 的模组才需要理解它,协议核心不读取也不依赖扩展。
因此,当你设计弹药的数据模型时,一个判断标准是:如果这个数据参与击穿判定(影响 resolvePenetration 的返回值),它应该是武器侧在构造上下文前完成的计算——结果反映在 penetration 的值中,而不是作为一个扩展 key 让每个护甲模组分别读取和计算。如果这个数据用于非判定性的反馈(音效类型、粒子颜色、后续效果),它天然适合作为扩展 key——武器侧写入,回调中读取,护甲侧可选忽略。
完整实例:携带弹药元信息的上下文¶
以下是一个从弹药配置到上下文构造的完整流程:
// === 弹药配置(JSON 或代码定义) ===
public class AmmoConfig {
public static final BFDamageExtensionKey<String> AMMO_ID = BFDamageExtensions.register(
ResourceLocation.fromNamespaceAndPath("my_gun_mod", "ammo_id"),
String.class, () -> "unknown"
);
public static final BFDamageExtensionKey<Float> EXPLOSIVE_MASS = BFDamageExtensions.register(
ResourceLocation.fromNamespaceAndPath("my_gun_mod", "explosive_mass"),
Float.class, () -> 0f
);
}
// === 武器侧:构造上下文 ===
public class APFSDSBullet {
public BFDamageContext buildContext(DamageSource source, Vec3 hitVelocity,
Vec3 hitPoint, Vec3 hitNormal, float rawPen) {
BFDamageExtensions exts = new BFDamageExtensions();
exts.set(BFDamageExtensions.FUSE_DELAY, 0f);
exts.set(BFDamageExtensions.CALIBER, 0.025f);
exts.set(BFDamageExtensions.MASS, 4.5f);
exts.set(AmmoConfig.AMMO_ID, "apfsds_m829a1");
exts.set(AmmoConfig.EXPLOSIVE_MASS, 0f);
float effectivePen = rawPen / (float)Math.abs(hitVelocity.normalize().dot(hitNormal));
return BFDamageContext.builder()
.source(source)
.baseDamage(50f)
.hitVelocity(hitVelocity)
.hitPoint(hitPoint)
.hitNormal(hitNormal)
.penetration(effectivePen)
.extensions(exts)
.build();
}
}
// === 护甲侧:读取扩展做精细判定 ===
@Override
public float modifyPenetration(BFDamageContext ctx) {
float base = ctx.penetration();
String ammoId = ctx.extensions().get(AmmoConfig.AMMO_ID);
// 特定弹药对此装甲有额外穿透加成
if ("apfsds_m829a1".equals(ammoId)) {
return base * 1.1f; // 10% 加成——弹药专门优化过
}
// HEAT 弹药被爆反拦截
float explosiveMass = ctx.extensions().get(AmmoConfig.EXPLOSIVE_MASS);
if (explosiveMass > 0) {
return base * 0.3f; // 爆反拦截化学能射流
}
return base;
}
这个实例展示了扩展机制的典型使用模式:武器侧负责将弹药的全部元信息打包进扩展容器;护甲侧选择性地读取其中感兴趣的 key,做出精细判定;协议核心完全不知情,只使用核心字段做比较。