05-Unity复刻的整体架构
第 5 章:Unity 复刻的整体架构
问题导入
上一章已经把一次 Ability 拆成输入请求、激活检查、Commit、Target Data、Effect 结算、取消与结束六个阶段。现在准备在 Unity 中落地时,最容易走偏的第一步,是打开 IDE 创建一组与 UE 同名的 C# 类:
GameplayAbility.csGameplayEffect.csGameplayTag.csAbilitySystemComponent.cs类名齐了,不代表状态边界也齐了。如果 GameplayAbility.cs 仍然直接读取按键、修改 Mana、生成粒子、发送 RPC 和维护 Burning 计时器,那么它只是上一章的巨型技能脚本换了一套名字。
真正需要先决定的是依赖方向:
- 静态配置能不能被多个角色安全共享;
- 运行时规则能不能脱离场景和帧循环测试;
- 服务器与客户端是否复用同一套激活和 Effect 语义;
- 网络库替换时,战斗规则是否需要跟着重写;
- 动画、粒子和 UI 是否只能消费规则结果,而不能反向决定伤害;
- 一次 Ability 结束后,谁继续维护 2 秒冷却和 5 秒 Burning。
5.1 复刻的是职责,不是对象名单
在 UE GAS 中,AbilitySystemComponent、GameplayAbility、GameplayEffect、GameplayTag 和 AbilityTask 已经处在一套由引擎提供的对象模型、网络模型和生命周期中。项目使用这些类型时,可以依赖 UE 对激活、复制、预测和结束流程的既有约定。
在本文的 Unity 方案中,AbilityDefinition、AbilitySpec、AbilityRuntime、AttributeStore、EffectInstance、AbilityRequest 等名称都是本文自定义的设计类型,不是 Unity 内置 API。Unity 提供的是 ScriptableObject、MonoBehaviour、序列化、场景对象、协程等基础机制;能力系统语义需要项目自己定义。
因此,正确的映射不是:
UE 类名 -> 同名 C# 类而是:
UE GAS 解决的职责 -> 项目需要保留的语义 -> Unity 中合适的数据和生命周期 -> 网络库与表现层的适配方式5.2 五层架构
本文采用五层来承载前四章已经建立的语义:
| 层 | 典型内容 |
|---|---|
| Definition | Ability、Effect、Tag Query、数值参数 |
| Runtime | Attribute、Tag、AbilitySpec、Active Effect、运行中的 Ability |
| Simulation | 激活检查、Commit、Effect 应用、计时、取消 |
| Transport | Request、Result、Snapshot、序列号、网络适配 |
| Presentation | 输入桥接、动画、VFX、SFX、UI、镜头 |
依赖方向可以先压缩成一张图:
Authoring / Unity Assets | v Definition | v Runtime <-> Simulation ^ ^ | |Transport Presentation Adapter Adapter | | Network Unity Scene这张图表达的是逻辑依赖,不是要求每层只能有一个程序集。关键约束是:
- Runtime 和 Simulation 不依赖具体网络库;
- Runtime 和 Simulation 不直接查找
GameObject、Animator或粒子资源; - Transport 把网络消息转换成核心层理解的请求与结果,不重新实现战斗规则;
- Presentation 消费状态快照和 Cue,不直接写入权威 Attribute;
- Definition 可以使用 Unity 资产做编辑入口,但进入规则层前应转换为稳定、只读的运行时配置。
5.2.1 Definition
Definition 保存可复用的静态规则。火球术的以下内容属于 Definition:
AbilityId = Ability.FireballMana Cost = 30Cooldown = 2 秒Direct Damage = 120Burning Duration = 5 秒Burning Period = 1 秒Blocked Tags = State.Dead / State.Stunned / State.SilencedCooldown Tag = Cooldown.Fireball在本文的 Unity 方案中,可以用 ScriptableObject 作为策划编辑和资源引用入口。下面只有 ScriptableObject、CreateAssetMenu、SerializeField 属于 Unity;AbilityId、TagQueryDefinition 和 EffectDefinition 都是本文自定义类型。
[CreateAssetMenu(menuName = "Combat/Abilities/Fireball")]public sealed class FireballAbilityAsset : ScriptableObject{ [SerializeField] private string abilityId = "Ability.Fireball"; [SerializeField] private int manaCost = 30; [SerializeField] private float cooldownSeconds = 2f; [SerializeField] private int directDamage = 120; [SerializeField] private float burningDurationSeconds = 5f; [SerializeField] private float burningPeriodSeconds = 1f; [SerializeField] private TagQueryDefinition blockedTags; [SerializeField] private EffectDefinition burningEffect;
public FireballConfig BuildConfig() { return new FireballConfig( abilityId, manaCost, cooldownSeconds, directDamage, burningDurationSeconds, burningPeriodSeconds, blockedTags.BuildQuery(), burningEffect.BuildConfig()); }}FireballAbilityAsset 的职责是接受 Unity 序列化和 Inspector 编辑,再生成供核心层使用的只读配置。
这些都是运行时事实。若把它们写进共享的 ScriptableObject,两个角色使用同一资产时就可能覆盖彼此状态,编辑器停止播放后还可能留下难以追踪的脏数据。
5.2.2 Runtime
Runtime 是 Actor 级能力上下文。
在本文的 Unity 方案中,可以用一个纯 C# 的 CombatRuntime 表达这层语义:
public sealed class CombatRuntime{ public CombatRuntime( EntityId ownerId, AttributeStore attributes, TagSet tags, AbilitySpecCollection abilities, ActiveEffectCollection effects) { OwnerId = ownerId; Attributes = attributes; Tags = tags; Abilities = abilities; Effects = effects; }
public EntityId OwnerId { get; } public AttributeStore Attributes { get; } public TagSet Tags { get; } public AbilitySpecCollection Abilities { get; } public ActiveEffectCollection Effects { get; }}这里的全部类型都是本文自定义类型,不是 Unity 组件。它们可以存在于服务器进程、客户端预测副本、离线测试或战斗回放中。
一次火球术开始前,施法者 Runtime 可能是:
Attributes: Mana = 80
Tags: 不包含 State.Stunned 不包含 Cooldown.Fireball
Abilities: 拥有 Ability.Fireball 的 AbilitySpec
Effects: 没有火球术冷却Commit 后,施法者 Runtime 变成:
Attributes: Mana = 50
Tags: 包含 Cooldown.Fireball
Effects: 存在剩余约 2 秒的 Cooldown Effect命中后,目标 Runtime 则会保存 Health 的权威变化和一个持续 5 秒、每 1 秒结算的 Burning Effect。火球 Ability 本身可以结束,但这些 Runtime 状态继续存在。
5.2.3 Simulation
Runtime 保存事实,Simulation 推进事实。二者需要分开,否则 CombatRuntime 很快会膨胀成包含每一种技能分支的上帝对象。
Simulation 可以包含以下服务:
AbilityActivationService -> 查询 AbilitySpec、Tag、Attribute 和 Cooldown
AbilityCommitService -> 以统一边界提交 Cost 和 Cooldown
EffectApplicationService -> 创建 EffectInstance,修改 Attribute,贡献 Tag
EffectScheduler -> 推进 Duration 与 Periodic Effect
AbilityExecutionService -> 创建 AbilityRuntime,驱动状态迁移和取消
TargetValidationService -> 验证目标数据并生成可执行上下文通用原则:Simulation 应依赖显式输入,而不是在规则执行到一半时偷偷读取全局单例、场景时间或随机的 MonoBehaviour。
例如 Effect 的推进可以接收统一时钟:
public interface ICombatClock{ double Now { get; }}
public sealed class EffectScheduler{ private readonly ICombatClock clock;
public EffectScheduler(ICombatClock clock) { this.clock = clock; }
public void Advance(CombatRuntime target) { target.Effects.AdvanceTo(clock.Now); }}ICombatClock 和 EffectScheduler 是本文自定义类型。测试可以提供一个手动推进的时钟,服务器可以提供权威 Tick 时钟,客户端可以提供用于表现和预测的本地时钟。规则层不必直接依赖 Time.time,也不必为每个 Effect 启动独立协程。
5.2.4 Transport
在本文的 Unity 方案中,核心层只接受协议数据:
public readonly record struct AbilityRequest( string RequestId, ulong SpecId, string AbilityId, ulong PredictionKey, uint ClientTick, uint ClientSequence, TargetData TargetData);
public readonly record struct AbilityResult( string RequestId, ulong PredictionKey, AbilityResultKind Kind, AbilityResultStage Stage, AbilityResultCode Code, ulong ResultVersion, ulong AuthorityRevision, bool Committed, AuthoritativeState AuthoritativeState);这些都是本文自定义 DTO,不是 Unity Netcode for GameObjects、Mirror、Photon 或其他网络库的内置消息。
Transport 只能传递和关联结果,不能因为收到客户端的 TargetData 就直接对目标扣除 120 Health。最终结算仍然属于权威 Simulation。
5.2.5 Presentation
Presentation 面向 Unity 场景和玩家体验。它适合使用 MonoBehaviour、Animator、粒子、音频、相机和 UI,也可以根据本地玩家、其他玩家和旁观者采用不同表现策略。
它消费的应当是语义事件:
Cue.Fireball.CastStartedCue.Fireball.LaunchedCue.Fireball.ImpactCue.Burning.StartedCue.Burning.TickCue.Burning.Ended而不是把规则写进表现回调
动画事件可以推进 Ability,但它本身不是伤害权威。粒子没有播放成功,也不能撤销服务器已经确认的 Burning。
5.3 五层之间的依赖规则
分层只有在依赖可检查时才有价值。若所有层仍然可以互相调用,目录再整齐也只是视觉分类。
5.3.1 Definition 不引用运行时对象
Definition 可以引用其他静态定义,例如火球术引用 Cost、Cooldown、Damage 和 Burning 的 Effect Definition,但不能引用某个角色的 CombatRuntime、某次 AbilityRuntime 或场景中的目标对象。
可以把它理解为:
允许: Fireball Definition -> Mana Cost Effect Definition -> Cooldown Effect Definition -> Direct Damage Effect Definition -> Burning Effect Definition
禁止: Fireball Definition -> 当前玩家 Runtime -> 当前锁定目标 GameObject -> 上一次施法的 PredictionKeyDefinition 的共享性依赖它不可被一次执行污染。需要根据等级、装备或运行时参数调整数值时,应创建执行期的配置快照或 Effect Spec,而不是修改共享资产。
5.3.2 Runtime 保存状态,不选择表现
Runtime 可以保存 Effect.Burning 是否存在、下一次周期结算时间和来源句柄,但不应该保存“使用哪个火焰粒子预制体”这种表现决策。
同一份 Burning 状态可能有多种呈现:
- 本地玩家看到完整粒子、音效和屏幕边缘提示;
- 远端玩家只看到简化粒子;
- Dedicated Server 完全不创建表现对象;
- 自动化测试只断言 Tag、周期和到期时间。
把表现资源放进 Runtime,会迫使服务器和测试环境依赖 Unity 场景资源。
5.3.3 Simulation 不直接发送网络消息
Simulation 的输出应是结构化结果或领域事件,例如:
AbilityAcceptedAbilityRejectedAttributeChangedEffectAppliedEffectRemovedCueRequestedTransport 再决定这些结果发给谁、是否可靠发送、如何批量和序列化。这样规则测试只需要断言“服务器接受后 Mana 从 80 变为 50,并产生 2 秒冷却”,不需要等待一个真实 RPC。
5.3.4 Transport 不重写规则
客户端适配器和服务器适配器最容易产生一份“为了少调用几层”的平行规则:
客户端 RPC 入口检查一套 Mana 与 TagSimulation 又检查另一套 Mana 与 Tag本地预检查可以存在,但权威请求必须进入同一个 AbilityActivationService。Transport 只负责验证消息结构、连接上下文和基础协议合法性,战斗条件仍由 Simulation 判断。
5.3.5 Presentation 不拥有最终战斗事实
UI 可以显示预测 Mana,投射物表现可以预测飞行,动画可以先于服务器确认播放,但它们都必须接受后续确认或撤销。
Presentation 不应该提供这类入口:
public void OnImpactVisualPlayed(){ target.Health -= 120;}它应该接收已经关联来源和生命周期的 Cue:
public void HandleCue(GameplayCue cue){ cuePlayer.Play(cue);}GameplayCue 和 cuePlayer 是本文自定义的表现协议与适配对象,不是 Unity 内置 API。
5.4 纯 C# 核心与 MonoBehaviour 适配层
“纯 C# 核心”并不意味着项目不能使用 Unity,也不意味着所有对象都要手写引擎替代品。
一个常见组合是:
Unity 输入组件 -> 构造激活意图 -> 调用纯 C# Ability API
纯 C# Simulation -> 返回激活结果和领域事件
Unity 表现组件 -> 消费 Cue -> 播放动画、VFX、SFX、UI下面的 FireballInputBridge 是项目自定义的 MonoBehaviour 适配器;MonoBehaviour 和 SerializeField 是 Unity 类型,其他接口均属于本文方案:
public sealed class FireballInputBridge : MonoBehaviour{ [SerializeField] private TargetingView targetingView;
private IAbilityCommandPort abilityCommands; private EntityId localActorId;
public void Initialize( EntityId actorId, IAbilityCommandPort commands) { localActorId = actorId; abilityCommands = commands; }
public void OnFireballPressed() { TargetData target = targetingView.CaptureIntent(); abilityCommands.TryActivate( localActorId, "Ability.Fireball", target); }}这段桥接代码没有读取 Mana,也没有判断 State.Stunned。它可以做输入层面的可用性提示,但正式激活检查仍进入核心规则。
反方向上,表现适配器订阅 Cue:
public sealed class CombatCuePresenter : MonoBehaviour{ [SerializeField] private FireballView fireballView; [SerializeField] private BurningView burningView;
public void Present(GameplayCue cue) { switch (cue.Id) { case "Cue.Fireball.CastStarted": fireballView.PlayCast(cue); break; case "Cue.Fireball.Impact": fireballView.PlayImpact(cue); break; case "Cue.Burning.Started": burningView.StartLoop(cue); break; case "Cue.Burning.Ended": burningView.StopLoop(cue); break; } }}FireballView、BurningView 和 GameplayCue 都是本文自定义类型。这个示例没有规定动画系统、对象池或特效库,只说明表现消费规则语义。
5.4.1 哪些能力可以留在 Unity 适配层
下面这些职责适合由 Unity 侧适配:
- 输入系统回调;
Animator状态和动画事件;- 场景射线、碰撞查询和挂点位置采样;
- GameObject 与网络 EntityId 的映射;
- 粒子、音效、镜头和 UI;
Update或 Fixed Tick 对核心时钟的驱动;ScriptableObject到只读配置的构建。
但适配层输出的场景结果仍需进入规则边界。例如场景射线得到一个候选目标,不代表它已经通过服务器的距离、阵营和可受击验证。
5.5 建议的目录与程序集边界
下面是一种可逐步采用的组织方式:
Combat/ Core/ Definitions/ Runtime/ Simulation/ Abstractions/
Authoring/ AbilityAssets/ EffectAssets/ TagCatalogAssets/
Transport/ Contracts/ Server/ Client/ Adapters/
Presentation/ Input/ Animation/ Cues/ UI/
Tests/ Rules/ Lifecycle/ Protocol/如果项目使用 Assembly Definition,可以进一步约束:
Combat.Core -> 不引用 UnityEngine
Combat.Authoring -> 引用 Combat.Core -> 引用 UnityEngine
Combat.Transport -> 引用 Combat.Core -> 网络库只出现在 Adapters
Combat.Presentation -> 引用 Combat.Core -> 引用 UnityEngine
Combat.Tests -> 主要引用 Combat.Core完全移除 Combat.Core 对 UnityEngine 的引用不是唯一正确答案,但它是一个很有价值的约束信号:如果属性聚合测试必须创建 GameObject,通常说明规则层仍然泄漏了场景依赖。
5.6 最小版本与多人版本
GAS 风格设计很容易诱导团队一次实现“最终形态”。更稳妥的做法是先交付一个垂直切片,再逐层增加网络能力。
5.6.1 最小可行版本
单机或服务器先行的最小版本可以只包含:
| 能力 | 最小实现 |
|---|---|
| Definition | Ability 与 Effect 的只读配置 |
| Runtime | Attribute、Tag、AbilitySpec、Active Effect |
| Simulation | 激活检查、Commit、Instant/Duration/Periodic |
| 生命周期 | Ability 开始、运行、取消、幂等结束 |
| 时间 | 可注入时钟或显式 Tick |
| Presentation | Cue 事件和少量 Unity 适配器 |
| 测试 | 不启动场景即可验证规则和到期清理 |
用火球术验收时,应至少能证明:
- Mana 少于 30 时拒绝激活;
- 激活成功后 Mana 扣除 30;
Cooldown.Fireball持续 2 秒并自动撤销;- 命中时应用 120 点直接伤害;
- Burning 持续 5 秒,每 1 秒触发一次周期结算;
- Ability 结束后 Burning 仍由目标 Runtime 继续维护;
- 取消时 Task、临时 Tag 和事件订阅都被清理。
这个版本还不需要 Prediction Journal、状态快照或真实 RPC。它先证明规则语义和生命周期成立。
5.6.2 多人版本
多人版本在最小实现上增加:
| 新边界 | 需要补充的能力 |
|---|---|
| Authority | 服务器拥有最终激活、Commit、伤害与 Effect 写入权 |
| Protocol | AbilityRequest、AbilityResult、Snapshot、稳定错误码 |
| Idempotency | RequestId 去重,重复请求返回相同结果 |
| Prediction | PredictionKey 关联本地临时变化与服务器结果 |
| Reconciliation | Confirm、Reject、Correct 与可逆记录 |
| Replication | Attribute、Tag、Active Effect 和 Cue 的状态或增量同步 |
| Time | 客户端表现时间与服务器权威时间的映射 |
客户端和服务器可以运行相同的规则语义,但只有服务器能把结果写成最终事实;客户端的本地变化必须被标记为预测。
5.7 用五层走一遍火球术
把同一发火球放回五层,数据流会变得清楚:
Definition Fireball: Mana Cost = 30 Cooldown = 2 秒 Direct Damage = 120 Burning = 5 秒,Period = 1 秒
Presentation 玩家按键并采集 TargetData
Transport 构造 AbilityRequest
Simulation 查询施法者 Runtime 检查 AbilitySpec、Mana、Tag、Cooldown、TargetData Commit Mana Cost 与 Cooldown
Runtime Mana 变化 添加 Cooldown Effect 创建本次 AbilityRuntime
Simulation 命中后应用 Direct Damage 与 Burning
目标 Runtime Health 受到 120 点直接伤害 持有 5 秒 Burning Effect 每 1 秒进入一次周期结算
Presentation 消费 Cast、Launch、Impact、Burning Started/Ended Cue