跳转至

4.3 ThreadLocal 与 Mixin

协议需要在一次伤害调用链中携带 BFDamageContext,但又不能把上下文塞进原版 DamageSource,也不能把它长期存进实体。BallisticsFramework 的做法是使用一个内部的 ThreadLocal 上下文栈:进入协议管线时压栈,离开时弹栈;管线内部如果需要读取当前上下文,就通过 BFDamageApi.getContextFor(target) 查询栈顶。

为什么需要 ThreadLocal

Minecraft 的伤害调用本质上是同步方法调用。武器侧调用 BFDamageApi.hurt(target, ctx) 后,协议会在同一条调用链里完成穿甲判定、最终伤害计算、原版 hurt 调用和回调触发。上下文只在这条调用链内有意义,出了这次命中就不应该继续存在。

这类数据不适合放进实体字段。实体字段是长期状态,而命中点、入射角、弹体质量是瞬时状态;如果存进去,需要处理清理时机、嵌套伤害、异常退出和多目标并发等问题。ThreadLocal 栈把上下文绑定到当前线程和当前调用深度,生命周期正好覆盖一次协议伤害。

它也不适合放进 DamageSourceDamageSource 表达的是伤害来源和伤害类型,而不是任意一次命中的高维数据容器。协议选择在 DamageSource 外围包一层上下文,而不是改造它。

上下文栈的结构

内部类 TBContextStack 维护的是:

ThreadLocal<Deque<Entry>>

每个 Entry 包含两个字段:targetctx。也就是说,栈里保存的不是单纯的上下文,而是"这份上下文属于哪个目标"。

private record Entry(Object target, BFDamageContext ctx) {}

这点很重要。协议可能在一次伤害中触发另一次伤害,例如目标的反伤、爆炸后效、破片二次命中等。它们都发生在同一个线程里,但目标可能不同。用 (target, ctx) 成对入栈,可以区分"当前目标的重入"和"同线程内另一个目标的新伤害"。

push 与 pop

BFDamageApi.hurt 在进入时压栈,在 finally 中弹栈:

TBContextStack.INSTANCE.push(target, ctx);
try {
    // 穿甲判定、伤害执行、回调
} finally {
    TBContextStack.INSTANCE.pop();
}

finally 是这里的关键。无论管线中是正常返回、目标拒绝伤害,还是某个 mod 的覆写方法抛出异常,只要控制流离开 hurt,协议都会尝试弹出当前栈帧。这避免了上下文泄漏到后续无关伤害中。

外部模组不需要也不应该直接接触 TBContextStack。公开入口只有 BFDamageApi.hasContextFor(target)BFDamageApi.getContextFor(target)

getContextFor 的匹配规则

getContextFor(target) 只在栈顶目标与传入目标相同引用时返回上下文:

Entry top = deque.peek();
return top.target() == target ? top.ctx() : null;

这里使用的是 == 引用比较,而不是 equals。实体和命中目标本来就应该按对象身份区分;使用 equals 反而可能让两个逻辑上相等但实际不同的对象误共享上下文。

只检查栈顶也很有意图。栈顶代表当前最内层的协议伤害。如果外层目标 A 的伤害过程中触发了内层目标 B 的伤害,那么内层执行期间,A 的上下文仍在栈里,但它不是当前正在处理的上下文。此时 getContextFor(A) 返回 null,可以防止外层上下文被内层逻辑误读。

hasContextFor 与重入守卫

hasContextFor(target)getContextFor 使用同样的栈顶匹配规则,但只返回布尔值。它主要给 Mixin 拦截器使用,用来判断当前 Entity#hurt 是否已经处在协议管线内部。

典型流程如下:

  1. 武器侧调用 BFDamageApi.hurt(entity, ctx)
  2. 协议压入 (entity, ctx)
  3. 目标实现 BFHurtTarget,协议计算出 finalDmg
  4. 协议调用 tb.hurt(source, finalDmg)
  5. 实体的 hurt 方法进入原版/NeoForge 流程,同时命中 Mixin 注入点。
  6. Mixin 发现 hasContextFor(entity) 为 true,于是放行,不再次拦截。

如果没有这个守卫,第 5 步的原版 hurt 会被 Mixin 当成"协议外伤害"再次转换成协议伤害,形成递归调用。ThreadLocal 栈的目标匹配正是为了切断这种重入。

Mixin 注入点

协议包含两个 Mixin:

Mixin 注入目标 用途
EntityHurtMixin Entity#hurt(DamageSource, float) 覆盖非 LivingEntity 的协议目标,例如纯载具实体
LivingEntityHurtMixin LivingEntity#hurt(DamageSource, float) 覆盖生物实体及其子类

两个 Mixin 都注入在 HEAD,并且 cancellable = true。它们本身不写复杂逻辑,而是统一委托给 BFHurtInterceptor.intercept

之所以需要两个注入点,是因为 LivingEntity 覆写了 Entity#hurt。对一个生物实体调用 hurt 时,JVM 方法分派会直接进入 LivingEntity#hurt,不会先经过 Entity#hurt 的注入逻辑。因此只注入 Entity 不足以覆盖生物;只注入 LivingEntity 又覆盖不到纯载具等非生物实体。

协议外伤害拦截

BFHurtInterceptor.intercept 的判断顺序:

// 情况1:已在此目标的协议管线内 → 放行
if (BFDamageApi.hasContextFor(self)) return;

// 情况2:实体自身是 BFHurtTarget → 接管
if (self instanceof BFHurtTarget tb) {
    BFDamageContext ctx = tb.createContextFromVanilla(source, amount);
    if (ctx == null) return;
    float dealt = BFDamageApi.hurt(self, ctx);
    cir.setReturnValue(dealt > 0f);
    return;
}

