Zum Inhalt springen
SheetForge

Plugin-Erstellung — eine Domäne hinzufügen, ganz ohne Änderungen am Core

Eine Domäne (Skills, Items, Quests, …) tritt SheetForge als separates Paket bei, das SheetForge.Core referenziert — Core referenziert es nie zurück.

Ein Plugin kann enums, benutzerdefinierte Zelltypen, Wrapper-Typen, Domänen-Validatoren, Graphkanten, Strukturmarkierungen, „Create sheet"-Vorlagen, komplette Importquellen, die Canvas-Overrides des Data Studio, Code-Registries, deklarative Authoring-Oberflächen, Widgets und Aktionen, Farb-Presets, benutzerdefinierte Cell-Widgets, Pipeline-Beobachter sowie eigene lokalisierte UI-Strings hinzufügen – die sechzehn Verträge unten.

„Eine Domäne hinzufügen = null geänderte Core-Zeilen" ist keine Richtlinie, sondern compilererzwungen. Eine Test-Assembly ohne InternalsVisibleTo (SheetForge.Tests.Consumer) implementiert fünfzehn der sechzehn – und die Capability-Interfaces daneben – allein über die öffentliche Oberfläche. Würde auch nur einer davon auf internal verengt, würde der Build fehlschlagen (CS0122). Der sechzehnte, die editor-only Rich-Panel-Ausweichmöglichkeit, gibt ein VisualElement zurück und wird daher stattdessen von einem editor-seitigen Test geprüft.

Die elf Core-Verträge sind reines C#. Das ist es, was einer einzigen kompilierten Plugin-DLL erlaubt, dieselben Slots im Unity-Editor und im Browser aufleuchten zu lassen (SheetForge Web) — Zusammenstellung und Isolation sind eine einzige gemeinsame Core-Funktion, und nur die Entdeckung unterscheidet sich je Host (Unitys TypeCache, der Scan der hochgeladenen Assembly im Browser).

Die fünf Editor-Verträge geben UIToolkit-Elemente zurück oder berühren Fensterzustand, daher existieren sie nur im Editor.

Alle sechzehn werden automatisch entdeckt — ein parameterloser Konstruktor ist die einzige Voraussetzung, ohne Assembly-Referenz, Registrierungsaufruf oder zu bearbeitendes Manifest:

VertragRegistriertOpt-in?
ISheetForgePluginenums + benutzerdefinierte Zelltyp-ParserDer Basisvertrag
ISheetForgeValidatorPluginDomänen-Validierungsregeln (spaltenübergreifend / tab-übergreifend)Optionale Erweiterung
ISheetForgeEdgePluginGraphkanten-Deklarationen, die der Core-Scanner nicht sehen kannOptionale Erweiterung
ISheetForgeMarkerPluginBenutzerdefinierte Strukturmarkierungen (spaltenweise @marker-Zeilen)Optionale Erweiterung
ISheetForgeTemplatePlugin„Create sheet"-Vorlagen (Tabs + Beispieldaten)Optionale Erweiterung
ISheetForgeGraphPluginCanvas-Overrides pro Tab für das Data StudioOptionale Erweiterung
ISheetForgeCodeRegistryPluginSchreibgeschützte Schlüsselräume, die im Code leben, als gesperrte virtuelle TabsOptionale Erweiterung
ISheetForgeThemePluginFarb-Presets für die SheetForge-Fenster (dunkel und hell)Optionale Erweiterung
ISheetForgeStudioPluginDeklarative Authoring-Oberflächen — Aktionen, Panels, Spalten-Badges, Cell-Editor-HintsOptionale Erweiterung
ISheetForgeStringsPluginDie UI-Strings Ihres Pakets, pro Sprache (ein Overlay, das vor den Produkttabellen konsultiert wird)Optionale Erweiterung
ISheetForgePipelinePluginPipeline-Beobachter — schreibgeschützte Benachrichtigung darüber, was ein Import erzeugt hatOptionale Erweiterung
ISheetSourceProviderEine komplette Importquelle (DB / REST / unternehmensintern)Unabhängig (Editor-Assembly)
IStudioGraphWidgetEin Domänen-Widget über der Data-Studio-CanvasUnabhängig (Editor-Assembly)
IStudioInspectorActionEine zusätzliche Schaltfläche im Data-Studio-Node-InspectorUnabhängig (Editor-Assembly)
IStudioCellEditorProviderEin benutzerdefiniertes Eingabe-Widget für einen Zelltyp im Data-Studio-GridUnabhängig (Editor-Assembly)
IStudioPanelProviderEin beliebiges UIToolkit-Panel im Studio — die Ausweichmöglichkeit neben der deklarativenUnabhängig (Editor-Assembly)

Das Referenzbeispiel ist ein selektiver Import. Das vollständig ausgearbeitete Beispiel (SheetForge.PluginDemo) wird als Unity-Paket unter Assets/SheetForge/Examples/SheetForgePluginDemo.unitypackage ausgeliefert — doppelklicken Sie darauf, oder klicken Sie im Fenster „Erste Schritte" (Tools ▸ SheetForge ▸ Getting Started, dem einzigen Ort, an dem Demo-Importe leben) auf Plugin Demo importieren, um es unter Assets/SheetForge.PluginDemo/… wiederherzustellen.

Bis Sie es importieren, ist es überhaupt nicht in Ihrem Projekt vorhanden — das Beispiel wird ausschließlich als dieses Paket ausgeliefert —, sodass seine Assemblies/Typen/Tabs/Adressen niemals mit Ihrem kollidieren. Die unten referenzierten Pfade (Assets/SheetForge.PluginDemo/ModifierCellParser.cs usw.) existieren, sobald Sie das Paket importiert haben.

(Ein zweites, pluginfreies Beispiel — SheetForge.CoreDemo — demonstriert die Pipeline nur mit integrierten Core-Typen.)

Die Erweiterungen erweitern das Basis-Interface, ohne es zu verändern — ein Plugin, das keine Validierung oder Kanten benötigt, bleibt von deren Existenz unberührt.

Sieben weitere Interfaces sind Capabilities statt Verträge:

  • Sie werden nicht eigenständig entdeckt.
  • Sie werden zusätzlich von etwas bereits Registriertem implementiert.
  • Der Core findet sie, indem er dieses registrierte Objekt castet.

Sechs werden aus einem registrierten Kanten-Contributor oder Canvas-Override gecastet — siehe §4.12 für die Entdeckungsregel und jede einzelne. Die siebte, IReferencingCellType, wird aus einem registrierten Zell-Parser gecastet. Sie verschafft Ihrer eigenen Notation dieselbe Referenzbehandlung, die RecordId@Tab erhält — siehe §4.4a. Jede von ihnen zu ignorieren, ändert nichts.

1. Paket-Einrichtung

Erstellen Sie einen Ordner mit einer eigenen .asmdef, die SheetForge.Core referenziert (plus SheetForge.Runtime, falls Sie Runtime-Lookups benötigen). Das ist alles. Die PluginRegistry des Editors entdeckt Ihre ISheetForgePlugin-Implementierung über TypeCache und ruft Ihre Registrierungsmethoden auf, und die Registrierung ist Ihr expliziter Code, kein Assembly-Scan.

Halten Sie die Implementierungen der Core-Verträge in dieser Hauptassembly. Eine editor-seitige Begleitassembly (die zusätzlich SheetForge.Editor referenziert) ist der Ort für die fünf IStudio*-/ISheetSourceProvider-Implementierungen — der Browser lädt nur Ihre Haupt-DLL, sodass ein in der Editor-Begleitassembly implementierter Core-Vertrag dort still fehlen würde.

1.1 Kompatibilität deklarieren (optional, eine Zeile)

Ein Assembly-Level-Attribut gibt an, für welche Generation des Plugin-Formats Ihre Assembly gebaut wurde, und welchen niedrigsten Host sie voraussetzt:

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
  • Das Weglassen ist unbedenklich. Eine Assembly ohne Deklaration wird als Generation SheetForgePluginFormat.Minimum ohne Host-Anforderung gelesen, sodass Plugins, die vor Einführung des Attributs geschrieben wurden, genau wie zuvor laden.
  • Die Beurteilungseinheit ist die Assembly, und eine abgelehnte Assembly verliert alle ihre Registrierungen. Eine Deklaration pro Typ würde einen unerklärten Nachbartyp durchrutschen lassen und Sie mit „abgelehnt, aber halb registriert" zurücklassen.
  • Die DLL ist der Richter, nicht der Katalog. Die Markt-Registry bewirbt dieselben zwei Werte (pluginFormat, minHost), sodass ein Eintrag vor dem Download gefiltert werden kann, aber das Gate liest das Attribut aus den verifizierten Bytes — ein Eintrag kann falsch sein, die kompilierte Deklaration nicht.
  • Eine Ablehnung ist eine PluginIncompatible-Diagnose, die benennt, was die Assembly deklariert hat und was dieser Host liest, kein stilles Verschwinden. Dies ist eine Kompatibilitätsdeklaration, keine Signatur: Integrität ist Aufgabe des Vertriebskanals (siehe Web-Plugin-Markt).
  • Die Generationsnummer bewegt sich nur, wenn das Plugin-Format selbst ersetzt wird. Rein additives Wachstum — ein neuer Vertrag, ein neues Mitglied auf einer Registry — bewegt sie nie, weil Ihr bestehendes Plugin ohne Neukompilierung weiterläuft.

2. Das Basis-Plugin: enums + benutzerdefinierte Zelltypen

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

Ein benutzerdefinierter Zelltyp von Anfang bis Ende

Implementieren Sie ICellValueParser (String → Wert). Um das stark typisierte Bake und den Export-/Push-Round-Trip zu vervollständigen, implementieren Sie zusätzlich ICustomCellType (CLR-Typ + Wert → kanonischer String).

Die Modifier-Mini-Grammatik des Beispiels (stat:op:value, z. B. 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;
    }
}

(Siehe Assets/SheetForge.PluginDemo/ModifierCellParser.cs für die vollständige Produktionsversion mit Op-Token-Validierung und Vorschlägen für die nächstliegende Übereinstimmung.)

@target bei benutzerdefinierten Typen funktioniert allein durch Registrierung: Deklarieren Sie eine Spalte als Modifier@Stats, und Ihr Parser liest context.Type.TargetName ("Stats"). Die Integritätsprüfung dieses Ziels (existiert der Tab? löst sich die Id auf?) obliegt einem Domänen-Validator — dieselbe Arbeitsteilung wie bei RecordId@Tab. Ein unregistrierter Typname mit @ bleibt weiterhin ein Fehler mit Vorschlag, sodass die Tippfehler-Sicherheit erhalten bleibt.

Typnamen, die der Core bereits besitzt. Die eingebauten Skalarnamen — int, float, bool, string, Enum, RecordId, IntId, AssetRef, Color, AnimationCurve und Gradient — sind vor jedem Plugin registriert. Ein Parser, der einen davon wiederverwendet, scheitert bei der Registrierung mit PluginRegistrationConflict — der eingebaute Typ bleibt bestehen, dieser RegisterCellParsers-Aufruf stoppt beim widersprüchlichen Parser, und die übrigen Slots des Plugins laden weiterhin —, sodass ein Paket, das einen eigenen Color- oder Gradient-Typ auslieferte, ihn umbenennen muss (siehe die Upgrade-Hinweise im Changelog). Speichert Ihr Typ eine Farbe, eine Kurve oder einen Verlauf, müssen Sie die Notation nicht neu implementieren: Die Core-Wertmodelle ColorValue, CurveValue und GradientValue stellen TryParse(text, out value, out error) und Render() bereit, CurveEvaluator / GradientEvaluator samplen sie genau wie Unity, und ein StudioCellEditorHint mit dem Archetyp ColorPicker, CurveEditor oder GradientEditor (§4.16) öffnet den nativen Editor für Ihren Typ in beiden Hosts.

