跳至主要内容
SheetForge

插件开发——零 Core 改动接入新领域

一个领域(技能、物品、任务……)以引用 SheetForge.Core 的独立包形式接入 SheetForge——Core 永远不会反过来引用它。

插件可以新增 enum、自定义单元格类型、包装类型、领域验证器、图边、结构标记、"创建工作表"模板、完整的导入来源、Data Studio 的画布覆盖、代码注册表、声明式创作界面、部件和操作、色彩预设、自定义单元格部件、管线观察者,以及插件自己的本地化 UI 字符串——即下方这十六种契约。

"新增一个领域 = 零 Core 代码行改动"由编译器强制保证:一个不使用 InternalsVisibleTo 的测试程序集(SheetForge.Tests.Consumer)仅凭公共 API 表面就实现了十六种中的十五种——以及它们旁边的 capability 接口。只要其中任何一个被收窄为 internal,构建就会失败(CS0122)。第十六种——仅限编辑器的富面板逃生舱——返回的是一个 VisualElement,因此改由一项编辑器侧测试来验证。

十一种 Core 契约是纯 C#。正是这一点让同一份编译好的插件 DLL,既能在 Unity 编辑器中点亮相应的插槽,又能在浏览器中做到同样的事情(SheetForge Web)——组装与隔离是同一个共享的 Core 功能,各宿主环境之间只有发现方式不同(Unity 的 TypeCache,浏览器端的已上传程序集扫描)。

五种 Editor 契约会返回 UIToolkit 元素,或者需要接触窗口状态,因此它们只存在于编辑器中。

全部十六种都会被自动发现——唯一的要求是一个无参构造函数,不需要程序集引用、注册调用,也不需要编辑任何清单文件:

契约注册内容是否可选
ISheetForgePluginenum + 自定义单元格类型解析器基础契约
ISheetForgeValidatorPlugin领域验证规则(跨列 / 跨标签页)可选附加项
ISheetForgeEdgePlugin核心扫描器看不到的图边声明可选附加项
ISheetForgeMarkerPlugin自定义结构标记(按列的 @marker 行)可选附加项
ISheetForgeTemplatePlugin"创建工作表"模板(标签页 + 示例数据)可选附加项
ISheetForgeGraphPlugin面向 Data Studio 的按标签页画布覆盖可选附加项
ISheetForgeCodeRegistryPlugin存在于代码中的只读键空间,以锁定虚拟标签页的形式呈现可选附加项
ISheetForgeThemePluginSheetForge 各窗口的色彩预设(深色与浅色)可选附加项
ISheetForgeStudioPlugin声明式创作界面——动作、面板、列徽标、单元格编辑器提示可选附加项
ISheetForgeStringsPlugin你的包按语言提供的 UI 字符串(在产品自带的语言表之前被查询的覆盖层)可选附加项
ISheetForgePipelinePlugin管线观察者——对一次导入产出内容的只读通知可选附加项
ISheetSourceProvider一整个导入来源(DB / REST / 内部专有)独立(Editor 程序集)
IStudioGraphWidget位于 Data Studio 画布上方的一个领域部件独立(Editor 程序集)
IStudioInspectorActionData Studio 节点检查器上的一个额外按钮独立(Editor 程序集)
IStudioCellEditorProviderData Studio 网格中某一种单元格类型的自定义输入部件独立(Editor 程序集)
IStudioPanelProviderStudio 中一个任意的 UIToolkit 面板——声明式方式之外的另一个逃生舱独立(Editor 程序集)

参考示例是一个可选导入包。 完整的实战示例(SheetForge.PluginDemo)以 Unity 包的形式发布于 Assets/SheetForge/Examples/SheetForgePluginDemo.unitypackage——双击它,或在入门窗口(Tools ▸ SheetForge ▸ Getting Started,演示导入唯一的落脚处)中按下 导入 Plugin Demo,即可将其还原到 Assets/SheetForge.PluginDemo/… 下。在你导入它之前,它根本不存在于你的项目中——该示例仅以那个包的形式发布——因此它的程序集/类型/标签页/地址永远不会和你的项目冲突。下文引用的路径(Assets/SheetForge.PluginDemo/ModifierCellParser.cs 等)只有在你导入该包之后才存在。(另有一个不含插件的示例——SheetForge.CoreDemo——仅用核心内置类型演示这条管线。)

这些附加项在扩展基础接口的同时不会改动它——一个不需要验证或边的插件,完全不会受它们存在与否的影响。

另有七个接口是capability,而不是契约

  • 它们不会被单独发现;
  • 而是由某个已经注册的对象额外实现;
  • Core 通过对那个已注册对象做类型转换来找到它们。

其中六个是从一个已注册的边贡献者或画布覆盖上转换而来的——发现规则以及每一个的详情见 §4.12。第七个,IReferencingCellType,是从一个已注册的单元格解析器上转换而来的,它能让你自己的记法获得与 RecordId@Tab 相同的引用处理待遇——见 §4.4a。忽略它们中的任何一个都不会有任何影响。

1. 包设置

创建一个拥有自己的 .asmdef 的文件夹,引用 SheetForge.Core(如果你需要运行时查找,再加上 SheetForge.Runtime)。就这么简单——Editor 的 PluginRegistry 会通过 TypeCache 发现你的 ISheetForgePlugin 实现,并调用你的注册方法。注册是你显式编写的代码,而不是一次程序集扫描。

把 Core 契约的实现保留在这个主程序集中。一个编辑器侧的配套程序集(同时引用 SheetForge.Editor)则是五个 IStudio* / ISheetSourceProvider 实现应该存放的地方——浏览器只会加载你的主 DLL,因此如果一个 Core 契约被实现在编辑器配套程序集里,它在浏览器端就会静默缺席。

1.1 声明兼容性(可选,一行代码)

一个程序集级别的特性,用于声明你的程序集是针对插件格式的哪一代构建的,以及它要求的最低宿主版本:

using SheetForge.Core.Plugins;
 
[assembly: SheetForgePluginCompat(
    SheetForgePluginFormat.Current,          // the generation constant of the SDK you compiled against
    MinHostVersion = "0.1.0",                // optional — omit for "any host"
    PluginVersion  = "1.0.0")]               // optional, display only
  • 省略它也没关系。 没有声明的程序集会被当作 SheetForgePluginFormat.Minimum 代、不带任何宿主要求,因此那些写于该特性出现之前的插件,加载方式与以往完全一致。
  • 判定的单位是整个程序集,一个被拒绝的程序集会失去它全部的注册内容。按类型逐个声明,会让一个未声明的邻居类型蒙混过关,最终留给你一个"被拒绝,但其中一半又注册成功了"的局面。
  • 裁判是 DLL 本身,不是目录列表。 市场注册表会展示同样的两个值(pluginFormatminHost),以便在下载之前就能对列表进行筛选,但真正的关卡读取的是已验证字节中的那个特性——列表可能出错,编译出来的声明不会。
  • 拒绝会表现为一条 PluginIncompatible 诊断信息,说明程序集声明了什么、这个宿主实际读到了什么,而不是悄无声息地消失。这是一份兼容性声明,而不是签名:完整性校验是分发渠道的职责(见 Web 插件市场)。
  • 只有当插件格式本身被替换时,这个代数才会推进。纯粹的增量式增长——一个新契约、一个注册表上的新成员——永远不会推进它,因为你现有的插件无需重新编译就能继续运行。

2. 基础插件:enum + 自定义单元格类型

using SheetForge.Core.Model;
using SheetForge.Core.Plugins;
 
public sealed class SkillsPlugin : ISheetForgePlugin
{
    public string Name => "Skills";   // for diagnostics / duplicate-conflict reports
 
    public void RegisterEnums(EnumRegistry enums)
    {
        // Any Enum<ActionType> / Enum<EffectType> cell in a sheet now resolves,
        // and codegen emits the real CLR enum type on the generated field.
        enums.Register<ActionType>();
        enums.Register<EffectType>();
    }
 
    public void RegisterCellParsers(CellParserRegistry parsers)
    {
        // A custom cell type joins parsing, validation, codegen, bake and
        // round-trip by registration alone (open-closed — zero pipeline edits).
        parsers.Register(new ModifierCellParser());
    }
}

端到端的自定义单元格类型

实现 ICellValueParser(字符串 → 值),并且,为了完成强类型烘焙以及导出/推送往返,还要实现 ICustomCellType(CLR 类型 + 值 → 规范字符串)。示例中的 Modifier 迷你语法(stat:op:value,例如 attack:add:10):

using System;
using SheetForge.Core.Model;
using SheetForge.Core.Unparse;
 
public sealed class ModifierCellParser : ICellValueParser, ICustomCellType
{
    // The @type cell text: a column declares "Modifier" or "List<Modifier>".
    public string TypeName => "Modifier";
 
    // ICustomCellType: the CLR value type codegen emits ([Serializable] struct).
    public Type ValueType => typeof(Modifier);
 
    public bool TryParse(CellParseContext context, string text, out object value)
    {
        value = null;
        string[] parts = text.Split(':');
        if (parts.Length != 3)
        {
            // Failure = collect a structured error and return false. Never throw.
            context.Errors.Add(new ImportError(
                ImportErrorCode.CustomTypeParseFailed, context.Coordinate,
                text, "'stat:op:value' form (e.g. attack:add:10)", null));
            return false;
        }
        // ... parse the three parts (InvariantCulture; reject NaN/Infinity) ...
        value = new Modifier(parts[0].Trim(), /*op*/ default, /*value*/ 0f);
        return true;
    }
 
    // ICustomCellType: value → canonical cell string (the exact inverse of TryParse).
    public bool TryRender(object value, out string text, out string reason)
    {
        reason = null;
        if (!(value is Modifier m)) { text = null; reason = "Not a Modifier."; return false; }
        // Use CanonicalValueRenderer.RenderFloat for floats — round-trip-safe on Mono.
        text = m.stat + ":" + "add" + ":" + CanonicalValueRenderer.RenderFloat(m.value);
        return true;
    }
}

(完整的、包含运算符 token 验证与近似匹配建议的生产级版本,请见 Assets/SheetForge.PluginDemo/ModifierCellParser.cs。)

自定义类型上的 @target 仅凭注册即可生效:将某列声明为 Modifier@Stats,你的解析器就可以读取 context.Type.TargetName"Stats")。对该目标的完整性检查(标签页是否存在?该 id 是否可解析?)属于领域验证器的职责——与 RecordId@Tab 采用相同的分工方式。带 @未注册类型名仍然是一个带建议的错误,因此拼写错误的安全性得以保留。

Core 已经占用的类型名。 内置的标量名——intfloatboolstringEnumRecordIdIntIdAssetRefColorAnimationCurveGradient——会在任何插件之前完成注册。一个重用其中某个名字的解析器会以 PluginRegistrationConflict 注册失败——内置类型保持不变,那次 RegisterCellParsers 调用会在发生冲突的解析器处停下,而该插件的其他槽位依然会正常加载——因此一个自带 ColorGradient 类型的插件包必须为它改名(升级说明见更新日志)。如果你的类型存储的是一个颜色、一条曲线或一个渐变,你不需要重新实现这套记法:Core 的值模型 ColorValueCurveValueGradientValue 提供了 TryParse(text, out value, out error)Render()CurveEvaluator / GradientEvaluator 会像 Unity 一样对它们取样,而一个带有 ColorPickerCurveEditorGradientEditor 形态(§4.16)的 StudioCellEditorHint,会在两个宿主环境中都为你的类型打开原生编辑器。

