跳转至

3.5 协议外伤害兼容

当一个实体实现了 BFHurtTarget,它不仅会响应 BFDamageApi.hurt() 发起的协议伤害,还会被协议的 Mixin 注入所拦截——原版的生物攻击、TNT 爆炸、投射物、魔法伤害等都可能被转换为协议伤害。createContextFromVanilla 方法决定了哪些原版伤害走协议管线、哪些退回原版流程。正确地实现此方法对模组的兼容性和性能都至关重要。

Mixin 拦截的真实行为

协议通过两个 Mixin 注入类(EntityHurtMixinLivingEntityHurtMixin)拦截所有的 Entity#hurt 调用。拦截逻辑分四步走:

第一步:重入检查。 如果当前线程已经有针对该目标的协议上下文(即已经处于协议管线内),拦截器放行——这说明这次 hurt 调用是协议管线内部发出的(第五步 target.hurt()),不应重复拦截。

第二步:BFHurtTarget 检查。 如果目标实现了 BFHurtTarget,拦截器调用 tb.createContextFromVanilla(source, amount)。如果返回 null,继续下一步;如果返回了一个上下文,拦截器调用 BFDamageApi.hurt(self, ctx) 并将 Mixin 的返回值设为 true。本章后续内容全部围绕此情况展开。

第三步:BFArmorMaterial 检查。 如果目标没有实现 BFHurtTarget,但它是 LivingEntity 且穿戴了 BFArmorMaterial 护甲,拦截器通过 BFArmorAdapter 自动构造上下文并调用 BFDamageApi.hurt()。适配器的 createContextFromVanilla 始终返回有效上下文——这意味着穿戴了协议护甲的实体自动将所有原版伤害纳入穿甲判定,无需手动筛选。详见 3.6 BFArmorMaterial 接口 中关于协议外伤害兼容的讨论。

第四步:放行。 以上条件均不满足——普通实体,走原版流程。

理解这个流程对正确实现 createContextFromVanilla 至关重要。它说明了两件事:你在 hurt 方法内调用的 super.hurt() 不会触发二次拦截(因为此时已在管线内);你在 createContextFromVanilla 中的判断决定了哪些原版伤害被"升级"为协议伤害。

策略一:返回 null(推荐起步方式)

对于大多数护甲模组,最安全且最简洁的策略是在 createContextFromVanilla 中直接返回 null。这意味着只有通过 BFDamageApi.hurt() 显式发起的协议伤害才走穿甲判定,所有原版来源的伤害保持原样:

@Override
@Nullable
public BFDamageContext createContextFromVanilla(DamageSource source, float amount) {
    return null;
}

这种策略的好处是可以让你分阶段开发:首先实现简易模式的穿甲判定(getArmorLevel + hurt),让枪械模组的协议伤害正确运行;等这个路径稳定后,再逐步将某些原版伤害也纳入协议管线。

返回 null 时,协议层的存在对游戏行为没有影响。一个实现了 BFHurtTarget 且返回 null 的僵尸被另一个僵尸攻击时,伤害判定与原版完全相同。只有在枪械模组通过 BFDamageApi.hurt() 攻击它时,才会走穿甲管线。

策略二:筛选后转换

当你的装甲系统成熟后,你可能希望部分原版伤害也受装甲减免。最常见的需求是:原版生物的物理攻击应该被装甲抵挡,但环境伤害(岩浆、溺水、虚空)和魔法伤害(女巫的药水)不应该被装甲影响。

你可以在 createContextFromVanilla 中根据 DamageSource 类型做筛选:

@Override
@Nullable
public BFDamageContext createContextFromVanilla(DamageSource source, float amount) {
    // 以下类型的伤害完全不适合走穿甲管线,直接退回原版
    if (source.is(DamageTypes.OUT_OF_WORLD)      // 虚空
        || source.is(DamageTypes.DROWN)           // 溺水
        || source.is(DamageTypes.STARVE)          // 饥饿
        || source.is(DamageTypes.MAGIC)           // 魔法
        || source.is(DamageTypes.WITHER)          // 凋零
        || source.is(DamageTypes.ON_FIRE)         // 着火
        || source.is(DamageTypes.HOT_FLOOR)       // 岩浆块
        || source.is(DamageTypes.LAVA)) {         // 岩浆
        return null;
    }

    // 物理攻击(僵尸、骷髅、铁傀儡等)、投射物、爆炸——这些走穿甲判定
    // 构造低信息量上下文
    return BFDamageContext.builder()
        .source(source)
        .baseDamage(amount)
        .build();
}

筛选列表的取舍取决于你的游戏设计。有些模组可能希望爆炸伤害也走穿甲判定(坦克应能承受 TNT 爆炸),有些则希望爆炸永远绕过装甲(模拟冲击波直接伤害内部)。返回 null 的伤害类型越多,协议对游戏行为的改变越小;转换的伤害类型越多,装甲的一致性越强。

低信息量上下文的行为

当你在 createContextFromVanilla 中构造上下文时,通常只能设置 sourcebaseDamage——原版伤害没有弹道信息。其余字段使用 Builder 的默认值:penetration 为 0,hitVelocity 为零向量,hitNormal 朝上。

这会导致什么?穿深为 0 的伤害经过 getPenetrationLevel() 映射到 UNARMORED_1。在简易模式的默认判定中,UNARMORED_1.canDefeat(UNARMORED_1) 返回 true(等于算击穿),因此同为"无甲"的目标会被击穿;而任何有装甲的目标(LIGHT_1 及以上)都不会被击穿——原版生物攻击无法穿透装甲。

这个行为往往是期望的。一个僵尸攻击坦克应该不造成伤害,因为它没有穿甲能力。但在 calculateFinalDamage 中,你仍然可以覆写为返回非零钝伤——让大口径原版爆炸对装甲目标造成一定程度的冲击伤害。

如果你希望返回更丰富的上下文,可以基于 source 的类型做推断。例如,爆炸伤害可以基于爆炸中心位置估算一个命中法线:

if (source.is(DamageTypes.EXPLOSION) || source.is(DamageTypes.PLAYER_EXPLOSION)) {
    Vec3 explosionPos = source.getSourcePosition();
    if (explosionPos != null) {
        Vec3 toEntity = this.position().subtract(explosionPos).normalize();
        // 爆炸方向视为命中法线的反方向
        return BFDamageContext.builder()
            .source(source)
            .baseDamage(amount)
            .hitPoint(this.position())      // 近似为实体中心
            .hitNormal(toEntity.scale(-1))  // 法线指向爆炸中心方向(面外侧)
            .penetration(20f)               // 假设爆炸破片有 20mm 穿深
            .build();
    }
}

这种推断虽不精确,但比纯零信息量上下文能提供更合理的判定——至少跳弹和角度相关的判定可以有正确的方向参考。

与扩展模组的协作

如果你的护甲模组实现了 createContextFromVanilla 并对某些原版伤害进行转换,需要注意与其他也实现了 BFHurtTarget 的模组的交互。因为 createContextFromVanilla 是你类中的覆写,不会影响其他实体的行为。不同的 BFHurtTarget 实现者可以有不同的转换策略——一个坦克可能将爆炸转换为协议伤害,一个魔法傀儡可能连物理攻击也不转换。

但如果你的模组期望其他护甲模组也按照你的逻辑来转换原版伤害,这是做不到的——createContextFromVanilla 的覆写权属于每个实体类自身,不存在全局配置。如果你需要统一所有 BFHurtTarget 的转换行为,需要与各护甲模组作者逐个协调,或在你的文档中说明推荐策略。