Ein Wrapper-Typ von Anfang bis Ende (MyWrapper<T>)

Ein Wrapper ist eine generische Wertform — Pair<int> = 1~2 —, die mehrere innere T-Werte in eine Zelle packt. Sie besitzen nur die äußere Syntax (Trennzeichen, Arität); der Core parst das innere T rekursiv, sodass Pair<RecordId@Effects>, Pair<Enum<DamageType>> und verschachteltes Box<Pair<int>> einfach funktionieren, und darin enthaltene Referenzen werden vollständig validiert. Implementieren Sie ICellWrapperType und registrieren Sie es im selben RegisterCellParsers-Hook über 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());

Diese einzige Registrierung verschafft Ihnen:

  • Rekursive @type-Auflösung.
  • Stark typisierten Codegen (Pair<RecordRef> First;).
  • Bake.
  • Den Export-/Push-Round-Trip.
  • Referenz-Pass-Through — ein RecordId@Tab innerhalb des Wrappers wird integritätsgeprüft, bei Schlüssel-Umbenennung propagiert und bei Tab-Umbenennung umgeschrieben.

Ablehnungsregeln und der Vorbehalt zum ;-Trennzeichen sind in Tabellensyntax dokumentiert.

3. Domänen-Validatoren (opt-in)

Die Core-Validierung ist auf vier Arten festgelegt (Schlüssel, Referenzen, @overlap, Asset-Schlüssel). Für spaltenübergreifende Regeln („wenn type Custom ist, ist script erforderlich") oder tab-übergreifende Regeln (Prüfung der Bedeutung eines referenzierten Datensatzes) implementieren Sie 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'."));
        }
    }
}

Registrierte Validatoren nehmen automatisch sowohl an der Import-Validierung als auch an der Authoring-Vorab-Validierung teil. Regeln:

  • Melden Sie Verstöße in ctx.Errors als ImportErrorCode.DomainRuleViolation — werfen Sie niemals eine Exception (eine geworfene Exception wird isoliert und weitergegeben; die anderen Validatoren laufen dennoch weiter).
  • Füllen Sie alle vier Elemente — wo (CellCoordinate), was (ActualValue), warum (Expected), wie (Suggestion). Das „wie" wird wortgetreu als der umsetzbare Hinweissatz angezeigt.
  • ctx gibt Ihnen:
    • alle geparsten Tabellen (Tables),
    • Schlüsselindizes (KeyIndices),
    • Asset-Schlüssel (AssetKeysnull bedeutet, dass die Asset-Validierung übersprungen wurde).
  • Alles-sammeln und Kein-Teil-Zusammenbau werden automatisch vererbt.

4. Kanten-Contributor (opt-in)

Wenn Sie Tooling über dem Datengraphen bauen (oder möchten, dass eine zukünftige Graph-Canvas die Verbindungen Ihrer Domäne sieht), deklarieren Sie Kanten, die der Core-Referenz-Scanner nicht sehen kann — z. B. einen Stat, der innerhalb eines Mini-Grammatik-Werts referenziert wird:

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

Ein IEdgeContributor erhält einen schreibgeschützten tab-übergreifenden Kontext und fügt EdgeSpec-Elemente hinzu (Von-/Zu-Tab + Datensatz-Id, optionales Feld, Payload-Datensatz, Label). Contributor geben niemals Diagnosen aus — Kanten sind Projektionsmaterial, keine Validierung. Siehe Authoring-Kernel.

4.4 Rezept: ein benutzerdefinierter Typ, der einen Schlüssel in sich trägt

RecordId@Tab ist die eine Referenzform, die der Core versteht, und sie erhält Integritätsprüfung, Graphkanten, Vorschläge für die nächstliegende Übereinstimmung und Umbenennungs-Propagierung gratis dazu.

In dem Moment, in dem Ihre eigene Notation einen Schlüssel verschluckt — attack:add:10, stat.hp>50, fire@0.4 —, sieht der Core nur einen opaken String, sodass diese vier Dienste vor Ihrer Tür haltmachen. Drei Registrierungen bringen drei davon zurück. Schreiben Sie sie als Set: eine Mini-Syntax mit nur einem der drei ist genau die Form, die „importiert einwandfrei, aber nichts zeigt auf irgendetwas" erzeugt.

TeilVertragWas er wiederherstelltOhne ihn
1. IntegritätIDomainValidator (§3)Ein Schlüssel innerhalb Ihrer Notation, der nicht existiert, wird gemeldet, mit Koordinate und umsetzbarem HinweissatzEin Tippfehler importiert sauber und scheitert erst zur Laufzeit
2. SichtbarkeitIEdgeContributor (§4)Der vergrabene Link wird zu einer echten Kante: die Canvas zeichnet ihn, die Used by-Liste zählt ihn, der Referenz-Index indiziert ihnDie Verbindung existiert in den Daten und nirgends auf dem Bildschirm
3. Das „Wie"TextSuggestion.FindNearest innerhalb von Teil 1„Unknown stat 'atack'. Did you mean 'attack'?" — dieselbe Satzform, die die eingebauten Referenzfehler verwendenEine korrekte Diagnose ohne Möglichkeit, danach zu handeln
// 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."));
            }
    }
}

Verwenden Sie einen gemeinsamen Splitter für die Notation — Parser, Validator und Kanten-Contributor müssen sich einig sein, wo ein Schlüssel beginnt und endet, und drei private Kopien dieser Aufteilung sind es, wie sie auseinanderdriften. (Ein Wrapper-Typ, §2, bekommt dies gratis dazu: TrySplit ist der gemeinsame Splitter.)

Der vierte Dienst — Umbenennungs-Propagierung — braucht noch eine Sache, und es gibt zwei Wege, sie zu bekommen. Das Umbenennen eines Datensatzes schreibt referenzierende Zellen nur dort um, wo der Core den Schlüssel im Text finden kann. Das kann er für ein RecordId@Tab-Feld, eine Liste davon und einen Wrapper, dessen TrySplit den Schlüssel als Element offenlegt. Er kann die Substring-Grenzen Ihrer Grammatik nicht von sich aus erraten. Also entweder:

  • Sagen Sie es ihm — implementieren Sie IReferencingCellType (§4.4a), was dieses gesamte Drei-Teile-Rezept durch ein einziges Opt-in ersetzt und alle vier Dienste auf einmal wiederherstellt.
  • Oder akzeptieren Sie die Grenze, was zumindest ehrlich statt still ist: Teil 1 meldet den nun hängenden Schlüssel beim nächsten Import, mit Koordinate und Vorschlag.

Das obige Rezept ist weiterhin die richtige Antwort in einem Fall: wenn die Spalte kein @target hat, weil es keinen einzelnen Tab gibt, in dem der Schlüssel lebt. Genau das ist beim mitgelieferten Beispiel der Fall — List<Modifier> nennt kein Ziel, daher kann der Core nicht wissen, wo attack sich auflösen sollte, und ModifierStatEdgeContributor öffnet diese Kanten von Hand. Geben Sie der Spalte ein Ziel (List<Modifier@Stats>), und §4.4a übernimmt.

4.4a Der eigenen Notation volle Referenz-Parität geben (opt-in)

Implementieren Sie IReferencingCellType auf einem Parser, den Sie bereits registrieren, und eine MyType@Tab-Spalte hört auf, ein Sonderfall zu sein: Sie wird exakt wie RecordId@Tab validiert, vorgeschlagen, propagiert, gezeichnet, ausgewählt und indiziert.

Es gibt keinen neuen Registrierungskanal. Der Core castet die bereits in der CellParserRegistry vorhandenen Parser, genau so, wie Canvas-Capabilities aus registrierten Kanten-Contributoren gecastet werden (§4.12). Ein benutzerdefinierter Typ, der es nicht implementiert, verhält sich bit-genau wie zuvor.

Die fünf Hooks

Die fünf Hooks arbeiten alle mit einem Element: der gesamten Zelle bei einer Skalarspalte, oder einem durch ; getrennten Element bei List<MyType@Tab> — derselben Einheit, die Ihr ICellValueParser.TryParse erhält.

HookBeantwortetVerwendet für
bool TryGetTokenKey(elementText, out key)„Worauf zeigt dieses Element?"Mitgliedschaft — ist diese Zelle bereits mit diesem Datensatz verknüpft
string MakeToken(key)„Einen neuen Link zu diesem Schlüssel schreiben"Eine leere Zelle, oder das Anhängen an eine Liste. Füllen Sie die Nutzlast mit einem neutralen Ausgangspunkt; eine Authoring-Oberfläche darf keine Werte erfinden. Geben Sie null/leer zurück, und die Geste wird mit einem Grund deaktiviert, statt vorgetäuscht
bool TryRetargetToken(elementText, newKey, out newText)„Dies auf etwas anderes zeigen lassen"Die Auswahl eines anderen Datensatzes in der -Zelle, und das Neu-Verankern einer Verbindung auf der Canvas. Ändern Sie nur das Ziel — das Entfernen und Neu-Erstellen des Tokens würde die von einer Person eingetippten Zahlen zurücksetzen
bool TryRemoveToken(elementText, key, out newText)„Diese Verknüpfung lösen"Geben Sie leeren Text zurück, und das Element verschwindet (die Skalarzelle leert sich, das Listenelement wird entfernt); geben Sie nicht-leeren Text zurück, und so viel bleibt erhalten
bool TryRewriteKeys(elementText, renames, out newText)„Alle diese Schlüssel ersetzen"Der Umbenennungs-Durchlauf. Getrennt von TryRetargetToken, weil das eine eine einzelne Anweisung einer Person ist, während dies ein Sammeldurchlauf ist — und ein Element mit zwei Referenzen muss beide umschreiben

Die Text-Hälfte und die Wert-Hälfte

Fügen Sie dem geparsten Wert außerdem IRefBearingValue hinzu — die beiden Hälften erledigen unterschiedliche Aufgaben, und beide werden benötigt. Die Text-Hälfte kann keinen geparsten Wert sehen; die Wert-Hälfte kann die vom Autor eingetippte Notation nicht wiederherstellen:

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

Die Implementierung eines Interfaces fügt keine Felder hinzu, sodass das gebackene ScriptableObject und der generierte Code unverändert bleiben.

Was Sie durch ein einziges Opt-in erhalten — jedes davon ist der eigene Code-Pfad des Core, keine Neuimplementierung:

  • Integrität + Vorschläge — ein nicht existierender Schlüssel wird als UnresolvedRecordId gemeldet, mit Koordinate und „meinten Sie …", unter Teilung des Vorschlagsbudgets pro Feld mit eingebauten Referenzen.
  • Umbenennungs-Propagierung mit intakter Nutzlast — das Umbenennen von attack zu power schreibt attack:add:10 in power:add:10 um; der Operator und die Zahl gehören dem Autor und überleben.
  • Graph — der Link wird zu einer echten Kante mit Koordinaten: Er wird gezeichnet, der Node erhält einen Port, die Used by-Liste zählt ihn, und der Referenz-Index kennt ihn in beide Richtungen.
  • Der -Picker — die Zelle erhält dasselbe durchsuchbare Dropdown wie eine RecordId@Tab-Zelle, und die Auswahl eines anderen Datensatzes ersetzt nur das Ziel und bewahrt den Rest. Ohne die Registrierung lehnt der Picker ab, statt einen bloßen Schlüssel über Ihren Wert zu kleben.
  • Waisen-Erkennung und die exportierte Dropdown-Regel — eine Zeile, deren einziger ausgehender Link innerhalb Ihrer Notation lebt, gilt nicht länger als unverbunden, und eine Skalarspalte Ihres Typs erhält ein Datenvalidierungs-Dropdown über die Schlüssel des Ziel-Tabs (Quellen, Export & Push).