端到端的包装类型(MyWrapper<T>

包装类型是一种通用的值形态——Pair<int> = 1~2——把若干个内部 T 值打包进一个单元格中。你只需要拥有外层语法(分隔符、元数);Core 会递归解析内部的 T,因此 Pair<RecordId@Effects>Pair<Enum<DamageType>>,以及嵌套的 Box<Pair<int>> 都能直接可用,其内部的引用也会被完整验证。实现 ICellWrapperType,并通过 parsers.RegisterWrapper(...) 在同一个 RegisterCellParsers 钩子中注册它:

// A [Serializable] generic value type — codegen emits Pair<int>, Pair<RecordRef>, ...
[Serializable] public struct Pair<T> { public T First; public T Second; public Pair(T a, T b){First=a;Second=b;} }
 
public sealed class PairWrapper : ICellWrapperType
{
    public string Name => "Pair";                 // the @type token: Pair<Inner>
    public Type OpenClrType => typeof(Pair<>);     // generic open type — exactly one type parameter
 
    // Outer syntax only: split "1~2" into ["1","2"]. Use a delimiter OTHER than ';'
    // so List<Pair<T>> doesn't clash with the list separator.
    public bool TrySplit(string cell, out IReadOnlyList<string> pieces, out string reason)
    {
        reason = null;
        var parts = (cell ?? "").Split('~');
        if (parts.Length != 2) { pieces = null; reason = "'a~b' form (two parts)."; return false; }
        pieces = new[] { parts[0], parts[1] };
        return true;                               // the Core parses each piece as the inner type
    }
 
    public string JoinCanonical(IReadOnlyList<string> inner) => inner[0] + "~" + inner[1]; // inverse of TrySplit
 
    public object Assemble(IReadOnlyList<object> inner, Type closed) =>
        Activator.CreateInstance(closed, inner[0], inner[1]);      // bake: build Pair<TInner>
 
    public bool TryDisassemble(object v, out IReadOnlyList<object> inner, out string reason)
    {
        reason = null;
        var t = v.GetType();
        inner = new[] { t.GetField("First").GetValue(v), t.GetField("Second").GetValue(v) };
        return true;                               // Export: read the values back out (inverse of Assemble)
    }
}
 
// In your ISheetForgePlugin.RegisterCellParsers:
public void RegisterCellParsers(CellParserRegistry parsers) => parsers.RegisterWrapper(new PairWrapper());

那一次注册就能为你带来:

  • 递归的 @type 解析。
  • 强类型代码生成(Pair<RecordRef> First;)。
  • 烘焙。
  • 导出/推送往返。
  • 引用透传——包装类型内部的 RecordId@Tab 会被完整性检查、在键重命名时被传播、在标签页重命名时被重写。

拒绝规则以及 ; 分隔符的注意事项记录在表格语法中。

3. 领域验证器(可选)

Core 自带的验证固定为四种(键、引用、@overlap、资源键)。对于跨列规则("如果 type 是 Custom,则 script 为必填")或跨标签页规则(检查被引用记录的含义),请实现 ISheetForgeValidatorPlugin

using SheetForge.Core.Model;
using SheetForge.Core.Plugins;
using SheetForge.Core.Validation;
 
public sealed class SkillsPlugin : ISheetForgePlugin, ISheetForgeValidatorPlugin
{
    // ... Name / RegisterEnums / RegisterCellParsers unchanged ...
 
    public void RegisterValidators(DomainValidatorRegistry validators)
    {
        validators.Register(new CustomEffectRequiresScriptValidator());
    }
}
 
public sealed class CustomEffectRequiresScriptValidator : IDomainValidator
{
    public string Name => "CustomEffectRequiresScript";
 
    public void Validate(DomainValidationContext ctx)
    {
        if (!ctx.Tables.TryGetValue("ExampleEffects", out var effects)) return;
        if (!effects.Schema.TryGetField("script", out var scriptField)) return;
        foreach (var rec in effects.Records)
        {
            if (!(rec["type"].Value is EnumValue ev) || ev.MemberName != "Custom") continue;
            var scripts = rec["script"].AsList;
            if (scripts != null && scripts.Count == 0)
                ctx.Errors.Add(new ImportError(ImportErrorCode.DomainRuleViolation,
                    new CellCoordinate("ExampleEffects", rec.RowNumber, scriptField.ColumnNumber, "script"),
                    /* what */ rec["codeName"].Value.ToString(),
                    /* why  */ "A Custom effect must specify a script to run, but 'script' is empty.",
                    /* how  */ "Put a script address in the 'script' column, or change 'type'."));
        }
    }
}

已注册的验证器会自动同时加入导入验证创作预检。规则:

  • 把违规情况以 ImportErrorCode.DomainRuleViolation 的形式报告到 ctx.Errors 中——绝不要抛出异常(抛出的异常会被单独隔离并提升;其他验证器仍会继续运行)。
  • 填满全部四个要素——在哪里(CellCoordinate)、是什么(ActualValue)、为什么(Expected)、怎么办(Suggestion)。"怎么办"会被原样显示为那条可执行的提示语句。
  • ctx 会给你:
    • 全部已解析的表(Tables);
    • 键索引(KeyIndices);
    • 资源键(AssetKeys——null 表示资源验证被跳过了)。
  • 收集所有信息与不允许部分组装这两点会被自动继承。

4. 边贡献者(可选)

如果你在数据图之上构建工具(或者希望未来的图形画布能看到你所在领域的连接关系),可以声明核心引用扫描器看不到的边——例如一个在迷你语法值内部被引用的属性:

public sealed class SkillsPlugin : /* ... */, ISheetForgeEdgePlugin
{
    public void RegisterEdgeContributors(EdgeContributorRegistry contributors)
    {
        contributors.Register(new ModifierStatEdgeContributor()); // effect → stat edges
    }
}

一个 IEdgeContributor 会收到一个只读的跨标签页上下文,并追加 EdgeSpec 条目(源/目标标签页 + 记录 id、可选字段、payload 记录、标签)。贡献者从不产生诊断信息——边只是投影素材,不是验证。见创作内核

4.4 配方:一个自身携带键的自定义类型

RecordId@Tab 是 Core 唯一能理解的引用形态,它免费获得完整性检查、图边、最接近匹配建议,以及重命名传播。一旦你自己的记法把一个键吞进了内部——attack:add:10stat.hp>50fire@0.4——Core 看到的就只是一个不透明的字符串,于是这四项服务就在你的门口止步了。三次注册可以把其中三项找回来。 请把它们作为一整套来写;一个只具备这三项之一的迷你语法,正是那种"导入起来一切正常,但什么都指不到任何东西"的形态。

部分契约恢复了什么没有它会怎样
1. 完整性IDomainValidator(§3)你的记法内部一个不存在的键会被报告出来,带有坐标和一条可执行的提示语句拼写错误会顺利导入,然后在运行时才失败
2. 可见性IEdgeContributor(§4)藏起来的链接会变成一条真实的边:画布会画出它,Used by 列表会计入它,引用索引会为它建立索引这条连接只存在于数据中,屏幕上哪里都看不到
3. "怎么办"第 1 部分内部的 TextSuggestion.FindNearest"未知的属性 'atack'。你是不是想输入 'attack'?"——与内置引用错误相同的句式一个正确的诊断,但没有任何可以据此采取的行动
// Piece 1 + 3 together — the validator is where the suggestion belongs, because it is the
// only one of the three that produces a sentence a person reads.
using SheetForge.Core.Model;
using SheetForge.Core.Validation;
 
public sealed class ModifierStatExistsValidator : IDomainValidator
{
    public string Name => "ModifierStatExists";
 
    public void Validate(DomainValidationContext ctx)
    {
        if (!ctx.KeyIndices.TryGetValue("Stats", out var stats)) return;   // no target tab: nothing to check
        if (!ctx.Tables.TryGetValue("Effects", out var effects)) return;
        if (!effects.Schema.TryGetField("modifier", out var field)) return;
 
        foreach (var rec in effects.Records)
            foreach (string statKey in StatKeysIn(rec["modifier"]))       // your notation's own split
            {
                if (stats.Contains(statKey)) continue;
                string near = TextSuggestion.FindNearest(statKey, stats.Keys);   // piece 3
                ctx.Errors.Add(new ImportError(ImportErrorCode.DomainRuleViolation,
                    new CellCoordinate("Effects", rec.RowNumber, field.ColumnNumber, "modifier"),
                    /* what */ statKey,
                    /* why  */ "This modifier points at a stat that does not exist in 'Stats'.",
                    /* how  */ near != null
                        ? "Did you mean '" + near + "'? Fix the stat name in the modifier value."
                        : "Add that record to 'Stats', or correct the stat name."));
            }
    }
}

为这套记法复用同一个拆分器——解析器、验证器和边贡献者必须对键从哪里开始、到哪里结束达成一致,而这个拆分逻辑一旦有三份私有副本,就是它们彼此走样的开始。(一个包装类型,见 §2,会免费获得这一点:TrySplit 本身就是那个共享的拆分器。)

第四项服务——重命名传播——还需要多一样东西,而获得它有两种方式。 重命名一条记录,只会重写 Core 能在文本中找到该键的那些引用单元格。它能对一个 RecordId@Tab 字段、一份这样的列表,以及一个 TrySplit 把键暴露为某个元素的包装类型做到这一点;但它无法自行猜测你的语法的子字符串边界。所以要么:

  • 告诉它该怎么做——实现 IReferencingCellType§4.4a),它会用一个开关取代这整套三部分配方,一次性恢复全部四项服务。
  • 接受这个边界,至少它是诚实的,而不是悄无声息的:第 1 部分会在下一次导入时报告这个已经悬空的键,带上坐标和建议。

在一种情况下,上面这套配方依然是正确答案:当这一列没有 @target,因为键并不生活在某一个单一的标签页里时。内置的示例正是如此——List<Modifier> 没有指名任何目标,因此 Core 无法知道 attack 应该解析到哪里,于是 ModifierStatEdgeContributor 手动打开了那些边。给这一列一个目标(List<Modifier@Stats>),§4.4a 就会接管一切。

4.4a 为你自己的记法赋予完整的引用对等能力(可选)

在一个你已经注册的解析器上实现 IReferencingCellType,一列 MyType@Tab 就不再是特例:它会像 RecordId@Tab 一样被验证、给出建议、传播、绘制、选取和索引。

这里不存在新的注册渠道——Core 是对 CellParserRegistry 中已经注册的解析器做类型转换,做法与画布 capability 从已注册的边贡献者上转换而来完全相同(§4.12)。一个没有实现它的自定义类型,行为与之前一字不差、完全相同。

五个钩子

这五个钩子全都作用于一个元素:对标量列而言是整个单元格,对 List<MyType@Tab> 而言是按 ; 分隔出的一个元素——与你的 ICellValueParser.TryParse 所接收到的单位相同。

钩子回答的问题用途
bool TryGetTokenKey(elementText, out key)"这个元素指向什么?"成员关系判断——这个单元格是否已经链接到那条记录
string MakeToken(key)"写一条指向这个键的新链接"用于空单元格,或向列表追加元素。请用一个中立的起始值填充载荷;创作界面绝不能凭空捏造取值。返回 null/空时,对应手势会被禁用并给出原因,而不是被假装完成
bool TryRetargetToken(elementText, newKey, out newText)"把这个改指向别的东西"用于在 单元格中选取另一条记录,以及在画布上重新指向一条连线。只改变目标——移除并重新生成整个 token 会重置一个人已经手动输入的数字
bool TryRemoveToken(elementText, key, out newText)"解除这个链接"返回空文本,该元素就会消失(标量单元格被清空,列表元素被移除);返回非空文本,则那部分内容会被保留
bool TryRewriteKeys(elementText, renames, out newText)"替换所有这些键"用于重命名批量扫描。与 TryRetargetToken 分开的原因是:后者是一个人发出的单条指令,而前者是一次批量处理——并且一个持有两个引用的元素必须把两个都重写

文本这一半与取值这一半

再给解析后的取值加上 IRefBearingValue——这两半各自承担不同的职责,而且缺一不可。文本这一半看不到解析后的取值;取值这一半也无法还原作者当初输入的记法:

using System.Collections.Generic;
using SheetForge.Core.Model;
 
// Text half — on the parser. `stat:op:value`, e.g. attack:add:10
public sealed class ModifierCellParser : ICellValueParser, ICustomCellType, IReferencingCellType
{
    public bool TryGetTokenKey(string t, out string key)
    {
        key = Head(t);                                  // the first segment is the reference
        return key.Length != 0;
    }
 
    public string MakeToken(string key) => key + ":add:0";                 // neutral, ready to edit
 
    public bool TryRetargetToken(string t, string newKey, out string newText)
    {
        newText = newKey + Rest(t);                     // the residue is preserved
        return Head(t).Length != 0;
    }
 
    public bool TryRemoveToken(string t, string key, out string newText)
    {
        newText = string.Empty;                         // nothing is left without the key
        return Head(t) == key;                          // not ours → false, never overwrite blindly
    }
 
