本文へスキップ
SheetForge

プラグイン作成 — Core への変更ゼロでドメインを追加する

ドメイン(skills、items、quests など)は、SheetForge.Core を参照する独立したパッケージ として SheetForge に参加します——Core がそれを参照し返すことは決してありません。

プラグインは、enum、カスタムセルタイプ、ラッパー型、ドメインバリデーター、グラフエッジ、構造マーカー、「シート作成」テンプレート、インポートソースそのもの、Data Studio のキャンバスオーバーライド、コードレジストリ、宣言的オーサリングサーフェス、ウィジェットとアクション、カラープリセット、カスタムセルウィジェット、パイプラインオブザーバー、そして自分自身のローカライズされた UI 文字列を追加できます——以下の十六の契約です。

「ドメインの追加 = Core のコード変更ゼロ」は、コンパイラによって強制されています。InternalsVisibleTo を持たないテストアセンブリ(SheetForge.Tests.Consumer)が、公開サーフェスのみを使って十六のうち十五——そしてそれに付随する capability インターフェースも——を実装しているため、もしどれか一つが internal に狭められれば、ビルドは失敗します(CS0122)。十六個目、エディター専用のリッチパネル用エスケープハッチは VisualElement を返すため、代わりに Editor 側のテストで検証されます。

十一の Core 契約 は純粋な C# です。これによって、一つのコンパイル済みプラグイン DLL が、Unity エディター ブラウザー(SheetForge Web)の両方で同じスロットを有効化できます——アセンブリと隔離は一つの共有された Core の機能であり、ホストごとに異なるのは 発見 の方法だけです(Unity の TypeCache、ブラウザーのアップロード済みアセンブリスキャン)。

五つの Editor 契約 は UIToolkit の要素を返すか、ウィンドウの状態に触れるため、エディター内でのみ存在します。

十六すべてが自動的に検出されます——引数なしのコンストラクターだけが要件であり、アセンブリ参照も登録の呼び出しも、編集すべきマニフェストもありません:

契約登録するものオプトイン?
ISheetForgePluginenum + カスタムセルタイプのパーサー基本の契約
ISheetForgeValidatorPluginドメインの検証ルール(列をまたぐ / タブをまたぐ)オプトインのアドオン
ISheetForgeEdgePluginコアのスキャナーからは見えないグラフエッジの宣言オプトインのアドオン
ISheetForgeMarkerPluginカスタム構造マーカー(列ごとの @marker 行)オプトインのアドオン
ISheetForgeTemplatePlugin「シート作成」テンプレート(タブ + サンプルデータ)オプトインのアドオン
ISheetForgeGraphPluginData Studio 向けのタブ単位のキャンバスオーバーライドオプトインのアドオン
ISheetForgeCodeRegistryPluginコードの中に存在する、読み取り専用のキー空間(ロックされた仮想タブとして)オプトインのアドオン
ISheetForgeThemePluginSheetForge のウィンドウのためのカラープリセット(ダーク・ライト両方)オプトインのアドオン
ISheetForgeStudioPlugin宣言的オーサリングサーフェス——アクション、パネル、列バッジ、セルエディターの hintオプトインのアドオン
ISheetForgeStringsPluginあなたのパックの UI 文字列を、言語ごとに(製品テーブルより先に参照されるオーバーレイ)オプトインのアドオン
ISheetForgePipelinePluginパイプラインオブザーバー——インポートが生成したものの読み取り専用通知オプトインのアドオン
ISheetSourceProviderインポートソースそのもの(DB / REST / 独自形式)独立(Editor アセンブリ)
IStudioGraphWidgetData Studio のキャンバス上部に表示するドメインウィジェット独立(Editor アセンブリ)
IStudioInspectorActionData Studio の node inspector に追加するボタン独立(Editor アセンブリ)
IStudioCellEditorProviderData Studio のグリッド内の、一つのセルタイプのためのカスタム入力ウィジェット独立(Editor アセンブリ)
IStudioPanelProviderStudio 内の任意の UIToolkit パネル——宣言的な方式の隣にあるエスケープハッチ独立(Editor アセンブリ)

参照用サンプルは選択的インポートです。 完全な作例(SheetForge.PluginDemo)は、Assets/SheetForge/Examples/SheetForgePluginDemo.unitypackage に Unity パッケージとして同梱されています——ダブルクリックするか、Getting Started ウィンドウ(Tools ▸ SheetForge ▸ Getting Started、デモのインポートが行われる唯一の場所です)で プラグインデモをインポート を押すと、Assets/SheetForge.PluginDemo/… の下に復元されます。

インポートするまでは、プロジェクトの中にはまったく存在しません——このサンプルはそのパッケージとしてのみ提供されます——そのため、そのアセンブリ/型/タブ/アドレスがあなたのプロジェクトと衝突することは決してありません。以下で参照されているパス(Assets/SheetForge.PluginDemo/ModifierCellParser.cs など)は、パッケージをインポートした後に存在します。

(プラグインを使わない第二のサンプル SheetForge.CoreDemo は、コアの組み込み型のみでパイプラインを示します。)

アドオンは、基本のインターフェースを変更することなく拡張します——検証やエッジを必要としないプラグインは、それらの存在によって何の影響も受けません。

さらに七つのインターフェースは、契約ではなく capability です:

  • それら自体が単独で発見されることはありません。
  • すでに登録されている何かによって 追加で 実装されます。
  • Core はその登録済みのオブジェクトをキャストすることでそれらを見つけます。

そのうち六つは、登録済みのエッジコントリビューターまたはキャンバスオーバーライドからキャストされます——発見のルールとそれぞれの内容については §4.12 を参照してください。七つ目の IReferencingCellType は、登録済みの セルパーサー からキャストされ、あなた自身の記法に、RecordId@Tab が持つのと同じ参照の扱いを与えます——§4.4a を参照してください。これらのいずれを無視しても、何も変わりません。

1. パッケージのセットアップ

SheetForge.Core を参照する独自の .asmdef を持つフォルダーを作成してください(ランタイムでのルックアップが必要なら SheetForge.Runtime も)。それだけです——Editor の PluginRegistryTypeCache を通じてあなたの ISheetForgePlugin の実装を検出し、あなたの登録メソッドを呼び出します。登録は あなたが明示的に書くコード であり、アセンブリスキャンではありません。

Core 契約の実装は、そのメインアセンブリの中に留めてください。五つの IStudio* / ISheetSourceProvider の実装は、Editor 側のコンパニオンアセンブリ(SheetForge.Editor も参照するもの)に置きます——ブラウザーはあなたのメインの DLL だけを読み込むため、Editor 側のコンパニオンに実装された Core 契約は、そちらではサイレントに欠落してしまいます。

1.1 互換性の宣言(任意、一行)

アセンブリレベルの属性で、あなたのアセンブリがどの世代のプラグインフォーマットに対してビルドされたか、そして要求する最低ホストバージョンを宣言できます:

using SheetForge.Core.Plugins;
 
[assembly: SheetForgePluginCompat(
    SheetForgePluginFormat.Current,          // the generation constant of the SDK you compiled against
    MinHostVersion = "0.1.0",                // optional — omit for "any host"
    PluginVersion  = "1.0.0")]               // optional, display only
  • 省略しても構いません。 宣言のないアセンブリは、ホスト要件なしの世代 SheetForgePluginFormat.Minimum として読み取られるため、この属性が存在する前に書かれたプラグインも、それまでどおり読み込まれます。
  • 判定の単位はアセンブリです。 拒否されたアセンブリは、その登録の すべて を失います。型ごとの宣言では、宣言されていない隣の型を素通りさせてしまい、「拒否されたのに半分だけ登録された」という状態を招きます。
  • 判定するのは DLL であり、カタログではありません。 マーケットのレジストリも同じ二つの値(pluginFormatminHost)を掲示するため、ダウンロード前に一覧を絞り込めますが、ゲートが読み取るのは検証済みバイト列から取り出した属性の方です——一覧の表示は間違っていることがあっても、コンパイル済みの宣言が間違うことはありません。
  • 拒否は、そのアセンブリが宣言した内容と、このホストが読み取った内容を名指しする PluginIncompatible 診断であり、サイレントな消失ではありません。これは 互換性の宣言であり、署名ではありません: 完全性の保証は配布チャネル側の仕事です(Web プラグインマーケット を参照)。
  • 世代番号が動くのは、プラグインフォーマット自体が置き換えられたときだけです。純粋な追加的成長——新しい契約、レジストリへの新しいメンバー——では決して動きません。あなたの既存のプラグインは、再コンパイルなしに動作し続けます。

2. 基本プラグイン: enum + カスタムセルタイプ

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

カスタムセルタイプの一連の流れ

ICellValueParser(文字列 → 値)を実装し、強く型付けされたベイク Export/Push のラウンドトリップの両方を完成させるには、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;
    }
}

(op トークンの検証や最も近い候補の提案を含む、完全な実運用版については Assets/SheetForge.PluginDemo/ModifierCellParser.cs を参照してください。)

カスタムタイプにおける @target は、登録だけで機能します: 列を Modifier@Stats として宣言すると、あなたのパーサーは context.Type.TargetName("Stats")を読み取ります。その対象の整合性チェック(そのタブは存在するか? id は解決できるか?)は、ドメインバリデーターの役割です——RecordId@Tab と同じ役割分担です。未登録の 型名に @ が付いている場合も、提案付きのエラーになるため、タイプミスに対する安全性は保たれます。