Die einfachste Verwendung ist ein Alias-Typ. Angenommen, der Wert ist nur ein Schlüssel, und der Zelltext ist dieser Schlüssel:

  • TryGetTokenKey trimmt.
  • MakeToken gibt den Schlüssel zurück.
  • TryRetargetToken gibt den neuen Schlüssel zurück.
  • TryRemoveToken gibt leer zurück.

Die Spalte ist dann in jeder funktionalen Hinsicht ein RecordId@Tab. Das Einzige, was Ihnen bleibt, ist die Darstellung: Sie erscheint unter ihrem eigenen Namen in @type, und Sie können ein Cell-Widget (§4.13) oder eine Canvas-Form (§4.7) allein an diese Spalte anhängen. Für einen Alias ist kein separater Vertrag nötig.

Zwei Einschränkungen, beide struktureller Natur:

  • Kein ; in der Nutzlast. Der Core teilt eine Listenzelle in Elemente auf, bevor Ihr Parser oder einer dieser Hooks den Text sieht, sodass ein Semikolon innerhalb eines Werts in zwei Elemente zerrissen würde. (Wrapper-Typen tragen aus demselben Grund dieselbe Einschränkung.)
  • @target muss einen echten Tabellen-Tab benennen, genau wie RecordId@Tab es tut — der virtuelle Tab einer Code-Registry wird mit UnknownTargetTab abgelehnt. Diese Einschränkung ist es, die es der Meldung nicht aufgelöster Referenzen, den Vorschlägen für die nächstliegende Übereinstimmung und der Umbenennungs-Propagierung erlaubt, unverändert die eigenen des Core zu sein.

Keiner der fünf Hooks darf werfen: Antworten Sie mit false oder null für alles, was Sie nicht interpretieren können, und bewahren Sie den Rest, wann immer Sie umschreiben.

Es funktioniert auch gegen einen Integer-Schlüsselraum. Schlüsselt der Tab, den Ihr @target benennt, über IntId statt über RecordId, ändert sich nichts an Ihrem Code — der Schlüssel, den Ihre Hooks zurückgeben und empfangen, ist einfach der als Text geschriebene Integer. Welcher Schlüsselraum zum Vergleich herangezogen wird, entscheidet die eigene Identität des Ziel-Tabs, nicht Ihr Typ.

  • Validierung, Vorschläge für die nächstliegende Übereinstimmung, Umbenennungs-Propagierung, Kanten, der Picker und die Erkennung verwaister Datensätze leuchten alle auf dieselbe Weise auf.
  • Eine Feinheit, die der Core dort für Sie hinzufügt: Weil sich ein Integer auf mehrere Arten schreiben lässt, übergibt eine Umbenennung an TryRewriteKeys sowohl die Schreibweise, wie sie in diesem Element erscheint, als auch die kanonische (007 und 7 bilden beide auf 12 ab), sodass eine ordinale Suche innerhalb Ihres Typs einen aufgefüllten Wert nicht übersieht.
  • Die mitgelieferte Demo enthält keinen referenzierenden benutzerdefinierten Typ, der auf einen IntId-Tab zielt — ihr Modifier-Beispiel zielt auf einen String-Schlüssel-Tab —, daher hat dieser Pfad Tests, aber kein nachbaubares Beispiel.

4.5 Benutzerdefinierte Strukturmarkierungen (opt-in)

Die eingebauten Markierungen sind @name, @type, @desc sowie drei optionale:

  • @overlap.
  • @style, das die Tabelle — ihre Gruppenbezeichnung und Farbe — statt ihrer Spalten beschreibt.
  • @enum, das die Tabelle als eine Menge von Enum-Definitionen statt als Datentabelle markiert.

@overlap ist eine spaltenweise Markierung: Ihre Zeile trägt einen Wert pro Spalte, validiert Spalte für Spalte. Sie können auf dieselbe Weise eigene Markierungen registrieren — zum Beispiel eine @curve-Markierung, die festhält, wie jede numerische Spalte interpoliert. Implementieren Sie IStructuralMarkerDefinition und registrieren Sie es über 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");
    }
}

Die Tabelle akzeptiert dann eine @curve-Zeile (beliebige Reihenfolge, über den Daten):

@name    | level | atk
@type    | int   | int
@curve   |       | ease
         | 1     | 10
  • Der Wert wird als domänenunabhängige Metadaten gespeichert: field.MarkerValues["curve"]. Ein Domänen-Validator oder Kanten-Contributor liest ihn aus context.Tables[tab].Schema.Fields[i].MarkerValues; das Authoring-Fenster zeigt ihn im Spaltenkopf-Tooltip.
  • Eine abgelehnte Zelle wird zu einer MarkerCellInvalid-Diagnose — Sie liefern das „warum" und „wie zu beheben"; der Core liefert die Koordinate und den fehlerhaften Wert.
  • Markierungsnamen müssen gültige Bezeichner sein und dürfen nicht mit den eingebauten sechs (@name/@type/@desc/@overlap/@style/@enum) kollidieren (sonst wirft Register eine Exception, die als PluginRegistrationConflict angezeigt wird).
  • Markierungen sind für Metadaten pro Spalte gedacht, nicht für neue Datenformen — eine Markierung besitzt ihre Zellvalidierung, nicht die gesamte Zeile. Benutzerdefinierte Markierungszeilen bleiben bei Export/Round-Trip wortgetreu erhalten und werden bei jeder Strukturbearbeitung (Hinzufügen / Löschen / Verschieben / Umbenennen) mit ihrer Spalte mitbewegt.
  • Codegen bakt Markierungswerte nicht (wie @overlap sind sie nur Validierungs-/Anzeige-Metadaten, unsichtbar für den Schema-Fingerabdruck).

4.6 „Create sheet"-Vorlagen (opt-in)

Der Ablauf Create sheet liefert zwei eingebaute Vorlagen — eine Items-Tabelle, die nur Core-Typen verwendet, und eine @enum-Definitionstabelle — plus „von Grund auf". Domänen-Vorlagen — Tabellen-Skelette, die Ihre enums, benutzerdefinierten Typen und Referenzen verwenden — kommen von Plugins, sodass eine Vorlage genau dann vorhanden ist, wenn es ihr Plugin auch ist. Implementieren Sie 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",  /* ... */ ""),
            }));
    }
}
  • Eine Vorlage trägt einen oder mehrere Tabs, jeweils ein vollständig normalisiertes TSV (Kommentar-/Markierungszeilen plus Beispieldaten) — im Gegensatz zum eingebauten Item-Beispiel, das ein 0-Zeilen-Skelett ist. Da Ihre Domänen-Typen bereits registriert sind (das Plugin ist geladen), lassen sich die erstellten Tabellen sofort erfolgreich erneut importieren.
  • Die Anzeigetexte gehören Ihnen. Ein Plugin besitzt seinen eigenen Text (das Beispielpaket steht außerhalb der Domänenwort-Schranke) — Sie sind nicht auf die Loc-Schlüssel des Core beschränkt.
  • Mehr-Tab-Vorlagen erstellen alle ihre Tabs und importieren einmal erneut, sodass sich tab-übergreifende Referenzen gemeinsam auflösen. Das Create-Panel blendet für diese das Tab-Namen-Feld aus (die Tab-Namen sind durch die Vorlage festgelegt).
  • Schlüssel, leere Anzeigenamen, null Tabs und leeres Tab-TSV werden abgelehnt (Register wirft eine Exception, die als PluginRegistrationConflict angezeigt wird).

4.7 Canvas-Overrides pro Tab (opt-in)

Die Canvas des Data Studio entscheidet selbst, was sie zeichnet: Sie öffnen einen Datensatz — den Terminus — und sie durchläuft den Referenz-Index nach außen, sammelt alles, was dieser Datensatz konsumiert, und legt das Ergebnis dann von links nach rechts an. Das funktioniert ganz ohne Plugin.

Was ein Plugin hinzufügt, ist das, was der Core nicht sehen oder nicht wissen kann:

  • eine Identität, die kein Sheet-Datensatz ist,
  • ein Link, der nicht in einer RecordId@Tab-Spalte geschrieben ist,
  • eine Reihenfolge, die Domänenregel statt Referenztiefe ist.

Implementieren Sie IRecordCanvasAugmenter und registrieren Sie es pro Tab über 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");
    }
}
  • Die Registrierung erfolgt pro Tab-Name. Tabs, die Sie nicht registrieren, erhalten trotzdem eine Canvas — die Core-Closure —, sodass ein Plugin nie jedes Sheet abdecken muss. Ein doppelter Tab, ein leerer Tab-Name und ein null-Override werden abgelehnt (Register wirft eine Exception, die als PluginRegistrationConflict angezeigt wird).
  • Sie fügen hinzu, Sie ersetzen nicht. Welche Datensätze erscheinen, ist die Antwort der Closure. Ein virtueller Node, dessen (Tab, Schlüssel) bereits auf dem Bildschirm ist, wird verworfen — der echte Datensatz gewinnt —, sodass ein Override keinen Datensatz erfinden kann, der in einer Tabelle existiert. Was er tun kann, ist Identitäten einzubringen, die überhaupt keine Sheet-Zeile haben.
  • Namen sind die eine Ausnahme. Ein Anzeige-Hinweis ist Darstellung statt Identität, daher gilt er doch für bereits existierende Datensätze, und er darf Datensätze benennen, die überhaupt nicht auf dem Bildschirm sind — der Connect-Picker liest diese, weshalb der Untertitel einer Karte und eine Picker-Zeile dasselbe sagen. Leere Namen werden ignoriert (das ist dasselbe wie „Standard verwenden"), und der erste Name für einen Datensatz gewinnt.
  • Eine Kante bringt ihren eigenen Node mit. Ist ein Ende einer zusätzlichen Kante nicht auf dem Bildschirm, wird es als Node hinzugefügt, damit der Link nie ins Leere zeigt. Eine Kante mit leerem Schlüssel an einem der beiden Enden wird ignoriert.
  • Wo die Zelle liegt und wohin der Pfeil zeigt, können sich unterscheiden. Standardmäßig wird angenommen, dass die von fieldName benannte Zelle auf dem Ausgangsdatensatz sitzt. Übergeben Sie fieldOnTarget: true, wenn sie stattdessen auf dem Ankunftsdatensatz sitzt — eine Published-Event-Verbindung wird Event → Datensatz gezeichnet, aber der Text steht in der eigenen Spalte des Datensatzes. Der Verbindungs-Inspector zeigt dann auf die echte Zelle statt ins Leere.
  • Zyklen: Der Core markiert die, die er sehen kann, Sie deklarieren die, die Sie kennen. Schließen Ihre zusätzlichen Kanten eine Schleife, klassifiziert die Canvas die Rückkante und zeichnet sie von selbst gestrichelt. Zu beurteilen, ob ein Zyklus ein Problem ist, ist Aufgabe eines Domänen-Validators (§3); die Canvas ist Anzeigematerial, niemals Validierung.
    • isCyclic markiert eine Verbindung nur zur Anzeige als Zyklus, ohne das Layout zu berühren.
    • cyclicNote trägt, was nur Sie wissen (etwa einen Dämpfungswert) — belassen Sie das Label beim Spaltennamen und packen Sie die Erklärung in die Notiz.
  • Layer-Hinweise kommen in zwei Varianten.
    • SetLayer ist absolut — Spalte 0 ist ganz links, negative Werte gehen weiter nach links.
    • SetLayerRelative zählt vom Terminus aus (−1 ist die Spalte unmittelbar links davon), was meist gemeint ist, wenn eine feste Stufe gebraucht wird. Das Bild liest sich dann gleich, egal ob die Kette flach oder tief ist, und Sie müssen den Terminus selbst nicht fixieren, um zu verhindern, dass die Stufen kollidieren.
    • Relative Hinweise lösen sich gegen die Terminus-Spalte auf, bevor irgendein Hinweis sie verschoben hat, sodass die Reihenfolge, in der Sie Hinweise hinzufügen, das Ergebnis nicht ändern kann. Geht das Ergebnis links von null, verschiebt sich das gesamte Bild nach rechts.
    • Ein Hinweis für einen Node, der nicht auf dem Bildschirm ist, wird verworfen, und der erste Hinweis für einen Node gewinnt.
  • Fehlschläge bleiben eingedämmt. Augment läuft innerhalb von try/catch: Eine Exception wird zu einer englischen Konsolen-Warnung und dem Core-Bild, nie zu einem kaputten Fenster.
  • Erweiterung bricht Sie nie. Jede seit dem ersten Release hinzugefügte Capability ist ein nachgestelltes Argument oder eine neue Methode; ein gegen die frühere Oberfläche geschriebener Override kompiliert und verhält sich identisch.

(Siehe Assets/SheetForge.PluginDemo/Graphing/ExampleReactiveAugmenter.cs und ExamplePipelineAugmenter.cs für die vollständigen Overrides — eine Reaktion, die Event-Nodes und einen Codeblock um den Datensatz herum wachsen lässt, sowie eine Besetzung, deren feste Stufen an ihre eigenen Spalten angeheftet sind.)

4.8 Code-Registries — Referenzziele, die im Code leben (opt-in)

Manche Referenzziele werden überhaupt nicht in einer Tabelle verfasst: die Ausführungs-Atome, an die Ihre Runtime dispatcht. Sie als gesperrten virtuellen Tab zu registrieren, stellt sie schreibgeschützt auf die Authoring-Oberfläche und verhindert, dass Kanten, die auf sie zeigen, als defekt gezeichnet werden. Implementieren Sie 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),
        }));
    }
}
  • Drei Konsumpunkte:
    • Die Data-Studio-Seitenleiste zeigt den virtuellen Tab unter READ-ONLY als Schlüssel/Label/Raises-Raster;
    • ein Canvas-Override kann die Einträge über context.CodeRegistries nachschlagen;
    • und der Node-Inspector listet die Raises eines Eintrags auf.
  • Die Schlüssel treten der Existenzprüfung des Studios bei. Eine Kante, deren Ziel ein registrierter Schlüssel ist — typischerweise einer, den ein IEdgeContributor (§4) deklariert oder den Ihre Form aufbaut —, wird nicht als defekte Referenz gemalt.
  • Der Import-Validator kennt keine virtuellen Tabs. Code-Registries sind ein Konzept der Authoring-Oberfläche, daher typisieren Sie keine Sheet-Spalte als RecordId@_Refs (der Import würde UnknownTargetTab melden). Verbinden Sie Sheet-Daten mit Code-Atomen so, wie es die Demo tut — eine type-Spalte plus ein Kanten-Contributor-/Form-Lookup.
  • Wählen Sie einen Namen, der nicht mit einem echten Sheet kollidieren kann (die Demo stellt _ voran). Kollidiert doch einer, markiert das Studio den Konflikt in der Seitenleiste mit einem Badge, statt einen der beiden stillschweigend zu verbergen.
  • Ablehnungen: null-Quelle, ein leerer Tab-Name oder ein doppelter Tab-Name wirft eine Exception (angezeigt als PluginRegistrationConflict); eine null-Raises-Liste wird zu leer normalisiert. Core behandelt Schlüssel/Label/Raises als opake Strings — es interpretiert sie nie.