    public bool TryRewriteKeys(string t, IReadOnlyDictionary<string, string> renames, out string newText)
    {
        newText = t;
        if (!renames.TryGetValue(Head(t), out string to)) return false;
        newText = to + Rest(t);                         // attack:add:10 → power:add:10
        return true;
    }
    // … TypeName / TryParse / ValueType / TryRender as in §2
}
 
// Value half — on the value the parser produces.
public struct Modifier : IRefBearingValue
{
    public string stat; public string op; public float value;
 
    IEnumerable<string> IRefBearingValue.ReferencedKeys =>
        string.IsNullOrEmpty(stat) ? System.Array.Empty<string>() : new[] { stat };
}

实现一个接口不会新增任何字段,因此烘焙出的 ScriptableObject 和生成的代码都不受影响。

一次可选启用,你能得到什么——以下每一项都是 Core 自身的代码路径,而不是重新实现的一份:

  • 完整性 + 建议——一个不存在的键会被报告为 UnresolvedRecordId,带有坐标和"你是不是想输入……",并与内置引用共享同一份按字段计算的建议预算。
  • 保留载荷的重命名传播——把 attack 重命名为 power,会把 attack:add:10 重写为 power:add:10;运算符和数字都是作者自己写的,它们会被保留下来。
  • ——这条链接会变成一条带坐标的真实边:它会被绘制出来,节点会获得一个端口,Used by 列表会计入它,引用索引会双向记录它。
  • 选择器——该单元格会获得与 RecordId@Tab 单元格相同的可搜索下拉菜单,选取另一条记录会只替换目标,保留剩余部分。没有这项注册时,选择器会拒绝操作,而不是把一个裸键直接粘贴覆盖到你的取值上。
  • 孤立检测与导出的下拉规则——一行记录,如果它唯一的外向链接就藏在你的记法内部,就不会再被当作未连接处理;你这种类型的一个标量列,也会获得一个覆盖目标标签页键集合的数据校验下拉列表(数据源、导出与推送)。

最简单的用法是一个别名类型。 假设这个取值本身就是一个键,而单元格文本正是这个键:

  • TryGetTokenKey 去除首尾空白。
  • MakeToken 返回该键。
  • TryRetargetToken 返回新键。
  • TryRemoveToken 返回空。

这一列在功能上的每一个方面都等同于一个 RecordId@Tab,留给你的唯一事情就是呈现方式:它会以自己的名字出现在 @type 中,你也可以单独为这一列附加一个单元格部件(§4.13)或一个画布形状(§4.7)。别名不需要单独的契约。

两条约束,都是结构性的:

  • 载荷中不能有 ; Core 会在你的解析器或这些钩子中的任何一个看到文本之前,就先把一个列表单元格拆分成元素,因此取值内部的一个分号会被拆碎成两个元素。(包装类型出于同样的原因带有相同的约束。)
  • @target 必须指向一个真实的工作表标签页,与 RecordId@Tab 完全一样——代码注册表的虚拟标签页会被 UnknownTargetTab 拒绝。正是这条限制,才让未解析引用报告、最接近匹配建议,以及重命名传播,都能原样使用 Core 自身的实现,无需任何改动。

这五个钩子都不允许抛出异常:对任何你无法解读的内容,回答 falsenull,并且每当你重写时都要保留剩余部分。

它同样适用于一个整数键空间。 如果你 @target 指名的那个标签页是以 IntId 而不是 RecordId 为键的,你的代码不需要做任何改动——你的钩子收发的键,只是被写成文本的那个整数而已。到底要对照哪一个键空间,是由目标标签页自身的身份决定的,与你的类型无关。

  • 验证、最接近匹配建议、重命名传播、边、选择器,以及孤立检测,都会以同样的方式被点亮。
  • Core 在这里为你额外做了一件贴心的事:由于一个整数可以有好几种写法,一次重命名会把该元素中实际出现的写法连同规范写法一起交给 TryRewriteKeys0077 都会映射到 12),因此你类型内部的一次序数查找不会漏掉一个带填充位的取值。
  • 内置示例中没有包含一个指向 IntId 标签页的引用型自定义类型——它的 Modifier 示例指向的是一个字符串键的标签页——因此这条路径有测试覆盖,但没有可供照抄的实战样例。

4.5 自定义结构标记(可选)

内置的标记有 @name@type@desc,以及三个可选的标记:

  • @overlap
  • @style,它描述的是整张工作表——它的分组标签和颜色——而不是它的列。
  • @enum,它把这张工作表标记为一组枚举定义,而不是一张数据表。

@overlap 是一个按列标记:它的行按列携带一个值,逐列进行验证。你可以用同样的方式注册自己的标记——例如一个记录每个数值列插值方式的 @curve 标记。实现 IStructuralMarkerDefinition,并通过 ISheetForgeMarkerPlugin 注册它:

// A hypothetical plugin (the bundled Plugin Demo does not register a marker):
public sealed class CurvesPlugin : /* ... */, ISheetForgeMarkerPlugin
{
    public void RegisterStructuralMarkers(MarkerRegistry markers)
    {
        markers.Register(new CurveMarker());
    }
}
 
public sealed class CurveMarker : IStructuralMarkerDefinition
{
    public string MarkerName => "curve";                 // without '@' → the sheet row is @curve
    public string Description => "How this column interpolates (linear/ease/step).";
 
    // Validate this column's @curve cell. Empty is allowed (defaults to linear).
    public void ValidateCell(MarkerCellContext context)
    {
        string v = context.RawText.Trim();
        if (v.Length == 0) return;                        // you decide what an empty cell means
        if (v != "linear" && v != "ease" && v != "step")
            context.Reject("@curve must be linear, ease, or step", "use one of: linear, ease, step");
    }
}

工作表随后就可以接受一个 @curve 行(顺序任意,位于数据之上):

@name    | level | atk
@type    | int   | int
@curve   |       | ease
         | 1     | 10
  • 该值会被存储为与领域无关的元数据:field.MarkerValues["curve"]。领域验证器边贡献者可以从 context.Tables[tab].Schema.Fields[i].MarkerValues 读取它;创作窗口会在列表头的工具提示中显示它。
  • 被拒绝的单元格会成为一条 MarkerCellInvalid 诊断信息——"为什么"和"怎么修"由你提供;坐标和出问题的值由 Core 提供。
  • 标记名称必须是合法的标识符,且不能与内置的六个标记冲突(@name/@type/@desc/@overlap/@style/@enum——否则 Register 会抛出异常,并呈现为 PluginRegistrationConflict)。
  • 标记用于按列元数据,而不是新的数据形状——一个标记只拥有它自己的单元格验证,而不是整行。自定义标记行会在导出/往返时原样保留,并在每一次结构编辑(新增/删除/移动/重命名)中随其所在列一起移动。
  • 代码生成不会烘焙标记值(与 @overlap 一样,它们只是验证/展示用的元数据,对架构指纹不可见)。

4.6 "创建工作表"模板(可选)

创建工作表流程自带两个内置模板——一张仅使用核心类型的物品工作表,以及一张 @enum 定义表——外加"从零开始"选项。而领域模板——使用你自己的 enum、自定义类型和引用的工作表骨架——则来自插件,因此只有在对应插件存在时,模板才会出现。实现 ISheetForgeTemplatePlugin

public sealed class SkillsPlugin : /* ... */, ISheetForgeTemplatePlugin
{
    public void RegisterTemplates(TemplateRegistry templates)
    {
        templates.Register(new DataTemplate(
            "skills.demo",                                   // registry key (unique; duplicates rejected)
            "Skill demo (Actions · Effects · Skills)",       // your own display string
            new List<DataTemplateTab>
            {
                // Each tab carries a full TSV: marker rows + example data.
                new DataTemplateTab("Actions", "@name\tcodeName\ttype\n@type\tRecordId\tEnum<ActionType>\n\tfireball\tProjectile"),
                new DataTemplateTab("Effects", /* ... */ ""),
                new DataTemplateTab("Skills",  /* ... */ ""),
            }));
    }
}
  • 一个模板携带一个或多个标签页,每个标签页都是一份完整的规范化 TSV(注释/标记行加上示例数据)——不同于内置的物品示例,后者是一份 0 行的骨架。由于你的领域类型已经注册完毕(插件已加载),创建出的工作表可以立即成功重新导入。
  • 展示字符串由你决定。 一个插件拥有自己的文本(示例包位于领域词汇守护测试的范围之外)——你不受限于 Core 的 Loc 键。
  • 多标签页模板会创建全部标签页并一次性重新导入,因此跨标签页的引用可以一起解析。对于这类模板,创建面板会隐藏标签页名称字段(标签页名称由模板固定)。
  • 键重复、显示名为空、零标签页,以及标签页 TSV 为空都会被拒绝(Register 会抛出异常,并呈现为 PluginRegistrationConflict)。

4.7 按标签页的画布覆盖(可选)

Data Studio 的画布会自行决定要画什么:你打开一条记录——终点节点——它就会沿引用索引向外遍历,收集这条记录消耗的一切,然后把结果从左到右布局出来。这一切不需要任何插件就能工作。

插件所新增的,是核心看不见或无法知道的东西:

  • 一个不是工作表记录的身份,
  • 一条没有写在 RecordId@Tab 列中的链接,
  • 以及一种由领域规则决定、而不是由引用深度决定的顺序。

实现 IRecordCanvasAugmenter,并通过 ISheetForgeGraphPlugin 按标签页注册它:

using SheetForge.Core.Graphing;
using SheetForge.Core.Plugins;
 
public sealed class SkillsPlugin : /* ... */, ISheetForgeGraphPlugin
{
    public void RegisterGraphShapes(GraphShapeRegistry shapes)
    {
        shapes.Register("ExampleActions", new ExampleReactiveAugmenter());   // tab name → override
    }
}
 
public sealed class ExampleReactiveAugmenter : IRecordCanvasAugmenter
{
    public void Augment(GraphBuildContext context, CanvasAugmentBuilder builder,
                        string terminusTab, string terminusRecordId)
    {
        // context = Tables (parsed sheets) · References (indexed both ways) · CodeRegistries
 
        // ① A virtual node: an identity that is not a sheet record. The tab may be empty —
        //    then the key alone identifies it. The last argument is where clicking it jumps.
        builder.AddNode(string.Empty, "evt:impact_landed", "impact_landed", "event");
 
        // ② An extra edge the core scanner cannot see (this link lives in a plain string column).
        //    Naming the field says *which cell* it is written in; leaving it out keeps the wire
        //    display-only. Direction is "A uses B", and B is drawn to the left of A.
        builder.AddEdge(terminusTab, terminusRecordId, string.Empty, "evt:impact_landed",
                        /*label*/ "listen", /*fieldName*/ "listen");
 
        // ②b An edge drawn one way whose cell lives on the other end, and a loop you know about.
        //     Both are trailing arguments — the short call above still compiles unchanged.
        builder.AddEdge(string.Empty, "evt:impact_landed", terminusTab, terminusRecordId,
                        label: "raises", fieldName: "raises", fieldOnTarget: true,
                        isCyclic: true, cyclicNote: "brake 0s — no damping");
 
        // ③ A layer hint. Absolute columns count from 0 at the left (negative goes further left);
        //    relative columns count from the terminus, which is what a fixed stage usually means.
        builder.SetLayerRelative(string.Empty, "evt:impact_landed", -2);
 
        // ④ A display hint: what a human calls this record. Only you know which column is a name.
        builder.SetSubtitle(terminusTab, terminusRecordId, "Counter strike");
    }
}
  • 注册是按标签页名称进行的。 你没有注册的标签页依然会获得一个画布——核心闭包——因此插件永远不需要覆盖每一张工作表。重复的标签页、空的标签页名称,以及 null 覆盖都会被拒绝(Register 会抛出异常,并呈现为 PluginRegistrationConflict)。
  • 你是在追加,而不是替换。 哪些记录会出现,答案由闭包决定。一个 (标签页, 键) 已经在屏幕上的虚拟节点会被丢弃——真实记录优先——因此一个覆盖无法凭空捏造出一条工作表中已经存在的记录。它能做的是引入那些完全没有工作表行的身份。
  • 名称是唯一的例外。 显示提示属于呈现层面,而不是身份层面,所以它确实适用于已经存在的记录,并且它可以为完全不在屏幕上的记录命名——连接选择器会读取这些名称,这也是卡片副标题和选择器行会显示同样文字的原因。空白名称会被忽略(等同于"使用默认值"),一条记录的第一个名称胜出。
  • 一条边会带来它自己的节点。 如果一条额外边的某一端不在屏幕上,它会被作为一个节点添加进来,让这条链接永远不会悬空。两端都为空键的边会被忽略。
  • 单元格所在位置和箭头所指方向可能不同。 默认情况下,fieldName 所指名的单元格被假定位于出发端记录上。当它实际位于到达端时,传入 fieldOnTarget: true——一条"已发布事件"连线画成事件 → 记录的方向,但文本却在记录自己的列中。这样连线检查器就会指向真实的单元格,而不是指向空处。
  • 循环:核心会标记它能看到的那些,你负责声明你所知道的那些。 如果你的额外边闭合成一个循环,画布会自行判定这条回边并把它画成虚线。判断一个循环是否是一个问题,是领域验证器的职责(§3);画布只是展示素材,永远不是验证。
    • isCyclic 只是把一条连线标记为用于显示的循环,不会影响布局。
    • cyclicNote 则携带只有你才知道的信息(比如一个阻尼值)——请把标签保留为列名,把解释放进这条说明里。
  • 层级提示有两种形式。
    • SetLayer 是绝对的——列 0 是最左侧,负数则更靠左。
    • SetLayerRelative 是从终点节点开始计数的(−1 是它紧邻左侧的那一列),这通常正是一个固定阶段所要表达的意思。这样一来,无论链条是浅是深,画面读起来都一样,你也不必为了防止各阶段互相碰撞而固定终点节点本身。
    • 相对提示是针对任何提示移动它之前的终点节点所在列来解析的,因此你添加提示的顺序不会改变结果;如果结果落到零的左侧,整幅画面就会整体右移。
    • 针对一个不在屏幕上的节点的提示会被丢弃,一个节点的第一条提示胜出。
  • 失败会被隔离在局部。 Augment 在 try/catch 内部运行:异常会变成一条英文的控制台警告,加上核心画面,而不会导致窗口损坏。
  • 能力扩展永远不会破坏你的代码。 自首次发布以来新增的每一项能力,都只是一个末尾参数或一个新方法;针对更早版本界面编写的覆盖依然能编译,并且行为完全相同。

