Перейти к содержимому
SheetForge

Создание плагинов — добавление домена без единой правки Core

Домен (навыки, предметы, квесты, …) подключается к SheetForge как отдельный пакет, который ссылается на SheetForge.Core — Core никогда не ссылается на него в ответ.

Плагин может добавлять enum, пользовательские типы ячеек, wrapper-типы, доменные валидаторы, рёбра графа, структурные маркеры, шаблоны «Create sheet», целые источники импорта, переопределения холста Data Studio, реестры кода, декларативные поверхности авторинга, виджеты и действия, цветовые пресеты, пользовательские виджеты ячеек, наблюдателей конвейера и собственные локализованные строки интерфейса — шестнадцать контрактов ниже.

«Добавление домена = ноль изменённых строк в Core» обеспечивается компилятором. Тестовая сборка без InternalsVisibleTo (SheetForge.Tests.Consumer) реализует пятнадцать из шестнадцати — и интерфейсы возможностей рядом с ними — используя только публичную поверхность. Если бы хоть один из них сузили до internal, сборка сломалась бы (CS0122). Шестнадцатый — лазейка редактора для богатой панели — возвращает VisualElement, поэтому вместо этого его задействует тест на стороне редактора.

Одиннадцать контрактов Core — это чистый C#. Именно это позволяет одной скомпилированной DLL плагина зажигать те же слоты и в редакторе Unity, и в браузере (SheetForge Web) — сборка и изоляция являются одной общей функцией Core, и только обнаружение различается по хосту (TypeCache Unity, сканирование загруженной сборки в браузере).

Пять контрактов Editor возвращают элементы UIToolkit или затрагивают состояние окна, поэтому существуют только в редакторе.

Все шестнадцать обнаруживаются автоматически — конструктор без параметров это всё, что требуется, без ссылки на сборку, вызова регистрации или манифеста для правки:

КонтрактРегистрируетОпционально?
ISheetForgePluginEnum + парсеры пользовательских типов ячеекБазовый контракт
ISheetForgeValidatorPluginПравила доменной проверки (межстолбцовые / межвкладочные)Опциональное дополнение
ISheetForgeEdgePluginОбъявления рёбер графа, которые не видит сканер ядраОпциональное дополнение
ISheetForgeMarkerPluginПользовательские структурные маркеры (поколоночные строки @marker)Опциональное дополнение
ISheetForgeTemplatePluginШаблоны «Create sheet» (вкладки + примерные данные)Опциональное дополнение
ISheetForgeGraphPluginПереопределения холста для вкладки в Data StudioОпциональное дополнение
ISheetForgeCodeRegistryPluginПространства ключей только для чтения, которые живут в коде, как заблокированные виртуальные вкладкиОпциональное дополнение
ISheetForgeThemePluginЦветовые пресеты для окон SheetForge (тёмный и светлый)Опциональное дополнение
ISheetForgeStudioPluginДекларативные поверхности авторинга — действия, панели, значки столбцов, подсказки редактора ячеекОпциональное дополнение
ISheetForgeStringsPluginСтроки интерфейса вашего пакета, для каждого языка (оверлей, к которому обращаются раньше таблиц продукта)Опциональное дополнение
ISheetForgePipelinePluginНаблюдатели конвейера — уведомление только для чтения о том, что произвёл импортОпциональное дополнение
ISheetSourceProviderЦелый источник импорта (БД / REST / внутренний)Независимо (сборка Editor)
IStudioGraphWidgetДоменный виджет над холстом Data StudioНезависимо (сборка Editor)
IStudioInspectorActionДополнительная кнопка в инспекторе узла Data StudioНезависимо (сборка Editor)
IStudioCellEditorProviderПользовательский виджет ввода для одного типа ячейки в таблице Data StudioНезависимо (сборка Editor)
IStudioPanelProviderПроизвольная панель UIToolkit в Studio — лазейка рядом с декларативнойНезависимо (сборка Editor)

Эталонный пример — это выборочный импорт. Полный рабочий пример (SheetForge.PluginDemo) поставляется как пакет Unity в Assets/SheetForge/Examples/SheetForgePluginDemo.unitypackage — дважды щёлкните по нему, либо нажмите Импортировать Plugin Demo в окне «Начало работы» (Tools ▸ SheetForge ▸ Getting Started, единственное место, где живут импорты демо), чтобы восстановить его в Assets/SheetForge.PluginDemo/….

Пока вы его не импортировали, его вообще нет в вашем проекте — этот пример поставляется только в виде этого пакета — поэтому его сборки/типы/вкладки/адреса никогда не конфликтуют с вашим. Пути, упомянутые ниже (Assets/SheetForge.PluginDemo/ModifierCellParser.cs и т. д.), появляются после того, как вы импортировали пакет.

(Второй, не требующий плагина пример — SheetForge.CoreDemo — демонстрирует конвейер, используя только встроенные типы core.)

Дополнения расширяют базовый интерфейс, не изменяя его — плагин, которому не нужны проверка или рёбра, никак не затрагивается их существованием.

Семь дополнительных интерфейсов — это возможности, а не контракты:

  • Они не обнаруживаются сами по себе.
  • Их дополнительно реализует что-то, что уже зарегистрировано.
  • Core находит их приведением типа этого зарегистрированного объекта.

Шесть приводятся от зарегистрированного поставщика рёбер или переопределения холста — см. §4.12 о правиле обнаружения и каждой из них. Седьмая, IReferencingCellType, приводится от зарегистрированного парсера ячейки. Она даёт вашей собственной нотации ту же обработку ссылок, что получает RecordId@Tab — см. §4.4a. Игнорирование любой из них ничего не меняет.

1. Настройка пакета

Создайте папку с собственным .asmdef, ссылающимся на SheetForge.Core (плюс SheetForge.Runtime, если вам нужен поиск во время выполнения). Это всё. PluginRegistry редактора обнаруживает вашу реализацию ISheetForgePlugin через TypeCache и вызывает ваши методы регистрации, и регистрация — это ваш явный код, а не сканирование сборки.