(Siehe Assets/SheetForge.PluginDemo/Graphing/ExampleCodeAtoms.cs.)

4.9 Data-Studio-Graph-Widgets (opt-in, Editor-Assembly)

Ein Widget ist ein Streifen Ihrer eigenen UI über der Graph-Canvas — eine Übersicht fester Stufen, ein Aggregat-Badge, was auch immer die Domäne will. Der Core liefert keine Widgets aus, daher ist dieser Bereich leer, bis ein Plugin ihn füllt. Weil der Rückgabetyp ein VisualElement ist, lebt dieser Vertrag in der Editor-Assembly (dieselbe gerechtfertigte Asymmetrie wie bei ISheetSourceProvider); implementieren Sie ihn in einer editor-seitigen Assembly, die SheetForge.Editor und SheetForge.Core referenziert:

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
    }
}
  • Die Entdeckung erfolgt automatischTypeCache findet jede Implementierung mit einem parameterlosen Konstruktor; es gibt keinen Registrierungsaufruf und keine Registry zum Binden. Ein Instanziierungs-Fehlschlag wird protokolliert und übersprungen.
  • Vertraglich schreibgeschützt. Der Kontext legt die geparsten Tabellen, den Referenz-Index und die Code-Registries offen — aber keine Staging-Oberfläche. Authoring aus dem Graphen heraus gehört zu einer Inspector-Aktion (§4.10), die es vermittelt.
  • Kein Zustand innerhalb des Elements. Widgets werden bei jedem Graph-Rebuild neu erzeugt; halten Sie Zustand in Ihren eigenen Objekten. Rebuilds werden auf die Häufigkeit menschlicher Aktionen zusammengefasst, nicht auf Tastenanschläge.
  • Exceptions bleiben isoliert — wirft AppliesTo / Create, entsteht eine englische Konsolen-Warnung; der Graph zeichnet trotzdem weiter.

(Siehe Assets/SheetForge.PluginDemo/Demo/Editor/ExampleStageStripWidget.cs.)

4.10 Data-Studio-Inspector-Aktionen (opt-in, Editor-Assembly)

Eine Aktion ist eine zusätzliche Schaltfläche am Node-Inspector — „was diese Domäne mit diesem Datensatz tun kann". Der Core liefert eine eingebaute Aktion (Go to this sheet); alles andere kommt über diesen Vertrag:

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();
    }
}
  • Die Authoring-Session wird bewusst nicht offengelegt. Jede Staging-Änderung muss ein nativer Undo-Schritt mit erhöhter Projektionsgeneration sein; das Herausgeben der rohen Session würde einen Weg um diese Regel herum institutionalisieren. StageCell(tab, recordId, field, rawText) und StageCells(writes) sind die gesamte Mutationsoberfläche, und das Fenster besitzt die Buchführung.
  • Mehrere Zellen ändern? Verwenden Sie StageCells. context.StageCells(new[] { new EdgeCellWrite(tab, recordId, field, text), … }) merkt die gesamte Liste als einen Undo-Schritt vor, alles oder nichts (kann ein Schreibvorgang nicht angewendet werden, wird keiner angewendet). Mehrfaches Aufrufen von StageCell zerlegt Ctrl+Z in ebenso viele Schritte — und bei parallelen Spalten bedeutet das, dass mitten im Rückgängigmachen ein halb gültiger Zustand erscheint. Eine null- oder leere Liste bewirkt nichts.
  • Übergeben Sie kanonischen Text. Der vorgemerkte Text wird zur Übernehmen-Zeit vom selben Parser geparst, den der Importer verwendet — schreiben Sie also, was die Tabelle enthalten würde.
  • Ein Schlüssel, der nicht in der Baseline ist, bewirkt nichts (ein brandneuer oder unaufgelöster Datensatz): Es wird nichts stillschweigend geschrieben.
  • Dienste: FocusCell scrollt das Grid zu einer Koordinate, RequestRebuild fordert eine Aktualisierung an, nachdem Sie etwas vorgemerkt haben.
  • Entdeckung, Labels und Isolation funktionieren genau wie bei Widgets: TypeCache-Entdeckung, wortgetreuer Fallback für einen nicht registrierten LabelKey (ein leerer Schlüssel fällt auf den Typnamen zurück) und try/catch um AppliesTo / Execute.

(Siehe Assets/SheetForge.PluginDemo/Demo/Editor/ExampleInspectorAction.cs. Seine Editor-Assembly — SheetForge.PluginDemo.Demo.Editor — referenziert SheetForge.Editor, SheetForge.Core und die Plugin-Assembly; das ist die gesamte Verdrahtung, die eine editor-seitige Erweiterung benötigt.)

4.11 Farb-Presets (opt-in)

SheetForge bemalt seine eigenen Fenster aus einem kleinen Vokabular von Farb-Slots (Oberflächen, Linien, Text, semantische Farben, Staging-Markierungen). Ein Preset färbt die Slots um, die es kennt; jeder andere Slot behält die Produktvorgabe. Implementieren Sie 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 },
            }));
    }
}
  • Farben sind 0xRRGGBB. Core referenziert keinen Engine-Typ, daher gibt es hier kein UnityEngine.Color; das oberste Byte wird ignoriert. Durchscheinende Oberflächen (Badge-Füllungen, der Modal-Scrim) leiten sich aus einer Slot-Farbe plus einem festen Alphawert ab — Sie legen die Farbe fest, nicht den Alphawert.
  • Liefern Sie beide Bildschirme. Stellen Sie eine dunkle und eine helle Map bereit; die Helligkeitswahl des Nutzers (Editor folgen / immer dunkel / immer hell) wählt eine davon aus. Slots, die Sie auslassen, fallen für diese Helligkeit auf die Produktvorgabe zurück, sodass ein Drei-Slot-Preset vollkommen normal ist.
  • Das Registrieren wendet es nicht an. Ihr Preset erscheint unter Preferences ▸ SheetForge ▸ Theme ▸ Colour preset neben den eingebauten Default und High contrast; nur die Auswahl des Nutzers wirkt sich aus. Die Anzeigetexte gehören Ihnen (kein Core-Loc-Schlüssel nötig).
  • Leere Ids, Duplikate und die reservierten eingebauten Ids (default, highContrast) werden abgelehnt (Register wirft eine Exception, die als PluginRegistrationConflict angezeigt wird).
  • Was ein Theme nicht umgestalten kann: Die nativen Unity-Widgets, die innerhalb unserer Fenster gezeichnet werden (Schaltflächen-Rahmen, Feldränder), folgen weiterhin dem Editor-Skin — siehe Möglichkeiten & Grenzen.

4.12 Bearbeiten auf der Graph-Canvas (opt-in)

Der Graph des Data Studio ist eine Authoring-Oberfläche, kein Bild: Rechtsklick erstellt Datensätze, verbindet sie und trennt Verbindungen (siehe Data Studio). All das funktioniert in einem einfachen Projekt für gewöhnliche RecordId@Tab-Spalten. Die folgenden Capabilities erweitern es dort, wo der Core nicht hinreicht — keine von ihnen ändert einen bestehenden Vertrag, sodass ein Plugin, das sie ignoriert, unverändert kompiliert.

Wie Capabilities entdeckt werden (zuerst lesen)

Eine Capability wird nie eigenständig entdeckt. Das Fenster findet jede von ihnen, indem es die bereits registrierten Objekte castet:

CapabilityGecastet ausWas sie hinzufügt
IAuthorableGraphShapedem von ISheetForgeGraphPlugin registrierten Canvas-OverrideWo neue Datensätze erstellt werden dürfen
IAuthorableEdgeContributordem von ISheetForgeEdgePlugin registrierten Kanten-ContributorEine Geste in einen Zellschreibvorgang verwandeln
IBatchAuthorableEdgeContributordemselben Kanten-ContributorEine Geste in mehrere Zellschreibvorgänge verwandeln
IVirtualNodeFactorydemselben Kanten-Contributor„Ein weiteres erstellen" im Node-Menü anbieten
IEdgeSlotDeclarerdemselben Kanten-ContributorConnect-Slots deklarieren, die das Schema nicht ableiten kann
IEdgeTokenEditordemselben Kanten-ContributorEin Token beschreiben und den Teil bearbeiten, der nicht der Schlüssel ist

Die fünf kantenseitigen Capabilities werden also nur erreicht, wenn die Klasse als IEdgeContributor registriert ist (über ISheetForgeEdgePlugin, §4). Öffnet Ihre Domäne keine eigenen Kanten, ist das kein Grund, die Registrierung auszulassen — implementieren Sie ContributeEdges als leere Methode und registrieren Sie trotzdem. Dieser leere Contributor ist der offiziell unterstützte Weg beizutreten:

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