(完整的覆盖实现见 Assets/SheetForge.PluginDemo/Graphing/ExampleReactiveAugmenter.csExamplePipelineAugmenter.cs——一个会在记录周围生长出事件节点和代码块的反应,以及一个把固定阶段钉在各自列上的施法过程。)

4.8 代码注册表——存在于代码中的引用目标(可选)

有些引用目标根本不是在工作表中创作出来的:它们是你运行时所分发到的执行原子。把它们注册为一个锁定的虚拟标签页,会将它们以只读的形式呈现在创作界面上,并阻止指向它们的边被画成损坏状态。实现 ISheetForgeCodeRegistryPlugin

using System.Collections.Generic;
using SheetForge.Core.Graphing;
using SheetForge.Core.Plugins;
 
public sealed class SkillsPlugin : /* ... */, ISheetForgeCodeRegistryPlugin
{
    public void RegisterCodeRegistries(CodeRegistryCatalog catalog)
    {
        catalog.Register(new CodeRegistrySource("_Refs", new List<CodeRegistryEntry>
        {
            // key = the referenceable id · label = shown text · raises = optional related keys
            new CodeRegistryEntry("action.projectile", "Projectile launch", new[] { "impact_landed" }),
            new CodeRegistryEntry("effect.script",     "Script effect",     null),
        }));
    }
}
  • 三个消费点
    • Data Studio 侧边栏会把这个虚拟标签页显示在 READ-ONLY 分组下,呈现为一个键/标签/raises 网格;
    • 一个画布覆盖可以通过 context.CodeRegistries 查找这些条目;
    • 节点检查器会列出某个条目的 Raises
  • 这些键会加入 Studio 的存在性检查。 一条边如果目标是一个已注册的键——通常是由一个 IEdgeContributor(§4)声明的,或者由你的形状构建出来的——就不会被画成损坏的引用。
  • 导入验证器并不知道虚拟标签页。 代码注册表是创作界面层面的概念,因此不要把工作表的某一列类型写成 RecordId@_Refs(导入会报告 UnknownTargetTab)。请像示例那样把工作表数据与代码原子连接起来——一个 type 列,配合一个边贡献者 / 形状查找。
  • 选择一个不会与真实工作表冲突的名字(示例使用 _ 前缀)。如果确实发生了冲突,Studio 会在侧边栏中把这次冲突标示出来,而不是悄悄隐藏其中一个。
  • 拒绝情形null 来源、空的标签页名称,或者重复的标签页名称都会抛出异常(呈现为 PluginRegistrationConflict);nullRaises 列表会被归一化为空集合。Core 把键 / 标签 / raises 都当作不透明的字符串处理——它从不解读它们。

(见 Assets/SheetForge.PluginDemo/Graphing/ExampleCodeAtoms.cs。)

4.9 Data Studio 图形部件(可选,Editor 程序集)

部件是画布上方一条你自己的 UI 信息条——一个固定阶段概览、一个汇总徽标,或者领域想要的任何东西。核心不附带任何部件,因此这块区域在插件填充它之前是空的。由于返回类型是 VisualElement,这个契约位于 Editor 程序集中(与 ISheetSourceProvider 相同的正当不对称性);请在一个引用了 SheetForge.EditorSheetForge.Core 的 Editor 侧程序集中实现它:

using SheetForge.Editor.Studio;
using UnityEngine.UIElements;
 
public sealed class ExampleStageStripWidget : IStudioGraphWidget
{
    // context = Tab · ShapeId · ModeId · FocusRecordId · FocusRecord · Tables · References · CodeRegistries
    public bool AppliesTo(StudioGraphContext context) =>
        context.Tab == "ExampleSkills" && context.FocusRecord != null;
 
    public VisualElement Create(StudioGraphContext context)
    {
        var strip = new VisualElement();
        strip.Add(new Label("VALIDATE → CAST → COMMIT → DELIVER → APPLY"));
        return strip;                       // return null to add nothing
    }
}
  • 发现是自动的——TypeCache 会找到每一个带有无参构造函数的实现;不需要注册调用,也没有需要绑定的注册表。实例化失败会被记录日志并跳过。
  • 按契约只读。 上下文暴露的是已解析的表、引用索引和代码注册表——但没有暂存能力。从图形上进行创作,属于检查器操作(§4.10)的职责,由它来中介这个过程。
  • 不要在元素内部持有状态。 部件会在每次图形重建时被重新创建;请把状态保存在你自己的对象中。重建会被合并到人类操作的频率,而不是每次按键。
  • 异常会被隔离——AppliesTo / Create 抛出异常会产生一条英文的控制台警告;图形依然会正常绘制。

(见 Assets/SheetForge.PluginDemo/Demo/Editor/ExampleStageStripWidget.cs。)

4.10 Data Studio 检查器操作(可选,Editor 程序集)

操作是节点检查器上的一个额外按钮——"这个领域能对这条记录做什么"。核心提供一个内置操作(Go to this sheet);其余的一切都通过这个契约到来:

using SheetForge.Editor.Studio;
 
public sealed class ExampleInspectorAction : IStudioInspectorAction
{
    // A Loc key. The demo registers this key's sentences per language (§4.14);
    // an unregistered key is displayed verbatim, so plain text also works.
    public string LabelKey => ExampleLocStrings.BrakeActionKey;
 
    public bool AppliesTo(StudioInspectorContext context) =>
        context.Tab == "ExampleActions" && context.Record != null;
 
    public void Execute(StudioInspectorContext context)
    {
        // Mediated mutation: the window turns this into one Undo step + one staged edit
        // carrying the logical address (tab · record id · field).
        context.StageCell(context.Tab, context.RecordId, "brakeSeconds", "0.25");
        // Show the user what changed: (tab, original sheet row number, field); row 0 = tab only.
        context.FocusCell(context.Tab, context.Record.RowNumber, "brakeSeconds");
        context.RequestRebuild();
    }
}
  • 创作会话被刻意地不暴露出来。 每一次暂存更改都必须是一步原生撤销操作,并且要递增投影代次;把原始会话直接交出去,会让绕过这条规则的做法变成一种制度化的方式。StageCell(tab, recordId, field, rawText)StageCells(writes) 就是全部的变更接口,记账工作由窗口负责。
  • 要更改多个单元格?使用 StageCells context.StageCells(new[] { new EdgeCellWrite(tab, recordId, field, text), … }) 会把整份列表暂存为一步撤销操作,要么全部生效,要么全都不生效(只要有一次写入无法应用,就没有任何一个会生效)。多次调用 StageCell 会把 Ctrl+Z 拆分成同样多个步骤——对于并列列而言,这意味着撤销到一半时会出现一个半有效的状态。传入 null 或空列表则什么都不会发生。
  • 传入规范文本。 暂存的文本会在反映时由导入器使用的同一个解析器来解析——因此请写出工作表里应有的内容。
  • 一个不在 baseline 中的键,会是一次空操作(全新的或无法解析的记录):不会静默写入任何内容。
  • 服务FocusCell 会把网格滚动到某个坐标,RequestRebuild 会在你暂存了内容之后请求刷新。
  • 发现、标签与隔离的工作方式与部件完全相同:TypeCache 发现机制、未注册的 LabelKey 会原样回退显示(空键回退为类型名),并且 AppliesTo / Execute 都被包裹在 try/catch 中。

(见 Assets/SheetForge.PluginDemo/Demo/Editor/ExampleInspectorAction.cs。它所在的 Editor 程序集——SheetForge.PluginDemo.Demo.Editor——引用了 SheetForge.EditorSheetForge.Core 和插件程序集;一个 Editor 侧扩展所需要的接线工作就这么多。)

4.11 色彩预设(可选)

SheetForge 用一套很小的颜色插槽词汇(界面、线条、文本、语义色、暂存标记)来绘制自己的窗口。一个预设会重新着色它所关心的那些插槽;其余每一个插槽都保持产品默认值。实现 ISheetForgeThemePlugin

public sealed class SkillsPlugin : /* ... */, ISheetForgeThemePlugin
{
    public void RegisterThemes(ThemeRegistry themes)
    {
        themes.Register(new SheetForgeTheme(
            "skills.forge",                       // registry key (unique; the built-in ids are reserved)
            "Forge (Skill demo)",                 // your own display string
            new Dictionary<ThemeColorSlot, uint>  // dark screens
            {
                { ThemeColorSlot.Accent, 0xff9a4d },
                { ThemeColorSlot.Canvas, 0x120d0a },
                { ThemeColorSlot.Text,   0xe8dccf },
            },
            new Dictionary<ThemeColorSlot, uint>  // light screens
            {
                { ThemeColorSlot.Accent, 0x9c4a10 },
                { ThemeColorSlot.Canvas, 0xf7f2ec },
                { ThemeColorSlot.Text,   0x2b1f16 },
            }));
    }
}
  • 颜色格式为 0xRRGGBB Core 不引用任何引擎类型,因此这里不存在 UnityEngine.Color;最高位字节会被忽略。半透明界面(徽标填充、模态遮罩)由某个插槽颜色加一个固定的透明度推算而来——你设置的是颜色,而不是透明度。
  • 同时提供两种屏幕模式。 提供一份深色映射表和一份浅色映射表;用户的明暗选择(跟随编辑器 / 始终深色 / 始终浅色)会选中其中一份。你没有填写的插槽,会在对应明暗模式下回退到产品默认值,因此一个只有三个插槽的预设完全正常。
  • 注册这个动作本身不会应用它。 你的预设会出现在 Preferences ▸ SheetForge ▸ Theme ▸ Colour preset 中,与内置的 DefaultHigh contrast 并列;只有用户自己选中它才会生效。展示字符串由你决定(不需要 Core 的 Loc 键)。
  • 空白的 id、重复项,以及保留的内置 id(defaulthighContrast)都会被拒绝(Register 会抛出异常,并呈现为 PluginRegistrationConflict)。
  • 主题无法重新样式化的内容: 绘制在我们窗口内部的原生 Unity 控件(按钮外观、字段边框)依然会跟随编辑器皮肤——见功能与限制

