Skip to content

GameData 数据驱动系统 ​

概述 ​

WasiCore 引擎的数据驱动架构由 IGameData(数编表)和 IGameLink(数编 Link)组成,实现游戏数据与逻辑的解耦。

  • IGameData(数编表) — 定义游戏对象的静态数据结构和配置模板
  • IGameLink(数编 Link) — 提供类型安全的数据引用和索引机制
  • 数编类型 — 图形化项目在触发编辑器中定义;纯代码项目也可手写 C# partial class。最终都由 [GameDataCategory] / [GameDataNodeType<T,V>] 驱动代码生成
  • GameDataCategory<T> — 生成后的运行时基类,管理同一分类的数据集合和查找

这三个概念处在不同层,不能互相替代:

概念维护入口决定什么
数编类型图形化路线:触发编辑器的 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# 特性再期待图形化编辑器接管这份类型声明。

新建独立数编分类 ​

  1. 在触发编辑器选择 “共用(Common)” 环境,通过 “类定义” 新建并选中一个普通 Class,再添加需要暴露给数据编辑器的属性。
  2. 选中这个 Class,在属性面板勾选 “设置为数编分类”。
  3. 如果这个分类只作为子类型的根、不允许直接创建条目,同时启用 “禁止创建实例”。
  4. 先执行 “保存” 持久化触发定义,再执行 “生成代码文件” 刷新 C# 输出;这是两个独立动作。

生成的 src/TriggerGenerated/common.cs 会包含对应的 partial class 和 [GameDataCategoryAttribute]。后续 source generator 会补齐运行时基类、Link 构造器与注册样板。C# 中省略 Attribute 后缀写成 [GameDataCategory] 语义相同。TriggerGenerated 是派生结果,不要手工修改。

新建已有分类下的数编类型 ​

  1. 在触发编辑器选择 “共用(Common)” 环境,通过 “类定义” 新建并选中一个普通 Class,再添加该子类型自己的属性。
  2. 选中这个 Class,在属性面板勾选 “设置为数编类型”。
  3. 在弹出的对话框中先设置 “节点分类”,再设置 “节点父类”:
    • 节点分类是整棵继承树的根,例如 GameDataUnit
    • 节点父类是当前类型的直接父类;第一层通常与节点分类相同,多层继承时选择上一层节点类型
  4. 确认后,属性面板会显示最终的 “数编分类” 和 “数编父类”。
  5. 先执行 “保存”,再执行 “生成代码文件”。

生成代码会带上 [GameDataNodeTypeAttribute<T,V>];手写 C# 时省略 Attribute 后缀的 [GameDataNodeType<T,V>] 与之等价。定义普通子类型并不要求重写 CreateUnit 等工厂方法;只有确实要创建自定义运行时对象时才增加专用工厂行为。

让类型进入数据编辑器 ​

  1. 同时编译 Client-Debug 和 Server-Debug,确认触发器生成代码与 GameData source generator 都通过。
  2. 让编辑器重新加载最新项目程序集。
  3. 用数据编辑器选择新类型,或用 gamedata_list_types / gamedata_get_type_schema 确认类型和字段已被发现。
  4. 创建表和条目,填写具体值,再用 data_save_and_generate 保存并刷新 src/DataGenerated/**。
  5. 从生成的 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; }
}

数编 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 比较规则:

  • IGameLink vs IGameLink:必须使用 .Equals(),不能用 ==
  • GameLink<T,V> vs GameLink<T,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。

数据访问模式 ​

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

数编表作为工厂 ​

核心数编表类型提供工厂方法创建运行时实例:

数编表类型工厂方法创建的实例
GameDataUnitCreateUnit(player, scenePoint, facing)Unit
GameDataAbilityCreateAbility(owner, item?)Ability
GameDataItemCreateItem(scenePoint, player?)Item
GameDataBuffCreateBuff(target, caster, context)Buff
GameDataActorCreateActor(scope)Actor
GameDataEffectCreateEffect(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物品数据(品质、等级、分类)
GameDataBuffBuff 数据(持续时间、效果)
GameDataActorActor 数据(模型、粒子、音效)
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

相关文档 ​