Core がすでに所有しているタイプ名。 組み込みのスカラー名——intfloatboolstringEnumRecordIdIntIdAssetRefColorAnimationCurveGradient——は、どのプラグインよりも先に登録されます。これらのどれかを再利用するパーサーは、PluginRegistrationConflict で登録に失敗します——組み込みの方が残り、その RegisterCellParsers 呼び出しは競合したパーサーの箇所で止まりますが、プラグインの他のスロットは引き続き読み込まれます。そのため、独自の Color 型や Gradient 型をすでに同梱していたパックは、それをリネームする必要があります(チェンジログのアップグレードノートを参照してください)。あなたの型がカラー・カーブ・グラデーションの値を 保持する だけなら、その記法を自分で再実装する必要はありません: Core の値モデルである ColorValueCurveValueGradientValueTryParse(text, out value, out error)Render() を公開しており、CurveEvaluator / GradientEvaluator は Unity とまったく同じ方法でそれらをサンプリングします。そして ColorPickerCurveEditorGradientEditor のアーキタイプ(§4.16)を指定した StudioCellEditorHint は、両方のホストであなたの型に対してネイティブエディターを開きます。

ラッパー型の一連の流れ(MyWrapper<T>)

ラッパー とは、複数の内側の T 値を一つのセルに詰め込む、汎用の値の形です——Pair<int> = 1~2。あなたが持つのは 外側の構文(区切り文字、要素数)だけです。内側の T は Core が再帰的にパースするため、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;)。
  • ベイク。
  • Export/Push のラウンドトリップ。
  • 参照のパススルー——ラッパー内部の RecordId@Tab は整合性がチェックされ、キー名変更時に伝播し、タブ名変更時に書き換えられます。

却下ルールと ; 区切り文字に関する注意点は、シート構文 に記載されています。

3. ドメインバリデーター(オプトイン)

Core の検証は、四種類(キー、参照、@overlap、アセットキー)に固定されています。列をまたぐ ルール(「type が Custom なら script が必須」)や、タブをまたぐ ルール(参照先レコードの 意味 をチェックする)には、ISheetForgeValidatorPlugin を実装してください。

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

登録されたバリデーターは、インポート時の検証とオーサリングの事前検証の 両方に 自動的に参加します。ルール:

  • 違反は ImportErrorCode.DomainRuleViolation として ctx.Errors に報告してください——決して例外を投げないこと。投げられた例外は分離され、格上げされますが、他のバリデーターはそのまま実行されます。
  • 四つの要素すべてを埋めてください——どこで(CellCoordinate)、何が(ActualValue)、なぜ(Expected)、どうすればよいか(Suggestion)。「どうすればよいか」は、実行可能な文としてそのまま表示されます。
  • ctx は次を提供します:
    • パース済みのすべてのテーブル(Tables)、
    • キーのインデックス(KeyIndices)、
    • アセットキー(AssetKeysnull はアセット検証がスキップされたことを意味します)。
  • 「すべてを収集する」ことと「部分的な組み立てをしない」ことは、自動的に継承されます。

4. エッジコントリビューター(オプトイン)

データグラフの上にツールを構築する場合(あるいは将来のグラフキャンバスに、あなたのドメインのつながりを見えるようにしたい場合)は、コアの参照スキャナーからは見えないエッジを宣言してください——例えば、ミニ文法の値の 内側 で参照されている stat など:

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

IEdgeContributor は読み取り専用のタブ横断コンテキストを受け取り、EdgeSpec の項目(from/to タブ + レコード id、任意のフィールド、ペイロードレコード、ラベル)を追加します。コントリビューターが診断情報を発行することは決してありません——エッジはプロジェクション用の材料であり、検証ではありません。オーサリングカーネル を参照してください。

4.4 レシピ: 内部にキーを保持するカスタム型

RecordId@Tab は、Core が理解する唯一の参照の形であり、整合性チェック・グラフエッジ・最も近い候補の提案・名前変更の伝播を、無料で手に入れます。あなた自身の記法がキーを飲み込んだ瞬間——attack:add:10stat.hp>50fire@0.4——Core にはただの不透明な一つの文字列として見えてしまうため、この四つのサービスはあなたの記法の手前で止まってしまいます。三つの登録で、そのうち三つを取り戻せます。 これらは一組として書いてください。三つのうち一つだけを備えたミニ構文は、「インポートは通るのに、何もどこも指していない」という状態を生み出す典型的な形です。

ピース契約何を取り戻すかないとどうなるか
1. 整合性IDomainValidator(§3)あなたの記法の内部にある、存在しないキーが、座標と実行可能な文とともに報告されますタイプミスがそのままインポートを通過し、ランタイムで失敗します
2. 可視性IEdgeContributor(§4)埋め込まれたリンクが、本物のエッジになります: キャンバスはそれを描画し、Used by リストはそれを数え、参照インデックスはそれを索引化しますデータの中には接続が存在するのに、画面のどこにも現れません
3. 「どうすればよいか」ピース 1 の内部にある TextSuggestion.FindNearest「不明な stat 'atack'。'attack' の間違いではありませんか?」——組み込みの参照エラーが使うのと同じ文の形です診断は正しくても、対処する手段がありません
// Piece 1 + 3 together — the validator is where the suggestion belongs, because it is the
// only one of the three that produces a sentence a person reads.
using SheetForge.Core.Model;
using SheetForge.Core.Validation;
 
public sealed class ModifierStatExistsValidator : IDomainValidator
{
    public string Name => "ModifierStatExists";
 
    public void Validate(DomainValidationContext ctx)
    {
        if (!ctx.KeyIndices.TryGetValue("Stats", out var stats)) return;   // no target tab: nothing to check
        if (!ctx.Tables.TryGetValue("Effects", out var effects)) return;
        if (!effects.Schema.TryGetField("modifier", out var field)) return;
 
        foreach (var rec in effects.Records)
            foreach (string statKey in StatKeysIn(rec["modifier"]))       // your notation's own split
            {
                if (stats.Contains(statKey)) continue;
                string near = TextSuggestion.FindNearest(statKey, stats.Keys);   // piece 3
                ctx.Errors.Add(new ImportError(ImportErrorCode.DomainRuleViolation,
                    new CellCoordinate("Effects", rec.RowNumber, field.ColumnNumber, "modifier"),
                    /* what */ statKey,
                    /* why  */ "This modifier points at a stat that does not exist in 'Stats'.",
                    /* how  */ near != null
                        ? "Did you mean '" + near + "'? Fix the stat name in the modifier value."
                        : "Add that record to 'Stats', or correct the stat name."));
            }
    }
}

その記法のための分割処理は 一つだけ 再利用してください——パーサー、バリデーター、エッジコントリビューターは、キーがどこから始まりどこで終わるかについて一致していなければならず、その分割処理を三つ private にコピーしてしまうことが、それらが食い違っていく原因になります。(ラッパー型(§2)は、これを無料で手に入れます: TrySplit こそが、その共有の分割処理そのものだからです。)

四つ目のサービス——名前変更の伝播——には、もう一つ必要なものがあり、それを手に入れる方法は二つあります。 レコードの名前を変更すると、Core がテキストの中にキーを見つけられる場所に限って、参照しているセルが書き換えられます。これができるのは、RecordId@Tab フィールド、そのリスト、そして TrySplit がキーを一つの要素として公開しているラッパーに対してだけです。あなたの文法の部分文字列の境界を、Core が自力で推測することはできません。そのため、次のいずれかになります:

  • やり方を教えるIReferencingCellType(§4.4a)を実装すれば、この三点セットのレシピ全体が一つのオプトインに置き換わり、四つのサービスすべてが一度に取り戻せます。
  • この境界を受け入れる。 少なくともサイレントではなく正直です: ピース 1 が、次のインポート時に、宙に浮いたキーを座標と提案つきで報告します。

上記のレシピが依然として正しい答えとなるケースが一つあります: 列に @target がない 場合です——キーが属する単一のタブが存在しないからです。同梱のサンプルはまさにそれです——List<Modifier> はターゲットを指定していないため、Core は attack がどこで解決されるべきかを知ることができず、ModifierStatEdgeContributor がそれらのエッジを手作業で開いています。列にターゲットを与えれば(List<Modifier@Stats>)、§4.4a が代わりを引き受けます。

4.4a 自前の記法に完全な参照整合性を持たせる(オプトイン)

すでに登録済みのパーサーに IReferencingCellType を実装すると、MyType@Tab 列は特別扱いされなくなります: RecordId@Tab とまったく同じように、検証・提案・伝播・描画・選択・索引化が行われるようになります。新しい登録経路は不要です——Core は、CellParserRegistry にすでに登録されているパーサーをキャストして扱います。これは、キャンバスの capability が登録済みのエッジコントリビューターからキャストされるのと同じ仕組みです(§4.12)。この実装を持たないカスタム型は、以前とまったく同じにビットレベルで振る舞います。

五つのフック

五つのフックはすべて、一つの要素 に対して働きます: スカラー列ではセル全体、List<MyType@Tab> では ; で区切られた一つの要素——あなたの ICellValueParser.TryParse が受け取るのと同じ単位です。

フック答える問い用途
bool TryGetTokenKey(elementText, out key)「この要素は何を指しているか?」メンバーシップ——このセルはすでにそのレコードにリンクされているか
string MakeToken(key)「このキーへの新しいリンクを書く」空のセル、またはリストへの追加。ペイロードには 中立な初期値 を入れてください。オーサリングサーフェスが値を勝手に作り出してはいけません。null/空を返すと、そのジェスチャーは偽装されるのではなく、理由とともに無効化されます
bool TryRetargetToken(elementText, newKey, out newText)「これを別の何かへ向ける」 セルで別のレコードを選ぶ場合や、キャンバス上でワイヤーの向き先を変更する場合。ターゲットだけを変更してください——トークンを削除して作り直すと、その人が入力した数値がリセットされてしまいます
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 として、座標と「もしかして…」とともに報告され、組み込みの参照と同じフィールドごとの提案予算を共有します。
  • ペイロードを保ったままの名前変更伝播attackpower に名前変更すると、attack:add:10power:add:10 に書き換えられます。演算子と数値は作者のものであり、そのまま残ります。
  • グラフ — そのリンクは、座標を持つ本物のエッジになります: 描画され、ノードにポートが付き、Used by リストがそれを数え、参照インデックスには双方向で記録されます。
  • ピッカー — セルは RecordId@Tab セルと同じ検索可能なドロップダウンを手に入れ、別のレコードを選ぶと ターゲットだけが置き換わり、残りの部分は保たれます。このレジストリがなければ、ピッカーはあなたの値の上に裸のキーを貼り付ける代わりに、辞退します。
  • 孤立検出とエクスポートされるドロップダウンルール — 唯一の外向きのリンクがあなたの記法の内部にある行は、もはや未接続として扱われなくなり、あなたの型のスカラー列には、対象タブのキーに対するデータ検証ドロップダウンが付与されます(ソース・エクスポート・プッシュ)。