4.12 在图形画布上编辑(可选)

Data Studio 的图形是一个创作界面,而不是一张图片:右键点击可以创建记录、连接它们,以及断开连线(见 Data Studio)。对于普通的 RecordId@Tab 列,这一切在一个普通项目上都能直接工作。下面这些 capability 会把它扩展到核心无法触及的地方——它们都不会改动任何一个既有契约,因此一个忽略它们的插件依然能原样编译。

capability 是如何被发现的(请先阅读这一节)

一个 capability永远不会被单独发现。窗口是通过对已经注册的对象做类型转换来找到它们中的每一个的:

capability从什么类型转换而来增加了什么
IAuthorableGraphShapeISheetForgeGraphPlugin 注册的画布覆盖新记录可以在哪里被创建
IAuthorableEdgeContributorISheetForgeEdgePlugin 注册的边贡献者把一次手势变成一次单元格写入
IBatchAuthorableEdgeContributor同一个边贡献者把一次手势变成多次单元格写入
IVirtualNodeFactory同一个边贡献者在节点菜单上提供"再创建一个"选项
IEdgeSlotDeclarer同一个边贡献者声明架构无法推导出的连接槽位
IEdgeTokenEditor同一个边贡献者描述一个 token,并编辑其中不属于键的那部分

因此,只有当这个类被注册为一个 IEdgeContributor(通过 §4 的 ISheetForgeEdgePlugin)时,这五个边侧的 capability 才能被触及到。如果你的领域自己不打算开放任何边,这也不是跳过注册的理由——把 ContributeEdges 实现成一个空方法,然后照样注册它。这个空贡献者正是官方支持的加入方式:

public sealed class ExampleSlotPlugin : ISheetForgeEdgePlugin
{
    public void RegisterEdgeContributors(EdgeContributorRegistry contributors)
        => contributors.Register(new ExampleSlotContributor());
}
 
public sealed class ExampleSlotContributor : IEdgeContributor, IEdgeSlotDeclarer
{
    public string Name => "ExampleSlots";
 
    // Nothing to declare — this class is here for the capabilities below.
    public void ContributeEdges(EdgeContributionContext context, ICollection<EdgeSpec> edges) { }
 
    public IReadOnlyList<DeclaredSlot> DeclareSlots(EdgeAuthoringContext context,
                                                    string nodeTab, string nodeRecordId) => …;
}

它们全都在 try/catch 内部运行:异常会变成一条英文的控制台警告,并且只会禁用那一个操作,不会影响其他任何东西。

新记录可以在哪里被创建——IAuthorableGraphShape

存在两种默认行为,而且它们被刻意设计得不同。

  • 可创建标签页列表——这个 capability 所取代的那个轴,它同时也决定了画布是否会打开——会覆盖焦点标签页架构能够触及的每一张标签页,沿引用传递地追踪。它是根据架构计算出来的,而不是根据数据,因此即便在一张还没有任何行的工作表上也依然成立;到达一张两跳链接之外的标签页是分步进行的——先创建中间那条记录,它的端口才会出现,下一跳才会加入这个级联。
  • 连接级联——你在空白画布上实际看到的那个选择器——则更窄:它是从当前已画出的端口所指向的标签页开始的。

无论哪一种,代码注册表所拥有的标签页,以及没有键列的标签页,都会被剔除,因为在那里创建的新记录不可能拥有身份。

一个为该标签页注册的覆盖(§4.7)可以额外实现这个接口,来替换掉这两种默认行为——而它指名的、但当前没有任何屏幕上的端口接受的标签页,会继续保留在连接级联中并附上原因,而不是直接消失:

using SheetForge.Core.Graphing;
 
public sealed class ExamplePipelineAugmenter : IRecordCanvasAugmenter, IAuthorableGraphShape
{
    // Empty list = no creating from this canvas. The window still applies its own gates
    // (read-only source, running pipeline, workbook-backed tab, no key column) on top.
    public IReadOnlyList<string> CreatableTabs(GraphBuildContext context, string tabName)
        => new[] { "ExampleEffects", "ExampleActions" };
}

让你自己的边可以被编辑——IAuthorableEdgeContributor

一条你用 IEdgeContributor(§4)打开的边,会被画出来,但无法编辑,因为只有你才知道它所寄居的记法。添加这个接口,把一次手势重新转化为单元格文本;窗口会精确暂存你返回的内容,解析器依然是最终的裁决者:

using SheetForge.Core.Edges;
 
public sealed class ModifierStatEdgeContributor : IEdgeContributor, IAuthorableEdgeContributor
{
    public bool TryPlanConnect(EdgeAuthoringContext context, string fromTab, string fromRecordId,
                               string toTab, string toRecordId, out EdgeCellWrite write)
    {
        write = default;
        if (fromTab != "ExampleEffects" || toTab != "ExampleStats") return false;   // not mine
 
        // CellText = the cell as it reads right now (baseline + staging), not the parsed value.
        string current = context.CellText(fromTab, fromRecordId, "modifier");
        if (current.Contains(toRecordId + ":")) return false;                       // already linked
 
        string next = current.Length == 0 ? toRecordId + ":add:0"
                                          : current + "; " + toRecordId + ":add:0";
        write = new EdgeCellWrite(fromTab, fromRecordId, "modifier", next);
        return true;
    }
 
    public bool TryPlanDisconnect(EdgeAuthoringContext context, RecordEdge edge, out EdgeCellWrite write)
    {
        write = default;
        if (edge.FieldName != "modifier") return false;
        // …remove the fragment naming edge.ToRecordId, hand back the rewritten cell…
        write = new EdgeCellWrite(edge.FromTab, edge.FromRecordId, "modifier", rewritten);
        return true;
    }
}
  • false 意味着什么都不会发生。 不会创建任何暂存,菜单项会被禁用并给出如实的原因——绝不会出现半应用状态的编辑。返回 true 并附带无意义的文本是被允许的,但没有意义:暂存的取值会经过与手动输入完全相同的预检验证,并出现在 Problems 中。
  • 按键寻址,而不是按行。 EdgeCellWrite 指名的是 (标签页, 记录 id, 字段);行号会在写入时重新解析,因此一份暂存的计划能挺过行的移动。
  • 你是在一次手势期间被调用的。 两个方法都在 try/catch 内部运行——异常会变成一条英文的控制台警告,并且只会禁用那一个操作,不会影响其他任何东西。
  • 询问上下文,而不是工作表。 CellText 返回的取值包含暂存内容,因此连续做出的两次链接彼此可见。改为读取已解析的表则会漏掉第一次。

在一次手势中更改多个单元格——IBatchAuthorableEdgeContributor

有些数据会把一个项目分散存放在并列的多个列中:stepDelays | stepTargets | stepCounts,每一列里第 i 个索引对应同一个步骤。在这样的数据上添加一条链接,必须同时让每一列都增长,否则各列的长度就会不一致——这是单单元格计划无法避免的一种半有效状态。这个 capability 是 IAuthorableEdgeContributor同级(不是子类),因此只实现了单数形式的贡献者不会受到影响:

using SheetForge.Core.Edges;
 
public sealed class ExampleStepContributor : IEdgeContributor, IBatchAuthorableEdgeContributor
{
    public bool TryPlanConnectMany(EdgeAuthoringContext context, string fromTab, string fromRecordId,
                                   string toTab, string toRecordId,
                                   out IReadOnlyList<EdgeCellWrite> writes)
    {
        writes = new[]
        {
            new EdgeCellWrite(fromTab, fromRecordId, "stepTargets", Append(context, fromTab, fromRecordId, toRecordId)),
            new EdgeCellWrite(fromTab, fromRecordId, "stepDelays",  AppendDefault(context, fromTab, fromRecordId)),
        };
        return true;
    }
 
    public bool TryPlanDisconnectMany(EdgeAuthoringContext context, RecordEdge edge,
                                      out IReadOnlyList<EdgeCellWrite> writes) => …;
}
  • 要么全部,要么全不。 列表中的每一次写入都会被暂存为一步原生撤销操作;只要其中一次无法被写入(没有这样的行、只读来源、管线正在运行),就完全不会暂存任何内容。
  • 批量版本优先。 如果一个类同时实现了单数形式和批量形式,窗口只会询问批量形式——一次手势永远不会有两个不同的答案。多个贡献者依然按注册顺序被依次询问,第一个给出计划的胜出。
  • 每一次写入都需要一个地址。 一份包含空标签页或空字段的写入(或一份空列表)的列表,会被算作"没有计划"。
  • 解除链接是按链条运行的。 当在一次手势中同时剪断一张卡片上的多条连线时,你读取到的上下文已经携带了这次手势中更早的那些计划,因此从同一个单元格中剪掉两个 token 会把两个都移除。单数形式的契约没有接收这个中间值的接口——这个 capability 正是用来解除这一限制的。
  • 正在被创建的记录不能作为目标。 在"一次手势中创建并链接"这个流程中,写入地址是在新行进入会话之前就已解析好的,因此一份以正在被创建的记录为目标的计划无法成立,整次手势会如实地失败。以已经存在的行为目标(并列列的情形)则不受影响。

再创建一个东西——IVirtualNodeFactory

当"再来一个"指的不是一行新的行,而是分散在多个单元格中各增加一个元素时,画布无法凭空发明出这个手势。声明你能创建的种类,并在其中一个被选中时交出对应的单元格写入:

using SheetForge.Core.Edges;
 
public sealed class ExampleStepContributor : IEdgeContributor, IVirtualNodeFactory
{
    // Called every time the node menu is built — keep it cheap and side-effect free.
    public IReadOnlyList<VirtualNodeKind> KindsFor(EdgeAuthoringContext context, string tab, string recordId)
        => tab == "ExampleSkills"
            ? new[] { new VirtualNodeKind("step", Loc("Add a step")) }   // your own translated string
            : null;
 
    public bool TryPlanCreate(EdgeAuthoringContext context, string tab, string recordId,
                              VirtualNodeKind kind, out IReadOnlyList<EdgeCellWrite> writes)
    {
        writes = null;
        if (kind.Id != "step") return false;                            // not mine → nothing happens
        writes = new[] { … };                                           // one element appended per column
        return true;
    }
}
  • 标签已经是翻译好的文本。 Core 不会翻译它——请提供你的包自己解析出的字符串(见 §4.14)。标签中的 / 会构成一个子菜单,因此你可以把自己的条目分组。
  • tab 可以是一个虚拟标签页名称,也可以为空。 你的画布覆盖放到屏幕上的节点并不生活在某张工作表里;菜单依然会提供你所声明的内容,因为你写入的单元格是由你的计划命名的,而不是由节点的身份命名的。代码注册表拥有的标签页会被排除在外。
  • 一步撤销操作,要么全部生效要么全不生效——与上面的批量 capability 规则相同。false 表示什么都不会被暂存。

声明连接槽位——IEdgeSlotDeclarer

连接槽位通常来自架构(RecordId@Tab 列)。你的覆盖放到屏幕上的节点没有任何列,而一个贡献者边只有在链接已经存在之后才会显露出一个槽位——因此第一条链接根本无处起步。改为直接声明这些槽位:

using SheetForge.Core.Edges;
 
public sealed class ExampleStepContributor : IEdgeContributor, IEdgeSlotDeclarer, IBatchAuthorableEdgeContributor
{
    // Called per card and per port gate — keep it cheap and side-effect free.
    public IReadOnlyList<DeclaredSlot> DeclareSlots(EdgeAuthoringContext context,
                                                    string nodeTab, string nodeRecordId)
        => nodeTab == "#step"
            ? new[] { new DeclaredSlot("target", "ExampleEffects", /*isList*/ false) }
            : null;
}
  • 这个名字承担两项职责。 它必须在该节点内唯一,并且必须与你画进这个槽位的边的 FieldName 相等——槽位查找和连线锚定都是按这个名字匹配的。如果一张工作表的某一列已经使用了这个名字,工作表会优先,你的声明会被悄悄丢弃。
  • 声明不等于规划。 一个被声明的槽位,要通过你的计划(IAuthorableEdgeContributor 或批量形式)来连接。只声明而不规划,端口会打开,但不会暂存任何内容——请两者都实现。
  • 端口可以在没有工作表行的节点上打开。 对于一个标签页不是工作表的节点,窗口不会按这个名字去查找一行;写入地址来自你的计划,并会在暂存时被检查。

