05-Unity复刻的整体架构

3721 字
19 分钟
05-Unity复刻的整体架构

第 5 章:Unity 复刻的整体架构#

问题导入#

上一章已经把一次 Ability 拆成输入请求、激活检查、Commit、Target Data、Effect 结算、取消与结束六个阶段。现在准备在 Unity 中落地时,最容易走偏的第一步,是打开 IDE 创建一组与 UE 同名的 C# 类:

GameplayAbility.cs
GameplayEffect.cs
GameplayTag.cs
AbilitySystemComponent.cs

类名齐了,不代表状态边界也齐了。如果 GameplayAbility.cs 仍然直接读取按键、修改 Mana、生成粒子、发送 RPC 和维护 Burning 计时器,那么它只是上一章的巨型技能脚本换了一套名字。

真正需要先决定的是依赖方向:

  • 静态配置能不能被多个角色安全共享;
  • 运行时规则能不能脱离场景和帧循环测试;
  • 服务器与客户端是否复用同一套激活和 Effect 语义;
  • 网络库替换时,战斗规则是否需要跟着重写;
  • 动画、粒子和 UI 是否只能消费规则结果,而不能反向决定伤害;
  • 一次 Ability 结束后,谁继续维护 2 秒冷却和 5 秒 Burning。

5.1 复刻的是职责,不是对象名单#

在 UE GAS 中AbilitySystemComponentGameplayAbilityGameplayEffectGameplayTagAbilityTask 已经处在一套由引擎提供的对象模型、网络模型和生命周期中。项目使用这些类型时,可以依赖 UE 对激活、复制、预测和结束流程的既有约定。

在本文的 Unity 方案中AbilityDefinitionAbilitySpecAbilityRuntimeAttributeStoreEffectInstanceAbilityRequest 等名称都是本文自定义的设计类型,不是 Unity 内置 API。Unity 提供的是 ScriptableObjectMonoBehaviour、序列化、场景对象、协程等基础机制;能力系统语义需要项目自己定义。

因此,正确的映射不是:

UE 类名 -> 同名 C# 类

而是:

UE GAS 解决的职责
-> 项目需要保留的语义
-> Unity 中合适的数据和生命周期
-> 网络库与表现层的适配方式

5.2 五层架构#

本文采用五层来承载前四章已经建立的语义:

典型内容
DefinitionAbility、Effect、Tag Query、数值参数
RuntimeAttribute、Tag、AbilitySpec、Active Effect、运行中的 Ability
Simulation激活检查、Commit、Effect 应用、计时、取消
TransportRequest、Result、Snapshot、序列号、网络适配
Presentation输入桥接、动画、VFX、SFX、UI、镜头

依赖方向可以先压缩成一张图:

Authoring / Unity Assets
|
v
Definition
|
v
Runtime <-> Simulation
^ ^
| |
Transport Presentation
Adapter Adapter
| |
Network Unity Scene

这张图表达的是逻辑依赖,不是要求每层只能有一个程序集。关键约束是:

  1. Runtime 和 Simulation 不依赖具体网络库;
  2. Runtime 和 Simulation 不直接查找 GameObjectAnimator 或粒子资源;
  3. Transport 把网络消息转换成核心层理解的请求与结果,不重新实现战斗规则;
  4. Presentation 消费状态快照和 Cue,不直接写入权威 Attribute;
  5. Definition 可以使用 Unity 资产做编辑入口,但进入规则层前应转换为稳定、只读的运行时配置。

5.2.1 Definition#

Definition 保存可复用的静态规则。火球术的以下内容属于 Definition:

AbilityId = Ability.Fireball
Mana Cost = 30
Cooldown = 2 秒
Direct Damage = 120
Burning Duration = 5 秒
Burning Period = 1 秒
Blocked Tags = State.Dead / State.Stunned / State.Silenced
Cooldown Tag = Cooldown.Fireball

在本文的 Unity 方案中,可以用 ScriptableObject 作为策划编辑和资源引用入口。下面只有 ScriptableObjectCreateAssetMenuSerializeField 属于 Unity;AbilityIdTagQueryDefinitionEffectDefinition 都是本文自定义类型。

[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);
}
}

ICombatClockEffectScheduler 是本文自定义类型。测试可以提供一个手动推进的时钟,服务器可以提供权威 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 场景和玩家体验。它适合使用 MonoBehaviourAnimator、粒子、音频、相机和 UI,也可以根据本地玩家、其他玩家和旁观者采用不同表现策略。

