Création de plugins — ajouter un domaine sans aucune modification du Core
Un domaine (compétences, items, quêtes, …) rejoint SheetForge comme un package séparé qui référence SheetForge.Core — le Core ne le référence jamais en retour.
Un plugin peut ajouter des enums, des types de cellule personnalisés, des types wrapper, des validateurs de domaine, des arêtes de graphe, des marqueurs structurels, des modèles « Créer une feuille », des sources d'import entières, les surcharges de canevas du Data Studio, des registres de code, des surfaces de création déclaratives, des widgets et des actions, des préréglages de couleur, des widgets de cellule personnalisés, des observateurs de pipeline, et ses propres chaînes d'UI localisées — les seize contrats ci-dessous.
« Ajouter un domaine = zéro ligne du Core modifiée » est imposé par le compilateur. Un assembly de test sans InternalsVisibleTo (SheetForge.Tests.Consumer) implémente quinze des seize — ainsi que les interfaces de capacité à leurs côtés — en utilisant la seule surface publique. Si l'un d'eux était restreint à internal, le build échouerait (CS0122). Le seizième, l'échappatoire de panneau riche réservée à l'éditeur, renvoie un VisualElement et est donc exercé par un test côté éditeur à la place.
Les onze contrats Core sont du C# pur. C'est ce qui permet à une seule DLL de plugin compilée d'allumer les mêmes emplacements dans l'éditeur Unity et dans le navigateur (SheetForge Web) — l'assemblage et l'isolation sont une seule fonction Core partagée, et seule la découverte diffère selon l'hôte (le TypeCache d'Unity, le scan d'assembly téléversé du navigateur).
Les cinq contrats Editor rendent des éléments UIToolkit ou touchent à l'état de la fenêtre, ils n'existent donc que dans l'éditeur.
Les seize sont découverts automatiquement — un constructeur sans paramètre est toute l'exigence, sans référence d'assembly, appel d'enregistrement ni manifeste à modifier :
| Contrat | Enregistre | Optionnel ? |
|---|---|---|
ISheetForgePlugin | Enums + parseurs de type de cellule personnalisés | Le contrat de base |
ISheetForgeValidatorPlugin | Règles de validation de domaine (inter-colonnes / inter-onglets) | Module optionnel |
ISheetForgeEdgePlugin | Déclarations d'arête de graphe que le scanner du Core ne peut pas voir | Module optionnel |
ISheetForgeMarkerPlugin | Marqueurs structurels personnalisés (lignes @marker par colonne) | Module optionnel |
ISheetForgeTemplatePlugin | Modèles « Créer une feuille » (onglets + données d'exemple) | Module optionnel |
ISheetForgeGraphPlugin | Surcharges de canevas par onglet pour le Data Studio | Module optionnel |
ISheetForgeCodeRegistryPlugin | Espaces de clé en lecture seule qui vivent dans le code, comme des onglets virtuels verrouillés | Module optionnel |
ISheetForgeThemePlugin | Préréglages de couleur pour les fenêtres de SheetForge (sombre et clair) | Module optionnel |
ISheetForgeStudioPlugin | Surfaces de création déclaratives — actions, panneaux, badges de colonne, indices d'éditeur de cellule | Module optionnel |
ISheetForgeStringsPlugin | Les chaînes d'UI de votre pack, par langue (une surcouche consultée avant les tables du produit) | Module optionnel |
ISheetForgePipelinePlugin | Observateurs de pipeline — notification en lecture seule de ce qu'un import a produit | Module optionnel |
ISheetSourceProvider | Toute une source d'import (DB / REST / maison) | Indépendant (assembly Editor) |
IStudioGraphWidget | Un widget de domaine au-dessus du canevas du Data Studio | Indépendant (assembly Editor) |
IStudioInspectorAction | Un bouton supplémentaire sur l'inspecteur de nœud du Data Studio | Indépendant (assembly Editor) |
IStudioCellEditorProvider | Un widget de saisie personnalisé pour un type de cellule dans la grille du Data Studio | Indépendant (assembly Editor) |
IStudioPanelProvider | Un panneau UIToolkit arbitraire dans le Studio — l'échappatoire à côté de celle déclarative | Indépendant (assembly Editor) |
L'exemple de référence est un import sélectif. L'exemple complet et abouti (
SheetForge.PluginDemo) est livré sous forme de package Unity àAssets/SheetForge/Examples/SheetForgePluginDemo.unitypackage— double-cliquez dessus, ou appuyez sur Importer le Plugin Demo dans la fenêtre Prise en main (Tools ▸ SheetForge ▸ Getting Started, l'unique endroit où vivent les imports de démo), pour le restaurer sousAssets/SheetForge.PluginDemo/…. Tant que vous ne l'avez pas importé, il n'est pas du tout présent dans votre projet — l'exemple n'est distribué que sous forme de ce package — si bien que ses assemblies/types/onglets/adresses n'entrent jamais en collision avec le vôtre. Les chemins référencés ci-dessous (Assets/SheetForge.PluginDemo/ModifierCellParser.cs, etc.) existent une fois que vous avez importé le package. (Un second exemple, sans plugin —SheetForge.CoreDemo— démontre le pipeline avec uniquement les types intégrés du Core.)
Les modules optionnels étendent l'interface de base sans la modifier — un plugin qui n'a besoin ni de validation ni d'arêtes n'est pas affecté par leur existence.
Sept interfaces supplémentaires sont des capacités plutôt que des contrats :
- Elles ne sont pas découvertes seules.
- Elles sont implémentées en plus par quelque chose déjà enregistré.
- Le Core les trouve en castant cet objet enregistré.
Six sont castées depuis un contributeur d'arêtes ou une surcharge de canevas enregistrés — voir §4.12 pour la règle de découverte et chacune d'elles. La septième, IReferencingCellType, est castée depuis un parseur de cellule enregistré. Elle donne à votre propre notation le même traitement de référence que reçoit RecordId@Tab — voir §4.4a. Les ignorer ne change rien.
1. Configuration du package
Créez un dossier avec son propre .asmdef référençant SheetForge.Core (plus SheetForge.Runtime si vous avez besoin de recherche à l'exécution). C'est tout — le PluginRegistry de l'Editor découvre votre implémentation d'ISheetForgePlugin via TypeCache et appelle vos méthodes d'enregistrement. L'enregistrement, c'est votre code explicite, pas un scan d'assembly.
Gardez les implémentations des contrats Core dans cet assembly principal. Un assembly compagnon côté éditeur (référençant aussi SheetForge.Editor) est l'endroit où vont les cinq implémentations IStudio* / ISheetSourceProvider — le navigateur ne charge que votre DLL principale, donc un contrat Core implémenté dans le compagnon éditeur y serait silencieusement absent.
1.1 Déclarer la compatibilité (optionnel, une ligne)
Un attribut au niveau de l'assembly indique pour quelle génération du format de plugin votre assembly a été compilé, et l'hôte minimal qu'il souhaite :
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- L'omettre est parfaitement possible. Un assembly sans déclaration est lu comme la génération
SheetForgePluginFormat.Minimumsans exigence d'hôte, si bien que les plugins écrits avant que l'attribut n'existe se chargent exactement comme avant. - L'unité de jugement est l'assembly, et un assembly rejeté perd tous ses enregistrements. Une déclaration par type laisserait passer un type voisin non déclaré et vous laisserait avec « refusé, mais à moitié enregistré ».
- La DLL est le juge, pas le catalogue. Le registre du marché annonce les deux mêmes valeurs (
pluginFormat,minHost) afin qu'une fiche puisse être filtrée avant téléchargement, mais la barrière lit l'attribut dans les octets vérifiés — une fiche peut se tromper, la déclaration compilée ne le peut pas. - Le rejet est un diagnostic
PluginIncompatiblenommant ce que l'assembly a déclaré et ce que cet hôte lit, pas une disparition silencieuse. C'est une déclaration de compatibilité, pas une signature : l'intégrité est le travail du canal de distribution (voir Marché de plugins Web). - Le numéro de génération ne bouge que si le format de plugin lui-même est remplacé. Une croissance purement additive — un nouveau contrat, un nouveau membre sur un registre — ne le fait jamais bouger, car votre plugin existant continue de fonctionner sans recompilation.
2. Le plugin de base : enums + types de cellule personnalisés
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());
}
}Un type de cellule personnalisé de bout en bout
Implémentez ICellValueParser (chaîne → valeur) et, pour compléter le bake fortement typé et l'aller-retour Export/Push, également ICustomCellType (type CLR + valeur → chaîne canonique). La mini-grammaire Modifier de l'exemple (stat:op:value, par ex. 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;
}
}(Voir Assets/SheetForge.PluginDemo/ModifierCellParser.cs pour la version complète de production, avec validation des jetons d'opérateur et suggestions de correspondance la plus proche.)
Le @target sur les types personnalisés fonctionne par le seul enregistrement : déclarez une colonne comme Modifier@Stats et votre parseur lit context.Type.TargetName ("Stats"). La vérification d'intégrité de cette cible (l'onglet existe-t-il ? l'id se résout-il ?) revient à un validateur de domaine — la même répartition du travail que pour RecordId@Tab. Un nom de type non enregistré avec @ reste une erreur assortie d'une suggestion, si bien que la sécurité contre les fautes de frappe est préservée.
Les noms de type que le Core possède déjà. Les noms de scalaire intégrés — int, float, bool, string, Enum, RecordId, IntId, AssetRef, Color, AnimationCurve et Gradient — sont enregistrés avant tout plugin. Un parseur qui réutilise l'un d'eux échoue à l'enregistrement avec PluginRegistrationConflict — l'intégré reste, cet appel à RegisterCellParsers s'arrête au parseur en conflit, et les autres emplacements du plugin se chargent quand même — si bien qu'un pack qui livrait son propre type Color ou Gradient doit le renommer (voir les notes de mise à niveau dans le changelog). Si votre type stocke une couleur, une courbe ou un dégradé, vous n'avez pas à réimplémenter la notation : les modèles de valeur du Core ColorValue, CurveValue et GradientValue exposent TryParse(text, out value, out error) et Render(), CurveEvaluator / GradientEvaluator les échantillonnent exactement comme le fait Unity, et un StudioCellEditorHint avec l'archétype ColorPicker, CurveEditor ou GradientEditor (§4.16) ouvre l'éditeur natif pour votre type dans les deux hôtes.
Un type wrapper de bout en bout (MyWrapper<T>)
Un wrapper est une forme de valeur générique — Pair<int> = 1~2 — qui regroupe plusieurs valeurs internes T dans une seule cellule. Vous ne possédez que la syntaxe externe (délimiteur, arité) ; le Core analyse le T interne de façon récursive, si bien que Pair<RecordId@Effects>, Pair<Enum<DamageType>>, et l'imbrication Box<Pair<int>> s'analysent et se valident sans code supplémentaire, et les références internes sont pleinement validées. Implémentez ICellWrapperType et enregistrez-le dans le même hook RegisterCellParsers via 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());Ce seul enregistrement vous donne :
- la résolution récursive de
@type; - un codegen fortement typé (
Pair<RecordRef> First;) ; - le bake ;
- l'aller-retour Export/Push ;
- la transmission des références — un
RecordId@Tabà l'intérieur du wrapper est vérifié en intégrité, propagé lors d'un renommage de clé, et réécrit lors d'un renommage d'onglet.
Les règles de rejet et la mise en garde sur le délimiteur ; sont documentées dans Syntaxe des feuilles.
3. Validateurs de domaine (optionnel)
La validation du Core est fixée à quatre types (clés, références, @overlap, clés d'asset). Pour des règles inter-colonnes (« si type vaut Custom, script est obligatoire ») ou des règles inter-onglets (vérifier le sens d'un enregistrement référencé), implémentez 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'."));
}
}
}Les validateurs enregistrés rejoignent automatiquement à la fois la validation d'import et la validation préalable de création. Règles :
- Signalez les violations dans
ctx.Errorssous la formeImportErrorCode.DomainRuleViolation— ne levez jamais d'exception (une exception levée est isolée et remontée ; les autres validateurs continuent de s'exécuter). - Remplissez les quatre éléments — où (
CellCoordinate), quoi (ActualValue), pourquoi (Expected), comment (Suggestion). Le « comment » est affiché tel quel comme phrase exploitable. ctxvous donne :- toutes les tables analysées (
Tables) ; - les index de clé (
KeyIndices) ; - les clés d'asset (
AssetKeys—nullsignifie que la validation des assets a été ignorée).
- toutes les tables analysées (
- La collecte intégrale et l'absence d'assemblage partiel sont héritées automatiquement.
4. Contributeurs d'arêtes (optionnel)
Si vous construisez de l'outillage par-dessus le graphe de données (ou si vous voulez qu'un futur canevas de graphe voie les connexions de votre domaine), déclarez les arêtes que le scanner de référence du Core ne peut pas voir — par ex. une statistique référencée à l'intérieur d'une valeur en mini-grammaire :
public sealed class SkillsPlugin : /* ... */, ISheetForgeEdgePlugin
{
public void RegisterEdgeContributors(EdgeContributorRegistry contributors)
{
contributors.Register(new ModifierStatEdgeContributor()); // effect → stat edges
}
}Un IEdgeContributor reçoit un contexte inter-onglets en lecture seule et ajoute des éléments EdgeSpec (onglet source/cible + id d'enregistrement, champ optionnel, enregistrement de charge utile, libellé). Les contributeurs n'émettent jamais de diagnostics — les arêtes sont de la matière de projection, pas de la validation. Voir Noyau de création.
4.4 Recette : un type personnalisé qui porte une clé en son sein
RecordId@Tab est la seule forme de référence que le Core comprend, et elle reçoit gratuitement la vérification d'intégrité, les arêtes de graphe, les suggestions de correspondance la plus proche et la propagation de renommage. Dès l'instant où votre propre notation avale une clé — attack:add:10, stat.hp>50, fire@0.4 — le Core ne voit qu'une chaîne opaque, si bien que ces quatre services s'arrêtent à votre porte. Trois enregistrements en restaurent trois. Écrivez-les comme un ensemble ; une mini-syntaxe qui n'a qu'un des trois est la forme qui produit « ça s'importe très bien mais rien ne pointe vers rien ».
| Pièce | Contrat | Ce qu'elle restaure | Sans elle |
|---|---|---|---|
| 1. Intégrité | IDomainValidator (§3) | Une clé à l'intérieur de votre notation qui n'existe pas est signalée, avec la coordonnée et une phrase exploitable | Une faute de frappe s'importe proprement et échoue à l'exécution |
| 2. Visibilité | IEdgeContributor (§4) | Le lien enfoui devient une véritable arête : le canevas le dessine, la liste Used by le compte, l'index de référence l'indexe | La connexion existe dans les données et nulle part à l'écran |
| 3. Le « comment » | TextSuggestion.FindNearest à l'intérieur de la pièce 1 | « Unknown stat 'atack'. Did you mean 'attack'? » — la même forme de phrase qu'utilisent les erreurs de référence intégrées | Un diagnostic correct sans aucun moyen d'agir dessus |
// 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."));
}
}
}Réutilisez un seul découpeur pour la notation — le parseur, le validateur et le contributeur d'arêtes doivent s'accorder sur où une clé commence et se termine, et trois copies privées de ce découpage sont la façon dont ils finissent par diverger. (Un type wrapper, §2, obtient cela gratuitement : TrySplit est le découpeur partagé.)
Le quatrième service — la propagation de renommage — a besoin d'une chose de plus, et il y a deux façons de l'obtenir. Renommer un enregistrement ne réécrit les cellules qui le référencent que là où le Core peut trouver la clé dans le texte. Il peut le faire pour un champ RecordId@Tab, une liste de ceux-ci, et un wrapper dont le TrySplit expose la clé comme un élément. Il ne peut pas deviner tout seul les limites de sous-chaîne de votre grammaire. Donc soit vous :
- lui dites comment — implémentez
IReferencingCellType(§4.4a), ce qui remplace toute cette recette en trois pièces par un seul opt-in et restaure les quatre services d'un coup ; - acceptez la limite, qui est au moins honnête plutôt que silencieuse : la pièce 1 signale la clé désormais pendante au prochain import, avec la coordonnée et la suggestion.
La recette ci-dessus reste la bonne réponse dans un cas : quand la colonne n'a aucun @target parce qu'il n'y a pas un seul onglet où vit la clé. L'exemple fourni est exactement ce cas — List<Modifier> ne nomme aucune cible, donc le Core ne peut pas savoir où attack devrait se résoudre, et ModifierStatEdgeContributor ouvre ces arêtes à la main. Donnez une cible à la colonne (List<Modifier@Stats>) et le §4.4a prend le relais.
4.4a Donner à votre propre notation la parité complète de référence (opt-in)
Implémentez IReferencingCellType sur un parseur que vous enregistrez déjà, et une colonne MyType@Tab cesse d'être un cas particulier : elle est validée, suggérée, propagée, dessinée, sélectionnée et indexée exactement comme RecordId@Tab.
Il n'y a pas de nouveau canal d'enregistrement. Le Core caste les parseurs déjà présents dans CellParserRegistry, de la même façon que les capacités de canevas sont castées depuis des contributeurs d'arêtes enregistrés (§4.12). Un type personnalisé qui ne l'implémente pas se comporte exactement comme avant, bit pour bit.
Les cinq hooks
Ils fonctionnent tous sur un seul élément : la cellule entière pour une colonne scalaire, ou un élément séparé par ; pour List<MyType@Tab> — la même unité que reçoit votre ICellValueParser.TryParse.
| Hook | Répond à | Utilisé pour |
|---|---|---|
bool TryGetTokenKey(elementText, out key) | « Vers quoi pointe cet élément ? » | L'appartenance — cette cellule est-elle déjà liée à cet enregistrement |
string MakeToken(key) | « Écrire un nouveau lien vers cette clé » | Une cellule vide, ou un ajout à une liste. Remplissez la charge utile avec un point de départ neutre ; une surface de création ne doit pas inventer de valeurs. Renvoyez null/vide et le geste est désactivé avec une raison au lieu d'être simulé |
bool TryRetargetToken(elementText, newKey, out newText) | « Pointer ceci vers autre chose » | Choisir un enregistrement différent dans la cellule ▾, et réorienter un fil sur le canevas. Changez uniquement la cible — retirer puis refaire le jeton réinitialiserait les nombres qu'une personne a tapés |
bool TryRemoveToken(elementText, key, out newText) | « Délier ceci » | Renvoyez un texte vide et l'élément disparaît (la cellule scalaire se vide, l'élément de liste est retiré) ; renvoyez un texte non vide et cette partie reste |
bool TryRewriteKeys(elementText, renames, out newText) | « Substituer toutes ces clés » | Le passage de renommage. Séparé de TryRetargetToken car celui-ci est une instruction unique venant d'une personne alors que celui-ci est une passe en masse — et un élément portant deux références doit réécrire les deux |
La moitié texte et la moitié valeur
Ajoutez aussi IRefBearingValue à la valeur analysée — les deux moitiés font des travaux différents et les deux sont nécessaires. La moitié texte ne peut pas voir une valeur analysée ; la moitié valeur ne peut pas restaurer la notation que l'auteur a tapée :
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 };
}Implémenter une interface n'ajoute aucun champ, si bien que le ScriptableObject bake et le code généré restent inchangés.
Ce que vous obtenez, à partir d'un seul opt-in — chacun de ces éléments est le propre chemin de code du Core, pas une réimplémentation :
- Intégrité + suggestions — une clé qui n'existe pas est signalée comme
UnresolvedRecordIdavec la coordonnée et « did you mean … », en partageant le budget de suggestion par champ avec les références intégrées. - Propagation de renommage avec la charge utile intacte — renommer
attackenpowerréécritattack:add:10enpower:add:10; l'opérateur et le nombre appartiennent à l'auteur, et ils survivent. - Graphe — le lien devient une véritable arête avec des coordonnées : elle est dessinée, le nœud reçoit un port, la liste Used by la compte, et l'index de référence l'a dans les deux sens.
- Le sélecteur
▾— la cellule reçoit la même liste déroulante cherchable qu'a une celluleRecordId@Tab, et choisir un enregistrement différent remplace la cible et conserve le reste. Sans l'enregistrement, le sélecteur se refuse plutôt que de coller une clé nue par-dessus votre valeur. - La détection d'orphelins et la règle de liste déroulante exportée — une ligne dont le seul lien sortant vit à l'intérieur de votre notation n'est plus traitée comme non connectée, et une colonne scalaire de votre type reçoit une liste déroulante de validation des données sur les clés de l'onglet cible (Sources, export et push).
L'usage le plus simple est un type alias. Si la valeur n'est qu'une clé et que le texte de la cellule est cette clé :
TryGetTokenKeyréduit les espaces.MakeTokenrenvoie la clé.TryRetargetTokenrenvoie la nouvelle clé.TryRemoveTokenrenvoie du vide.
La colonne est alors un RecordId@Tab à tous égards fonctionnels. La seule chose qui vous reste, c'est la présentation : elle apparaît sous son propre nom dans @type, et vous pouvez attacher un widget de cellule (§4.13) ou une forme de canevas (§4.7) à cette seule colonne. Aucun contrat séparé n'est nécessaire pour un alias.
Deux contraintes, toutes deux structurelles :
- Pas de
;dans la charge utile. Le Core découpe une cellule de liste en éléments avant que votre parseur ou l'un de ces hooks ne voie le texte, si bien qu'un point-virgule à l'intérieur d'une valeur serait déchiqueté en deux éléments. (Les types wrapper portent la même contrainte pour la même raison.) @targetdoit nommer un véritable onglet de feuille, exactement comme le faitRecordId@Tab— l'onglet virtuel d'un registre de code est rejeté avecUnknownTargetTab. Cette restriction est ce qui permet au signalement de référence non résolue, aux suggestions de correspondance la plus proche et à la propagation de renommage d'être ceux du Core, non modifiés.
Aucun des cinq hooks ne peut lever d'exception : répondez false ou null pour tout ce que vous ne pouvez pas interpréter, et préservez le reste chaque fois que vous réécrivez.
Cela fonctionne aussi contre un espace de clé entier. Si l'onglet que nomme votre @target a pour clé IntId plutôt que RecordId, rien ne change dans votre code — la clé que vos hooks renvoient et reçoivent est simplement l'entier écrit en texte. Quel espace de clé comparer est décidé par la propre identité de l'onglet cible, pas par votre type.
- La validation, les suggestions de correspondance la plus proche, la propagation de renommage, les arêtes, le sélecteur et la détection d'orphelins s'activent tous de la même façon.
- Une attention que le Core ajoute pour vous là : comme un entier peut s'écrire de plusieurs façons, un renommage remet à
TryRewriteKeysl'orthographe telle qu'elle apparaît dans cet élément à côté de la forme canonique (007et7correspondent tous deux à12), si bien qu'une recherche ordinale à l'intérieur de votre type ne rate pas une valeur complétée par des zéros. - La démo fournie n'inclut pas de type personnalisé référençant visant un onglet
IntId— son exempleModifiercible un onglet à clé chaîne — donc ce chemin a des tests mais aucun exemple abouti à copier.
4.5 Marqueurs structurels personnalisés (optionnel)
Les marqueurs intégrés sont @name, @type, @desc, et trois optionnels :
@overlap;@style, qui décrit la feuille — son libellé de groupe et sa couleur — plutôt que ses colonnes ;@enum, qui marque la feuille comme un ensemble de définitions d'enum plutôt qu'une table.
@overlap est un marqueur par colonne : sa ligne porte une valeur par colonne, validée colonne par colonne. Vous pouvez enregistrer vos propres marqueurs de la même façon — par exemple un marqueur @curve qui enregistre la façon dont chaque colonne numérique interpole. Implémentez IStructuralMarkerDefinition et enregistrez-le via 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");
}
}La feuille accepte alors une ligne @curve (n'importe quel ordre, au-dessus des données) :
@name | level | atk
@type | int | int
@curve | | ease
| 1 | 10- La valeur est stockée comme métadonnée indépendante du domaine :
field.MarkerValues["curve"]. Un validateur de domaine ou un contributeur d'arêtes la lit depuiscontext.Tables[tab].Schema.Fields[i].MarkerValues; la fenêtre de création l'affiche dans l'infobulle de l'en-tête de colonne. - Une cellule rejetée devient un diagnostic
MarkerCellInvalid— vous fournissez le « pourquoi » et le « comment corriger » ; le Core fournit la coordonnée et la valeur fautive. - Les noms de marqueur doivent être des identifiants valides et ne doivent pas entrer en collision avec les six intégrés (
@name/@type/@desc/@overlap/@style/@enum—Registerlève une exception sinon, remontée commePluginRegistrationConflict). - Les marqueurs servent à des métadonnées par colonne, pas à de nouvelles formes de données — un marqueur possède sa propre validation de cellule, pas la ligne entière. Les lignes de marqueur personnalisées sont préservées telles quelles lors de l'export/aller-retour, et déplacées avec leur colonne par chaque modification de structure (ajout / suppression / déplacement / renommage).
- Le codegen ne bake pas les valeurs de marqueur (comme
@overlap, ce sont uniquement des métadonnées de validation/affichage, invisibles pour l'empreinte de schéma).
4.6 Modèles « Créer une feuille » (optionnel)
Le flux Créer une feuille propose deux modèles intégrés — une feuille d'items utilisant uniquement les types du Core, et une feuille de définitions @enum — plus « à partir de zéro ». Les modèles de domaine — des squelettes de feuille qui utilisent vos enums, types personnalisés et références — proviennent des plugins, si bien qu'un modèle est présent exactement quand son plugin l'est. Implémentez 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", /* ... */ ""),
}));
}
}- Un modèle porte un ou plusieurs onglets, chacun un TSV normalisé complet (lignes de commentaire/marqueur plus données d'exemple) — contrairement à l'exemple d'item intégré, qui est un squelette à 0 ligne. Comme vos types de domaine sont déjà enregistrés (le plugin est chargé), les feuilles créées se réimportent avec succès immédiatement.
- Les chaînes d'affichage vous appartiennent. Un plugin possède son propre texte (le package d'exemple est en dehors du garde-fou de vocabulaire du domaine) — vous n'êtes pas limité aux clés
Locdu Core. - Les modèles multi-onglets créent tous leurs onglets et se réimportent une seule fois, si bien que les références inter-onglets se résolvent ensemble. Le panneau Créer masque le champ de nom d'onglet pour ceux-ci (les noms d'onglet sont fixés par le modèle).
- Les clés, les noms d'affichage vides, l'absence d'onglet et les TSV d'onglet vides sont rejetés (
Registerlève une exception, remontée commePluginRegistrationConflict).
4.7 Surcharges de canevas par onglet (optionnel)
Le canevas du Data Studio décide seul de ce qu'il dessine : vous ouvrez un enregistrement — le terminus — et il parcourt l'index de référence vers l'extérieur, collectant tout ce que cet enregistrement consomme, puis dispose le résultat de gauche à droite. Cela fonctionne sans aucun plugin.
Ce qu'un plugin ajoute, c'est ce que le Core ne peut ni voir ni savoir :
- une identité qui n'est pas un enregistrement de feuille ;
- un lien qui n'est pas écrit dans une colonne
RecordId@Tab; - un ordre qui relève d'une règle de domaine plutôt que de la profondeur de référence.
Implémentez IRecordCanvasAugmenter et enregistrez-le par onglet via 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");
}
}- L'enregistrement se fait par nom d'onglet. Les onglets que vous n'enregistrez pas obtiennent quand même un canevas — la fermeture du Core — si bien qu'un plugin n'a jamais à couvrir toutes les feuilles. Un onglet en double, un nom d'onglet vide et une surcharge nulle sont rejetés (
Registerlève une exception, remontée commePluginRegistrationConflict). - Vous ajoutez, vous ne remplacez pas. Quels enregistrements apparaissent est la réponse de la fermeture. Un nœud virtuel dont le (onglet, clé) est déjà à l'écran est abandonné — le véritable enregistrement l'emporte — si bien qu'une surcharge ne peut pas inventer un enregistrement qui existe dans une feuille. Ce qu'elle peut faire, c'est apporter des identités qui n'ont aucune ligne de feuille du tout.
- Les noms sont l'unique exception. Un indice d'affichage relève de la présentation plutôt que de l'identité, donc il s'applique aux enregistrements qui existent déjà, et il peut nommer des enregistrements qui ne sont pas du tout à l'écran — le sélecteur de connexion les lit, ce qui explique pourquoi le sous-titre d'une carte et une ligne du sélecteur disent la même chose. Les noms vides sont ignorés (ce qui revient à « utiliser la valeur par défaut »), et le premier nom pour un enregistrement l'emporte.
- Une arête apporte son propre nœud. Si une extrémité d'une arête supplémentaire n'est pas à l'écran, elle est ajoutée comme un nœud afin que le lien ne pende jamais dans le vide. Une arête avec une clé vide à l'une ou l'autre extrémité est ignorée.
- Où se trouve la cellule, et où pointe la flèche, peuvent différer. Par défaut, la cellule nommée par
fieldNameest supposée se trouver sur l'enregistrement de départ. PassezfieldOnTarget: truequand elle se trouve plutôt sur celui d'arrivée — un fil d'événement publié se dessine événement → enregistrement, mais le texte est dans la propre colonne de l'enregistrement. L'inspecteur de fil pointe alors vers la vraie cellule plutôt que vers rien. - Cycles : le Core marque ceux qu'il peut voir, vous déclarez ceux que vous connaissez. Si vos arêtes supplémentaires referment une boucle, le canevas classe l'arête de retour et la dessine en pointillé de lui-même. Juger si un cycle est un problème est le travail d'un validateur de domaine (§3) ; le canevas est un matériau d'affichage, jamais de validation.
isCyclicmarque un fil comme un cycle pour l'affichage sans toucher à la disposition.cyclicNoteporte ce que vous seul savez (une valeur d'amortissement, par exemple) — gardez le libellé comme nom de colonne et mettez l'explication dans la note.
- Les indices de calque viennent en deux saveurs.
SetLayerest absolu — la colonne 0 est la plus à gauche et les valeurs négatives vont plus à gauche encore.SetLayerRelativecompte depuis le terminus (−1 est la colonne immédiatement à sa gauche), ce qui correspond généralement à ce que signifie une étape fixe. L'image se lit alors de la même façon que la chaîne soit peu ou très profonde, et vous n'avez pas besoin d'épingler le terminus lui-même pour empêcher les étapes de se percuter.- Les indices relatifs se résolvent par rapport à la colonne du terminus avant qu'un quelconque indice ne l'ait déplacée, si bien que l'ordre dans lequel vous ajoutez les indices ne peut pas changer le résultat. Si le résultat va à gauche de zéro, toute l'image se décale à droite.
- Un indice pour un nœud absent de l'écran est abandonné, et le premier indice pour un nœud l'emporte.
- Les échecs sont contenus.
Augments'exécute à l'intérieur d'un try/catch : une exception devient un avertissement de console en anglais, et l'image du Core reste intacte, jamais une fenêtre cassée. - L'élargissement ne vous casse jamais. Chaque capacité ajoutée depuis la première version est un argument en fin de liste ou une nouvelle méthode ; une surcharge écrite pour la surface antérieure compile et se comporte de façon identique.
(Voir Assets/SheetForge.PluginDemo/Graphing/ExampleReactiveAugmenter.cs et ExamplePipelineAugmenter.cs pour les surcharges complètes — une réaction qui fait pousser des nœuds d'événement et un bloc de code autour de l'enregistrement, et un lancement dont les étapes fixes sont épinglées à leurs propres colonnes.)
4.8 Registres de code — cibles de référence qui vivent dans le code (optionnel)
Certaines cibles de référence ne sont pas du tout écrites dans une feuille : les atomes d'exécution vers lesquels votre runtime distribue. Les enregistrer comme un onglet virtuel verrouillé les place sur la surface de création en lecture seule, et empêche que les arêtes qui pointent vers eux soient dessinées comme cassées. Implémentez 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),
}));
}
}- Trois points de consommation :
- la barre latérale du Data Studio montre l'onglet virtuel sous READ-ONLY comme une grille clé/libellé/raises ;
- une surcharge de canevas peut chercher les entrées via
context.CodeRegistries; - et l'inspecteur de nœud liste le
Raisesd'une entrée.
- Les clés rejoignent le contrôle d'existence du Studio. Une arête dont la cible est une clé enregistrée — typiquement une clé déclarée par un
IEdgeContributor(§4) ou construite par votre forme — n'est pas peinte comme une référence cassée. - Le validateur d'import ne connaît pas les onglets virtuels. Les registres de code sont un concept de surface de création, donc ne typez pas une colonne de feuille comme
RecordId@_Refs(l'import signaleraitUnknownTargetTab). Connectez les données de la feuille aux atomes de code comme le fait la démo — une colonnetypeplus une recherche dans un contributeur d'arêtes / une forme. - Choisissez un nom qui ne peut pas entrer en collision avec une vraie feuille (la démo préfixe avec
_). En cas de collision, le Studio badge le conflit dans la barre latérale plutôt que de cacher silencieusement l'un ou l'autre. - Rejets : une source
null, un nom d'onglet vide, ou un nom d'onglet en double lève une exception (remontée commePluginRegistrationConflict) ; une listeRaisesnullest normalisée en liste vide. Le Core traite clé / libellé / raises comme des chaînes opaques — il ne les interprète jamais.
(Voir Assets/SheetForge.PluginDemo/Graphing/ExampleCodeAtoms.cs.)
4.9 Widgets de graphe du Data Studio (optionnel, assembly Editor)
Un widget est une bande de votre propre UI au-dessus du canevas de graphe — un aperçu d'étapes fixes, un badge agrégé, tout ce que le domaine veut. Le Core ne fournit aucun widget, donc cette zone est vide tant qu'un plugin ne la remplit pas. Comme le type de retour est un VisualElement, ce contrat vit dans l'assembly Editor (la même asymétrie justifiée que pour ISheetSourceProvider) ; implémentez-le dans un assembly côté Editor qui référence SheetForge.Editor et SheetForge.Core :
using SheetForge.Editor.Studio;
using UnityEngine.UIElements;
public sealed class ExampleStageStripWidget : IStudioGraphWidget
{
// context = Tab · ShapeId · ModeId · FocusRecordId · FocusRecord · Tables · References · CodeRegistries
public bool AppliesTo(StudioGraphContext context) =>
context.Tab == "ExampleSkills" && context.FocusRecord != null;
public VisualElement Create(StudioGraphContext context)
{
var strip = new VisualElement();
strip.Add(new Label("VALIDATE → CAST → COMMIT → DELIVER → APPLY"));
return strip; // return null to add nothing
}
}- La découverte est automatique —
TypeCachetrouve chaque implémentation ayant un constructeur sans paramètre ; il n'y a ni appel d'enregistrement ni registre à lier. Un échec d'instanciation est journalisé et ignoré. - En lecture seule par contrat. Le contexte expose les tables analysées, l'index de référence et les registres de code — mais aucune surface de préparation. Créer depuis le graphe relève d'une action d'inspecteur (§4.10), qui la médie.
- Aucun état à l'intérieur de l'élément. Les widgets sont recréés à chaque reconstruction du graphe ; gardez l'état dans vos propres objets. Les reconstructions sont regroupées à une fréquence d'action humaine, pas à chaque frappe.
- Les exceptions sont isolées —
AppliesTo/Createqui lève une exception produit un avertissement de console en anglais ; le graphe se dessine quand même.
(Voir Assets/SheetForge.PluginDemo/Demo/Editor/ExampleStageStripWidget.cs.)
4.10 Actions d'inspecteur du Data Studio (optionnel, assembly Editor)
Une action est un bouton supplémentaire sur l'inspecteur de nœud — « ce que ce domaine peut faire avec cet enregistrement ». Le Core fournit une action intégrée (Go to this sheet) ; tout le reste arrive par ce contrat :
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();
}
}- La session de création n'est délibérément pas exposée. Chaque changement de préparation doit être une étape de l'Undo natif avec la génération de projection incrémentée ; distribuer la session brute institutionnaliserait un moyen de contourner cette règle.
StageCell(tab, recordId, field, rawText)etStageCells(writes)constituent toute la surface de mutation, et la fenêtre possède la comptabilité. - Vous changez plusieurs cellules ? Utilisez
StageCells.context.StageCells(new[] { new EdgeCellWrite(tab, recordId, field, text), … })prépare toute la liste comme une seule étape d'Undo, tout ou rien (si une écriture ne peut pas être appliquée, aucune ne l'est). AppelerStageCellplusieurs fois découpe Ctrl+Z en autant d'étapes — et pour des colonnes parallèles, cela signifie qu'un état à moitié valide apparaît au milieu de l'annulation. Une liste nulle ou vide ne fait rien. - Passez du texte canonique. Le texte préparé est analysé par le même parseur que l'importateur utilise, au moment de la répercussion — écrivez donc ce que la feuille contiendrait.
- Une clé absente de la baseline est un no-op (un enregistrement flambant neuf ou non résolu) : rien n'est écrit silencieusement.
- Services :
FocusCellfait défiler la grille jusqu'à une coordonnée,RequestRebuilddemande un rafraîchissement après que vous avez préparé quelque chose. - Découverte, libellés et isolation fonctionnent exactement comme pour les widgets : découverte par
TypeCache, repli verbatim pour uneLabelKeynon enregistrée (une clé vide se replie sur le nom du type), et try/catch autour d'AppliesTo/Execute.
(Voir Assets/SheetForge.PluginDemo/Demo/Editor/ExampleInspectorAction.cs. Son assembly Editor — SheetForge.PluginDemo.Demo.Editor — référence SheetForge.Editor, SheetForge.Core et l'assembly du plugin ; c'est là tout le câblage dont a besoin une extension côté Editor.)
4.11 Préréglages de couleur (optionnel)
SheetForge peint ses propres fenêtres à partir d'un petit vocabulaire de slots de couleur (surfaces, lignes, texte, couleurs sémantiques, marques de préparation). Un préréglage recolore les slots qui l'intéressent ; chaque autre slot garde la valeur par défaut du produit. Implémentez 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 },
}));
}
}- Les couleurs sont en
0xRRGGBB. Le Core ne référence aucun type moteur, donc il n'y a pas deUnityEngine.Colorici ; l'octet de poids fort est ignoré. Les surfaces translucides (remplissages de badge, voile de la fenêtre modale) sont dérivées de la couleur d'un slot plus un alpha fixe — vous réglez la couleur, pas l'alpha. - Fournissez les deux écrans. Fournissez une carte sombre et une carte claire ; le choix de luminosité de l'utilisateur (suivre l'éditeur / toujours sombre / toujours clair) en choisit une. Les slots que vous omettez retombent sur la valeur par défaut du produit pour cette luminosité, si bien qu'un préréglage à trois slots est parfaitement normal.
- Enregistrer ne l'applique pas. Votre préréglage apparaît dans
Preferences ▸ SheetForge ▸ Theme ▸ Colour presetaux côtés des préréglages intégrés Default et High contrast ; seul le choix de l'utilisateur prend effet. Les chaînes d'affichage vous appartiennent (aucune cléLocdu Core n'est nécessaire). - Les id vides, les doublons, et les id intégrés réservés (
default,highContrast) sont rejetés (Registerlève une exception, remontée commePluginRegistrationConflict). - Ce qu'un thème ne peut pas restyler : les widgets Unity natifs dessinés à l'intérieur de nos fenêtres (chrome des boutons, bordures de champ) continuent de suivre le skin de l'éditeur — voir Capacités et limites.
4.12 Éditer sur le canevas de graphe (optionnel)
Le graphe du Data Studio est une surface de création, pas une image : le clic droit crée des enregistrements, les connecte et déconnecte des fils (voir Data Studio). Tout cela fonctionne sur un projet nu pour les colonnes RecordId@Tab ordinaires. Les capacités ci-dessous l'étendent là où le Core ne peut pas atteindre — aucune d'elles ne change un contrat existant, si bien qu'un plugin qui les ignore compile sans changement.
Comment les capacités sont découvertes (à lire en premier)
Une capacité n'est jamais découverte seule. La fenêtre les trouve toutes en castant les objets déjà enregistrés :
| Capacité | Castée depuis | Ce qu'elle ajoute |
|---|---|---|
IAuthorableGraphShape | la surcharge de canevas enregistrée par ISheetForgeGraphPlugin | Où de nouveaux enregistrements peuvent être créés |
IAuthorableEdgeContributor | le contributeur d'arêtes enregistré par ISheetForgeEdgePlugin | Transformer un geste en une écriture de cellule |
IBatchAuthorableEdgeContributor | le même contributeur d'arêtes | Transformer un geste en plusieurs écritures de cellule |
IVirtualNodeFactory | le même contributeur d'arêtes | Proposer « créer un de plus » sur le menu de nœud |
IEdgeSlotDeclarer | le même contributeur d'arêtes | Déclarer des slots de connexion que le schéma ne peut pas dériver |
IEdgeTokenEditor | le même contributeur d'arêtes | Décrire un jeton et éditer la partie qui n'est pas la clé |
Les cinq capacités côté arête ne sont donc atteintes que si la classe est enregistrée comme IEdgeContributor (via ISheetForgeEdgePlugin, §4). Si votre domaine n'ouvre aucune arête propre, ce n'est pas une raison de sauter l'enregistrement — implémentez ContributeEdges comme une méthode vide et enregistrez-la quand même. Ce contributeur vide est la façon officiellement prise en charge de rejoindre :
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) => …;
}Elles s'exécutent toutes à l'intérieur d'un try/catch : une exception devient un avertissement de console en anglais et désactive cette seule affordance, rien d'autre.
Où de nouveaux enregistrements peuvent être créés — IAuthorableGraphShape
Il y a deux comportements par défaut, et ils sont délibérément différents.
- La liste des onglets créables — l'axe que cette capacité remplace, et qui décide aussi si un canevas s'ouvre du tout — couvre chaque onglet que le schéma de l'onglet ciblé peut atteindre, en suivant les références transitivement. Elle est calculée à partir du schéma, pas des données, si bien qu'elle tient même sur une feuille qui n'a pas encore de ligne. Atteindre un onglet à deux liens de distance se fait par étapes — créez l'enregistrement intermédiaire, ses ports apparaissent, et le saut suivant rejoint la cascade.
- La cascade de liaison — le sélecteur que vous voyez réellement sur un canevas vide — est plus étroite. Elle part des onglets que ciblent les ports actuellement dessinés à l'écran.
Dans les deux cas, les onglets possédés par un registre de code et les onglets sans colonne clé sont abandonnés, car un nouvel enregistrement n'y pourrait pas avoir d'identité.
Une surcharge enregistrée pour cet onglet (§4.7) peut ajouter cette interface pour remplacer les deux comportements par défaut — et un onglet qu'elle nomme mais qu'aucun port à l'écran n'accepte reste listé dans la cascade de liaison avec sa raison attachée plutôt que de disparaître :
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" };
}Rendre votre propre arête éditable — IAuthorableEdgeContributor
Une arête que vous avez ouverte avec IEdgeContributor (§4) est dessinée mais pas éditable, car vous seul connaissez la notation dans laquelle elle vit. Ajoutez cette interface pour retransformer un geste en texte de cellule ; la fenêtre prépare exactement ce que vous renvoyez et le parseur reste le juge final :
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;
}
}falsesignifie que rien ne se passe. Aucune préparation n'est créée et l'élément de menu est désactivé avec une raison honnête — jamais une modification à moitié appliquée. Renvoyertrueavec du texte absurde est permis mais inutile : la valeur préparée passe par la même validation préalable qu'une valeur tapée et apparaît dans Problems.- Adressez par clé, pas par ligne.
EdgeCellWritenomme (onglet, id d'enregistrement, champ) ; les numéros de ligne sont résolus à nouveau au moment de l'écriture, si bien qu'un plan préparé survit au déplacement des lignes. - Vous êtes appelé pendant un geste. Les deux méthodes s'exécutent à l'intérieur d'un try/catch — une exception devient un avertissement de console en anglais et désactive cette seule affordance, rien d'autre.
- Interrogez le contexte, pas la feuille.
CellTextrenvoie la valeur y compris la préparation, si bien que deux liens faits à la suite se voient l'un l'autre. Lire la table analysée à la place manquerait le premier.
Changer plusieurs cellules en un seul geste — IBatchAuthorableEdgeContributor
Certaines données gardent un même élément réparti sur des colonnes parallèles : stepDelays | stepTargets | stepCounts, où l'index i de chaque colonne est une étape. Ajouter un lien là doit faire grandir chaque colonne à la fois, sinon les colonnes finissent avec des longueurs différentes — un état à moitié valide qu'un plan à une seule cellule ne peut pas éviter. Cette capacité est la sœur de IAuthorableEdgeContributor (pas une sous-classe), si bien que les contributeurs qui n'ont que la forme singulière ne sont pas touchés :
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) => …;
}- Tout ou rien. Chaque écriture de la liste est préparée comme une seule étape d'Undo native ; si une seule ne peut pas être écrite (pas de ligne correspondante, source en lecture seule, pipeline en cours), rien n'est préparé du tout.
- Le lot l'emporte. Si une classe implémente à la fois la forme singulière et la forme en lot, la fenêtre ne demande que la forme en lot — un geste n'a jamais deux réponses différentes. Les contributeurs sont quand même interrogés dans l'ordre d'enregistrement, et le premier qui produit un plan l'emporte.
- Chaque écriture a besoin d'une adresse. Une liste contenant une écriture avec un onglet ou un champ vide (ou une liste vide) compte comme « pas de plan ».
- La déliaison s'exécute sur une chaîne. Quand plusieurs fils d'une même carte sont coupés en un seul geste, le contexte que vous lisez porte déjà les plans précédents de ce geste, si bien que couper deux jetons de la même cellule retire les deux. Le contrat singulier n'a pas de surface pour recevoir cette valeur intermédiaire — cette capacité est ce qui lève cette limite.
- Un enregistrement en cours de création ne peut pas être une cible. Dans le flux « créer et lier en un seul geste », les adresses d'écriture sont résolues avant que la nouvelle ligne n'entre dans la session, si bien qu'un plan visant l'enregistrement en cours de création ne peut pas tenir et tout le geste échoue honnêtement. Viser des lignes qui existent déjà (le cas des colonnes parallèles) n'est pas affecté.
Créer un de plus de quelque chose — IVirtualNodeFactory
Quand « un de plus » n'est pas une nouvelle ligne mais un élément de plus dans chacune de plusieurs cellules, le canevas ne peut pas inventer le geste. Déclarez les sortes que vous pouvez créer et renvoyez les écritures de cellule quand l'une est choisie :
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;
}
}- Le libellé est déjà traduit. Le Core ne le traduit pas — fournissez la chaîne que votre pack a résolue (voir §4.14). Un
/dans le libellé crée un sous-menu, ce qui vous permet de regrouper vos propres entrées. tabpeut être un nom d'onglet virtuel ou vide. Les nœuds que votre surcharge de canevas place à l'écran ne vivent pas dans une feuille ; le menu propose quand même ce que vous déclarez, car les cellules que vous écrivez sont nommées par votre plan, pas par l'identité du nœud. Les onglets possédés par un registre de code sont exclus.- Une seule étape d'Undo, tout ou rien — la même règle que la capacité en lot ci-dessus.
falsene prépare rien du tout.
Déclarer des slots de connexion — IEdgeSlotDeclarer
Les slots de connexion viennent normalement du schéma (colonnes RecordId@Tab). Un nœud que votre surcharge place à l'écran n'a pas de colonnes, et une arête de contributeur ne révèle un slot qu'une fois qu'un lien existe déjà — si bien que le premier lien n'avait nulle part où commencer. Déclarez les slots à la place :
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;
}- Le nom a deux fonctions. Il doit être unique au sein de ce nœud, et il doit égaler le
FieldNamede l'arête que vous dessinez dedans — la recherche de slot et l'ancrage de fil correspondent tous deux sur ce nom. Si une colonne de feuille porte déjà ce nom, la feuille l'emporte et votre déclaration est silencieusement abandonnée. - Déclarer n'est pas planifier. Un slot déclaré se connecte via votre plan (
IAuthorableEdgeContributorou la forme en lot). Déclarez sans planifier et le port s'ouvre mais rien n'est préparé — implémentez les deux. - Les ports s'ouvrent sur des nœuds sans ligne de feuille. Pour un nœud dont l'onglet n'est pas une feuille, la fenêtre ne cherche pas de ligne sous ce nom ; l'adresse d'écriture provient de votre plan et est vérifiée au moment de la préparation.
Éditer ce que dit le jeton — IEdgeTokenEditor
Lier et délier déplace tout un jeton. Souvent le jeton est plus qu'une clé : attack:add:10 nomme une statistique et combien. Ajoutez cette capacité au même contributeur et l'inspecteur de fil gagne une ligne pour ce reste — la partie qui n'est pas la clé :
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…
}
}- Les deux moitiés lisent la même cellule. Une arête sait vers quoi elle pointe, pas avec quelles lettres elle est écrite aujourd'hui, donc décrire prend le même
EdgeAuthoringContextque l'écriture prend. C'est ce qui rend le fragment mis en évidence et le fragment réécrit prouvablement identiques. - La clé ne passe jamais par cette porte. Changer vers quoi un lien pointe, c'est le réorienter (faire glisser le fil) ; cette ligne ne change que le reste. Renvoyer
falsedepuis l'une ou l'autre moitié cache ou désactive honnêtement la ligne — pas de préparation, pas d'échec silencieux. - Le widget vous appartient pour le décrire.
isChoiceavec des options dessine un popup, sinon un champ de texte ; le libellé de la ligne et les libellés des options sont vos propres chaînes. S'il n'y a pas de reste du tout, construiseznew EdgeTokenDescription(tokenText)et la ligne n'est pas dessinée — une référence du Core (dont la clé est le jeton entier) se comporte ainsi sans aucun code.
Rendre vos propres fils éditables, tout court
Un fil ne peut être édité que s'il nomme la cellule dans laquelle il est écrit. Le Core le renseigne pour les références qu'il lit lui-même ; une arête supplémentaire que vous ajoutez (§4.7) le fait en nommant le champ :
// 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");Nommer une cellule ne promet pas qu'elle est éditable — cela dit où le lien vit. Une arête que vous avez ajoutée est confiée à la même plomberie qu'utilise une arête de contributeur, elle devient donc éditable exactement quand un IAuthorableEdgeContributor la revendique. Si cette colonne est une colonne de texte brut ou d'enum sans personne pour la réécrire, le canevas signale honnêtement que le fil n'est pas éditable ici, ce qui est la vérité plutôt qu'un no-op silencieux.
4.13 Widgets de cellule personnalisés (optionnel, assembly Editor)
La grille dessine chaque cellule avec un widget intégré (bascule booléenne, popup d'enum, sélecteur de référence, texte brut). Quand un type mérite une meilleure saisie — une courbe, une couleur, un compositeur de mini-grammaire, une zone multi-ligne — remplacez le widget pour ce nom de type sans toucher à la façon dont la valeur est analysée :
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;
}
}- Le widget façonne la saisie, le parseur possède le sens. Ce que vous validez est du texte de feuille canonique ; cela passe par la même validation préalable qu'une valeur tapée, et les problèmes remontent dans le panneau Problems. Le widget n'a jamais besoin de valider.
- Deux surfaces de validation, volontairement.
Commit(choisir dans une liste, relâcher un curseur, perte de focus) crée une étape d'Undo ;CommitTyping(par frappe) regroupe une rafale en une seule étape. Les fusionner en un seul appel produirait soit une pluie d'étapes d'Undo par lettre, soit fusionnerait deux choix distincts. - Renvoyer
nullrefuse la cellule et le widget intégré prend le relais — la réponse honnête pour les formes que vous ne gérez pas (List<T>de votre type, champs optionnels).context.Type(le jeton@typeanalysé) porte tout ce qu'il faut pour décider. ReferenceKeys(tab)vous remet la même liste de candidats qu'utilise le sélecteur de référence intégré (clés projetées ∪ clés de registre de code ∪ clés de nouvelle ligne préparées, triées) — pas besoin de rassembler la vôtre. Pour laisser la personne choisir dans cette liste dans la même liste déroulante que celle qu'ouvre la cellule intégrée, appelezStudioKeyPicker.Show(screenAnchor, tab, candidates, picked)et insérez la clé renvoyée dans votre propre notation avant de valider. (Créer un enregistrement, laisser la cellule vide et cocher plusieurs éléments d'une liste sont les propres règles de la cellule de référence intégrée et ne figurent pas sur cette façade — un widget qui possède tout le texte de la cellule possède aussi ces décisions.)- Vous pouvez revendiquer un nom de type intégré, pas seulement le vôtre. La branche du widget enregistré s'exécute en premier, donc
TypeName => "float"remplace vraiment la zone de texte brut pour chaque colonnefloat— c'est ainsi qu'un curseur, un champ de pourcentage ou une zone à suffixe d'unité s'y glisse. Deux précautions viennent avec cela :- Cela s'applique à chaque colonne de ce type dans le projet, donc cadrez-le en lisant
context.FieldName/context.Tabet en renvoyantnullpour les colonnes que vous ne visiez pas. - Ce que vous validez reste du texte de feuille canonique, donc un curseur doit rendre sa valeur de la façon dont le parseur la relit (voir
CanonicalValueRenderer.RenderFloatpour l'orthographe flottante qu'attend l'aller-retour).
- Cela s'applique à chaque colonne de ce type dans le projet, donc cadrez-le en lisant
- Les conflits avertissent, la découverte est automatique. Même découverte par
TypeCacheque tout autre contrat ; si deux fournisseurs revendiquent un même nom de type, le premier trouvé l'emporte et un avertissement de console nomme les deux. UnCreateEditorqui lève une exception est intercepté, un avertissement émis, et la cellule retombe sur le widget intégré. - Avant d'en écrire un, vérifiez si un indice suffirait. Si tout ce que vous voulez est une liste déroulante, une zone multi-ligne, un curseur, une bascule, un sélecteur de couleur, un éditeur de courbe ou un éditeur de dégradé, enregistrez plutôt un
StudioCellEditorHint(§4.16) — aucun code de widget, et cela fonctionne aussi dans le navigateur. L'ordre est : ce contrat d'abord, puis l'indice, puis les défauts du Core ; un indice est donc ce que reçoit la cellule chaque fois qu'aucun widget n'a revendiqué le type, ou que celui qui l'a fait a décliné.
4.14 Chaînes d'UI de plugin (optionnel)
Les libellés que votre pack affiche — actions d'inspecteur, légendes de widget, les surfaces déclaratives du §4.16 — peuvent suivre la langue de l'utilisateur. Enregistrez des phrases par clé de langue ; Loc.Tr consulte cette surcouche avant les tables du produit, et le t() du navigateur fait de même :
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", "…");
}
}- Ce contrat vit dans le Core, mettez-le donc dans votre assembly principal. Les deux hôtes montrent les libellés de votre pack, et le navigateur ne charge jamais que la DLL principale — une surcouche de chaînes logée dans l'assembly compagnon éditeur laisserait l'application web afficher des clés brutes.
- L'enregistrement est optionnel. Une clé non enregistrée continue de s'afficher verbatim — ce contrat est un chemin de mise à niveau, pas une obligation.
- Les langues sont des codes IETF (
"en","ko","zh-Hans","pt-BR", …), comparés sans tenir compte de la casse.- Enregistrez l'anglais au minimum : la recherche retombe langue demandée → anglais → échec, si bien qu'un utilisateur dans toute autre langue lit votre phrase anglaise plutôt que la clé brute.
- Un code que le produit ne connaît pas est rejeté avec une raison plutôt que d'être replié sur l'anglais — une faute de frappe devenue silencieusement de l'anglais serait introuvable.
- Les clés du produit ne peuvent pas être remplacées — un enregistrement qui nomme une clé intégrée est refusé, si bien qu'une surcouche ne peut jamais faire dire à l'UI autre chose que les propres phrases du produit. Les libellés de menu en particulier sont bakés directement depuis les tables de langue, si bien qu'une surcouche qui pourrait les réécrire ferait diverger le texte de guidage et le vrai chemin de menu. La surcouche est pour les clés nouvelles.
- Les enregistrements en double entre packs conservent le premier trouvé, avec une raison consignée — si le dernier enregistrement l'emportait silencieusement, l'écran dépendrait de l'ordre d'installation des plugins.
- Les clés vides et les valeurs vides sont refusées aussi. Chaque refus est une ligne en anglais destinée au développeur, car le public visé est l'auteur du plugin, pas l'utilisateur final.
- La règle de parité à 10 langues du produit reste intacte : vos chaînes vivent dans une surcouche de lookup, jamais dans les tables du Core.
(La démo livre ceci à Assets/SheetForge.PluginDemo/ExampleLocStrings.cs — dans l'assembly principal, pour la raison ci-dessus — en enregistrant les libellés qu'affichent son action d'inspecteur (§4.10) et ses surfaces déclaratives (§4.16).)
4.15 Texte multi-ligne dans une cellule (dialogues, descriptions, scripts)
Un véritable saut de ligne ne peut jamais vivre à l'intérieur d'une cellule. L'entrée du pipeline est du TSV, où une tabulation sépare les cellules et un saut de ligne sépare les lignes, donc une cellule portant l'un ou l'autre caractère n'a aucune représentation possible. Chaque source impose cela à la porte plutôt que de laisser passer une grille corrompue :
- les lecteurs CSV et xlsx signalent
UnsupportedCellCharacteravec la coordonnée de la cellule, en collectant chaque cellule fautive, pas seulement la première ; - la récupération Google fait de même ;
- et dans un fichier
.tsvle caractère était déjà le séparateur de ligne.
C'est une constante de conception du format, pas une lacune en attente d'être comblée — donc un domaine avec du texte long fonctionne avec elle, via une convention en trois parties qui reste entièrement du ressort du plugin.
1. Choisissez un échappement et écrivez-le dans votre parseur. Le choix conventionnel est un \n littéral à deux caractères dans la feuille, dont l'échappement est retiré à l'entrée et réappliqué à la sortie :
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;
}
}Faites en sorte que les deux directions soient des inverses exacts, et prouvez-le. TryRender est ce que Export et Push réécrivent, donc s'il n'annule pas TryParse caractère pour caractère, un aller-retour « feuille → import → export → feuille » réécrit du texte que personne n'a modifié. Normaliser \r\n en \n à la sortie (comme ci-dessus) est ce qui empêche une valeur rédigée sous Windows d'alterner entre deux orthographes à travers des exports successifs. Un seul test qui rend une valeur analysée et la compare au texte de cellule original suffit à verrouiller cela.
2. Donnez à la cellule un véritable éditeur. Une valeur échappée avec \n est désagréable à taper dans une zone à une ligne, ce qui est exactement ce à quoi sert le §4.13 — enregistrez un IStudioCellEditorProvider pour "Prose" qui renvoie un TextField multi-ligne (multiline = true), montrant la valeur avec de vrais sauts de ligne et validant avec l'échappement réappliqué. Validez à la perte de focus avec Commit (une étape d'annulation par session d'édition) plutôt qu'à chaque frappe.
3. Sachez l'unique endroit où la convention n'atteint pas. Quelqu'un qui tape Alt+Enter directement dans la feuille Google crée un véritable saut de ligne dans la cellule en direct, et cette cellule est refusée à la prochaine récupération avec une coordonnée qui la désigne. Le refus est honnête et corrigible, mais c'est un refus — donc si les rédacteurs de votre équipe rédigent de la prose directement dans le tableur, indiquez dans votre propre documentation que le texte long s'écrit avec \n, ou laissez-les le rédiger dans le widget de cellule du Data Studio de l'étape 2, où l'échappement se fait pour eux.
4.16 Surfaces de création déclaratives (optionnel)
Les §4.9, §4.10 et §4.13 rendent un VisualElement, ce qui est exactement pourquoi ils sont réservés à l'éditeur : le navigateur ne peut pas charger un type UIToolkit, si bien qu'une extension écrite ainsi existe sur un écran et pas sur l'autre.
Ce contrat répond aux mêmes besoins comme données. Vous décrivez la coquille — un id, une clé de libellé, un emplacement, un ton — et ne fournissez que le prédicat et l'effet comme délégués. Un seul enregistrement est alors dessiné aussi bien par le rendu UIToolkit de l'éditeur que par le rendu React du navigateur.
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));
}
}Le vocabulaire est délibérément borné — il ne grandit qu'en ajoutant, jamais en insérant, si bien qu'un enregistrement existant garde son sens.
- Cinq emplacements pour une action :
Inspector,RowContextMenu,TopbarMenu,ColumnHeaderMenu,CanvasNodeMenu.- Chacun remplit le contexte avec ce que ce siège sait — l'emplacement de ligne porte l'enregistrement, l'emplacement de colonne porte le nom de colonne, l'emplacement de canevas porte l'enregistrement du nœud — et laisse le reste vide, protégez-vous donc avant de lire un champ qu'un siège ne fournit pas.
- Treize genres de nœud pour un panneau ou un badge :
Row,Label,Chip,Badge,Button,Rule,Heading,KeyValue,Table,List,Progress,Input,Link.- Ils sont construits via des fabriques statiques (
StudioUiNode.Label(…),.WithTooltip(…)), si bien qu'un nœud est immuable et que seuls les champs qui ont un sens pour son genre sont renseignés.
- Ils sont construits via des fabriques statiques (
- Sept archétypes d'éditeur de cellule :
Dropdown(vous fournissez les candidats),MultilineText,Slider(vous fournissez la plage),Toggle(vous fournissez les deux textes canoniques),ColorPicker(#RRGGBB/#RRGGBBAA),CurveEditoretGradientEditor(le texte de cellule est la notation canonique de courbe / dégradé de Syntaxe des feuilles — un pack dont le propre type écrit cette notation, par exemple viaCurveValue.Render(), peut les déclarer). Les types intégrésColor,AnimationCurveetGradientsont câblés via ce même mécanisme —BuiltinCellEditorHintsdétient leurs trois indices — et un hôte consulte d'abord les enregistrements d'un pack, si bien qu'enregistrer un indice sous l'un de ces noms de type remplace le choix intégré au lieu d'être refusé. Dans l'éditeur, les trois derniers archétypes sont les propres champs de couleur, de courbe et de dégradé d'Unity ; dans le navigateur, ce sont les propres éditeurs de l'application ; unList<>d'un type qui en porte un devient un éditeur à puces dans les deux. Le typeFalloffdu Plugin Demo fait exactement cela : son parseur lit la cellule avecCurveValue.TryParse, et un seul enregistrement d'indice lui donne un champ de courbe dans Unity et l'éditeur de courbe dans le navigateur. - Aucun chiffre de mise en page nulle part. Les pixels et les ratios feraient fuir le grain d'un écran dans l'autre ; vous dites quoi montrer et chaque moteur de rendu décide comment le placer.
Règles à connaître avant d'en écrire un :
- La mutation passe par la même porte que votre main.
StudioSurfaceContextdonne à une action exactement quatre pouvoirs —StageCell,StageCells(plusieurs cellules, une seule étape d'Undo, tout ou rien),FocusRecord,RequestRebuild— en plus desTables/References/CodeRegistriesen lecture seule.- Le verbe d'un plugin est donc une modification préparée ordinaire : une étape
Ctrl+Z, rien n'atteint la feuille avant que vous ne poussiez, même validation préalable. - La barrière de préparation s'applique aussi — une source en lecture seule, un pipeline en cours ou un onglet d'origine classeur la bloque avec la raison affichée.
- Le verbe d'un plugin est donc une modification préparée ordinaire : une étape
- Les prédicats s'exécutent constamment.
AppliesTo, la construction de panneau et la fourniture de badge s'exécutent à chaque geste et à chaque tick de recalcul. Lisez l'instantané qu'on vous a remis ; pas d'E/S, pas de réseau, pas de calcul long. - Affiché n'est pas exécuté. L'hôte revérifie le prédicat au moment de l'invocation. Si la situation a changé depuis que le menu a été dessiné, la réponse est un no-op honnête plus un redessin, plutôt qu'un second échec. Le navigateur fait de même pour un id périmé.
ConfirmKeydemande d'abord. Donnez à une action une clé de confirmation et l'hôte affiche cette phrase avant de l'exécuter — la bonne chose pour un verbe qui prépare de nombreuses cellules à la fois.- Un nœud
Linkn'ouvre quehttp/https. La règle est un seul prédicat du Core (StudioUiNode.IsAllowedUrl) que les deux hôtes interrogent, si bien qu'ils ne peuvent pas être en désaccord sur ce qui est sûr à ouvrir ; le navigateur revérifie ensuite la même forme avant de rendre une ancre, ce qui ne peut que refuser davantage, jamais moins.- L'url est stockée exactement telle que vous l'avez écrite et refusée à l'extrémité d'ouverture avec une raison, plutôt que d'être nettoyée au moment de l'enregistrement — le pack qui l'a écrite doit pouvoir découvrir pourquoi rien ne s'est passé.
- Les panneaux ne portent aucun état. Ils sont reconstruits à chaque tick ; le seul endroit où une valeur a sa place est la feuille (préparée). Si rien n'enregistre de panneau, le panneau n'est pas dessiné du tout.
- Les exceptions sont isolées — une levée devient un avertissement de console en anglais et retire cette seule affordance, pas la fenêtre.
Quand la description ne suffit pas — IStudioPanelProvider (assembly Editor)
Le rendu arbitraire, la saisie composite et les flux à plusieurs étapes n'ont aucun vocabulaire ici, et en inventer un signifierait maintenir pour toujours un framework d'UI miniature. Le plafond est donc délibéré et l'échappatoire large : implémentez IStudioPanelProvider dans votre assembly compagnon éditeur et peignez ce que vous voulez.
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…");
}Enregistrez les deux sous le même Id et chaque hôte prend ce qu'il peut dessiner : l'éditeur utilise la version riche, le navigateur utilise la version descriptive. C'est ainsi que « aussi loin que va le navigateur, jusqu'au bout dans l'éditeur » tient sans un second jeu de contrats.
Il n'existe pas de variante web uniquement — un panneau riche absent signifie que le panneau descriptif est dessiné, pas que le panneau disparaît. L'élément vit un tick de recalcul, il ne porte donc pas d'état non plus.
4.17 Observer le pipeline (optionnel)
Un pont inter-produits, une télémétrie de domaine ou un générateur en aval a souvent besoin de savoir ce qu'un import a produit sans le réanalyser. Implémentez IPipelineObserver et enregistrez-le via 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 ...
}
}- Observer ne peut pas changer le résultat. Vous recevez un instantané immuable et ne renvoyez rien. Il n'y a délibérément aucun crochet pour modifier une valeur ou ajouter un diagnostic : interpréter une valeur revient à un type de cellule (§2) et signaler une violation de règle revient à un validateur de domaine (§3). Mélanger la participation dans un contrat d'observation rendrait « les observateurs ne peuvent pas changer le résultat » faux dans la pratique.
- Une fois par cycle d'import explicite, à sa fin, qu'il ait réussi ou échoué. Il ne s'exécute pas sur la projection de validation préalable qui se recalcule pendant que vous préparez — aucun code tiers n'est attaché à la fréquence des frappes.
- Une exécution en échec signale quand même ce qu'elle a analysé.
Tablesporte les onglets qui se sont analysés avant que la validation n'échoue, la même matière qu'utilise le flux de quarantaine (Data Studio), si bien qu'un observateur voit une image véridique d'une exécution en échec plutôt que rien du tout. - Deux lacunes honnêtes. L'observateur se déclenche depuis le propre point d'achèvement du cycle d'import, si bien qu'une exécution qui ne l'atteint jamais ne se déclenche pas du tout.
- Un import interrompu avant que le pipeline ne s'exécute (aucun paramètre actif, la barrière Addressables qui refuse).
- Le segment codegen→compilation interrompu par une erreur de compilation.
- C'est un zéro-déclenchement, jamais un faux-déclenchement : si vous avez besoin de « un import a été tenté », associez ceci au bus
ImportEventscôté éditeur.
- Une exception levée est isolée à cet observateur, avec la raison collectée ; la sortie de l'import ne change pas d'un iota.
- Les futurs points d'observation (juste après le parsing, un cycle d'export) arriveront comme des interfaces de capacité sœurs découvertes en castant l'observateur enregistré, si bien qu'en ajouter une ne cassera pas une implémentation écrite aujourd'hui.
5. Sources d'import personnalisées (ISheetSourceProvider)
Une nouvelle source (base de données, endpoint REST, format maison) rejoint le produit avec zéro modification du Core/Editor. Implémentez ISheetSourceProvider dans un assembly Editor ; SourceProviderRegistry la découvre via TypeCache, et elle apparaît dans la liste déroulante « Source » des paramètres, aux côtés des sources intégrées. Les quatre choses auxquelles un fournisseur répond :
- Récupération —
CreateTabSource(settings)renvoie unITabSourcefournissant nom d'onglet → texte TSV brut (asynchrone ; les problèmes d'environnement sont des diagnostics, pas des exceptions ; sortie partielle autorisée). - Écriture en retour —
CreateReflectTarget(dispatcher, settings)renvoie unISourceReflectTargetqui se branche sur le dispatcher de création (utilisez lesSession/Callbacks/Baselinespublics du dispatcher pour assembler votre cible). Ne renvoyez une cible que si votre source peut être écrite. - Visibilité —
GetVisibility(settings)renvoie les champs de paramètres que l'inspecteur doit afficher pour vous. CanAuthor— renvoyezfalsepour les sources en lecture seule ; les fenêtres de création désactivent leur interface d'édition (comme pour Google ExportUrl).
La chaîne stable Id persiste dans sourceProviderId. Les sources intégrées utilisent "LocalFile" / "GoogleSheet" comme Ids ; un sourceProviderId vide se résout vers LocalFile, le fournisseur intégré par défaut. Un Id vide exclut le fournisseur de l'interface (utile pour des sondes de test).
Les fournisseurs vivent volontairement dans l'assembly Editor — les sources sont la frontière des E/S, et garder les E/S hors du Core préserve sa pureté (les trois autres contrats sont du Core pur).
5.5 Outils publics pour l'automatisation et l'intégration
Au-delà des contrats d'enregistrement, cinq points d'entrée publics existent pour du code qui pilote SheetForge plutôt que de l'étendre — un script de CI, un hook de build, votre propre bouton d'inspecteur, ou un second produit qui bake ses propres assets à partir des mêmes feuilles.
Exécuter un cycle — SheetForge.Editor.Pipeline.SheetForgeActions :
SheetForgeActions.RunImport(); // exactly what the toolbar's "Pull from source" does
SheetForgeActions.RunExport();
SheetForgeActions.RunPush();
SheetForgeActions.RunHealthCheck();Chaque appel est le cycle entier : résolution des paramètres, la barrière Addressables, l'exclusion mutuelle, les fenêtres modales de confirmation et d'approbation, la barre de progression, et la reprise codegen→compilation→bake à travers le rechargement de domaine. Il n'y a pas de demi-cycle à assembler, et donc aucune barrière à sauter par accident.
Deux choses à savoir :
RunImportetRunPushsont fire-and-forget — leur corps estasync voidcar le thread principal de l'éditeur ne doit pas bloquer sur des E/S réseau. Le retour n'est donc pas la complétion ; abonnez-vous àImportEvents.ImportCompletedpour cela.- Push affiche quand même sa fenêtre modale d'approbation, si bien qu'un script sans surveillance ne peut pas envoyer sans une personne.
Prendre le même verrou que les fonctions intégrées — pour un fournisseur de source personnalisé qui écrit vers son propre backend :
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-entrantSheetForgeActions.IsBusy répond à la même question sans rien prendre. Le verrou lui-même reste interne volontairement : s'il était public, appeler son End() pourrait libérer l'exécution de quelqu'un d'autre — le scope rend cela impossible, car seul le détenteur peut libérer.
Terminer une écriture en retour comme le font les fonctions intégrées — AuthoringDispatcher.FinalizeReflectSuccess(writtenTabs) exécute la fin qu'un ISourceReflectTarget doit atteindre :
- l'élagage de rétention pour les onglets qu'il a écrits ;
- la limite de confirmation
ClearUndo; - et le réimport automatique.
Les chemins local et Google intégrés exécutent le même corps, si bien que votre fournisseur se termine identiquement au lieu de l'approximer. BuildProjectedTabs() à ses côtés vous remet la projection comme TSV par onglet — ce que vous êtes sur le point d'envoyer — si bien qu'un fournisseur peut prévisualiser ou transformer sans écrire. Une liste writtenTabs vide est un no-op qui garde la préparation intacte.
Afficher les propres phrases du produit dans votre propre UI — ImportReportText.Render(report) (Core.Tooling) renvoie le rapport lisible par un humain comme une chaîne sans rien écrire dans la console ; SheetForgeActions.RenderReportText(report) fait la même chose dans la langue d'éditeur actuelle de l'utilisateur. Utilisez-le avec AuthoringDispatchCallbacks.RenderReport afin qu'une seconde surface de création signale les échecs exactement dans les mots qu'utilise le produit.
Énumérer un onglet baké sans connaître son type généré — DefinitionDatabase.RecordsUntyped :
foreach (DefinitionDatabase db in myBakedDatabases)
foreach (object record in db.RecordsUntyped) // reflect on the fields you care about
;C'est le chemin sanctionné pour un second baker (un produit différent qui transforme les mêmes feuilles en ses propres assets). Ne faites pas de réflexion sur le champ privé records : le faire transforme un nom de champ en un contrat non déclaré qui casse silencieusement le jour où le codegen le renomme. La liste est en lecture seule — la feuille est canonique. Elle vaut vide par défaut sur du code généré écrit avant que ce membre n'existe ; un réimport émet la substitution.
Étendre les classes générées en toute sécurité — les deux classes générées sont partial, si bien qu'un membre dérivé (une propriété calculée, une implémentation d'interface, un opérateur) peut vivre dans votre propre fichier à leurs côtés et survivre à chaque réimport. N'y ajoutez aucun champ sérialisé : le ScriptableObject bake est reconstruit à partir de la feuille à chaque import, donc un champ que seule votre partie sérialise revient à sa valeur par défaut. Si une valeur appartient aux données, elle appartient à une colonne.
Ce qui reste fermé — volontairement
Les surfaces ci-dessus sont la limite extérieure sanctionnée. Ce qui suit reste interne, peu importe à quel point l'ouvrir semblerait pratique, car chacun est une limite de confiance ou d'intégrité, pas une limite de commodité :
- Les identifiants et la signature — le localisateur de clé de compte de service, les primitives JWT/PEM/PKCS8 et le fournisseur de jeton d'accès Google. Les ouvrir remettrait à n'importe quel plugin un jeton porteur portant sur votre feuille de calcul.
- La chaîne de push brute (exécuteur de push, passerelles de feuille, écritures de cellule) — l'approbation (
IPushApprover) est imposée à l'intérieur de cette orchestration ; un écrivain brut public serait une écriture de feuille sans étape d'approbation. - La vérification pré-envoi et les moteurs d'écriture de répercussion — le code externe entre uniquement par
AuthoringDispatcher.Reflect(), qui passe par les vérifications d'ancre périmée, la validation préalable et l'approbation en chemin ; le moteur d'écriture en dessous n'est pas un contrat. - La chaîne d'intégrité bake/codegen (empreintes de schéma, écrivain de source générée, nettoyage des orphelins) et la barrière de fraîcheur de build — les ouvrir ferait de la falsification ou du contournement de l'état de bake une simple ligne de code.
- La surcouche de SO éphémère — « la feuille est la source de vérité » a exactement une exception sanctionnée (l'interrupteur de modification test de l'inspecteur), et elle n'est délibérément pas offerte comme API.
Si un flux de travail semble avoir besoin de l'un de ceux-ci, c'est qu'il a besoin d'une demande de fonctionnalité, pas de réflexion.
6. Emplacement du code généré et espaces de noms
generatedCodeFolderpeut être n'importe quel dossier (l'asmdef compagnon autoréparateur câble automatiquement les références de type de plugin), mais le placer à l'intérieur de votre package (par ex.Assets/MyDomain/Runtime/Generated) est le plus soigné — les types générés compilent alors dans le même assembly que vos enums/types personnalisés, sans avoir besoin d'asmdef compagnon.- Foyer par onglet : un onglet dont le type généré existe déjà quelque part est régénéré sur place — le dossier
Generatedcommité de votre package reste la référence, même si les paramètres pointent ailleurs. Les doublons obsolètes sont nettoyés automatiquement (journalisés, jamais silencieusement). generatedNamespaceisole vos types générés (par ex.MyGame.Data). La découverte des types utilise le marqueur intrinsèqueSchemaFingerprintdes types générés, pas l'espace de noms, si bien que n'importe quel espace de noms fonctionne. Changer la valeur déclenche automatiquement une régénération.- Commiter ou non le dossier
Generatedde votre package relève de la politique de votre package. L'exemple fourni commite le sien (classesExample*dans l'espace de noms par défautSheetForge.Generated, ongletsExampleSkills/ExampleEffects/ExampleActions) afin qu'un clone tout neuf compile immédiatement, et c'est le préfixe de nom de classeExample*— pas un espace de noms séparé — qui les empêche d'entrer en collision avec les véritables ongletsSkills/Effectsde votre projet.
7. Consommer à l'exécution — « assemblez, ne scriptez pas »
Votre runtime lit les bases de données générées et distribue vers des atomes de code en fonction de l'enum type :
using SheetForge.Runtime;
using SheetForge.Generated;
var hSkills = SheetForgeDatabases.LoadAsync<ExampleSkillsDatabase>("ExampleSkills");
var hActions = SheetForgeDatabases.LoadAsync<ExampleActionsDatabase>("ExampleActions");
var hEffects = SheetForgeDatabases.LoadAsync<ExampleEffectsDatabase>("ExampleEffects");
var runner = new SkillRunner(await hSkills.Task, await hActions.Task, await hEffects.Task);
// keep the handles for the system's lifetime; Release each on shutdownPour une logique réellement procédurale et ponctuelle, référencez un asset de script via AssetRef — SheetForge valide la référence et bake l'addressable (exactement comme pour une image) ; l'exécuter est le travail du jeu.
8. Détecter SheetForge depuis un autre asset
Un asset différent — un qui s'intègre à SheetForge plutôt que de l'étendre (un système de statistiques, par exemple) — peut détecter que SheetForge est installé. Comme un produit payant de l'Asset Store est un produit dossier (pas de package.json / UPM), il ne peut pas fournir d'entrée versionDefines ; à la place, l'assembly Editor de SheetForge s'auto-enregistre comme symbole de compilation SHEETFORGE sur chaque plateforme cible.
(a) À la compilation (préféré) :
- Si votre intégration vit dans sa propre assembly definition, ajoutez
SHEETFORGEaux Define Constraints de cet asmdef — l'assembly ne compile alors que lorsque SheetForge est présent. - Si le code touchant à SheetForge partage un assembly avec du code qui doit compiler dans tous les cas, protégez uniquement ces parties avec
#if SHEETFORGE … #endif.
(b) À l'exécution dans l'Editor (alternative) : quand vous ne pouvez pas compter sur l'ordre de compilation, sondez par réflexion — par ex. System.Type.GetType("SheetForge.Editor.Pipeline.ImportEvents, SheetForge.Editor") != null — puis branchez (par exemple) le bus de complétion dynamiquement.
SHEETFORGE signifie « SheetForge est installé ». C'est distinct de SHEETFORGE_ADDRESSABLES, un version-define interne sur les assemblies propres de SheetForge qui indique seulement si le package Addressables est présent — n'utilisez pas ce dernier comme sonde d'installation.
Le define persiste si SheetForge est ensuite supprimé (il n'y a aucun observateur pour le désactiver) ; retirez-le à la main dans Project Settings ▸ Player. Voir Capacités et limites.
Ce qui nécessite encore des modifications du Core
Tout ce qui précède s'intègre avec zéro modification du Core. Ce qu'un plugin ne peut toujours pas faire sans changement du Core :
- Émettre des valeurs de marqueur dans le code généré — les marqueurs personnalisés sont des métadonnées de validation/affichage ; les faire baker en constantes ou attributs de codegen sort du périmètre tant qu'aucun consommateur n'en a besoin.
- Faire en sorte que la propagation de renommage de clé atteigne l'intérieur d'une notation personnalisée sans qu'on lui dise comment — un enregistrement renommé est réécrit dans les cellules
RecordId@Tab, leurs listes, et les éléments de wrapper par le Core lui-même. Pour votre propre grammaire, implémentezIReferencingCellType(§4.4a) et elle est réécrite avec la charge utile préservée ; c'est un opt-in, pas une modification du Core. Déclinez l'opt-in et la limite tient : votre validateur de domaine signale la clé pendante plutôt que le renommage la corrigeant silencieusement. - Ajouter des membres à un enum C# enregistré par plugin depuis une feuille — un enum enregistré avec
enums.Register<T>()est possédé par le code, donc une feuille de définition d'enum ne peut pas l'étendre et le Data Studio n'offre pas la ligne. Déplacez l'enum dans une feuille d'enum si la feuille doit le posséder (voir Syntaxe des feuilles).
Le type wrapper <> (ICellWrapperType, voir §2) et le marqueur structurel personnalisé (IStructuralMarkerDefinition, voir §4.5) étendent tous deux le pipeline sans modification du Core.
Pages associées
- Syntaxe des feuilles — comment les types enregistrés apparaissent dans les feuilles
- Data Studio — où apparaissent les surcharges de canevas, les registres de code, les widgets et les actions
- Référence de l'API — la signature complète de chaque contrat
- Noyau de création — les arêtes et la surface du moteur
- Capacités et limites — les limites de l'extension par plugin (règles de rejet des wrappers, limites des marqueurs) et les coutures réservées