主题
GameData 数据驱动系统
概述
WasiCore 引擎的数据驱动架构由 IGameData(数编表)和 IGameLink(数编 Link)组成,实现游戏数据与逻辑的解耦。
- IGameData(数编表) — 定义游戏对象的静态数据结构和配置模板
- IGameLink(数编 Link) — 提供类型安全的数据引用和索引机制
- 数编类型 — 图形化项目在触发编辑器中定义;纯代码项目也可手写 C#
partial class。最终都由[GameDataCategory]/[GameDataNodeType<T,V>]驱动代码生成 - GameDataCategory<T> — 生成后的运行时基类,管理同一分类的数据集合和查找
先分清类型、条目和 Link
这三个概念处在不同层,不能互相替代:
| 概念 | 维护入口 | 决定什么 |
|---|---|---|
| 数编类型 | 图形化路线:触发编辑器的 Class;纯代码路线:src/**/*.cs | 数据编辑器可见的字段 schema、继承关系和可选行为 |
| 数编条目 | 数据编辑器 / editor/data/**/*.json,或少量代码注册 | 某一份具体配置的字段值 |
| 数编 Link | 通常由数据生成到 src/DataGenerated/** / ScopeData | 对具体条目的强类型引用 |
大多数需求只是在内置类型(如 GameDataUnit、GameDataAbility)下创建条目,不需要定义新类型。只有现有 schema 无法表达业务字段或需要专用行为时,才新增数编类型。
| 需求 | 推荐做法 |
|---|---|
| 给现有类型增加一份配置 | 用数据编辑器创建条目 |
| 在现有分类下增加字段或行为 | 图形化路线把触发编辑器 Class“设置为数编类型”;纯代码路线才手写 [GameDataNodeType<T,V>] |
| 建立与 Unit、Ability 等无关的独立数据族 | 图形化路线把触发编辑器 Class“设置为数编分类”;纯代码路线才手写 [GameDataCategory] |
| 只在本局临时构造、不需要编辑器维护 | 在统一初始化路径用代码创建 runtime-only 条目 |
data_create_table / data_create_entry 只能创建已有 CLR 类型的表和条目,不能仅靠 JSON 发明新的 C# 数编类型。
自定义数编类型:两条作者路线
text
图形化路线:触发编辑器 Class -> 保存到 editor/trigger -> 生成 TriggerGenerated
-> 项目编译 / GameData source generator -> CLR 类型与数据编辑器 schema
-> 数据编辑器条目(editor/data)-> DataGenerated / ScopeData -> 运行时 Link.Data
纯代码路线:src/**/*.cs 中的特性声明 -> 项目编译 / GameData source generator
-> 在统一初始化路径用 new GameData...(link) 注册条目 -> 运行时 Link.Data两条路线最终得到相同的 GameData 类型、Link 与运行时注册语义,但 authoring source 不同:图形化路线由触发编辑器维护类型、数据编辑器维护条目;纯代码路线由源码同时维护类型和条目。
图形化路线:触发编辑器定义类型,数据编辑器填写条目
如果项目希望继续使用图形化数据编辑器,这是默认路线。类型结构在触发编辑器中定义,具体条目和值在数据编辑器中维护;不要先手写 C# 特性再期待图形化编辑器接管这份类型声明。
新建独立数编分类
- 在触发编辑器选择 “共用(Common)” 环境,通过 “类定义” 新建并选中一个普通 Class,再添加需要暴露给数据编辑器的属性。
- 选中这个 Class,在属性面板勾选 “设置为数编分类”。
- 如果这个分类只作为子类型的根、不允许直接创建条目,同时启用 “禁止创建实例”。
- 先执行 “保存” 持久化触发定义,再执行 “生成代码文件” 刷新 C# 输出;这是两个独立动作。
生成的 src/TriggerGenerated/common.cs 会包含对应的 partial class 和 [GameDataCategoryAttribute]。后续 source generator 会补齐运行时基类、Link 构造器与注册样板。C# 中省略 Attribute 后缀写成 [GameDataCategory] 语义相同。TriggerGenerated 是派生结果,不要手工修改。
新建已有分类下的数编类型
- 在触发编辑器选择 “共用(Common)” 环境,通过 “类定义” 新建并选中一个普通 Class,再添加该子类型自己的属性。
- 选中这个 Class,在属性面板勾选 “设置为数编类型”。
- 在弹出的对话框中先设置 “节点分类”,再设置 “节点父类”:
- 节点分类是整棵继承树的根,例如
GameDataUnit - 节点父类是当前类型的直接父类;第一层通常与节点分类相同,多层继承时选择上一层节点类型
- 节点分类是整棵继承树的根,例如
- 确认后,属性面板会显示最终的 “数编分类” 和 “数编父类”。
- 先执行 “保存”,再执行 “生成代码文件”。
生成代码会带上 [GameDataNodeTypeAttribute<T,V>];手写 C# 时省略 Attribute 后缀的 [GameDataNodeType<T,V>] 与之等价。定义普通子类型并不要求重写 CreateUnit 等工厂方法;只有确实要创建自定义运行时对象时才增加专用工厂行为。
让类型进入数据编辑器
- 同时编译
Client-Debug和Server-Debug,确认触发器生成代码与 GameData source generator 都通过。 - 让编辑器重新加载最新项目程序集。
- 用数据编辑器选择新类型,或用
gamedata_list_types/gamedata_get_type_schema确认类型和字段已被发现。 - 创建表和条目,填写具体值,再用
data_save_and_generate保存并刷新src/DataGenerated/**。 - 从生成的
ScopeData.*Link 访问条目。
双端都会使用的数编类型默认放在 “共用(Common)”。只有类型确实只属于单端时才选择 “客户端(Client)” 或 “服务端(Server)”;类型选择对话框只会在当前类所在环境中选择合法分类和父类。字段与公式的双端边界见 GameData 公式字段赋值位置。
设置前应保持 Class 为普通类:不要先配置其它父类,也不要自己创建名为 Link 的成员。一个 Class 选择“数编分类”或“数编类型”其中一种,不要同时承担两种角色。
纯代码路线:直接声明 C# 特性
只有项目明确采用纯代码开发、不需要通过触发编辑器维护类型结构时,才直接手写这些特性。
csharp
using GameCore.Extension;
namespace GameEntry.Data;
[GameDataCategory]
public partial class GameDataEncounter
{
public int RecommendedLevel { get; set; } = 1;
}
[GameDataNodeType<GameDataEncounter, GameDataEncounter>]
public partial class GameDataTimedEncounter
{
public float TimeLimitSeconds { get; set; } = 60;
}
[GameDataNodeType<GameDataEncounter, GameDataTimedEncounter>]
public partial class GameDataBossEncounter
{
public int PhaseCount { get; set; } = 1;
}特性的泛型参数与图形化对话框一一对应:
T= “节点分类”,即整棵继承树的根分类V= “节点父类”,即当前节点类型的直接父类
纯代码声明必须是具名 namespace 中的 public partial class。不要显式写生成器负责的父类,也不要手写 IGameData<T> / IGameDataCategory<T>、Link 构造器或 Link 属性。需要抽象根时可加 abstract。
纯代码路线中的具体条目也在代码初始化路径注册,例如:
csharp
using GameCore.BaseInterface;
using GameCore.GameSystem;
using GameData;
namespace GameEntry.Data;
public sealed class EncounterGameDataRegistration : IGameClass
{
public static readonly GameLink<GameDataEncounter, GameDataTimedEncounter> TimedEncounter
= new("TimedEncounter"u8);
public static void OnRegisterGameClass()
{
Game.OnGameDataInitialization += RegisterGameData;
}
private static void RegisterGameData()
{
_ = new GameDataTimedEncounter(TimedEncounter)
{
RecommendedLevel = 10,
TimeLimitSeconds = 90,
};
}
}示例通过 IGameClass.OnRegisterGameClass 接入统一的 GameData 初始化流程。需要双端使用时,两端必须注册相同 Link 与字段值;不要再为同一个条目创建 JSON-backed 定义。
两条路线的共同边界
- 图形化路线和纯代码路线最终使用同一组 C# 特性与 source generator 语义,但类型与条目的 authoring source 均应保持成套
- 一个类型只选择一条维护路线;不要同时在触发编辑器和手写源码中重复定义同名类型
- 图形化路线用数据编辑器 /
editor/data维护条目;纯代码路线用统一初始化代码注册条目,不要把同一条目跨两边拼接 data_create_table/data_create_entry只能创建已被 CLR/schema 发现的类型、表和条目,不能仅靠未知$type或额外 JSON 字段发明类型src/TriggerGenerated/**和src/DataGenerated/**都是派生结果:前者投影触发编辑器的类/逻辑,后者投影数据编辑器的条目/Link,均不应手改
完整的 MCP、JSON、$id / $inherit 与保存边界见 SCE 数据编辑器 Skill;用触发器工具创建或维护 Class 时见 触发器编辑器 Skill。
核心接口
IGameData
游戏数据基础接口,所有数编表最终都必须实现。项目作者通常不手写这个接口,而由上述两个特性的 source generator 为 partial class 生成实现。
csharp
public interface IGameData
{
IGameLink Link { get; }
string Name { get; }
string FriendlyName { get; } // 优先使用 Name,否则使用 Id 或 HashCode
static IGameData? Get(int hashCode); // 通过 HashCode 获取全局实例
}
public interface IGameData<out T> : IGameData
where T : IGameData<T>
{
new IGameLink<T> Link { get; }
}IGameLink
数编 Link 接口,提供类型安全的数据引用。
csharp
public interface IGameLink : IEquatable<IGameLink>
{
string? Id { get; }
int HashCode { get; } // 全局唯一哈希码
int HashCodeLocal { get; } // 类型内的本地哈希码
IGameData? Data { get; } // 延迟加载,访问时查找
string FriendlyName { get; }
}
public interface IGameLink<out T> : IGameLink
where T : IGameData<T>
{
new T? Data { get; }
}> 注意:IGameLink / GameLink 本身只是可查找的标识,不携带数编实例。link.Data 只会在当前端已经注册好的 GameData 目录中查找;把一个 Link ID 从客户端传到服务端,并不会让服务端自动拥有对应的数据。
IGameLink 比较规则:
IGameLinkvsIGameLink:必须使用.Equals(),不能用==GameLink<T,V>vsGameLink<T,V>:可以使用==(有运算符重载)
GameLink<TCategory, V>
数编 Link 的具体实现,轻量级值类型结构体。
csharp
// 通过字符串 ID 创建
var heroLink = new GameLink<GameDataUnit, GameDataUnit>("HostTestHero");
// 通过 UTF-8 字节跨度创建(与字符串构造使用不同的哈希输入)
var heroLink2 = new GameLink<GameDataUnit, GameDataUnit>("HostTestHero"u8);
// 通过本地 HashCode 创建
var heroLink3 = new GameLink<GameDataUnit, GameDataUnit>(12345);
// 相等性比较
bool isEqual = heroLink == heroLink2; // false:当前两种构造方式的哈希不同
bool sameStringId = heroLink == new GameLink<GameDataUnit, GameDataUnit>("HostTestHero"); // true构造方式必须与数据注册时保持一致。 当前字符串构造按 UTF-16 字节计算本地哈希,ReadOnlySpan<byte> 构造直接按传入字节计算;"id"u8 提供的是 UTF-8 字节。即使文本内容相同,两种写法也不能当作同一个 Link 互换。u8 是 C# 的字面量编码标记,不是 ID 文本的一部分。
引用已有条目时,优先复用其生成的 ScopeData.* Link;需要自行构造时,沿用注册端的构造方式。当前数据编辑器生成的字符串 ID 使用普通字符串构造,显式整数 $hash 则使用整数构造;不要擅自把它们改写成 u8。双端注册和查找也必须遵守相同规则。
哈希码机制:
全局 HashCode = HashCodeDeterministic.Combine(本地 HashCode, 分类 TypeHashCode)确保不同类型的相同 ID 不会冲突,支持 O(1) 查找。
运行时 GameDataCategory<T>
这是 [GameDataCategory] 生成代码所继承的运行时基类,负责同分类数据的集合、注册和查找。项目作者定义新分类时应使用特性,不要照抄下面的基类签名手写继承。
csharp
public abstract class GameDataCategory<TSelf> : IGameDataCategory<TSelf>
where TSelf : GameDataCategory<TSelf>, IGameDataCategory<TSelf>
{
public IGameLink<TSelf> Link { get; }
public string Name { get; set; }
public static IEnumerable<TSelf> Catalog { get; } // 遍历所有同类数据
public static V? Get<V>(IGameLink<V> gameLink) where V : IGameData<V>, TSelf;
public static TSelf? GetData(int hashCode);
public static IGameLink<TSelf>? GetLink(int hashCode);
public static IGameLink<TSelf>? GetLinkByHashLocal(int hashCodeLocal);
}构造时自动注册到全局目录和类别目录,重复注册会抛出 InvalidOperationException。
数据访问模式
通过 Link 访问
csharp
var unitData = myUnitLink.Data;
if (unitData != null)
{
Game.Logger.LogInformation("单位名称: {Name}", unitData.Name);
}类别遍历
csharp
foreach (var unitData in GameDataCategory<GameDataUnit>.Catalog)
{
Game.Logger.LogInformation("单位: {Name}", unitData.Name);
}动态查找
csharp
// 通过字符串 ID
var link = new GameLink<GameDataUnit, GameDataUnit>("SomeUnit"u8);
var data = link.Data;
// 通过全局 HashCode
var foundData = IGameData.Get(link.HashCode);数编表作为工厂
核心数编表类型提供工厂方法创建运行时实例:
| 数编表类型 | 工厂方法 | 创建的实例 |
|---|---|---|
GameDataUnit | CreateUnit(player, scenePoint, facing) | Unit |
GameDataAbility | CreateAbility(owner, item?) | Ability |
GameDataItem | CreateItem(scenePoint, player?) | Item |
GameDataBuff | CreateBuff(target, caster, context) | Buff |
GameDataActor | CreateActor(scope) | Actor |
GameDataEffect | CreateEffect(caster, context) | Effect |
csharp
#if SERVER
// 通过数编表创建单位
var unit = myUnitLink.Data?.CreateUnit(player, spawnPoint, facing);
// 通过数编表创建物品
var item = myItemLink.Data?.CreateItem(spawnPoint);
#endif继承与工厂模式
当继承实体类时,必须同时继承对应的数编表类并重载工厂方法:
下面展示的是纯代码路线的高级写法。图形化路线应在触发编辑器中创建对应 Class 并“设置为数编类型”,由生成代码携带相同特性;不要直接修改 TriggerGenerated。
csharp
// 步骤一:继承实体类
public class Hero : Unit
{
public Hero(IGameLink<GameDataUnit> link, Player player, ScenePoint scenePoint, Angle facing)
: base(link, player, scenePoint, facing) { }
public int HeroLevel { get; set; }
}
// 步骤二:声明单位分类下的节点类型;生成器会补上对 GameDataUnit 的继承
[GameDataNodeType<GameDataUnit, GameDataUnit>]
public partial class GameDataHero
{
public int BaseLevel { get; set; } = 1;
public override Unit? CreateUnit(Player player, ScenePoint scenePoint, Angle facing,
IExecutionContext? creationContext = null, bool useDefaultAI = false)
{
var hero = new Hero(Link, player, scenePoint, facing)
{
CreationContext = creationContext,
HeroLevel = BaseLevel
};
ScopeScript.LastCreatedUnit = hero;
return hero;
}
}常见数编表类型
| 数编表类型 | 用途 |
|---|---|
GameDataUnit | 单位数据(属性、模型、AI) |
GameDataAbility | 技能数据(冷却、消耗、效果) |
GameDataItem | 物品数据(品质、等级、分类) |
GameDataBuff | Buff 数据(持续时间、效果) |
GameDataActor | Actor 数据(模型、粒子、音效) |
GameDataScene | 场景数据(地图大小) |
GameDataEffect | 效果数据(伤害、治疗、搜索) |
最佳实践
- 缓存 Data 访问:频繁使用时将
link.Data缓存到局部变量 - 空检查:访问
Data前始终检查 null(数据可能未注册) - 保持 Link 构造方式一致:优先复用
ScopeData.*;自行构造时与注册端保持一致,不要互换"id"和"id"u8 - IGameLink 比较:接口类型必须用
.Equals(),不能用== - 默认按共享静态定义处理:普通运行时逻辑不要随手修改;确需动态创建或修改时,遵守下文的双端一致性与缓存刷新规则
静态定义与实例态的边界
GameData 默认应被视为“定义”,而不是“本局可随手改写的状态”。尤其是 ScopeData.*.Data 指向的条目,如果来源于数据编辑器,通常还对应 editor/data 下的 JSON 文件;这些对象在语义上应被当作共享静态配置。
- 静态定义放在
GameDataUnit、GameDataAbility、GameDataModel、GameDataAI等GameData中维护。 - 动态状态放在
Unit、Ability、Item、Buff等运行时实例上维护。 - 按波次成长、出生后临时加成、局内倍率变化,应在工厂方法创建实例后修改实例,而不是回头补丁共享
GameData。 - 如果确实需要运行时代码生成
GameData,优先创建独立的 runtime-only 条目,并在客户端与服务端通过相同初始化路径注册。不要只在客户端 UI 事件、服务端触发器或其他单边流程里构造需要跨端使用的 GameData。
不推荐的写法:
- 在 gameplay trigger 或刷怪循环里修改
ScopeData.GameDataUnit.SomeUnit.Data.Properties - 在服务端刷怪前临时修改
ScopeData.GameDataUnit.SomeUnit.Data.Model/TacticalAI/SyncType - 让客户端和服务端在不同时间、不同条件下看到不同版本的同一条
GameData
推荐的写法:
- 把基础模型、AI、同步方式、默认属性写回数编文件或统一的
OnGameDataInitialization - 通过
CreateUnit(...)创建实例后,再对该实例应用波次属性、Buff、掉落经验等局内变化 - 若必须补丁共享
GameData,显式保证双端同点初始化,并记录补丁原因和影响范围
故障排除
| 问题 | 原因 | 解决方案 |
|---|---|---|
Duplicate registered GameData | 同一 Link 创建了多个数编表实例 | 确保每个 Link 只对应一个实例 |
Data 返回 null | 当前端未注册该数编表,或 Link ID / 类型错误。只传 Link ID 不会在另一端自动创建数据 | 检查数编表创建和 Link ID;需要跨端使用时,在双端相同初始化路径注册 |
| 客户端/服务端数据不一致 | 创建顺序不同,或只在单端运行时补丁了 ScopeData.*.Data | 使用相同的数据加载逻辑;避免单边修改共享 GameData |
相关文档
- SCE 数据编辑器 Skill — 用 MCP 或 JSON 创建表/条目并保存生成
- 数编 MCP 工作流 — 地图工程内的精简操作规则
- GameData 公式字段赋值位置 — 客户端、服务端与公式字段的放置边界
- 1.0 → 2.0 核心差异 — 从旧数编系统迁移时的对照视角
- 物品系统 — GameDataItem 的使用
- Buff 系统 — GameDataBuff 的使用
- 技能系统 — GameDataAbility 的使用
- 单位系统 — GameDataUnit 的使用