Держите реализации контрактов 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, а не каталог. Реестр маркета рекламирует те же два значения (pluginFormat, minHost), чтобы листинг можно было отфильтровать до загрузки, но шлюз читает атрибут из проверенных байтов — листинг может ошибаться, скомпилированное заявление — нет.
  • Отказ — это диагностика PluginIncompatible, называющая то, что заявила сборка, и то, что читает этот хост, а не молчаливое исчезновение. Это заявление о совместимости, а не подпись: целостность — задача канала распространения (см. Веб-магазин плагинов).
  • Номер поколения меняется, только если сам формат плагина заменяется. Чисто аддитивный рост — новый контракт, новый член реестра — никогда его не сдвигает, потому что ваш существующий плагин продолжает работать без перекомпиляции.

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 (строка → значение). Чтобы завершить строго типизированное запекание и round-trip экспорта/отправки, реализуйте также 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;
    }
}

(См. Assets/SheetForge.PluginDemo/ModifierCellParser.cs для полной, готовой к использованию версии с проверкой op-токена и предложениями ближайшего совпадения.)

@target для пользовательских типов работает благодаря одной лишь регистрации: объявите столбец как Modifier@Stats, и ваш парсер прочитает context.Type.TargetName ("Stats").

Проверка целостности этой цели (существует ли вкладка? разрешается ли id?) — задача доменного валидатора, то же разделение труда, что и у RecordId@Tab. Незарегистрированное имя типа с @ по-прежнему является ошибкой с подсказкой, поэтому защита от опечаток сохраняется.

Имена типов, которыми уже владеет Core. Встроенные имена скаляров — int, float, bool, string, Enum, RecordId, IntId, AssetRef, Color, AnimationCurve и Gradient — регистрируются раньше любого плагина. Парсер, который повторно использует одно из них, не проходит регистрацию с PluginRegistrationConflict — встроенный тип остаётся, вызов RegisterCellParsers останавливается на конфликтующем парсере, а остальные слоты плагина всё равно загружаются, — поэтому пакет, поставлявший собственный тип Color или Gradient, должен переименовать его (см. заметки об обновлении в журнале изменений). Если ваш тип хранит цвет, кривую или градиент, вам не нужно заново реализовывать нотацию: модели значений Core ColorValue, CurveValue и GradientValue предоставляют TryParse(text, out value, out error) и Render(), CurveEvaluator / GradientEvaluator вычисляют их значения точно так же, как это делает Unity, а StudioCellEditorHint с архетипом ColorPicker, CurveEditor или GradientEditor (§4.16) открывает нативный редактор для вашего типа в обоих хостах.

Wrapper-тип от начала до конца (MyWrapper<T>)

Wrapper — это обобщённая форма значения — Pair<int> = 1~2 — упаковывающая несколько внутренних значений T в одну ячейку. Вы владеете только внешним синтаксисом (разделитель, арность); Core разбирает внутренний T рекурсивно, поэтому Pair<RecordId@Effects>, Pair<Enum<DamageType>> и вложенный Box<Pair<int>> — всё это просто работает, а ссылки внутри полностью проверяются.

Реализуйте ICellWrapperType и зарегистрируйте его в том же хуке RegisterCellParsers через parsers.RegisterWrapper(...):

// 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;).
  • Запекание.
  • Round-trip экспорта/отправки.
  • Сквозной проход ссылок — RecordId@Tab внутри wrapper проверяется на целостность, распространяется при переименовании ключа и переписывается при переименовании вкладки.

Правила отклонения и оговорка о разделителе ; описаны в разделе Синтаксис таблиц.

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'."));
        }
    }
}

Зарегистрированные валидаторы автоматически подключаются и к проверке при импорте, и к предварительной проверке авторинга. Правила:

  • Сообщайте о нарушениях в ctx.Errors как ImportErrorCode.DomainRuleViolation — никогда не бросайте исключение. Брошенное исключение изолируется и поднимается наверх; остальные валидаторы всё равно продолжают выполняться.
  • Заполняйте все четыре элемента — где (CellCoordinate), что (ActualValue), почему (Expected), как (Suggestion). «Как» отображается дословно в качестве предложения с конкретными действиями.
  • ctx предоставляет вам:
    • все разобранные таблицы (Tables),
    • индексы ключей (KeyIndices),
    • ключи ассетов (AssetKeysnull означает, что проверка ассетов была пропущена).
  • Принципы «собирать всё» и «никакой частичной сборки» наследуются автоматически.

4. Поставщики рёбер (опционально)

Если вы строите инструментарий поверх графа данных (или хотите, чтобы будущий холст графа видел связи вашего домена), объявляйте рёбра, которые не видит сканер ссылок ядра — например, характеристику, на которую ссылаются внутри значения мини-грамматики:

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

IEdgeContributor получает доступный только для чтения межвкладочный контекст и добавляет элементы EdgeSpec (вкладка/id записи источника и цели, необязательное поле, запись полезной нагрузки, метка). Поставщики никогда не выдают диагностику — рёбра являются материалом для проекции, а не проверкой. См. Ядро авторинга.

4.4 Рецепт: пользовательский тип, несущий ключ внутри себя

RecordId@Tab — единственная форма ссылки, которую понимает Core, и она бесплатно получает проверку целостности, рёбра графа, предложения ближайшего совпадения и распространение переименования.

В тот момент, когда ваша собственная нотация поглощает ключ — attack:add:10, stat.hp>50, fire@0.4 — Core видит одну непрозрачную строку, поэтому эти четыре сервиса останавливаются у вашего порога. Три регистрации возвращают три из них обратно. Пишите их как набор; мини-синтаксис лишь с одним из трёх — это та форма, что порождает «импортируется нормально, но ничто никуда не указывает».

ЧастьКонтрактЧто восстанавливаетБез неё
1. ЦелостностьIDomainValidator (§3)Ключ внутри вашей нотации, которого не существует, сообщается с координатой и предложением конкретных действийОпечатка импортируется без ошибок и падает во время выполнения
2. ВидимостьIEdgeContributor (§4)Зарытая связь становится настоящим ребром: холст рисует её, список Used by её считает, индекс ссылок её индексируетСвязь существует в данных, но нигде на экране
3. «Как»TextSuggestion.FindNearest внутри части 1«Unknown stat 'atack'. Did you mean '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."));
            }
    }
}