最も単純な使い方は、エイリアス型です。 値が単なるキーであり、セルのテキストがそのキーそのものである場合:

  • TryGetTokenKey はトリムするだけ。
  • MakeToken はそのキーを返すだけ。
  • TryRetargetToken は新しいキーを返すだけ。
  • TryRemoveToken は空を返すだけ。

その列は、機能面ではあらゆる点で RecordId@Tab と同じになり、あなたに残されているのは 見た目 だけです: @type に自分自身の名前で表示され、その列だけにセルウィジェット(§4.13)やキャンバス形状(§4.7)を割り当てることもできます。エイリアスのために別の契約は必要ありません。

構造的な制約が二つあります:

  • ペイロードに ; は使えません。 Core は、あなたのパーサーやこれらのフックがテキストを見る 前に、リストセルを要素に分割します。そのため値の中にセミコロンがあると、二つの要素に分断されてしまいます。(ラッパー型も、同じ理由で同じ制約を持ちます。)
  • @target は実在するシートのタブを指していなければなりません——RecordId@Tab とまったく同じです。コードレジストリの仮想タブは UnknownTargetTab として拒否されます。この制限があるからこそ、未解決参照の報告・最も近い候補の提案・名前変更の伝播が、Core 自身の変更されていないコードのままで機能します。

五つのフックはいずれも例外を投げてはいけません: 解釈できないものには false または null を返し、書き換える際には常に残りの部分を保存してください。

整数のキー空間に対しても機能します。 あなたの @target が指すタブが RecordId ではなく IntId をキーにしている場合でも、あなたのコードで変える必要のあるものは何もありません——あなたのフックが受け渡しするキーは、単にテキストとして書かれた整数になるだけです。どちらのキー空間と比較するかを決めるのは、あなたの型ではなく 対象タブ自身のアイデンティティ です。

  • 検証、最も近い候補の提案、名前変更の伝播、エッジ、ピッカー、孤立検出は、すべて同じように機能します。
  • そこで Core が追加してくれる一つの配慮: 整数は複数の綴り方ができるため、名前変更は TryRewriteKeys に対して、正規形とともに その要素に実際に現れている綴り も渡します(0077 はどちらも 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 として表面化します)と衝突してはいけません。
  • マーカーは 列ごとのメタデータ のためのものであり、新しいデータの形のためのものではありません——マーカーが持つのは自身のセルの検証であり、行全体ではありません。カスタムマーカーの行は、エクスポート/ラウンドトリップの際にそのまま保持され、あらゆる構造編集(追加/削除/移動/名前変更)の際にその列と一緒に移動します。
  • コード生成はマーカーの値をベイク しません(@overlap と同様、これらは検証/表示用のメタデータに過ぎず、スキーマフィンガープリントからは見えません)。

4.6 「シート作成」テンプレート(オプトイン)

Create sheet の流れには、二つの組み込みテンプレート——コア型のみを使うアイテムシートと、@enum 定義シート——に加えて「from scratch」が同梱されています。ドメインテンプレート——あなたの 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行の雛形である組み込みの item 例とは異なります。あなたのドメイン型はすでに登録されているため(プラグインがロードされているため)、作成されたシートはただちに再インポートに成功します。
  • 表示文字列はあなた次第です。 プラグインは自身のテキストを所有します(サンプルパッケージはドメイン語彙のガードの対象外です)——Core の Loc キーに縛られることはありません。
  • マルチタブのテンプレートは、そのすべてのタブを作成したうえで 一度だけ 再インポートするため、タブをまたぐ参照はまとめて解決されます。Create パネルは、これらについてはタブ名フィールドを隠します(タブ名はテンプレートによって固定されているためです)。
  • キー、空の表示名、ゼロ個のタブ、空のタブ TSV は却下されます(Register が例外を投げ、PluginRegistrationConflict として表面化します)。

4.7 タブ単位のキャンバスオーバーライド(オプトイン)

Data Studio のキャンバスは、何を描画するかを自力で決定します: レコードを一つ開くと——それが terminus です——参照インデックスを外向きにたどり、そのレコードが消費するものすべてを集め、結果を左から右へ配置します。これはプラグインなしでも機能します。

プラグインが追加するのは、core が 見ることも知ることもできないもの です:

  • シートのレコードではない識別子、
  • 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");
    }
}
  • 登録はタブ名単位です。 登録していないタブにも、core のクロージャによるキャンバスは表示されるため、プラグインがすべてのシートを網羅する必要はありません。重複したタブ、空のタブ名、null のオーバーライドは拒否されます(Register が例外を投げ、PluginRegistrationConflict として表面化します)。
  • 追加するのであって、置き換えるのではありません。 どのレコードが表示されるかは、クロージャが答えます。すでに画面上にある (タブ, キー) と同じ仮想ノードはドロップされます——実在するレコードが勝ちます——そのためオーバーライドは、シートに存在するレコードを勝手に作り出すことはできません。オーバーライドにできるのは、シートの行を一切持たない識別子を持ち込むことです。
  • 名前だけは例外です。 表示のヒントはアイデンティティではなく見た目の情報であるため、すでに存在するレコードにも 適用され、画面にまったく表示されていないレコードに名前を付けることもできます——connect ピッカーはそれらを読み取るため、カードのサブタイトルとピッカーの行が同じことを言うのはそのためです。空の名前は無視され(「デフォルトを使う」のと同じ扱いです)、あるレコードに対して最初に付いた名前が優先されます。
  • エッジは自分自身のノードを連れてきます。 追加のエッジの片端が画面にない場合、そのリンクが宙に浮かないよう、ノードとして追加されます。どちらかの端が空のキーであるエッジは無視されます。
  • セルの場所と、矢印が指す向きは、異なることがあります。 デフォルトでは、fieldName で指定されたセルは、出発側のレコードにあると仮定されます。到着側にある場合は、代わりに fieldOnTarget: true を渡してください——発行されたイベントのワイヤーは event → record の向きで描画されますが、そのテキストはレコード自身の列にあります。これにより wire inspector は、何もないところではなく、実際のセルを指すようになります。
  • 循環: core が見えるものにはマークを付け、あなたは知っているものを宣言します。 あなたの追加エッジがループを閉じる場合、キャンバスはその戻りエッジを自力で分類し、ダッシュ線で描画します。ある循環が 問題 かどうかを判断するのはドメインバリデーターの仕事です(§3)。キャンバスはあくまで表示のための材料であり、検証を行うことは決してありません。
    • isCyclic は、レイアウトには触れずに、そのワイヤーを 表示上 循環としてマークします。
    • cyclicNote は、あなたにしか分からないこと(たとえば減衰値など)を運びます——ラベルは列名のままにし、説明は note の方に入れてください。
  • レイヤーのヒントには二種類あります。
    • SetLayer は絶対位置です——列 0 が最も左で、負の値はさらに左になります。
    • SetLayerRelative は terminus からの相対位置です(−1 はその一つ左の列です)。これは通常、固定されたステージが意味することそのものです: この方式なら、チェーンが浅くても深くても同じ絵になり、ステージ同士がぶつからないようにするために terminus 自体を固定する必要もありません。
    • 相対ヒントは、どのヒントによっても動かされる 前の terminus の列を基準に解決されるため、ヒントを追加する順序が結果を変えることはありません。結果がゼロより左に出る場合は、絵全体が右にシフトします。
    • 画面にないノードへのヒントはドロップされ、あるノードに対して最初のヒントが優先されます。
  • 失敗は封じ込められます。 Augment は try/catch の内側で実行されます: 例外は英語のコンソール警告になり、core の絵はそのまま残ります——ウィンドウが壊れることは決してありません。
  • 拡張されても、あなたのコードは壊れません。 初回リリース以降に追加されたすべての capability は、末尾の引数か新しいメソッドであるため、以前のサーフェスに対して書かれたオーバーライドは、そのままコンパイルでき、まったく同じように振る舞います。

(完全なオーバーライドについては Assets/SheetForge.PluginDemo/Graphing/ExampleReactiveAugmenter.csExamplePipelineAugmenter.cs を参照してください——レコードの周囲にイベントノードとコードブロックを生やす反応の例と、固定ステージがそれぞれの列に固定される詠唱の例です。)

4.8 コードレジストリ — コードの中に存在する参照先(オプトイン)

参照先の中には、そもそもシートでオーサリングされないものもあります: あなたのランタイムがディスパッチする先の実行アトムです。これらを ロックされた仮想タブ として登録すると、オーサリングサーフェス上に読み取り専用で表示されるようになり、それらを指すエッジが壊れているものとして描画されるのを防げます。ISheetForgeCodeRegistryPlugin を実装してください:

using System.Collections.Generic;
using SheetForge.Core.Graphing;
using SheetForge.Core.Plugins;
 
