2000 字
10 分钟
数据驱动 Gameplay——DataAsset、DataTable 与配置边界

一、为什么 Gameplay 代码会越来越像配置表#

最开始做一个物品系统,可能只需要一个结构体:

struct FItem {
FString Name;
int32 Damage;
int32 Rarity;
};

很快它会变成:

if (ItemId == "IronSword") {
Damage = 100;
PlaySpecialEffect = true;
} else if (ItemId == "HealingPotion") {
Damage = 0;
AddHealingEffect = true;
}

当数据开始进入 if-else,系统就出现了三个问题:

  • 策划改一个数值需要改代码、重新编译
  • 新增一种物品会不断扩大分支,测试范围也跟着扩大
  • 规则、资源路径和运行时逻辑混在一起,没人知道谁才是权威

数据驱动的目标不是“所有东西都做成表”,而是把变化频率不同、拥有者不同、生命周期不同的内容分到合适的载体


二、四种数据载体先分清楚#

载体适合存什么典型拥有者
UDataAsset一条复杂对象定义,字段多、引用资产多设计师 / Gameplay 系统
UDataTable大量同构行数据,适合批量编辑策划 / 数据团队
UDeveloperSettings / Config项目或平台配置程序 / 技术美术
代码常量 / 枚举编译期不应随内容变化的协议程序

可以用一个简单的问题做选择:

这一项数据是不是“一个有身份的对象定义”?
→ 是:DataAsset
是不是“很多行字段完全相同的数据”?
→ 是:DataTable
是不是“这个项目/平台的运行配置”?
→ 是:Config
改它是否应该改变二进制接口或状态机协议?
→ 是:代码 / 枚举

三、DataAsset:复杂定义的容器#

UDataAsset 适合表达一个需要编辑器资产身份的定义,例如一把武器、一种技能、一个角色原型:

UCLASS(BlueprintType)
class UWeaponDefinition : public UPrimaryDataAsset {
GENERATED_BODY()
public:
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
FName WeaponId;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
FText DisplayName;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
int32 BaseDamage = 0;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
TSoftObjectPtr<USkeletalMesh> Mesh;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
TSubclassOf<UGameplayAbility> AbilityClass;
};

它的优势是:

  • 可以有复杂的嵌套结构
  • 可以引用软资产,配合前一篇的 Asset Manager
  • 每个定义都有独立的资产文件和版本
  • 可以被蓝图和编辑器直接消费

1 DataAsset 不应该存运行时状态#

// 错误:把玩家当前耐久写回“武器定义”
Definition->CurrentDurability = 37;

WeaponDefinition 是模板,所有玩家共享同一个定义。运行时状态应该放在实例上:

USTRUCT()
struct FWeaponInstance {
GENERATED_BODY()
UPROPERTY()
FPrimaryAssetId DefinitionId;
UPROPERTY()
int32 CurrentDurability = 0;
};

Definition 描述“它是什么”,Instance 描述“这一份现在怎样”。 这条边界对存档和网络复制都非常重要。


四、DataTable:大量同构行数据#

当数据是上百条结构相同的行时,UDataTable 更方便:

USTRUCT(BlueprintType)
struct FItemDataRow : public FTableRowBase {
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadOnly)
FText DisplayName;
UPROPERTY(EditAnywhere, BlueprintReadOnly)
int32 MinDamage = 0;
UPROPERTY(EditAnywhere, BlueprintReadOnly)
int32 MaxDamage = 0;
UPROPERTY(EditAnywhere, BlueprintReadOnly)
FGameplayTag RarityTag;
};
const FItemDataRow* Row = ItemTable->FindRow<FItemDataRow>(
FName(TEXT("HealingPotion")),
TEXT("ItemLookup"));
if (Row) {
CurrentItem.MinDamage = Row->MinDamage;
}

DataTable 适合:

  • 物品基础数值
  • 等级经验曲线的离散采样点
  • 文本、标签、权重等表格式内容
  • 从外部表格导入的大批量数据

它不适合承载复杂的对象图。一个 DataTable 单元格里塞大量软引用、嵌套数组和特殊规则后,编辑体验和依赖追踪都会变差,这时应该回到 DataAsset 或拆成多个数据源。

1 RowName 是数据的一部分#

不要只把 RowName 当成编辑器里的行号。它会被存档、配置、脚本和网络消息引用:

FName ItemId = TEXT("IronSword");

删除或重命名 RowName 相当于改变了一个外部 ID。正式项目应当有稳定的业务 ID,必要时额外维护旧 ID 到新 ID 的迁移表。


五、Config:配置和 Gameplay 数据不是一回事#

配置更适合“这个项目怎么运行”:

UCLASS(Config = Game, DefaultConfig)
class UGameplayDeveloperSettings : public UDeveloperSettings {
GENERATED_BODY()
public:
UPROPERTY(Config, EditAnywhere, Category = "Network")
float ServerActionTimeout = 0.25f;
UPROPERTY(Config, EditAnywhere, Category = "Debug")
bool bEnableVerboseGameplayLog = false;
};