Используйте один разделитель для нотации — парсер, валидатор и поставщик рёбер должны сходиться в том, где начинается и заканчивается ключ, а три приватные копии этого разбиения — это именно то, как они расходятся друг с другом. (Wrapper-тип, §2, получает это бесплатно: TrySplit и есть общий разделитель.)

Четвёртому сервису — распространению переименования — нужна ещё одна вещь, и есть два способа её получить. Переименование записи переписывает ссылающиеся ячейки только там, где Core может найти ключ в тексте. Он может сделать это для поля RecordId@Tab, списка таких полей и wrapper'а, чей 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 парсеров, точно так же, как возможности холста приводятся от зарегистрированных поставщиков рёбер (§4.12). Пользовательский тип, который его не реализует, ведёт себя точно так же, как и раньше, бит в бит.

Пять хуков

Все пять хуков работают с одним элементом: вся ячейка для скалярного столбца, либо один элемент, разделённый ;, для List<MyType@Tab> — та же единица, которую получает ваш ICellValueParser.TryParse.

ХукОтвечает наИспользуется для
bool TryGetTokenKey(elementText, out key)«На что указывает этот элемент?»Членство — уже ли эта ячейка связана с этой записью
string MakeToken(key)«Записать новую связь с этим ключом»Пустая ячейка, либо добавление в список. Заполните полезную нагрузку нейтральной отправной точкой; поверхность авторинга не должна изобретать значения. Верните null/пусто, и жест будет отключён с указанием причины, а не подделан
bool TryRetargetToken(elementText, newKey, out newText)«Указать этим на что-то другое»Выбор другой записи в ячейке , и перенацеливание линии на холсте. Меняйте только цель — удаление и повторное создание токена сбросило бы числа, которые набрал человек
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 с координатой и «did you mean …», разделяя бюджет подсказок на поле со встроенными ссылками.
  • Распространение переименования с сохранённой полезной нагрузкой — переименование attack в power переписывает attack:add:10 в power:add:10; оператор и число принадлежат автору и сохраняются.
  • Граф — связь становится настоящим ребром с координатами: она отрисовывается, узел получает порт, список Used by её считает, а индекс ссылок содержит её в обе стороны.
  • Пикер — ячейка получает тот же выпадающий список с поиском, что и ячейка RecordId@Tab, а выбор другой записи заменяет цель и сохраняет остаток. Без реестра пикер отказывается, вместо того чтобы вставить голый ключ поверх вашего значения.
  • Обнаружение сирот и правило экспортируемого выпадающего списка — строка, чья единственная исходящая связь живёт внутри вашей нотации, больше не считается несвязанной, а скалярный столбец вашего типа получает выпадающий список проверки данных по ключам целевой вкладки (Источники, экспорт и отправка).

Самое простое применение — тип-псевдоним. Если значение — это просто ключ, а текст ячейки и есть этот ключ:

  • TryGetTokenKey обрезает пробелы.
  • MakeToken возвращает ключ.
  • TryRetargetToken возвращает новый ключ.
  • TryRemoveToken возвращает пусто.

Столбец функционально полностью эквивалентен RecordId@Tab, и единственное, что остаётся на ваше усмотрение, — это представление: он появляется под собственным именем в @type, и вы можете прикрепить к этому столбцу отдельно виджет ячейки (§4.13) или форму холста (§4.7). Для псевдонима отдельный контракт не нужен.

Два ограничения, оба структурные:

  • Никакого ; в полезной нагрузке. Core разбивает ячейку списка на элементы до того, как ваш парсер или любой из этих хуков увидит текст, поэтому точка с запятой внутри значения была бы раздроблена на два элемента. (Wrapper-типы несут то же ограничение по той же причине.)
  • @target должен называть настоящую вкладку таблицы, точно так же, как это делает RecordId@Tab, — виртуальная вкладка реестра кода отклоняется с UnknownTargetTab. Именно это ограничение позволяет отчётности о неразрешённых ссылках, предложениям ближайшего совпадения и распространению переименования оставаться собственными, неизменёнными механизмами Core.

Ни один из пяти хуков не может бросать исключение: отвечайте false или null на всё, что не можете интерпретировать, и сохраняйте остаток при каждой перезаписи.

Это работает и против целочисленного пространства ключей. Если вкладка, которую называет ваш @target, использует ключ IntId, а не RecordId, в вашем коде ничего не меняется — ключ, который принимают и возвращают ваши хуки, это просто целое число, записанное как текст. То, с каким пространством ключей сравнивать, решает сама идентичность целевой вкладки, а не ваш тип.

  • Проверка, предложения ближайшего совпадения, распространение переименования, рёбра, пикер и обнаружение сирот — всё это включается точно так же.
  • Одна любезность, которую Core добавляет здесь за вас: поскольку целое число можно записать несколькими способами, переименование передаёт TryRewriteKeys написание таким, каким оно есть в этом элементе, наряду с каноническим (007 и 7 оба отображаются в 12), так что порядковый поиск внутри вашего типа не пропустит значение с ведущими нулями.
  • Встроенное демо не включает ссылающийся пользовательский тип, нацеленный на вкладку IntId — её пример Modifier целится в вкладку со строковым ключом, — так что у этого пути есть тесты, но нет готового примера для копирования.

4.5 Пользовательские структурные маркеры (опционально)

Встроенные маркеры — это @name, @type, @desc, и три необязательных:

  • @overlap.
  • @style, который описывает саму таблицу — её метку группы и цвет, — а не её столбцы.
  • @enum, который помечает таблицу как набор определений 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).
  • Маркеры предназначены для метаданных на уровне столбца, а не для новых форм данных — маркер отвечает за проверку своей ячейки, но не всей строки. Строки пользовательских маркеров сохраняются дословно при экспорте/round-trip и перемещаются вместе со своим столбцом при любой правке структуры (добавление / удаление / перемещение / переименование).
  • Генерация кода не запекает значения маркеров (как и @overlap, это лишь метаданные для проверки/отображения, невидимые для отпечатка схемы).

4.6 Шаблоны «Create sheet» (опционально)