编辑 token 的内容——IEdgeTokenEditor

链接与解除链接移动的是整个 token。而 token 往往不只是一个键:attack:add:10 既命名了一个属性,说明了数值多少。为同一个贡献者添加这个 capability,连线检查器就会为那部分剩余内容——不属于键的那部分——增加一行:

using SheetForge.Core.Edges;
 
public sealed class ModifierStatEdgeContributor : IEdgeContributor, IAuthorableEdgeContributor, IEdgeTokenEditor
{
    public bool TryDescribeToken(EdgeAuthoringContext context, RecordEdge edge,
                                 out EdgeTokenDescription description)
    {
        description = null;
        if (edge.FieldName != "modifier") return false;                    // not mine
 
        // Read the fragment out of the cell — never rebuild it from the edge, or the
        // highlight points at a piece that is not there.
        string fragment = FindFragment(context.CellText(edge.FromTab, edge.FromRecordId, "modifier"),
                                       edge.ToRecordId);
        if (fragment == null) return false;                                // hand-edited away
 
        description = new EdgeTokenDescription(
            /*tokenText*/ fragment,                                        // "attack:add:10"
            /*modifierText*/ fragment.Substring(fragment.IndexOf(':') + 1),// "add:10"
            /*modifierLabel*/ "op:value",
            /*isChoice*/ false, /*options*/ null, /*optionLabels*/ null);   // free text
        return true;
    }
 
    public bool TryPlanSetModifier(EdgeAuthoringContext context, RecordEdge edge,
                                   string newModifier, out EdgeCellWrite write)
    {
        // …rebuild the cell with that one fragment's leftover replaced, key untouched…
    }
}
  • 两个方法读取的是同一个单元格。 一条边知道自己指向哪里,但不知道它今天是用什么字符写成的,因此描述过程使用的是与写入相同的那个 EdgeAuthoringContext。这正是让高亮的片段和被重写的片段可以被证明是同一个片段的原因。
  • 键永远不会经过这道门移动。 更改一条链接指向什么,是重新指向(拖动连线);这一行只改变剩余部分。任何一半返回 false,都会隐藏该行,或者如实禁用它——不会暂存,也不会静默失败。
  • 这个部件的描述方式由你决定。 带选项的 isChoice 会绘制一个弹出菜单,否则就是一个文本框;这一行的标签和选项标签都是你自己的字符串。如果根本没有剩余部分,就构造 new EdgeTokenDescription(tokenText),这一行就不会被绘制——一个核心引用(它的键就是整个 token)不用写任何代码就是这样表现的。

让你自己的连线整体上可以被编辑

一条连线只有在指名了自己写入哪个单元格时,才能被编辑。对于它自己读取的引用,核心会自动填上这个信息;而你添加的一条额外边(§4.7),则要通过指名字段来做到这一点:

// Display-only edge — the canvas honestly reports it cannot be edited.
builder.AddEdge(tab, recordId, targetTab, targetKey, "raises");
 
// Edge that names its cell: "this link is written in (tab, record, column)".
builder.AddEdge(tab, recordId, targetTab, targetKey, "listen", /*fieldName*/ "listen");

指名一个单元格并不保证它可以被编辑——它只是说明这条链接生活在哪里。你添加的边会被交给与贡献者边相同的一套管线处理,因此它会在一个 IAuthorableEdgeContributor 认领它的那一刻起变得可编辑。如果那一列是一个普通文本列或 enum 列,没有任何东西负责重写它,画布就会如实报告这条连线在这里无法编辑,这是事实,而不是一次静默的空操作。

4.13 自定义单元格部件(可选,Editor 程序集)

网格用内置部件绘制每一个单元格(布尔开关、enum 弹出菜单、引用选择器、原始文本)。当某种类型值得拥有更好的输入方式——一条曲线、一个颜色、一个迷你语法编写器、一个多行文本框——就可以为这个类型名称替换掉对应部件,而不必触碰取值的解析方式:

using SheetForge.Editor.Studio;
using UnityEngine.UIElements;
 
public sealed class ModifierCellEditor : IStudioCellEditorProvider
{
    // The base type name from @type (a CellParserRegistry name; for a wrapper, the wrapper name).
    public string TypeName => "Modifier";
 
    public VisualElement CreateEditor(StudioCellEditorContext context)
    {
        if (context.Type.IsList) return null;   // decline — the built-in widget takes this cell.
 
        var field = new TextField { value = context.CurrentRawText };
        // Typing burst: coalesced into ONE Undo step for this cell.
        field.RegisterValueChangedCallback(e => context.CommitTyping(e.newValue));
        // Discrete confirmation (focus out): its own Undo step.
        field.RegisterCallback<FocusOutEvent>(_ => context.Commit(field.value));
        return field;
    }
}
  • 部件负责塑造输入,解析器负责把握含义。 无论你提交什么,都是规范的工作表文本;它会经过与手动输入完全相同的预检验证,问题会显示在 Problems 面板中。部件本身永远不需要做校验。
  • 刻意设计了两种提交方式。 Commit(从列表中选择、松开滑块、失焦)会创建一步撤销操作;CommitTyping(逐次按键)会把一连串输入合并为一步。把两者合并成一次调用,要么会为每个字母都产生一步撤销,要么会把两次明显不同的选择合并在一起。
  • 返回 null 表示放弃该单元格,改由内置部件接管——这是对你不处理的形态(你这种类型的 List<T>、可选字段)给出的如实回答。context.Type(已解析的 @type token)携带了做出这个判断所需要的一切信息。
  • ReferenceKeys(tab) 会把内置引用选择器所使用的相同候选列表交给你(投影出的键 ∪ 代码注册表的键 ∪ 暂存中新行的键,已排序)——无需自己收集。要让用户在内置单元格所打开的那个下拉菜单中选择该列表中的内容,调用 StudioKeyPicker.Show(screenAnchor, tab, candidates, picked),并在提交之前把返回的键拼接进你自己的记法中。(创建记录、把单元格留空,以及对列表做多选切换,都是内置引用单元格自己的规则,不在这个外观接口之列——一个拥有整个单元格文本的部件,也拥有这些决定权。)
  • 你可以认领一个内置类型名,而不仅仅是你自己的类型名。 已注册部件这个分支是最先运行的,因此 TypeName => "float" 确实会替换掉项目中每一个 float 列的原始文本框——这正是滑块、百分比字段,或者带单位后缀的输入框得以实现的方式。这样做需要注意两点:
    • 它会应用到项目中该类型的每一列,因此请通过读取 context.FieldName / context.Tab 来限定范围,对你不打算处理的列返回 null
    • 你提交的依然是规范的工作表文本,因此一个滑块必须按解析器读回的方式来渲染它的取值(浮点数往返所期望的写法参见 CanonicalValueRenderer.RenderFloat)。
  • 冲突会发出警告,发现过程是自动的。 与其他每一个契约相同的 TypeCache 发现机制;如果两个提供方认领了同一个类型名,先发现的那个胜出,控制台警告会点名双方。抛出异常的 CreateEditor 会被捕获、发出警告,该单元格回退为内置部件。
  • 在动手写一个部件之前,先看看一个 hint 是否就够用了。 如果你想要的只是一个下拉菜单、一个多行文本框、一个滑杆、一个开关、一个颜色选择器、一个曲线编辑器,或者一个渐变编辑器,改为注册一个 StudioCellEditorHint(§4.16)——不需要写任何部件代码,并且在浏览器中同样有效。优先级顺序是:先是这个契约,然后是 hint,最后才是核心默认形态;因此只要没有部件认领这个类型、或者认领了但选择放弃,这个单元格得到的就是 hint。

4.14 插件 UI 字符串(可选)

你的包所展示的标签——检查器操作、部件说明文字、§4.16 的声明式界面——可以跟随用户的语言。按语言逐键注册句子;Loc.Tr 会在查询产品语言表之前先查询这层覆盖,浏览器端的 t() 也是同样的做法:

using System.Collections.Generic;
using SheetForge.Core.Model;
using SheetForge.Core.Plugins;
 
public sealed class ExampleLocStrings : ISheetForgeStringsPlugin
{
    // Prefix keys with your pack name so packs never collide.
    public const string BrakeActionKey = "plugin.skillsDemo.action.setBrake";
 
    public void RegisterStrings(StringOverlayRegistry strings)
    {
        strings.Register(BrakeActionKey, new Dictionary<string, string>
        {
            { "en", "Set reaction brake to 0.25s" },
            { "ko", "반응 제동을 0.25초로 넣기" },
        });
        // Or one language at a time: strings.Register(key, "en", "…");
    }
}
  • 这个契约位于 Core 中,因此要把它放进你的主程序集。 两个宿主都会显示你的包的标签,而浏览器只会加载主 DLL——一个字符串插件如果放在编辑器配套程序集里,就会导致 Web 应用只能显示裸键名。
  • 注册是可选的。 一个未注册的键会一直原样显示——这个契约是一条升级路径,而不是硬性要求。
  • 语言使用 IETF 代码"en""ko""zh-Hans""pt-BR"……),匹配时不区分大小写。
    • 至少注册英语:查找时会依次回退——请求的语言 → 英语 → 未命中——因此任何其他语言的用户读到的是你的英语句子,而不是裸键名。
    • 产品不认识的代码会带着原因被拒绝,而不是被静默折叠进英语——一个悄悄变成英语的拼写错误将无从追查。
  • 产品自身的键无法被覆盖——一个指名了某个内置键的注册会被拒绝,因此一层覆盖永远无法让 UI 与产品自己的句子产生分歧。菜单标签尤其是直接从语言表烘焙而来的,因此一层可以重写它们的覆盖,会让引导文案与真实的菜单路径互相矛盾。这层覆盖是留给键的。
  • 跨包的重复注册以先发现的为准,并记录下原因——如果由最后一次注册悄悄胜出,界面显示的内容就会取决于插件的安装顺序。
  • 空键和空值同样会被拒绝。每一次拒绝都是一行面向开发者的英文提示,因为它的受众是插件作者,而不是终端用户。
  • 产品的 10 语言一致性规则不受影响:你的字符串存在于一层查找覆盖中,绝不会进入核心语言表。

(示例把它放在 Assets/SheetForge.PluginDemo/ExampleLocStrings.cs——出于上述原因,位于主程序集中——注册了它的检查器操作(§4.10)和声明式界面(§4.16)所显示的标签。)

4.15 单元格内的多行文本(对话、描述、脚本)

一个真正的换行符永远不能存在于单元格内部。 这条管线的输入是 TSV,制表符用来分隔单元格,换行符用来分隔行,因此一个持有这两种字符任意一个的单元格,根本无法被表示出来。

每一种来源都会在入口处强制执行这一点,而不是放任一个已损坏的网格进入下一步:

  • CSV 和 xlsx 读取器会带着该单元格的坐标报告 UnsupportedCellCharacter(会收集每一个出问题的单元格,而不只是第一个);
  • Google 抓取的行为相同;
  • 而在一个 .tsv 文件中,这个字符本来就已经是行分隔符了。

这是这种格式的一项设计常量,而不是一个等待被填补的缺口——因此,一个需要长文本的领域,是借助这项约束、而不是绕开它来工作的,方法是一套完全位于插件范畴之内的三部分约定。

1. 选定一种转义写法,并写进你的解析器里。 惯用的选择是在工作表里写一个字面上的两字符 \n,进来的路上做反转义,出去的路上再重新转义:

public sealed class ProseCellParser : ICellValueParser, ICustomCellType
{
    public string TypeName => "Prose";
    public Type ValueType => typeof(string);
 
    public bool TryParse(CellParseContext ctx, string text, out object value)
    {
        value = text.Replace("\\n", "\n");     // sheet spelling → the value your game sees
        return true;
    }
 
    public bool TryRender(object value, out string text, out string reason)
    {
        reason = null;
        text = ((string)value).Replace("\r\n", "\n").Replace("\n", "\\n");   // the exact reverse
        return true;
    }
}