public sealed class SkillsPlugin : /* ... */, ISheetForgeCodeRegistryPlugin
{
    public void RegisterCodeRegistries(CodeRegistryCatalog catalog)
    {
        catalog.Register(new CodeRegistrySource("_Refs", new List<CodeRegistryEntry>
        {
            // key = the referenceable id · label = shown text · raises = optional related keys
            new CodeRegistryEntry("action.projectile", "Projectile launch", new[] { "impact_landed" }),
            new CodeRegistryEntry("effect.script",     "Script effect",     null),
        }));
    }
}
  • 消費される場所は三つです:
    • Data Studio のサイドバーは、その仮想タブを READ-ONLY の下に key/label/raises のグリッドとして表示します。
    • キャンバスオーバーライドは context.CodeRegistries を通じてエントリを参照できます。
    • そして node inspector は、あるエントリの Raises を一覧表示します。
  • これらのキーは、Studio の存在チェックにも加わります。 ターゲットが登録済みのキーであるエッジ——典型的には IEdgeContributor(§4)が宣言するか、あなたの shape が構築するもの——は、壊れた参照として描画されません。
  • インポートのバリデーターは、仮想タブを認識しません。 コードレジストリはオーサリングサーフェス側の概念なので、シートの列を RecordId@_Refs として型付けしないでください(インポートは UnknownTargetTab を報告します)。シートのデータをコードのアトムに結び付けるには、デモと同じ方法——type 列と、エッジコントリビューター/shape によるルックアップ——を使ってください。
  • 実在のシートと衝突しない名前を選んでください(デモでは _ を接頭辞にしています)。もし衝突した場合、Studio はどちらかをサイレントに隠すのではなく、サイドバーでその衝突をバッジ表示します。
  • 拒否されるケース: null のソース、空のタブ名、重複したタブ名は例外を投げます(PluginRegistrationConflict として表面化します)。nullRaises リストは空に正規化されます。Core は key / label / raises を不透明な文字列として扱うだけで、それらを解釈することは決してありません。

(Assets/SheetForge.PluginDemo/Graphing/ExampleCodeAtoms.cs を参照してください。)

4.9 Data Studio のグラフウィジェット(オプトイン、Editor アセンブリ)

ウィジェットとは、グラフキャンバスの上に表示される、あなた自身の UI の帯です——固定ステージの概観、集計バッジなど、ドメインが必要とするものは何でも構いません。core はウィジェットを 一切 同梱しないため、プラグインがそれを埋めるまで、この領域は空のままです。戻り値の型が VisualElement であるため、この契約は Editor アセンブリに置かれます(ISheetSourceProvider と同じ理にかなった非対称性です)。SheetForge.EditorSheetForge.Core を参照する Editor 側のアセンブリで実装してください:

using SheetForge.Editor.Studio;
using UnityEngine.UIElements;
 
public sealed class ExampleStageStripWidget : IStudioGraphWidget
{
    // context = Tab · ShapeId · ModeId · FocusRecordId · FocusRecord · Tables · References · CodeRegistries
    public bool AppliesTo(StudioGraphContext context) =>
        context.Tab == "ExampleSkills" && context.FocusRecord != null;
 
    public VisualElement Create(StudioGraphContext context)
    {
        var strip = new VisualElement();
        strip.Add(new Label("VALIDATE → CAST → COMMIT → DELIVER → APPLY"));
        return strip;                       // return null to add nothing
    }
}
  • 検出は自動ですTypeCache が、引数なしのコンストラクターを持つすべての実装を見つけます。登録の呼び出しも、紐付けるレジストリもありません。インスタンス化に失敗した場合はログに記録され、スキップされます。
  • 契約上、読み取り専用です。 コンテキストは、パース済みのテーブル、参照インデックス、コードレジストリを公開しますが、ステージング用のサーフェスは公開しません。グラフからのオーサリングは、それを仲介するインスペクターアクション(§4.10)の役割です。
  • 要素の内部に状態を持たせないでください。 ウィジェットはグラフが再構築されるたびに作り直されるため、状態は自分自身のオブジェクト側に保持してください。再構築は、キー入力ではなく、人間の操作の頻度にまとめられます。
  • 例外は分離されますAppliesTo / Create が例外を投げると、英語のコンソール警告になりますが、グラフはそのまま描画されます。

(Assets/SheetForge.PluginDemo/Demo/Editor/ExampleStageStripWidget.cs を参照してください。)

4.10 Data Studio のインスペクターアクション(オプトイン、Editor アセンブリ)

アクションとは、node inspector に追加されるボタンです——「このドメインが、このレコードに対して何をできるか」。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 がその回数分だけ分割されてしまいます——並列した列の場合、undo の途中で半端に有効な状態が現れることを意味します。null または空のリストは何もしません。
  • 正規のテキストを渡してください。 ステージングされたテキストは、反映時にインポーターが使うのと同じパーサーによって解析されます——つまり、シートに実際に入る内容を書いてください。
  • baseline にないキーは何もしません(真新しい、または解決できないレコードの場合): 何もサイレントに書き込まれることはありません。
  • サービス: FocusCell はグリッドを指定した座標までスクロールし、RequestRebuild は、何かをステージングした後の再描画を要求します。
  • 検出・ラベル・分離は、ウィジェットとまったく同じように動作します: TypeCache による検出、未登録の LabelKey に対する逐語的なフォールバック(空のキーは型名にフォールバックします)、そして AppliesTo / Execute を囲む try/catch です。

(Assets/SheetForge.PluginDemo/Demo/Editor/ExampleInspectorAction.cs を参照してください。その Editor アセンブリ——SheetForge.PluginDemo.Demo.Editor——は SheetForge.EditorSheetForge.Core、そしてプラグイン本体のアセンブリを参照しています。Editor 側の拡張に必要な配線はそれだけです。)

4.11 カラープリセット(オプトイン)

SheetForge は、自身のウィンドウを、少数の 色スロット の語彙(サーフェス、罫線、テキスト、意味を持つ色、ステージングのマーク)から描画します。プリセットは、自分が関心を持つスロットだけを再着色し、それ以外のスロットはすべて製品のデフォルトのままになります。ISheetForgeThemePlugin を実装してください:

public sealed class SkillsPlugin : /* ... */, ISheetForgeThemePlugin
{
    public void RegisterThemes(ThemeRegistry themes)
    {
        themes.Register(new SheetForgeTheme(
            "skills.forge",                       // registry key (unique; the built-in ids are reserved)
            "Forge (Skill demo)",                 // your own display string
            new Dictionary<ThemeColorSlot, uint>  // dark screens
            {
                { ThemeColorSlot.Accent, 0xff9a4d },
                { ThemeColorSlot.Canvas, 0x120d0a },
                { ThemeColorSlot.Text,   0xe8dccf },
            },
            new Dictionary<ThemeColorSlot, uint>  // light screens
            {
                { ThemeColorSlot.Accent, 0x9c4a10 },
                { ThemeColorSlot.Canvas, 0xf7f2ec },
                { ThemeColorSlot.Text,   0x2b1f16 },
            }));
    }
}
  • 色は 0xRRGGBB です。 Core はエンジンの型を一切参照しないため、ここに UnityEngine.Color は登場しません。最上位バイトは無視されます。半透明のサーフェス(バッジの塗り、モーダルのスクリム)は、スロットの色に固定のアルファ値を組み合わせて導出されます——設定するのは色であり、アルファ値ではありません。
  • 両方の画面を用意してください。 ダーク用のマップとライト用のマップの両方を渡してください。ユーザーの明るさの選択(エディターに従う / 常にダーク / 常にライト)が、どちらを使うかを決めます。省略したスロットは、その明るさにおける製品デフォルトにフォールバックするため、三つのスロットだけのプリセットもまったく問題ありません。
  • 登録しただけでは適用されません。 あなたのプリセットは、組み込みの DefaultHigh contrast の隣に、Preferences ▸ SheetForge ▸ Theme ▸ Colour preset に表示されます。実際に効果を持つのは、ユーザーが選んだものだけです。表示文字列はあなた次第です(Core の Loc キーは不要です)。
  • 空の id、重複、そして予約済みの組み込み id(defaulthighContrast)は拒否されます(Register が例外を投げ、PluginRegistrationConflict として表面化します)。
  • テーマが再スタイルできないもの: 私たちのウィンドウの内部に描画されるネイティブな Unity ウィジェット(ボタンの外枠、フィールドの境界線)は、引き続きエディターのスキンに従います——機能と制限 を参照してください。

4.12 グラフキャンバス上での編集(オプトイン)

Data Studio のグラフは、単なる絵ではなくオーサリングサーフェスです: 右クリックでレコードを作成し、それらを接続し、ワイヤーを切断できます(Data Studio を参照)。これらはすべて、通常の RecordId@Tab 列であれば、プラグインなしのプロジェクトでも機能します。以下の capability は、core が届かない範囲でこれを拡張します——どれも既存の契約を変更しないため、これらを無視するプラグインは変更なしにコンパイルできます。

capability がどのように発見されるか(まずここを読んでください)

capability は、それ自体だけで発見されることは決してありません。 ウィンドウは、すでに登録されているオブジェクトをキャストすることで、それらすべてを見つけます:

capabilityどこからキャストされるか何を追加するか
IAuthorableGraphShapeISheetForgeGraphPlugin が登録するキャンバスオーバーライド新しいレコードをどこに作成できるか
IAuthorableEdgeContributorISheetForgeEdgePlugin が登録するエッジコントリビューター一つのジェスチャーを 一つ のセル書き込みに変える
IBatchAuthorableEdgeContributor同じエッジコントリビューター一つのジェスチャーを 複数 のセル書き込みに変える
IVirtualNodeFactory同じエッジコントリビューターノードメニューに「もう一つ作成」を提供する
IEdgeSlotDeclarer同じエッジコントリビュータースキーマからは導出できない接続スロットを宣言する
IEdgeTokenEditor同じエッジコントリビュータートークンを説明し、キーでない部分を編集できるようにする