Процесс Create sheet поставляется с двумя встроенными шаблонами — таблицей предметов, использующей только типы core, и листом определений @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 строк. Поскольку ваши доменные типы уже зарегистрированы (плагин загружен), созданные таблицы сразу же успешно проходят повторный импорт.
  • Отображаемые строки — ваши. Плагин владеет собственным текстом (пакет примера находится вне защиты доменных слов) — вы не ограничены ключами Loc из Core.
  • Многовкладочные шаблоны создают все свои вкладки и выполняют повторный импорт один раз, так что межвкладочные ссылки разрешаются вместе. Панель создания скрывает поле имени вкладки для таких шаблонов (имена вкладок фиксированы шаблоном).
  • Ключи, пустые отображаемые имена, ноль вкладок и пустой 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.cs и ExamplePipelineAugmenter.cs — реакция, которая наращивает узлы событий и блок кода вокруг записи, и каст, чьи фиксированные стадии закреплены за собственными столбцами.)

4.8 Реестры кода — цели ссылок, которые живут в коде (опционально)

Некоторые цели ссылок вообще не описываются в таблице: это атомы выполнения, к которым диспетчеризует ваш runtime. Регистрация их как заблокированной виртуальной вкладки размещает их на поверхности авторинга только для чтения и не даёт рёбрам, указывающим на них, отрисовываться как сломанным. Реализуйте 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); null-список Raises нормализуется в пустой. Core обращается с key / label / raises как с непрозрачными строками — никогда их не интерпретирует.

(См. Assets/SheetForge.PluginDemo/Graphing/ExampleCodeAtoms.cs.)

4.9 Графовые виджеты Data Studio (опционально, сборка Editor)

Виджет — это полоса вашего собственного интерфейса над холстом графа — обзор фиксированных стадий, сводный значок, что угодно, что нужно домену. Core не поставляет ни одного виджета, поэтому эта область пуста, пока её не заполнит плагин.

Поскольку тип возврата — VisualElement, этот контракт живёт в сборке Editor (та же обоснованная асимметрия, что и у ISheetSourceProvider). Реализуйте его в сборке на стороне редактора, ссылающейся на SheetForge.Editor и SheetForge.Core:

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)

Действие — это дополнительная кнопка в инспекторе узла — «что этот домен может сделать с этой записью». Core предоставляет одно встроенное действие (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();
    }
}
  • Сессия авторинга намеренно не предоставляется. Каждое подготовленное изменение должно быть одним нативным шагом Undo с увеличенным поколением проекции; раздача сырой сессии институционализировала бы способ обойти это правило. StageCell(tab, recordId, field, rawText) и StageCells(writes) — это вся поверхность изменения, а бухгалтерию ведёт само окно.
  • Меняете несколько ячеек? Используйте StageCells. context.StageCells(new[] { new EdgeCellWrite(tab, recordId, field, text), … }) подготавливает весь список как один шаг Undo, всё или ничего (если одна запись не может быть применена, не применяется ни одна). Вызов StageCell несколько раз разбивает Ctrl+Z на столько же шагов — а для параллельных столбцов это означает, что наполовину валидное состояние появляется в середине отмены. Null или пустой список ничего не делает.
  • Передавайте канонический текст. Подготовленный текст разбирается тем же парсером, что использует импортёр, во время отражения — так что пишите то, что содержала бы таблица.
  • Ключ, которого нет в baseline, — это отсутствие операции (совершенно новая или неразрешённая запись): ничего не записывается молча.
  • Сервисы: FocusCell прокручивает таблицу к координате, RequestRebuild запрашивает обновление после того, как вы что-то подготовили.
  • Обнаружение, метки и изоляция работают точно так же, как у виджетов: обнаружение через TypeCache, дословный запасной вариант для незарегистрированного LabelKey (пустой ключ откатывается к имени типа), и try/catch вокруг AppliesTo / Execute.

(См. Assets/SheetForge.PluginDemo/Demo/Editor/ExampleInspectorAction.cs. Его сборка Editor — SheetForge.PluginDemo.Demo.Editor — ссылается на SheetForge.Editor, SheetForge.Core и сборку плагина; это вся связка, которая нужна расширению на стороне редактора.)

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 рядом со встроенными Default и High contrast; вступает в силу только выбор пользователя. Отображаемые строки — ваши (ключ Loc из Core не нужен).
  • Пустые id, дубликаты и зарезервированные встроенные id (default, highContrast) отклоняются (Register бросает исключение, всплывающее как PluginRegistrationConflict).
  • Что тема не может перестилизовать: нативные виджеты Unity, отрисованные внутри наших окон (оформление кнопок, рамки полей), продолжают следовать скину редактора — см. Возможности и ограничения.

4.12 Редактирование на холсте графа (опционально)

Граф Data Studio — это поверхность авторинга, а не картинка: правый клик создаёт записи, соединяет их и отключает линии (см. Data Studio). Всё это работает в обычном проекте для обыкновенных столбцов RecordId@Tab.

Возможности ниже расширяют это там, куда ядро не может дотянуться. Ни одна из них не меняет существующий контракт, поэтому плагин, который их игнорирует, компилируется без изменений.

Как обнаруживаются возможности (прочитайте это в первую очередь)

Возможность никогда не обнаруживается сама по себе. Окно находит каждую из них, приводя тип объектов, которые уже зарегистрированы:

ВозможностьПриводится отЧто добавляет
IAuthorableGraphShapeпереопределения холста, зарегистрированного через ISheetForgeGraphPluginГде могут создаваться новые записи
IAuthorableEdgeContributorпоставщика рёбер, зарегистрированного через ISheetForgeEdgePluginПревращает один жест в одну запись ячейки
IBatchAuthorableEdgeContributorтого же поставщика рёберПревращает один жест в несколько записей ячеек
IVirtualNodeFactoryтого же поставщика рёберПредлагает «создать ещё один» в меню узла
IEdgeSlotDeclarerтого же поставщика рёберОбъявляет слоты подключения, которые схема не может вывести
IEdgeTokenEditorтого же поставщика рёберОписывает токен и редактирует часть, не являющуюся ключом

Таким образом, пять возможностей на стороне рёбер достижимы только если класс зарегистрирован как IEdgeContributor (через ISheetForgeEdgePlugin, §4). Если ваш домен не открывает собственных рёбер, это не повод пропускать регистрацию — реализуйте 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

