外掛開發——以零 Core 修改新增領域
一個領域(技能、物品、任務……等)會以一個參照 SheetForge.Core 的獨立套件的形式加入 SheetForge——Core 絕不會反過來參照它。
一個外掛可以新增 enum、自訂儲存格型別、wrapper 型別、領域驗證器、圖形邊、結構標記、「建立試算表」範本、完整的匯入來源、Data Studio 的畫布覆寫、程式碼註冊表、宣告式編寫介面、小工具與動作、色彩預設集、自訂儲存格元件、管線觀察者,以及自己的在地化 UI 字串——也就是下方這十六種合約。
「新增一個領域 = 零行 Core 程式碼變更」由編譯器強制執行。一個不具 InternalsVisibleTo 的測試組件(SheetForge.Tests.Consumer)僅使用公開介面,就實作了十六種合約中的十五種——連同它們旁邊的能力介面。只要任何一項合約被縮小為 internal,建置就會失敗(CS0122)。第十六種——僅限編輯器使用的 rich-panel escape hatch——會回傳一個 VisualElement,因此改由一項編輯器端測試來驗證它。
十一種 Core 合約皆為純 C#。這正是為什麼一份編譯完成的外掛 DLL,能夠同時點亮 Unity 編輯器與瀏覽器(SheetForge Web)中相同的插槽——組裝與隔離是同一個共用的 Core 功能,只有發現方式會依主機而異(Unity 的 TypeCache、瀏覽器的已上傳組件掃描)。
五種 Editor 合約會回傳 UIToolkit 元件,或觸及視窗狀態,因此僅存在於編輯器中。
全部十六種皆會被自動偵測——唯一的要求是一個無參數建構函式,不需要組件參照、註冊呼叫,也不需要編輯任何清單檔:
| 合約 | 註冊內容 | 是否為選用? |
|---|---|---|
ISheetForgePlugin | Enum + 自訂儲存格型別剖析器 | 基礎合約 |
ISheetForgeValidatorPlugin | 領域驗證規則(跨欄/跨分頁) | 選用附加元件 |
ISheetForgeEdgePlugin | 核心掃描器看不到的圖形邊宣告 | 選用附加元件 |
ISheetForgeMarkerPlugin | 自訂結構標記(逐欄的 @marker 列) | 選用附加元件 |
ISheetForgeTemplatePlugin | 「建立試算表」範本(分頁 + 範例資料) | 選用附加元件 |
ISheetForgeGraphPlugin | 為 Data Studio 提供的逐分頁畫布覆寫 | 選用附加元件 |
ISheetForgeCodeRegistryPlugin | 存放於程式碼中的唯讀鍵值空間,以鎖定的虛擬分頁形式呈現 | 選用附加元件 |
ISheetForgeThemePlugin | SheetForge 視窗的色彩預設集(深色與淺色) | 選用附加元件 |
ISheetForgeStudioPlugin | 宣告式編寫介面——動作、面板、欄徽章、儲存格元件提示 | 選用附加元件 |
ISheetForgeStringsPlugin | 你套件的 UI 字串,依語言區分(一個在產品字串表之前先被查詢的疊加層) | 選用附加元件 |
ISheetForgePipelinePlugin | 管線觀察者——以唯讀方式通知一次匯入產生了什麼 | 選用附加元件 |
ISheetSourceProvider | 一整個匯入來源(資料庫/REST/自有格式) | 獨立(Editor 組件) |
IStudioGraphWidget | Data Studio 畫布上方的一個領域小工具 | 獨立(Editor 組件) |
IStudioInspectorAction | Data Studio 節點檢閱器上的一個額外按鈕 | 獨立(Editor 組件) |
IStudioCellEditorProvider | Data Studio 網格中,供單一儲存格型別使用的自訂輸入元件 | 獨立(Editor 組件) |
IStudioPanelProvider | Studio 中任意的 UIToolkit 面板——與宣告式面板並列的另一個逃生艙口 | 獨立(Editor 組件) |
參考範例是一個可選擇匯入的套件。 完整的實作範例(
SheetForge.PluginDemo)以 Unity 套件的形式提供,位於Assets/SheetForge/Examples/SheetForgePluginDemo.unitypackage——雙擊它,或在入門視窗(Tools ▸ SheetForge ▸ Getting Started,示範匯入的唯一存放位置)中按下匯入 Plugin Demo,即可將其還原至Assets/SheetForge.PluginDemo/…底下。在你匯入它之前,它根本不存在於你的專案中——這個範例僅以該套件的形式提供——因此其組件/型別/分頁/位址絕不會與你的專案發生衝突。下方所參照的路徑(
Assets/SheetForge.PluginDemo/ModifierCellParser.cs等)會在你匯入該套件之後才存在。(另一個不含外掛的範例——
SheetForge.CoreDemo——則只用核心內建型別示範整條管線。)
這些附加元件會擴充基礎介面,而不會改變它——不需要驗證或邊功能的外掛,完全不會受到它們存在與否的影響。
另外還有七個介面是能力,而非合約:
- 它們不會被單獨偵測到。
- 它們是由某個已經註冊的物件額外實作的。
- Core 透過轉型該已註冊物件來找到它們。
其中六個是從一個已註冊的邊貢獻者或畫布覆寫轉型而來——關於偵測規則與各項細節,請參閱 §4.12。第七個——IReferencingCellType——則是從一個已註冊的儲存格剖析器轉型而來。它讓你自己的記法能獲得與 RecordId@Tab 相同的參照處理方式——請參閱 §4.4a。忽略其中任何一個,都不會造成任何影響。
1. 套件設定
建立一個擁有自己 .asmdef 的資料夾,並參照 SheetForge.Core(如果你需要執行期查詢,則再加上 SheetForge.Runtime)。就這樣而已。Editor 的 PluginRegistry 會透過 TypeCache 偵測到你的 ISheetForgePlugin 實作,並呼叫你的註冊方法,而註冊是你明確撰寫的程式碼,而不是組件掃描的結果。
把 Core 合約的實作留在那個主要組件中。一個編輯器端的附屬組件(同時參照 SheetForge.Editor)則是五種 IStudio*/ISheetSourceProvider 實作該存放的位置——瀏覽器只會載入你的主要 DLL,因此若把一個 Core 合約實作放進編輯器附屬組件中,它在瀏覽器裡就會被靜默地遺漏。
1.1 宣告相容性(選填,一行程式碼)
一個組件層級的屬性,用來說明你的組件是依照哪一代外掛格式編譯的,以及它所需要的最低主機版本:
using SheetForge.Core.Plugins;
[assembly: SheetForgePluginCompat(
SheetForgePluginFormat.Current, // the generation constant of the SDK you compiled against
MinHostVersion = "0.1.0", // optional — omit for "any host"
PluginVersion = "1.0.0")] // optional, display only- 省略它完全沒問題。 沒有宣告的組件,會被視為
SheetForgePluginFormat.Minimum這一代、且沒有主機版本要求,因此在此屬性存在之前寫成的外掛,載入方式完全不變。 - 判斷的單位是整個組件,一個被拒絕的組件會失去它全部的註冊內容。若改為逐型別宣告,會讓一個未宣告的鄰近型別蒙混過關,讓你陷入「被拒絕,但有一半仍註冊成功」的窘境。
- 判斷者是 DLL 本身,而非型錄。 市集登錄庫會公告相同的兩個值(
pluginFormat、minHost),讓一筆項目能在下載前先被篩選,但真正的關卡讀取的是經過驗證位元組中的屬性——一筆項目的描述可能出錯,但編譯完成的宣告不會。 - 被拒絕時會產生一則
PluginIncompatible診斷,明確指出該組件宣告了什麼、而此主機讀到的是什麼,而不是靜默消失。這是一個相容性宣告,而非簽章:完整性驗證是散佈通道的職責(請參閱 Web 外掛市集)。 - 世代編號只有在外掛格式本身被替換時才會變動。單純的新增式成長——一個新合約、一個註冊表上的新成員——絕不會使其變動,因為你現有的外掛不需要重新編譯就能繼續運作。
2. 基礎外掛:enum +自訂儲存格型別
using SheetForge.Core.Model;
using SheetForge.Core.Plugins;
public sealed class SkillsPlugin : ISheetForgePlugin
{
public string Name => "Skills"; // for diagnostics / duplicate-conflict reports
public void RegisterEnums(EnumRegistry enums)
{
// Any Enum<ActionType> / Enum<EffectType> cell in a sheet now resolves,
// and codegen emits the real CLR enum type on the generated field.
enums.Register<ActionType>();
enums.Register<EffectType>();
}
public void RegisterCellParsers(CellParserRegistry parsers)
{
// A custom cell type joins parsing, validation, codegen, bake and
// round-trip by registration alone (open-closed — zero pipeline edits).
parsers.Register(new ModifierCellParser());
}
}完整流程:一個自訂儲存格型別
實作 ICellValueParser(字串 → 數值)。為了完成強型別烘焙以及匯出/推送往返流程,也請一併實作 ICustomCellType(CLR 型別 + 數值 → 標準字串)。
範例中的 Modifier 迷你文法(stat:op:value,例如 attack:add:10):
using System;
using SheetForge.Core.Model;
using SheetForge.Core.Unparse;
public sealed class ModifierCellParser : ICellValueParser, ICustomCellType
{
// The @type cell text: a column declares "Modifier" or "List<Modifier>".
public string TypeName => "Modifier";
// ICustomCellType: the CLR value type codegen emits ([Serializable] struct).
public Type ValueType => typeof(Modifier);
public bool TryParse(CellParseContext context, string text, out object value)
{
value = null;
string[] parts = text.Split(':');
if (parts.Length != 3)
{
// Failure = collect a structured error and return false. Never throw.
context.Errors.Add(new ImportError(
ImportErrorCode.CustomTypeParseFailed, context.Coordinate,
text, "'stat:op:value' form (e.g. attack:add:10)", null));
return false;
}
// ... parse the three parts (InvariantCulture; reject NaN/Infinity) ...
value = new Modifier(parts[0].Trim(), /*op*/ default, /*value*/ 0f);
return true;
}
// ICustomCellType: value → canonical cell string (the exact inverse of TryParse).
public bool TryRender(object value, out string text, out string reason)
{
reason = null;
if (!(value is Modifier m)) { text = null; reason = "Not a Modifier."; return false; }
// Use CanonicalValueRenderer.RenderFloat for floats — round-trip-safe on Mono.
text = m.stat + ":" + "add" + ":" + CanonicalValueRenderer.RenderFloat(m.value);
return true;
}
}(完整的正式版本——包含運算子代碼驗證與最接近候選建議——請參閱 Assets/SheetForge.PluginDemo/ModifierCellParser.cs。)
自訂型別上的 @target 只需註冊即可運作:將某欄宣告為 Modifier@Stats,你的剖析器就能讀取 context.Type.TargetName(其值為 "Stats")。
該目標的完整性檢查——分頁是否存在?id 是否可解析?——則屬於領域驗證器的職責。這與 RecordId@Tab 的分工方式相同。若一個未註冊的型別名稱帶有 @,仍會被視為錯誤並提供候選建議,因此拼字安全性依然受到保障。
Core 已經自行擁有的型別名稱。 內建的純量名稱——int、float、bool、string、Enum、RecordId、IntId、AssetRef、Color、AnimationCurve 與 Gradient——會搶在任何外掛之前完成註冊。重複使用其中一個名稱的剖析器,其註冊會以 PluginRegistrationConflict 失敗——內建型別會保留下來,那一次 RegisterCellParsers 呼叫會在發生衝突的剖析器處停止,而外掛的其他插槽仍會照常載入——因此曾經自行出貨過 Color 或 Gradient 型別的套件,必須將它改名(請參閱版本紀錄中的升級注意事項)。如果你的型別儲存的是顏色、曲線或漸層,你不必重新實作這套記法:Core 的數值模型 ColorValue、CurveValue 與 GradientValue 皆公開了 TryParse(text, out value, out error) 與 Render(),CurveEvaluator / GradientEvaluator 對它們取樣的方式與 Unity 完全相同,而一個帶有 ColorPicker、CurveEditor 或 GradientEditor 原型(§4.16)的 StudioCellEditorHint,會在兩個主機上都為你的型別開啟原生編輯器。
完整流程:一個 wrapper 型別(MyWrapper<T>)
wrapper 是一種泛型數值結構——Pair<int> = 1~2——可將多個內部 T 值打包進單一儲存格中。你只需要負責外層語法(分隔符、元數),Core 會遞迴剖析內部的 T。因此 Pair<RecordId@Effects>、Pair<Enum<DamageType>>,以及巢狀的 Box<Pair<int>> 都能在不需額外程式碼的情況下完成剖析與驗證,且內部的參照會被完整驗證。
實作 ICellWrapperType,並在同一個 RegisterCellParsers 掛勾中透過 parsers.RegisterWrapper(...) 註冊它:
// A [Serializable] generic value type — codegen emits Pair<int>, Pair<RecordRef>, ...
[Serializable] public struct Pair<T> { public T First; public T Second; public Pair(T a, T b){First=a;Second=b;} }
public sealed class PairWrapper : ICellWrapperType
{
public string Name => "Pair"; // the @type token: Pair<Inner>
public Type OpenClrType => typeof(Pair<>); // generic open type — exactly one type parameter
// Outer syntax only: split "1~2" into ["1","2"]. Use a delimiter OTHER than ';'
// so List<Pair<T>> doesn't clash with the list separator.
public bool TrySplit(string cell, out IReadOnlyList<string> pieces, out string reason)
{
reason = null;
var parts = (cell ?? "").Split('~');
if (parts.Length != 2) { pieces = null; reason = "'a~b' form (two parts)."; return false; }
pieces = new[] { parts[0], parts[1] };
return true; // the Core parses each piece as the inner type
}
public string JoinCanonical(IReadOnlyList<string> inner) => inner[0] + "~" + inner[1]; // inverse of TrySplit
public object Assemble(IReadOnlyList<object> inner, Type closed) =>
Activator.CreateInstance(closed, inner[0], inner[1]); // bake: build Pair<TInner>
public bool TryDisassemble(object v, out IReadOnlyList<object> inner, out string reason)
{
reason = null;
var t = v.GetType();
inner = new[] { t.GetField("First").GetValue(v), t.GetField("Second").GetValue(v) };
return true; // Export: read the values back out (inverse of Assemble)
}
}
// In your ISheetForgePlugin.RegisterCellParsers:
public void RegisterCellParsers(CellParserRegistry parsers) => parsers.RegisterWrapper(new PairWrapper());那一次註冊就能讓你獲得:
- 遞迴的
@type解析。 - 強型別程式碼產生(
Pair<RecordRef> First;)。 - 烘焙。
- 匯出/推送往返流程。
- 參照的完整貫穿——wrapper 內部的
RecordId@Tab會被完整性檢查、在鍵值重新命名時同步更新、並在分頁重新命名時一併改寫。
拒絕規則與 ; 分隔符的注意事項,記載於試算表語法中。
3. 領域驗證器(選用)
Core 的驗證固定為四種(鍵值、參照、@overlap、資源鍵值)。若要實作跨欄規則(例如「若 type 為 Custom,則 script 為必填」)或跨分頁規則(檢查所參照記錄的含義),請實作 ISheetForgeValidatorPlugin:
using SheetForge.Core.Model;
using SheetForge.Core.Plugins;
using SheetForge.Core.Validation;
public sealed class SkillsPlugin : ISheetForgePlugin, ISheetForgeValidatorPlugin
{
// ... Name / RegisterEnums / RegisterCellParsers unchanged ...
public void RegisterValidators(DomainValidatorRegistry validators)
{
validators.Register(new CustomEffectRequiresScriptValidator());
}
}
public sealed class CustomEffectRequiresScriptValidator : IDomainValidator
{
public string Name => "CustomEffectRequiresScript";
public void Validate(DomainValidationContext ctx)
{
if (!ctx.Tables.TryGetValue("ExampleEffects", out var effects)) return;
if (!effects.Schema.TryGetField("script", out var scriptField)) return;
foreach (var rec in effects.Records)
{
if (!(rec["type"].Value is EnumValue ev) || ev.MemberName != "Custom") continue;
var scripts = rec["script"].AsList;
if (scripts != null && scripts.Count == 0)
ctx.Errors.Add(new ImportError(ImportErrorCode.DomainRuleViolation,
new CellCoordinate("ExampleEffects", rec.RowNumber, scriptField.ColumnNumber, "script"),
/* what */ rec["codeName"].Value.ToString(),
/* why */ "A Custom effect must specify a script to run, but 'script' is empty.",
/* how */ "Put a script address in the 'script' column, or change 'type'."));
}
}
}已註冊的驗證器會自動同時加入匯入驗證與編寫介面的預檢。規則如下:
- 將違規事項以
ImportErrorCode.DomainRuleViolation的形式回報進ctx.Errors——絕不要拋出例外。拋出的例外會被隔離並提升層級處理;其他驗證器仍會繼續執行。 - 填寫全部四個要素——在哪裡(
CellCoordinate)、是什麼(ActualValue)、為什麼(Expected)、怎麼修(Suggestion)。「怎麼修」的內容會被逐字顯示,作為可據以行動的說明句。 ctx會提供你:- 所有已剖析的資料表(
Tables); - 鍵值索引(
KeyIndices); - 資源鍵值(
AssetKeys——null代表資源驗證被略過)。
- 所有已剖析的資料表(
- 「蒐集一切」與「不會有部分組裝的結果」這兩項特性都會自動繼承。
4. 邊貢獻者(選用)
如果你要在資料圖形之上建構工具(或希望未來的圖形畫布能看到你這個領域中的連結關係),請宣告核心參照掃描器看不到的邊——例如內嵌於迷你文法值之中被參照的一個數值:
public sealed class SkillsPlugin : /* ... */, ISheetForgeEdgePlugin
{
public void RegisterEdgeContributors(EdgeContributorRegistry contributors)
{
contributors.Register(new ModifierStatEdgeContributor()); // effect → stat edges
}
}IEdgeContributor 會收到一個唯讀的跨分頁內容,並附加 EdgeSpec 項目(來源/目標分頁 + 記錄 id、選填欄位、負載記錄、標籤)。貢獻者絕不會發出診斷資訊——邊只是投影用的素材,而非驗證。請參閱編寫核心。
4.4 食譜:一個內部帶有鍵值的自訂型別
RecordId@Tab 是 Core 唯一能理解的參照形態,它能免費獲得完整性檢查、圖形邊、最接近候選建議,以及重新命名傳播。
一旦你自己的記法吞下了一個鍵值——例如 attack:add:10、stat.hp>50、fire@0.4——Core 所看到的就只是一個不透明的字串,因此上述四項服務都會止步於你的家門口。三次註冊就能把其中三項找回來。 請將它們視為一整套一起撰寫:一個迷你語法若只做到三項中的一項,正是「匯入時看起來沒問題,但沒有任何東西指向任何東西」這種狀況的成因。
| 部分 | 合約 | 恢復了什麼 | 沒有它會怎樣 |
|---|---|---|---|
| 1. 完整性 | IDomainValidator(§3) | 你記法中一個不存在的鍵值會被回報,附上座標與可據以行動的說明句 | 拼字錯誤會順利匯入,並在執行期才失敗 |
| 2. 可見性 | IEdgeContributor(§4) | 深埋的連結成為一條真正的邊:畫布會將其繪製出來,Used by 清單會將其計入,參照索引也會將其收錄 | 這個連結存在於資料中,但畫面上完全看不到 |
| 3. 「怎麼修」 | 部分 1 內部的 TextSuggestion.FindNearest | 「Unknown stat 'atack'. Did you mean 'attack'?」——與內建參照錯誤所用的句型完全相同 | 正確的診斷,卻沒有任何可以據以行動的方式 |
// Piece 1 + 3 together — the validator is where the suggestion belongs, because it is the
// only one of the three that produces a sentence a person reads.
using SheetForge.Core.Model;
using SheetForge.Core.Validation;
public sealed class ModifierStatExistsValidator : IDomainValidator
{
public string Name => "ModifierStatExists";
public void Validate(DomainValidationContext ctx)
{
if (!ctx.KeyIndices.TryGetValue("Stats", out var stats)) return; // no target tab: nothing to check
if (!ctx.Tables.TryGetValue("Effects", out var effects)) return;
if (!effects.Schema.TryGetField("modifier", out var field)) return;
foreach (var rec in effects.Records)
foreach (string statKey in StatKeysIn(rec["modifier"])) // your notation's own split
{
if (stats.Contains(statKey)) continue;
string near = TextSuggestion.FindNearest(statKey, stats.Keys); // piece 3
ctx.Errors.Add(new ImportError(ImportErrorCode.DomainRuleViolation,
new CellCoordinate("Effects", rec.RowNumber, field.ColumnNumber, "modifier"),
/* what */ statKey,
/* why */ "This modifier points at a stat that does not exist in 'Stats'.",
/* how */ near != null
? "Did you mean '" + near + "'? Fix the stat name in the modifier value."
: "Add that record to 'Stats', or correct the stat name."));
}
}
}請為這個記法重複使用同一個分割器。剖析器、驗證器與邊貢獻者,必須對鍵值從哪裡開始、到哪裡結束有一致的認定,而三份各自私有的分割邏輯複本,正是它們彼此走樣的原因。(wrapper 型別——見 §2——則能免費獲得這項保證:TrySplit 本身就是那個共用的分割器。)
第四項服務——重新命名傳播——還需要多一件事,且有兩種方式可以取得它。 重新命名一筆記錄時,只有在 Core 能夠在文字中找到那個鍵值的地方,才會改寫參照它的儲存格。它能對一個 RecordId@Tab 欄位、一份由它們組成的清單,以及一個 TrySplit 會將鍵值公開為一個元素的 wrapper 做到這件事。它無法自行猜測你的文法中子字串的邊界在哪裡。因此你可以:
- 明確告訴它該怎麼做——實作
IReferencingCellType(§4.4a),這會以單一選用直接取代整套三部分食譜,並一次恢復全部四項服務。 - 接受這道邊界,至少這是誠實而非靜默的:部分 1 會在下一次匯入時,回報那個現在已經失效的鍵值,並附上座標與候選建議。
在一種情況下,上述食譜仍然是正確答案:當該欄沒有 @target,因為鍵值並不屬於單一一個分頁。內建範例正是如此。List<Modifier> 沒有指名任何目標,因此 Core 無法得知 attack 應該解析到哪裡,ModifierStatEdgeContributor 便手動開啟了那些邊。只要為該欄加上一個目標(List<Modifier@Stats>),§4.4a 就會接手處理。
4.4a 讓你自己的記法擁有完整參照對等性(選用)
在你已經註冊的剖析器上實作 IReferencingCellType,一個 MyType@Tab 欄就不再是特例:它會像 RecordId@Tab 一樣被驗證、提供候選建議、傳播、繪製、挑選與索引。
這裡並沒有另外開一條新的註冊管道。Core 會轉型 CellParserRegistry 中已註冊的剖析器,方式與畫布能力從已註冊的邊貢獻者轉型而來相同(§4.12)。未實作它的自訂型別,其行為會與先前完全一致、位元不差。
五個掛勾
這五個掛勾,全都作用於單一元素上:純量欄位的整個儲存格,或 List<MyType@Tab> 中以 ; 分隔的一個元素——與你的 ICellValueParser.TryParse 所接收的是同一個單位。
| 掛勾 | 回答的問題 | 用途 |
|---|---|---|
bool TryGetTokenKey(elementText, out key) | 「這個元素指向什麼?」 | 成員關係——這個儲存格是否已經連結到那筆記錄 |
string MakeToken(key) | 「寫入一條指向此鍵值的新連結」 | 空白儲存格,或附加到清單。請以一個中性的起始值填入負載內容;編寫介面絕不能自行捏造數值。回傳 null/空字串,該手勢就會被停用並附上原因,而不是被偽裝成可行 |
bool TryRetargetToken(elementText, newKey, out newText) | 「將此指向別的東西」 | 在 ▾ 儲存格中挑選另一筆記錄,以及在畫布上重新瞄準一條連線。只變更目標——移除後重新建立 token 會重設一個人原本輸入的數字 |
bool TryRemoveToken(elementText, key, out newText) | 「取消連結」 | 回傳空字串,該元素就會消失(純量儲存格會被清空,清單元素則會被移除);回傳非空字串,那部分內容就會保留 |
bool TryRewriteKeys(elementText, renames, out newText) | 「替換所有這些鍵值」 | 重新命名掃描。之所以與 TryRetargetToken 分開,是因為後者是一個人下達的單一指令,而這個則是批次處理——且一個內含兩個參照的元素,必須將兩者都一併改寫 |
文字端與數值端
同時也在已剖析的數值上加入 IRefBearingValue——這兩個部分各自負責不同的工作,兩者缺一不可。文字端無法看見已剖析的數值;數值端則無法還原作者當初輸入的記法:
using System.Collections.Generic;
using SheetForge.Core.Model;
// Text half — on the parser. `stat:op:value`, e.g. attack:add:10
public sealed class ModifierCellParser : ICellValueParser, ICustomCellType, IReferencingCellType
{
public bool TryGetTokenKey(string t, out string key)
{
key = Head(t); // the first segment is the reference
return key.Length != 0;
}
public string MakeToken(string key) => key + ":add:0"; // neutral, ready to edit
public bool TryRetargetToken(string t, string newKey, out string newText)
{
newText = newKey + Rest(t); // the residue is preserved
return Head(t).Length != 0;
}
public bool TryRemoveToken(string t, string key, out string newText)
{
newText = string.Empty; // nothing is left without the key
return Head(t) == key; // not ours → false, never overwrite blindly
}
public bool TryRewriteKeys(string t, IReadOnlyDictionary<string, string> renames, out string newText)
{
newText = t;
if (!renames.TryGetValue(Head(t), out string to)) return false;
newText = to + Rest(t); // attack:add:10 → power:add:10
return true;
}
// … TypeName / TryParse / ValueType / TryRender as in §2
}
// Value half — on the value the parser produces.
public struct Modifier : IRefBearingValue
{
public string stat; public string op; public float value;
IEnumerable<string> IRefBearingValue.ReferencedKeys =>
string.IsNullOrEmpty(stat) ? System.Array.Empty<string>() : new[] { stat };
}實作一個介面不會新增任何欄位,因此烘焙後的 ScriptableObject 與產生的程式碼皆不受影響。
一次選用能為你帶來什麼——以下每一項都是 Core 自己原有的程式碼路徑,而非另一套重新實作:
- 完整性+候選建議——一個不存在的鍵值會以
UnresolvedRecordId回報,附上座標與「did you mean …」,並與內建參照共用同一個逐欄位的候選建議預算。 - 保留負載內容的重新命名傳播——將
attack重新命名為power,會把attack:add:10改寫成power:add:10;運算子與數字都是作者輸入的內容,會被完整保留。 - 圖形——這條連結會成為一條帶有座標的真正邊:它會被繪製出來,節點會得到一個連接埠,Used by 清單會將其計入,參照索引也會雙向收錄它。
▾選取器——該儲存格會得到與RecordId@Tab儲存格相同的可搜尋下拉選單,挑選另一筆記錄會只取代目標並保留殘留內容。若未註冊此介面,選取器則會拒絕操作,而不是把一個裸鍵值貼到你的數值上。- 孤兒偵測與匯出的下拉選單規則——一列唯一的對外連結存在於你的記法內部時,不再會被視為未連結。你這個型別的純量欄,也會得到一個涵蓋目標分頁鍵值的資料驗證下拉選單(來源、匯出與推送)。
最簡單的用法是別名型別。 假設該數值只是一個鍵值,且儲存格文字就是那個鍵值:
TryGetTokenKey去除頭尾空白。MakeToken回傳該鍵值。TryRetargetToken回傳新的鍵值。TryRemoveToken回傳空字串。
那麼這一欄在功能上的每一個層面都等同於 RecordId@Tab。留給你的唯一工作就是呈現方式:它會在 @type 中以自己的名稱出現,你也可以只針對這一欄附加一個儲存格元件(§4.13)或一個畫布形態(§4.7)。別名不需要另外的獨立合約。
有兩項限制,皆屬於結構性質:
- 負載內容中不得含有
;。 Core 會在你的剖析器或這些掛勾看到文字之前,就先將清單儲存格拆分成多個元素,因此值裡面的分號會被拆碎成兩個元素。(wrapper 型別基於相同原因,帶有相同的限制。) @target必須指向一個真正的試算表分頁,與RecordId@Tab的規則完全相同——程式碼註冊表的虛擬分頁會被以UnknownTargetTab拒絕。正是這項限制,才讓未解析參照回報、最接近候選建議與重新命名傳播,得以維持為 Core 自身、不加修改的機制。
五個掛勾皆不得拋出例外:對任何你無法解讀的內容,回答 false 或 null,並在每次改寫時保留殘留內容。
它同樣能對整數鍵值空間運作。 如果你 @target 所指名的分頁,鍵值是 IntId 而非 RecordId,你的程式碼完全不需要變更——你的掛勾所收發的鍵值,就只是以文字寫出的整數而已。要比對哪一個鍵值空間,是由目標分頁自身的身分決定,而非由你的型別決定。
- 驗證、最接近候選建議、重新命名傳播、邊、選取器與孤兒偵測,全都會以相同方式運作。
- Core 在這裡替你多做了一件貼心的事:由於一個整數可以有好幾種寫法,重新命名時會將
TryRewriteKeys收到的拼法,同時以該元素中出現的原始寫法與標準寫法一併提供(007與7都會對應到12),因此你型別內部的序數查找,不會漏掉一個補零的值。 - 隨附的示範並未包含一個以
IntId分頁為目標的參照型自訂型別——它的Modifier範例目標是一個以字串為鍵值的分頁——因此這條路徑有測試涵蓋,但沒有可供直接參考的實作範例。
4.5 自訂結構標記(選用)
內建的標記為 @name、@type、@desc,以及三個選填的標記:
@overlap。@style,描述的是試算表本身——其群組標籤與顏色——而非其欄位。@enum,將該試算表標示為一組 enum 定義,而非資料表。
@overlap 是一種逐欄標記:其列的每個儲存格各自帶有一欄的值,並逐欄進行驗證。你可以以相同方式註冊自己的標記——例如一個用來記錄每個數值欄如何內插的 @curve 標記。實作 IStructuralMarkerDefinition,並透過 ISheetForgeMarkerPlugin 註冊它:
// A hypothetical plugin (the bundled Plugin Demo does not register a marker):
public sealed class CurvesPlugin : /* ... */, ISheetForgeMarkerPlugin
{
public void RegisterStructuralMarkers(MarkerRegistry markers)
{
markers.Register(new CurveMarker());
}
}
public sealed class CurveMarker : IStructuralMarkerDefinition
{
public string MarkerName => "curve"; // without '@' → the sheet row is @curve
public string Description => "How this column interpolates (linear/ease/step).";
// Validate this column's @curve cell. Empty is allowed (defaults to linear).
public void ValidateCell(MarkerCellContext context)
{
string v = context.RawText.Trim();
if (v.Length == 0) return; // you decide what an empty cell means
if (v != "linear" && v != "ease" && v != "step")
context.Reject("@curve must be linear, ease, or step", "use one of: linear, ease, step");
}
}如此一來,試算表就能接受一個 @curve 列(任意順序,位於資料列之上):
@name | level | atk
@type | int | int
@curve | | ease
| 1 | 10- 此值會被儲存為與領域無關的中繼資料:
field.MarkerValues["curve"]。領域驗證器或邊貢獻者可以從context.Tables[tab].Schema.Fields[i].MarkerValues讀取它;編寫視窗則會在欄標頭工具提示中顯示它。 - 被拒絕的儲存格會變成一筆
MarkerCellInvalid診斷資訊——你負責提供「為什麼」與「如何修正」;Core 則負責提供座標與有問題的值。 - 標記名稱必須是合法的識別碼,且不能與內建的六種標記(
@name/@type/@desc/@overlap/@style/@enum)衝突(否則Register會拋出例外,並以PluginRegistrationConflict的形式呈現)。 - 標記是用於逐欄的中繼資料,而非新的資料結構形態——一個標記只負責自己儲存格的驗證,而非整列。自訂標記列會在匯出/往返流程中被逐字保留,並在每一次結構編輯(新增/刪除/移動/重新命名)時,隨其所屬的欄一起移動。
- 程式碼產生器不會將標記值烘焙進去(如同
@overlap,它們僅是驗證/顯示用的中繼資料,對結構描述指紋而言是不可見的)。
4.6「建立試算表」範本(選用)
建立試算表流程內建出貨兩個範本——一份僅使用核心型別的物品試算表,以及一份 @enum 定義試算表——外加「從零開始」。
領域範本是使用你的 enum、自訂型別與參照的試算表骨架。它們來自外掛,因此範本只會在對應的外掛存在時才會出現。實作 ISheetForgeTemplatePlugin:
public sealed class SkillsPlugin : /* ... */, ISheetForgeTemplatePlugin
{
public void RegisterTemplates(TemplateRegistry templates)
{
templates.Register(new DataTemplate(
"skills.demo", // registry key (unique; duplicates rejected)
"Skill demo (Actions · Effects · Skills)", // your own display string
new List<DataTemplateTab>
{
// Each tab carries a full TSV: marker rows + example data.
new DataTemplateTab("Actions", "@name\tcodeName\ttype\n@type\tRecordId\tEnum<ActionType>\n\tfireball\tProjectile"),
new DataTemplateTab("Effects", /* ... */ ""),
new DataTemplateTab("Skills", /* ... */ ""),
}));
}
}- 一個範本帶有一個或多個分頁,每個分頁都是一份完整、標準化的 TSV:註解/標記列加上範例資料。這與內建的物品範例不同,那只是一個 0 列的骨架。因為你的領域型別已經完成註冊(外掛已載入),所建立的試算表能立即成功重新匯入。
- 顯示字串由你自行決定。 外掛擁有自己的文字內容(範例套件位於領域詞彙防護之外)——你並不受限於 Core 的
Loc鍵值。 - 多分頁範本會建立所有分頁並只重新匯入一次,因此跨分頁的參照能一併正確解析。建立面板會為這類範本隱藏分頁名稱欄位(分頁名稱由範本固定)。
- 鍵值、空白顯示名稱、零分頁,以及空白的分頁 TSV 都會被拒絕(
Register會拋出例外,並以PluginRegistrationConflict的形式呈現)。
4.7 逐分頁畫布覆寫(選用)
Data Studio 的畫布會自行決定要繪製什麼。你開啟一筆記錄——終點——它便會向外走訪參照索引,蒐集該記錄所消耗的一切,再將結果由左到右排列出來。即使完全沒有外掛,這一切依然能夠正常運作。
外掛所新增的,是核心看不到或無法得知的內容:
- 一個並非試算表記錄的身分,
- 一條沒有寫在
RecordId@Tab欄中的連結, - 一種屬於領域規則、而非參照深度的順序。
實作 IRecordCanvasAugmenter,並透過 ISheetForgeGraphPlugin 逐分頁註冊它:
using SheetForge.Core.Graphing;
using SheetForge.Core.Plugins;
public sealed class SkillsPlugin : /* ... */, ISheetForgeGraphPlugin
{
public void RegisterGraphShapes(GraphShapeRegistry shapes)
{
shapes.Register("ExampleActions", new ExampleReactiveAugmenter()); // tab name → override
}
}
public sealed class ExampleReactiveAugmenter : IRecordCanvasAugmenter
{
public void Augment(GraphBuildContext context, CanvasAugmentBuilder builder,
string terminusTab, string terminusRecordId)
{
// context = Tables (parsed sheets) · References (indexed both ways) · CodeRegistries
// ① A virtual node: an identity that is not a sheet record. The tab may be empty —
// then the key alone identifies it. The last argument is where clicking it jumps.
builder.AddNode(string.Empty, "evt:impact_landed", "impact_landed", "event");
// ② An extra edge the core scanner cannot see (this link lives in a plain string column).
// Naming the field says *which cell* it is written in; leaving it out keeps the wire
// display-only. Direction is "A uses B", and B is drawn to the left of A.
builder.AddEdge(terminusTab, terminusRecordId, string.Empty, "evt:impact_landed",
/*label*/ "listen", /*fieldName*/ "listen");
// ②b An edge drawn one way whose cell lives on the other end, and a loop you know about.
// Both are trailing arguments — the short call above still compiles unchanged.
builder.AddEdge(string.Empty, "evt:impact_landed", terminusTab, terminusRecordId,
label: "raises", fieldName: "raises", fieldOnTarget: true,
isCyclic: true, cyclicNote: "brake 0s — no damping");
// ③ A layer hint. Absolute columns count from 0 at the left (negative goes further left);
// relative columns count from the terminus, which is what a fixed stage usually means.
builder.SetLayerRelative(string.Empty, "evt:impact_landed", -2);
// ④ A display hint: what a human calls this record. Only you know which column is a name.
builder.SetSubtitle(terminusTab, terminusRecordId, "Counter strike");
}
}- 註冊是以分頁名稱為單位的。 你未註冊的分頁,依然會取得一個畫布——也就是核心的閉包——因此外掛絕不需要涵蓋每一份試算表。重複的分頁、空白的分頁名稱,以及 null 覆寫皆會被拒絕(
Register會拋出例外,並以PluginRegistrationConflict的形式呈現)。 - 你只能新增,不能取代。 哪些記錄會出現,答案由核心閉包決定。若一個虛擬節點的 (分頁, 鍵值) 已經在畫面上,該虛擬節點就會被捨棄——真實記錄勝出——因此覆寫無法憑空捏造出一筆存在於試算表中的記錄。它能做到的,是帶入完全沒有試算表列對應的身分。
- 名稱是唯一的例外。 顯示提示屬於呈現方式,而非身分本身,因此它確實適用於已經存在的記錄。它也可以為畫面外完全沒有出現的記錄命名:連結選取器會讀取這些名稱,這正是為什麼卡片的副標題與選取器中的一列會顯示相同的內容。空白名稱會被忽略(等同於「使用預設值」),且一筆記錄的第一個名稱勝出。
- 一條邊會帶來自己的節點。 如果一條額外邊的其中一端不在畫面上,它就會被新增為一個節點,讓連結絕不會懸空。任一端鍵值為空的邊會被忽略。
- 儲存格所在的位置,與箭頭指向的方向,兩者可能不同。 預設情況下,
fieldName所指名的儲存格,會被假定位於出發端的記錄上。若它其實位於到達端,請傳入fieldOnTarget: true——一條「已發布事件」連線是以事件 → 記錄的方向繪製,但文字卻存放在該記錄自己的欄中。連線檢閱器接著就會指向那個真正的儲存格,而不是指向虛無。 - 循環:核心會標示它看得到的循環,你則宣告你所知道的循環。 如果你的額外邊閉合出一個迴圈,畫布會自行分類這條回頭邊,並將其繪製為虛線。判斷一個循環是否構成問題,是領域驗證器的職責(§3);畫布只是顯示用的素材,絕非驗證。
isCyclic會將一條連線標示為循環,僅供顯示用途,不會影響版面配置。cyclicNote則承載只有你才知道的資訊(例如一個阻尼值)——請將標籤保留為欄位名稱,並把說明放進備註中。
- 圖層提示有兩種形式。
SetLayer是絕對式的——第 0 欄位於最左側,負數則更靠左。SetLayerRelative則以終點為基準計數(−1 代表緊鄰其左側的一欄),這通常正是固定階段所代表的意義。如此一來,無論鏈路是淺是深,畫面讀起來都一致,你也不需要為了避免各階段互相衝突,而特意釘住終點本身。- 相對提示會在任何提示移動它之前,就先對照終點欄位解析完畢,因此你新增提示的順序不會改變結果。若結果落在零的左側,整幅畫面就會相應向右位移。
- 針對一個不在畫面上的節點所給的提示會被捨棄,且一個節點的第一個提示勝出。
- 失敗會被限制在局部範圍內。
Augment會在 try/catch 中執行:一個例外只會變成一則英文的 Console 警告,並保留核心原本的畫面,絕不會導致整個視窗損毀。 - 功能擴充絕不會破壞你的程式碼。 自初版發布以來,每一項新增的能力,都是尾端追加的參數或一個新方法;針對較早介面撰寫的覆寫,仍能正常編譯並呈現相同行為。
(完整的覆寫範例請參閱 Assets/SheetForge.PluginDemo/Graphing/ExampleReactiveAugmenter.cs 與 ExamplePipelineAugmenter.cs——前者是一個會在記錄周圍長出事件節點與程式碼區塊的反應,後者則是一個固定階段各自釘在自己欄位上的施放過程。)
4.8 程式碼註冊表——存放於程式碼中的參照目標(選用)
有些參照目標根本不是在試算表中編寫的:它們是你的執行期程式所派送到的執行原子。將它們註冊為一個鎖定的虛擬分頁,會把它們以唯讀方式呈現在編寫介面上,並讓指向它們的邊不再被繪製成失效狀態。實作 ISheetForgeCodeRegistryPlugin:
using System.Collections.Generic;
using SheetForge.Core.Graphing;
using SheetForge.Core.Plugins;
public sealed class SkillsPlugin : /* ... */, ISheetForgeCodeRegistryPlugin
{
public void RegisterCodeRegistries(CodeRegistryCatalog catalog)
{
catalog.Register(new CodeRegistrySource("_Refs", new List<CodeRegistryEntry>
{
// key = the referenceable id · label = shown text · raises = optional related keys
new CodeRegistryEntry("action.projectile", "Projectile launch", new[] { "impact_landed" }),
new CodeRegistryEntry("effect.script", "Script effect", null),
}));
}
}- 三個使用端:
- Data Studio 的側邊欄會在 READ-ONLY 底下,將此虛擬分頁顯示為一個鍵值/標籤/raises 網格;
- 畫布覆寫可以透過
context.CodeRegistries查詢這些項目; - 節點檢閱器則會列出某個項目的
Raises。
- 這些鍵值會加入 Studio 的存在性檢查。 一條目標為已註冊鍵值的邊——通常是由
IEdgeContributor(§4)宣告、或由你的畫布形態所建構的——不會被畫成失效參照。 - 匯入驗證器並不認識虛擬分頁。 程式碼註冊表是編寫介面層級的概念,因此請不要將某個試算表欄位的型別設為
RecordId@_Refs(匯入會回報UnknownTargetTab)。請以範例的做法,將試算表資料連結到程式碼原子——一個type欄,加上一個邊貢獻者/形態查詢。 - 請挑選一個不會與真實試算表衝突的名稱(範例使用
_前綴)。若真的發生衝突,Studio 會在側邊欄中以徽章標示衝突,而不會靜默隱藏其中任何一個。 - 拒絕情況:
null來源、空白分頁名稱,或重複的分頁名稱皆會拋出例外(並以PluginRegistrationConflict的形式呈現);null的Raises清單會被正規化為空清單。Core 會將鍵值/標籤/raises 皆視為不透明的字串——絕不會解讀它們。
(請參閱 Assets/SheetForge.PluginDemo/Graphing/ExampleCodeAtoms.cs。)
4.9 Data Studio 圖形小工具(選用,Editor 組件)
小工具是圖形畫布上方、屬於你自己的一條 UI 長條——一個固定階段總覽、一個彙總徽章,或任何領域需要的內容。核心本身不出貨任何小工具,因此在外掛填入內容之前,這個區域是空白的。
因為回傳型別是 VisualElement,這項合約位於 Editor 組件中——與 ISheetSourceProvider 屬於相同、合理的不對稱設計。請在一個參照 SheetForge.Editor 與 SheetForge.Core 的 Editor 端組件中實作它:
using SheetForge.Editor.Studio;
using UnityEngine.UIElements;
public sealed class ExampleStageStripWidget : IStudioGraphWidget
{
// context = Tab · ShapeId · ModeId · FocusRecordId · FocusRecord · Tables · References · CodeRegistries
public bool AppliesTo(StudioGraphContext context) =>
context.Tab == "ExampleSkills" && context.FocusRecord != null;
public VisualElement Create(StudioGraphContext context)
{
var strip = new VisualElement();
strip.Add(new Label("VALIDATE → CAST → COMMIT → DELIVER → APPLY"));
return strip; // return null to add nothing
}
}- 偵測是自動的——
TypeCache會找出每一個具備無參數建構子的實作;沒有註冊呼叫,也沒有需要繫結的登錄表。實體化失敗會被記錄並跳過。 - 依合約僅供唯讀使用。 此內容會公開已剖析的資料表、參照索引與程式碼註冊表——但沒有任何暫存介面。從圖形進行編寫,屬於檢閱器動作(§4.10)的職責,由它來居中處理。
- 元件內部不儲存任何狀態。 小工具會在每次圖形重建時重新建立;請將狀態保存在你自己的物件中。重建動作會被合併為人類操作的頻率,而非每次按鍵都重建。
- 例外會被隔離——
AppliesTo/Create拋出例外會產生一則英文的 Console 警告;圖形依然會照常繪製。
(請參閱 Assets/SheetForge.PluginDemo/Demo/Editor/ExampleStageStripWidget.cs。)
4.10 Data Studio 檢閱器動作(選用,Editor 組件)
動作是節點檢閱器上的一個額外按鈕——代表「這個領域可以對這筆記錄做什麼」。核心本身提供一個內建動作(Go to this sheet);其餘一切都透過這項合約加入:
using SheetForge.Editor.Studio;
public sealed class ExampleInspectorAction : IStudioInspectorAction
{
// A Loc key. The demo registers this key's sentences per language (§4.14);
// an unregistered key is displayed verbatim, so plain text also works.
public string LabelKey => ExampleLocStrings.BrakeActionKey;
public bool AppliesTo(StudioInspectorContext context) =>
context.Tab == "ExampleActions" && context.Record != null;
public void Execute(StudioInspectorContext context)
{
// Mediated mutation: the window turns this into one Undo step + one staged edit
// carrying the logical address (tab · record id · field).
context.StageCell(context.Tab, context.RecordId, "brakeSeconds", "0.25");
// Show the user what changed: (tab, original sheet row number, field); row 0 = tab only.
context.FocusCell(context.Tab, context.Record.RowNumber, "brakeSeconds");
context.RequestRebuild();
}
}- 編寫工作階段刻意不被公開。 每一項暫存變更都必須是一個原生 Undo 步驟、並伴隨投影世代的遞增;若直接交出原始的工作階段,就等於制度化了一條繞過這項規則的途徑。
StageCell(tab, recordId, field, rawText)與StageCells(writes)就是全部的變動介面,簿記工作則由視窗自行負責。 - 要變更多個儲存格?請使用
StageCells。context.StageCells(new[] { new EdgeCellWrite(tab, recordId, field, text), … })會將整份清單暫存為一個復原步驟,要嘛全部成功、要嘛全部不執行:若其中一項寫入無法套用,則沒有任何一項會被套用。多次呼叫StageCell會把 Ctrl+Z 拆分成同樣多個步驟,對於平行的欄位而言,這代表復原到一半時,會出現一個半有效的狀態。null 或空清單則不會有任何作用。 - 請傳入標準文字。 暫存的文字會在反映時,經由匯入器所使用的相同剖析器解析——因此請寫入試算表原本會存放的內容。
- 一個不在 baseline 中的鍵值,會是一個無操作(全新或尚未解析的記錄):不會靜默寫入任何內容。
- 服務:
FocusCell會將網格捲動到某個座標,RequestRebuild則會在你暫存內容之後要求重新整理畫面。 - 偵測、標籤與隔離機制,運作方式與小工具完全相同:
TypeCache偵測、未註冊的LabelKey會逐字回退顯示(空白鍵值則回退為型別名稱),AppliesTo/Execute皆置於 try/catch 中。
(請參閱 Assets/SheetForge.PluginDemo/Demo/Editor/ExampleInspectorAction.cs。其 Editor 組件——SheetForge.PluginDemo.Demo.Editor——參照了 SheetForge.Editor、SheetForge.Core 與該外掛組件;一個 Editor 端擴充功能所需的接線,僅此而已。)
4.11 色彩預設集(選用)
SheetForge 會以一組精簡的顏色插槽詞彙(表面、線條、文字、語意色彩、暫存標記)來繪製自己的視窗。一個預設集只會為它所在乎的插槽重新上色;其餘每一個插槽都會維持產品預設值。實作 ISheetForgeThemePlugin:
public sealed class SkillsPlugin : /* ... */, ISheetForgeThemePlugin
{
public void RegisterThemes(ThemeRegistry themes)
{
themes.Register(new SheetForgeTheme(
"skills.forge", // registry key (unique; the built-in ids are reserved)
"Forge (Skill demo)", // your own display string
new Dictionary<ThemeColorSlot, uint> // dark screens
{
{ ThemeColorSlot.Accent, 0xff9a4d },
{ ThemeColorSlot.Canvas, 0x120d0a },
{ ThemeColorSlot.Text, 0xe8dccf },
},
new Dictionary<ThemeColorSlot, uint> // light screens
{
{ ThemeColorSlot.Accent, 0x9c4a10 },
{ ThemeColorSlot.Canvas, 0xf7f2ec },
{ ThemeColorSlot.Text, 0x2b1f16 },
}));
}
}- 顏色皆為
0xRRGGBB。 Core 不參照任何引擎型別,因此這裡沒有UnityEngine.Color;最高位元組會被忽略。半透明表面(徽章填色、對話框遮罩)是由插槽顏色加上一個固定的透明度推導而來——你只需設定顏色,不需要設定透明度。 - 請同時提供兩種畫面。 提供一份深色對照表與一份淺色對照表;使用者的亮度選擇(跟隨編輯器/永遠深色/永遠淺色)會挑選其中一份。你省略的插槽,會回退為該亮度下的產品預設值,因此一個只涵蓋三個插槽的預設集也完全合理。
- 註冊並不會套用它。 你的預設集會出現在
Preferences ▸ SheetForge ▸ Theme ▸ Colour preset中,與內建的 Default 及 High contrast 並列;只有使用者的選擇才會真正生效。顯示字串由你自行決定(不需要 Core 的Loc鍵值)。 - 空白 id、重複的 id,以及保留的內建 id(
default、highContrast)皆會被拒絕(Register會拋出例外,並以PluginRegistrationConflict的形式呈現)。 - 主題無法重新設計的部分: 繪製於我們視窗內部的原生 Unity 小工具(按鈕外框、欄位邊框)仍會持續依循編輯器外觀主題——請參閱功能與限制。
4.12 在圖形畫布上編輯(選用)
Data Studio 的圖形是一個編寫介面,而不只是一張圖:按右鍵可以建立記錄、連結它們,並取消連結線(詳見 Data Studio)。以上這一切,對於一般的 RecordId@Tab 欄位而言,即使在純淨的專案中也完全可用。
以下這些能力,則延伸到核心所無法觸及之處。它們都不會變更任何既有合約,因此忽略它們的外掛依然能夠原樣編譯。
能力如何被偵測(請先閱讀本節)
一項能力絕不會被單獨偵測到。視窗是透過轉型已經註冊的物件,才找到它們每一個的:
| 能力 | 從何轉型而來 | 新增了什麼 |
|---|---|---|
IAuthorableGraphShape | 由 ISheetForgeGraphPlugin 註冊的畫布覆寫 | 新記錄可以在哪裡被建立 |
IAuthorableEdgeContributor | 由 ISheetForgeEdgePlugin 註冊的邊貢獻者 | 將一個手勢轉換成一次儲存格寫入 |
IBatchAuthorableEdgeContributor | 同一個邊貢獻者 | 將一個手勢轉換成多次儲存格寫入 |
IVirtualNodeFactory | 同一個邊貢獻者 | 在節點選單上提供「再建立一個」的選項 |
IEdgeSlotDeclarer | 同一個邊貢獻者 | 宣告結構描述無法推導出的連結插槽 |
IEdgeTokenEditor | 同一個邊貢獻者 | 描述一個 token,並編輯其中非鍵值的部分 |
因此,這五個邊相關的能力,只有當該類別被註冊為 IEdgeContributor(透過 ISheetForgeEdgePlugin,見 §4)時才能被觸及。如果你的領域沒有要開啟任何自己的邊,這也不是跳過註冊的理由——請將 ContributeEdges 實作為一個空方法,並依然註冊它。這個空的貢獻者,正是官方認可、用來加入以下能力的做法:
public sealed class ExampleSlotPlugin : ISheetForgeEdgePlugin
{
public void RegisterEdgeContributors(EdgeContributorRegistry contributors)
=> contributors.Register(new ExampleSlotContributor());
}
public sealed class ExampleSlotContributor : IEdgeContributor, IEdgeSlotDeclarer
{
public string Name => "ExampleSlots";
// Nothing to declare — this class is here for the capabilities below.
public void ContributeEdges(EdgeContributionContext context, ICollection<EdgeSpec> edges) { }
public IReadOnlyList<DeclaredSlot> DeclareSlots(EdgeAuthoringContext context,
string nodeTab, string nodeRecordId) => …;
}以上所有能力皆於 try/catch 中執行:一個例外只會變成一則英文的 Console 警告,並停用那一項功能,不會影響其他任何事物。
新記錄可以在哪裡被建立——IAuthorableGraphShape
有兩種預設值,且它們刻意各不相同。
- 可建立分頁清單——這項能力所取代的軸線,同時也決定畫布是否會開啟——涵蓋了焦點分頁的結構描述可以透過參照遞移地觸及的每一個分頁。它是依結構描述而非資料計算出來的,因此即使某份試算表尚無任何列,這條規則依然成立。抵達兩個連結之外的分頁則是逐步進行的:先建立中介記錄,其連接埠隨即出現,接著下一跳才會加入串聯。
- 連結串聯——你實際在空白畫布上看到的選取器——則較為狹窄。它只會從畫面上目前已繪製的連接埠所指向的分頁開始。
無論哪一種,程式碼註冊表所擁有的分頁,以及沒有鍵值欄的分頁,都會被排除,因為在那裡建立的新記錄無法擁有身分。
為該分頁註冊的覆寫(§4.7)可以加入這個介面,來取代這兩種預設值。它所指名、但畫面上沒有任何連接埠接受的分頁,仍會留在連結串聯清單中並附上原因,而不會直接消失:
using SheetForge.Core.Graphing;
public sealed class ExamplePipelineAugmenter : IRecordCanvasAugmenter, IAuthorableGraphShape
{
// Empty list = no creating from this canvas. The window still applies its own gates
// (read-only source, running pipeline, workbook-backed tab, no key column) on top.
public IReadOnlyList<string> CreatableTabs(GraphBuildContext context, string tabName)
=> new[] { "ExampleEffects", "ExampleActions" };
}讓你自己的邊變得可編輯——IAuthorableEdgeContributor
一條你透過 IEdgeContributor(§4)開啟的邊,會被繪製出來,但無法被編輯,因為只有你知道它所存在的記法。加入這個介面,就能將一個手勢重新轉換回儲存格文字;視窗會確切暫存你所回傳的內容,剖析器則依然是最終的裁判:
using SheetForge.Core.Edges;
public sealed class ModifierStatEdgeContributor : IEdgeContributor, IAuthorableEdgeContributor
{
public bool TryPlanConnect(EdgeAuthoringContext context, string fromTab, string fromRecordId,
string toTab, string toRecordId, out EdgeCellWrite write)
{
write = default;
if (fromTab != "ExampleEffects" || toTab != "ExampleStats") return false; // not mine
// CellText = the cell as it reads right now (baseline + staging), not the parsed value.
string current = context.CellText(fromTab, fromRecordId, "modifier");
if (current.Contains(toRecordId + ":")) return false; // already linked
string next = current.Length == 0 ? toRecordId + ":add:0"
: current + "; " + toRecordId + ":add:0";
write = new EdgeCellWrite(fromTab, fromRecordId, "modifier", next);
return true;
}
public bool TryPlanDisconnect(EdgeAuthoringContext context, RecordEdge edge, out EdgeCellWrite write)
{
write = default;
if (edge.FieldName != "modifier") return false;
// …remove the fragment naming edge.ToRecordId, hand back the rewritten cell…
write = new EdgeCellWrite(edge.FromTab, edge.FromRecordId, "modifier", rewritten);
return true;
}
}- 回傳
false代表什麼都不會發生。 不會建立任何暫存內容,選單項目會被停用並附上誠實的原因——絕不會出現半套用的編輯。回傳true並附上無意義的文字是允許的,但沒有意義:暫存的值會經過與手動輸入相同的預檢驗證,並顯示在 Problems 中。 - 以鍵值定址,而非以列定址。
EdgeCellWrite指名的是(分頁、記錄 id、欄位);列號會在寫入時重新解析,因此即使列發生移動,一份暫存的計畫依然有效。 - 你會在手勢進行期間被呼叫。 兩個方法皆於 try/catch 中執行——一個例外只會變成一則英文的 Console 警告,並停用那一項功能,不會影響其他任何事物。
- 詢問內容時請查詢 context,而不是試算表本身。
CellText回傳的值包含暫存內容,因此連續建立的兩個連結能夠彼此看見對方。若改為讀取已剖析的資料表,則會遺漏第一個。
在一個手勢中變更多個儲存格——IBatchAuthorableEdgeContributor
有些資料會把一個項目分散存放在平行的多個欄中:stepDelays | stepTargets | stepCounts,其中每一欄的索引 i 代表同一個步驟。在那裡新增一個連結,必須同時擴增每一欄,否則各欄的長度就會不一致——這是單一儲存格計畫無法避免的半有效狀態。
這項能力是 IAuthorableEdgeContributor 的同層介面(而非子類別),因此只實作單數形式的貢獻者不受影響:
using SheetForge.Core.Edges;
public sealed class ExampleStepContributor : IEdgeContributor, IBatchAuthorableEdgeContributor
{
public bool TryPlanConnectMany(EdgeAuthoringContext context, string fromTab, string fromRecordId,
string toTab, string toRecordId,
out IReadOnlyList<EdgeCellWrite> writes)
{
writes = new[]
{
new EdgeCellWrite(fromTab, fromRecordId, "stepTargets", Append(context, fromTab, fromRecordId, toRecordId)),
new EdgeCellWrite(fromTab, fromRecordId, "stepDelays", AppendDefault(context, fromTab, fromRecordId)),
};
return true;
}
public bool TryPlanDisconnectMany(EdgeAuthoringContext context, RecordEdge edge,
out IReadOnlyList<EdgeCellWrite> writes) => …;
}- 要嘛全部成功,要嘛全部不執行。 清單中的每一次寫入,都會被暫存為一個原生 Undo 步驟;若其中一項無法寫入(沒有這一列、唯讀來源、管線正在執行),就完全不會暫存任何內容。
- 批次形式優先。 若一個類別同時實作了單數形式與批次形式,視窗只會詢問批次形式——一個手勢絕不會有兩種不同的答案。貢獻者仍會依註冊順序被詢問,且第一個提出計畫的貢獻者勝出。
- 每一次寫入都需要一個位址。 清單中若有一次寫入的分頁或欄位為空(或整份清單為空),即視為「沒有計畫」。
- 取消連結會在一條鏈上執行。 當同一張卡片上的多條連線在單一手勢中被一併剪斷時,你所讀取的 context 已經帶有本次手勢中較早的計畫,因此從同一個儲存格中剪斷兩個 token,兩者都會被移除。單數合約沒有介面可以接收這個中間值——這項能力正是用來突破這個限制的。
- 一筆正在建立中的記錄,不能作為目標。 在「建立並在同一手勢中連結」的流程中,寫入位址會在新的一列進入工作階段之前就先被解析。一項瞄準正在建立中記錄的計畫因此無法成立,整個手勢會誠實地失敗。瞄準已經存在的列(平行欄的情況)則不受影響。
建立「再一個」某種東西——IVirtualNodeFactory
當「再一個」指的不是一列新記錄,而是多個儲存格中各自再多一個元素時,畫布無法自行想出這個手勢。請宣告你能建立的種類,並在挑選時交出儲存格寫入內容:
using SheetForge.Core.Edges;
public sealed class ExampleStepContributor : IEdgeContributor, IVirtualNodeFactory
{
// Called every time the node menu is built — keep it cheap and side-effect free.
public IReadOnlyList<VirtualNodeKind> KindsFor(EdgeAuthoringContext context, string tab, string recordId)
=> tab == "ExampleSkills"
? new[] { new VirtualNodeKind("step", Loc("Add a step")) } // your own translated string
: null;
public bool TryPlanCreate(EdgeAuthoringContext context, string tab, string recordId,
VirtualNodeKind kind, out IReadOnlyList<EdgeCellWrite> writes)
{
writes = null;
if (kind.Id != "step") return false; // not mine → nothing happens
writes = new[] { … }; // one element appended per column
return true;
}
}- 標籤本身已經翻譯完成。 Core 不會翻譯它——請提供你套件所解析出來的字串(見 §4.14)。標籤中的
/會產生子選單,因此你可以將自己的項目分組。 tab可能是虛擬分頁名稱,或空白。 你的畫布覆寫放到畫面上的節點,並不存在於試算表中。選單依然會提供你所宣告的內容,因為你寫入的儲存格是由你的計畫命名的,而不是由節點的身分決定。程式碼註冊表所擁有的分頁則會被排除。- 一個 Undo 步驟,要嘛全部成功要嘛全部不執行——與上方的批次能力規則相同。
false代表完全不會暫存任何內容。
宣告連結插槽——IEdgeSlotDeclarer
連結插槽通常來自結構描述(RecordId@Tab 欄)。你的覆寫放到畫面上的節點沒有任何欄,而一個貢獻者邊只有在連結已經存在時才會揭露一個插槽——因此第一個連結根本無處可以開始。請改為直接宣告這些插槽:
using SheetForge.Core.Edges;
public sealed class ExampleStepContributor : IEdgeContributor, IEdgeSlotDeclarer, IBatchAuthorableEdgeContributor
{
// Called per card and per port gate — keep it cheap and side-effect free.
public IReadOnlyList<DeclaredSlot> DeclareSlots(EdgeAuthoringContext context,
string nodeTab, string nodeRecordId)
=> nodeTab == "#step"
? new[] { new DeclaredSlot("target", "ExampleEffects", /*isList*/ false) }
: null;
}- 名稱身兼兩項職責。 它必須在該節點內是唯一的,且必須與你所繪製之邊的
FieldName相同——插槽查詢與連線定錨,兩者都是依這個名稱比對的。如果某個試算表欄已經使用了這個名稱,試算表會勝出,你的宣告則會被靜默捨棄。 - 宣告不等於規劃。 一個已宣告的插槽,是透過你的計畫(
IAuthorableEdgeContributor或批次形式)來連結的。只宣告而不規劃,連接埠會開啟,但不會暫存任何內容——請兩者都實作。 - 連接埠會在沒有試算表列的節點上開啟。 對於一個分頁並非試算表的節點,視窗不會依該名稱尋找任何一列;寫入位址則來自你的計畫,並在暫存時才被檢查。
編輯 token 實際內容——IEdgeTokenEditor
連結與取消連結,移動的是整個 token。但 token 往往不只是一個鍵值:attack:add:10 既指名了一個數值,也說明了增減多少。將這項能力加入同一個貢獻者,連線檢閱器就會為那個殘留部分——除鍵值以外的部分——多出一列:
using SheetForge.Core.Edges;
public sealed class ModifierStatEdgeContributor : IEdgeContributor, IAuthorableEdgeContributor, IEdgeTokenEditor
{
public bool TryDescribeToken(EdgeAuthoringContext context, RecordEdge edge,
out EdgeTokenDescription description)
{
description = null;
if (edge.FieldName != "modifier") return false; // not mine
// Read the fragment out of the cell — never rebuild it from the edge, or the
// highlight points at a piece that is not there.
string fragment = FindFragment(context.CellText(edge.FromTab, edge.FromRecordId, "modifier"),
edge.ToRecordId);
if (fragment == null) return false; // hand-edited away
description = new EdgeTokenDescription(
/*tokenText*/ fragment, // "attack:add:10"
/*modifierText*/ fragment.Substring(fragment.IndexOf(':') + 1),// "add:10"
/*modifierLabel*/ "op:value",
/*isChoice*/ false, /*options*/ null, /*optionLabels*/ null); // free text
return true;
}
public bool TryPlanSetModifier(EdgeAuthoringContext context, RecordEdge edge,
string newModifier, out EdgeCellWrite write)
{
// …rebuild the cell with that one fragment's leftover replaced, key untouched…
}
}- 兩個部分讀取的是同一個儲存格。 一條邊知道自己指向哪裡,但不知道自己今天是以什麼文字寫成的,因此描述動作所取得的
EdgeAuthoringContext,與寫入動作所取得的相同。這正是讓「被醒目標示的片段」與「被改寫的片段」可以被證明是同一個片段的原因。 - 鍵值絕不會透過這道門移動。 變更一個連結所指向的目標,是重新瞄準(拖曳連線);這一列只會變更殘留的部分。任一半回傳
false,該列就會被隱藏或誠實地停用——不會暫存任何內容,也不會靜默失敗。 - 元件的描述方式由你決定。 帶有選項的
isChoice會繪製一個彈出選單,否則就是一個文字欄位;該列的標籤與選項標籤皆為你自訂的字串。若完全沒有殘留部分,建構new EdgeTokenDescription(tokenText)即可,該列就不會被繪製——一個核心參照(其鍵值就是整個 token)在完全不需要任何程式碼的情況下,就是以這種方式運作。
讓你自己的連線徹底變得可編輯
一條連線只有在指明自己寫在哪個儲存格中時,才能被編輯。對於核心自行讀取的參照,核心會自動填入這項資訊;你透過(§4.7)新增的額外邊,則需要透過指名欄位來達成:
// Display-only edge — the canvas honestly reports it cannot be edited.
builder.AddEdge(tab, recordId, targetTab, targetKey, "raises");
// Edge that names its cell: "this link is written in (tab, record, column)".
builder.AddEdge(tab, recordId, targetTab, targetKey, "listen", /*fieldName*/ "listen");指名一個儲存格,並不代表它就一定可以被編輯——它只是說明這條連結存放在哪裡。你所新增的邊,會被交給貢獻者邊所使用的相同機制處理,因此當某個 IAuthorableEdgeContributor 宣稱擁有它時,它就會變得可編輯。如果該欄是一個沒有任何人負責改寫的一般文字或 enum 欄,畫布會誠實地回報這條連線在此處無法編輯,而不是靜默地什麼都不做。
4.13 自訂儲存格元件(選用,Editor 組件)
網格會以內建元件繪製每一個儲存格(布林切換開關、enum 彈出選單、參照選取器、原始文字)。當某個型別值得擁有更好的輸入方式——一條曲線、一個顏色、一個迷你語法組合器、一個多行文字框——請針對該型別名稱替換其元件,同時不去動到數值的剖析方式:
using SheetForge.Editor.Studio;
using UnityEngine.UIElements;
public sealed class ModifierCellEditor : IStudioCellEditorProvider
{
// The base type name from @type (a CellParserRegistry name; for a wrapper, the wrapper name).
public string TypeName => "Modifier";
public VisualElement CreateEditor(StudioCellEditorContext context)
{
if (context.Type.IsList) return null; // decline — the built-in widget takes this cell.
var field = new TextField { value = context.CurrentRawText };
// Typing burst: coalesced into ONE Undo step for this cell.
field.RegisterValueChangedCallback(e => context.CommitTyping(e.newValue));
// Discrete confirmation (focus out): its own Undo step.
field.RegisterCallback<FocusOutEvent>(_ => context.Commit(field.value));
return field;
}
}- 元件負責形塑輸入,剖析器負責掌管意義。 無論你提交什麼,都是標準的試算表文字;它會經過與手動輸入相同的預檢驗證,問題會出現在 Problems 面板中。元件本身完全不需要進行驗證。
- 刻意提供兩種提交介面。
Commit(從清單中挑選、放開滑桿、失去焦點)會建立一個 Undo 步驟;CommitTyping(逐鍵輸入)則會將一連串輸入合併為一個步驟。若將兩者合併成單一呼叫,要嘛每個字元都會噴出一個 Undo 步驟,要嘛會把兩次不同的挑選合併在一起。 - 回傳
null代表拒絕該儲存格,並改由內建元件接手——這是對你不處理的形態(你這個型別的List<T>、選填欄位)誠實的回答。context.Type(已剖析的@typetoken)帶有做出這項判斷所需的一切資訊。 ReferenceKeys(tab)會提供與內建參照選取器所使用相同的候選清單:已投影的鍵值 ∪ 程式碼註冊表鍵值 ∪ 暫存中的新列鍵值,並已排序。不需要自行蒐集。若要讓使用者從同一份清單中、以內建儲存格所開啟的相同下拉選單進行挑選,請呼叫StudioKeyPicker.Show(screenAnchor, tab, candidates, picked),並在提交之前,將回傳的鍵值拼接進你自己的記法中。(建立記錄、將儲存格留白,以及對清單進行多重切換,是內建參照儲存格自己的規則,並不在此介面之列——一個掌管整個儲存格文字的元件,同時也掌管這些決策。)- 你可以宣稱擁有一個內建型別名稱,不僅限於自己的型別。 已註冊元件這個分支會優先執行,因此
TypeName => "float"確實會取代每一個float欄位的原始文字框。這正是滑桿、百分比欄位,或帶有單位後綴的欄位得以實現的方式。這麼做有兩點需要留意:- 它會套用到專案中每一個該型別的欄位,因此請透過讀取
context.FieldName/context.Tab,並對你不打算處理的欄位回傳null來限縮範圍。 - 你所提交的內容依然是標準的試算表文字,因此一個滑桿必須以剖析器讀回時所預期的方式來呈現其數值(浮點數往返所需的拼寫方式,請參閱
CanonicalValueRenderer.RenderFloat)。
- 它會套用到專案中每一個該型別的欄位,因此請透過讀取
- 衝突會發出警告,偵測則是自動的。 與其他每一種合約相同,皆採用
TypeCache偵測;若有兩個提供者宣稱擁有同一個型別名稱,先找到的那一個勝出,並附上一則具名兩者的 Console 警告。拋出例外的CreateEditor會被攔截、發出警告,該儲存格則回退為內建元件。 - 動手撰寫元件之前,先確認一個提示是否就夠用。 如果你只是想要一個下拉選單、一個多行文字框、一個滑桿、一個切換開關、一個顏色選取器、一個曲線編輯器,或一個漸層編輯器,改為註冊一個
StudioCellEditorHint即可(§4.16)——不需要撰寫任何元件程式碼,而且在瀏覽器中也同樣有效。優先順序是:先看這個合約,接著是提示,最後才是核心預設值;因此只要沒有任何元件宣稱擁有該型別、或宣稱擁有的元件選擇了拒絕,該儲存格就會採用提示。
4.14 外掛 UI 字串(選用)
你套件所顯示的標籤——檢閱器動作、元件說明文字,以及 §4.16 的宣告式介面——都能夠跟隨使用者的語言。請依語言鍵值註冊句子;Loc.Tr 會在查詢產品字串表之前,先查詢這個疊加層,瀏覽器端的 t() 也是如此:
using System.Collections.Generic;
using SheetForge.Core.Model;
using SheetForge.Core.Plugins;
public sealed class ExampleLocStrings : ISheetForgeStringsPlugin
{
// Prefix keys with your pack name so packs never collide.
public const string BrakeActionKey = "plugin.skillsDemo.action.setBrake";
public void RegisterStrings(StringOverlayRegistry strings)
{
strings.Register(BrakeActionKey, new Dictionary<string, string>
{
{ "en", "Set reaction brake to 0.25s" },
{ "ko", "반응 제동을 0.25초로 넣기" },
});
// Or one language at a time: strings.Register(key, "en", "…");
}
}- 這項合約位於 Core,因此請放進你的主要組件中。 兩個主機都會顯示你套件的標籤,而瀏覽器永遠只會載入主要 DLL——若字串外掛放在編輯器附屬組件中,網頁應用就只會顯示原始鍵值。
- 註冊是選用的。 未註冊的鍵值仍會逐字顯示——這項合約是一條升級路徑,而非強制要求。
- 語言採用 IETF 代碼(
"en"、"ko"、"zh-Hans"、"pt-BR"……等),比對時不區分大小寫。- 請至少註冊英文:查詢順序是要求的語言 → 英文 → 未命中,因此使用其他任何語言的使用者,都會讀到你的英文句子,而不是原始的鍵值。
- 產品不認得的代碼,會被連同原因一起拒絕,而不是被悄悄併入英文——一個靜默變成英文的拼字錯誤,將無從追查。
- 產品自己的鍵值無法被覆寫——一個宣稱擁有內建鍵值的註冊會被拒絕,因此疊加層絕不可能讓 UI 與產品自己的句子產生分歧。選單標籤尤其是直接從語言表烘焙而成,因此若疊加層能夠改寫它們,就會讓指引文字與真正的選單路徑互相矛盾。這個疊加層是給新鍵值使用的。
- 跨套件重複的註冊,會保留最先找到的那一個,並記錄原因——如果由最後一個註冊靜默勝出,畫面顯示的內容就會取決於外掛的安裝順序。
- 空白的鍵值與空白的值同樣會被拒絕。每一則拒絕訊息都是面向開發者的英文句子,因為它的對象是外掛作者,而非終端使用者。
- 產品的 10 種語言對等規則不受影響:你的字串存放於一個核心對照表旁的查詢疊加層中,絕不會進入表格本身。
(基於上述原因,示範將這份檔案放在主要組件中的 Assets/SheetForge.PluginDemo/ExampleLocStrings.cs,用來註冊其檢閱器動作(§4.10)與宣告式介面(§4.16)所顯示的標籤。)
4.15 單一儲存格中的多行文字(對話、描述、指令碼)
一個真正的換行字元絕不能存在於儲存格內部。 這條管線的輸入是 TSV,其中定位字元分隔儲存格、換行字元分隔列,因此一個帶有其中任一字元的儲存格,完全無法被表示出來。
每一個來源都會在入口處強制執行這項規則,而不是放任一份損毀的網格通過:
- CSV 與 xlsx 讀取器會回報
UnsupportedCellCharacter,並附上該儲存格的座標,會蒐集每一個有問題的儲存格,而非僅止於第一個; - Google 擷取端的行為相同;
- 而在一份
.tsv檔案中,該字元本來就已經是列分隔符了。
這是這個格式設計上的常數,而不是一個等待被補上的缺口。因此,一個帶有長文字的領域,會透過一套完全屬於外掛範疇的三部分慣例,順應這項規則,而非對抗它。
1. 選定一個逸出符號,並將它寫進你的剖析器中。 慣例上的選擇,是在試算表中使用一個字面上的雙字元 \n,進入時解除逸出,輸出時重新逸出:
public sealed class ProseCellParser : ICellValueParser, ICustomCellType
{
public string TypeName => "Prose";
public Type ValueType => typeof(string);
public bool TryParse(CellParseContext ctx, string text, out object value)
{
value = text.Replace("\\n", "\n"); // sheet spelling → the value your game sees
return true;
}
public bool TryRender(object value, out string text, out string reason)
{
reason = null;
text = ((string)value).Replace("\r\n", "\n").Replace("\n", "\\n"); // the exact reverse
return true;
}
}讓兩個方向精確互逆,並加以證明。 TryRender 正是匯出與推送寫回時所使用的方法,因此如果它沒有逐字元地還原 TryParse 的結果,一趟「試算表 → 匯入 → 匯出 → 試算表」的往返,就會改寫沒有任何人編輯過的文字。在輸出時將 \r\n 正規化為 \n(如上所示),正是避免一個由 Windows 撰寫的值,在連續多次匯出之間於兩種拼寫形式間來回切換的關鍵。一個渲染已剖析數值、再與原始儲存格文字比對的單一測試,就足以鎖定這項行為。
2. 為該儲存格提供一個真正的編輯器。 一個以 \n 逸出的值,在單行文字框中輸入起來並不愉快,而這正是 §4.13 所要解決的問題。為 "Prose" 註冊一個 IStudioCellEditorProvider,回傳一個多行的 TextField(multiline = true),以真正的換行字元顯示該值,並在提交時重新逸出。請以 Commit 在失去焦點時提交(每次編輯工作階段一個 Undo 步驟),而不是每次按鍵都提交。
3. 了解這項慣例唯一無法觸及的地方。 有人若直接在 Google 試算表中按下 Alt+Enter,會在該即時儲存格中建立一個真正的換行字元,而下一次擷取時,該儲存格就會被拒絕,並附上指向它的座標。這項拒絕是誠實而且可修正的,但它終究是一次拒絕。因此,如果你團隊中的文案人員是直接在試算表裡撰寫文案的,請在你自己的說明資料中說明長文字是以 \n 撰寫的。或是讓他們改在 Data Studio 的儲存格元件中撰寫(步驟 2 所建立的那一個),逸出的工作會自動為他們完成。
4.16 宣告式編寫介面(選用)
§4.9、§4.10 與 §4.13 回傳的都是 VisualElement,這正是它們僅限編輯器使用的原因:瀏覽器無法載入 UIToolkit 型別,因此以這種方式撰寫的擴充功能,只會存在於一個畫面上,另一個畫面則沒有。
這項合約則以資料的形式回答相同的需求。你描述的是外殼——一個 id、一個標籤鍵值、一個放置位置、一種色調——並只提供判斷式與效果作為委派。單一次註冊,就能同時由編輯器的 UIToolkit 渲染器與瀏覽器的 React 渲染器繪製。
using SheetForge.Core.Plugins;
using SheetForge.Core.Studio;
using SheetForge.Core.Theming; // ThemeColorSlot — tones are slots, never hard-coded colours
public sealed class ExampleStudioUi : ISheetForgeStudioPlugin
{
public void RegisterStudioUi(StudioUiRegistry ui)
{
// ① A verb — right-click a row, and this appears at the end of the menu.
ui.AddAction(new StudioActionDescriptor(
"skillsDemo.setBrake", // unique id ("pack.verb" reads well)
ExampleLocStrings.BrakeActionKey, // a Loc key (§4.14); unregistered = shown verbatim
StudioActionPlacement.RowContextMenu,
ctx => ctx.Tab == "ExampleActions" && !string.IsNullOrEmpty(ctx.RecordId), // cheap predicate
ctx => ctx.StageCell(ctx.Tab, ctx.RecordId, "brakeSeconds", "0.25")));
// ② A summary panel — a node tree, rebuilt each recompute tick.
ui.AddPanel(new StudioPanelDescriptor("skillsDemo.summary", ExampleLocStrings.PanelTitleKey, ctx =>
StudioUiNode.List(
StudioUiNode.Heading("Cast summary"),
StudioUiNode.KeyValue("Total damage", TotalDamage(ctx).ToString()),
StudioUiNode.Progress("Cast time", CastRatio(ctx), ThemeColorSlot.Accent),
StudioUiNode.Button("Fill every unbraked reaction", "skillsDemo.fillBrakes"))));
// ③ A column badge — one node beside a column header (null = nothing on that column).
ui.AddColumnBadge(new StudioColumnBadgeDescriptor((ctx, tab, field) =>
field == "brakeSeconds" ? StudioUiNode.Badge(UnbrakedCount(ctx) + " unbraked", ThemeColorSlot.Warning) : null));
// ④ A cell-editor hint — pick a built-in widget for your type without writing one.
ui.AddCellEditorHint(new StudioCellEditorHint("Modifier", StudioCellEditorArchetype.Dropdown, Options));
}
}這份詞彙表刻意維持有限範圍——它只會透過附加成長,絕不會插入新項目,因此一個既有的註冊會永遠保有它原本的意義。
- 動作有五種放置位置:
Inspector、RowContextMenu、TopbarMenu、ColumnHeaderMenu、CanvasNodeMenu。- 每一種都會以該位置所知道的內容填入情境——列的放置位置帶有該筆記錄,欄的放置位置帶有該欄名稱,畫布的放置位置帶有該節點的記錄——其餘則留空,因此在讀取某個位置未提供的欄位之前,請先加以防護。
- 面板或徽章有十三種節點種類:
Row、Label、Chip、Badge、Button、Rule、Heading、KeyValue、Table、List、Progress、Input、Link。- 它們皆透過靜態工廠方法建構(
StudioUiNode.Label(…)、.WithTooltip(…)),因此一個節點是不可變的,且只會設定對該種類有意義的欄位。
- 它們皆透過靜態工廠方法建構(
- 七種儲存格元件原型:
Dropdown(由你提供候選項目)、MultilineText、Slider(由你提供範圍)、Toggle(由你提供兩段標準文字)、ColorPicker(#RRGGBB/#RRGGBBAA)、CurveEditor與GradientEditor(儲存格文字為試算表語法所定義的標準曲線/漸層記法——若某個套件自己的型別也寫入該記法,例如透過CurveValue.Render(),就可以宣告使用這兩者)。內建的Color、AnimationCurve與Gradient型別,正是透過同一套機制串接的——BuiltinCellEditorHints存放著它們的三個提示——而主機會優先查詢套件自己的註冊內容,因此在這些型別名稱之下註冊一個提示,會覆寫內建的選擇,而不會被拒絕。在編輯器中,最後三種原型就是 Unity 自己的顏色、曲線與漸層欄位;在瀏覽器中,則是應用程式自己的編輯器;帶有這三者之一的List<>,在兩個主機中都會變成晶片編輯器。Plugin Demo 的Falloff型別正是如此:其剖析器透過CurveValue.TryParse讀取儲存格,而僅僅一次提示註冊,就讓它在 Unity 中取得曲線欄位、在瀏覽器中取得曲線編輯器。 - 任何地方都沒有版面配置數值。 像素與比例會讓一個畫面的顆粒感滲透進另一個畫面;你只需說明要顯示什麼,如何擺放則交由各個渲染器自行決定。
撰寫之前值得了解的規則:
- 變動所經過的門,與你親手操作時相同。
StudioSurfaceContext給予一個動作恰好四項能力——StageCell、StageCells(多個儲存格、一個 Undo 步驟、全有或全無)、FocusRecord、RequestRebuild——以及唯讀的Tables/References/CodeRegistries。- 因此外掛的動作是一項普通的暫存編輯:一個
Ctrl+Z步驟,在你推送之前不會有任何內容抵達試算表,且同樣經過預檢。 - 暫存的把關機制同樣適用——唯讀來源、正在執行的管線,或以活頁簿為來源的分頁,都會將其封鎖並顯示原因。
- 因此外掛的動作是一項普通的暫存編輯:一個
- 判斷式會持續執行。
AppliesTo、面板建構與徽章提供,會在每一次手勢與每一次重新計算的時刻執行。請只讀取交給你的快照;不要進行 IO、網路存取,或長時間運算。 - 顯示出來不代表會被執行。 主機會在呼叫時重新檢查該判斷式。如果狀況自選單繪製以來已經改變,答案會是一次誠實的無動作加上重繪,而不是第二次失敗。瀏覽器對於過時的 id 也採用相同做法。
ConfirmKey會先詢問。 為一個動作附上一個確認鍵值,主機就會在執行它之前顯示那句話——這對於一次會暫存大量儲存格的動作而言,正是恰當的做法。Link節點只會開啟http/https。 這條規則是由兩個主機共同詢問的單一 Core 判斷式(StudioUiNode.IsAllowedUrl),因此它們對於什麼是安全可開啟的,絕不會產生分歧;瀏覽器接著會在繪製錨點之前,再次檢查相同的形狀——這只會讓限制更嚴格,絕不會放寬。- 該網址會完全依照你所寫的內容儲存,並在開啟端附上原因後拒絕,而不是在註冊當下就被清理——撰寫該網址的套件,應該能夠得知為什麼什麼都沒發生。
- 面板不持有任何狀態。 它們會在每個時刻重建;唯一適合存放數值的地方是試算表(暫存狀態)。如果沒有任何東西註冊面板,該窗格就完全不會被繪製。
- 例外會被隔離——一次拋出會變成一則英文的 Console 警告,並移除那一項功能,而不是整個視窗。
當描述不夠用時——IStudioPanelProvider(Editor 組件)
任意繪製、複合輸入,以及多步驟流程,在這裡沒有對應的詞彙,而發明一套詞彙,就意味著要永遠維護一個迷你版的 UI 框架。因此這個上限是刻意設下的,而逃生艙口則相當寬廣:在你的編輯器附屬組件中實作 IStudioPanelProvider,想畫什麼就畫什麼。
using SheetForge.Editor.Studio;
using UnityEngine.UIElements;
public sealed class ExampleStudioPanel : IStudioPanelProvider
{
public string Id => "skillsDemo.summary"; // same id as the descriptive panel above
public string TitleKey => ExampleLocStrings.PanelTitleKey;
public bool AppliesTo(StudioSurfaceContext context) => context.Tab == "ExampleSkills";
public VisualElement CreatePanel(StudioSurfaceContext context) => new Label("…anything…");
}以相同的 Id 同時註冊兩者,每個主機都會取用自己能繪製的那一個:編輯器使用豐富版本,瀏覽器使用描述版本。這正是「瀏覽器能走多遠,編輯器裡就走多遠」得以成立、而不需要第二套合約的原因。
沒有僅限網頁的變體——缺少豐富面板,代表描述版本會被繪製,而不是面板就此消失。這個元件只存活一個重新計算的時刻,因此它同樣不持有任何狀態。
4.17 觀察管線(選用)
一座跨產品的橋接、一項領域遙測,或一個後續的產生器,經常需要在不重新剖析的情況下得知一次匯入產生了什麼。請實作 IPipelineObserver,並透過 ISheetForgePipelinePlugin 註冊它:
using SheetForge.Core.Model;
using SheetForge.Core.Plugins;
public sealed class ExampleImportObserver : IPipelineObserver, ISheetForgePipelinePlugin
{
public void RegisterPipelineObservers(PipelineObserverRegistry observers) => observers.Register(this);
public void OnImportCompleted(PipelineRunView view)
{
// view = Success · Tables · Diagnostics · SkippedTabs · EnumTabs — an immutable snapshot.
if (!view.Success) return;
// … cache what you need; do not hold the tables ...
}
}- 觀察無法改變結果。 你收到的是一個不可變的快照,且不需要回傳任何內容。這裡刻意沒有提供任何可以修改數值或新增診斷的掛勾:解讀一個數值屬於儲存格型別的職責(§2),回報一項規則違反則屬於領域驗證器的職責(§3)。若在觀察合約中混入參與能力,就會讓「觀察者無法改變結果」這句話在實務上變成謊言。
- 每一次明確的匯入循環只會執行一次,在它結束時執行,無論成功或失敗皆然。它不會在你暫存期間持續重新計算的預檢投影上執行——沒有任何第三方程式碼,會依附在按鍵頻率上。
- 一次失敗的執行,依然會回報它剖析出的內容。
Tables帶有在驗證失敗之前已剖析完成的分頁,這與隔離流程所使用的是同一份材料(Data Studio),因此一個觀察者所看到的,是一次失敗執行的真實樣貌,而不是什麼都沒有。 - 兩個誠實的缺口。 觀察者是從匯入循環自身的完成點觸發,因此一次從未抵達該處的執行,完全不會觸發。
- 一次在管線執行之前就中止的匯入(沒有使用中設定、Addressables 關卡拒絕)。
- 程式碼產生 → 編譯這一段,被一次編譯錯誤中斷。
- 這是一次零觸發,而非一次錯誤觸發:如果你需要的是「曾經嘗試過一次匯入」,請將這個合約與編輯器端的
ImportEvents匯流排搭配使用。
- 一次拋出,只會被隔離在該觀察者身上,原因會被收集起來;匯入的輸出結果不會有絲毫改變。
- 未來的觀察點(剖析完成後、一次匯出循環)將會以同層能力介面的形式抵達,透過轉型已註冊的觀察者來發現,因此新增一個不會破壞今天寫成的實作。
5. 自訂匯入來源(ISheetSourceProvider)
一個新的來源(資料庫、REST 端點、自有格式)能夠以零 Core/Editor 修改的方式加入。請在一個 Editor 組件中實作 ISheetSourceProvider。SourceProviderRegistry 會透過 TypeCache 偵測到它,並讓它與內建來源一同顯示在設定的「Source」下拉選單中。
提供者需要回答的四件事:
- 擷取——
CreateTabSource(settings)會回傳一個ITabSource,提供分頁名稱 → 原始 TSV 文字的對應(非同步;環境問題屬於診斷資訊,而非例外;允許部分輸出)。 - 寫回——
CreateReflectTarget(dispatcher, settings)會回傳一個能夠接入編寫調度器的ISourceReflectTarget(請使用調度器公開的Session/Callbacks/Baselines來組裝你的目標)。只有在你的來源可被寫入時,才回傳目標物件。 - 可見性——
GetVisibility(settings)會回傳檢閱器應該為你顯示哪些設定欄位。 CanAuthor——唯讀來源請回傳false;編寫視窗會停用其編輯 UI(與 Google ExportUrl 相同)。
穩定的字串 Id 會持久化保存在 sourceProviderId 中。內建來源使用 "LocalFile" / "GoogleSheet" 作為其 Id;空白的 sourceProviderId 會解析為內建的 LocalFile 預設值。空白的 Id 會讓該提供者不出現在 UI 中(適合用於測試探針)。
提供者刻意存在於 Editor 組件中——來源是 IO 的邊界,將 IO 排除在 Core 之外能維持其純粹性(其他三種合約皆為純粹的 Core)。
5.5 供自動化與整合使用的公開工具
除了註冊合約之外,還有五個公開進入點,是給驅動 SheetForge、而非擴充 SheetForge 的程式碼使用的——例如一個 CI 指令碼、一個建置掛勾、你自己的檢閱器按鈕,或是另一個從相同試算表烘焙自己資源的第二產品。
執行一次週期——SheetForge.Editor.Pipeline.SheetForgeActions:
SheetForgeActions.RunImport(); // exactly what the toolbar's "Pull from source" does
SheetForgeActions.RunExport();
SheetForgeActions.RunPush();
SheetForgeActions.RunHealthCheck();每一次呼叫都是整個週期:設定解析、Addressables 把關、互斥鎖定、確認與核准對話框、進度條,以及橫跨網域重新載入的程式碼產生→編譯→烘焙接續流程。沒有半套週期可以組裝,因此也就不存在可以不慎跳過的把關機制。
有兩件事需要了解:
RunImport與RunPush皆為射後不理——其方法本體為async void,因為編輯器主執行緒不能因網路 IO 而被阻塞。因此回傳並不代表完成;請訂閱ImportEvents.ImportCompleted以得知完成時機。- 推送依然會顯示核准對話框,因此一個無人看管的指令碼無法在沒有人參與的情況下傳送內容。
取得與內建路徑相同的鎖——供自訂來源提供者寫入自己的後端使用:
if (!SheetForgeActions.TryBeginExclusiveScope(out IDisposable scope)) return; // something is running
using (scope) { /* write to your source */ } // Dispose releases; a second Dispose is harmless
// schedule any re-import AFTER the scope closes — the lock is not re-entrantSheetForgeActions.IsBusy 能在不佔用任何資源的情況下回答相同的問題。這把鎖本身刻意維持為 internal:如果它是公開的,呼叫它的 End() 就有可能釋放別人的執行;而這個範圍物件的設計,讓這種情況不可能發生,因為只有持有者才能釋放它。
以與內建路徑相同的方式完成一次寫回——AuthoringDispatcher.FinalizeReflectSuccess(writtenTabs) 會執行一個 ISourceReflectTarget 必須抵達的終點:
- 為它寫入的分頁進行保留狀態的清理,
ClearUndo確認邊界,- 以及自動重新匯入。
內建的本機與 Google 路徑執行的正是同一段方法本體,因此你的提供者會以完全相同的方式收尾,而不只是概略近似。旁邊的 BuildProjectedTabs() 會將該投影結果以逐分頁 TSV 的形式交給你——也就是你即將傳送的內容——讓提供者能夠在不寫入的情況下加以預覽或轉換。空白的 writtenTabs 清單為無操作,暫存內容維持不變。
在你自己的 UI 中顯示產品自己的句子——ImportReportText.Render(report)(Core.Tooling)會回傳一份人類可讀的報告字串,不會寫入任何 Console 內容;SheetForgeActions.RenderReportText(report) 則是同一件事,但會以使用者目前的編輯器語言呈現。請搭配 AuthoringDispatchCallbacks.RenderReport 使用,讓第二個編寫介面,能以與產品完全相同的措辭回報失敗訊息。
在不知道其產生型別的情況下列舉一個已烘焙的分頁——DefinitionDatabase.RecordsUntyped:
foreach (DefinitionDatabase db in myBakedDatabases)
foreach (object record in db.RecordsUntyped) // reflect on the fields you care about
;這是第二個烘焙端(另一個將相同試算表轉換成自己資源的產品)官方認可的做法。請不要反射私有的 records 欄位:這麼做會讓一個欄位名稱變成一份未宣告的合約,一旦程式碼產生器在某天重新命名它,就會靜默失效。此清單為唯讀(試算表才是權威來源)。對於此成員存在之前所產生的程式碼,它預設為空清單;重新匯入一次即可補上覆寫值。
安全地擴充產生的類別——兩個產生的類別皆為 partial,因此一個衍生成員(一個計算屬性、一個介面實作、一個運算子)可以存放在你自己的檔案中、緊鄰產生的檔案,並且在每次重新匯入後依然存續。請勿在其中新增任何序列化欄位:烘焙後的 ScriptableObject 每次匯入都會依照試算表重新建構,因此任何只存在於你那一部分中的欄位,都會被還原為其預設值——如果一個值屬於資料的一部分,它就應該存放在某個欄位中。
刻意維持封閉的部分
以上這些介面,是官方認可的最外層邊界。以下內容無論開放起來看似多麼方便,都會維持為 internal,因為它們每一個都是信任或完整性的邊界,而非單純的便利性邊界:
- 憑證與簽署——服務帳戶金鑰定位器、JWT/PEM/PKCS8 基本元件,以及 Google access-token 提供者。將它們公開,等於把一個範圍限定在你試算表上的持有者權杖交給任何外掛。
- 原始推送鏈路(推送執行器、試算表閘道、儲存格寫入)——核准機制(
IPushApprover)是在該編排流程內部強制執行的;一個公開的原始寫入器,將會是一次沒有核准步驟的試算表寫入。 - 傳送前驗證與反映寫入引擎——外部程式碼只能透過
AuthoringDispatcher.Reflect()進入,它會沿途通過過時錨點檢查、預檢驗證與核准;其底層的寫入引擎並非一項合約。 - 烘焙/程式碼產生完整性鏈路(結構描述指紋、產生原始碼寫入器、孤兒清理)與建置新鮮度掛勾——將它們開放,會讓偽造或繞過烘焙狀態變成一行程式碼就能做到的事。
- 暫時性 SO 覆蓋層——「試算表才是唯一真實來源」只有一項官方認可的例外(檢閱器的測試編輯切換開關),且它刻意不以 API 的形式提供。
如果某個工作流程看似需要以上任何一項,它需要的是一項功能請求,而不是反射存取。
6. 產生程式碼的位置與命名空間
generatedCodeFolder可以是任何資料夾(自我修復的附屬 asmdef 會自動串接外掛型別的參照),但將它放在你自己的套件內(例如Assets/MyDomain/Runtime/Generated)最為整潔。產生的型別就會與你的 enum/自訂型別在同一個組件中編譯,不需要附屬的 asmdef。- 每個分頁的歸屬位置:若某個分頁產生的型別已存在於某處,就會原地重新產生——即使設定指向別處,你套件中已提交的
Generated資料夾仍會保持其權威地位。過時的重複檔案會被自動清除(會記錄紀錄,絕不會靜默進行)。 generatedNamespace能隔離你產生的型別(例如MyGame.Data)。型別的偵測是依據產生型別本身內建的SchemaFingerprint標記,而非命名空間,因此任何命名空間都能運作。變更此值會自動觸發重新產生。- 是否要提交你套件中的
Generated資料夾,取決於你套件自身的政策。範例會提交它自己的(位於預設命名空間SheetForge.Generated中的Example*類別,分頁為ExampleSkills/ExampleEffects/ExampleActions),讓全新複製的專案能立即編譯。Example*這個類別名稱前綴——而非獨立的命名空間——則是讓它們不會與你專案中真正的Skills/Effects分頁發生衝突的原因。
7. 在執行期使用——「組裝,而非寫腳本」
你的執行期程式會讀取產生的資料庫,並依據 type 這個 enum 分派到程式碼中的原子:
using SheetForge.Runtime;
using SheetForge.Generated;
var hSkills = SheetForgeDatabases.LoadAsync<ExampleSkillsDatabase>("ExampleSkills");
var hActions = SheetForgeDatabases.LoadAsync<ExampleActionsDatabase>("ExampleActions");
var hEffects = SheetForgeDatabases.LoadAsync<ExampleEffectsDatabase>("ExampleEffects");
var runner = new SkillRunner(await hSkills.Task, await hActions.Task, await hEffects.Task);
// keep the handles for the system's lifetime; Release each on shutdown對於真正屬於程序性、一次性的邏輯,請透過 AssetRef 參照一個指令碼資源——SheetForge 會驗證該參照並烘焙其 addressable(與參照圖片完全相同);至於執行它,則是遊戲該做的事。
8. 從另一個素材偵測 SheetForge
一個不同的素材——也就是與 SheetForge 整合而非加以擴充的素材(例如一個數值系統)——可以偵測到 SheetForge 是否已安裝。由於付費的 Asset Store 商品是一種資料夾商品(沒有 package.json / UPM),因此無法隨附 versionDefines 項目。SheetForge 的 Editor 組件因而會在每個建置目標上自我註冊一個 SHEETFORGE scripting-define 符號。
(a) 編譯期(建議做法):
- 若你的整合功能位於自己獨立的組件定義(assembly definition)中,將
SHEETFORGE加入該 asmdef 的 Define Constraints——如此一來,該組件只有在 SheetForge 存在時才會編譯。 - 若碰觸 SheetForge 的程式碼與必須無論如何都要編譯的程式碼共用同一個組件,就只需以
#if SHEETFORGE … #endif包住那些部分即可。
(b) Editor 期(替代方案): 當你無法依賴編譯順序時,可透過反射進行探測——例如 System.Type.GetType("SheetForge.Editor.Pipeline.ImportEvents, SheetForge.Editor") != null——然後(舉例來說)動態接上完成事件匯流排。
SHEETFORGE 代表*「SheetForge 已安裝」*。它與 SHEETFORGE_ADDRESSABLES 是分開的——後者是 SheetForge 自身組件上的一個內部版本定義(version-define),僅用於標示 Addressables 套件是否存在——請勿將後者當作安裝偵測手段使用。
若之後移除 SheetForge,這個 define 仍會保留(沒有監看程式會將它取消設定);請在 Project Settings ▸ Player 中手動移除。詳見功能與限制。
哪些仍然需要 Core 修改
以上所有內容都能以零 Core 修改的方式加入。以下是外掛在不修改 Core 的情況下,仍然無法做到的事:
- 將標記值輸出進產生的程式碼中——自訂標記是驗證/顯示用的中繼資料;在有使用端真正需要之前,將它們烘焙成程式碼產生的常數或屬性,並不在範圍之內。
- 讓鍵值重新命名的傳播,在沒有被告知方法的情況下,觸及自訂記法的內部——一筆被重新命名的記錄,Core 會自行改寫
RecordId@Tab儲存格、由它們組成的清單,以及 wrapper 元素。對於你自己的文法,請實作IReferencingCellType(§4.4a),它就會在保留負載內容的情況下被改寫;這是一項選用功能,而不是 Core 的修改。若不選用,這道邊界依然成立:你的領域驗證器會回報那個現已失效的鍵值,而不是讓重新命名靜默地自動修正它。 - 從試算表為一個外掛註冊的 C# enum 新增成員——以
enums.Register<T>()註冊的 enum 歸程式碼所有,因此一份 enum 定義試算表無法擴充它,Data Studio 也不會提供那一列。若該 enum 應該由試算表擁有,請將它移入一份 enum 試算表中(請參閱試算表語法)。
<> wrapper 型別(ICellWrapperType,見 §2)與自訂結構標記(IStructuralMarkerDefinition,見 §4.5),兩者都能在不修改 Core 的情況下擴充此管線。
相關頁面
- 試算表語法——已註冊的型別在試算表中如何呈現
- Data Studio——畫布覆寫、程式碼註冊表、小工具與動作會出現在哪裡
- API 參考——每個合約的完整簽章
- 編寫核心——邊與引擎介面
- 功能與限制——外掛擴充的邊界(wrapper 拒絕規則、標記限制)與保留的介面縫隙