要让这两个方向互为精确的逆运算,并证明这一点。 TryRender 正是导出和推送写回时所使用的方法,因此如果它不能逐字符地撤销 TryParse 的效果,"工作表 → 导入 → 导出 → 工作表"这样一次往返,就会重写没有任何人编辑过的文本。在写出时把 \r\n 归一化为 \n(如上所示),正是防止一个 Windows 上写出的取值,在连续几次导出之间的拼写不断变来变去的原因。一个渲染出解析后的取值、并把它与原始单元格文本相比较的测试,就足以锁定这一点。

2. 给这个单元格一个真正的编辑器。 一个用 \n 转义过的取值,在一个单行输入框里输入起来很不舒服,而这正是 §4.13 存在的意义——为 "Prose" 注册一个 IStudioCellEditorProvider,返回一个多行的 TextFieldmultiline = true),以真正的换行符显示取值,并在提交时重新转义。使用 Commit 在失焦时提交(每次编辑会话一步撤销),而不是按每次按键提交。

3. 了解这套约定唯一触及不到的一个地方。 有人直接在 Google 表格中按下 Alt+Enter,会在这个实时单元格里创建一个真正的换行符,而这个单元格会在下一次抓取时被拒绝,并带有一个指向它的坐标。这个拒绝是诚实的、也是可以修复的,但它终究是一次拒绝——因此,如果你团队中的写手是直接在电子表格本身里创作长文本的,请在你自己的文档中说明长文本要用 \n 来写,或者让他们改在第 2 步所说的 Data Studio 单元格部件中创作,那里会替他们完成转义。

4.16 声明式创作界面(可选)

§4.9、§4.10 和 §4.13 返回的都是一个 VisualElement,这正是它们只能存在于编辑器中的原因:浏览器无法加载一个 UIToolkit 类型,因此以那种方式编写的扩展只存在于一块屏幕上,而不存在于另一块。

这个契约以数据的形式回答同样的需求。你只需要描述外壳——一个 id、一个标签键、一个放置位置、一种色调——并只以委托的形式提供判定条件效果。一次注册,就会同时被编辑器的 UIToolkit 渲染器和浏览器的 React 渲染器绘制出来。

using SheetForge.Core.Plugins;
using SheetForge.Core.Studio;
using SheetForge.Core.Theming;   // ThemeColorSlot — tones are slots, never hard-coded colours
 
public sealed class ExampleStudioUi : ISheetForgeStudioPlugin
{
    public void RegisterStudioUi(StudioUiRegistry ui)
    {
        // ① A verb — right-click a row, and this appears at the end of the menu.
        ui.AddAction(new StudioActionDescriptor(
            "skillsDemo.setBrake",                        // unique id ("pack.verb" reads well)
            ExampleLocStrings.BrakeActionKey,             // a Loc key (§4.14); unregistered = shown verbatim
            StudioActionPlacement.RowContextMenu,
            ctx => ctx.Tab == "ExampleActions" && !string.IsNullOrEmpty(ctx.RecordId),   // cheap predicate
            ctx => ctx.StageCell(ctx.Tab, ctx.RecordId, "brakeSeconds", "0.25")));
 
        // ② A summary panel — a node tree, rebuilt each recompute tick.
        ui.AddPanel(new StudioPanelDescriptor("skillsDemo.summary", ExampleLocStrings.PanelTitleKey, ctx =>
            StudioUiNode.List(
                StudioUiNode.Heading("Cast summary"),
                StudioUiNode.KeyValue("Total damage", TotalDamage(ctx).ToString()),
                StudioUiNode.Progress("Cast time", CastRatio(ctx), ThemeColorSlot.Accent),
                StudioUiNode.Button("Fill every unbraked reaction", "skillsDemo.fillBrakes"))));
 
        // ③ A column badge — one node beside a column header (null = nothing on that column).
        ui.AddColumnBadge(new StudioColumnBadgeDescriptor((ctx, tab, field) =>
            field == "brakeSeconds" ? StudioUiNode.Badge(UnbrakedCount(ctx) + " unbraked", ThemeColorSlot.Warning) : null));
 
        // ④ A cell-editor hint — pick a built-in widget for your type without writing one.
        ui.AddCellEditorHint(new StudioCellEditorHint("Modifier", StudioCellEditorArchetype.Dropdown, Options));
    }
}

这套词汇是刻意收窄的——它只会通过追加的方式增长,绝不会插入式增长,因此一份已有的注册永远不会改变含义。

  • 一个动作有五种放置位置InspectorRowContextMenuTopbarMenuColumnHeaderMenuCanvasNodeMenu
    • 每一个位置都会用它所知道的信息填充上下文——行位置携带记录,列位置携带列名,画布位置携带节点的记录——其余部分留空,因此在读取一个某个位置未必提供的字段之前,要先做好防护。
  • 面板或徽标共有十三种节点类型RowLabelChipBadgeButtonRuleHeadingKeyValueTableListProgressInputLink
    • 它们都通过静态工厂方法构建(StudioUiNode.Label(…).WithTooltip(…)),因此一个节点是不可变的,只会设置对它这种类型有意义的那些字段。
  • 七种单元格编辑器原型Dropdown(你提供候选项)、MultilineTextSlider(你提供范围)、Toggle(你提供两段规范文本)、ColorPicker#RRGGBB / #RRGGBBAA)、CurveEditorGradientEditor(单元格文本采用表格语法中那套规范的曲线/渐变记法——一个自有类型写出这种记法的插件包,例如通过 CurveValue.Render(),就可以声明它们)。内置的 ColorAnimationCurveGradient 类型也是通过这同一套机制接入的——BuiltinCellEditorHints 持有它们的三个 hint——宿主环境会优先查询插件包自己的注册,因此在这些类型名下注册一个 hint 会覆盖内置选择,而不会被拒绝。在编辑器中,最后三种原型分别是 Unity 的颜色、曲线与渐变字段;在浏览器中,它们是这个应用自己的编辑器;携带这三者之一的类型所构成的 List<>,在两个宿主环境中都会变成一个纸片编辑器。Plugin Demo 的 Falloff 类型正是这样做的:它的解析器用 CurveValue.TryParse 读取单元格,仅凭一次 hint 注册,就让它在 Unity 中获得一个曲线字段、在浏览器中获得曲线编辑器。
  • 任何地方都没有布局数值。 像素和比例会把一块屏幕的颗粒度泄漏进另一块;你只说明要展示什么,具体如何摆放由各自的渲染器决定。

写之前值得了解的几条规则:

  • 变更要走与你自己动手时相同的那扇门。 StudioSurfaceContext 只给一个动作四种能力——StageCellStageCells(多个单元格、一步撤销、要么全部生效要么全不生效)、FocusRecordRequestRebuild——再加上只读的 Tables / References / CodeRegistries
    • 因此一个插件的动作只是一次普通的暂存编辑:一步 Ctrl+Z,在你推送之前什么都不会到达工作表,走的是同一套预检。
    • 暂存关卡同样适用——只读来源、正在运行的管线,或者以工作簿为来源的标签页,都会阻止它并显示原因。
  • 判定条件会持续不断地运行。 AppliesTo、面板构建和徽标提供,会在每一次手势和每一次重新计算节拍上运行。请读取你被交予的那份快照;不要做 IO、网络请求,也不要做耗时计算。
  • 显示出来不等于已经执行。 宿主会在真正调用时重新检查这个判定条件。如果自菜单绘制以来情况发生了变化,得到的会是一次如实的空操作加上一次重绘,而不是第二次失败。浏览器对一个过期的 id 也采取同样的处理方式。
  • ConfirmKey 会先询问。 给一个动作配上一个确认键,宿主就会在执行之前展示那句话——这对于一次会暂存许多单元格的动作而言正合适。
  • Link 节点只会打开 http/https 这条规则是一个 Core 判定函数(StudioUiNode.IsAllowedUrl),两个宿主都会调用它,因此它们对"打开什么是安全的"这件事永远不会产生分歧;浏览器随后还会在渲染一个锚点之前再检查一次同样的形态,这只能让限制更严格,绝不会更宽松。
    • URL 会按你写的原样存储,并在真正要打开的那一端被拒绝、附带原因,而不是在注册时就被清洗掉——这样写下这个 URL 的那个包,才能查明为什么什么都没发生。
  • 面板不持有任何状态。 它们每一个节拍都会被重建;一个取值唯一该存在的地方是工作表(暂存中)。如果没有任何东西注册面板,这块面板区域根本不会被绘制。
  • 异常会被隔离——一次抛出会变成一条英文的控制台警告,并移除那一项能力,而不会影响整个窗口。

当描述性词汇不够用时——IStudioPanelProvider(Editor 程序集)

任意渲染、复合输入和多步骤流程在这里没有对应的词汇,而发明一套词汇就意味着要永远维护一个微型 UI 框架。因此这里的上限是刻意设定的,而逃生舱则开得很宽:在你的编辑器配套程序集中实现 IStudioPanelProvider,想画什么就画什么。

using SheetForge.Editor.Studio;
using UnityEngine.UIElements;
 
public sealed class ExampleStudioPanel : IStudioPanelProvider
{
    public string Id => "skillsDemo.summary";        // same id as the descriptive panel above
    public string TitleKey => ExampleLocStrings.PanelTitleKey;
    public bool AppliesTo(StudioSurfaceContext context) => context.Tab == "ExampleSkills";
    public VisualElement CreatePanel(StudioSurfaceContext context) => new Label("…anything…");
}

同一个 Id 把两者都注册进去,每个宿主就会各取自己能绘制的那一份:编辑器使用富面板,浏览器使用描述性面板。这正是"浏览器能做到多少就是多少,编辑器里则一路做到底"得以成立、而无需第二套契约的方式。

不存在仅限 Web 的变体——缺少一个富面板,意味着描述性面板会被绘制,而不是这块面板整个消失。这个元素只存活一个重新计算节拍,因此它同样不持有任何状态。

4.17 观察管线(可选)

一座跨产品的桥梁、一份领域遥测,或者一个后续生成器,往往需要在不重新解析的情况下知道一次导入产出了什么。实现 IPipelineObserver,并通过 ISheetForgePipelinePlugin 注册它:

using SheetForge.Core.Model;
using SheetForge.Core.Plugins;
 
public sealed class ExampleImportObserver : IPipelineObserver, ISheetForgePipelinePlugin
{
    public void RegisterPipelineObservers(PipelineObserverRegistry observers) => observers.Register(this);
 
    public void OnImportCompleted(PipelineRunView view)
    {
        // view = Success · Tables · Diagnostics · SkippedTabs · EnumTabs — an immutable snapshot.
        if (!view.Success) return;
        // … cache what you need; do not hold the tables ...
    }
}
  • 观察无法改变结果。 你收到的是一份不可变的快照,且不需要返回任何内容。这里刻意没有提供修改取值或新增诊断信息的钩子:解读一个取值是单元格类型的职责(§2),报告一次规则违反是领域验证器的职责(§3)。如果把参与式的能力混进一个观察契约里,"观察者无法改变结果"这句话在实践中就会变成一句空话。
  • 每一次显式的导入周期只会触发一次,在其结束时——无论成功还是失败。它不会在你暂存期间不断重新计算的预检投影上运行——没有任何第三方代码会挂在按键频率上。
  • 一次失败的运行依然会报告它解析出的内容。 Tables 携带的是验证失败之前已经解析成功的那些标签页——与隔离流程所使用的是同一份素材(Data Studio)——因此一个观察者看到的是一次失败运行的真实画面,而不是什么都看不到。
  • 两处如实存在的空白。 观察者是从导入周期自身的完成点触发的,因此一次从未到达那个点的运行根本不会触发它。
    • 一次在管线运行之前就中止的导入(没有活动设置、Addressables 关卡拒绝)。
    • 代码生成→编译这一段被一次编译错误打断。
    • 这是零触发,而绝不是错误触发:如果你需要知道"曾经尝试过一次导入",请把这个契约和编辑器侧的 ImportEvents 事件总线搭配使用。
  • 一次抛出会被隔离在那一个观察者身上,原因会被收集起来;导入本身的输出不会有分毫改变。
  • 未来的观察节点(解析刚完成之后、一次导出周期)将会以同级 capability 接口的形式到来,通过对已注册的观察者做类型转换来发现,因此新增一个不会破坏今天写好的实现。

