2.1 构造命中上下文¶
BFDamageContext 是一次协议伤害的核心——它携带了命中行为的所有高维信息,从伤害来源到穿深到入射角度。武器侧的工作本质上就是构造一个正确而完整的上下文,然后交给协议入口。本章逐一拆解上下文的每个字段,并说明构造过程中的常见模式与注意事项。
上下文的不可变性¶
BFDamageContext 是一个 Java 16 record,构造完成后所有字段只读。这意味着一旦 build() 完成,你不能再修改其中的任何值。协议在穿甲管线中多次访问这些字段(getRHA 内读 penetration、modifyPenetration 内读 hitNormal 等),不可变性保证了每次读取到的都是同一个值,避免了管线执行过程中值被意外篡改的问题。
对于 handler 字段,record 类型本身要求所有字段参与 equals/hashCode,这不太适合将 handler 作为值的组成部分。因此 handler 并未通过 record 的标准构造器声明,而是在紧凑构造器中记录为独立引用——getHandler() 返回它,但它不参与相等性比较。如需替换 handler,使用 ctx.withHandler(newHandler) 会返回一个全新的上下文实例,其余字段原样拷贝。
Builder 的字段与默认值¶
构造器通过 BFDamageContext.builder() 获取,流式设置各字段后调用 build()。除 source 外,所有字段都有默认值。下表汇总了 Builder 暴露的 setter 方法、对应字段及其默认值:
| setter | 对应字段(类型) | 默认值 | 说明 |
|---|---|---|---|
source(DamageSource) |
source(DamageSource) |
无默认,必须设置 | 伤害来源。build() 时若为 null 将抛出 NullPointerException |
baseDamage(float) |
baseDamage(float) |
0f |
标称伤害量,即弹丸在理想命中条件下的原始伤害期望 |
hitVelocity(Vec3) |
hitVelocity(Vec3) |
Vec3.ZERO |
弹丸命中瞬间的速度矢量(m/s)。方向用于计算入射角,模长用于判断是否接近垂直命中 |
hitPoint(Vec3) |
hitPoint(Vec3) |
Vec3.ZERO |
命中点世界坐标。护甲侧用于部位判定,武器侧用于粒子/音效生成 |
hitNormal(Vec3) |
hitNormal(Vec3) |
(0, 1, 0) 朝上 |
命中面法线,指向面外侧。默认朝上时按垂直入射处理 |
penetration(float) |
penetration(float) |
0f |
理论穿深(mm RHA),通常已经过角度修正。零值表示没有穿甲能力 |
extensions(BFDamageExtensions) |
extensions(BFDamageExtensions) |
自动新建空实例 | 扩展数据容器。未设置时 build() 自动创建空容器 |
handler(BFDamageHandler) |
handler(BFDamageHandler) |
null |
伤害发起方回调接口。null 表示不需要任何回调 |
Source 是唯一必须字段的原因在于——协议必须知道伤害的来源实体、伤害类型等信息。在穿甲管线中,护甲侧可能通过 ctx.source().getEntity() 判断是谁在攻击它;原版管线也需要 source 来完成伤害标记和无敌帧判断。
构造高信息量上下文¶
多数枪械/载具模组的典型用法是从射线命中结果(EntityHitResult)中提取信息。下面逐一说明构造过程中各参数的来源:
// 世界背景信息
DamageSource source = new DamageSource(
this.level().damageSources().mobAttack(this).typeHolder(),
this, // 伤害直接来源(开火的实体)
shooter // 伤害间接来源(持枪的实体,可与直接来源相同)
);
Vec3 bulletVelocity = bullet.getDeltaMovement(); // 弹丸实体当前帧的位移矢量
float baseDamage = 35f; // 配置中的标称伤害
float rawPenetration = 150f; // 配置中的垂直穿深(mm RHA)
// 从射线命中结果提取
EntityHitResult hit = projectile.raycast();
Vec3 hitPoint = hit.getLocation();
Vec3 hitNormal = new Vec3(
hit.getDirection().getNormal().getX(),
hit.getDirection().getNormal().getY(),
hit.getDirection().getNormal().getZ()
);
// 角度修正:弹丸方向与法线的夹角
Vec3 shotDir = bulletVelocity.normalize();
double cosTheta = Math.abs(shotDir.dot(hitNormal)); // 法线与弹丸反方向的夹角余弦
float angleCorrectedPenetration = cosTheta < 0.01f
? Float.MAX_VALUE // 近乎垂直命中,直接击穿
: (float)(rawPenetration / cosTheta); // 标准斜穿修正
BFDamageContext ctx = BFDamageContext.builder()
.source(source)
.baseDamage(baseDamage)
.hitVelocity(bulletVelocity)
.hitPoint(hitPoint)
.hitNormal(hitNormal)
.penetration(angleCorrectedPenetration)
.extensions(exts)
.handler(myHandler)
.build();
其中 hitNormal 的计算方式与 Minecraft 的 BlockHitResult 和 EntityHitResult 略有不同——方块命中结果直接提供 getDirection()(面朝向),而实体命中结果需要通过命中的实体 box 反算。上面的代码假设了 getDirection().getNormal() 可用(EntityHitResult 在某些版本中不提供面法线),更健壮的做法见 2.3 节。
构造低信息量上下文(回退路径)¶
在某些场景下,发起伤害时并没有弹道信息——例如原版爆炸间接伤害、荆棘反伤、环境伤害等。协议同样支持仅携带 source 和 baseDamage 的低信息量上下文:
这样的上下文中 penetration 为 0、hitVelocity 为零向量、hitNormal 朝上(垂直入射)。当目标实体走穿甲管线时,getPenetrationLevel() 会返回 UNARMORED_1(穿深为 0 映射到最低等级),因此只有同样无装甲的实体才会被判定为击穿。这恰好是合理的默认行为——低信息量伤害就按无装甲穿透处理。
重新注入 Handler¶
有时候你希望在构造上下文之后、发起伤害之前再注入 handler——例如上下文由一个工具方法构造,但 handler 取决于调用方。此时使用 withHandler() 创建新实例:
BFDamageContext baseCtx = buildBaseContext(target); // 某个工具方法返回
BFDamageContext ctx = baseCtx.withHandler(myHandler); // 注入 handler,其余字段原样拷贝
float dealt = BFDamageApi.hurt(target, ctx);
注意 withHandler 返回一个新实例,原实例不变。这符合上下文的不可变设计。
Extensions 的共享与拷贝¶
如果你在多个不同的上下文中需要使用相同的扩展数据(比如同一种弹药的所有命中都共享口径、质量、弹种等字段),可以创建一个基础 extensions 实例,在每次命中时调用 copy() 得到独立副本,再添加命中特定的数据:
BFDamageExtensions base = new BFDamageExtensions();
base.set(MyModKeys.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); // 命中特定数据
copy() 返回的是浅拷贝:新的容器拥有独立的内部映射,因此后续对 perHit.set(...) 的修改不会影响 base;但如果某个扩展值本身是可变对象,两个容器仍然会引用同一个值实例。因此扩展值建议优先使用 Float、Integer、Boolean、String、枚举或不可变 record。
如果你更喜欢构造器形式,也可以写成:
Extensions 不为 Null 的保证¶
build() 方法保证了 extensions 永远不会为 null——如果 Builder 未调用 extensions(),会自动新建一个空容器。因此护甲侧在读取 ctx.extensions().get(someKey) 时永远不需要做 null 检查。get() 方法自身也保证了返回值非 null——未设置时退回注册的默认值。
这个设计使得整个管线的每个环节都不需要防御式判空。武器侧构造、护甲侧读取、协议层传递,三者之间的契约是"容器一定存在,读操作一定返回有效值"。