跳转至

2.4 回调与事件处理

伤害执行之后,武器侧往往需要知道"刚才发生了什么"——弹丸击穿了、被挡住了、还是跳飞了?产生的破片是否该生成二次弹丸?BFDamageHandler 接口正是为这些需求设计的。通过将 handler 注入上下文,武器侧的代码可以在协议管线执行完毕后接收事件回调,并根据结果触发后续效果。

回调触发的时机

回调在 BFDamageApi.hurt() 方法的末尾触发——此时伤害已经执行完毕(target.hurt() 已调用并返回),但上下文栈尚未出栈(因此护甲侧在回调中仍然可以通过 getContextFor 读取上下文)。这个时机意味着两点:第一,你可以在回调中安全地读取上下文的任何字段;第二,你不会在伤害执行之前就错误地触发效果——如果伤害被原版机制拦截(hurt 返回 false),dealt 为 0,但回调仍会触发,因为你可能需要知道"发射了但被挡住了"。

五个事件回调

BFDamageHandler 提供了五个事件回调方法,全部带有 default 空实现。你只需覆写你关心的那几个。

public class MyGunHandler implements BFDamageHandler {

    @Override
    public void onPenetrated(BFHurtTarget target, BFDamageContext ctx) {
        // 击穿:在命中点产生穿透火花粒子
        spawnPenetrationParticles(ctx.hitPoint(), ctx.hitNormal());
    }

    @Override
    public void onBlocked(BFHurtTarget target, BFDamageContext ctx) {
        // 未击穿:播放金属撞击音效,产生火花粒子
        playImpactSound(ctx.hitPoint(), SoundEvents.ANVIL_LAND);
        spawnRicochetParticles(ctx.hitPoint(), ctx.hitNormal());
    }

    @Override
    public void onRicochet(BFHurtTarget target, BFDamageContext ctx) {
        // 跳弹:播放跳弹音效,产生偏转火花,并在偏转方向生成跳弹实体
        playRicochetSound(ctx.hitPoint());
        Vec3 ricochetDir = calculateRicochet(ctx);  // 根据入射角和法线计算偏转方向
        spawnRicochetBullet(ctx.hitPoint(), ricochetDir);
    }

    @Override
    public void onOvermatch(BFHurtTarget target, BFDamageContext ctx) {
        // 超匹配(碾压):弹丸完整穿透,碎甲不碎弹
        // 产生更激烈的穿透效果——大块火花 + 装甲碎片粒子
        spawnOvermatchParticles(ctx.hitPoint(), ctx.hitNormal());
        spawnArmorFragments(ctx.hitPoint(), ctx.hitNormal());
    }

    @Override
    public void onSpall(BFHurtTarget target, BFDamageContext ctx) {
        // 破片:弹体碎裂,产生二次破片弹丸
        int fragmentCount = 3 + random.nextInt(5);
        for (int i = 0; i < fragmentCount; i++) {
            spawnFragment(ctx.hitPoint(), randomSpread(ctx.hitNormal(), 30f));
        }
    }
}

每个回调方法的参数都包含 BFHurtTarget targetBFDamageContext ctx,因此你在回调中既能知道打在谁身上(target),也能知道打在哪儿、怎么打的(ctx)。targetBFHurtTarget 接口类型——如果你需要访问原始实体,可以将其转型为 Entity(因为实现 BFHurtTarget 的类几乎总是 Entity 的子类)。

回调的触发顺序

在管线内部,回调分为两类:主结果回调和派生效果回调。

主结果回调由 PenetrationResult 决定,三选一触发:

  • PENETRATED → 触发 onPenetrated
  • BLOCKED → 触发 onBlocked
  • RICOCHET → 触发 onRicochet

派生效果回调则只由武器侧 handler 的判断方法决定。主结果回调完成后,协议会分别调用 isOvermatch(target, ctx, result)isSpall(target, ctx, result);哪个返回 true,就触发哪个对应回调:

if (handler.isOvermatch(target, ctx, result)) {
    handler.onOvermatch(target, ctx);
}
if (handler.isSpall(target, ctx, result)) {
    handler.onSpall(target, ctx);
}

这意味着协议本身不会硬编码"跳弹一定不产生破片"或"超匹配一定排斥破片"。这些是默认 handler 判定表达的常见弹药模型,而不是协议层的限制。如果你的弹药会在跳弹时碎裂,覆写 isSpall 返回 true 即可;如果某种特殊弹药同时需要触发 onOvermatchonSpall,也可以让两个判断方法都返回 true。

一次命中至少会触发一个主结果回调,也可能追加触发零个、一个或两个派生效果回调。如果你只需要知道最终穿甲结果而不关心超匹配/破片的细分,只覆写 onPenetratedonBlockedonRicochet 三个主回调即可。

超匹配与破片的自动判定

isOvermatchisSpall 的默认实现在 BFDamageHandler 接口中定义,它们的判定逻辑如下。

超匹配(Overmatch)的条件:击穿 (PENETRATED) 并且修正后的穿深 > 目标的 RHA 等效厚度 × 1.5。物理含义是"穿深远超装甲厚度,弹体完整穿透,像热刀切黄油一样碾压装甲"。默认的 1.5 倍阈值意味着如果装甲有 100mm RHA,那么 150mm 以上的穿深才算超匹配。你可以覆写此方法来调整阈值:

@Override
public boolean isOvermatch(BFHurtTarget target, BFDamageContext ctx, PenetrationResult result) {
    if (result != PenetrationResult.PENETRATED) return false;
    float rha = target.getRHA(ctx);
    float modifiedPen = target.modifyPenetration(ctx);
    return modifiedPen > rha * 2.0f;  // 更严格的阈值
}

破片(Spall)的默认条件:未击穿 (BLOCKED)——弹体在装甲表面碎裂——或者击穿但非超匹配——弹体在穿透过程中碎裂。跳弹默认不产生破片。逻辑可以概括为:凡是默认模型中弹体碎裂的场景都产生破片;跳弹(弹体不碎)和碾压(弹体不碎)不产生。

// 默认实现
default boolean isSpall(BFHurtTarget target, BFDamageContext ctx, PenetrationResult result) {
    if (result == PenetrationResult.RICOCHET) return false;
    if (result == PenetrationResult.BLOCKED) return true;
    return !isOvermatch(target, ctx, result);
}

你可以覆写这两个判定方法来适配自己模组的弹药行为。例如,某些弹药类型(HESH 碎甲弹)可能在任何命中下都产生破片;某些弹药(全口径 AP)可能在特定角度下才碎裂;某些低质量弹丸可能即使击穿也不产生二次破片。

dealDamage 便捷方法

BFDamageHandler 接口提供了一个便捷方法 dealDamage,它会自动将自身注入上下文再调用 BFDamageApi.hurt()

// 传统写法
BFDamageContext ctx = BFDamageContext.builder()
    .source(source).baseDamage(35f).penetration(120f)
    .build();
float dealt = BFDamageApi.hurt(target, ctx.withHandler(this));

// 便捷写法
float dealt = this.dealDamage(target, ctx);

两种写法在逻辑上完全等价。使用 dealDamage 可以省略显式的 withHandler(this) 调用,并且让代码的语义更清晰——"我这个 handler 对目标造成了一次协议伤害"。在同一个类同时实现 BFDamageHandler 和武器逻辑的场景下(很常见),dealDamage 是最自然的选择。

Handler 作为类型标记

除了接收回调,BFDamageHandler 还有第二个作用:护甲侧可以通过 ctx.getHandler() instanceof MyGunHandler 来判断"是谁在打我"。

