游戏开发日志 (其𝒾):ECS中的Buff系统实现思路

发表于: 7/25/2022

ECS

Buff Debuff System

Modifiers

最近一直在磕ECS,其将数据拆分为组件的想法深得我心,毕竟有些东西你总是要做了才知道合不合适,但是做完好不好改,可就不一定了。 学习了很多ECS的利弊,毕竟软件开发没有银弹,真实的世界处处充满了妥协。 而看到了有人说Buff/Debuff在ECS中很难高效实现,那今时不同往日,我自然是要试试,已经涌现了不少想法,必可活用于下一次。

期望

一个Buff/Debuff,最为普遍的效果便是对数值的增减,对一个或数个数值,一个或数个Buff产生效果,作用到一个或数个目标。我们自然是希望这个系统可以比较好的描述这些行为。

思路

我们将一个Buff视为一个Entity,Buff的效果暂且仅关注各种数值的Modifier,因为很显然不作数值影响的各类Buff是必定要进行实现的,不能简单的复用。

这样一个Buff与被作用者的关系我们可以这样描述。

flowchart TB
    subgraph BuffEntity["Buff (Entity)"]
    b1["Buff (component)"]
    b2["xxx Effect"]
    end
    subgraph AffectedEntity["Affected Entity"]
    a1["xxx Modifiable"]
    a2["xxx Modification"]

    a1 --> a2
    end

    a2 --> b2
    a2 -.-> BuffEntity

:::info 此处仅表示逻辑上的关系。 :::

通过实体上的Buff组件,我们可以以此来追踪Buff的作用时间,ECS范式下普遍支持组件的移除检测,这样我们在Buff结束后仅将组件移除,而在Buff的实际实现中监测组件移除,就可以正确的移除Buff生效时添加的效果。

xxx Effect即为我们实现的Buff的标记在系统中查询区分。

xxx Modifiable是我们获取数值时查询的组件。而xxx Modification用于存储所有影响该组件的Buff(Modifiers,Buff)。 则其实本系统与 Modifiables 几乎是无关的,在对应ModificationModifierBuff实体对应存储,用于Buff效果的解除。只需要每次更新中按照{一定顺序^大 坑}计算Modifier求值更新数值到Modifiable

关于计算顺序

目前也有一点想法,可以使用优先队列,通过优先级排序确定计算顺序,但是需要注意的是,同优先级不能有两种不同的计算方法。

Bonus

在此附上实验用到的代码,使用了Bevy实现,不过思路应该可以通用。

:::warn 声明:代码仅保证了没有编译错误(笑),未实际运行检验,也并不与上文所述思路完全一致。!!这一切完全都是在实践中一点点发现不可行性后修补的结果的说…!! :::

#[derive(Component)]
pub struct Vision(f32);

#[derive(Component)]
pub struct Buff {
    target: Entity,
    from: Entity,
    duration: f32,
}
#[derive(Component)]
pub struct VisionModification {
    ord1: Vec<ModifierEffect>,
    ord2: collections::BTreeSet<(i32, ModifierEffect, Entity)>,
}
#[derive(Component)]
pub enum ModifierEffect {
    ByValue(f32),
    ByPercentage(f32),
}
#[derive(Component)]
pub struct VisionModifierEffect;

fn buff_vision_modify_system(
    mut commands: Commands,
    mut vision_effects: Query<(&Buff, &ModifierEffect), With<VisionModifierEffect>>,
    mut visions: Query<(Entity, &Vision, &VisionModification)>,
    mut visions_removed: RemovedComponents<Buff>,
) {
    for (buff, vision_effect) in vision_effects.iter() {
        let vision = visions.get(buff.target).unwrap();
        match vision_effect {
            ModifierEffect::ByValue(value) => {
                commands
                    .entity(buff.target)
                    .insert(Vision(vision.1 .0 + value));
            }
            ModifierEffect::ByPercentage(factor) => {
                commands
                    .entity(buff.target)
                    .insert(Vision(vision.1 .0 * factor));
            }
        }
    }

    for e in visions_removed.iter() {
        if let Ok((buff, effect)) = vision_effects.get(e) {
            let vision = visions.get(buff.target).unwrap();
            match effect {
                ModifierEffect::ByValue(value) => {
                    commands.entity(e).insert(Vision(vision.1 .0 - value));
                }
                ModifierEffect::ByPercentage(factor) => {
                    commands.entity(e).insert(Vision(vision.1 .0 / factor));
                }
            }

            commands.entity(e).despawn();
        }
    }
}

fn buff_duration_system(
    mut commands: Commands,
    mut buffs: Query<(Entity, &Buff)>,
    time: Res<Time>,
) {
    for mutable in buffs.iter() {
        mutable.1.duration -= time.delta().as_secs_f32();
        if mutable.1.duration <= 0.0 {
            commands.entity(mutable.0).remove::<Buff>();
        }
    }
}

Bonus Plus

最初的构想其实是使用接口抽象,每个Buff都实现此接口,作为组件挂在被作用对象的实体上,在System中通过接口来获取所有Buff组件进行处理。 但这样做问题很多,

Unity ECS支持查询接口,代价是无法使用高性能的Burst编译器,最重要的是多个影响某个数值的Buff无法结算——其他同类型的组件不可见;且想要用泛型接口来统一时就会发现组件内不允许出现非值类型,即使最后实现接口的均是struct

而在Rust中类似的方法时使用traits作为类型参数,而trait objects无法在编译时时确定大小需要装箱,那就要给Box<trait>实现Component,也太怪了。

最后一定要吐槽的是,Unity的ECS文档目前写的真是屎一样,这样是最后尝试都在Bevy中做的原因。 虽然可能有还在experimental的缘故?看了一大堆不明所以的概念,最后试了一下确认了System创建后直接可以生效。也不得不说其实现也用着有点别扭(相对于Bevy)。可能是我先入为主吗?或者说是基础设施对人潜意识的引导?总觉得现在Unity的ECS^0.51^有点不伦不类;写到这里我突然意识到没看到有创建Entity相关的内容,赶紧去找了下,嗯… … 牌子、车、岔路.jpg