它消费的应当是语义事件:

Cue.Fireball.CastStarted
Cue.Fireball.Launched
Cue.Fireball.Impact
Cue.Burning.Started
Cue.Burning.Tick
Cue.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
-> 上一次施法的 PredictionKey

Definition 的共享性依赖它不可被一次执行污染。需要根据等级、装备或运行时参数调整数值时,应创建执行期的配置快照或 Effect Spec,而不是修改共享资产。

5.3.2 Runtime 保存状态,不选择表现#

Runtime 可以保存 Effect.Burning 是否存在、下一次周期结算时间和来源句柄,但不应该保存“使用哪个火焰粒子预制体”这种表现决策。

同一份 Burning 状态可能有多种呈现:

  • 本地玩家看到完整粒子、音效和屏幕边缘提示;
  • 远端玩家只看到简化粒子;
  • Dedicated Server 完全不创建表现对象;
  • 自动化测试只断言 Tag、周期和到期时间。

把表现资源放进 Runtime,会迫使服务器和测试环境依赖 Unity 场景资源。

5.3.3 Simulation 不直接发送网络消息#

Simulation 的输出应是结构化结果或领域事件,例如:

AbilityAccepted
AbilityRejected
AttributeChanged
EffectApplied
EffectRemoved
CueRequested

Transport 再决定这些结果发给谁、是否可靠发送、如何批量和序列化。这样规则测试只需要断言“服务器接受后 Mana 从 80 变为 50,并产生 2 秒冷却”,不需要等待一个真实 RPC。

5.3.4 Transport 不重写规则#

客户端适配器和服务器适配器最容易产生一份“为了少调用几层”的平行规则:

客户端 RPC 入口检查一套 Mana 与 Tag
Simulation 又检查另一套 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);
}

GameplayCuecuePlayer 是本文自定义的表现协议与适配对象,不是 Unity 内置 API。

5.4 纯 C# 核心与 MonoBehaviour 适配层#

“纯 C# 核心”并不意味着项目不能使用 Unity,也不意味着所有对象都要手写引擎替代品。

一个常见组合是:

Unity 输入组件
-> 构造激活意图
-> 调用纯 C# Ability API
纯 C# Simulation
-> 返回激活结果和领域事件
Unity 表现组件
-> 消费 Cue
-> 播放动画、VFX、SFX、UI

下面的 FireballInputBridge 是项目自定义的 MonoBehaviour 适配器;MonoBehaviourSerializeField 是 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;
}
}
}

FireballViewBurningViewGameplayCue 都是本文自定义类型。这个示例没有规定动画系统、对象池或特效库,只说明表现消费规则语义。

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 最小可行版本#

单机或服务器先行的最小版本可以只包含:

能力最小实现
DefinitionAbility 与 Effect 的只读配置
RuntimeAttribute、Tag、AbilitySpec、Active Effect
Simulation激活检查、Commit、Instant/Duration/Periodic
生命周期Ability 开始、运行、取消、幂等结束
时间可注入时钟或显式 Tick
PresentationCue 事件和少量 Unity 适配器
测试不启动场景即可验证规则和到期清理

用火球术验收时,应至少能证明:

  1. Mana 少于 30 时拒绝激活;
  2. 激活成功后 Mana 扣除 30;
  3. Cooldown.Fireball 持续 2 秒并自动撤销;
  4. 命中时应用 120 点直接伤害;
  5. Burning 持续 5 秒,每 1 秒触发一次周期结算;
  6. Ability 结束后 Burning 仍由目标 Runtime 继续维护;
  7. 取消时 Task、临时 Tag 和事件订阅都被清理。

这个版本还不需要 Prediction Journal、状态快照或真实 RPC。它先证明规则语义和生命周期成立。

5.6.2 多人版本#

多人版本在最小实现上增加:

新边界需要补充的能力
Authority服务器拥有最终激活、Commit、伤害与 Effect 写入权
ProtocolAbilityRequestAbilityResult、Snapshot、稳定错误码
IdempotencyRequestId 去重,重复请求返回相同结果
PredictionPredictionKey 关联本地临时变化与服务器结果
ReconciliationConfirm、Reject、Correct 与可逆记录
ReplicationAttribute、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
05-Unity复刻的整体架构
https://blog.rouming-fei.top/posts/unity-replicated-the-overall-architecture/
作者
废江流
发布于
2026-08-28
许可协议
CC BY-NC-SA 4.0

评论区

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

文章目录