したがって、五つのエッジ側の capability に到達できるのは、そのクラスが(§4 の ISheetForgeEdgePlugin を通じて)IEdgeContributor として登録されている 場合だけです。あなたのドメインが独自のエッジを一切開かない場合でも、それは登録を省略してよい理由にはなりません——ContributeEdges を空のメソッドとして実装し、それでも登録してください。その空のコントリビューターこそが、公式にサポートされた参加方法です:

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

これらはすべて try/catch の内側で実行されます: 例外は英語のコンソール警告になり、その一つの機能だけが無効化されます。他には何も影響しません。

新しいレコードをどこに作成できるか — IAuthorableGraphShape

デフォルトの挙動は二つあり、それらは意図的に異なります。

  • 作成可能タブの一覧——この capability が置き換える基準であり、そもそもキャンバスが開くかどうかも決めます——は、フォーカスしているタブのスキーマが到達できるすべてのタブを、参照を 推移的に たどって網羅します。これはデータではなくスキーマから計算されるため、まだ一行もないシートでも成立します。二段階のリンクを介して届くタブには、段階を踏んで到達します——中間のレコードを作成すると、そのポートが現れ、次の一歩がカスケードに加わります。
  • リンク のカスケード——空のキャンバスで実際に目にするピッカー——はより狭く、現在画面に描画されているポートが指しているタブから始まります。

いずれの場合も、コードレジストリが所有するタブと、キー列を持たないタブは除外されます。そこに新しいレコードを作っても、アイデンティティを持てないためです。

そのタブに登録されたオーバーライド(§4.7)は、このインターフェースを追加することで、両方のデフォルトを置き換えられます——そして、画面上のどのポートも受け入れないタブをオーバーライドが指定した場合、それは消えるのではなく、理由を添えたまま linking のカスケードに残ります:

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 を返すことは許されますが、意味がありません: ステージングされた値は、入力された値と同じ pre-flight 検証を通過し、Problems に表示されます。
  • 行ではなく、キーでアドレス指定してください。 EdgeCellWrite は(タブ、レコード id、フィールド)で名指しします。行番号は書き込み時に再解決されるため、ステージングされた計画は、行が移動しても生き残ります。
  • あなたが呼び出されるのは、ジェスチャーの最中です。 両方のメソッドは try/catch の内側で実行されます——例外は英語のコンソール警告になり、その一つの機能だけを無効化します。他には何も影響しません。
  • シートではなく、コンテキストに尋ねてください。 CellText は、ステージング 込み の値を返すため、連続して作られた二つのリンクは互いを認識できます。代わりにパース済みのテーブルを読んでしまうと、最初の一つを見逃してしまいます。

一つのジェスチャーで複数のセルを変更する — IBatchAuthorableEdgeContributor

データの中には、一つの項目が 並列した列 にまたがって保持されるものがあります: stepDelays | stepTargets | stepCounts では、各列のインデックス i が一つのステップに対応します。そこにリンクを追加するには、すべての列を同時に伸ばさなければならず、そうしなければ列ごとの長さが食い違ってしまいます——単一セルの計画では避けられない、中途半端に有効な状態です。この capability は IAuthorableEdgeContributor兄弟 です(サブクラスではありません)。そのため、単一形式しか持たないコントリビューターには影響しません:

using SheetForge.Core.Edges;
 
public sealed class ExampleStepContributor : IEdgeContributor, IBatchAuthorableEdgeContributor
{
    public bool TryPlanConnectMany(EdgeAuthoringContext context, string fromTab, string fromRecordId,
                                   string toTab, string toRecordId,
                                   out IReadOnlyList<EdgeCellWrite> writes)
    {
        writes = new[]
        {
            new EdgeCellWrite(fromTab, fromRecordId, "stepTargets", Append(context, fromTab, fromRecordId, toRecordId)),
            new EdgeCellWrite(fromTab, fromRecordId, "stepDelays",  AppendDefault(context, fromTab, fromRecordId)),
        };
        return true;
    }
 
    public bool TryPlanDisconnectMany(EdgeAuthoringContext context, RecordEdge edge,
                                      out IReadOnlyList<EdgeCellWrite> writes) => …;
}
  • 全部か、無かのどちらかです。 リスト内のすべての書き込みは、一つ のネイティブ Undo ステップとしてステージングされます。一つでも書き込めないもの(該当する行がない、読み取り専用のソース、パイプラインの実行中など)があれば、何もステージングされません。
  • バッチが優先されます。 一つのクラスが単一形式とバッチ形式の両方を実装している場合、ウィンドウはバッチ形式にだけ尋ねます——一つのジェスチャーが二つの異なる答えを持つことは決してありません。コントリビューターは引き続き登録順に尋ねられ、計画を立てた 最初の ものが採用されます。
  • すべての書き込みにはアドレスが必要です。 空のタブまたはフィールドを持つ書き込みを含むリスト(あるいは空のリスト)は、「計画なし」として扱われます。
  • リンク解除は、連鎖の上で実行されます。 一つのカード上の複数のワイヤーが、一つのジェスチャーで切断されるとき、あなたが読み取るコンテキストには、このジェスチャーにおけるそれ以前の計画がすでに反映されています。そのため、同じセルから二つのトークンを切り取ると、両方とも削除されます。単一形式の契約には、その中間的な値を受け取るための手段がありません——この capability が、その制限を取り除く方法です。
  • 作成中のレコードを、ターゲットにすることはできません。 「一つのジェスチャーで作成してリンクする」フローでは、書き込みアドレスは、新しい行がセッションに入る に解決されます。そのため、作成中のレコードを狙った計画は成立せず、ジェスチャー全体が正直に失敗します。すでに存在する行(並列列のケース)を狙うことには影響しません。

「もう一つ」を作成する — IVirtualNodeFactory

「もう一つ」が新しい行ではなく、複数のセルそれぞれにもう一つずつ要素を追加すること を意味する場合、キャンバスはそのジェスチャーを自力で作り出すことができません。作成できる種類を宣言し、いずれかが選ばれたときにセルへの書き込みを返してください:

using SheetForge.Core.Edges;
 
public sealed class ExampleStepContributor : IEdgeContributor, IVirtualNodeFactory
{
    // Called every time the node menu is built — keep it cheap and side-effect free.
    public IReadOnlyList<VirtualNodeKind> KindsFor(EdgeAuthoringContext context, string tab, string recordId)
        => tab == "ExampleSkills"
            ? new[] { new VirtualNodeKind("step", Loc("Add a step")) }   // your own translated string
            : null;
 
    public bool TryPlanCreate(EdgeAuthoringContext context, string tab, string recordId,
                              VirtualNodeKind kind, out IReadOnlyList<EdgeCellWrite> writes)
    {
        writes = null;
        if (kind.Id != "step") return false;                            // not mine → nothing happens
        writes = new[] { … };                                           // one element appended per column
        return true;
    }
}
  • ラベルは、すでに翻訳済みのものを渡してください。 Core はそれを翻訳しません——あなたのパックが解決した文字列を渡してください(§4.14 を参照)。ラベルに / を含めるとサブメニューになるため、自分の項目をグループ化できます。
  • tab は、仮想タブ名または空である場合があります。 あなたのキャンバスオーバーライドが画面に置いたノードは、シートに存在しているわけではありません。それでもメニューはあなたが宣言したものを提供します。あなたが書き込むセルの名前は、ノードのアイデンティティではなく、あなたの 計画によって決まるからです。コードレジストリが所有するタブは除外されます。
  • 一つの Undo ステップで、全部か無かです — 上記のバッチ capability と同じルールです。false は、何もステージングしません。

接続スロットを宣言する — IEdgeSlotDeclarer

接続スロットは、通常スキーマ(RecordId@Tab 列)から得られます。あなたのオーバーライドが画面に置いたノードには列がなく、コントリビューターのエッジは、リンクがすでに存在して初めてスロットを明らかにします——つまり、最初の リンクには始まりの場所がありません。代わりにスロットを宣言してください:

using SheetForge.Core.Edges;
 
public sealed class ExampleStepContributor : IEdgeContributor, IEdgeSlotDeclarer, IBatchAuthorableEdgeContributor
{
    // Called per card and per port gate — keep it cheap and side-effect free.
    public IReadOnlyList<DeclaredSlot> DeclareSlots(EdgeAuthoringContext context,
                                                    string nodeTab, string nodeRecordId)
        => nodeTab == "#step"
            ? new[] { new DeclaredSlot("target", "ExampleEffects", /*isList*/ false) }
            : null;
}
  • 名前には二つの役割があります。 そのノードの中で一意でなければならず、かつ、そこに描画するエッジの FieldName と一致していなければなりません——スロットの検索とワイヤーのアンカリングは、どちらもこの名前で照合されます。もしシートの列にすでにその名前がある場合は、シートの方が優先され、あなたの宣言は静かに無視されます。
  • 宣言することは、計画することとは違います。 宣言されたスロットは、あなたの計画(IAuthorableEdgeContributor またはバッチ形式)を通じて接続されます。計画なしに宣言すると、ポートは開きますが何もステージングされません——両方を実装してください。
  • シートの行を持たないノードにも、ポートは開きます。 タブがシートではないノードについては、ウィンドウはその名前の行を探しません。書き込みアドレスはあなたの計画から得られ、ステージング時に検証されます。

トークンが 何を語っているか を編集する — IEdgeTokenEditor

リンクとリンク解除は、トークン全体を動かします。多くの場合、トークンはキー以上の情報を持ちます: attack:add:10 は、stat その量の両方を表しています。同じコントリビューターにこの capability を追加すると、wire inspector に、その残りの部分——キーではない部分——のための行が一つ増えます:

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) を構築すれば、その行は描画されません——core の参照(そのキーがトークン全体と 一致する もの)は、コードを一切書かなくても、この挙動になります。