Sie alle laufen innerhalb von try/catch: Eine Exception wird zu einer englischen Konsolen-Warnung und deaktiviert genau diese eine Möglichkeit, sonst nichts.

Wo neue Datensätze erstellt werden dürfen — IAuthorableGraphShape

Es gibt zwei Vorgaben, und sie sind bewusst unterschiedlich.

  • Die Liste erstellbarer Tabs — die Achse, die diese Capability ersetzt und die auch entscheidet, ob überhaupt eine Canvas öffnet — deckt jeden Tab ab, den das Schema des Fokus-Tabs erreichen kann, indem sie Referenzen transitiv folgt. Sie wird aus dem Schema berechnet, nicht aus den Daten, daher gilt sie auch für eine Tabelle, die noch keine Zeilen hat. Einen Tab zwei Links tief zu erreichen, geschieht schrittweise: Erstellen Sie den Zwischendatensatz, seine Ports erscheinen, und der nächste Sprung tritt der Kaskade bei.
  • Die Verlinkungs-Kaskade — der Picker, den Sie auf leerer Canvas tatsächlich sehen — ist enger. Sie beginnt bei den Tabs, auf die die gerade gezeichneten Ports zielen.

So oder so werden Tabs, die einer Code-Registry gehören, und Tabs ohne Schlüsselspalte ausgelassen, weil ein neuer Datensatz dort keine Identität haben könnte.

Ein für diesen Tab registrierter Override (§4.7) kann dieses Interface hinzufügen, um beide Vorgaben zu ersetzen — und ein von ihm benannter Tab, den kein Port auf dem Bildschirm akzeptiert, bleibt mit angehängtem Grund in der Verlinkungs-Kaskade gelistet, statt zu verschwinden:

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

Die eigene Kante bearbeitbar machen — IAuthorableEdgeContributor

Eine Kante, die Sie mit IEdgeContributor (§4) geöffnet haben, wird gezeichnet, ist aber nicht bearbeitbar, weil nur Sie die Notation kennen, in der sie lebt. Fügen Sie dieses Interface hinzu, um eine Geste zurück in Zelltext zu verwandeln; das Fenster merkt genau das vor, was Sie zurückgeben, und der Parser bleibt der letzte Richter:

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 bedeutet, dass nichts passiert. Es wird kein Staging erzeugt, und der Menüpunkt ist mit einem ehrlichen Grund deaktiviert — nie eine halb angewendete Bearbeitung. true mit unsinnigem Text zurückzugeben ist erlaubt, aber sinnlos: Der vorgemerkte Wert durchläuft dieselbe Vorab-Validierung wie ein getippter und erscheint in Problems.
  • Adressieren Sie per Schlüssel, nicht per Zeile. EdgeCellWrite benennt (Tab, Datensatz-Id, Feld); Zeilennummern werden zur Schreibzeit neu aufgelöst, sodass ein vorgemerkter Plan verschobene Zeilen übersteht.
  • Sie werden während einer Geste aufgerufen. Beide Methoden laufen innerhalb von try/catch — eine Exception wird zu einer englischen Konsolen-Warnung und deaktiviert genau diese eine Möglichkeit, sonst nichts.
  • Fragen Sie den Kontext, nicht die Tabelle. CellText gibt den Wert einschließlich Staging zurück, sodass zwei nacheinander erstellte Links einander sehen. Stattdessen die geparste Tabelle zu lesen, würde den ersten übersehen.

Mehrere Zellen in einer Geste ändern — IBatchAuthorableEdgeContributor

Manche Daten halten ein Element über parallele Spalten verteilt: stepDelays | stepTargets | stepCounts, wobei Index i jeder Spalte einen Schritt darstellt. Einen Link dort hinzuzufügen, muss jede Spalte gleichzeitig wachsen lassen, sonst enden die Spalten mit unterschiedlicher Länge — ein halb gültiger Zustand, den ein Ein-Zell-Plan nicht vermeiden kann. Diese Capability ist das Geschwister von IAuthorableEdgeContributor (keine Unterklasse), sodass Contributor, die nur die einfache Form haben, unberührt bleiben:

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) => …;
}
  • Alles oder nichts davon. Jeder Schreibvorgang in der Liste wird als ein nativer Undo-Schritt vorgemerkt; kann auch nur einer nicht geschrieben werden (keine solche Zeile, schreibgeschützte Quelle, laufende Pipeline), wird überhaupt nichts vorgemerkt.
  • Batch gewinnt. Implementiert eine Klasse sowohl die einfache als auch die Batch-Form, fragt das Fenster nur die Batch-Form — eine Geste hat nie zwei verschiedene Antworten. Contributor werden weiterhin in Registrierungsreihenfolge gefragt, und der erste, der plant, gewinnt.
  • Jeder Schreibvorgang braucht eine Adresse. Eine Liste, die einen Schreibvorgang mit leerem Tab oder Feld enthält (oder eine leere Liste), zählt als „kein Plan".
  • Das Trennen läuft auf einer Kette. Werden mehrere Verbindungen an einer Karte in einer einzigen Geste durchtrennt, trägt der Kontext, den Sie lesen, bereits die früheren Pläne dieser Geste, sodass das Herausschneiden zweier Tokens aus derselben Zelle beide entfernt. Der einfache Vertrag hat keine Oberfläche, um diesen Zwischenwert zu empfangen — diese Capability ist es, wie diese Grenze aufgehoben wird.
  • Ein gerade erstellter Datensatz kann kein Ziel sein. Im Ablauf „in einer Geste erstellen und verlinken" werden Schreibadressen aufgelöst, bevor die neue Zeile die Session betritt, daher kann ein Plan, der auf den gerade erstellten Datensatz zielt, nicht bestehen, und die gesamte Geste scheitert ehrlich. Das Zielen auf bereits existierende Zeilen (der Fall paralleler Spalten) ist davon nicht betroffen.

Ein weiteres von etwas erstellen — IVirtualNodeFactory

Wenn „ein weiteres" keine neue Zeile ist, sondern ein weiteres Element in jeder von mehreren Zellen, kann die Canvas die Geste nicht erfinden. Deklarieren Sie die Arten, die Sie erzeugen können, und geben Sie die Zellschreibvorgänge zurück, sobald eine gewählt wird:

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;
    }
}
  • Das Label ist bereits übersetzt. Core übersetzt es nicht — liefern Sie den String, den Ihr Paket aufgelöst hat (siehe §4.14). Ein / im Label erzeugt ein Untermenü, sodass Sie Ihre eigenen Einträge gruppieren können.
  • tab kann ein virtueller Tab-Name oder leer sein. Nodes, die Ihr Canvas-Override auf den Bildschirm bringt, leben in keiner Tabelle; das Menü bietet trotzdem an, was Sie deklarieren, weil die Zellen, die Sie schreiben, von Ihrem Plan benannt werden, nicht von der Identität des Nodes. Tabs, die einer Code-Registry gehören, sind ausgeschlossen.
  • Ein Undo-Schritt, alles oder nichts — dieselbe Regel wie bei der Batch-Capability oben. false merkt überhaupt nichts vor.

Connect-Slots deklarieren — IEdgeSlotDeclarer

Connect-Slots kommen normalerweise aus dem Schema (RecordId@Tab-Spalten). Ein Node, den Ihr Override auf den Bildschirm gebracht hat, hat keine Spalten, und eine Contributor-Kante enthüllt einen Slot erst, sobald bereits ein Link existiert — sodass der erste Link nirgendwo hätte beginnen können. Deklarieren Sie die Slots stattdessen:

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;
}
  • Der Name hat zwei Aufgaben. Er muss innerhalb dieses Nodes eindeutig sein, und er muss dem FieldName der Kante entsprechen, die Sie hineinzeichnen — sowohl die Slot-Suche als auch die Verbindungs-Verankerung gleichen über diesen Namen ab. Trägt eine Sheet-Spalte diesen Namen bereits, gewinnt das Sheet, und Ihre Deklaration wird still verworfen.
  • Deklarieren ist nicht Planen. Ein deklarierter Slot wird über Ihren Plan verbunden (IAuthorableEdgeContributor oder die Batch-Form). Deklarieren ohne Planen öffnet den Port, merkt aber nichts vor — implementieren Sie beides.
  • Ports öffnen sich auf Nodes ohne Sheet-Zeile. Für einen Node, dessen Tab kein Sheet ist, sucht das Fenster nicht nach einer Zeile mit diesem Namen; die Schreibadresse kommt aus Ihrem Plan und wird zur Staging-Zeit geprüft.

Bearbeiten, was das Token sagtIEdgeTokenEditor

Verlinken und Trennen bewegen ein ganzes Token. Oft ist das Token mehr als ein Schlüssel: attack:add:10 benennt einen Stat und wie viel. Fügen Sie diese Capability demselben Contributor hinzu, und der Verbindungs-Inspector erhält eine Zeile für diesen Rest — den Teil, der nicht der Schlüssel ist:

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…
    }
}
  • Beide Hälften lesen dieselbe Zelle. Eine Kante weiß, wohin sie zeigt, nicht, mit welchen Buchstaben sie heute geschrieben ist, daher nimmt das Beschreiben denselben EdgeAuthoringContext wie das Schreiben. Das ist es, was das hervorgehobene Fragment und das umgeschriebene Fragment nachweislich zum selben macht.
  • Der Schlüssel bewegt sich nie durch diese Tür. Zu ändern, worauf ein Link zeigt, ist Neu-Verankern (die Verbindung ziehen); diese Zeile ändert nur den Rest. false von einer der beiden Hälften zurückzugeben, verbirgt oder deaktiviert die Zeile ehrlich — kein Staging, kein stiller Fehlschlag.
  • Das Widget zu beschreiben, ist Ihre Sache. isChoice mit Optionen zeichnet ein Popup, sonst ein Textfeld; das Label der Zeile und die Options-Labels sind Ihre Strings. Gibt es überhaupt keinen Rest, konstruieren Sie new EdgeTokenDescription(tokenText), und die Zeile wird nicht gezeichnet — eine Core-Referenz (deren Schlüssel das gesamte Token ist) verhält sich so, ganz ohne Code.

Die eigenen Verbindungen überhaupt bearbeitbar machen

Eine Verbindung kann nur bearbeitet werden, wenn sie die Zelle benennt, in der sie geschrieben steht. Der Core trägt dies für die Referenzen ein, die er selbst liest; eine zusätzliche Kante, die Sie hinzufügen (§4.7), tut dies, indem sie das Feld benennt:

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

Eine Zelle zu benennen, verspricht nicht, dass sie bearbeitbar ist — es sagt, wo der Link lebt. Eine von Ihnen hinzugefügte Kante wird an dieselbe Verrohrung übergeben, die eine Contributor-Kante nutzt, sodass sie genau dann bearbeitbar wird, wenn ein IAuthorableEdgeContributor sie beansprucht. Ist diese Spalte eine reine Text- oder enum-Spalte ohne jemanden, der sie umschreibt, meldet die Canvas die Verbindung hier als nicht bearbeitbar, was die Wahrheit ist statt ein stiller No-op.

4.13 Benutzerdefinierte Cell-Widgets (opt-in, Editor-Assembly)

