06-Tag-Attribute与Effect实现

3869 字
19 分钟
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 的 Attribute

6.2 Tag#

6.2.1 为什么不能到处传字符串#

最小原型常常这样判断:

if (tags.Contains("State.Stuned"))
return false;

拼写错误不会被编译器发现,命名层级没有统一约束,重命名也无法可靠追踪。更麻烦的是,字符串容器通常只知道“有没有”,不知道“这个 Tag 有几个来源”。

Tag 至少需要四个约束:

  1. 空值和非法路径不能创建;
  2. 路径格式统一,例如使用 . 分隔;
  3. 比较规则固定,不受区域性字符串规则影响;
  4. 精确匹配与父级匹配必须显式区分。
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 可以被看作一条层级路径:

State
State.Control
State.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.Burning
EffectHandle 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 = 100
Additive = +20
Multiplicative = x1.10
Current = (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 保存 Base
Modifier 集合保存每个来源
查询时按稳定顺序重新聚合
Effect 结束时删除该来源 Modifier

状态撤销因此变成“移除来源并重新计算”。

6.3.4 Mana 与 Health 的修改入口#

火球术的 Mana Cost 为 30。激活检查读取最终 Mana,Commit 则通过 Instant Effect 提交变化:

检查:
Current Mana >= 30
提交:
Mana Base/Current 进入项目定义的消耗入口
结果减少 30

Health 的直接伤害同理。命中后服务器应用 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 = Active

Definition 可以被多个技能和角色复用。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 结算直接伤害 120

Instant 为了日志、伤害归因、幂等和网络结果,仍然应该保留一次应用记录或结算事件,只是不需要每个 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.Fireball
Duration = 2 秒
Granted Tag = Cooldown.Fireball

应用后,目标 Runtime 需要保存一个 Active Effect:

Handle = E2001
StartTime = 100.0
EndTime = 102.0
Granted Tags:
Cooldown.Fireball

到期时,系统按 Handle 移除 E2001:

  1. 从 Active Effect 集合注销;
  2. 撤销 E2001 贡献的 Cooldown.Fireball;
  3. 移除 E2001 贡献的持续 Modifier;
  4. 取消 E2001 的后续周期调度;
  5. 发布 Effect Removed 与对应 Cue;
  6. 保证重复 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 -= deltaTime
nextTick -= deltaTime
if (nextTick <= 0) nextTick += period

更稳定的方式是保存绝对时间:

StartTime
EndTime
NextTickTime

调度器推进到 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 和 Spec
2. 检查目标 Application Requirements
3. 分配稳定 EffectHandle
4. 对 Instant Effect 执行一次结算
5. 对持续 Effect 创建 EffectInstance
6. 注册 Modifier 来源
7. 注册 Granted Tag 来源
8. 注册周期和到期调度
9. 重新聚合受影响 Attribute
10. 发布 Attribute / Tag / Effect / Cue 事件

移除时按来源反向清理:

1. 将 EffectInstance 标记为 Removing
2. 从调度器注销,封闭后续 Tick
3. 移除该 Handle 的 Modifier
4. 移除该 Handle 的 Granted Tag
5. 重新聚合受影响 Attribute
6. 从 Active Effect 集合移除
7. 发布移除事件和结束 Cue
8. 标记 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.Fireball

Commit 后 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 序号不应使用浮点表示。

06-Tag-Attribute与Effect实现
https://blog.rouming-fei.top/posts/06-tag-attribute-and-effect-implementation/
作者
废江流
发布于
2026-09-05
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
废江流
提灯寻影,灯到影灭。
分类
标签
站点统计
文章
19
分类
3
标签
16
总字数
62,531
运行时长
0 天
最后活动
0 天前

文章目录