そもそも自分のワイヤーを編集可能にする

ワイヤーは、それが書き込まれているセルの名前を持っている場合にのみ編集できます。core は、自身が読み取る参照についてはこれを自動的に埋めます。あなたが追加する追加のエッジ(§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;
    }
}
  • ウィジェットは入力の形を作り、パーサーが意味を所有します。 あなたがコミットするものは何であれ 正規のシートテキスト です。それは、入力された値と同じ pre-flight 検証を通過し、問題は Problems パネルに表面化します。ウィジェット自身が検証を行う必要はありません。
  • 意図的に、二つのコミット経路があります。 Commit(リストからの選択、スライダーを離す、フォーカスアウト)は一つの Undo ステップを作り、CommitTyping(キー入力ごと)は一連の入力を一つのステップにまとめます。これらを一つの呼び出しに統合してしまうと、一文字ごとに Undo ステップが乱発されるか、二つの異なる選択が一つにマージされてしまいます。
  • null を返すと、そのセルを辞退します——組み込みのウィジェットが代わりを引き受けます。あなたが扱わない形(あなたの型の List<T>、任意フィールドなど)に対する、正直な答えです。context.Type(パース済みの @type トークン)には、判断に必要なすべてが含まれています。
  • ReferenceKeys(tab) は、組み込みの参照ピッカーが使うのと同じ候補リスト(投影されたキー ∪ コードレジストリのキー ∪ ステージングされた新規行のキー、ソート済み)を渡してくれます——自分で集める必要はありません。同じドロップダウンで人に 選ばせる には、StudioKeyPicker.Show(screenAnchor, tab, candidates, picked) を呼び出し、返されたキーをコミットする前にあなた自身の記法へ組み込んでください。(レコードの作成、セルを空のままにすること、リストの複数トグルは、組み込みの参照セル自身のルールであり、このファサードには含まれていません——セルのテキスト全体を所有するウィジェットは、それらの判断も自分で持つことになります。)
  • 組み込みの型名を、自分のものだけでなく乗っ取ることもできます。 登録済みウィジェットの分岐は 最初に 実行されるため、TypeName => "float" とすれば、実際にすべての float 列の生テキストボックスを置き換えられます——これが、スライダーやパーセンテージフィールド、単位付きのボックスを実現する方法です。二つ注意点があります:
    • これはプロジェクト内のその型の すべての 列に適用されるため、context.FieldName / context.Tab を読んで、意図していない列には null を返すことでスコープを絞ってください。
    • あなたがコミットするものは依然として正規のシートテキストであるため、スライダーは、パーサーが読み戻すのと同じ形でその値を描画しなければなりません(ラウンドトリップが期待する float の綴りについては CanonicalValueRenderer.RenderFloat を参照してください)。
  • 衝突は警告され、検出は自動です。 他のすべての契約と同じ TypeCache による検出です。二つのプロバイダーが同じ型名を主張した場合、最初に見つかったものが勝ち、コンソール警告が両方の名前を挙げます。CreateEditor が例外を投げた場合は捕捉され、警告が出た上で、そのセルは組み込みのウィジェットにフォールバックします。
  • 一つ書く前に、hint で足りるか確認してください。 ドロップダウン、複数行ボックス、スライダー、トグル、カラーピッカー、カーブエディター、グラデーションエディターだけで十分なら、代わりに StudioCellEditorHint(§4.16)を登録してください——ウィジェットのコードは不要で、ブラウザーでも動作します。優先順位は、この契約 → hint → core のデフォルトの順です。そのため、どの型もウィジェットに主張されていない場合、あるいは主張したものが辞退した場合に、hint がそのセルに適用されます。

4.14 プラグインの UI 文字列(オプトイン)

あなたのパックが表示するラベル——インスペクターアクション、ウィジェットのキャプション、§4.16 の宣言的サーフェス——を、ユーザーの言語に対応させることができます。言語キーごとに文を登録してください。Loc.Tr は、製品のテーブルを見る前にこのオーバーレイを参照し、ブラウザーの t() も同様です:

using System.Collections.Generic;
using SheetForge.Core.Model;
using SheetForge.Core.Plugins;
 
public sealed class ExampleLocStrings : ISheetForgeStringsPlugin
{
    // Prefix keys with your pack name so packs never collide.
    public const string BrakeActionKey = "plugin.skillsDemo.action.setBrake";
 
    public void RegisterStrings(StringOverlayRegistry strings)
    {
        strings.Register(BrakeActionKey, new Dictionary<string, string>
        {
            { "en", "Set reaction brake to 0.25s" },
            { "ko", "반응 제동을 0.25초로 넣기" },
        });
        // Or one language at a time: strings.Register(key, "en", "…");
    }
}
  • この契約は Core にあるため、メインアセンブリに置いてください。 両方のホストがあなたのパックのラベルを表示し、ブラウザーはメインの DLL しか読み込まないため、Editor 側のコンパニオンアセンブリに置かれた文字列プラグインは、Web アプリでは生のキーを表示させてしまいます。
  • 登録は任意です。 未登録のキーは、そのまま逐語的に表示され続けます——この契約はアップグレードのための経路であり、必須ではありません。
  • 言語は 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 は Export と Push が書き戻すものなので、これが TryParse を一文字単位で正確に打ち消していなければ、「シート → インポート → エクスポート → シート」というラウンドトリップが、誰も編集していないテキストを書き換えてしまいます。書き出し時に(上記のように)\r\n\n に正規化しておくことが、Windows で作成された値が、エクスポートを重ねるたびに二つの綴りの間を行き来してしまうのを防ぎます。パースした値を描画し、それを元のセルのテキストと比較する、たった一つのテストがあれば、これを固定するには十分です。

2. セルに、本物のエディターを与える。 \n でエスケープされた値は、一行のボックスに入力するには不快なものです——これはまさに §4.13 の出番です。"Prose" に対して IStudioCellEditorProvider を登録し、複数行の TextField(multiline = true)を返してください。本物の改行で値を表示し、再エスケープしてコミットします。キー入力ごとではなく、Commit によるフォーカスアウト時のコミット(編集セッションごとに一つの undo ステップ)にしてください。

3. この慣習が届かない、唯一の場所を知っておく。 誰かが Google シートの中で直接 Alt+Enter を押すと、ライブのセルに本物の改行が生まれてしまい、そのセルは、次回の取得時に、それを指す座標とともに拒否されます。この拒否は正直であり、修正可能ですが、それでも拒否であることに変わりはありません——そのため、あなたのチームのライターがスプレッドシート自体でプローズを執筆する場合は、長いテキストは \n で書くのだとあなた自身のドキュメントに明記するか、あるいはステップ 2 の Data Studio のセルウィジェットの中で執筆してもらってください。そこではエスケープが自動的に行われます。

4.16 宣言的オーサリングサーフェス(オプトイン)

§4.9、§4.10、§4.13 は VisualElement を返します。これはまさに、それらがエディター専用である理由です: ブラウザーは UIToolkit の型を読み込めないため、その方法で書かれた拡張は、一方の画面にだけ存在し、もう一方には存在しません。

この契約は、同じニーズに データとして 答えます。あなたが記述するのは外殻——id、ラベルキー、配置、トーン——だけであり、述語効果 だけをデリゲートとして渡します。一度登録すれば、エディターの UIToolkit レンダラーと、ブラウザーの React レンダラーの両方が、それを同じように描画します。

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

