跳转至

2.1 构造命中上下文

BFDamageContext 是一次协议伤害的核心——它携带了命中行为的所有高维信息,从伤害来源到穿深到入射角度。武器侧的工作本质上就是构造一个正确而完整的上下文,然后交给协议入口。本章逐一拆解上下文的每个字段,并说明构造过程中的常见模式与注意事项。

上下文的不可变性

BFDamageContext 是一个 Java 16 record,构造完成后所有字段只读。这意味着一旦 build() 完成,你不能再修改其中的任何值。协议在穿甲管线中多次访问这些字段(getRHA 内读 penetrationmodifyPenetration 内读 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 的 BlockHitResultEntityHitResult 略有不同——方块命中结果直接提供 getDirection()(面朝向),而实体命中结果需要通过命中的实体 box 反算。上面的代码假设了 getDirection().getNormal() 可用(EntityHitResult 在某些版本中不提供面法线),更健壮的做法见 2.3 节。

构造低信息量上下文(回退路径)

在某些场景下,发起伤害时并没有弹道信息——例如原版爆炸间接伤害、荆棘反伤、环境伤害等。协议同样支持仅携带 sourcebaseDamage 的低信息量上下文:

BFDamageContext ctx = BFDamageContext.builder()
    .source(source)
    .baseDamage(amount)
    .build();

这样的上下文中 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;但如果某个扩展值本身是可变对象,两个容器仍然会引用同一个值实例。因此扩展值建议优先使用 FloatIntegerBooleanString、枚举或不可变 record。

如果你更喜欢构造器形式,也可以写成:

BFDamageExtensions perHit = new BFDamageExtensions(base);

Extensions 不为 Null 的保证

build() 方法保证了 extensions 永远不会为 null——如果 Builder 未调用 extensions(),会自动新建一个空容器。因此护甲侧在读取 ctx.extensions().get(someKey) 时永远不需要做 null 检查。get() 方法自身也保证了返回值非 null——未设置时退回注册的默认值。

这个设计使得整个管线的每个环节都不需要防御式判空。武器侧构造、护甲侧读取、协议层传递,三者之间的契约是"容器一定存在,读操作一定返回有效值"。