这个模式对于需要精细判定的护甲系统特别有用。例如,爆反装甲可能只对化学能弹药(HEAT)生效,对动能弹药(APFSDS)不生效。枪械模组可以在构造上下文时注入特定类型的 handler,护甲侧在 modifyPenetration 中检查:

// 护甲侧
@Override
public float modifyPenetration(BFDamageContext ctx) {
    float base = ctx.penetration();
    if (ctx.getHandler() instanceof HeatRoundHandler) {
        // HEAT 弹药:爆反拦截生效
        return base * 0.4f;  // 穿深衰减至 40%
    }
    return base;  // 动能弹药:爆反不生效
}

这个用法的前提是武器模组将 handler 的类暴露为公共 API,且 handler 的类型能传达弹药的类别信息。由于 handler 通常也实现了回调逻辑,将它与"弹药类型"绑定是自然的——一种弹药类型 = 一种 handler 实现。

完整实例:子弹击中后的全部反馈

以下是一个完整的 handler 实现,展示了一次子弹命中后武器侧可能做的所有反馈:

public class RifleBulletHandler implements BFDamageHandler {

    private final Level level;

    public RifleBulletHandler(Level level) {
        this.level = level;
    }

    @Override
    public void onPenetrated(BFHurtTarget target, BFDamageContext ctx) {
        // 穿透命中标记(红色)
        spawnHitMarker(ctx.hitPoint(), 0xFF0000);
        // 穿透火花
        level.addParticle(ParticleTypes.LAVA,
            ctx.hitPoint().x, ctx.hitPoint().y, ctx.hitPoint().z, 0, 0, 0);
    }

    @Override
    public void onBlocked(BFHurtTarget target, BFDamageContext ctx) {
        // 未击穿命中标记(白色)
        spawnHitMarker(ctx.hitPoint(), 0xFFFFFF);
        // 金属撞击音效
        level.playSound(null, ctx.hitPoint().x, ctx.hitPoint().y, ctx.hitPoint().z,
            SoundEvents.ANVIL_LAND, SoundSource.PLAYERS, 0.3f, 1.2f);
    }

    @Override
    public void onRicochet(BFHurtTarget target, BFDamageContext ctx) {
        // 跳弹音效 + 偏转粒子
        level.playSound(null, ctx.hitPoint().x, ctx.hitPoint().y, ctx.hitPoint().z,
            SoundEvents.ARROW_HIT, SoundSource.PLAYERS, 0.5f, 1.5f);
        spawnRicochetSparks(ctx.hitPoint(), ctx.hitNormal());
    }

    @Override
    public void onSpall(BFHurtTarget target, BFDamageContext ctx) {
        // 破片:在法线反方向(指向目标内部)产生二次破片弹丸
        Vec3 inward = ctx.hitNormal().scale(-1);
        for (int i = 0; i < 4; i++) {
            Vec3 spreadDir = inward.add(
                (random.nextFloat() - 0.5) * 0.5,
                (random.nextFloat() - 0.5) * 0.5,
                (random.nextFloat() - 0.5) * 0.5
            ).normalize();
            spawnFragmentBullet(ctx.hitPoint(), spreadDir, 0.3f);
        }
    }

    // 发起伤害的入口
    public float fireAt(Object target, DamageSource source, float baseDamage, float penetration,
                         Vec3 hitVelocity, Vec3 hitPoint, Vec3 hitNormal) {
        BFDamageContext ctx = BFDamageContext.builder()
            .source(source)
            .baseDamage(baseDamage)
            .hitVelocity(hitVelocity)
            .hitPoint(hitPoint)
            .hitNormal(hitNormal)
            .penetration(penetration)
            .build();
        return this.dealDamage(target, ctx);
    }
}

这个 handler 将回调逻辑和伤害发起逻辑封装在同一个类中——fireAt 方法负责构造上下文并发起协议伤害,回调方法负责接收事件并产生视觉和音效反馈。这种"一体两面"的设计是武器模组中最常见的 handler 使用模式。