Есть два значения по умолчанию, и они намеренно различаются.

  • Список создаваемых вкладок — ось, которую заменяет эта возможность, и которая также решает, откроется ли холст вообще, — охватывает каждую вкладку, достижимую по схеме вкладки фокуса, следуя ссылкам транзитивно. Он вычисляется из схемы, а не из данных, поэтому действует даже на таблице, у которой ещё нет строк. Достижение вкладки на две связи вглубь происходит поэтапно: создайте промежуточную запись, появятся её порты, и следующий переход присоединится к каскаду.
  • Каскад связывания — пикер, который вы на самом деле видите на пустом холсте, — уже. Он начинается с вкладок, на которые нацелены порты, нарисованные на экране в данный момент.

В любом случае вкладки, которыми владеет реестр кода, и вкладки без ключевого столбца отбрасываются, поскольку новая запись там не могла бы иметь идентичность.

Переопределение, зарегистрированное для этой вкладки (§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 каждого столбца — это один шаг. Добавление связи там должно расширить все столбцы разом, иначе столбцы получат разную длину — наполовину валидное состояние, которого план на одну ячейку избежать не может. Эта возможность — родственник 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) => …;
}
  • Всё или ничего. Каждая запись в списке подготавливается как один нативный шаг Undo; если хотя бы одну нельзя записать (нет такой строки, источник только для чтения, конвейер выполняется), не подготавливается ничего.
  • Пакетная форма побеждает. Если один класс реализует и единичную, и пакетную форму, окно спрашивает только пакетную — у одного жеста никогда не бывает двух разных ответов. Поставщики по-прежнему опрашиваются в порядке регистрации, и побеждает первый, кто спланировал.
  • Каждой записи нужен адрес. Список, содержащий запись с пустой вкладкой или полем (или пустой список), считается «нет плана».
  • Отвязывание выполняется по цепочке. Когда несколько линий на одной карточке обрезаются за один жест, контекст, который вы читаете, уже несёт более ранние планы этого жеста, так что вырезание двух токенов из одной и той же ячейки удаляет оба. У единичного контракта нет поверхности для получения этого промежуточного значения — именно эта возможность снимает это ограничение.
  • Создаваемая запись не может быть целью. В процессе «создать и связать за один жест» адреса записи разрешаются до того, как новая строка входит в сессию, поэтому план, нацеленный на создаваемую запись, не может устоять, и весь жест честно проваливается. На строки, которые уже существуют (случай параллельных столбцов), это не влияет.

Создание ещё одного чего-либо — 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 может быть именем виртуальной вкладки или пустым. Узлы, которые ваше переопределение холста помещает на экран, не живут в таблице; меню всё равно предлагает то, что вы объявили, потому что ячейки, которые вы записываете, названы вашим планом, а не идентичностью узла. Вкладки, которыми владеет реестр кода, исключены.
  • Один шаг Undo, всё или ничего — то же правило, что и у пакетной возможности выше. 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 или пакетную форму). Объявите без планирования — и порт откроется, но ничего не подготовится — реализуйте оба.
  • Порты открываются на узлах без строки таблицы. Для узла, чья вкладка не является таблицей, окно не ищет строку с таким именем; адрес записи приходит из вашего плана и проверяется во время подготовки.

Редактирование того, что говорит токен — IEdgeTokenEditor

Связывание и отвязывание перемещают весь токен целиком. Часто токен — это больше, чем ключ: attack:add:10 называет характеристику и то, насколько сильно. Добавьте эту возможность к тому же поставщику, и инспектор линии получит одну строку для этого остатка — части, не являющейся ключом:

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), и строка не отрисовывается — встроенная ссылка (чей ключ и есть весь токен) ведёт себя так вообще без какого-либо кода.

Как вообще сделать собственные линии редактируемыми

Линию можно редактировать, только если она называет ячейку, в которой записана. Ядро заполняет это само для ссылок, которые само же читает; дополнительное ребро, которое вы добавляете (§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;
    }
}
  • Виджет формирует ввод, парсер владеет смыслом. Всё, что вы commit-ите, — это канонический текст таблицы; он проходит ту же предварительную проверку, что и введённое вручную значение, а проблемы всплывают на панели Problems. Виджету никогда не нужно ничего проверять.
  • Две поверхности commit намеренно. Commit (выбор из списка, отпускание слайдера, потеря фокуса) создаёт один шаг Undo; CommitTyping (на каждое нажатие клавиши) объединяет серию в один шаг. Слияние их в один вызов либо засыпало бы Undo шагами на каждую букву, либо слило бы два разных выбора.
  • Возврат null отклоняет ячейку, и её берёт на себя встроенный виджет — честный ответ для форм, которые вы не обрабатываете (List<T> вашего типа, необязательные поля). context.Type (разобранный токен @type) несёт всё необходимое для решения.
  • ReferenceKeys(tab) передаёт вам тот же список кандидатов, что использует встроенный пикер ссылок (спроецированные ключи ∪ ключи реестра кода ∪ ключи подготовленных новых строк, отсортированные) — не нужно собирать свой собственный. Чтобы позволить человеку выбрать из этого списка в том же выпадающем меню, что открывает встроенная ячейка, вызовите StudioKeyPicker.Show(screenAnchor, tab, candidates, picked) и вставьте возвращённый ключ в свою собственную нотацию перед commit. (Создание записи, оставление ячейки пустой и множественное переключение списка — это собственные правила встроенной ячейки ссылки, и их нет на этом фасаде — виджет, владеющий всем текстом ячейки, владеет и этими решениями тоже.)
  • Вы можете претендовать не только на своё имя типа, но и на встроенное. Ветка зарегистрированного виджета выполняется первой, так что TypeName => "float" действительно заменяет поле сырого текста для каждого столбца float — именно так появляются слайдер, поле процента или поле с суффиксом единиц измерения. С этим приходят две предосторожности:
    • Это применяется к каждому столбцу этого типа в проекте, так что ограничивайте область, читая context.FieldName / context.Tab и возвращая null для столбцов, которые вы не имели в виду.
    • То, что вы commit-ите, всё равно остаётся каноническим текстом таблицы, так что слайдер должен отрисовывать своё значение так, как его прочитает обратно парсер (см. CanonicalValueRenderer.RenderFloat для написания float, которого ожидает round trip).
  • Конфликты предупреждают, обнаружение автоматическое. То же обнаружение через TypeCache, что и у любого другого контракта; если два провайдера претендуют на одно имя типа, побеждает первый найденный, а предупреждение в консоли называет оба. Брошенное исключение в CreateEditor перехватывается, выводится предупреждение, и ячейка откатывается к встроенному виджету.
  • Прежде чем писать виджет, проверьте, не хватит ли подсказки. Если всё, что вам нужно, — это выпадающий список, многострочное поле, ползунок, переключатель, выбор цвета, редактор кривой или редактор градиента, зарегистрируйте StudioCellEditorHint вместо этого (§4.16) — никакого кода виджета, и это работает и в браузере тоже. Порядок такой: сначала этот контракт, затем подсказка, затем стандартное поведение core; так что подсказку ячейка получает всякий раз, когда ни один виджет не заявил тип или заявивший его отказался.