Das Grid zeichnet jede Zelle mit einem eingebauten Widget (Bool-Toggle, Enum-Popup, Referenz-Picker, Rohtext). Wenn ein Typ eine bessere Eingabe verdient — eine Kurve, eine Farbe, ein Mini-Grammatik-Composer, ein mehrzeiliges Feld — ersetzen Sie das Widget für diesen Typnamen, ohne anzufassen, wie der Wert geparst wird:

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;
    }
}
  • Das Widget formt die Eingabe, der Parser besitzt die Bedeutung. Was auch immer Sie committen, ist kanonischer Sheet-Text; er durchläuft dieselbe Vorab-Validierung wie ein getippter Wert, und Probleme erscheinen im Problems-Panel. Das Widget muss nie validieren.
  • Zwei Commit-Oberflächen, mit Absicht. Commit (Auswahl aus einer Liste, Loslassen eines Sliders, Fokus verlieren) erzeugt einen Undo-Schritt; CommitTyping (pro Tastenanschlag) fasst einen Schwall in einen Schritt zusammen. Beide in einen einzigen Aufruf zusammenzulegen, würde entweder Undo-Schritte pro Buchstabe verstreuen oder zwei verschiedene Auswahlen verschmelzen.
  • Die Rückgabe von null lehnt die Zelle ab, und das eingebaute Widget übernimmt — die ehrliche Antwort für Formen, die Sie nicht behandeln (List<T> Ihres Typs, optionale Felder). context.Type (das geparste @type-Token) trägt alles Nötige zur Entscheidung.
  • ReferenceKeys(tab) gibt Ihnen dieselbe Kandidatenliste, die der eingebaute Referenz-Picker verwendet (projizierte Schlüssel ∪ Code-Registry-Schlüssel ∪ vorgemerkte Neue-Zeile-Schlüssel, sortiert) — keine Notwendigkeit, Ihre eigene zu sammeln. Damit die Person aus dieser Liste in demselben Dropdown wählen kann, das die eingebaute Zelle öffnet, rufen Sie StudioKeyPicker.Show(screenAnchor, tab, candidates, picked) auf und fügen den zurückgegebenen Schlüssel in Ihre eigene Notation ein, bevor Sie committen. (Einen Datensatz erstellen, die Zelle leer lassen und eine Liste mehrfach umschalten sind die eigenen Regeln der eingebauten Referenzzelle und stehen nicht auf dieser Fassade — ein Widget, das den gesamten Zelltext besitzt, besitzt auch diese Entscheidungen.)
  • Sie dürfen einen eingebauten Typnamen beanspruchen, nicht nur Ihren eigenen. Der Zweig des registrierten Widgets läuft zuerst, sodass TypeName => "float" tatsächlich die Rohtext-Box für jede float-Spalte ersetzt. So gelangen ein Slider, ein Prozentfeld oder eine Box mit Einheiten-Suffix hinein. Zwei Vorsichtsmaßnahmen gehören dazu:
    • Es gilt für jede Spalte dieses Typs im Projekt, daher grenzen Sie es ein, indem Sie context.FieldName / context.Tab lesen und für die Spalten, die Sie nicht meinten, null zurückgeben.
    • Was Sie committen, ist weiterhin kanonischer Sheet-Text, daher muss ein Slider seinen Wert so rendern, wie der Parser ihn zurückliest (siehe CanonicalValueRenderer.RenderFloat für die Float-Schreibweise, die der Round-Trip erwartet).
  • Konflikte warnen, die Entdeckung ist automatisch. Dieselbe TypeCache-Entdeckung wie bei jedem anderen Vertrag; beanspruchen zwei Provider denselben Typnamen, gewinnt der zuerst gefundene, und eine Konsolen-Warnung nennt beide. Ein geworfenes CreateEditor wird abgefangen, verwarnt, und die Zelle fällt auf das eingebaute Widget zurück.
  • Prüfen Sie vor dem Schreiben eines eigenen, ob ein Hint genügt. Wollen Sie nur ein Dropdown, eine mehrzeilige Box, einen Schieberegler, einen Toggle, eine Farbauswahl, einen Kurven-Editor oder einen Verlaufs-Editor, registrieren Sie stattdessen einen StudioCellEditorHint (§4.16) — kein Widget-Code, und er funktioniert auch im Browser. Die Reihenfolge ist: zuerst dieser Vertrag, dann der Hint, dann die Core-Standards; ein Hint ist also das, was die Zelle bekommt, sobald kein Widget den Typ beansprucht hat oder das beanspruchende abgelehnt hat.

4.14 Plugin-UI-Strings (opt-in)

Labels, die Ihr Paket zeigt — Inspector-Aktionen, Widget-Beschriftungen, die deklarativen Oberflächen aus §4.16 —, können der Sprache des Nutzers folgen. Registrieren Sie Sätze pro Sprachschlüssel; Loc.Tr befragt dieses Overlay vor den Produkttabellen, und das t() des Browsers tut dasselbe:

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", "…");
    }
}
  • Dieser Vertrag lebt in Core, packen Sie ihn also in Ihre Hauptassembly. Beide Hosts zeigen die Labels Ihres Pakets, und der Browser lädt immer nur die Haupt-DLL — ein Strings-Plugin in der Editor-Begleitassembly würde die Web-App mit rohen Schlüsseln zurücklassen.
  • Das Registrieren ist optional. Ein nicht registrierter Schlüssel wird weiterhin wortgetreu angezeigt — dieser Vertrag ist ein Upgrade-Pfad, keine Pflicht.
  • Sprachen sind IETF-Codes ("en", "ko", "zh-Hans", "pt-BR", …), Groß-/Kleinschreibung wird ignoriert.
    • Registrieren Sie mindestens Englisch: Der Lookup fällt zurück angeforderte Sprache → Englisch → Fehltreffer, sodass ein Nutzer in jeder anderen Sprache Ihren englischen Satz liest statt des rohen Schlüssels.
    • Ein dem Produkt unbekannter Code wird mit einer Begründung abgelehnt, statt still nach Englisch gefaltet zu werden — ein Tippfehler, der stillschweigend zu Englisch würde, wäre nicht nachvollziehbar.
  • Produktschlüssel können nicht überschrieben werden — eine Registrierung, die einen eingebauten Schlüssel benennt, wird abgelehnt, sodass ein Overlay die UI nie in Widerspruch zu den eigenen Sätzen des Produkts bringen kann. Insbesondere Menü-Labels werden direkt aus den Sprachtabellen gebacken, sodass ein Overlay, das sie könnte umschreiben, den Hinweistext und den echten Menüpfad in Widerspruch brächte. Das Overlay ist für neue Schlüssel gedacht.
  • Doppelte Registrierungen über Pakete hinweg behalten die zuerst gefundene, mit protokollierter Begründung — würde die letzte Registrierung still gewinnen, hinge der Bildschirm von der Plugin-Installationsreihenfolge ab.
  • Leere Schlüssel und leere Werte werden ebenfalls abgelehnt. Jede Ablehnung ist eine für Entwickler bestimmte englische Zeile, denn das Publikum ist der Plugin-Autor, nicht der Endnutzer.
  • Die 10-Sprachen-Paritätsregel des Produkts bleibt unberührt: Ihre Strings leben in einem Lookup-Overlay neben den Core-Tabellen, nie in ihnen.

(Die Demo liefert dies unter Assets/SheetForge.PluginDemo/ExampleLocStrings.cs aus — aus dem obigen Grund in der Hauptassembly — und registriert die Labels, die dessen Inspector-Aktion (§4.10) und deklarative Oberflächen (§4.16) anzeigen.)

4.15 Mehrzeiliger Text in einer Zelle (Dialog, Beschreibungen, Skripte)

Ein echter Zeilenumbruch kann niemals in einer Zelle leben. Die Eingabe der Pipeline ist TSV, wo ein Tabulator Zellen trennt und ein Zeilenumbruch Zeilen trennt, daher hat eine Zelle, die eines der beiden Zeichen trägt, überhaupt keine Darstellung.

Jede Quelle erzwingt dies am Eingang, statt ein beschädigtes Grid durchzulassen:

  • die CSV- und xlsx-Reader melden UnsupportedCellCharacter mit der Koordinate der Zelle, wobei sie jede fehlerhafte Zelle sammeln, nicht nur die erste;
  • der Google-Abruf tut dasselbe;
  • und in einer .tsv-Datei war das Zeichen bereits der Zeilentrenner.

Das ist eine Konstruktionskonstante des Formats, keine Lücke, die noch geschlossen werden muss. Eine Domäne mit langem Text arbeitet also mit ihr, über eine dreiteilige Konvention, die vollständig im Plugin-Territorium liegt.

1. Wählen Sie eine Escape-Notation und schreiben Sie sie in Ihren Parser. Die konventionelle Wahl ist ein wörtliches Zwei-Zeichen-\n in der Tabelle, unescaped beim Hereinkommen und erneut escaped beim Herausgehen:

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

Machen Sie die beiden Richtungen zu exakten Umkehrungen und beweisen Sie es. TryRender ist das, was Export und Push zurückschreiben, daher schreibt ein „Tabelle → Import → Export → Tabelle"-Round-Trip Text um, den niemand bearbeitet hat, wenn es TryParse nicht zeichengenau umkehrt. \r\n beim Herausgehen zu \n zu normalisieren (wie oben) ist es, was einen unter Windows verfassten Wert davon abhält, bei aufeinanderfolgenden Exports zwischen zwei Schreibweisen zu wechseln. Ein einziger Test, der einen geparsten Wert rendert und ihn mit dem ursprünglichen Zelltext vergleicht, genügt, um es festzuschreiben.

2. Geben Sie der Zelle einen echten Editor. Ein \n-escapeter Wert ist unangenehm in einer einzeiligen Box zu tippen, wofür genau §4.13 da ist — registrieren Sie einen IStudioCellEditorProvider für "Prose", der ein mehrzeiliges TextField (multiline = true) zurückgibt, das den Wert mit echten Zeilenumbrüchen zeigt und ihn erneut escaped committet. Committen Sie beim Fokusverlust mit Commit (ein Undo-Schritt pro Bearbeitungssitzung) statt pro Tastenanschlag.

3. Kennen Sie den einen Ort, den die Konvention nicht erreicht. Tippt jemand Alt+Enter direkt im Google Sheet, erzeugt das einen echten Zeilenumbruch in der Live-Zelle, und diese Zelle wird beim nächsten Abruf mit einer darauf zeigenden Koordinate abgelehnt. Die Ablehnung ist ehrlich und behebbar, aber sie ist eine Ablehnung — verfassen die Autoren in Ihrem Team Prosa also direkt in der Tabelle selbst, vermerken Sie in Ihrer eigenen Dokumentation, dass langer Text mit \n geschrieben wird, oder lassen Sie sie im Data-Studio-Cell-Widget aus Schritt 2 verfassen, wo das Escaping für sie geschieht.

4.16 Deklarative Authoring-Oberflächen (opt-in)

§4.9, §4.10 und §4.13 geben ein VisualElement zurück, und genau deshalb sind sie editor-only: Der Browser kann keinen UIToolkit-Typ laden, daher existiert eine so geschriebene Erweiterung auf einem Bildschirm und nicht auf dem anderen.

Dieser Vertrag beantwortet dieselben Bedürfnisse als Daten. Sie beschreiben die Hülle — eine Id, einen Label-Schlüssel, eine Platzierung, einen Ton — und liefern nur das Prädikat und den Effekt als Delegaten. Eine Registrierung wird dann sowohl vom UIToolkit-Renderer des Editors als auch vom React-Renderer des Browsers gezeichnet.

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