配置的特点:

  • 一般按项目、平台或环境区分
  • 更接近工程设置,不是游戏内某个具体内容
  • 可能在打包前由不同构建配置覆盖
  • 不适合表达玩家可收集、可版本迁移的运行状态

“一把武器的伤害”通常是 Gameplay 数据;“服务器最多允许多少玩家”通常是 Config。两者都能存一个整数,但修改者、生命周期和发布流程完全不同。


六、数据和规则要有单一权威#

最危险的状态是同一份数据有多个来源:

代码默认伤害 = 100
DataTable 伤害 = 120
蓝图覆盖伤害 = 130
服务器启动参数伤害 = 140

最后到底是多少,只能靠加载顺序决定。更好的方式是先定义权威层级:

基础定义(DataAsset / DataTable)
→ 平台或模式修正(Modifier)
→ 运行时状态(Instance)
struct FResolvedWeaponStats {
int32 Damage = 0;
float FireRate = 0.0f;
};
FResolvedWeaponStats ResolveStats(
const UWeaponDefinition& Definition,
const FGameplayTagContainer& ActiveTags) {
FResolvedWeaponStats Result;
Result.Damage = Definition.BaseDamage;
if (ActiveTags.HasTag(FGameplayTag::RequestGameplayTag(
TEXT("Mode.DoubleDamage")))) {
Result.Damage *= 2;
}
return Result;
}

定义负责基础值,修正器负责规则,实例负责当前耐久/等级。不要在 UI、AI、服务器结算各自计算一遍基础伤害。


七、运行时校验比编辑器红色提示更重要#

数据资产是内容团队的代码。它也需要校验:

bool UItemDefinition::IsDataValid(
TArray<FText>& ValidationErrors) {
bool bValid = Super::IsDataValid(ValidationErrors).IsValid();
if (ItemId.IsNone()) {
ValidationErrors.Add(FText::FromString(
TEXT("Item must have an ItemId")));
bValid = false;
}
if (MinDamage > MaxDamage) {
ValidationErrors.Add(FText::FromString(
TEXT("MinDamage cannot exceed MaxDamage")));
bValid = false;
}
return bValid;
}

至少应该检查:

  • ID 是否为空、是否重复
  • 数值范围是否合理
  • 必需的资产引用是否存在
  • GameplayTag 是否在项目字典中注册
  • 规则之间是否互相矛盾

越靠近内容源头发现错误,修复成本越低。 不要等玩家在正式服务器里遇到一件伤害为负数的物品,才在战斗代码里加补丁。


八、数据驱动不等于把逻辑塞进表格#

常见的过度数据驱动是做一张表:

ActionType | ParamA | ParamB | ConditionA | ConditionB | ...

然后运行时根据 ActionType 写一个巨大的 switch。这只是把复杂度从代码搬到了表格里,既没有真正解耦,调试还更困难。

更好的边界是:

数据:有哪些参数、引用哪些定义、数值是多少
代码:这些参数怎样解释、顺序怎样执行、失败怎样处理

如果某种行为有稳定的算法结构,可以用 UObject 策略类或 Gameplay Ability 表达,而不是让 DataTable 变成一门隐形脚本语言。


九、RPG 物品系统的数据布局#

ItemDefinition(DataAsset / Primary Asset)
├── ItemId、名称、稀有度、资源引用
└── 基础属性、装备规则
ItemTable(DataTable)
└── 大量物品的基础属性、权重、标签
GameplayDeveloperSettings(Config)
└── 技能前摇、最大任务数量、调试日志开关
ItemInstance(运行时 / SaveGame)
└── DefinitionId、持有者、耐久、已装备状态

回合开始时:

服务器读取 Definition / Table
→ 生成本回合的运行时实例
→ 只复制必要的公开状态
→ SaveGame 保存 DefinitionId + Instance 状态

这就把前面几篇内容串起来了:Asset Manager 管理定义,Replication 分发状态,SaveGame 保存实例,UI 只消费已经解析好的 ViewModel。


十、总结#

概念一句话
DataAsset一个有身份、可引用复杂资产的内容定义
DataTable大量同构行数据的批量编辑容器
Config项目、平台和环境的运行配置
Definition描述“它是什么”的共享模板
Instance描述“这一份现在怎样”的运行时状态
数据校验在内容进入运行时之前发现错误
单一权威同一项基础数据只能有一个明确来源

数据驱动的价值不是少写几行代码,而是让变化有正确的归属。 当程序、策划和运行时系统都知道谁负责什么,新增内容才不会变成一次全局搜索和祈祷。

数据驱动 Gameplay——DataAsset、DataTable 与配置边界
https://www.m4doka.xyz/posts/ue/ue-12-data-driven-gameplay/
作者
m4doka
发布于
2026-08-24
许可协议
CC BY-NC-SA 4.0