4.14 Строки интерфейса плагина (опционально)

Метки, которые показывает ваш пакет — действия инспектора, подписи виджетов, декларативные поверхности из §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 — плагин строк, сидящий в сопутствующей сборке редактора, оставил бы веб-приложение показывать сырые ключи.
  • Регистрация опциональна. Незарегистрированный ключ продолжает отображаться дословно — этот контракт является путём улучшения, а не требованием.
  • Языки — это коды IETF ("en", "ko", "zh-Hans", "pt-BR", …), сопоставляются без учёта регистра.
    • Зарегистрируйте как минимум английский: поиск откатывается запрошенный язык → английский → промах, так что пользователь на любом другом языке читает ваше английское предложение, а не сырой ключ.
    • Код, которого продукт не знает, отклоняется с указанием причины, а не сворачивается в английский — опечатка, молча ставшая английским, была бы неотслеживаемой.
  • Ключи продукта нельзя переопределить — регистрация, называющая встроенный ключ, отклоняется, так что оверлей никогда не сможет заставить интерфейс противоречить собственным предложениям продукта. В частности, подписи меню запекаются прямо из языковых таблиц, поэтому оверлей, который мог бы их переписать, рассорил бы текст рекомендации с настоящим путём меню. Оверлей предназначен для новых ключей.
  • Дублирующиеся регистрации между пакетами сохраняют первую найденную, с записанной причиной — если бы молча побеждала последняя регистрация, экран зависел бы от порядка установки плагинов.
  • Пустые ключи и пустые значения тоже отклоняются. Каждый отказ — это строка на английском, адресованная разработчику, потому что аудитория — автор плагина, а не конечный пользователь.
  • Правило паритета 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 посимвольно, round trip «таблица → импорт → экспорт → таблица» переписывает текст, который никто не редактировал. Нормализация \r\n в \n на выходе (как выше) — это то, что удерживает значение, написанное в Windows, от чередования между двумя написаниями при последовательных экспортах. Одного теста, который отрисовывает разобранное значение и сравнивает его с исходным текстом ячейки, достаточно, чтобы это закрепить.

2. Дайте ячейке настоящий редактор. Значение, экранированное \n, неприятно печатать в однострочном поле, и именно для этого нужен §4.13 — зарегистрируйте IStudioCellEditorProvider для "Prose", который возвращает многострочный TextField (multiline = true), показывающий значение с настоящими переносами строк и делающий commit с повторным экранированием. Делайте commit при потере фокуса через Commit (один шаг отмены на сессию редактирования), а не на каждое нажатие клавиши.

3. Знайте единственное место, куда соглашение не дотягивается. Если кто-то нажмёт Alt+Enter прямо в Google Таблице, это создаст настоящий перенос строки в живой ячейке, и эта ячейка будет отклонена при следующем получении с координатой, указывающей на неё. Отказ честен и исправим, но это отказ — так что если авторы в вашей команде пишут текст прямо в самой таблице, укажите в собственной документации, что длинный текст пишется с \n, либо позвольте им писать его в виджете ячейки Data Studio из шага 2, где экранирование происходит за них.

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

