把章节变成可单步观察的过程
先说结论
属性脏标记是在普通 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
一次成功消费包含三个提交动作:
- 写回新的
CurrentValue; - 清除该属性的
Dirty; - 通知观察者。
实体内所有脏属性处理后,再清除 EntityDirty。通知是否只在数值实际变化时触发,是系统契约的一部分;不能仅凭“执行过重算”推断“数值一定变化”。
7. 复杂度
设一个脏实体共有 A 个属性,其中 D 个属性脏;当前有 E 个效果,每个效果平均 M 个修改器:
O(A + D × E × M)
O(A):在脏实体内寻找具体脏属性;O(D × E × M):为每个脏属性聚合有效修改器。
这是一种属性级局部重算,而不是依赖图级增量计算。
8. 必须验证的边界
- 两级标记必须同时维护;
- 同帧多次写入是否只触发一次最终重算;
- 即时读取后统一阶段是否避免重复重算;
- 失效效果引用是否安全移除;
- 未激活修改器是否被跳过;
- 通知是否允许
oldValue == newValue; - 跨属性依赖是否有显式传播规则。
最后记住
全局 dirty 回答是否需要刷新。
局部 dirty 集合回答哪些属性需要重算。
同帧多次修改应合并为一次消费。
立即读取前必须 EnsureFresh,属性依赖也要传播脏标记。