Das Vokabular ist bewusst begrenzt — es wächst nur durch Anhängen, nie durch Einfügen, sodass eine bestehende Registrierung ihre Bedeutung behält.

  • Fünf Platzierungen für eine Aktion: Inspector, RowContextMenu, TopbarMenu, ColumnHeaderMenu, CanvasNodeMenu.
    • Jede füllt den Kontext mit dem, was diese Stelle weiß — die Zeilen-Platzierung trägt den Datensatz, die Spalten-Platzierung trägt den Spaltennamen, die Canvas-Platzierung trägt den Datensatz des Nodes — und lässt den Rest leer, sichern Sie also ab, bevor Sie ein Feld lesen, das eine Stelle nicht liefert.
  • Dreizehn Node-Arten für ein Panel oder Badge: Row, Label, Chip, Badge, Button, Rule, Heading, KeyValue, Table, List, Progress, Input, Link.
    • Sie werden über statische Fabriken gebaut (StudioUiNode.Label(…), .WithTooltip(…)), sodass ein Node unveränderlich ist und nur die Felder gesetzt sind, die für seine Art etwas bedeuten.
  • Sieben Cell-Editor-Archetypen: Dropdown (Sie liefern die Kandidaten), MultilineText, Slider (Sie liefern den Bereich), Toggle (Sie liefern die beiden kanonischen Texte), ColorPicker (#RRGGBB / #RRGGBBAA), CurveEditor und GradientEditor (der Zelltext ist die kanonische Kurven- / Verlaufs-Notation aus Tabellensyntax — ein Paket, dessen eigener Typ diese Notation schreibt, etwa über CurveValue.Render(), darf sie deklarieren). Die eingebauten Typen Color, AnimationCurve und Gradient sind über genau denselben Mechanismus verdrahtet — BuiltinCellEditorHints hält deren drei Hints —, und ein Host konsultiert die Registrierungen eines Pakets zuerst, sodass die Registrierung eines Hints unter einem dieser Typnamen die eingebaute Wahl überschreibt, statt abgelehnt zu werden. Im Editor sind die letzten drei Archetypen Unitys Farb-, Kurven- und Verlaufsfelder; im Browser sind es die eigenen Editoren der App; eine List<> eines Typs, der einen von ihnen trägt, wird in beiden zu einem Chip-Editor. Der Falloff-Typ der Plugin Demo tut genau das: Sein Parser liest die Zelle mit CurveValue.TryParse, und eine einzige Hint-Registrierung gibt ihm ein Kurvenfeld in Unity und den Kurven-Editor im Browser.
  • Nirgends Layout-Zahlen. Pixel und Verhältnisse würden die Körnung des einen Bildschirms in den anderen durchsickern lassen; Sie sagen was gezeigt werden soll, und jeder Renderer entscheidet, wie er es platziert.

Regeln, die Sie kennen sollten, bevor Sie eine schreiben:

  • Mutation läuft durch dieselbe Tür wie Ihre Hand. StudioSurfaceContext gibt einer Aktion genau vier Fähigkeiten — StageCell, StageCells (mehrere Zellen, ein Undo-Schritt, alles-oder-nichts), FocusRecord, RequestRebuild — zusätzlich zu den schreibgeschützten Tables / References / CodeRegistries.
    • Das Verb eines Plugins ist also eine gewöhnliche vorgemerkte Bearbeitung: ein Ctrl+Z-Schritt, nichts erreicht die Tabelle, bevor Sie pushen, dieselbe Vorab-Validierung.
    • Das Staging-Gate gilt ebenfalls — eine schreibgeschützte Quelle, eine laufende Pipeline oder ein arbeitsmappen-gestützter Tab blockiert es mit dem gezeigten Grund.
  • Prädikate laufen ständig. AppliesTo, Panel-Aufbau und Badge-Bereitstellung laufen bei jeder Geste und jedem Neuberechnungs-Tick. Lesen Sie den Ihnen übergebenen Snapshot; keine IO, kein Netzwerk, keine lange Berechnung.
  • Angezeigt ist nicht ausgeführt. Der Host prüft das Prädikat bei der Ausführung erneut. Hat sich die Situation seit dem Zeichnen des Menüs geändert, ist die Antwort ein ehrlicher No-op plus ein Neuzeichnen statt eines zweiten Fehlschlags. Der Browser tut dasselbe für eine veraltete Id.
  • ConfirmKey fragt zuerst nach. Geben Sie einer Aktion einen Bestätigungsschlüssel, und der Host zeigt diesen Satz, bevor er sie ausführt — richtig für ein Verb, das viele Zellen auf einmal vormerkt.
  • Ein Link-Node öffnet nur http/https. Die Regel ist ein einziges Core-Prädikat (StudioUiNode.IsAllowedUrl), das beide Hosts befragen, sodass sie nie uneinig darüber sein können, was sicher zu öffnen ist; der Browser prüft danach dieselbe Form erneut, bevor er einen Anker rendert, was nur mehr verweigern kann, nie weniger.
    • Die URL wird exakt so gespeichert, wie Sie sie geschrieben haben, und am Öffnungspunkt mit einer Begründung abgelehnt, statt bei der Registrierung bereinigt zu werden — das Paket, das sie geschrieben hat, sollte herausfinden können, warum nichts passiert ist.
  • Panels halten keinen Zustand. Sie werden bei jedem Tick neu aufgebaut; der einzige Ort, an dem ein Wert hingehört, ist die Tabelle (vorgemerkt). Registriert nichts ein Panel, wird der Bereich gar nicht gezeichnet.
  • Ausnahmen sind isoliert — ein Wurf wird zu einer englischen Konsolen-Warnung und entfernt genau diese eine Möglichkeit, nicht das Fenster.

Wenn Beschreibung nicht genügt — IStudioPanelProvider (Editor-Assembly)

Beliebiges Rendering, zusammengesetzte Eingaben und mehrstufige Abläufe haben hier kein Vokabular, und eines zu erfinden würde bedeuten, für immer ein Miniatur-UI-Framework zu pflegen. Daher ist die Obergrenze bewusst gesetzt, und die Ausweichmöglichkeit ist weit: Implementieren Sie IStudioPanelProvider in Ihrer editor-seitigen Begleitassembly und zeichnen Sie, was Sie möchten.

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

Registrieren Sie beide unter derselben Id, und jeder Host nimmt, was er zeichnen kann: Der Editor verwendet die umfangreiche Variante, der Browser die beschreibende. So gilt „so weit der Browser reicht, im Editor die ganze Strecke", ohne einen zweiten Satz Verträge.

Es gibt keine reine Web-Variante — ein fehlendes umfangreiches Panel bedeutet, dass das beschreibende gezeichnet wird, nicht dass das Panel verschwindet. Das Element lebt einen Neuberechnungs-Tick lang, hält also ebenfalls keinen Zustand.

4.17 Die Pipeline beobachten (opt-in)

Eine produktübergreifende Brücke, Domänen-Telemetrie oder ein nachgelagerter Generator müssen oft wissen, was ein Import erzeugt hat, ohne ihn erneut zu parsen. Implementieren Sie IPipelineObserver und registrieren Sie ihn über 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 ...
    }
}
  • Beobachten kann das Ergebnis nicht ändern. Sie erhalten einen unveränderlichen Snapshot und geben nichts zurück. Es gibt absichtlich keinen Hook, um einen Wert zu ändern oder eine Diagnose hinzuzufügen: Einen Wert zu interpretieren gehört einem Zelltyp (§2), eine Regelverletzung zu melden gehört einem Domänen-Validator (§3). Teilnahme in einen Beobachtungsvertrag zu mischen würde „Beobachter können das Ergebnis nicht ändern" in der Praxis unwahr machen.
  • Einmal pro explizitem Import-Zyklus, an dessen Ende, ob erfolgreich oder fehlgeschlagen. Er läuft nicht auf der Vorab-Projektion, die sich neu berechnet, während Sie vormerken — kein Drittanbieter-Code hängt an der Tastenanschlag-Frequenz.
  • Ein fehlgeschlagener Lauf meldet weiterhin, was er geparst hat. Tables trägt die Tabs, die geparst wurden, bevor die Validierung fehlschlug — dasselbe Material, das der Quarantäne-Ablauf verwendet (Data Studio) —, sodass ein Beobachter ein wahrheitsgemäßes Bild eines fehlgeschlagenen Laufs sieht statt gar nichts.
  • Zwei ehrliche Lücken. Der Beobachter feuert vom eigenen Abschlusspunkt des Import-Zyklus aus, daher feuert ein Lauf, der ihn nie erreicht, überhaupt nicht.
    • Ein Import, der abgebrochen wird, bevor die Pipeline läuft (keine aktiven Einstellungen, das Addressables-Gate verweigert).
    • Der Codegen→Kompilierung-Abschnitt, unterbrochen durch einen Kompilierungsfehler.
    • Das ist ein Nullfeuern, nie ein Falschfeuern: Brauchen Sie „ein Import wurde versucht", kombinieren Sie dies mit dem editor-seitigen ImportEvents-Bus.
  • Ein Wurf wird auf diesen Beobachter isoliert, mit protokollierter Begründung; die Ausgabe des Imports ändert sich um keinen Deut.
  • Künftige Beobachtungspunkte (direkt nach dem Parsen, ein Export-Zyklus) werden als Sibling-Capability-Interfaces ankommen, die durch Casten des registrierten Beobachters entdeckt werden, sodass das Hinzufügen eines neuen eine heute geschriebene Implementierung nicht bricht.

5. Benutzerdefinierte Importquellen (ISheetSourceProvider)

Eine neue Quelle (Datenbank, REST-Endpunkt, unternehmensinternes Format) tritt bei ganz ohne Änderungen an Core/Editor. Implementieren Sie ISheetSourceProvider in einer Editor-Assembly; SourceProviderRegistry entdeckt sie über TypeCache, und sie erscheint im Settings-Dropdown „Source" neben den eingebauten Optionen. Die vier Dinge, die ein Provider beantwortet:

  1. FetchCreateTabSource(settings) liefert eine ITabSource, die Tab-Name → rohen TSV-Text bereitstellt (asynchron; Umgebungsprobleme sind Diagnosen, keine Exceptions; Teilausgaben erlaubt).
  2. Write-backCreateReflectTarget(dispatcher, settings) liefert ein ISourceReflectTarget, das sich in den Authoring-Dispatcher einklinkt (verwenden Sie die öffentlichen Session / Callbacks / Baselines des Dispatchers, um Ihr Ziel zusammenzustellen). Geben Sie nur dann ein Ziel zurück, wenn Ihre Quelle beschreibbar ist.
  3. VisibilityGetVisibility(settings) gibt zurück, welche Settings-Felder der Inspector für Sie anzeigen soll.
  4. CanAuthor — geben Sie für schreibgeschützte Quellen false zurück; die Authoring-Fenster deaktivieren ihre Bearbeitungsoberfläche (genau wie bei Google ExportUrl).

Der stabile String Id wird in sourceProviderId gespeichert. Die eingebauten Quellen verwenden "LocalFile" / "GoogleSheet" als ihre Ids; eine leere sourceProviderId löst sich zum eingebauten LocalFile-Standard auf. Eine leere Id nimmt den Provider aus der UI heraus (nützlich für Test-Sonden).

Provider leben bewusst in der Editor-Assembly — Quellen sind die IO-Grenze, und IO aus dem Core herauszuhalten bewahrt dessen Reinheit (die anderen drei Verträge sind reiner Core).

5.5 Öffentliche Werkzeuge für Automatisierung und Integration

Über die Registrierungsverträge hinaus existieren fünf öffentliche Einstiegspunkte für Code, der SheetForge steuert, statt es zu erweitern — ein CI-Skript, ein Build-Hook, Ihre eigene Inspector-Schaltfläche, oder ein zweites Produkt, das seine eigenen Assets aus denselben Tabellen bakt.

Einen Zyklus ausführenSheetForge.Editor.Pipeline.SheetForgeActions:

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

Jeder Aufruf ist der gesamte Zyklus: Settings-Auflösung, die Addressables-Sperre, gegenseitiger Ausschluss, Bestätigungs- und Genehmigungsdialoge, die Fortschrittsanzeige und die Codegen→Kompilierung→Bake-Fortsetzung über den Domain-Reload hinweg. Es gibt keinen Halb-Zyklus zusammenzustellen und daher keine Sperre, die versehentlich übersprungen werden könnte.

Zwei Dinge sollten Sie wissen:

  • RunImport und RunPush sind fire-and-forget — ihre Bodies sind async void, weil der Editor-Hauptthread bei Netzwerk-IO nicht blockieren darf. Die Rückkehr ist daher keine Fertigstellung; abonnieren Sie dafür ImportEvents.ImportCompleted.
  • Push zeigt weiterhin seinen Genehmigungsdialog, sodass ein unbeaufsichtigtes Skript nicht ohne eine Person senden kann.

