DYNAMIC EXPLAINER / ARTICLE FLOW

把章节变成可单步观察的过程

01 / 11
STEP 01 / CODE / LOGIC

先说结论

属性脏标记是在普通 Dirty Flag 上增加“哪一项变了”。全局标记回答是否需要刷新,局部标记集合回答只重算 Attack、Defense 还是 Speed,避免一个速度 Buff 让全部属性重新计算。

  • BaseValue:不考虑临时修改器的基础值;
  • CurrentValue:基础值叠加当前有效修改器后的结果。
基础属性:Attack=100,Defense=50,Speed=6
新增 Buff:Attack +20%,Speed 不变
脏集合:{ Attack }
重算结果:Attack=120
未重算:Defense=50,Speed=6

先说结论

属性脏标记是在普通 Dirty Flag 上增加“哪一项变了”。全局标记回答是否需要刷新,局部标记集合回答只重算 Attack、Defense 还是 Speed,避免一个速度 Buff 让全部属性重新计算。

一次局部重算的数据

基础属性:Attack=100,Defense=50,Speed=6
新增 Buff:Attack +20%,Speed 不变
脏集合:{ Attack }
重算结果:Attack=120
未重算:Defense=50,Speed=6

同一帧再加入 Attack +10 时,脏集合仍只有一个 Attack;帧末根据明确的加法、乘法顺序重算一次。局部标记减少工作量,但也要求依赖关系准确,例如 MaxHP 改变是否会连带影响 CurrentHP 必须显式声明。

属性系统常同时保存两类值:

  • BaseValue:不考虑临时修改器的基础值;
  • CurrentValue:基础值叠加当前有效修改器后的结果。

每次写入都立刻重算虽然直观,却会让同一帧的连续变化重复遍历修改器。更实用的模型是:写入阶段只标脏,统一消费阶段再局部重算。

跟着代码做:从重复计算改成一次重算

具体场景是:角色在同一帧内更换武器、升级并获得 Buff,三次操作都会修改攻击力。先做一个最直接的属性类,每次修改基础攻击力都立即计算最终值:

sealed class AttackAttribute
{
    public float BaseValue { get; private set; } = 100f;
    public float CurrentValue { get; private set; } = 100f;
    public int RecalculateCount { get; private set; }

    public void SetBaseValue(float value)
    {
        BaseValue = value;
        Recalculate();
    }

    private void Recalculate()
    {
        RecalculateCount++;
        CurrentValue = BaseValue * 1.2f; // 教学示例:固定增加 20%
    }
}

同一帧连续处理装备、升级和临时修正:

AttackAttribute attack = new();
attack.SetBaseValue(110f);
attack.SetBaseValue(125f);
attack.SetBaseValue(130f);

Console.WriteLine(attack.CurrentValue);     // 156
Console.WriteLine(attack.RecalculateCount); // 3

最终只关心 BaseValue = 130,中间两次完整计算没有被任何系统读取,却仍然执行了三遍修改器遍历。

第二步:写入时只标脏

sealed class AttackAttribute
{
    public float BaseValue { get; private set; } = 100f;
    public float CurrentValue { get; private set; } = 100f;
    public bool Dirty { get; private set; }
    public int RecalculateCount { get; private set; }

    public void SetBaseValue(float value)
    {
        BaseValue = value;
        Dirty = true;
    }

    public void RefreshIfDirty()
    {
        if (!Dirty)
            return;

        RecalculateCount++;
        CurrentValue = BaseValue * 1.2f;
        Dirty = false;
    }
}

调用方式改成:

attack.SetBaseValue(110f);
attack.SetBaseValue(125f);
attack.SetBaseValue(130f);

// 本帧所有写入完成后,由属性系统统一消费。
attack.RefreshIfDirty();

Console.WriteLine(attack.CurrentValue);     // 156
Console.WriteLine(attack.RecalculateCount); // 1

三次写入都只是把同一个 Dirty 设为 true,消费阶段只重算一次。这就是同帧合并。

第三步:读取发生在统一消费之前

如果技能结算在本帧中途立刻读取:

attack.SetBaseValue(200f);
DealDamage(attack.CurrentValue); // 错:仍然可能读到旧值 156

需要提供一个保证新鲜的读取入口:

public float GetCurrentValue()
{
    RefreshIfDirty();
    return CurrentValue;
}

attack.SetBaseValue(200f);
DealDamage(attack.GetCurrentValue()); // 240

现在形成两条明确路径:

普通情况:多次写入 -> 帧末统一 RefreshIfDirty
立即读取:写入 -> GetCurrentValue -> 必要时提前刷新

后文的两级标记是在这个单属性版本上再增加一层:世界里可能有几千个实体,系统先用 EntityDirty 找到哪些实体值得进入,再用每个属性自己的 Dirty 找到具体要重算的字段。

1. 两级标记不是重复信息

可以把它想成办公楼的两层索引:

  • EntityDirty 是楼层门口的待办牌:调度器只进入挂了牌子的实体;
  • AttributeDirty 是房间内的具体清单:进入后只重算被勾选的属性。

只有实体标记,没有属性清单,系统会进入实体却找不到工作;只有属性清单,没有实体标记,查询阶段根本不会选中它。

写入攻击基础值
    ↓
Attack.Dirty = true        // 精筛:哪一个属性需要更新
    ↓
EntityDirty = true         // 粗筛:哪一个实体需要进入系统
    ↓
统一消费阶段扫描该实体

2. 同帧合并

同一帧内,装备、状态和升级可能连续改变属性。写入口只更新基础值或修改器状态,并重复写入同一布尔标记。最终消费阶段只看到“这个属性需要重算”,因此多个写入可以合并成一次计算。

这不是事件丢失:中间写入若需要审计,应由独立日志记录;脏标记表达的是“最终缓存已失效”,不是变更历史。

3. 局部重算

对一个脏属性,重算链可以抽象为:

float Recalculate(Entity entity, Attribute attribute)
{
    float next = attribute.BaseValue;

    // 只消费仍有效、仍激活且命中当前属性的修改器
    for (int i = entity.Effects.Count - 1; i >= 0; i--)
    {
        Effect effect = entity.Effects[i];
        if (!effect.IsValid)
        {
            entity.Effects.RemoveAt(i);
            continue;
        }

        if (!effect.Active)
            continue;

        next = ApplyMatchingModifiers(effect, attribute.Code, next);
    }

    next = Clamp(next, attribute.Min, attribute.Max);
    attribute.CurrentValue = next;
    attribute.Dirty = false;
    NotifyChanged(attribute.Code, next);
    return next;
}

从尾部向前删除失效引用,是为了避免删除后数组左移导致漏检下一项。

4. active modifiers

持续修改器只有在有效且激活时才参与 CurrentValue 聚合。一次性变化通常直接改变基础值;持续变化保留为独立修改器,让移除、失效或叠层变化后可以从基础值重新推导。

脏标记本身不负责推导依赖。若一个属性依赖另一个属性,需要显式建立依赖传播规则;不能假定修改 A 会自动标记 B。

5. 需要立即读取:ensureFresh

统一消费适合批处理,但有些写入后马上就要读取新值。此时使用单属性即时路径:

float EnsureFresh(Entity entity, Attribute attribute)
{
    if (attribute.Dirty)
        return Recalculate(entity, attribute);

    return attribute.CurrentValue;
}

即时路径只提前支付当前属性的重算成本。实体级标记仍可留给统一阶段清理;统一阶段看到该属性已经不脏,就不会重复聚合。

6. clear 与 notify

一次成功消费包含三个提交动作:

  1. 写回新的 CurrentValue
  2. 清除该属性的 Dirty
  3. 通知观察者。

实体内所有脏属性处理后,再清除 EntityDirty。通知是否只在数值实际变化时触发,是系统契约的一部分;不能仅凭“执行过重算”推断“数值一定变化”。

7. 复杂度

设一个脏实体共有 A 个属性,其中 D 个属性脏;当前有 E 个效果,每个效果平均 M 个修改器:

O(A + D × E × M)
  • O(A):在脏实体内寻找具体脏属性;
  • O(D × E × M):为每个脏属性聚合有效修改器。

这是一种属性级局部重算,而不是依赖图级增量计算。

8. 必须验证的边界

  • 两级标记必须同时维护;
  • 同帧多次写入是否只触发一次最终重算;
  • 即时读取后统一阶段是否避免重复重算;
  • 失效效果引用是否安全移除;
  • 未激活修改器是否被跳过;
  • 通知是否允许 oldValue == newValue
  • 跨属性依赖是否有显式传播规则。

最后记住

全局 dirty 回答是否需要刷新。
局部 dirty 集合回答哪些属性需要重算。
同帧多次修改应合并为一次消费。
立即读取前必须 EnsureFresh,属性依赖也要传播脏标记。