// 情况3:实体穿戴了 BFArmorMaterial 护甲 → 通过适配器接管
if (self instanceof LivingEntity living && BFArmorAdapter.hasBFArmor(living)) {
    BFArmorAdapter adapter = new BFArmorAdapter(living);
    BFDamageContext ctx = adapter.createContextFromVanilla(source, amount);
    if (ctx == null) return;
    float dealt = BFDamageApi.hurt(self, ctx);
    cir.setReturnValue(dealt > 0f);
}

// 情况4:其他 → 放行原版流程

这段逻辑对应四种情况。

第一,已经在此目标的协议管线内:放行。这就是重入守卫,保证协议内部委托给原版 hurt 时不会再次被拦截。

第二,目标自身是 BFHurtTarget:通过 createContextFromVanilla 构造上下文后调用 BFDamageApi.hurt()。如果上下文为 null(实现者决定将此伤害退回原版),放行。

第三,目标自身不是 BFHurtTarget,但穿戴了 BFArmorMaterial 护甲:由适配器构造上下文后走完整管线。适配器的 createContextFromVanilla 始终返回有效上下文(穿深 = 原版伤害量 / 2)——这意味着穿戴了协议护甲的实体自动将所有原版伤害纳入穿甲判定,无需手动筛选。

第四,以上条件均不满足:放行,原版流程继续。

如果情况2中的 createContextFromVanilla 返回 null,协议也会放行。这让护甲侧可以逐类决定哪些原版伤害要进入穿甲管线,哪些继续按原版处理。

为什么 result > 0 才取消原版返回值

拦截器在协议伤害返回值大于 0 时调用 cir.setReturnValue(true)。这表示"本次原版 hurt 调用已经被协议接管,并且造成了有效伤害"。

如果协议返回 0,拦截器不会取消原版流程。这样做的效果是:当 createContextFromVanilla 选择接管某个伤害,但协议判定没有造成实际伤害时,原版仍有机会继续处理这次伤害。这个策略偏向兼容性,避免某些普通环境伤害因为穿深为 0 而被协议无意吞掉。

如果你的实体希望"未击穿时完全免疫原版伤害",可以在 createContextFromVanilla 中只接管你明确想拦截的伤害类型,并在 calculateFinalDamage 或实体自身逻辑中表达这种免疫策略。协议层默认不替你吞掉所有原版伤害。

在 hurt 内读取上下文

护甲侧最常见的 ThreadLocal 用法,是在自己的 hurt 实现中读取当前上下文:

@Override
public boolean hurt(DamageSource source, float amount) {
    BFDamageContext ctx = BFDamageApi.getContextFor(this);
    if (ctx != null) {
        spawnHitParticles(ctx.hitPoint(), ctx.hitNormal());
    }
    return super.hurt(source, amount);
}

这段代码只在协议管线内能读到上下文。如果实体被普通原版路径伤害,ctx 会是 null,因此必须保留 null 判断。

同理,calculateFinalDamageresolvePenetration、回调方法中通常不需要通过 ThreadLocal 取上下文,因为方法参数已经直接传入了 ctxgetContextFor 主要服务于那些签名无法携带 BFDamageContext 的位置,尤其是原版 hurt(DamageSource, float)

嵌套伤害示例

假设 A 被一发炮弹击中,护甲侧在 hurt 中触发了对攻击者 B 的反伤:

push(A, ctxA)
  A.resolvePenetration(ctxA)
  A.hurt(...)
    push(B, ctxB)
      B.resolvePenetration(ctxB)
      B.hurt(...)
    pop(B)
  handler.onPenetrated(A, ctxA)
pop(A)

内层 B 的伤害执行期间,栈顶是 (B, ctxB)。因此 B 的 Mixin 重入守卫生效,B 的 getContextFor(B) 能读到 ctxB;A 的 getContextFor(A) 在此时返回 null,因为 A 不是栈顶。

当 B 的伤害结束后,pop(B) 恢复栈顶为 (A, ctxA),外层回调继续执行。这个栈结构让嵌套伤害可以自然工作,而不会把两个目标的上下文混在一起。

线程边界

ThreadLocal 的含义是"每个线程各自拥有一份栈"。如果你在协议管线中把任务丢到另一个线程执行,那个线程无法通过 BFDamageApi.getContextFor 读到当前上下文。需要跨线程使用的数据,应显式从 ctx 中取出并传给任务,而不是依赖 ThreadLocal。

在常规 Minecraft 逻辑中,实体伤害应发生在服务器主线程。协议的 ThreadLocal 设计正是围绕同步伤害调用链优化的,不是异步消息传递机制。

开发者应遵守的边界

TBContextStackBFHurtInterceptor 和 Mixin 类都位于 internalmixin 包中,不构成公开 API。外部 mod 不应直接调用这些类,也不应依赖它们的内部结构。

你可以依赖的稳定入口只有:

BFDamageApi.hurt(target, ctx)
BFDamageApi.hasContextFor(target)
BFDamageApi.getContextFor(target)

多数情况下,武器侧只需要第一个方法;护甲侧只在原版 hurt 等无法直接拿到 ctx 的位置使用第三个方法。hasContextFor 主要是协议内部和高级兼容代码使用的工具,不是普通业务逻辑的必要组成部分。

理解 ThreadLocal 与 Mixin 的目的,是为了知道协议为什么能在"不替代原版"的前提下仍然携带高维上下文:外层用 BFDamageApi 包裹一次伤害,内层用 Mixin 接住协议外入口,中间用上下文栈守住调用链边界。三者合在一起,构成了 BallisticsFramework 的透明兼容层。