Dieselbe Sperre nehmen, die die eingebauten Pfade nehmen — für einen benutzerdefinierten Source-Provider, der in sein eigenes Backend schreibt:

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 beantwortet dieselbe Frage, ohne etwas zu nehmen. Die Sperre selbst bleibt absichtlich internal: Wäre sie öffentlich, könnte ihr Aufruf von End() den Lauf einer anderen Stelle freigeben — der Scope macht das unmöglich, weil nur der Halter freigeben kann.

Ein Rückschreiben so beenden, wie die eingebauten Pfade es beendenAuthoringDispatcher.FinalizeReflectSuccess(writtenTabs) durchläuft den Abschluss, den ein ISourceReflectTarget erreichen muss:

  • Retain-Bereinigung für die geschriebenen Tabs,
  • die ClearUndo-Bestätigungsgrenze,
  • und den automatischen erneuten Import.

Die eingebauten lokalen und Google-Pfade durchlaufen denselben Rumpf, sodass Ihr Provider identisch endet, statt es nur anzunähern. BuildProjectedTabs() daneben gibt Ihnen die Projektion als TSV pro Tab — was Sie gleich senden werden —, sodass ein Provider sie vorschauen oder transformieren kann, ohne zu schreiben. Eine leere writtenTabs-Liste ist ein No-op, der das Staging intakt lässt.

Die eigenen Sätze des Produkts in Ihrer eigenen UI zeigenImportReportText.Render(report) (Core.Tooling) gibt den menschenlesbaren Bericht als String zurück, ohne etwas in die Konsole zu schreiben; SheetForgeActions.RenderReportText(report) ist dasselbe in der aktuellen Editor-Sprache des Nutzers. Verwenden Sie es mit AuthoringDispatchCallbacks.RenderReport, damit eine zweite Authoring-Oberfläche Fehlschläge in genau den Worten meldet, die das Produkt verwendet.

Einen per Bake erzeugten Tab aufzählen, ohne seinen generierten Typ zu kennenDefinitionDatabase.RecordsUntyped:

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

Dies ist der offiziell vorgesehene Weg für einen zweiten Baker (ein anderes Produkt, das dieselben Tabellen in seine eigenen Assets verwandelt). Verwenden Sie keine Reflection auf das private Feld records: Das würde einen Feldnamen zu einem unerklärten Vertrag machen, der an dem Tag still bricht, an dem Codegen ihn umbenennt. Die Liste ist schreibgeschützt — die Tabelle ist kanonisch. Sie ist standardmäßig leer bei generiertem Code, der vor Existenz dieses Members geschrieben wurde; ein erneuter Import gibt den Override aus.

Die generierten Klassen sicher erweitern — beide generierten Klassen sind partial, sodass ein abgeleitetes Member (eine berechnete Eigenschaft, eine Interface-Implementierung, ein Operator) in Ihrer eigenen Datei neben ihnen leben und jeden erneuten Import überstehen kann. Fügen Sie dort keine serialisierten Felder hinzu: Das per Bake erzeugte ScriptableObject wird bei jedem Import aus der Tabelle neu aufgebaut, daher kommt ein Feld, das nur Ihr Teil serialisiert, auf seinen Standardwert zurück. Gehört ein Wert zu den Daten, gehört er in eine Spalte.

Was geschlossen bleibt — mit Absicht

Die obigen Oberflächen sind der offiziell vorgesehene äußere Rand. Das Folgende bleibt internal, egal wie bequem es aussähe, es zu öffnen, weil jedes davon eine Vertrauens- oder Integritätsgrenze ist, keine Bequemlichkeitsgrenze:

  • Zugangsdaten und Signierung — der Service-Account-Schlüssel-Locator, die JWT-/PEM-/PKCS8-Primitive und der Google-Access-Token-Provider. Sie zu öffnen, würde jedem Plugin ein Bearer-Token in die Hand geben, das für Ihre Tabelle gilt.
  • Die rohe Push-Kette (Push-Runner, Sheet-Gateways, Zellschreibvorgänge) — Genehmigung (IPushApprover) wird innerhalb dieser Orchestrierung erzwungen; ein öffentlicher roher Writer wäre ein Sheet-Schreibvorgang ohne Genehmigungsschritt.
  • Vorab-Sende-Verifikation und die Übernehmen-Schreib-Engines — externer Code betritt nur über AuthoringDispatcher.Reflect(), das auf dem Weg Prüfungen auf veraltete Anker, Vorab-Validierung und Genehmigung durchläuft; die darunterliegende Schreib-Engine ist kein Vertrag.
  • Die Bake-/Codegen-Integritätskette (Schema-Fingerabdrücke, Writer für generierten Quellcode, Waisen-Bereinigung) und das Build-Aktualitäts-Gate — sie zu öffnen, würde das Fälschen oder Umgehen des Bake-Zustands zu einem Einzeiler machen.
  • Das Ephemeral-SO-Overlay — „die Tabelle ist die Quelle der Wahrheit" hat genau eine offiziell vorgesehene Ausnahme (der Testbearbeitung-Schalter im Inspector), und diese wird bewusst nicht als API angeboten.

Scheint ein Workflow eines davon zu benötigen, braucht er einen Feature-Wunsch, keine Reflection.

6. Platzierung generierten Codes und Namespaces

  • generatedCodeFolder kann jeder beliebige Ordner sein (das selbstheilende Companion-asmdef verdrahtet Plugin-Typ-Referenzen automatisch), aber die Platzierung innerhalb Ihres Pakets (z. B. Assets/MyDomain/Runtime/Generated) ist am saubersten — generierte Typen kompilieren dann in derselben Assembly wie Ihre enums/benutzerdefinierten Typen, ohne dass ein Companion-asmdef nötig ist.
  • Heimatort pro Tab: Ein Tab, dessen generierter Typ bereits irgendwo existiert, wird an Ort und Stelle neu generiert — der committete Generated-Ordner Ihres Pakets bleibt maßgeblich, selbst wenn die Settings anderswohin zeigen. Veraltete Duplikate werden automatisch bereinigt (protokolliert, nie stillschweigend).
  • generatedNamespace isoliert Ihre generierten Typen (z. B. MyGame.Data). Die Typ-Erkennung nutzt die intrinsische SchemaFingerprint-Markierung der generierten Typen, nicht den Namespace, sodass jeder Namespace funktioniert. Das Ändern des Werts löst automatisch eine Regenerierung aus.
  • Ob Sie den Generated-Ordner Ihres Pakets committen, ist die Richtlinie Ihres Pakets. Das Beispiel committet seinen (Example*-Klassen im Standard-Namespace SheetForge.Generated, Tabs ExampleSkills/ExampleEffects/ExampleActions), damit ein frischer Klon sofort kompiliert — und das Klassennamen-Präfix Example*, nicht ein eigener Namespace, ist es, was verhindert, dass sie mit den echten Skills/Effects-Tabs Ihres Projekts kollidieren.

7. Konsum zur Laufzeit — „zusammensetzen statt skripten"

Ihre Runtime liest die generierten Datenbanken und dispatcht anhand des type-enum zu Code-Atomen:

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

Für wirklich prozedurale Einmal-Logik referenzieren Sie ein Skript-Asset über AssetRef — SheetForge validiert die Referenz und bakt das Addressable (genau wie bei einem Bild); es auszuführen ist Aufgabe des Spiels.

8. SheetForge aus einem anderen Asset erkennen

Ein anderes Asset — eines, das sich mit SheetForge integriert, statt es zu erweitern (zum Beispiel ein Stat-System) — kann erkennen, dass SheetForge installiert ist. Da ein bezahltes Asset-Store-Produkt ein Ordner-Produkt ist (kein package.json / UPM), kann es keinen versionDefines-Eintrag ausliefern; stattdessen registriert die Editor-Assembly von SheetForge bei jedem Build-Target selbst ein SHEETFORGE-Scripting-Define-Symbol.

(a) Kompilierzeit (bevorzugt):

  • Wenn Ihre Integration in einer eigenen Assembly-Definition lebt, fügen Sie SHEETFORGE zu den Define Constraints dieser asmdef hinzu — die Assembly kompiliert dann nur, wenn SheetForge vorhanden ist.
  • Wenn Code, der SheetForge berührt, sich eine Assembly mit Code teilt, der in jedem Fall kompilieren muss, schützen Sie nur diese Teile mit #if SHEETFORGE … #endif.

(b) Editor-Zeit (Alternative): Wenn Sie sich nicht auf die Kompilierreihenfolge verlassen können, prüfen Sie per Reflection — z. B. System.Type.GetType("SheetForge.Editor.Pipeline.ImportEvents, SheetForge.Editor") != null — und verdrahten dann (zum Beispiel) den Completion-Bus dynamisch.

SHEETFORGE bedeutet „SheetForge ist installiert". Es ist getrennt von SHEETFORGE_ADDRESSABLES, einem internen Version-Define auf SheetForges eigenen Assemblies, das lediglich markiert, ob das Addressables-Paket vorhanden ist — verwenden Sie Letzteres keinesfalls als Installations-Sonde.

Das Define bleibt bestehen, wenn SheetForge später entfernt wird (es gibt keinen Watcher, der es zurücksetzt); entfernen Sie es manuell unter Project Settings ▸ Player. Siehe Möglichkeiten & Grenzen.

Was weiterhin Änderungen am Core erfordert

Alles oben Genannte tritt ohne jede Änderung am Core bei. Was ein Plugin ohne Core-Änderungen weiterhin nicht tun kann:

  • Markierungswerte in generierten Code ausgeben — benutzerdefinierte Markierungen sind Validierungs-/Anzeige-Metadaten; sie in Codegen-Konstanten oder -Attribute zu backen liegt außerhalb des Umfangs, bis ein Konsument dies benötigt.
  • Umbenennungs-Propagierung von sich aus in eine benutzerdefinierte Notation hineinreichen lassen, ohne dass ihr gesagt wird, wie — ein umbenannter Datensatz wird von sich aus in RecordId@Tab-Zellen, Listen davon und Wrapper-Elementen umgeschrieben. Für Ihre eigene Grammatik implementieren Sie IReferencingCellType (§4.4a), und sie wird mit erhaltener Nutzlast umgeschrieben; das ist ein Opt-in, keine Core-Änderung. Lehnen Sie das Opt-in ab, bleibt die Grenze bestehen: Ihr Domänen-Validator meldet den hängenden Schlüssel, statt dass die Umbenennung ihn stillschweigend behebt.
  • Members zu einem per Plugin registrierten C#-enum aus einer Tabelle hinzufügen — ein mit enums.Register<T>() registriertes Enum gehört dem Code, daher kann eine Enum-Definitionstabelle es nicht erweitern, und das Data Studio bietet die Zeile nicht an. Verschieben Sie das Enum in eine Enum-Definitionstabelle, falls die Tabelle es besitzen soll (siehe Tabellensyntax).

Der <>-Wrapper-Typ (ICellWrapperType, siehe §2) und die benutzerdefinierte Strukturmarkierung (IStructuralMarkerDefinition, siehe §4.5) erweitern beide die Pipeline, ohne Änderungen am Core zu erfordern.

Verwandte Seiten

  • Tabellensyntax – wie registrierte Typen in Tabellen erscheinen
  • Data Studio – wo die Canvas-Overrides, Code-Registries, Widgets und Aktionen erscheinen
  • API-Referenz – die vollständige Signatur jedes Vertrags
  • Authoring-Kernel – Kanten und die Engine-Oberfläche
  • Möglichkeiten & Grenzen – die Grenzen der Plugin-Erweiterung (Wrapper-Ablehnungsregeln, Markierungs-Grenzen) und reservierte Nahtstellen