UE5 RPG动态伤害公式:基于GAS Execution Calculations实现数据驱动计算
1. 项目概述:为什么我们需要动态伤害公式?
在UE5里做RPG,伤害计算是绕不开的核心。很多新手或者从传统蓝图转过来的开发者,习惯把伤害逻辑直接写在角色的蓝图事件图表里,比如一个“造成伤害”事件,后面跟着一堆加减乘除和条件判断。这种做法在小体量原型阶段没问题,但项目一旦复杂起来,你就会发现它变成了一个“屎山”——难以维护、难以扩展、难以调试。
想象一下这个场景:你的游戏有战士、法师、游侠三个职业,每个职业有10个技能。战士的“重劈”技能基础伤害是攻击力的150%,并且有20%概率造成双倍伤害;法师的“火球术”基础伤害是法术强度的200%,并且会受目标火焰抗性减免,还可能点燃目标造成持续伤害;游侠的“精准射击”则无视目标部分护甲。如果这些逻辑全用蓝图节点硬编码,你会发现:
- 修改成本高:老板说要把所有暴击概率从20%调到25%,你得打开几十个技能蓝图一个一个改。
- 复用性差:你想给一个新技能“雷霆一击”也加上和“重劈”一样的暴击机制,只能复制粘贴节点,一旦“重劈”的暴击逻辑改了,“雷霆一击”又得重新改。
- 平衡性调整噩梦:数值策划想测试不同伤害公式(比如从加法公式改为乘法公式)对游戏平衡的影响,程序员就得加班重写大量逻辑。
- 难以实现复杂交互:一个技能“削弱护甲”效果,需要让后续所有物理伤害都享受增益,这种全局性的影响用分散的蓝图逻辑实现起来非常别扭。
这就是为什么我们要引入UE5的Gameplay Ability System(GAS)以及其中的Execution Calculations(执行计算)。GAS提供了一套基于组件(AbilitySystemComponent)和属性(AttributeSet)的框架,而Execution Calculations则是这个框架里专门用来处理瞬时、复杂的游戏效果计算的利器。它把伤害、治疗、资源消耗等计算逻辑,从具体的技能或效果中解耦出来,变成一个独立的、可配置的、可复用的计算类。
简单说,我们的目标就是:告别硬编码,拥抱数据驱动。让伤害公式、暴击、格挡、属性克制等所有计算规则,都变成一个可以被技能、武器、Buff轻松引用的“计算器”。这样,策划在数据表里改几个数值,游戏里的伤害表现就立刻跟着变了,这才是现代游戏开发该有的效率。
2. GAS与Execution Calculations核心概念解析
在深入动手之前,我们必须先理清几个关键概念,否则很容易在UE5的C++和蓝图混合编程中迷失方向。
2.1 Gameplay Ability System (GAS) 基础框架
GAS不是某个具体的伤害算法,而是一套用于构建复杂角色能力系统的架构。你可以把它想象成一个专门为MOBA、MMO、RPG等游戏定制的“ECS”(实体组件系统)变体。它的核心是以下几个部分:
AbilitySystemComponent(ASC):这是GAS的心脏,通常作为一个组件挂载到你的角色Actor上。它负责管理这个角色的所有技能(Gameplay Ability)、属性(Attribute)和效果(Gameplay Effect)。任何想使用GAS功能的角色,都必须有一个ASC。AttributeSet:定义和存储角色的属性值,比如生命值(Health)、魔法值(Mana)、攻击力(Strength)、护甲(Armor)等。它是一组变量的集合,并负责处理属性值变化时的回调(如生命值降到0时触发死亡)。GameplayAbility(GA):代表一个具体的、可激活的技能或动作。比如“普通攻击”、“火球术”、“跳跃”。它定义了技能的冷却(Cooldown)、消耗(Cost)以及激活后要执行的具体逻辑(通常通过触发GameplayEffect来实现)。GameplayEffect(GE):这是GAS中施加影响的核心单元。它可以是持续性的(Buff/Debuff),也可以是瞬时的(一次伤害或治疗)。一个GameplayEffect主要做三件事:- 修改属性:给目标增加或减少
AttributeSet中的属性值。 - 施加标签:给目标添加或移除
GameplayTag,用于标识状态(如“燃烧中”、“眩晕”)。 - 执行计算:这是我们今天的重点,通过
Execution Calculation来执行复杂的、自定义的计算逻辑。
- 修改属性:给目标增加或减少
2.2 Execution Calculations 的角色与优势
GameplayEffectExecutionCalculation(简称ExecutionCalc)是一个C++类,它的使命就是在GameplayEffect被应用时,执行一次自定义的计算。
为什么说它是实现动态伤害公式的关键?因为传统的GameplayEffect修改属性是简单的“加/减一个值”,或者“乘以一个百分比”。但我们的伤害公式可能是:最终伤害 = (基础攻击力 + 武器伤害) * (1 + 力量加成系数) * 技能倍率 * (1 - 目标护甲减免率) * 暴击倍率(如果触发)
这种涉及多个属性、条件判断和随机数的复杂计算,简单的GE配置无法满足。ExecutionCalc允许你编写任意的C++代码来计算这个“最终值”。
它的工作流程可以简化为:
- 一个技能(
GameplayAbility)被激活。 - 该技能应用一个
GameplayEffect到目标身上。 - 这个GE配置了使用我们自定义的
MyDamageExecution类。 - UE5引擎会自动创建
MyDamageExecution的实例,并调用它的Execute函数。 - 在
Execute函数里,我们编写C++代码,读取施法者的攻击力、暴击率,读取目标的护甲、抗性,然后进行一系列计算,得出一个伤害值。 - 最后,将这个计算出的伤害值,作用到目标的“生命值”属性上。
它的核心优势在于:
- 集中化:所有伤害计算逻辑集中在少数几个
ExecutionCalc类里,一目了然。 - 数据驱动:计算公式是代码,但公式里的参数(如基础伤害、暴击率、护甲减免系数)可以从GE的数据资产(Data Asset)或上下文(Context)中读取,方便策划配置。
- 高度可复用:同一个
DamageExecution类,可以被“火球术”、“寒冰箭”、“背刺”等无数个技能引用,每个技能通过GE传递不同的参数(如技能倍率、伤害类型)。 - 性能可控:C++执行效率远高于复杂的蓝图节点链,对于高频次的伤害计算(如每秒多次的DOT伤害)更友好。
2.3 相关核心类与数据结构
要玩转ExecutionCalc,还需要熟悉它的左膀右臂:
FGameplayEffectSpec:你可以把它理解为一次GameplayEffect应用的“说明书”或“实例”。它包含了这次效果的所有具体参数,比如是谁发的(Instigator)、等级(Level)、以及通过SetSetByCallerMagnitude设置的动态参数。在我们的ExecutionCalc的Execute函数中,主要就是通过FGameplayEffectSpec来获取计算所需的数据。FGameplayEffectCustomExecutionParameters/FGameplayEffectCustomExecutionOutput:这是Execute函数的输入和输出参数。ExecutionParams:提供了本次执行的所有上下文信息,包括来源(Source)和目标(Target)的ASC和属性快照(Capture来的属性值),以及FGameplayEffectSpec。OutExecutionOutput:用于输出我们的计算结果。我们要告诉引擎,最终要修改目标的哪个属性,修改的值是多少。这是通过AddOutputModifier来完成的。
GameplayEffectAttributeCaptureDefinition:属性捕获定义。这是ExecutionCalc能读取到角色实时属性的关键。我们需要在类中声明想要捕获哪些属性(比如“攻击力”、“护甲”),然后在Execute时,这些属性的当前值会被捕获并供我们使用。这里有个重要细节:捕获可以指定是在效果应用的一瞬间(Snapshot)捕获,还是每次执行时(True)捕获。对于伤害计算,我们通常需要来源的当前攻击力(True)和目标的当前护甲(True)。
3. 实战:构建动态伤害与暴击执行计算类
理论说得再多,不如一行代码。我们从头开始,创建一个名为UGS_DamageExecution的计算类。
3.1 创建自定义Execution Calculation类
首先,在UE5 C++项目中,创建一个继承自UGameplayEffectExecutionCalculation的类。
// GS_DamageExecution.h #pragma once #include "CoreMinimal.h" #include "GameplayEffectExecutionCalculation.h" #include "GS_DamageExecution.generated.h" /** * 用于计算物理/魔法伤害的执行类,包含暴击判断。 */ UCLASS() class YOURPROJECT_API UGS_DamageExecution : public UGameplayEffectExecutionCalculation { GENERATED_BODY() public: UGS_DamageExecution(); // 声明我们要捕获的属性 FGameplayEffectAttributeCaptureDefinition SourceAttackPowerDef; FGameplayEffectAttributeCaptureDefinition SourceCritChanceDef; FGameplayEffectAttributeCaptureDefinition SourceCritMultiplierDef; FGameplayEffectAttributeCaptureDefinition TargetArmorDef; FGameplayEffectAttributeCaptureDefinition TargetFireResistDef; // 示例:火焰抗性 protected: // 重写执行函数 virtual void Execute_Implementation(const FGameplayEffectCustomExecutionParameters& ExecutionParams, FGameplayEffectCustomExecutionOutput& OutExecutionOutput) const override; };在构造函数中,我们需要初始化这些属性捕获定义,告诉GAS我们要捕获哪个属性集的哪个属性。
// GS_DamageExecution.cpp #include "GS_DamageExecution.h" #include "AttributeSet.h" // 你的自定义AttributeSet头文件 #include "GameplayEffectTypes.h" UGS_DamageExecution::UGS_DamageExecution() { // 定义捕获来源(攻击方)的属性:攻击力 FGameplayEffectAttributeCaptureDefinition AttackPowerDef; AttackPowerDef.AttributeSource = EGameplayEffectAttributeCaptureSource::Source; AttackPowerDef.AttributeToCapture = UYourAttributeSet::GetAttackPowerAttribute(); // 替换为你的属性Getter AttackPowerDef.bSnapshot = false; // 不捕获快照,使用实时值 SourceAttackPowerDef = AttackPowerDef; // 定义捕获来源的属性:暴击率 FGameplayEffectAttributeCaptureDefinition CritChanceDef; CritChanceDef.AttributeSource = EGameplayEffectAttributeCaptureSource::Source; CritChanceDef.AttributeToCapture = UYourAttributeSet::GetCritChanceAttribute(); CritChanceDef.bSnapshot = false; SourceCritChanceDef = CritChanceDef; // 定义捕获来源的属性:暴击伤害倍率 FGameplayEffectAttributeCaptureDefinition CritMultiplierDef; // ... 类似上述初始化 SourceCritMultiplierDef = CritMultiplierDef; // 定义捕获目标(受击方)的属性:护甲 FGameplayEffectAttributeCaptureDefinition ArmorDef; ArmorDef.AttributeSource = EGameplayEffectAttributeCaptureSource::Target; ArmorDef.AttributeToCapture = UYourAttributeSet::GetArmorAttribute(); ArmorDef.bSnapshot = false; TargetArmorDef = ArmorDef; // 将所有的捕获定义添加到RelevantAttributesToCapture数组中,这是必须的! RelevantAttributesToCapture.Add(SourceAttackPowerDef); RelevantAttributesToCapture.Add(SourceCritChanceDef); RelevantAttributesToCapture.Add(SourceCritMultiplierDef); RelevantAttributesToCapture.Add(TargetArmorDef); // ... 添加其他需要捕获的属性 }注意:这里的
UYourAttributeSet::GetXXXAttribute()需要替换成你项目中实际属性集的静态函数。你需要先在自定义的AttributeSet类里使用ATTRIBUTE_ACCESSORS宏来生成这些Getter。
3.2 实现Execute函数与动态伤害公式
现在来到最核心的Execute_Implementation函数。这里我们将实现完整的伤害流水线。
void UGS_DamageExecution::Execute_Implementation(const FGameplayEffectCustomExecutionParameters& ExecutionParams, FGameplayEffectCustomExecutionOutput& OutExecutionOutput) const { // 1. 获取执行上下文 const FGameplayEffectSpec& Spec = ExecutionParams.GetOwningSpec(); UAbilitySystemComponent* SourceASC = ExecutionParams.GetSourceAbilitySystemComponent(); UAbilitySystemComponent* TargetASC = ExecutionParams.GetTargetAbilitySystemComponent(); AActor* SourceActor = SourceASC ? SourceASC->GetAvatarActor() : nullptr; AActor* TargetActor = TargetASC ? SourceASC->GetAvatarActor() : nullptr; // 安全检查 if (!SourceASC || !TargetASC || !SourceActor || !TargetActor) { return; } // 2. 捕获属性值 FAggregatorEvaluateParameters EvalParams; EvalParams.SourceTags = Spec.CapturedSourceTags.GetAggregatedTags(); EvalParams.TargetTags = Spec.CapturedTargetTags.GetAggregatedTags(); float SourceAttackPower = 0.0f; ExecutionParams.AttemptCalculateCapturedAttributeMagnitude(SourceAttackPowerDef, EvalParams, SourceAttackPower); float SourceCritChance = 0.0f; ExecutionParams.AttemptCalculateCapturedAttributeMagnitude(SourceCritChanceDef, EvalParams, SourceCritChance); float SourceCritMultiplier = 1.5f; // 默认1.5倍 ExecutionParams.AttemptCalculateCapturedAttributeMagnitude(SourceCritMultiplierDef, EvalParams, SourceCritMultiplier); float TargetArmor = 0.0f; ExecutionParams.AttemptCalculateCapturedAttributeMagnitude(TargetArmorDef, EvalParams, TargetArmor); // 3. 从GameplayEffectSpec获取动态参数(例如:技能基础倍率) float SkillDamageMultiplier = 1.0f; if (Spec.SetByCallerTagValues.Contains(FGameplayTag::RequestGameplayTag(FName("Data.DamageMultiplier")))) { SkillDamageMultiplier = Spec.SetByCallerTagValues[FGameplayTag::RequestGameplayTag(FName("Data.DamageMultiplier"))]; } // 4. 核心伤害公式计算 float BaseDamage = SourceAttackPower * SkillDamageMultiplier; // 护甲减免公式(示例:每点护甲减少0.5%的物理伤害) float ArmorReductionFactor = FMath::Clamp(TargetArmor * 0.005f, 0.0f, 0.75f); // 最多减免75% float DamageAfterArmor = BaseDamage * (1.0f - ArmorReductionFactor); // 5. 暴击判定 bool bIsCriticalHit = false; float FinalDamage = DamageAfterArmor; // 使用一个确定性的随机种子,保证客户端和服务器计算结果一致 FRandomStream RandomStream; // 通常使用Ability的预测键(Prediction Key)或某个同步的种子来初始化RandomStream // 这里简化为使用当前帧数等,实际项目需要更严谨的同步方案 RandomStream.GenerateNewSeed(); float RandomRoll = RandomStream.FRandRange(0.0f, 1.0f); if (RandomRoll <= SourceCritChance) { bIsCriticalHit = true; FinalDamage *= SourceCritMultiplier; } // 6. 输出结果,修改目标的Health属性 if (FinalDamage > 0.0f) { // 找到目标Health属性的FProperty FProperty* HealthProperty = UYourAttributeSet::StaticClass()->FindPropertyByName(GET_MEMBER_NAME_CHECKED(UYourAttributeSet, Health)); if (HealthProperty) { // 创建一个Modifier,告诉GAS如何修改属性 FGameplayModifierEvaluatedData HealthModifier(HealthProperty, EGameplayModOp::Additive, -FinalDamage); // 伤害是负值 OutExecutionOutput.AddOutputModifier(HealthModifier); } } // 7. (可选)添加用于UI或特效的临时变量 FGameplayCueParameters CueParams; CueParams.RawMagnitude = FinalDamage; CueParams.AggregatedSourceTags = *Spec.CapturedSourceTags.GetAggregatedTags(); CueParams.AggregatedTargetTags = *Spec.CapturedTargetTags.GetAggregatedTags(); // 可以设置CueParams.Location, Normal等用于播放受击特效 if (bIsCriticalHit) { // 添加一个标签,表示这是暴击,可以被GameplayCue捕获来播放暴击特效和音效 OutExecutionOutput.AddOutputModifier(FGameplayModifierEvaluatedData(FGameplayTag::RequestGameplayTag(FName("Event.Damage.Critical")), EGameplayModOp::Additive, 1.0f)); } }3.3 在蓝图中配置GameplayEffect使用Execution
C++部分完成后,我们需要在编辑器中创建一个GameplayEffect蓝图来使用它。
- 在内容浏览器中右键,创建
蓝图类,父类选择GameplayEffect,命名为GE_Damage_Physical。 - 打开这个蓝图。
- 在
Duration Policy(持续时间策略)中选择Instant(瞬时),因为我们造成的是一次性伤害。 - 在
Modifiers(修饰符)数组中,点击+号添加一个。 - 在
Modifier Magnitude(修饰符量值)中,选择Custom Calculation Class(自定义计算类)。 - 在下拉菜单中,选择我们刚刚创建的
GS_DamageExecution类。 - 在
Attribute(属性)中,选择Health(或其他你最终想修改的属性)。注意:这里选择的属性是“最终要修改哪个属性”,具体的计算逻辑完全由我们的ExecutionCalc决定。即使我们在这里选了Health,ExecutionCalc内部也可以读取攻击力、护甲等其他属性进行计算。 - 关键步骤:传递动态参数。我们如何在技能里告诉这个GE,本次伤害的倍率是多少?这需要使用
Set by Caller。- 在
GameplayEffect蓝图的Set by Caller部分,可以定义一些GameplayTag和浮点值。但更常用的方法是在技能激活时动态设置。 - 在技能的蓝图中,当你应用这个
GE_Damage_Physical效果时,使用Make GameplayEffect Spec节点创建效果规格,然后使用Set Set by Caller Magnitude节点,传入一个GameplayTag(例如Data.DamageMultiplier)和一个值(如1.5代表150%倍率)。这样,这个值就会被传递到ExecutionCalc的FGameplayEffectSpec中,供我们在第3步读取。
- 在
3.4 实现暴击、格挡等判定逻辑的扩展
上面的示例已经包含了基础的暴击判定。在实际项目中,你可能需要更复杂的判定链,例如:先判断是否被格挡,如果格挡则减免部分伤害且不会暴击;再判断是否命中弱点(必定暴击);最后才是普通暴击判定。
这种逻辑非常适合在ExecutionCalc中实现,因为它能在一个地方顺序处理所有规则。你可以:
- 捕获更多属性:如目标的格挡率(BlockChance)、格挡减伤(BlockReduction)、弱点暴露标记(通过
GameplayTag判断)。 - 顺序判定:
// 伪代码逻辑 bool bBlocked = RandomRoll <= TargetBlockChance; if (bBlocked) { FinalDamage *= (1.0f - TargetBlockReduction); bCanCrit = false; // 格挡后不暴击 AddTag(“Event.Damage.Blocked”); } if (TargetHasTag(“Debuff.ExposedWeakpoint”)) { bIsCriticalHit = true; // 弱点暴露,强制暴击 RemoveTag(“Debuff.ExposedWeakpoint”); // 消耗掉这个Debuff } else if (bCanCrit) { // 正常暴击判定 bIsCriticalHit = RandomRoll <= SourceCritChance; } - 输出复杂结果:除了修改生命值,你还可以通过
AddOutputModifier输出多个结果,比如同时添加一个“本次攻击被格挡”的标签,或者添加一个“获得怒气”的临时属性修改,这些都可以被其他系统(如UI、技能、成就)捕获并响应。
4. 高级技巧、优化与疑难排查
当你的RPG系统越来越复杂,ExecutionCalc也会面临更多挑战。这里分享一些实战中积累的经验。
4.1 性能优化与最佳实践
- 减少捕获属性数量:
RelevantAttributesToCapture列表中的每个属性都会在每次执行时进行查询和捕获。只捕获计算真正需要的属性。如果一个Buff只影响最终伤害值,而不参与公式计算,就不要捕获它。 - 慎用Snapshot:
bSnapshot = true会在GE创建时捕获属性值并固定下来。这对于“基于施法时属性”的效果(如一个根据施法时法力值决定伤害的技能)很有用,但大多数动态伤害计算需要实时值(bSnapshot = false)。 - 使用GameplayTag进行分支判断:与其在
ExecutionCalc里用硬编码的if-else判断伤害类型(物理、火焰、奥术),不如使用GameplayTag。在GE上添加一个GameplayTag(如Damage.Type.Fire),在ExecutionCalc中读取这个Tag,然后决定使用哪一套抗性属性(FireResist)进行计算。这使得添加新的伤害类型只需配置数据,无需修改C++代码。 - 预计算与缓存:对于极其复杂的公式,如果发现它是性能瓶颈,可以考虑将部分不经常变化的参数(如角色的基础属性成长系数)预计算并缓存起来,而不是在每次伤害计算时都重新从属性集读取。
4.2 网络同步与预测(Prediction)要点
GAS内置了对客户端预测的良好支持,但ExecutionCalc需要特别注意:
- 确定性随机:暴击、命中、格挡等随机判定必须是确定性的。即服务器和客户端使用相同的随机种子和算法,算出相同的结果。上面的示例中使用了
FRandomStream,但种子需要同步。通常的做法是使用FActiveGameplayEffectHandle或Ability的ActivationPredictionKey作为种子的一部分。绝对不要使用FMath::FRand(),它在客户端和服务器上会产生不同结果。 - 只预测可预测的内容:属性修改(如扣血)本身可以被预测。客户端预测自己扣血,播放受击动画。但如果你的
ExecutionCalc有副作用,比如触发了一个服务器才有的全局事件(如广播全服公告),这个副作用不能被预测,需要在服务器执行后通过RPC同步。 - 正确处理Miss:如果计算结果是“未命中”(伤害为0),你仍然需要输出一个对Health的Additive 0修改。这是因为GAS需要记录这个效果确实被执行了,只是结果为零,这对于预测回滚和状态同步很重要。
4.3 常见问题与调试技巧
伤害计算为0或不起作用
- 检查属性捕获:在
Execute函数开头,打印(UE_LOG)所有捕获到的属性值,确认是否成功读取到了攻击力、护甲等。最常见的问题是属性定义(AttributeSet中的UPROPERTY)没有正确暴露,或者捕获定义中的AttributeToCapture指向了错误的属性。 - 检查ExecutionCalc配置:在GE蓝图中,确认
Custom Calculation Class已正确设置为你创建的类。 - 检查输出:确认
OutExecutionOutput.AddOutputModifier被成功调用,并且你修改的属性(如Health)确实存在于目标的AttributeSet中。 - 检查网络角色:在多人游戏中,确保伤害计算在服务器(
Authority)上执行。客户端的预测执行可能因为各种原因被服务器拒绝(Rejected)。
- 检查属性捕获:在
暴击等随机事件不同步
- 确保随机种子同步:这是最可能的原因。检查你的随机数生成器是否在客户端和服务器上以完全相同的方式初始化。使用与Ability预测键或GameplayEffect句柄相关的确定性种子。
- 使用GAS的
ShouldBroadcastAbilityTask:对于重要的、可视的随机事件(如暴击特效),即使预测成功了,也可能需要服务器通过RPC明确通知客户端播放,以确保万无一失。
如何调试复杂的计算公式?
- 使用
UE_LOG进行分步打印:将公式拆解,把每一步的中间结果(基础伤害、护甲减免后伤害、暴击前伤害、最终伤害)都打印到输出日志。 - 利用编辑器的
Debug功能:在游戏运行时,你可以选中一个角色,在World Outliner的细节面板中找到其AbilitySystemComponent,展开后可以查看当前激活的GameplayEffect和属性值,这有助于确认Buff是否生效,属性是否正确。 - 绘制调试字符串:在
ExecutionCalc中,可以使用DrawDebugString函数将本次伤害计算的简要信息(如“Damage: 150, CRIT!”)绘制在目标角色头顶,非常直观。
- 使用
与UI(伤害数字)和特效(受击、暴击光效)的联动
- 使用
GameplayCue:这是GAS中处理游戏内视觉、听觉反馈的推荐方式。在ExecutionCalc中,你可以根据计算结果(如是否暴击)来触发不同的GameplayCue。GameplayCue可以配置播放粒子特效、音效、动画等。 - 通过
GameplayTag传递信息:如上文示例,在输出中添加一个Event.Damage.Critical的标签。在UI组件中,可以监听ASC的GameplayEvent,当收到带有这个Tag的事件时,就显示一个特殊的暴击伤害数字UI。 - 使用
AbilityTask_WaitGameplayEvent:在技能或客户端的能力蓝图中,可以添加一个任务来等待特定的事件Tag,收到后执行播放特效、更新UI等本地操作。
- 使用
将伤害计算逻辑迁移到Execution Calculations,初期会感觉比直接写蓝图复杂。但一旦搭建好这个管道,你会发现整个项目的可维护性和扩展性有了质的飞跃。策划可以自由地调整数值、设计新的伤害类型和交互规则,而程序员只需要维护好那几个核心的计算类。这种架构上的清晰,是支撑一个大型、长期运营的RPG项目不可或缺的基石。