Словарь намеренно ограничен — он растёт только через добавление в конец, никогда через вставку, так что существующая регистрация сохраняет свой смысл.

  • Пять мест размещения для действия: Inspector, RowContextMenu, TopbarMenu, ColumnHeaderMenu, CanvasNodeMenu.
    • Каждое заполняет контекст тем, что известно этому месту, — место строки несёт запись, место столбца несёт имя столбца, место холста несёт запись узла — а остальное оставляет пустым, так что защищайтесь, прежде чем читать поле, которое это место не предоставляет.
  • Тринадцать видов узлов для панели или значка: Row, Label, Chip, Badge, Button, Rule, Heading, KeyValue, Table, List, Progress, Input, Link.
    • Они строятся через статические фабрики (StudioUiNode.Label(…), .WithTooltip(…)), поэтому узел неизменяем, и заданы только те поля, которые имеют смысл для его вида.
  • Семь архетипов редактора ячейки: Dropdown (вы предоставляете кандидатов), MultilineText, Slider (вы предоставляете диапазон), Toggle (вы предоставляете два канонических текста), ColorPicker (#RRGGBB / #RRGGBBAA), CurveEditor и GradientEditor (текст ячейки — это каноническая нотация кривой / градиента из раздела Синтаксис таблиц — пакет, чей собственный тип пишет эту нотацию, например через CurveValue.Render(), может заявить эти архетипы). Встроенные типы Color, AnimationCurve и Gradient подключены через тот же самый механизм — BuiltinCellEditorHints хранит их три подсказки, — и хост сначала обращается к регистрациям пакета, так что регистрация подсказки под одним из этих имён типов переопределяет встроенный выбор, а не отклоняется. В редакторе последние три архетипа — это нативные поля Unity для цвета, кривой и градиента; в браузере это собственные редакторы приложения; List<> типа, несущего один из них, в обоих случаях становится редактором-плашкой. Именно так поступает тип Falloff из Plugin Demo: его парсер читает ячейку через CurveValue.TryParse, и одна регистрация подсказки даёт ему поле кривой в Unity и редактор кривой в браузере.
  • Нигде нет чисел раскладки. Пиксели и соотношения перенесли бы особенности одного экрана на другой; вы говорите что показать, а каждый рендерер сам решает, как это разместить.

Правила, которые стоит знать, прежде чем писать такую регистрацию:

  • Изменение проходит через ту же дверь, что и ваша рука. StudioSurfaceContext даёт действию ровно четыре возможности — StageCell, StageCells (несколько ячеек, один шаг Undo, всё или ничего), FocusRecord, RequestRebuild — поверх доступных только для чтения Tables / References / CodeRegistries.
    • Так что команда плагина — это обычная подготовленная правка: один шаг Ctrl+Z, ничего не достигает таблицы, пока вы не отправите, та же предварительная проверка.
    • Шлюз подготовки тоже применяется — источник только для чтения, работающий конвейер или вкладка из книги блокируют это с показанной причиной.
  • Предикаты выполняются постоянно. AppliesTo, построение панели и предоставление значка выполняются на каждый жест и на каждый такт пересчёта. Читайте снимок, который вам передали; никакого ввода-вывода, сети, долгих вычислений.
  • Показанное не значит выполненное. Хост заново проверяет предикат при вызове. Если ситуация изменилась с момента отрисовки меню, ответ — честный no-op плюс перерисовка, а не второй провал. Браузер делает то же самое для устаревшего id.
  • ConfirmKey спрашивает сначала. Дайте действию ключ подтверждения, и хост покажет это предложение перед его выполнением — то, что нужно команде, подготавливающей много ячеек сразу.
  • Узел Link открывает только http/https. Правило — это один предикат Core (StudioUiNode.IsAllowedUrl), который спрашивают оба хоста, поэтому они не могут разойтись в том, что безопасно открывать; браузер затем заново проверяет ту же форму, прежде чем отрисовать ссылку, что может только сильнее отказать, но никогда не ослабить проверку.
    • 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, и каждый хост берёт то, что может нарисовать: редактор использует богатую версию, браузер — описательную. Именно так «насколько далеко заходит браузер, настолько же далеко и редактор» держится без второго набора контрактов.

Веб-только варианта нет — отсутствующая богатая панель означает, что рисуется описательная, а не что панель исчезает. Элемент живёт один такт пересчёта, поэтому тоже не хранит состояние.

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 на стороне редактора.
  • Исключение изолируется до этого наблюдателя, с собранной причиной; результат импорта не меняется ни на бит.
  • Будущие точки наблюдения (сразу после разбора, цикл экспорта) придут как родственные интерфейсы возможностей, обнаруживаемые приведением зарегистрированного наблюдателя, так что добавление нового не сломает реализацию, написанную сегодня.

5. Пользовательские источники импорта (ISheetSourceProvider)

Новый источник (база данных, конечная точка REST, внутренний формат) подключается без единой правки Core/Editor. Реализуйте ISheetSourceProvider в сборке Editor. SourceProviderRegistry обнаруживает его через TypeCache, и он появляется в выпадающем списке «Source» настроек рядом со встроенными источниками.

Четыре вещи, на которые отвечает провайдер:

  1. Получение данныхCreateTabSource(settings) возвращает ITabSource, предоставляющий соответствие имя вкладки → исходный текст TSV (асинхронно; проблемы окружения — это диагностика, а не исключения; частичный результат допустим).
  2. Запись обратноCreateReflectTarget(dispatcher, settings) возвращает ISourceReflectTarget, который подключается к диспетчеру авторинга (используйте публичные Session / Callbacks / Baselines диспетчера, чтобы собрать свою цель). Возвращайте цель, только если в ваш источник можно записывать.
  3. ВидимостьGetVisibility(settings) возвращает, какие поля настроек инспектор должен показывать для вас.
  4. CanAuthor — возвращайте false для источников только для чтения; окна авторинга отключают интерфейс авторинга (так же, как для Google ExportUrl).

Стабильная строка Id сохраняется в sourceProviderId. Встроенные источники используют "LocalFile" / "GoogleSheet" в качестве своих Id; пустой sourceProviderId разрешается во встроенный LocalFile по умолчанию. Пустой Id исключает провайдера из интерфейса (полезно для тестовых проб).

Провайдеры намеренно находятся в сборке Editor — источники являются границей ввода-вывода, и удержание ввода-вывода за пределами 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, взаимное исключение, модальные окна подтверждения и одобрения, индикатор прогресса, и возобновление генерация кода→компиляция→запекание через перезагрузку домена. Здесь нет полуцикла, который можно было бы собрать, а значит, и нет шлюза, который можно случайно пропустить.

Две вещи, которые нужно знать:

  • RunImport и RunPush являются fire-and-forget — их тела async void, поскольку главный поток редактора не должен блокироваться на сетевом вводе-выводе, — так что возврат не означает завершения. Подпишитесь на ImportEvents.ImportCompleted для этого.
  • Отправка всё равно показывает своё модальное окно одобрения, так что скрипт без присмотра не может отправить без участия человека.

Возьмите ту же блокировку, что берут встроенные пути — для пользовательского провайдера источника, пишущего в собственный бэкенд:

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 — это отсутствие операции, сохраняющее подготовленное состояние нетронутым.

Показывайте собственные предложения продукта в своём интерфейсе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
        ;

Это санкционированный путь для второго бейкера (другого продукта, превращающего те же таблицы в свои собственные ассеты). Не используйте reflection для приватного поля records: это превращает имя поля в необъявленный контракт, который молча сломается в день, когда генерация кода его переименует. Список доступен только для чтения — таблица канонична. По умолчанию он пуст для сгенерированного кода, написанного до появления этого члена; один повторный импорт выпускает переопределение.

Безопасно расширяйте сгенерированные классы — оба сгенерированных класса — partial, так что производный член (вычисляемое свойство, реализация интерфейса, оператор) может жить в вашем собственном файле рядом с ними и пережить каждый повторный импорт. Не добавляйте туда сериализуемые поля: запечённый ScriptableObject перестраивается из таблицы при каждом импорте, поэтому поле, которое сериализует только ваша часть, возвращается к значению по умолчанию. Если значение принадлежит данным, ему место в столбце.

Что остаётся закрытым — намеренно

Поверхности выше — это санкционированный внешний край. Следующее остаётся internal независимо от того, насколько удобным выглядело бы его открытие, потому что каждое из них — граница доверия или целостности, а не граница удобства:

  • Учётные данные и подписание — локатор ключа сервисного аккаунта, примитивы JWT/PEM/PKCS8 и провайдер токена доступа Google. Открытие их вручило бы любому плагину bearer-токен, ограниченный вашей таблицей.
  • Сырая цепочка отправки (исполнитель отправки, шлюзы таблицы, записи ячеек) — одобрение (IPushApprover) обеспечивается внутри этой оркестрации; публичный сырой writer был бы записью в таблицу без шага одобрения.
  • Проверка перед отправкой и движки записи отражения — внешний код входит только через AuthoringDispatcher.Reflect(), который по пути проходит проверки устаревшего якоря, предварительную проверку и одобрение; движок записи под этим — не контракт.
  • Цепочка целостности запекания/генерации кода (отпечатки схемы, writer сгенерированного исходного кода, очистка сирот) и шлюз проверки актуальности сборки — открытие их сделало бы подделку или обход состояния запекания делом одной строки кода.
  • Эфемерный оверлей SO — у «таблица есть источник истины» есть ровно одно санкционированное исключение (переключатель тестовой правки в инспекторе), и оно намеренно не предлагается как API.

Если рабочему процессу кажется, что нужно что-то из этого, ему нужен запрос функции, а не reflection.

6. Расположение сгенерированного кода и пространства имён

  • generatedCodeFolder может быть любой папкой (самовосстанавливающийся сопутствующий asmdef автоматически связывает ссылки на типы плагина), но размещать её внутри вашего пакета (например, Assets/MyDomain/Runtime/Generated) — самый аккуратный вариант: тогда сгенерированные типы компилируются в той же сборке, что и ваши enum/пользовательские типы, без необходимости в сопутствующем asmdef.
  • Расположение для каждой вкладки: вкладка, чей сгенерированный тип уже существует где-то, перегенерируется на месте — закоммиченная папка Generated вашего пакета остаётся авторитетной, даже если настройки указывают в другое место. Устаревшие дубликаты автоматически удаляются (с записью в лог, никогда молча).
  • generatedNamespace изолирует ваши сгенерированные типы (например, MyGame.Data). Обнаружение типов использует встроенный маркер SchemaFingerprint сгенерированных типов, а не пространство имён, поэтому подходит любое пространство имён. Изменение значения автоматически вызывает перегенерацию.
  • Коммитить ли папку Generated вашего пакета — это политика самого пакета. Пример коммитит свою (классы Example* с пространством имён по умолчанию SheetForge.Generated, вкладки ExampleSkills/ExampleEffects/ExampleActions), чтобы свежий клон компилировался немедленно, и именно префикс имени класса Example* — а не отдельное пространство имён — не даёт им конфликтовать с настоящими вкладками Skills/Effects вашего проекта.

7. Использование во время выполнения — «собирайте, а не программируйте»

Ваш runtime читает сгенерированные базы данных и диспетчеризует по enum type к атомам кода:

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 проверяет ссылку и запекает addressable (точно так же, как для изображения); выполнение — задача игры.

8. Обнаружение SheetForge из другого ассета

Другой ассет — тот, что интегрируется с SheetForge, а не расширяет его (например, система характеристик), — может обнаружить, что SheetForge установлен. Поскольку платный продукт Asset Store — это продукт-папка (без package.json / UPM), он не может поставлять запись versionDefines; вместо этого сборка Editor SheetForge самостоятельно регистрирует символ scripting-define SHEETFORGE для каждой платформы сборки.

(a) Время компиляции (предпочтительно):

  • Если ваша интеграция находится в собственном assembly definition, добавьте SHEETFORGE в Define Constraints этого asmdef — тогда сборка будет компилироваться только при наличии SheetForge.
  • Если код, взаимодействующий с SheetForge, находится в одной сборке с кодом, который должен компилироваться в любом случае, оградите только эти части директивой #if SHEETFORGE … #endif.

(b) Время редактора (альтернатива): когда нельзя полагаться на порядок компиляции, проверяйте через рефлексию — например, System.Type.GetType("SheetForge.Editor.Pipeline.ImportEvents, SheetForge.Editor") != null — а затем подключайте (например) шину завершения импорта динамически.

SHEETFORGE означает «SheetForge установлен». Это отдельно от SHEETFORGE_ADDRESSABLES — внутреннего version-define на собственных сборках SheetForge, который лишь отмечает, присутствует ли пакет Addressables; не используйте последний как пробу установки.

Определение сохраняется, даже если SheetForge впоследствии удалён (наблюдателя, который бы его снял, нет); удалите его вручную в Project Settings ▸ Player. См. Возможности и ограничения.

Что всё ещё требует правок Core

Всё вышеописанное подключается без единой правки Core. Чего плагин всё ещё не может сделать без изменений в Core:

  • Выводить значения маркеров в сгенерированный код — пользовательские маркеры являются метаданными для проверки/отображения; запекание их в константы или атрибуты генерируемого кода не входит в текущий объём, пока это не понадобится какому-либо потребителю.
  • Заставить распространение переименования ключа достать внутрь пользовательской нотации, не будучи проинструктированным как — переименованная запись переписывается в ячейках RecordId@Tab, их списках и элементах wrapper'ов силами самого Core. Для собственной грамматики реализуйте IReferencingCellType (§4.4a), и она будет переписана с сохранением полезной нагрузки; это опция, а не правка Core. Откажитесь от опции — и граница останется: ваш доменный валидатор сообщит о повисшем ключе, вместо того чтобы переименование молча его исправило.
  • Добавить члены в зарегистрированный плагином enum C# из таблицы — enum, зарегистрированный через enums.Register<T>(), принадлежит коду, поэтому лист определений enum не может его расширить, а Data Studio не предлагает эту строку. Перенесите enum в лист enum, если таблица должна им владеть (см. Синтаксис таблиц).

Wrapper-тип <> (ICellWrapperType, см. §2) и пользовательский структурный маркер (IStructuralMarkerDefinition, см. §4.5) оба расширяют конвейер без правок Core.

Похожие страницы