この語彙は、意図的に境界が定められています——それが成長するのは追加によってのみであり、挿入によってではありません。そのため、既存の登録はその意味を保ち続けます。

  • 五つの配置 がアクションに用意されています: InspectorRowContextMenuTopbarMenuColumnHeaderMenuCanvasNodeMenu
    • それぞれが、その場所が知っていることでコンテキストを満たします——行の配置はレコードを、列の配置は列名を、キャンバスの配置はノードのレコードを運びます——そして残りは空のままになるため、ある配置が提供しないフィールドを読む前にはガードしてください。
  • パネルやバッジのための、十三のノード種別: RowLabelChipBadgeButtonRuleHeadingKeyValueTableListProgressInputLink
    • これらは静的なファクトリー(StudioUiNode.Label(…).WithTooltip(…))を通じて構築されるため、ノードは不変であり、その種別にとって意味のあるフィールドだけが設定されます。
  • 七つのセルエディター・アーキタイプ: Dropdown(候補はあなたが渡します)、MultilineTextSlider(範囲はあなたが渡します)、Toggle(二つの正規テキストはあなたが渡します)、ColorPicker(#RRGGBB / #RRGGBBAA)、そして CurveEditorGradientEditor(セルのテキストは、シート構文にある正規のカーブ/グラデーション記法です——たとえば CurveValue.Render() を通じてその記法を書き出す型を持つパックが、これらを宣言できます)。組み込みの ColorAnimationCurveGradient 型もまったく同じ仕組みで配線されています——BuiltinCellEditorHints がその三つの hint を保持しています——そしてホストはパックの登録を 先に 参照するため、それらの型名のいずれかに hint を登録すると、拒否されるのではなく組み込みの選択を上書きします。エディターでは、最後の三つのアーキタイプは Unity のカラー・カーブ・グラデーションフィールドであり、ブラウザーではアプリ自身のエディターです。それらのいずれかを持つ型の List<> は、両方のホストでチップエディターになります。Plugin Demo の Falloff 型はまさにこれを行います: そのパーサーは CurveValue.TryParse でセルを読み取り、一つの hint 登録だけで、Unity ではカーブフィールドを、ブラウザーではカーブエディターを得ます。
  • レイアウトの数値は、どこにも登場しません。 ピクセルや比率は、一方の画面の粒度をもう一方に漏らしてしまいます。あなたが指定するのは 何を 表示するかだけであり、それをどう配置するかは各レンダラーが決めます。

書く前に知っておくべきルール:

  • 変更は、あなたが手で行うのと同じ扉を通ります。 StudioSurfaceContext は、アクションに対して正確に四つの権限だけを与えます——StageCellStageCells(複数セル、一つの Undo ステップ、オールオアナッシング)、FocusRecordRequestRebuild——読み取り専用の Tables / References / CodeRegistries に加えて。
    • そのため、プラグインの動詞は通常のステージング編集です: 一つの Ctrl+Z ステップであり、push するまで何もシートに届かず、同じ pre-flight を通ります。
    • ステージングのゲートも同様に適用されます——読み取り専用のソース、実行中のパイプライン、ワークブック由来のタブは、理由とともにこれをブロックします。
  • 述語は常時実行されます。 AppliesTo、パネルの構築、バッジの提供は、あらゆるジェスチャーとあらゆる再計算のたびに実行されます。渡されたスナップショットを読んでください——IO も、ネットワークも、長時間の計算もなしに。
  • 表示されていることは、実行されることを意味しません。 ホストは、呼び出し時に述語を再チェックします。メニューが描画されてから状況が変わっていた場合、答えは正直な no-op と再描画であり、二度目の失敗ではありません。ブラウザーも、古い id に対して同じことを行います。
  • ConfirmKey は、先に確認を求めます。 アクションに確認キーを与えると、ホストはそれを実行する前にその文を表示します——一度に多数のセルをステージングする動詞にふさわしい挙動です。
  • Link ノードが開けるのは http/https だけです。 このルールは、両方のホストが尋ねる、たった一つの Core の述語(StudioUiNode.IsAllowedUrl)であるため、何を開いて安全かについて意見が食い違うことはありません。ブラウザーは、アンカーを描画する前に同じ形をもう一度チェックするため、拒否が増えることはあっても、減ることはありません。
    • URL は、あなたが書いたとおりに正確に保存され、登録時に取り除かれるのではなく、開こうとした側で理由とともに拒否されます——それを書いたパックが、なぜ何も起きなかったのかを知ることができるようにするためです。
  • パネルは状態を持ちません。 それらは毎ティック作り直されるため、値の居場所はシート(ステージング済み)だけです。何もパネルを登録しなければ、そのペインはまったく描画されません。
  • 例外は分離されます——投げられた例外は英語のコンソール警告になり、その一つの機能だけを取り除きます。ウィンドウ全体には影響しません。

記述だけでは足りないとき — IStudioPanelProvider(Editor アセンブリ)

任意の描画、複合的な入力、複数ステップのフローには、ここに語彙がありません。それを新しく発明することは、永遠にミニチュアの UI フレームワークを保守し続けることを意味します。そのため、この天井は意図的なものであり、エスケープハッチは広く開かれています: Editor 側のコンパニオンアセンブリで IStudioPanelProvider を実装し、好きなように描画してください。

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

両方 を同じ Id で登録すると、各ホストは自分が描画できる方を採用します——エディターはリッチな方を使い、ブラウザーは記述的な方を使います。これが、「ブラウザーでできる範囲では、エディターでも完全に通用する」という原則を、二組目の契約なしに成立させている仕組みです。

Web 専用のバリアントはありません——リッチパネルがないことは、記述的な方が描画されることを意味し、パネルが消えることを意味しません。この要素は一回の再計算ティックだけ生存するため、状態も持ちません。

4.17 パイプラインを監視する(オプトイン)

クロスプロダクトのブリッジ、ドメインのテレメトリ、後続のジェネレーターは、しばしば、再パースすることなく インポートが何を生成したか を知る必要があります。IPipelineObserver を実装し、ISheetForgePipelinePlugin を通じて登録してください:

using SheetForge.Core.Model;
using SheetForge.Core.Plugins;
 
public sealed class ExampleImportObserver : IPipelineObserver, ISheetForgePipelinePlugin
{
    public void RegisterPipelineObservers(PipelineObserverRegistry observers) => observers.Register(this);
 
    public void OnImportCompleted(PipelineRunView view)
    {
        // view = Success · Tables · Diagnostics · SkippedTabs · EnumTabs — an immutable snapshot.
        if (!view.Success) return;
        // … cache what you need; do not hold the tables ...
    }
}
  • 観察は、結果を変えることができません。 あなたが受け取るのは一つの不変なスナップショットであり、返すものは何もありません。値を書き換えたり、診断を追加したりするフックは、意図的に存在しません: 値の解釈はセルタイプ(§2)の役割であり、ルール違反の報告はドメインバリデーター(§3)の役割です。参加の余地を観察契約に混ぜてしまうと、「オブザーバーは結果を変えられない」という原則が、実際には成り立たなくなってしまいます。
  • 明示的なインポートサイクルごとに一度、その終了時に、成功したか失敗したかを問わず呼び出されます。あなたがステージング中に再計算される pre-flight の投影に対しては実行 されません——サードパーティのコードが、キー入力の頻度に結び付けられることはありません。
  • 失敗した実行でも、パースできたものは報告されます。 Tables には、検証が失敗する前にパースできていたタブが含まれます——これは quarantine のフロー(Data Studio)が使うのと同じ材料であるため、オブザーバーは、失敗した実行についても、何もないのではなく、真実に基づいた姿を目にします。
  • 正直な二つの欠落があります。 オブザーバーは、インポートサイクル自身の完了地点から発火するため、そこに到達しない実行はまったく発火しません。
    • パイプラインが実行される前にインポートが中断された場合(アクティブな設定がない、Addressables のゲートが拒否した、など)。
    • codegen → コンパイルの区間が、コンパイルエラーによって中断された場合。
    • これはゼロ発火であり、誤発火では決してありません: 「インポートが試みられたこと」を知る必要がある場合は、これを Editor 側の ImportEvents バスと組み合わせてください。
  • 投げられた例外は、そのオブザーバーだけに分離されます(理由も収集されます)。インポートの出力は、一切変わりません。
  • 将来の観察ポイント(パース直後、エクスポートサイクルなど)は、登録済みのオブザーバーをキャストすることで発見される 兄弟 capability インターフェース として追加されるため、一つを追加しても、今日書かれた実装が壊れることはありません。

5. カスタムインポートソース(ISheetSourceProvider)

新しいソース(データベース、REST エンドポイント、独自形式)は、Core/Editor への変更ゼロ で参加します。Editor アセンブリで ISheetSourceProvider を実装してください。SourceProviderRegistryTypeCache を通じてそれを検出し、組み込みのソースと並んで、設定の「Source」ドロップダウンに表示されます。プロバイダーが答えるべき四つのこと:

  1. FetchCreateTabSource(settings) は、タブ名 → 生の TSV テキストを供給する ITabSource を返します(非同期。環境の問題は例外ではなく診断情報として扱われ、部分的な出力も許可されます)。
  2. 書き戻しCreateReflectTarget(dispatcher, settings) は、オーサリングディスパッチャーに接続する ISourceReflectTarget を返します(あなたのターゲットを組み立てるには、ディスパッチャーの公開されている Session / Callbacks / Baselines を使ってください)。あなたのソースが書き込み可能な場合にのみ、ターゲットを返してください。
  3. 可視性GetVisibility(settings) は、インスペクターがあなたのために表示すべき設定フィールドを返します。
  4. CanAuthor — 読み取り専用のソースには false を返してください。オーサリングウィンドウは、それぞれの編集用 UI を無効化します(Google ExportUrl と同様です)。

安定した文字列 IdsourceProviderId に永続化されます。組み込みのソースは、それぞれの Id として "LocalFile" / "GoogleSheet" を使います。sourceProviderId が空の場合は、組み込みの LocalFile がデフォルトとして解決されます。空の Id は、そのプロバイダーを UI から除外します(テスト用のプローブに便利です)。

プロバイダーは意図的に Editor アセンブリに置かれています——ソースは IO の境界であり、IO を Core の外に保つことで、その純粋性が保たれます(他の三つの契約は、純粋な Core です)。

5.5 自動化と連携のための公開ツール

登録契約に加えて、SheetForge を拡張するのではなく 操作する コードのために、五つの公開エントリーポイントが用意されています——CI スクリプト、ビルドフック、あなた自身のインスペクターボタン、あるいは同じシートから自分自身のアセットをベイクする第二の製品などです。

サイクルを実行するSheetForge.Editor.Pipeline.SheetForgeActions:

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

それぞれの呼び出しは、サイクル全体 です: 設定の解決、Addressables のゲート、排他制御、確認および承認のモーダル、プログレスバー、そしてドメインリロードをまたぐコード生成 → コンパイル → ベイクの再開まで、すべてを含みます。組み立てるべき半端なサイクルは存在しないため、うっかりゲートを飛ばしてしまうこともありません。

知っておくべきことが二つあります:

  • RunImportRunPush投げっぱなし(fire-and-forget) です——そのボディは async void です。エディターのメインスレッドがネットワーク IO でブロックされてはならないためです。そのため戻り値は完了を意味しません——完了を知るには ImportEvents.ImportCompleted を購読してください。
  • Push は引き続き承認モーダルを表示するため、無人のスクリプトが人を介さずに送信することはできません。

組み込みの機能と同じロックを取得する — カスタムソースプロバイダーが、自身のバックエンドに書き込む場合:

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

SheetForgeActions.IsBusy は、何も取得することなく同じ問いに答えます。ロック自体は意図的に internal のままです: もし公開されていたら、その End() を呼び出すことで 他の誰かの 実行を解放できてしまいます——scope はそれを不可能にします。解放できるのは、保持者だけだからです。

組み込みの機能と同じ方法で、書き戻しを完了させるAuthoringDispatcher.FinalizeReflectSuccess(writtenTabs) は、ISourceReflectTarget が到達しなければならない終端処理を実行します:

  • 書き込んだタブの保持プルーニング、
  • ClearUndo の確定境界、
  • そして自動的な再インポートです。

組み込みのローカルと Google の経路は、同じ本体を実行しているため、あなたのプロバイダーも、それを模倣するのではなく、まったく同じように終了できます。その隣にある BuildProjectedTabs() は、投影結果を タブごとの TSV——これから送信しようとしている内容——として渡してくれるため、プロバイダーは、書き込むことなくそれをプレビューしたり変換したりできます。空の writtenTabs リストは、ステージングをそのまま保つ no-op です。

製品自身の文を、あなた自身の UI に表示するImportReportText.Render(report)(Core.Tooling)は、コンソールには何も書き込まずに、人間に読みやすいレポートを文字列として返します。SheetForgeActions.RenderReportText(report) は、ユーザーの現在のエディター言語で同じことを行います。AuthoringDispatchCallbacks.RenderReport と組み合わせて使うことで、第二のオーサリングサーフェスも、製品が使うのとまったく同じ言葉で失敗を報告できます。

生成された型を知らなくても、ベイクされたタブを列挙するDefinitionDatabase.RecordsUntyped:

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

これは、第二のベイカー(同じシートを、別の製品が自身のアセットへと変換するようなケース)のために公認された経路です。private な records フィールドに対してリフレクションを行わないでください: そうしてしまうと、フィールド名が、宣言されていない契約になってしまい、コード生成がそのフィールド名を変更した日に、サイレントに壊れてしまいます。このリストは読み取り専用です——シートが正規だからです。このメンバーが存在する前に書かれた生成コードでは、デフォルトで空になります。一度再インポートすれば、上書きが出力されます。

生成されたクラスを、安全に拡張する — 生成される二つのクラスはどちらも partial であるため、派生メンバー(計算プロパティ、インターフェースの実装、演算子など)を、それらの隣にあるあなた自身のファイルに置くことができ、再インポートのたびにそれは生き残ります。そこにシリアライズされるフィールドを追加しないでください: ベイクされた ScriptableObject は、インポートのたびにシートから再構築されるため、あなたの部分だけがシリアライズするフィールドは、デフォルト値に戻ってしまいます。ある値がデータに属するのであれば、それは列に属するべきです。

意図的に閉じられたままのもの

上記のサーフェスが、公認された外縁です。以下は、公開することがどれほど便利に見えたとしても internal のまま残ります。それぞれが、利便性の境界ではなく、信頼または整合性の境界だからです:

  • 認証情報と署名 — サービスアカウントキーのロケーター、JWT/PEM/PKCS8 のプリミティブ、そして Google のアクセストークンプロバイダーです。これらを公開すると、どんなプラグインにも、あなたのスプレッドシートに対する bearer トークンを渡してしまうことになります。
  • push の生のチェーン(push ランナー、シートゲートウェイ、セルへの書き込み)——承認(IPushApprover)は、そのオーケストレーションの 内側 で強制されます。生の書き込み処理を公開すれば、承認のステップを持たないシートへの書き込みになってしまいます。
  • 送信前検証と、反映の書き込みエンジン — 外部コードは AuthoringDispatcher.Reflect() を通じてのみ入ってきます。これは、古いアンカーのチェック、事前検証、承認を経由します。その内側にある書き込みエンジンは、契約ではありません。
  • ベイク/コード生成の整合性チェーン(スキーマフィンガープリント、生成ソースのライター、孤立ファイルのクリーンアップ)と ビルド鮮度ゲート — これらを公開すると、ベイク状態を偽装したり回避したりすることが、一行で済んでしまいます。
  • 一時的な SO オーバーレイ — 「シートが唯一の信頼できる情報源である」という原則には、公認された例外がただ一つだけあります(インスペクターのテスト編集トグル)。それは意図的に API としては提供されていません。

もしあるワークフローがこれらのいずれかを必要としているように見えるなら、それが必要としているのは、リフレクションではなく、機能追加のリクエストです。

6. 生成コードの配置場所と名前空間

  • generatedCodeFolderどのフォルダーでも構いません(伴走用 asmdef の自己修復が、プラグイン型の参照を自動的に配線します)が、あなたのパッケージの中に置く(例: Assets/MyDomain/Runtime/Generated)のが最もすっきりします——そうすれば、生成型はあなたの enum やカスタム型と同じアセンブリでコンパイルされ、伴走用の asmdef は不要になります。
  • タブごとの置き場所: 生成型がすでにどこかに存在するタブは、そのまま 再生成されます——設定が別の場所を指していても、あなたのパッケージがコミットしている Generated フォルダーが正となり続けます。古い重複ファイルは自動的にクリーンアップされます(ログに記録され、サイレントになることはありません)。
  • generatedNamespace は、あなたの生成型を分離します(例: MyGame.Data)。型の検出は、名前空間ではなく、生成型に固有の SchemaFingerprint マーカーを使うため、どんな名前空間でも機能します。この値を変更すると、再生成が自動的にトリガーされます。
  • あなたのパッケージの Generated フォルダーを コミットするかどうか は、そのパッケージ自身の方針です。サンプルはこれをコミットしています(SheetForge.Generated にあるデフォルト名前空間の Example* クラス、タブ ExampleSkills/ExampleEffects/ExampleActions)。これにより、クローン直後でもすぐにコンパイルでき、Example* というクラス名の接頭辞——別の名前空間ではなく——が、あなたのプロジェクトの実際の Skills/Effects タブとの衝突を防いでいます。

7. ランタイムでの利用 — 「スクリプトを書くのではなく、組み立てる」

あなたのランタイムは、生成されたデータベースを読み取り、type の enum に応じてコードの原子的な要素にディスパッチします。

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

本当に手続き的な一回限りのロジックには、AssetRef を通じてスクリプトアセットを参照してください——SheetForge は参照を検証し、アドレス可能な形でベイクします(画像とまったく同じように)。それを実行するのは、ゲームの仕事です。

8. 他のアセットから SheetForge を検出する

別の アセット——SheetForge を拡張するのではなく、それと 連携する アセット(たとえば stat システムなど)——は、SheetForge がインストールされていることを検出できます。有料の Asset Store 製品は フォルダー製品 であり(package.json も UPM もありません)、versionDefines エントリを同梱できません。その代わりに、SheetForge の Editor アセンブリが、あらゆるビルドターゲットに対して SHEETFORGE スクリプティング定義シンボルを自己登録します。

(a) コンパイル時(推奨):

  • あなたの連携コードが 独自の アセンブリ定義を持っている場合は、その asmdef の Define ConstraintsSHEETFORGE を追加してください——そうすれば、そのアセンブリは SheetForge が存在するときにのみコンパイルされます。
  • SheetForge に触れるコードが、無条件にコンパイルされる必要のあるコードと同じアセンブリを共有している場合は、その部分だけを #if SHEETFORGE … #endif で囲んでください。

(b) エディター時(代替手段): コンパイル順序に頼れない場合は、リフレクションでプローブしてください——例えば System.Type.GetType("SheetForge.Editor.Pipeline.ImportEvents, SheetForge.Editor") != null ——そのうえで、(たとえば)完了通知バスを動的に配線してください。

SHEETFORGE「SheetForge がインストールされている」 ことを意味します。これは SHEETFORGE_ADDRESSABLES(SheetForge 自身のアセンブリにある、Addressables パッケージが存在するかどうかだけを示す内部的なバージョン定義)とは別のものです——後者をインストールの検出には使わないでください。

SheetForge が後で削除された場合も、この define は そのまま残ります(それを解除するウォッチャーは存在しません)。Project Settings ▸ Player で手動で削除してください。機能と制限 を参照してください。

それでも Core への変更が必要なもの

上記のすべては、Core への変更ゼロで参加できます。プラグインが、Core の変更なしには依然として できないこと:

  • マーカーの値を生成コードに出力する ことです——カスタムマーカーは検証/表示用のメタデータであり、それをコード生成の定数や属性として焼き込むことは、それを必要とする利用者が現れるまでは対象外です。
  • キー名変更の伝播を、教えられることなくカスタム記法の内部にまで届かせる ことです——名前変更されたレコードは、RecordId@Tab セル、そのリスト、そしてラッパーの要素については Core が自力で書き換えます。あなた自身の文法については、IReferencingCellType(§4.4a)を実装すれば、ペイロードを保ったまま書き換えられるようになります——これはオプトインであり、Core の変更ではありません。このオプトインを行わない場合、この境界はそのまま残ります: 名前変更が宙に浮いたキーをサイレントに直す代わりに、あなたのドメインバリデーターがそれを報告することになります。
  • プラグインが登録した C# の enum に、シートからメンバーを追加する ことです——enums.Register<T>() で登録された enum はコードが所有しているため、enum 定義シートでそれを拡張することはできず、Data Studio もその行を提供しません。シートに所有させたい場合は、その enum を enum シートへ移してください(シート構文 を参照)。

<> ラッパー 型(ICellWrapperType、§2 参照)と、カスタム 構造マーカー(IStructuralMarkerDefinition、§4.5 参照)は、どちらも Core への変更なしにパイプラインを拡張します。

関連ページ

  • シート構文 — 登録された型がシート上でどのように現れるか
  • Data Studio — キャンバスオーバーライド・コードレジストリ・ウィジェット・アクションがどこに現れるか
  • API リファレンス — すべての契約の完全なシグネチャ
  • オーサリングカーネル — エッジとエンジンのサーフェス
  • 機能と制限 — プラグイン拡張の境界(ラッパーの却下ルール、マーカーの制限)と予約された継ぎ目