5. 自定义导入来源(ISheetSourceProvider

一个新来源(数据库、REST 端点、内部专有格式)可以以零 Core/Editor 改动的方式加入。在一个 Editor 程序集中实现 ISheetSourceProviderSourceProviderRegistry 会通过 TypeCache 发现它,它就会和内置来源一起出现在设置的"Source"下拉菜单中。提供方需要回答的四件事:

  1. 获取——CreateTabSource(settings) 返回一个 ITabSource,提供"标签页名称 → 原始 TSV 文本"(异步;环境问题是诊断信息,不是异常;允许部分输出)。
  2. 写回——CreateReflectTarget(dispatcher, settings) 返回一个接入创作调度器的 ISourceReflectTarget(使用调度器公开的 Session / Callbacks / Baselines 来组装你的目标)。只有当你的来源可写时才返回一个目标。
  3. 可见性——GetVisibility(settings) 返回面板应该为你显示哪些设置字段。
  4. CanAuthor——对只读来源返回 false;创作窗口会如实禁用创作 UI(与 Google ExportUrl 相同)。

稳定的字符串 Id 会持久化保存在 sourceProviderId 中。内置来源以 "LocalFile" / "GoogleSheet" 作为各自的 Id;空的 sourceProviderId 会解析为内置的 LocalFile 默认值。空的 Id 会让该提供方不出现在 UI 中(对测试探针很有用)。

提供方被特意放在 Editor 程序集中——来源是 IO 边界,把 IO 排除在 Core 之外能保持它的纯粹性(另外三种契约都是纯 Core)。

5.5 用于自动化与集成的公共工具

除了这些注册契约之外,还存在五个公共入口点,供驱动 SheetForge 而不是扩展它的代码使用——一个 CI 脚本、一个构建钩子、你自己的检查器按钮,或者一个从同一批工作表烘焙出自己资源的第二产品。

运行一次完整流程——SheetForge.Editor.Pipeline.SheetForgeActions

SheetForgeActions.RunImport();        // exactly what the toolbar's "Pull from source" does
SheetForgeActions.RunExport();
SheetForgeActions.RunPush();
SheetForgeActions.RunHealthCheck();

每一次调用都是完整的一套流程:设置解析、Addressables 关卡、互斥控制、确认与批准弹窗、进度条,以及跨越域重载的代码生成→编译→烘焙续接。没有半套流程可以拼装,因此也就不存在会被意外跳过的关卡。

有两点需要了解:

  • RunImportRunPush即发即忘的——它们的方法体是 async void,因为编辑器主线程不能阻塞等待网络 IO。所以它们的返回并不代表完成;请订阅 ImportEvents.ImportCompleted 来获知完成状态。
  • Push 依然会展示它的批准弹窗,因此一个无人值守的脚本无法在没有人的情况下发送内容。

获取与内置实现相同的那把锁——用于向自己后端写入的自定义来源提供方:

if (!SheetForgeActions.TryBeginExclusiveScope(out IDisposable scope)) return;   // something is running
using (scope) { /* write to your source */ }        // Dispose releases; a second Dispose is harmless
// schedule any re-import AFTER the scope closes — the lock is not re-entrant

SheetForgeActions.IsBusy 能回答同样的问题,而不需要真正占用任何东西。这把锁本身刻意保持 internal:如果它是公共的,调用它的 End() 就有可能释放别人的运行——而这个 scope 让这种情况变得不可能,因为只有持有者才能释放它。

以内置实现同样的方式结束一次写回——AuthoringDispatcher.FinalizeReflectSuccess(writtenTabs) 运行的是一个 ISourceReflectTarget 必须抵达的终点:

  • 为它所写入的标签页做保留性清理,
  • ClearUndo 确认边界,
  • 以及自动重新导入。

内置的本地路径和 Google 路径运行的是同一段逻辑,因此你的提供方能以完全相同的方式结束,而不只是近似地模拟它。旁边的 BuildProjectedTabs() 会把投影以每个标签页一份 TSV 的形式交给你——即你即将发送的内容——因此一个提供方可以在不写入的情况下预览或转换它。传入空的 writtenTabs 列表是一次空操作,暂存内容会保持不变。

在你自己的 UI 中展示产品自己的句子——ImportReportText.Render(report)(Core.Tooling)会把人类可读的报告以字符串形式返回,不会向控制台写入任何内容;SheetForgeActions.RenderReportText(report) 是同样的东西,只是使用了用户当前的编辑器语言。请把它与 AuthoringDispatchCallbacks.RenderReport 搭配使用,这样第二个创作界面就能用产品自己使用的确切措辞来报告失败。

在不知道生成类型的情况下枚举一个已烘焙的标签页——DefinitionDatabase.RecordsUntyped

foreach (DefinitionDatabase db in myBakedDatabases)
    foreach (object record in db.RecordsUntyped)     // reflect on the fields you care about
        ;

这是第二烘焙工具(另一个把同一批工作表转换成自己资源的产品)的官方认可路径。请不要反射读取私有的 records 字段:这样做会把一个字段名变成一份未声明的契约,一旦代码生成把它改名,就会静默地崩溃。这份列表是只读的——工作表才具有权威性。对于在这个成员存在之前生成的代码,它默认为空;重新导入一次就会生成对应的覆盖实现。

安全地扩展生成的类——两个生成的类都是 partial 的,因此一个派生成员(一个计算属性、一个接口实现、一个运算符)可以存在于它们旁边你自己的文件中,并挺过每一次重新导入。不要在那里添加任何序列化字段:烘焙出的 ScriptableObject 每次导入都会从工作表重新构建,因此任何只存在于你那部分里的序列化字段,都会回到其默认值。如果一个取值属于数据,它就应该属于某一列。

哪些东西依然是关闭的——这是刻意的

以上这些界面就是官方认可的外部边界。以下内容无论开放起来看似多方便,都依然会保持 internal,因为它们每一个都是信任或完整性边界,而不是便利性边界:

  • 凭据与签名——服务账号密钥定位器、JWT/PEM/PKCS8 原语,以及 Google 访问令牌提供方。开放它们会把一个作用域覆盖你的表格的持有者令牌交给任何一个插件。
  • 原始的推送链路(推送执行器、工作表网关、单元格写入)——批准(IPushApprover)是在这套编排内部被强制执行的;一个公共的原始写入器将会是一次没有批准步骤的工作表写入。
  • 发送前校验与反映写入引擎——外部代码只能通过 AuthoringDispatcher.Reflect() 进入,它会在过程中通过过期锚点检查、预检验证与批准;下层的写入引擎本身不是一个契约。
  • 烘焙/代码生成的完整性链路(架构指纹、生成源代码写入器、孤立清理)以及构建新鲜度关卡——开放它们会让伪造或绕过烘焙状态变成一行代码就能做到的事。
  • 临时 SO 叠加层——"工作表是真实来源"这条规则只有一个官方认可的例外(面板中的测试编辑开关),而它被刻意地不作为 API 提供。

如果某个工作流看起来需要用到这些东西之一,它需要的是一个功能请求,而不是反射。

6. 生成代码的位置与命名空间

  • generatedCodeFolder 可以是任意文件夹(配套 asmdef 的自我修复机制会自动接通插件类型引用),但把它放在你自己的包内(例如 Assets/MyDomain/Runtime/Generated)是最整洁的做法——这样生成的类型就会与你的 enum/自定义类型编译进同一个程序集,无需配套 asmdef。
  • 按标签页归位:如果某个标签页生成的类型已经存在于某处,就会原地重新生成——即便设置指向别处,你的包中已提交的 Generated 文件夹仍然保持权威。过期的重复文件会被自动清理(会记录日志,绝不会静默处理)。
  • generatedNamespace 用于隔离你生成的类型(例如 MyGame.Data)。类型发现依据的是生成类型自带的 SchemaFingerprint 标记,而不是命名空间,因此任何命名空间都可以正常工作。更改该值会自动触发重新生成。
  • 是否提交你的包的 Generated 文件夹,由你的包自行决定。示例会提交它的生成代码(默认命名空间 SheetForge.Generated 下的 Example* 类,标签页为 ExampleSkills/ExampleEffects/ExampleActions),这样全新克隆下来就能立即编译——真正防止它们与你项目里 Skills/Effects 标签页冲突的是 Example* 这个类名前缀,而不是一个独立的命名空间。

7. 在运行时消费——"组装,而不是脚本化"

你的运行时会读取生成的数据库,并根据 type 这个 enum 分发到代码原子:

using SheetForge.Runtime;
using SheetForge.Generated;
 
var hSkills  = SheetForgeDatabases.LoadAsync<ExampleSkillsDatabase>("ExampleSkills");
var hActions = SheetForgeDatabases.LoadAsync<ExampleActionsDatabase>("ExampleActions");
var hEffects = SheetForgeDatabases.LoadAsync<ExampleEffectsDatabase>("ExampleEffects");
var runner = new SkillRunner(await hSkills.Task, await hActions.Task, await hEffects.Task);
// keep the handles for the system's lifetime; Release each on shutdown

对于真正的一次性过程式逻辑,可以通过 AssetRef 引用一个脚本资源——SheetForge 会验证该引用并烘焙其可寻址地址(就像对待一张图片一样);执行它则是游戏自己的职责。

8. 从其他资产中检测 SheetForge

一个不同的资产——即与 SheetForge 集成而非扩展它的资产(比如一个属性系统)——可以检测到 SheetForge 是否已安装。由于付费的 Asset Store 产品是一个文件夹产品(没有 package.json / UPM),它无法发布 versionDefines 条目;因此 SheetForge 的 Editor 程序集会在每个构建目标上自我注册一个 SHEETFORGE 脚本编译宏。

(a) 编译期(推荐):

  • 如果你的集成代码位于自己独立的程序集定义中,在该 asmdef 的 Define Constraints 中添加 SHEETFORGE——这样该程序集只有在 SheetForge 存在时才会编译。
  • 如果涉及 SheetForge 的代码与必须无条件编译的代码共享同一个程序集,就只用 #if SHEETFORGE … #endif 保护这部分代码。

(b) Editor 期(备选方案): 当你无法依赖编译顺序时,可以通过反射进行探测——例如 System.Type.GetType("SheetForge.Editor.Pipeline.ImportEvents, SheetForge.Editor") != null——然后(例如)动态接通导入完成事件总线。

SHEETFORGE 的含义是*"SheetForge 已安装"*。它与 SHEETFORGE_ADDRESSABLES 是两回事——后者是 SheetForge 自身程序集上的一个内部版本宏,只用于标记 Addressables 包是否存在——不要把后者当作安装探测手段来使用。

如果之后卸载了 SheetForge,该宏仍会残留(没有监视器会自动取消它);需要在 Project Settings ▸ Player 中手动移除。参见功能与限制

还有哪些事情仍然需要修改 Core

以上所有内容都能以零 Core 改动的方式加入。插件在不修改 Core 的情况下仍然做不到的事情:

  • 标记值输出进生成代码中——自定义标记是验证/展示用的元数据;把它们烘焙进代码生成的常量或特性中,在有消费方真正需要之前都不在范围内。
  • 在没有被告知方法的情况下,让键重命名传播深入到一个自定义记法内部——一条被重命名的记录,在 RecordId@Tab 单元格、这样的列表,以及包装类型元素中,会由 Core 自行重写。对于你自己的语法,实现 IReferencingCellType§4.4a)就会让它在保留载荷的情况下被重写;那是一次可选启用,而不是对 Core 的改动。不选择启用它,这条边界就依然成立:你的领域验证器会报告这个悬空的键,而不是让重命名操作静默地修复它。
  • 给一个由插件注册的 C# enum,从工作表中新增成员——一个用 enums.Register<T>() 注册的 enum 归代码所有,因此一张枚举定义表无法扩展它,Data Studio 也不会提供这一行。如果这个枚举应该归工作表所有,就把它迁移进一张枚举定义表(见表格语法)。

<> 包装类型ICellWrapperType,见 §2)与自定义结构标记IStructuralMarkerDefinition,见 §4.5)二者都能在不修改 Core 的情况下扩展管线。

相关页面

  • 表格语法——已注册的类型在工作表中如何呈现
  • Data Studio——画布覆盖、代码注册表、部件和操作会出现在哪里
  • API 参考——每个契约的完整签名
  • 创作内核——边与引擎表面
  • 功能与限制——插件扩展的边界(包装类型拒绝规则、标记限制)与保留的接口缝隙