发表于: 7/25/2022
最近一直在磕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(Modifier
s,Buff
)。
则其实本系统与 Modifiable
s 几乎是无关的,在对应Modification
中Modifier
与Buff
实体对应存储,用于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