06-Tag-Attribute与Effect实现
第 6 章:Tag、Attribute 与 Effect 实现
导入
上一章建立了五层架构。Definition 保存火球术的静态参数,Runtime 保存角色当前状态,Simulation 负责检查、Commit 和 Effect 推进。现在需要回答更具体的问题:
State.Stunned和Cooldown.Fireball如何避免拼写错误;- 两个来源同时授予
Effect.Burning时,移除一个来源为什么不能直接删 Tag; - Mana 的基础值、装备加成、Buff 百分比和临时覆盖如何得到最终值;
- 直接伤害 120 为什么适合 Instant Effect;
- 2 秒冷却和 5 秒 Burning 为什么必须成为可追踪的运行时实例;
- Burning 每 1 秒结算时,目标死亡、净化或重复应用应该怎样处理;
- Effect 结束后,Modifier、Tag、计时和表现如何完整撤销。
6.1 三个概念分别回答什么
| 概念 | 回答的问题 | 火球术示例 |
|---|---|---|
| Tag | 当前是否处于某种离散状态 | State.Stunned、Cooldown.Fireball、Effect.Burning |
| Attribute | 当前可计算数值是多少 | Mana、Health、AttackPower |
| Effect | 为什么发生变化,变化持续多久 | Mana -30、冷却 2 秒、伤害 120、Burning 5 秒 |
它们需要协作,但不能互相替代:
Cooldown.Fireball 是 Tag -> 表示当前存在冷却状态
Cooldown Effect 是 Active Effect -> 记录该状态由谁施加、何时开始、何时结束
2 秒是 Effect 的持续配置 -> 不是一个需要每帧手工写回 Tag 的 Attribute6.2 Tag
6.2.1 为什么不能到处传字符串
最小原型常常这样判断:
if (tags.Contains("State.Stuned")) return false;拼写错误不会被编译器发现,命名层级没有统一约束,重命名也无法可靠追踪。更麻烦的是,字符串容器通常只知道“有没有”,不知道“这个 Tag 有几个来源”。
Tag 至少需要四个约束:
- 空值和非法路径不能创建;
- 路径格式统一,例如使用
.分隔; - 比较规则固定,不受区域性字符串规则影响;
- 精确匹配与父级匹配必须显式区分。
public readonly struct GameplayTag : IEquatable<GameplayTag>{ public GameplayTag(string value) { if (string.IsNullOrWhiteSpace(value)) throw new ArgumentException("Tag cannot be empty.", nameof(value));
Value = value; }
public string Value { get; }
public bool Equals(GameplayTag other) { return StringComparer.Ordinal.Equals(Value, other.Value); }
public override bool Equals(object obj) { return obj is GameplayTag other && Equals(other); }
public override int GetHashCode() { return StringComparer.Ordinal.GetHashCode(Value); }
public override string ToString() { return Value; }}这只是最小骨架。生产实现还应在构建阶段校验字符集、连续分隔符、大小写策略和 Tag Catalog。重点是业务代码不再随手拼接 "Cooldown." + abilityName。
6.2.2 精确匹配与层级匹配
State.Control.Stunned 可以被看作一条层级路径:
StateState.ControlState.Control.Stunned查询时需要区分:
Exact: 查询 State.Control.Stunned 只匹配完全相同的 Tag
Hierarchical: 查询 State.Control 可以匹配 State.Control.Stunned不要让 HasTag() 隐含一种团队成员无法确定的匹配方式。接口应直接表达意图:
public interface ITagReader{ bool HasExact(GameplayTag tag); bool HasAnyExact(IReadOnlyList<GameplayTag> tags); bool Matches(GameplayTag parentTag);}火球术的冷却检查通常需要精确匹配 Cooldown.Fireball;“任意控制状态阻断施法”则可以查询 State.Control 的层级。
层级查询也不能仅靠未经约束的 StartsWith("State")。否则 Statement.* 也可能被错误命中。父子关系需要以完整路径片段为边界。
6.2.3 TagSet 为什么需要计数
假设目标同时被两个独立来源授予 Effect.Burning:
来源 A:火球术 Burning来源 B:环境火焰 Burning如果 TagSet 只是 HashSet<GameplayTag>,来源 A 到期时执行 Remove(Effect.Burning),会把来源 B 仍然有效的状态一起删除。
更稳妥的最小实现是引用计数:
public sealed class TagSet{ private readonly Dictionary<GameplayTag, int> counts = new();
public bool HasExact(GameplayTag tag) { return counts.TryGetValue(tag, out int count) && count > 0; }
public void Add(GameplayTag tag) { counts.TryGetValue(tag, out int count); counts[tag] = checked(count + 1); }
public void Remove(GameplayTag tag) { if (!counts.TryGetValue(tag, out int count)) throw new InvalidOperationException($"Tag not owned: {tag}");
if (count == 1) counts.Remove(tag); else counts[tag] = count - 1; }}计数解决了“还有几个来源”,但仍然没有回答“具体是哪些来源”。为了排错、净化和网络校正,实际项目通常还需要 EffectHandle 或 TagSourceId:
EffectHandle E1001 -> 贡献 Effect.BurningEffectHandle E1044 -> 贡献 Effect.Burning移除 E1001 时,只撤销 E1001 的贡献。TagSet 的聚合计数随来源表更新,而不是由业务代码猜测应该减几次。
6.2.4 Tag Query 不等于一串 if
火球术激活条件可以表达为:
Required: 已授予 Ability.Fireball
Blocked: State.Dead State.Stunned State.Silenced Cooldown.Fireball简单项目可以先使用 All、Any、None 三类查询:
public sealed class TagQuery{ public IReadOnlyList<GameplayTag> All { get; init; } public IReadOnlyList<GameplayTag> Any { get; init; } public IReadOnlyList<GameplayTag> None { get; init; }}查询对象是规则配置,不保存 Actor 当前 Tag。它可以被 Ability Definition、Effect Application Requirement 和 AI 条件复用。
6.3 Attribute
6.3.1 Base Value、Modifier 和 Current Value
一个 Attribute 不应该只有一个可被任意系统直接赋值的 float。
Base Value -> 角色成长、初始化或长期设置形成的基础值
Modifier -> 装备、Buff、Debuff、被动等运行时来源
Current Value -> 按统一规则聚合后,供战斗逻辑读取的最终值在本文的 Unity 方案中,使用以下聚合模型作为可解释的起点:
若存在有效 Override: Current = 选中的 Override Value
否则: Current = (Base + AdditiveSum) * MultiplicativeProduct对应示例:
Base = 100Additive = +20Multiplicative = x1.10Current = (100 + 20) * 1.10 = 132如果存在有效 Override 50,则最终值为 50。至于多个 Override 如何选择,必须有稳定规则,例如按优先级、应用序号或显式冲突策略决定,不能依赖字典遍历顺序。
6.3.2 Modifier 必须保留来源
Modifier 的一个简化结构如下:
public enum ModifierOperation{ Additive, Multiplicative, Override}
public readonly struct Modifier{ public Modifier( AttributeId attribute, ModifierOperation operation, double magnitude, EffectHandle source, int priority) { Attribute = attribute; Operation = operation; Magnitude = magnitude; Source = source; Priority = priority; }
public AttributeId Attribute { get; } public ModifierOperation Operation { get; } public double Magnitude { get; } public EffectHandle Source { get; } public int Priority { get; }}AttributeId、EffectHandle 和 Modifier 都是本文自定义类型。保留来源后,Effect 到期时可以按句柄删除自己贡献的 Modifier,而不是尝试执行一条“反向加减法”。
6.3.3 为什么不推荐用逆运算撤销 Buff
假设一个 Buff 应用时执行:
AttackPower *= 1.10到期时再执行:
AttackPower /= 1.10当多个 Buff、钳制、Override 和服务器校正交错时,逆运算很容易失效,也会积累浮点误差。更稳妥的方法是:
AttributeStore 保存 BaseModifier 集合保存每个来源查询时按稳定顺序重新聚合Effect 结束时删除该来源 Modifier状态撤销因此变成“移除来源并重新计算”。
6.3.4 Mana 与 Health 的修改入口
火球术的 Mana Cost 为 30。激活检查读取最终 Mana,Commit 则通过 Instant Effect 提交变化:
检查: Current Mana >= 30
提交: Mana Base/Current 进入项目定义的消耗入口 结果减少 30Health 的直接伤害同理。命中后服务器应用 120 点 Direct Damage Effect,而不是让 Ability 或投射物表现直接写字段。
资源和生命通常还需要最小值、最大值和死亡事件等规则。钳制应位于统一属性入口,例如:
Mana 约束在 [0, MaxMana]Health 约束在 [0, MaxHealth]不能让每个 Effect 自己决定是否钳制,否则回复、伤害、消耗和校正会产生不同结果。
6.4 Effect:把变化变成有来源的生命周期
6.4.1 Definition、Spec 与 Instance
Effect 也需要区分静态配置和运行时事实:
Effect Definition -> 这种效果是什么
Effect Spec -> 本次应用时解析出的参数快照
Effect Instance -> 应用到目标后正在生效的运行时对象以 Burning 为例:
Definition: Id = Effect.Burning Duration = 5 秒 Period = 1 秒 Granted Tag = Effect.Burning Tick Resolver = BurningTick
Spec: Source = 施法者 Target = 被命中者 CreatedBy = Ability.Fireball Level / Snapshot / Context = 本次应用上下文
Instance: Handle = E1001 StartTime = 20.0 EndTime = 25.0 NextTickTime = 按周期策略确定 State = ActiveDefinition 可以被多个技能和角色复用。Spec 固化本次应用所需的来源与数值上下文。Instance 只存在于目标 Runtime,用于推进、移除和同步。
6.4.2 Instant Effect
Instant Effect 在应用时完成一次变化,不需要作为持续对象长期留在 Active Effect 集合中。
火球术至少有两个 Instant Effect:
Mana Cost: Source/Target = 施法者 Mana -= 30
Direct Damage: Source = 施法者 Target = 被命中者 Health 结算直接伤害 120Instant 为了日志、伤害归因、幂等和网络结果,仍然应该保留一次应用记录或结算事件,只是不需要每个 Tick 更新它。
一个简化的应用入口可以是:
public EffectApplicationResult Apply( EffectSpec spec, CombatRuntime target){ if (!requirements.CanApply(spec, target)) return EffectApplicationResult.Rejected();
if (spec.DurationPolicy == EffectDurationPolicy.Instant) return ApplyInstant(spec, target);
EffectInstance instance = CreateInstance(spec, target); target.Effects.Add(instance); ApplyOngoingContributions(instance, target); return EffectApplicationResult.Applied(instance.Handle);}6.4.3 Duration Effect
Duration Effect 在有限时间内保持有效。火球术的冷却是最直接的例子:
Effect Id = Effect.Cooldown.FireballDuration = 2 秒Granted Tag = Cooldown.Fireball应用后,目标 Runtime 需要保存一个 Active Effect:
Handle = E2001StartTime = 100.0EndTime = 102.0Granted Tags: Cooldown.Fireball到期时,系统按 Handle 移除 E2001:
- 从 Active Effect 集合注销;
- 撤销 E2001 贡献的
Cooldown.Fireball; - 移除 E2001 贡献的持续 Modifier;
- 取消 E2001 的后续周期调度;
- 发布 Effect Removed 与对应 Cue;
- 保证重复 Remove 不会执行第二次副作用。
冷却剩余时间可以从 EndTime - Now 计算,不需要每帧把剩余秒数写成 Attribute。客户端 UI 可以显示倒计时,但权威条件判断应使用服务器 Active Effect 或对应 Tag。
6.4.4 Periodic Effect
Periodic 描述 Effect 生效期间是否按固定间隔触发。Burning 同时具有 Duration 与 Periodic:
Duration = 5 秒Period = 1 秒周期策略至少要明确以下问题:
- 第一次 Tick 在应用瞬间,还是 1 秒后;
- EndTime 恰好等于 NextTickTime 时,先 Tick 还是先到期;
- 一次推进跨过多个周期时,补算全部 Tick、合并处理还是丢弃过期 Tick;
- 目标死亡、免疫或 Effect 被净化时,未发生 Tick 是否取消;
- 客户端显示 Tick 是否必须逐次复制,还是可由服务器结果聚合。
本系列后续示例采用“由 Effect Definition 明确首跳策略”的原则。
6.4.5 使用绝对时间
下面的写法容易积累漂移:
remaining -= deltaTimenextTick -= deltaTimeif (nextTick <= 0) nextTick += period更稳定的方式是保存绝对时间:
StartTimeEndTimeNextTickTime调度器推进到 Now 时:
while (instance.NextTickTime <= now && instance.NextTickTime <= instance.EndTime){ ResolvePeriodicTick(instance); instance.MoveToNextTick();}
if (now >= instance.EndTime) Remove(instance.Handle, EffectRemoveReason.Expired);这段代码只是周期语义示例。生产实现还要定义首跳策略、Tick 上限、时间精度和同一时刻事件顺序。
服务器可以使用统一 Simulation Tick 或单调时钟。客户端展示剩余时间时,应根据服务器时间基准校正,而不是假设本地 Time.time 与服务器完全一致。
6.5 从 Effect 到 Attribute 和 Tag 的完整应用链
一次 Effect 应用可以按以下顺序处理:
1. 验证 Definition 和 Spec2. 检查目标 Application Requirements3. 分配稳定 EffectHandle4. 对 Instant Effect 执行一次结算5. 对持续 Effect 创建 EffectInstance6. 注册 Modifier 来源7. 注册 Granted Tag 来源8. 注册周期和到期调度9. 重新聚合受影响 Attribute10. 发布 Attribute / Tag / Effect / Cue 事件移除时按来源反向清理:
1. 将 EffectInstance 标记为 Removing2. 从调度器注销,封闭后续 Tick3. 移除该 Handle 的 Modifier4. 移除该 Handle 的 Granted Tag5. 重新聚合受影响 Attribute6. 从 Active Effect 集合移除7. 发布移除事件和结束 Cue8. 标记 Removed,重复调用直接返回先封闭周期入口很重要。否则 Effect 正在移除时,调度器又触发一个 Burning Tick,就可能出现“净化成功后仍然多掉一次血”的边界错误。
6.5.1 一个 Active Effect 的最小结构
public sealed class EffectInstance{ public EffectHandle Handle { get; init; } public EffectSpec Spec { get; init; } public double StartTime { get; init; } public double EndTime { get; init; } public double? NextTickTime { get; private set; } public EffectInstanceState State { get; private set; }
public void MarkRemoving() { if (State != EffectInstanceState.Active) return;
State = EffectInstanceState.Removing; }
public void MarkRemoved() { State = EffectInstanceState.Removed; }}6.6 叠加、刷新与重复应用
Burning 再次被应用时,系统必须选择明确策略。常见选项包括:
| 策略 | 行为 | 适用场景 |
|---|---|---|
| Independent | 每个来源各有独立实例和计时 | 多来源独立结算、归因重要 |
| Refresh Duration | 保留实例或层数,刷新到期时间 | 重复命中延长同一状态 |
| Stack Count | 增加层数,按层数影响 Modifier 或 Tick | 可叠层中毒、流血 |
| Replace | 新实例替换旧实例 | 只保留最新来源或最高优先级 |
| Reject | 已存在时拒绝新应用 | 不允许重复的姿态或唯一 Buff |
本章不替项目选择 Burning 的叠加策略,只要求 Definition 明确声明。没有策略时“先看列表里有没有同名 Effect”会产生大量隐式行为。
重复应用还要区分“同名”与“同一实例”。网络重发导致相同 RequestId 再次到达时,不应被当作一个新的 Burning 来源;真正来自另一次合法命中的 Burning,才可能按叠加策略处理。
6.7 用火球术验证数据模型
现在把统一参数放进完整链路。
6.7.1 激活和 Commit
施法者 Runtime: Mana = 80 不包含 Cooldown.Fireball
激活检查: Mana >= 30 Blocked Tag Query 不命中
Commit: 应用 Instant Mana Cost Effect Mana -= 30
应用 Duration Cooldown Effect Duration = 2 秒 Granted Tag = Cooldown.FireballCommit 后 Mana 为 50。冷却到期前,Tag Query 命中 Cooldown.Fireball,再次激活被拒绝。
6.7.2 命中和 Burning
服务器确认命中: 应用 Instant Direct Damage Effect Direct Damage = 120
应用 Burning Effect Duration = 5 秒 Period = 1 秒 Granted Tag = Effect.Burning直接伤害结算后,120 已经影响目标 Health。Burning 作为独立 Active Effect 留在目标 Runtime 中。它的每次周期结算由自身 Definition 和 Effect Spec 决定,不把 120 当成 Tick 数值。
6.7.3 Ability 结束后的状态
Fireball AbilityRuntime: 已 Ended
施法者 Runtime: Cooldown Effect 继续到 2 秒到期
目标 Runtime: Burning Effect 继续到 5 秒到期或被提前移除这再次说明 Ability 生命周期与 Effect 生命周期不同。能力结束时不能遍历并删除它创建的所有 Effect;哪些 Effect 应保留、哪些临时状态应撤销,要由各自 Definition 和提交策略决定。
6.8 浮点、顺序与可重复性边界
战斗数值不一定必须使用整数或定点数,但必须有一致策略。
6.8.1 不要比较未经规范化的时间相等
不要依赖:
if (now == endTime)应使用统一的 Tick、时间量化或明确的 >= 顺序规则。周期和到期发生在同一逻辑时刻时,也要规定处理优先级。
6.8.2 聚合顺序必须稳定
加法在常见数值范围内通常较直观,多个乘法、Override 和钳制的组合却可能因顺序不同产生差异。Modifier 应按明确规则排序,服务器、客户端预测和测试使用同一聚合器。
6.8.3 不把显示精度当作规则精度
UI 可以显示 49.9 或取整为 50,规则层仍应保存项目约定的真实精度。客户端显示四舍五入不能反向成为服务器资源检查依据。
6.8.4 金钱、层数和离散计数优先使用整数
Mana、Health 是否使用整数取决于项目规则,但 Tag 计数、Stack Count、RequestId 和 Tick 序号不应使用浮点表示。