플러그인 작성 — Core 수정 없이 도메인 추가하기
도메인(skills, items, quests 등)은 SheetForge.Core를 참조하는 별도의 패키지로서 SheetForge에 합류한다 — Core는 결코 그것을 되돌아 참조하지 않는다. 플러그인은 enum, 커스텀 셀 타입, 래퍼 타입, 도메인 검증기, 그래프 엣지, 구조 마커, "시트 생성" 템플릿, 임포트 소스 전체, 데이터 스튜디오의 캔버스 오버라이드, 코드 레지스트리, 선언적 작성 표면, 위젯과 액션, 컬러 프리셋, 커스텀 셀 위젯, 파이프라인 관찰자, 그리고 자신만의 현지화된 UI 문자열까지 추가할 수 있다 — 아래의 열여섯 가지 계약이다.
"도메인 추가 = Core 코드 변경 없음"은 컴파일러로 강제된다: InternalsVisibleTo가 없는 테스트 어셈블리(SheetForge.Tests.Consumer)가 공개 표면만을 사용해 열여섯 가지 중 열다섯 가지 — 그리고 그 옆의 capability 인터페이스들 — 를 구현하므로, 그중 무엇이라도 internal로 좁혀진다면 빌드가 실패할 것이다(CS0122). 열여섯 번째, 즉 에디터 전용 리치 패널 탈출구는 VisualElement를 반환하므로 대신 에디터 측 테스트로 검증된다.
열한 개의 Core 계약은 순수 C#이다. 이 덕분에 컴파일된 플러그인 DLL 하나가 Unity 에디터 그리고 브라우저(SheetForge Web) 양쪽에서 동일한 슬롯을 켤 수 있다 — 어셈블리와 격리는 하나의 공유 Core 함수이며, 오직 발견만 호스트별로 다르다(Unity의 TypeCache, 브라우저의 업로드된 어셈블리 스캔).
다섯 개의 Editor 계약은 UIToolkit 엘리먼트를 반환하거나 창 상태를 건드리므로, 에디터에만 존재한다.
열여섯 가지 모두 자동으로 발견된다 — 매개변수 없는 생성자가 요구 사항의 전부이며, 어셈블리 참조도, 등록 호출도, 편집할 매니페스트도 없다:
| 계약 | 등록 대상 | 선택 여부 |
|---|---|---|
ISheetForgePlugin | Enum + 커스텀 셀 타입 파서 | 기본 계약 |
ISheetForgeValidatorPlugin | 도메인 검증 규칙(컬럼 간 / 탭 간) | 선택적 애드온 |
ISheetForgeEdgePlugin | 코어 스캐너가 볼 수 없는 그래프 엣지 선언 | 선택적 애드온 |
ISheetForgeMarkerPlugin | 커스텀 구조 마커(컬럼별 @marker 행) | 선택적 애드온 |
ISheetForgeTemplatePlugin | "시트 생성" 템플릿(탭 + 예제 데이터) | 선택적 애드온 |
ISheetForgeGraphPlugin | 데이터 스튜디오를 위한 탭별 캔버스 오버라이드 | 선택적 애드온 |
ISheetForgeCodeRegistryPlugin | 코드에 존재하는 읽기 전용 키 공간을, 잠긴 가상 탭으로 | 선택적 애드온 |
ISheetForgeThemePlugin | SheetForge 창을 위한 컬러 프리셋(다크와 라이트) | 선택적 애드온 |
ISheetForgeStudioPlugin | 선언적 작성 표면 — 액션, 패널, 컬럼 배지, 셀 에디터 힌트 | 선택적 애드온 |
ISheetForgeStringsPlugin | 언어별 자신의 팩 UI 문자열(제품 테이블보다 먼저 참조되는 오버레이) | 선택적 애드온 |
ISheetForgePipelinePlugin | 파이프라인 관찰자 — 임포트가 만든 것에 대한 읽기 전용 알림 | 선택적 애드온 |
ISheetSourceProvider | 임포트 소스 전체(DB / REST / 자체 시스템) | 독립적(Editor 어셈블리) |
IStudioGraphWidget | 데이터 스튜디오 캔버스 위의 도메인 위젯 | 독립적(Editor 어셈블리) |
IStudioInspectorAction | 데이터 스튜디오 노드 인스펙터의 추가 버튼 | 독립적(Editor 어셈블리) |
IStudioCellEditorProvider | 데이터 스튜디오 그리드의 특정 셀 타입을 위한 커스텀 입력 위젯 | 독립적(Editor 어셈블리) |
IStudioPanelProvider | 스튜디오 안의 임의의 UIToolkit 패널 — 선언적 방식 옆의 탈출구 | 독립적(Editor 어셈블리) |
참조 샘플은 선택적 임포트다. 완전한 예제(
SheetForge.PluginDemo)는Assets/SheetForge/Examples/SheetForgePluginDemo.unitypackage에 Unity 패키지로 제공된다 — 더블클릭하거나, 시작하기 창(Tools ▸ SheetForge ▸ Getting Started, 데모 임포트가 있는 유일한 곳)에서 플러그인 데모 임포트를 누르면Assets/SheetForge.PluginDemo/…아래에 복원된다. 임포트하기 전까지는 프로젝트 안에 아예 존재하지 않는다 — 이 샘플은 오직 그 패키지로만 제공된다 — 그래서 이 패키지의 어셈블리/타입/탭/주소는 사용자의 프로젝트와 결코 충돌하지 않는다. 아래에서 참조하는 경로(Assets/SheetForge.PluginDemo/ModifierCellParser.cs등)는 패키지를 임포트한 이후에 존재한다. (플러그인이 없는 두 번째 샘플 —SheetForge.CoreDemo— 는 코어 내장 타입만으로 파이프라인을 보여준다.)
애드온은 기본 인터페이스를 변경하지 않으면서 확장한다 — 검증이나 엣지가 필요 없는 플러그인은 이들의 존재에 전혀 영향받지 않는다.
일곱 개의 인터페이스는 계약이 아니라 capability다:
- 이들은 스스로 발견되지 않는다.
- 이들은 이미 등록된 무언가에 의해 추가로 구현된다.
- Core는 그 등록된 객체를 캐스팅해서 이들을 찾아낸다.
여섯 개는 등록된 엣지 기여자 또는 캔버스 오버라이드로부터 캐스팅된다 — 발견 규칙과 각각의 내용은 §4.12를 참고하라. 일곱 번째인 IReferencingCellType은 등록된 셀 파서로부터 캐스팅되며, 자신의 표기법에 RecordId@Tab이 받는 것과 동일한 참조 처리를 부여한다 — §4.4a를 참고하라. 이들 중 무엇을 무시하든 아무것도 바뀌지 않는다.
1. 패키지 설정
SheetForge.Core를 참조하는 자체 .asmdef를 가진 폴더를 생성한다(런타임 조회가 필요하다면 SheetForge.Runtime도 추가). 그게 전부다 — Editor의 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진단이다. 이는 호환성 선언이지 서명이 아니다: 무결성은 배포 채널의 몫이다(웹 플러그인 마켓 참고). - 세대 번호는 플러그인 형식 자체가 교체될 때만 움직인다. 순수하게 추가되는 성장 — 새 계약, 레지스트리의 새 멤버 — 은 결코 이를 움직이지 않는다. 기존 플러그인이 재컴파일 없이 계속 실행되기 때문이다.
2. 기본 플러그인: enum + 커스텀 셀 타입
using SheetForge.Core.Model;
using SheetForge.Core.Plugins;
public sealed class SkillsPlugin : ISheetForgePlugin
{
public string Name => "Skills"; // for diagnostics / duplicate-conflict reports
public void RegisterEnums(EnumRegistry enums)
{
// Any Enum<ActionType> / Enum<EffectType> cell in a sheet now resolves,
// and codegen emits the real CLR enum type on the generated field.
enums.Register<ActionType>();
enums.Register<EffectType>();
}
public void RegisterCellParsers(CellParserRegistry parsers)
{
// A custom cell type joins parsing, validation, codegen, bake and
// round-trip by registration alone (open-closed — zero pipeline edits).
parsers.Register(new ModifierCellParser());
}
}커스텀 셀 타입 전체 과정
ICellValueParser(문자열 → 값)를 구현하고, 강타입 베이크 그리고 Export/Push 라운드트립을 완성하려면 ICustomCellType(CLR 타입 + 값 → 정규 문자열)도 구현한다. 샘플의 Modifier 미니 문법(stat:op:value, 예: attack:add:10):
using System;
using SheetForge.Core.Model;
using SheetForge.Core.Unparse;
public sealed class ModifierCellParser : ICellValueParser, ICustomCellType
{
// The @type cell text: a column declares "Modifier" or "List<Modifier>".
public string TypeName => "Modifier";
// ICustomCellType: the CLR value type codegen emits ([Serializable] struct).
public Type ValueType => typeof(Modifier);
public bool TryParse(CellParseContext context, string text, out object value)
{
value = null;
string[] parts = text.Split(':');
if (parts.Length != 3)
{
// Failure = collect a structured error and return false. Never throw.
context.Errors.Add(new ImportError(
ImportErrorCode.CustomTypeParseFailed, context.Coordinate,
text, "'stat:op:value' form (e.g. attack:add:10)", null));
return false;
}
// ... parse the three parts (InvariantCulture; reject NaN/Infinity) ...
value = new Modifier(parts[0].Trim(), /*op*/ default, /*value*/ 0f);
return true;
}
// ICustomCellType: value → canonical cell string (the exact inverse of TryParse).
public bool TryRender(object value, out string text, out string reason)
{
reason = null;
if (!(value is Modifier m)) { text = null; reason = "Not a Modifier."; return false; }
// Use CanonicalValueRenderer.RenderFloat for floats — round-trip-safe on Mono.
text = m.stat + ":" + "add" + ":" + CanonicalValueRenderer.RenderFloat(m.value);
return true;
}
}(op 토큰 검증과 최근접 일치 제안을 포함한 완전한 프로덕션 버전은 Assets/SheetForge.PluginDemo/ModifierCellParser.cs를 참고하라.)
커스텀 타입에 대한 @target은 등록만으로 동작한다: 컬럼을 Modifier@Stats로 선언하면 파서가 context.Type.TargetName("Stats")을 읽는다. 그 대상의 무결성 검사(탭이 존재하는가? id가 해석되는가?)는 도메인 검증기의 몫이다 — RecordId@Tab과 동일한 역할 분담이다. @가 붙었지만 등록되지 않은 타입 이름도 여전히 제안과 함께 에러가 되므로, 오타 안전성이 유지된다.
Core가 이미 소유한 타입 이름. 내장 스칼라 이름 — 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는 두 호스트 모두에서 사용자 타입을 위한 네이티브 에디터를 연다.
래퍼 타입 전체 과정 (MyWrapper<T>)
래퍼는 여러 개의 내부 T 값을 하나의 셀에 담는 제네릭 값 형태다 — Pair<int> = 1~2. 사용자는 오직 외부 문법(구분자, 인자 수)만 소유하면 된다. 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;). - 베이크.
- Export/Push 라운드트립.
- 참조 통과 처리 — 래퍼 안의
RecordId@Tab은 무결성이 검사되고, 키 이름 변경 시 전파되며, 탭 이름 변경 시 재작성된다.
거부 규칙과 ; 구분자 관련 유의 사항은 시트 문법에 문서화되어 있다.
3. 도메인 검증기 (선택)
Core 검증은 네 가지 종류(키, 참조, @overlap, 에셋 키)로 고정되어 있다. 컬럼 간 규칙("type이 Custom이면 script가 필수다")이나 탭 간 규칙(참조된 레코드의 의미를 확인하는 것)에는 ISheetForgeValidatorPlugin을 구현한다:
using SheetForge.Core.Model;
using SheetForge.Core.Plugins;
using SheetForge.Core.Validation;
public sealed class SkillsPlugin : ISheetForgePlugin, ISheetForgeValidatorPlugin
{
// ... Name / RegisterEnums / RegisterCellParsers unchanged ...
public void RegisterValidators(DomainValidatorRegistry validators)
{
validators.Register(new CustomEffectRequiresScriptValidator());
}
}
public sealed class CustomEffectRequiresScriptValidator : IDomainValidator
{
public string Name => "CustomEffectRequiresScript";
public void Validate(DomainValidationContext ctx)
{
if (!ctx.Tables.TryGetValue("ExampleEffects", out var effects)) return;
if (!effects.Schema.TryGetField("script", out var scriptField)) return;
foreach (var rec in effects.Records)
{
if (!(rec["type"].Value is EnumValue ev) || ev.MemberName != "Custom") continue;
var scripts = rec["script"].AsList;
if (scripts != null && scripts.Count == 0)
ctx.Errors.Add(new ImportError(ImportErrorCode.DomainRuleViolation,
new CellCoordinate("ExampleEffects", rec.RowNumber, scriptField.ColumnNumber, "script"),
/* what */ rec["codeName"].Value.ToString(),
/* why */ "A Custom effect must specify a script to run, but 'script' is empty.",
/* how */ "Put a script address in the 'script' column, or change 'type'."));
}
}
}등록된 검증기는 임포트 검증과 작성 사전 검증 양쪽 모두에 자동으로 합류한다. 규칙:
- 위반 사항은
ImportErrorCode.DomainRuleViolation으로ctx.Errors에 보고한다 — 결코 throw하지 않는다(예외를 던지면 격리되어 승격되지만, 다른 검증기는 계속 실행된다). - 네 가지 요소를 모두 채운다 — 어디서(
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 항목(from/to 탭 + 레코드 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 | "알 수 없는 스탯 'atack'. 'attack'을 의도한 것인가요?" — 내장 참조 에러가 사용하는 것과 동일한 문장 형태 | 진단은 정확하지만 조치할 방법이 없다 |
// Piece 1 + 3 together — the validator is where the suggestion belongs, because it is the
// only one of the three that produces a sentence a person reads.
using SheetForge.Core.Model;
using SheetForge.Core.Validation;
public sealed class ModifierStatExistsValidator : IDomainValidator
{
public string Name => "ModifierStatExists";
public void Validate(DomainValidationContext ctx)
{
if (!ctx.KeyIndices.TryGetValue("Stats", out var stats)) return; // no target tab: nothing to check
if (!ctx.Tables.TryGetValue("Effects", out var effects)) return;
if (!effects.Schema.TryGetField("modifier", out var field)) return;
foreach (var rec in effects.Records)
foreach (string statKey in StatKeysIn(rec["modifier"])) // your notation's own split
{
if (stats.Contains(statKey)) continue;
string near = TextSuggestion.FindNearest(statKey, stats.Keys); // piece 3
ctx.Errors.Add(new ImportError(ImportErrorCode.DomainRuleViolation,
new CellCoordinate("Effects", rec.RowNumber, field.ColumnNumber, "modifier"),
/* what */ statKey,
/* why */ "This modifier points at a stat that does not exist in 'Stats'.",
/* how */ near != null
? "Did you mean '" + near + "'? Fix the stat name in the modifier value."
: "Add that record to 'Stats', or correct the stat name."));
}
}
}표기법을 위한 분리 로직은 하나만 재사용하라 — 파서, 검증기, 엣지 기여자는 키가 어디서 시작해서 어디서 끝나는지에 대해 합의해야 하며, 그 분리 로직을 세 벌의 개별 사본으로 두면 서로 어긋나게 된다. (래퍼 타입(§2)은 이를 공짜로 얻는다: TrySplit이 곧 공유되는 분리 로직이다.)
네 번째 서비스 — 이름 변경 전파 — 는 한 가지가 더 필요하며, 이를 얻는 방법은 두 가지다. 레코드 이름 변경은 Core가 텍스트 안에서 키를 찾을 수 있는 곳에서만 참조하는 셀을 재작성한다. 이는 RecordId@Tab 필드, 그 리스트, 그리고 TrySplit이 키를 하나의 요소로 노출하는 래퍼에 대해서는 가능하다 — 하지만 사용자 문법의 부분 문자열 경계를 스스로 추측할 수는 없다. 그러니 다음 둘 중 하나를 선택하라:
- 어떻게 하는지 알려주기 —
IReferencingCellType(§4.4a)을 구현하면 이 세 조각짜리 레시피 전체가 하나의 옵트인으로 대체되며 네 가지 서비스가 한 번에 복원된다. - 그 경계를 받아들이기 — 이는 적어도 조용한 것보다는 정직하다: 조각 1이 다음 임포트에서 좌표와 제안과 함께 이제 매달린 키를 보고한다.
위의 레시피는 한 가지 경우에는 여전히 옳은 답이다: 컬럼에 @target이 없을 때 — 키가 사는 단일 탭이 없기 때문이다. 번들 샘플이 정확히 그런 경우다 — List<Modifier>는 대상을 명명하지 않으므로 Core는 attack이 어디로 해석되어야 하는지 알 수 없고, ModifierStatEdgeContributor가 그 엣지들을 손으로 연다. 컬럼에 대상을 주면(List<Modifier@Stats>) §4.4a가 대신 맡는다.
4.4a 자신의 표기법에 완전한 참조 등가성 부여하기 (선택)
이미 등록한 파서에 **IReferencingCellType**을 구현하면, MyType@Tab 컬럼은 더 이상 특별한 경우가 아니게 된다: RecordId@Tab과 정확히 동일하게 검증되고, 제안되고, 전파되고, 그려지고, 선택되고, 색인된다.
새로운 등록 채널은 없다. Core는 CellParserRegistry에 이미 있는 파서를 캐스팅한다 — 캔버스 capability가 등록된 엣지 기여자로부터 캐스팅되는 것과 같은 방식이다(§4.12). 이를 구현하지 않는 커스텀 타입은 이전과 정확히 비트 단위로 동일하게 동작한다.
다섯 개의 훅
다섯 개의 훅은 모두 요소 하나를 대상으로 동작한다: 스칼라 컬럼이라면 셀 전체, List<MyType@Tab>이라면 ;로 구분된 요소 하나 — 사용자의 ICellValueParser.TryParse가 받는 것과 동일한 단위다.
| 훅 | 답하는 질문 | 쓰이는 곳 |
|---|---|---|
bool TryGetTokenKey(elementText, out key) | "이 요소는 무엇을 가리키는가?" | 소속 여부 — 이 셀이 이미 그 레코드에 연결되어 있는가 |
string MakeToken(key) | "이 키에 대한 새 링크를 작성하라" | 빈 셀, 또는 리스트에 추가하는 경우. 페이로드를 중립적인 시작점으로 채운다 — 작성 표면은 결코 값을 지어내서는 안 된다. null/빈 값을 반환하면 그 제스처는 거짓으로 꾸며지는 대신 사유와 함께 비활성화된다 |
bool TryRetargetToken(elementText, newKey, out newText) | "이것을 다른 무언가로 향하게 하라" | ▾ 셀에서 다른 레코드를 선택하는 것, 그리고 캔버스에서 와이어를 재조준하는 것. 대상만 바꾼다 — 토큰을 제거했다가 다시 만들면 사람이 입력했던 숫자가 초기화될 것이다 |
bool TryRemoveToken(elementText, key, out newText) | "이것의 연결을 끊어라" | 빈 텍스트를 반환하면 요소가 사라진다(스칼라 셀은 비워지고, 리스트 요소는 제거된다). 비어 있지 않은 값을 반환하면 그만큼은 남는다 |
bool TryRewriteKeys(elementText, renames, out newText) | "이 키들을 모두 치환하라" | 이름 변경 일괄 처리. TryRetargetToken과는 별개다 — 그것은 사람의 단일 지시인 반면 이것은 일괄 처리이기 때문이다 — 그리고 두 개의 참조를 담은 요소는 둘 다 재작성해야 한다 |
텍스트 쪽과 값 쪽
파싱된 값에도 **IRefBearingValue**를 추가하라 — 두 절반은 서로 다른 일을 하며 둘 다 필요하다. 텍스트 쪽은 파싱된 값을 볼 수 없고, 값 쪽은 작성자가 입력한 표기법을 복원할 수 없다:
using System.Collections.Generic;
using SheetForge.Core.Model;
// Text half — on the parser. `stat:op:value`, e.g. attack:add:10
public sealed class ModifierCellParser : ICellValueParser, ICustomCellType, IReferencingCellType
{
public bool TryGetTokenKey(string t, out string key)
{
key = Head(t); // the first segment is the reference
return key.Length != 0;
}
public string MakeToken(string key) => key + ":add:0"; // neutral, ready to edit
public bool TryRetargetToken(string t, string newKey, out string newText)
{
newText = newKey + Rest(t); // the residue is preserved
return Head(t).Length != 0;
}
public bool TryRemoveToken(string t, string key, out string newText)
{
newText = string.Empty; // nothing is left without the key
return Head(t) == key; // not ours → false, never overwrite blindly
}
public bool TryRewriteKeys(string t, IReadOnlyDictionary<string, string> renames, out string newText)
{
newText = t;
if (!renames.TryGetValue(Head(t), out string to)) return false;
newText = to + Rest(t); // attack:add:10 → power:add:10
return true;
}
// … TypeName / TryParse / ValueType / TryRender as in §2
}
// Value half — on the value the parser produces.
public struct Modifier : IRefBearingValue
{
public string stat; public string op; public float value;
IEnumerable<string> IRefBearingValue.ReferencedKeys =>
string.IsNullOrEmpty(stat) ? System.Array.Empty<string>() : new[] { stat };
}인터페이스를 구현해도 필드가 추가되지 않으므로, 베이크된 ScriptableObject와 생성된 코드는 변하지 않는다.
하나의 옵트인으로 얻는 것 — 이들 각각은 재구현이 아니라 Core 자신의 코드 경로다:
- 무결성 + 제안 — 존재하지 않는 키는 좌표와 "혹시 이것을 의도했나요…"와 함께
UnresolvedRecordId로 보고되며, 내장 참조와 컬럼당 제안 예산을 공유한다. - 페이로드가 그대로 보존되는 이름 변경 전파 —
attack을power로 이름 변경하면attack:add:10이power:add:10으로 재작성된다. 연산자와 숫자는 작성자의 것이며, 그대로 살아남는다. - 그래프 — 링크가 좌표를 가진 실제 엣지가 된다: 그려지고, 노드가 포트를 받으며, Used by 목록이 이를 세고, 참조 인덱스가 양방향으로 이를 가진다.
▾피커 — 셀은RecordId@Tab셀이 가진 것과 동일한 검색 가능한 드롭다운을 받으며, 다른 레코드를 선택하면 대상만 교체되고 나머지는 유지된다. 이 등록이 없으면 피커는 값 위에 순수한 키를 덮어쓰는 대신 거부한다.- 고아 탐지와 export되는 드롭다운 규칙 — 유일한 외부 링크가 자신의 표기법 안에 있는 행은 더 이상 연결되지 않은 것으로 취급되지 않으며, 자신의 타입을 가진 스칼라 컬럼은 대상 탭의 키에 대한 데이터 검증 드롭다운을 받는다(소스, 내보내기 및 Push).
가장 단순한 사용법은 별칭(alias) 타입이다. 값이 그냥 키이고 셀 텍스트가 곧 그 키라면:
TryGetTokenKey는 다듬기만 한다.MakeToken은 키를 그대로 반환한다.TryRetargetToken은 새 키를 반환한다.TryRemoveToken은 빈 값을 반환한다.
그 컬럼은 기능적으로 완전히 RecordId@Tab이며, 사용자에게 남은 유일한 몫은 표현이다: @type에 자신만의 이름으로 나타나며, 그 컬럼 하나에만 셀 위젯(§4.13)이나 캔버스 도형(§4.7)을 붙일 수 있다. 별칭에는 별도의 계약이 필요 없다.
두 가지 제약이 있으며, 둘 다 구조적이다:
- 페이로드에
;를 넣을 수 없다. Core는 사용자의 파서나 이 훅들 중 무엇이든 텍스트를 보기 전에 리스트 셀을 요소로 분리하므로, 값 안의 세미콜론은 두 요소로 조각날 것이다. (래퍼 타입도 동일한 이유로 동일한 제약을 갖는다.) @target은 실제 시트 탭을 가리켜야 한다,RecordId@Tab과 정확히 동일하게 — 코드 레지스트리의 가상 탭은UnknownTargetTab으로 거부된다. 이 제약이 있기에 미해결 참조 보고, 최근접 일치 제안, 이름 변경 전파가 Core 자신의 것 그대로, 수정 없이 적용될 수 있다.
다섯 개의 훅 중 무엇도 throw해서는 안 된다: 해석할 수 없는 것에는 false나 null로 답하고, 재작성할 때는 항상 나머지를 보존하라.
정수 키 공간에 대해서도 동작한다. 사용자의 @target이 명명하는 탭이 RecordId가 아니라 IntId로 키를 잡는다면, 사용자의 코드에서는 아무것도 달라지지 않는다 — 훅이 주고받는 키는 그저 텍스트로 쓰인 정수일 뿐이다. 어느 키 공간과 비교할지는 사용자의 타입이 아니라 대상 탭 자신의 아이덴티티가 결정한다.
- 검증, 최근접 일치 제안, 이름 변경 전파, 엣지, 피커, 고아 탐지 모두 동일한 방식으로 작동한다.
- Core가 그곳에서 대신 챙겨주는 사소하지만 유용한 점 하나: 정수는 여러 방식으로 표기될 수 있으므로, 이름 변경은
TryRewriteKeys에 정본 표기와 함께 그 요소에 나타난 그대로의 표기를 함께 건네준다(007과7둘 다12로 매핑된다) — 그래서 사용자 타입 안의 순서형(ordinal) 조회가 앞자리가 채워진(padded) 값을 놓치지 않는다. - 번들 데모에는
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로 나타난다). - 마커는 새로운 데이터 형태가 아니라 컬럼별 메타데이터를 위한 것이다 — 마커는 자신의 셀 검증만 소유할 뿐, 행 전체를 소유하지 않는다. 커스텀 마커 행은 export/라운드트립 시 그대로 보존되며, 모든 구조 편집(추가/삭제/이동/이름 변경)에서 자신의 컬럼과 함께 이동한다.
- 코드젠은 마커 값을 베이크하지 않는다(
@overlap처럼, 이들은 검증/표시용 메타데이터일 뿐이며 스키마 핑거프린트에는 보이지 않는다).
4.6 "시트 생성" 템플릿 (선택)
시트 생성 흐름은 두 가지 내장 템플릿 — 코어 타입만 사용하는 items 시트, 그리고 @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행 골격인 내장 item 예제와는 다르다. 사용자의 도메인 타입이 이미 등록되어 있으므로(플러그인이 로드된 상태이므로), 생성된 시트는 바로 재임포트에 성공한다.
- 표시 문자열은 사용자의 것이다. 플러그인은 자신의 텍스트를 직접 소유한다(예제 패키지는 도메인 어휘 가드 밖에 있다) — Core
Loc키에 제한되지 않는다. - 다중 탭 템플릿은 자신의 탭을 모두 생성하고 한 번만 재임포트하므로, 탭 간 참조가 함께 해석된다. Create 패널은 이런 템플릿에 대해서는 탭 이름 필드를 숨긴다(탭 이름은 템플릿에 의해 고정된다).
- 키, 빈 표시 이름, 탭이 하나도 없는 경우, 빈 탭 TSV는 거부된다(
Register가 예외를 던지며,PluginRegistrationConflict로 나타난다).
4.7 탭별 캔버스 오버라이드 (선택)
데이터 스튜디오의 캔버스는 무엇을 그릴지 스스로 결정한다: 레코드 하나를 열면 — 그것이 종착점이다 — 참조 인덱스를 바깥으로 따라가며 그 레코드가 소비하는 모든 것을 모은 다음, 그 결과를 왼쪽에서 오른쪽으로 배치한다. 이는 플러그인이 전혀 없어도 동작한다.
플러그인이 추가하는 것은 코어가 볼 수 없거나 알 수 없는 것이다:
- 시트 레코드가 아닌 아이덴티티;
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로 나타난다). - 추가할 뿐, 대체하지 않는다. 어떤 레코드가 나타나는지는 클로저의 답이다. (tab, key)가 이미 화면에 있는 가상 노드는 버려진다 — 실제 레코드가 이긴다 — 그래서 오버라이드는 시트에 존재하는 레코드를 지어낼 수 없다. 할 수 있는 것은 시트 행이 전혀 없는 아이덴티티를 데려오는 것이다.
- 이름은 유일한 예외다. 표시 힌트는 아이덴티티가 아니라 표현이므로, 이미 존재하는 레코드에도 실제로 적용되며, 화면에 전혀 없는 레코드의 이름을 붙일 수도 있다 — 연결 피커가 그것들을 읽으므로, 카드의 부제와 피커 행이 같은 것을 말하는 이유다. 빈 이름은 무시되며(이는 "기본값을 사용한다"와 같다), 한 레코드에 대한 첫 번째 이름이 우선한다.
- 엣지는 자신의 노드를 함께 데려온다. 추가 엣지의 한쪽 끝이 화면에 없으면, 링크가 결코 매달리지 않도록 노드로 추가된다. 어느 한쪽 끝의 키가 빈 엣지는 무시된다.
- 셀이 있는 곳과 화살표가 가리키는 곳은 다를 수 있다. 기본적으로
fieldName이 명명한 셀은 출발 레코드에 있다고 가정된다. 대신 도착 쪽에 있다면fieldOnTarget: true를 전달한다 — 발행된 이벤트 와이어는 이벤트 → 레코드로 그려지지만, 텍스트는 레코드 자신의 컬럼에 있다. 그러면 와이어 인스펙터는 아무것도 아닌 것이 아니라 진짜 셀을 가리킨다. - 순환: 코어는 자신이 볼 수 있는 것을 표시하고, 사용자는 자신이 아는 것을 선언한다. 사용자의 추가 엣지가 루프를 닫으면, 캔버스는 그 백 엣지를 알아서 분류하고 점선으로 그린다. 순환이 문제인지 판단하는 것은 도메인 검증기의 몫이다(§3) — 캔버스는 표시 소재일 뿐, 결코 검증이 아니다.
isCyclic은 레이아웃을 건드리지 않고 와이어를 표시상 순환으로 표시한다.cyclicNote는 사용자만 아는 것(예를 들어 감쇠 값)을 담는다 — 라벨은 컬럼 이름으로 유지하고 설명은 note에 넣는다.
- 레이어 힌트는 두 가지 종류다.
SetLayer는 절대적이다 — 컬럼 0이 가장 왼쪽이고, 음수는 더 왼쪽으로 간다.SetLayerRelative는 종착점으로부터 센다(−1은 그 바로 왼쪽 컬럼이다). 이는 대개 고정된 단계가 의미하는 바다: 그러면 체인이 얕든 깊든 그림은 동일하게 읽히며, 단계들이 충돌하지 않도록 종착점 자체를 고정할 필요가 없다.- 상대적 힌트는 어떤 힌트가 이동시키기 전의 종착점 컬럼을 기준으로 해석되므로, 힌트를 추가하는 순서가 결과를 바꿀 수 없다. 결과 위치가 원점보다 왼쪽으로 가면 그림 전체가 오른쪽으로 이동한다.
- 화면에 없는 노드에 대한 힌트는 버려지며, 한 노드에 대한 첫 번째 힌트가 우선한다.
- 실패는 격리된다.
Augment는 try/catch 안에서 실행된다: 예외는 영어 콘솔 경고가 되고 코어의 그림만 남을 뿐, 결코 창이 깨지지 않는다. - 확장은 결코 사용자를 깨뜨리지 않는다. 첫 릴리스 이후 추가된 모든 capability는 후행 인자이거나 새 메서드다 — 더 이른 표면을 대상으로 작성된 오버라이드는 그대로 컴파일되고 동일하게 동작한다.
(완전한 오버라이드는 Assets/SheetForge.PluginDemo/Graphing/ExampleReactiveAugmenter.cs와 ExamplePipelineAugmenter.cs를 참고하라 — 레코드 주위에 이벤트 노드와 코드 블록을 덧붙이는 reaction, 그리고 고정된 단계가 자신의 컬럼에 고정되는 cast.)
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),
}));
}
}- 세 가지 소비 지점:
- 데이터 스튜디오 사이드바는 가상 탭을 읽기 전용 아래 key/label/raises 그리드로 보여준다;
- 캔버스 오버라이드는
context.CodeRegistries를 통해 항목을 조회할 수 있다; - 그리고 노드 인스펙터는 항목의
Raises를 나열한다.
- 이 키들은 스튜디오의 존재 여부 검사에 합류한다. 대상이 등록된 키인 엣지 — 보통
IEdgeContributor(§4)가 선언했거나 사용자의 shape가 만든 것 — 는 깨진 참조로 칠해지지 않는다. - 임포트 검증기는 가상 탭을 모른다. 코드 레지스트리는 작성 표면의 개념이므로, 시트 컬럼을
RecordId@_Refs로 타이핑하지 마라(임포트가UnknownTargetTab을 보고할 것이다). 데모가 하는 방식 —type컬럼과 엣지 기여자/shape 조회 — 으로 시트 데이터를 코드 원자에 연결하라. - 실제 시트와 충돌할 수 없는 이름을 선택하라(데모는
_를 접두사로 사용한다). 충돌이 발생하면, 스튜디오는 어느 한쪽을 조용히 숨기는 대신 사이드바에 그 충돌을 배지로 표시한다. - 거부되는 경우:
nullsource, 빈 탭 이름, 또는 중복된 탭 이름은 예외를 던진다(PluginRegistrationConflict로 나타남).null인Raises목록은 빈 목록으로 정규화된다. Core는 key / label / raises를 불투명한 문자열로 취급한다 — 결코 이를 해석하지 않는다.
(Assets/SheetForge.PluginDemo/Graphing/ExampleCodeAtoms.cs를 참고하라.)
4.9 데이터 스튜디오 그래프 위젯 (선택, 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가 throw하면 영어 콘솔 경고가 발생하며, 그래프는 계속 그려진다.
(Assets/SheetForge.PluginDemo/Demo/Editor/ExampleStageStripWidget.cs를 참고하라.)
4.10 데이터 스튜디오 인스펙터 액션 (선택, 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), … })는 전체 목록을 하나의 Undo 단계로, 전부 아니면 전무로 스테이징한다(하나의 쓰기가 적용될 수 없으면 아무것도 적용되지 않는다).StageCell을 여러 번 호출하면 Ctrl+Z가 그 횟수만큼의 단계로 쪼개진다 — 그리고 병렬 컬럼의 경우 이는 undo 도중에 절반만 유효한 상태가 나타난다는 뜻이다. null이거나 빈 목록은 아무 일도 하지 않는다. - 정본 텍스트를 전달하라. 스테이징된 텍스트는 반영 시점에 임포터가 사용하는 것과 동일한 파서로 파싱된다 — 그러니 시트가 담을 내용을 써라.
- baseline에 없는 키는 아무 일도 하지 않는다(완전히 새롭거나 해석되지 않은 레코드): 조용히 기록되는 것은 아무것도 없다.
- 서비스:
FocusCell은 그리드를 특정 좌표로 스크롤하고,RequestRebuild는 무언가를 스테이징한 뒤 새로고침을 요청한다. - 발견, 라벨, 격리는 위젯과 정확히 동일하게 동작한다:
TypeCache발견, 등록되지 않은LabelKey에 대한 그대로 표시하는 폴백(빈 키는 타입 이름으로 대체된다), 그리고AppliesTo/Execute를 감싸는 try/catch.
(Assets/SheetForge.PluginDemo/Demo/Editor/ExampleInspectorAction.cs를 참고하라. 그 Editor 어셈블리 — SheetForge.PluginDemo.Demo.Editor — 는 SheetForge.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 옆에 나타난다 — 오직 사용자의 선택만 적용된다. 표시 문자열은 사용자의 것이다(CoreLoc키 불필요). - 빈 id, 중복, 그리고 예약된 내장 id(
default,highContrast)는 거부된다(Register가 예외를 던지며,PluginRegistrationConflict로 나타난다). - 테마가 재스타일링할 수 없는 것: 이 창들 안에 그려지는 네이티브 Unity 위젯(버튼 크롬, 필드 테두리)은 계속 에디터 스킨을 따른다 — 기능 및 제한 참고.
4.12 그래프 캔버스에서 편집하기 (선택)
데이터 스튜디오의 그래프는 그림이 아니라 작성 표면이다: 우클릭으로 레코드를 생성하고, 연결하고, 와이어 연결을 끊는다(데이터 스튜디오 참고). 이 모든 것은 평범한 RecordId@Tab 컬럼에 대해 플러그인 없는 프로젝트에서도 동작한다. 아래의 capability들은 코어가 닿을 수 없는 곳까지 이를 확장한다 — 그중 무엇도 기존 계약을 바꾸지 않으므로, 이들을 무시하는 플러그인도 변경 없이 컴파일된다.
capability는 어떻게 발견되는가 (먼저 읽을 것)
capability는 결코 스스로 발견되지 않는다. 창은 이미 등록된 객체를 캐스팅해서 이들 각각을 찾아낸다:
| Capability | 캐스팅되는 대상 | 추가하는 것 |
|---|---|---|
IAuthorableGraphShape | ISheetForgeGraphPlugin이 등록한 캔버스 오버라이드 | 새 레코드를 어디에 만들 수 있는지 |
IAuthorableEdgeContributor | ISheetForgeEdgePlugin이 등록한 엣지 기여자 | 제스처 하나를 셀 쓰기 하나로 |
IBatchAuthorableEdgeContributor | 동일한 엣지 기여자 | 제스처 하나를 셀 쓰기 여러 개로 |
IVirtualNodeFactory | 동일한 엣지 기여자 | 노드 메뉴에 "하나 더 만들기" 제공 |
IEdgeSlotDeclarer | 동일한 엣지 기여자 | 스키마가 유도할 수 없는 연결 슬롯 선언 |
IEdgeTokenEditor | 동일한 엣지 기여자 | 토큰을 서술하고 키가 아닌 부분을 편집 |
그러므로 엣지 쪽 다섯 개의 capability는 그 클래스가 IEdgeContributor로 등록되었을 때만(ISheetForgeEdgePlugin을 통해, §4) 도달된다. 사용자의 도메인이 자신만의 엣지를 전혀 열지 않더라도, 그것이 등록을 건너뛸 이유는 아니다 — ContributeEdges를 빈 메서드로 구현하고 어쨌든 등록하라. 그 빈 기여자가 공식적으로 지원되는 합류 방법이다:
public sealed class ExampleSlotPlugin : ISheetForgeEdgePlugin
{
public void RegisterEdgeContributors(EdgeContributorRegistry contributors)
=> contributors.Register(new ExampleSlotContributor());
}
public sealed class ExampleSlotContributor : IEdgeContributor, IEdgeSlotDeclarer
{
public string Name => "ExampleSlots";
// Nothing to declare — this class is here for the capabilities below.
public void ContributeEdges(EdgeContributionContext context, ICollection<EdgeSpec> edges) { }
public IReadOnlyList<DeclaredSlot> DeclareSlots(EdgeAuthoringContext context,
string nodeTab, string nodeRecordId) => …;
}이들 모두 try/catch 안에서 실행된다: 예외는 영어 콘솔 경고가 되며 그 하나의 어포던스만 비활성화될 뿐, 다른 무엇도 영향받지 않는다.
새 레코드를 어디에 만들 수 있는가 — IAuthorableGraphShape
두 가지 기본값이 있으며, 이들은 의도적으로 다르다.
- 생성 가능 탭 목록 — 이 capability가 대체하는 축이며, 캔버스가 애초에 열리는지도 결정한다 — 은 포커스 탭의 스키마가 도달할 수 있는 모든 탭을 참조를 전이적으로 따라가며 포함한다. 이는 데이터가 아니라 스키마로부터 계산되므로 아직 행이 없는 시트에서도 성립한다. 두 링크 깊이의 탭에 도달하는 것은 단계적이다 — 중간 레코드를 만들면 그 포트가 나타나고, 다음 홉이 계단식 목록에 합류한다.
- 연결 계단식 목록 — 빈 캔버스에서 실제로 보게 되는 피커 — 은 더 좁다: 현재 화면에 그려진 포트들이 가리키는 탭에서 시작한다.
어느 쪽이든, 코드 레지스트리가 소유한 탭과 키 컬럼이 없는 탭은 제외된다 — 그곳의 새 레코드는 아이덴티티를 가질 수 없기 때문이다.
그 탭에 등록된 오버라이드(§4.7)는 이 인터페이스를 추가해 두 기본값을 모두 대체할 수 있다 — 그리고 화면의 어떤 포트도 받아들이지 않는다고 이름 붙인 탭은 사라지는 대신 사유가 붙은 채로 연결 계단식 목록에 남는다:
using SheetForge.Core.Graphing;
public sealed class ExamplePipelineAugmenter : IRecordCanvasAugmenter, IAuthorableGraphShape
{
// Empty list = no creating from this canvas. The window still applies its own gates
// (read-only source, running pipeline, workbook-backed tab, no key column) on top.
public IReadOnlyList<string> CreatableTabs(GraphBuildContext context, string tabName)
=> new[] { "ExampleEffects", "ExampleActions" };
}자신의 엣지를 편집 가능하게 만들기 — IAuthorableEdgeContributor
IEdgeContributor(§4)로 연 엣지는 그려지지만 편집할 수는 없다 — 그 엣지가 사는 표기법을 아는 것은 오직 사용자뿐이기 때문이다. 이 인터페이스를 추가하면 제스처를 다시 셀 텍스트로 되돌릴 수 있다. 창은 사용자가 반환한 것을 정확히 그대로 스테이징하며, 파서가 최종 판단자로 남는다:
using SheetForge.Core.Edges;
public sealed class ModifierStatEdgeContributor : IEdgeContributor, IAuthorableEdgeContributor
{
public bool TryPlanConnect(EdgeAuthoringContext context, string fromTab, string fromRecordId,
string toTab, string toRecordId, out EdgeCellWrite write)
{
write = default;
if (fromTab != "ExampleEffects" || toTab != "ExampleStats") return false; // not mine
// CellText = the cell as it reads right now (baseline + staging), not the parsed value.
string current = context.CellText(fromTab, fromRecordId, "modifier");
if (current.Contains(toRecordId + ":")) return false; // already linked
string next = current.Length == 0 ? toRecordId + ":add:0"
: current + "; " + toRecordId + ":add:0";
write = new EdgeCellWrite(fromTab, fromRecordId, "modifier", next);
return true;
}
public bool TryPlanDisconnect(EdgeAuthoringContext context, RecordEdge edge, out EdgeCellWrite write)
{
write = default;
if (edge.FieldName != "modifier") return false;
// …remove the fragment naming edge.ToRecordId, hand back the rewritten cell…
write = new EdgeCellWrite(edge.FromTab, edge.FromRecordId, "modifier", rewritten);
return true;
}
}false는 아무 일도 일어나지 않는다는 뜻이다. 스테이징이 생성되지 않으며 메뉴 항목은 정직한 사유와 함께 비활성화된다 — 결코 절반만 적용된 편집은 없다. 말이 안 되는 텍스트로true를 반환하는 것도 허용되지만 무의미하다: 스테이징된 값은 직접 입력한 것과 동일한 사전 검증을 거치며 Problems에 나타난다.- 행이 아니라 키로 주소화한다.
EdgeCellWrite는 (탭, 레코드 id, 필드)를 명명한다. 행 번호는 쓰기 시점에 다시 해석되므로, 스테이징된 계획은 행이 이동해도 살아남는다. - 사용자는 제스처 도중에 호출된다. 두 메서드 모두 try/catch 안에서 실행된다 — 예외는 영어 콘솔 경고가 되며 그 하나의 어포던스만 비활성화될 뿐, 다른 무엇도 영향받지 않는다.
- 시트가 아니라 컨텍스트에 물어보라.
CellText는 스테이징을 포함한 값을 반환하므로, 연달아 만든 두 링크는 서로를 본다. 대신 파싱된 테이블을 읽으면 첫 번째를 놓칠 것이다.
하나의 제스처로 여러 셀 바꾸기 — IBatchAuthorableEdgeContributor
일부 데이터는 하나의 항목을 병렬 컬럼에 나누어 담는다: stepDelays | stepTargets | stepCounts, 각 컬럼의 인덱스 i가 하나의 단계다. 그곳에 링크를 추가하려면 모든 컬럼을 한 번에 늘려야 한다 — 그렇지 않으면 컬럼들의 길이가 서로 달라진다 — 이는 단일 셀 계획으로는 피할 수 없는 절반만 유효한 상태다. 이 capability는 IAuthorableEdgeContributor의 형제다(하위 클래스가 아니다), 그래서 단수 형태만 가진 기여자는 영향받지 않는다:
using SheetForge.Core.Edges;
public sealed class ExampleStepContributor : IEdgeContributor, IBatchAuthorableEdgeContributor
{
public bool TryPlanConnectMany(EdgeAuthoringContext context, string fromTab, string fromRecordId,
string toTab, string toRecordId,
out IReadOnlyList<EdgeCellWrite> writes)
{
writes = new[]
{
new EdgeCellWrite(fromTab, fromRecordId, "stepTargets", Append(context, fromTab, fromRecordId, toRecordId)),
new EdgeCellWrite(fromTab, fromRecordId, "stepDelays", AppendDefault(context, fromTab, fromRecordId)),
};
return true;
}
public bool TryPlanDisconnectMany(EdgeAuthoringContext context, RecordEdge edge,
out IReadOnlyList<EdgeCellWrite> writes) => …;
}- 전부이거나 전무다. 목록 안의 모든 쓰기는 하나의 네이티브 Undo 단계로 스테이징된다. 하나라도 쓸 수 없으면(그런 행이 없거나, 읽기 전용 소스이거나, 파이프라인 실행 중) 아무것도 전혀 스테이징되지 않는다.
- 배치 형태가 우선한다. 한 클래스가 단수 형태와 배치 형태를 둘 다 구현하면, 창은 배치 형태에만 묻는다 — 하나의 제스처가 서로 다른 두 개의 답을 갖는 일은 결코 없다. 기여자는 여전히 등록 순서대로 질의되며, 계획을 세우는 첫 번째 것이 우선한다.
- 모든 쓰기는 주소가 필요하다. 빈 탭이나 필드를 가진 쓰기를 담은 목록(또는 빈 목록)은 "계획 없음"으로 취급된다.
- 연결 해제는 사슬처럼 실행된다. 하나의 카드 위 여러 와이어가 단일 제스처로 잘릴 때, 사용자가 읽는 컨텍스트는 이 제스처 안의 이전 계획들을 이미 담고 있으므로, 같은 셀에서 토큰 두 개를 자르면 둘 다 제거된다. 단수 계약에는 그 중간값을 받을 표면이 없다 — 이 capability가 바로 그 한계를 없애는 방법이다.
- 생성 중인 레코드는 대상이 될 수 없다. "한 제스처로 생성하고 연결하기" 흐름에서, 쓰기 주소는 새 행이 세션에 들어오기 전에 해석되므로, 생성 중인 레코드를 겨냥한 계획은 성립할 수 없고 전체 제스처가 정직하게 실패한다. 이미 존재하는 행을 겨냥하는 것(병렬 컬럼의 경우)은 영향받지 않는다.
무언가를 하나 더 만들기 — IVirtualNodeFactory
"하나 더"가 새로운 행이 아니라 여러 셀 각각에 하나씩 더해지는 요소일 때, 캔버스는 그 제스처를 스스로 지어낼 수 없다. 만들 수 있는 종류를 선언하고, 하나가 선택되면 셀 쓰기를 돌려준다:
using SheetForge.Core.Edges;
public sealed class ExampleStepContributor : IEdgeContributor, IVirtualNodeFactory
{
// Called every time the node menu is built — keep it cheap and side-effect free.
public IReadOnlyList<VirtualNodeKind> KindsFor(EdgeAuthoringContext context, string tab, string recordId)
=> tab == "ExampleSkills"
? new[] { new VirtualNodeKind("step", Loc("Add a step")) } // your own translated string
: null;
public bool TryPlanCreate(EdgeAuthoringContext context, string tab, string recordId,
VirtualNodeKind kind, out IReadOnlyList<EdgeCellWrite> writes)
{
writes = null;
if (kind.Id != "step") return false; // not mine → nothing happens
writes = new[] { … }; // one element appended per column
return true;
}
}- 라벨은 이미 번역되어 있다. Core는 이를 번역하지 않는다 — 사용자의 팩이 해석한 문자열을 제공하라(§4.14 참고). 라벨 안의
/는 서브메뉴를 만드므로, 자신의 항목들을 그룹으로 묶을 수 있다. tab은 가상 탭 이름이거나 비어 있을 수 있다. 사용자의 캔버스 오버라이드가 화면에 놓은 노드는 시트에 살지 않는다 — 메뉴는 사용자가 선언한 것을 여전히 제공한다. 사용자가 쓰는 셀은 노드의 아이덴티티가 아니라 사용자의 계획이 명명하기 때문이다. 코드 레지스트리가 소유한 탭은 제외된다.- 하나의 Undo 단계, 전부 아니면 전무 — 위의 배치 capability와 동일한 규칙이다.
false는 아무것도 전혀 스테이징하지 않는다.
연결 슬롯 선언하기 — IEdgeSlotDeclarer
연결 슬롯은 보통 스키마(RecordId@Tab 컬럼)에서 온다. 사용자의 오버라이드가 화면에 놓은 노드에는 컬럼이 없으며, 기여자 엣지는 링크가 이미 존재해야만 슬롯을 드러낸다 — 그래서 첫 링크는 시작할 곳이 없었다. 대신 슬롯을 선언하라:
using SheetForge.Core.Edges;
public sealed class ExampleStepContributor : IEdgeContributor, IEdgeSlotDeclarer, IBatchAuthorableEdgeContributor
{
// Called per card and per port gate — keep it cheap and side-effect free.
public IReadOnlyList<DeclaredSlot> DeclareSlots(EdgeAuthoringContext context,
string nodeTab, string nodeRecordId)
=> nodeTab == "#step"
? new[] { new DeclaredSlot("target", "ExampleEffects", /*isList*/ false) }
: null;
}- 이름은 두 가지 역할을 한다. 그 노드 안에서 고유해야 하며, 그 안으로 그리는 엣지의
FieldName과 같아야 한다 — 슬롯 조회와 와이어 앵커링 둘 다 그 이름으로 매칭된다. 시트 컬럼이 이미 그 이름을 가지고 있다면, 시트가 이기고 사용자의 선언은 조용히 버려진다. - 선언은 계획이 아니다. 선언된 슬롯은 사용자의 계획(
IAuthorableEdgeContributor또는 배치 형태)을 통해 연결된다. 계획 없이 선언만 하면 포트는 열리지만 아무것도 스테이징되지 않는다 — 둘 다 구현하라. - 포트는 시트 행이 없는 노드에도 열린다. 탭이 시트가 아닌 노드에 대해서는, 창이 그 이름으로 행을 찾지 않는다 — 쓰기 주소는 사용자의 계획에서 나오며 스테이징 시점에 확인된다.
토큰이 말하는 것 편집하기 — IEdgeTokenEditor
연결과 연결 해제는 토큰 전체를 움직인다. 토큰은 종종 키 이상이다: attack:add:10은 스탯 그리고 그 양을 명명한다. 동일한 기여자에 이 capability를 추가하면 와이어 인스펙터는 그 나머지 부분 — 키가 아닌 부분 — 을 위한 행을 하나 얻는다:
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를 반환하면 행이 숨겨지거나 정직하게 비활성화된다 — 스테이징도, 조용한 실패도 없다. - 위젯을 서술하는 것은 사용자의 몫이다. options와 함께
isChoice를 쓰면 팝업이 그려지고, 아니면 텍스트 필드가 그려진다 — 행의 라벨과 옵션 라벨은 사용자의 문자열이다. 나머지가 전혀 없다면new EdgeTokenDescription(tokenText)를 구성하면 행이 그려지지 않는다 — 코어 참조(그 키가 곧 토큰 전체인)는 코드 없이도 이렇게 동작한다.
자신의 와이어를 조금이라도 편집 가능하게 만들기
와이어는 자신이 기록되는 셀을 명명할 때만 편집할 수 있다. 코어는 자신이 직접 읽는 참조에 대해서는 이를 채워 넣는다. 사용자가 추가한 추가 엣지(§4.7)는 필드를 명명해서 이를 한다:
// Display-only edge — the canvas honestly reports it cannot be edited.
builder.AddEdge(tab, recordId, targetTab, targetKey, "raises");
// Edge that names its cell: "this link is written in (tab, record, column)".
builder.AddEdge(tab, recordId, targetTab, targetKey, "listen", /*fieldName*/ "listen");셀을 명명한다고 편집 가능하다고 약속하는 것은 아니다 — 링크가 어디에 사는지를 말할 뿐이다. 사용자가 추가한 엣지는 기여자 엣지가 쓰는 것과 동일한 배관으로 넘겨지므로, IAuthorableEdgeContributor가 이를 주장할 때 정확히 그때 편집 가능해진다. 그 컬럼이 재작성할 사람이 없는 평범한 텍스트나 enum 컬럼이라면, 캔버스는 와이어가 여기서는 편집 가능하지 않다고 보고한다 — 조용한 무동작이 아니라 진실을 말하는 것이다.
4.13 커스텀 셀 위젯 (선택, Editor 어셈블리)
그리드는 모든 셀을 내장 위젯(불리언 토글, enum 팝업, 참조 피커, 원문 텍스트)으로 그린다. 어떤 타입이 더 나은 입력을 받을 자격이 있을 때 — 커브, 색상, 미니 문법 조합기, 여러 줄 박스 — 값이 어떻게 파싱되는지는 건드리지 않고 그 타입 이름에 대한 위젯만 교체한다:
using SheetForge.Editor.Studio;
using UnityEngine.UIElements;
public sealed class ModifierCellEditor : IStudioCellEditorProvider
{
// The base type name from @type (a CellParserRegistry name; for a wrapper, the wrapper name).
public string TypeName => "Modifier";
public VisualElement CreateEditor(StudioCellEditorContext context)
{
if (context.Type.IsList) return null; // decline — the built-in widget takes this cell.
var field = new TextField { value = context.CurrentRawText };
// Typing burst: coalesced into ONE Undo step for this cell.
field.RegisterValueChangedCallback(e => context.CommitTyping(e.newValue));
// Discrete confirmation (focus out): its own Undo step.
field.RegisterCallback<FocusOutEvent>(_ => context.Commit(field.value));
return field;
}
}- 위젯은 입력의 형태를 만들 뿐, 의미는 파서가 소유한다. 무엇을 커밋하든 정본 시트 텍스트다 — 입력한 값과 동일한 사전 검증을 거치며, 문제는 Problems 패널에 나타난다. 위젯은 결코 검증할 필요가 없다.
- 의도적으로 두 개의 커밋 표면이 있다.
Commit(목록에서 선택, 슬라이더 놓기, 포커스 아웃)은 하나의 Undo 단계를 만든다.CommitTyping(키 입력마다)은 연속 입력을 하나의 단계로 합친다. 이 둘을 하나의 호출로 합치면 글자마다 Undo 단계가 흩뿌려지거나, 서로 다른 두 선택이 합쳐질 것이다. null을 반환하면 그 셀을 거부하는 것이며 내장 위젯이 대신 맡는다 — 사용자가 처리하지 않는 형태(사용자 타입의List<T>, 선택적 필드)에 대한 정직한 답이다.context.Type(파싱된@type토큰)이 판단에 필요한 모든 것을 담고 있다.- **
ReferenceKeys(tab)**은 내장 참조 피커가 사용하는 것과 동일한 후보 목록(프로젝션된 키 ∪ 코드 레지스트리 키 ∪ 스테이징된 신규 행 키, 정렬됨)을 건네준다 — 직접 모을 필요가 없다. 사람이 내장 셀이 여는 것과 동일한 드롭다운에서 그 목록을 선택하게 하려면,StudioKeyPicker.Show(screenAnchor, tab, candidates, picked)를 호출하고 반환된 키를 커밋하기 전에 자신의 표기법에 끼워 넣는다. (레코드 생성, 셀을 비워두기, 리스트 다중 토글은 내장 참조 셀 자신의 규칙이며 이 파사드에는 없다 — 셀 텍스트 전체를 소유하는 위젯은 그 결정들도 함께 소유한다.) - 자신의 것뿐 아니라 내장 타입 이름도 주장할 수 있다. 등록된 위젯 분기가 먼저 실행되므로,
TypeName => "float"는 정말로 모든float컬럼의 원문 텍스트 박스를 대체한다 — 슬라이더, 퍼센트 필드, 단위가 붙은 박스가 들어오는 방법이 바로 이것이다. 여기에는 두 가지 주의가 따른다:- 프로젝트 안의 그 타입을 가진 모든 컬럼에 적용되므로,
context.FieldName/context.Tab을 읽어 의도하지 않은 컬럼에는null을 반환해 범위를 제한하라. - 커밋하는 것은 여전히 정본 시트 텍스트이므로, 슬라이더는 파서가 다시 읽어들이는 방식대로 값을 렌더링해야 한다(라운드트립이 기대하는 float 표기는
CanonicalValueRenderer.RenderFloat를 참고하라).
- 프로젝트 안의 그 타입을 가진 모든 컬럼에 적용되므로,
- 충돌은 경고하고, 발견은 자동이다. 다른 모든 계약과 동일한
TypeCache발견이다 — 두 프로바이더가 하나의 타입 이름을 주장하면 먼저 발견된 것이 이기며 콘솔 경고가 둘 다의 이름을 알려준다.CreateEditor가 던진 예외는 잡혀서 경고로 남고, 셀은 내장 위젯으로 대체된다. - 위젯을 작성하기 전에, 힌트로 충분한지 먼저 확인하라. 드롭다운, 여러 줄 박스, 슬라이더, 토글, 색상 피커, 커브 에디터, 그라디언트 에디터가 원하는 전부라면, 대신
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 fetch도 마찬가지다;
- 그리고
.tsv파일에서는 그 문자가 이미 행 구분자였다.
이는 메워야 할 공백이 아니라 형식의 설계 상수다 — 그래서 긴 텍스트를 다루는 도메인은 전적으로 플러그인 영역 안에 있는 세 부분짜리 관례를 통해 이와 함께 동작한다.
1. 이스케이프를 정하고 파서에 작성하라. 관례적인 선택은 시트에 두 글자로 된 리터럴 \n을 쓰고, 들어올 때 언이스케이프하고 나갈 때 다시 이스케이프하는 것이다:
public sealed class ProseCellParser : ICellValueParser, ICustomCellType
{
public string TypeName => "Prose";
public Type ValueType => typeof(string);
public bool TryParse(CellParseContext ctx, string text, out object value)
{
value = text.Replace("\\n", "\n"); // sheet spelling → the value your game sees
return true;
}
public bool TryRender(object value, out string text, out string reason)
{
reason = null;
text = ((string)value).Replace("\r\n", "\n").Replace("\n", "\\n"); // the exact reverse
return true;
}
}두 방향을 정확한 역함수로 만들고 이를 증명하라. TryRender는 Export와 Push가 다시 기록하는 것이므로, 이것이 TryParse를 글자 단위로 되돌리지 못하면 "시트 → 임포트 → export → 시트" 라운드트립이 아무도 편집하지 않은 텍스트를 재작성하게 된다. 나갈 때 \r\n을 \n으로 정규화하는 것(위처럼)이 Windows에서 작성된 값이 연속된 export마다 두 표기법 사이를 오가지 않도록 지켜준다. 파싱된 값을 렌더링해 원본 셀 텍스트와 비교하는 테스트 하나면 이를 고정하기에 충분하다.
2. 셀에 진짜 에디터를 부여하라. \n으로 이스케이프된 값은 한 줄짜리 박스에 타이핑하기 불쾌하다 — 이것이 정확히 §4.13이 존재하는 이유다. "Prose"에 대해 여러 줄 TextField(multiline = true)를 반환하는 IStudioCellEditorProvider를 등록해, 진짜 개행으로 값을 보여주고 다시 이스케이프해서 커밋하라. 키 입력마다가 아니라 포커스 아웃 시 Commit으로 커밋한다(편집 세션당 하나의 undo 단계).
3. 이 관례가 닿지 않는 한 곳을 알아두라. 누군가 Google 시트에서 직접 Alt+Enter를 입력하면 라이브 셀에 진짜 개행이 생기며, 그 셀은 다음 fetch에서 그 위치를 가리키는 좌표와 함께 거부된다. 그 거부는 정직하고 고칠 수 있지만, 어쨌든 거부다 — 그러니 팀의 작가들이 스프레드시트 자체에서 글을 쓴다면, 긴 텍스트는 \n으로 쓴다고 사용자 자신의 문서에 밝혀 두거나, 이스케이프가 대신 처리되는 2단계의 데이터 스튜디오 셀 위젯에서 쓰게 하라.
4.16 선언적 작성 표면 (선택)
§4.9, §4.10, §4.13은 VisualElement를 반환하며, 이것이 정확히 이들이 에디터 전용인 이유다: 브라우저는 UIToolkit 타입을 로드할 수 없으므로, 그런 방식으로 작성된 확장은 한쪽 화면에만 존재하고 다른 쪽에는 존재하지 않는다.
이 계약은 동일한 필요를 데이터로서 답한다. 사용자는 껍데기 — id, 라벨 키, 배치, 톤 — 를 서술하고, *조건(predicate)*과 효과만을 델리게이트로 제공한다. 이렇게 하나 등록하면 에디터의 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에서는 커브 필드를, 브라우저에서는 커브 에디터를 얻는다. - 레이아웃 수치는 어디에도 없다. 픽셀과 비율은 한쪽 화면의 결을 다른 쪽으로 새어 나가게 할 것이다 — 사용자는 무엇을 보여줄지만 말하고, 어떻게 배치할지는 각 렌더러가 결정한다.
작성하기 전에 알아 둘 규칙:
- 변형(mutation)은 사용자의 손이 지나가는 것과 같은 문을 통과한다.
StudioSurfaceContext는 읽기 전용인Tables/References/CodeRegistries위에, 액션에 정확히 네 가지 힘만 준다 —StageCell,StageCells(여러 셀, 하나의 Undo 단계, 전부 아니면 전무),FocusRecord,RequestRebuild.- 그래서 플러그인의 동사(verb)는 평범한 스테이징된 편집이다: 하나의
Ctrl+Z단계이며, push하기 전까지는 아무것도 시트에 닿지 않고, 동일한 사전 검증을 거친다. - 스테이징 게이트도 그대로 적용된다 — 읽기 전용 소스, 실행 중인 파이프라인, 워크북 기반 탭은 표시된 사유와 함께 이를 차단한다.
- 그래서 플러그인의 동사(verb)는 평범한 스테이징된 편집이다: 하나의
- 조건(predicate)은 끊임없이 실행된다.
AppliesTo, 패널 구축, 배지 제공은 모든 제스처와 모든 재계산 틱마다 실행된다. 건네받은 스냅샷을 읽어라 — IO도, 네트워크도, 긴 연산도 없어야 한다. - 표시되었다고 실행되는 것은 아니다. 호스트는 호출 시점에 조건을 다시 확인한다. 메뉴가 그려진 이후 상황이 바뀌었다면, 답은 두 번째 실패가 아니라 정직한 무동작과 다시 그리기다. 브라우저도 오래된 id에 대해 똑같이 동작한다.
ConfirmKey는 먼저 묻는다. 액션에 확인 키를 주면 호스트는 이를 실행하기 전에 그 문장을 보여준다 — 한 번에 여러 셀을 스테이징하는 동사에 알맞은 동작이다.Link노드는http/https만 연다. 이 규칙은 두 호스트 모두가 묻는 하나의 Core 조건(StudioUiNode.IsAllowedUrl)이므로, 무엇이 열기에 안전한지에 대해 서로 다르게 판단할 수 없다. 브라우저는 앵커를 렌더링하기 전에 동일한 형태를 다시 확인하며, 이는 더 거부할 수는 있어도 결코 덜 거부할 수는 없다.- url은 등록 시점에 걸러지는 대신 사용자가 쓴 그대로 저장되며, 여는 시점에 사유와 함께 거부된다 — 이를 작성한 팩이 왜 아무 일도 일어나지 않았는지 알아낼 수 있어야 하기 때문이다.
- 패널은 상태를 갖지 않는다. 매 틱마다 다시 만들어지며, 값이 있어야 할 유일한 곳은 시트다(스테이징됨). 아무것도 패널을 등록하지 않으면 그 패널은 아예 그려지지 않는다.
- 예외는 격리된다 — throw는 영어 콘솔 경고가 되고 그 하나의 어포던스만 사라질 뿐, 창은 사라지지 않는다.
서술만으로 부족할 때 — 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는 검증이 실패하기 전에 파싱된 탭들을 담으며, 이는 격리(quarantine) 흐름이 사용하는 것과 동일한 재료다(데이터 스튜디오) — 그래서 관찰자는 실패한 실행에 대해 아무것도 아닌 것 대신 진실한 그림을 본다. - 정직한 두 가지 공백. 관찰자는 임포트 사이클 자신의 완료 지점에서 발생하므로, 그곳에 결코 도달하지 못하는 실행은 아예 발생하지 않는다.
- 파이프라인이 실행되기 전에 중단된 임포트(활성 설정 없음, Addressables 게이트 거부).
- codegen→컴파일 단계가 컴파일 에러로 중단되는 경우.
- 이는 거짓 발생이 아니라 무발생이다: "임포트가 시도되었다"는 사실이 필요하다면, 이를 에디터 측
ImportEvents버스와 함께 사용하라.
- throw는 그 관찰자에게만 격리되며, 사유가 수집된다. 임포트의 출력은 조금도 바뀌지 않는다.
- 향후의 관찰 지점(파싱 직후, export 사이클)은 등록된 관찰자를 캐스팅해 발견되는 형제 capability 인터페이스로 도착할 것이므로, 새 지점을 추가해도 오늘 작성된 구현이 깨지지는 않는다.
5. 커스텀 임포트 소스 (ISheetSourceProvider)
새로운 소스(데이터베이스, REST 엔드포인트, 자체 형식)는 Core/Editor 수정 없이 합류한다. Editor 어셈블리에 ISheetSourceProvider를 구현한다. SourceProviderRegistry가 TypeCache를 통해 이를 발견하며, 설정의 "Source" 드롭다운에 내장 소스들과 나란히 나타난다. 프로바이더가 답해야 하는 네 가지:
- Fetch —
CreateTabSource(settings)는 탭 이름 → 원본 TSV 텍스트를 제공하는ITabSource를 반환한다(비동기, 환경 문제는 예외가 아니라 진단 정보, 부분 출력 허용). - 쓰기 반영 —
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를 확장하는 대신 구동하는 코드를 위한 다섯 개의 공개 진입점이 있다 — CI 스크립트, 빌드 훅, 자신만의 인스펙터 버튼, 또는 동일한 시트로부터 자신만의 에셋을 베이크하는 두 번째 제품.
한 사이클 실행하기 — SheetForge.Editor.Pipeline.SheetForgeActions:
SheetForgeActions.RunImport(); // exactly what the toolbar's "Pull from source" does
SheetForgeActions.RunExport();
SheetForgeActions.RunPush();
SheetForgeActions.RunHealthCheck();각 호출은 사이클 전체다: 설정 해석, Addressables 게이트, 상호 배제, 확인과 승인 모달, 진행 바, 그리고 도메인 리로드에 걸친 코드젠→컴파일→베이크 재개까지. 조립할 절반짜리 사이클은 없으므로 실수로 건너뛸 게이트도 없다.
알아둘 것 두 가지:
RunImport와RunPush는 던지고 잊는(fire-and-forget) 방식이다 — 에디터 메인 스레드가 네트워크 IO에 블록되어서는 안 되므로 본문이async void다. 그래서 반환은 완료를 의미하지 않는다 — 이를 위해서는ImportEvents.ImportCompleted를 구독하라.- Push는 여전히 자신의 승인 모달을 보여주므로, 지켜보는 사람 없이 스크립트가 전송할 수는 없다.
내장 소스가 잡는 것과 동일한 락을 잡기 — 자신만의 백엔드에 기록하는 커스텀 소스 프로바이더를 위해:
if (!SheetForgeActions.TryBeginExclusiveScope(out IDisposable scope)) return; // something is running
using (scope) { /* write to your source */ } // Dispose releases; a second Dispose is harmless
// schedule any re-import AFTER the scope closes — the lock is not re-entrantSheetForgeActions.IsBusy는 아무것도 잡지 않고 동일한 질문에 답한다. 락 자체는 의도적으로 internal로 남아 있다: 만약 공개되어 있다면, 그 End()를 호출하는 것이 다른 누군가의 실행을 해제할 수 있다 — scope는 이를 불가능하게 만든다. 오직 그것을 쥔 쪽만 해제할 수 있기 때문이다.
내장 소스가 마무리하는 방식 그대로 쓰기 반영을 마무리하기 — AuthoringDispatcher.FinalizeReflectSuccess(writtenTabs)는 ISourceReflectTarget이 도달해야 하는 마무리를 실행한다:
- 기록한 탭에 대한 retain 정리;
ClearUndo확정 경계;- 그리고 자동 재임포트.
내장 로컬과 Google 경로는 동일한 본문을 실행하므로, 사용자의 프로바이더도 이를 근사하는 대신 동일하게 끝난다. 그 옆의 BuildProjectedTabs()는 프로젝션을 탭별 TSV로 건네준다 — 사용자가 막 전송하려는 것 — 그래서 프로바이더는 기록하지 않고도 이를 미리보거나 변환할 수 있다. 빈 writtenTabs 목록은 스테이징을 그대로 유지하는 무동작이다.
사용자 자신의 UI에 제품 자신의 문장을 보여주기 — ImportReportText.Render(report)(Core.Tooling)는 콘솔에 아무것도 기록하지 않은 채 사람이 읽기 쉬운 리포트를 문자열로 반환한다. SheetForgeActions.RenderReportText(report)는 사용자의 현재 에디터 언어로 된 동일한 것이다. AuthoringDispatchCallbacks.RenderReport와 함께 사용하면, 두 번째 작성 표면도 제품이 사용하는 것과 정확히 같은 표현으로 실패를 보고한다.
생성된 타입을 몰라도 베이크된 탭을 열거하기 — DefinitionDatabase.RecordsUntyped:
foreach (DefinitionDatabase db in myBakedDatabases)
foreach (object record in db.RecordsUntyped) // reflect on the fields you care about
;이는 두 번째 베이커(동일한 시트를 자신만의 에셋으로 바꾸는 다른 제품)를 위해 승인된 경로다. private records 필드를 리플렉션하지 마라: 그렇게 하면 필드 이름이 선언되지 않은 계약이 되어, 코드젠이 그 이름을 바꾸는 날 조용히 깨질 것이다. 이 목록은 읽기 전용이다 — 시트가 정본이다. 이 멤버가 존재하기 전에 작성된 생성 코드에서는 기본적으로 비어 있다. 한 번의 재임포트가 그 오버라이드를 내보낸다.
생성된 클래스를 안전하게 확장하기 — 생성된 두 클래스는 모두 partial이므로, 파생 멤버(계산된 프로퍼티, 인터페이스 구현, 연산자)가 그 옆의 자신만의 파일에 살면서 모든 재임포트에서 살아남을 수 있다. 그곳에 직렬화된 필드는 추가하지 마라: 베이크된 ScriptableObject는 매 임포트마다 시트로부터 재구축되므로, 사용자의 부분만 직렬화하는 필드는 기본값으로 돌아온다. 값이 데이터에 속한다면, 그것은 컬럼에 속한다.
의도적으로 닫혀 있는 것
위의 표면들은 승인된 바깥 경계다. 아래의 것들은 공개하는 것이 아무리 편리해 보이더라도 internal로 남는다 — 이들 각각은 편의의 경계가 아니라 신뢰 또는 무결성의 경계이기 때문이다:
- 자격 증명과 서명 — 서비스 계정 키 로케이터, JWT/PEM/PKCS8 프리미티브, Google 액세스 토큰 프로바이더. 이를 공개하면 어떤 플러그인에게든 사용자의 스프레드시트로 범위가 지정된 bearer 토큰을 넘겨주게 될 것이다.
- 원본 push 체인(push 러너, 시트 게이트웨이, 셀 쓰기) — 승인(
IPushApprover)은 그 오케스트레이션 안에서 강제된다. 공개된 원본 라이터는 승인 단계 없는 시트 쓰기가 될 것이다. - 전송 전 검증과 반영 쓰기 엔진 — 외부 코드는 오직
AuthoringDispatcher.Reflect()를 통해서만 들어오며, 이는 도중에 오래된 앵커 검사, 사전 검증, 승인을 통과시킨다. 그 아래의 쓰기 엔진은 계약이 아니다. - 베이크/코드젠 무결성 체인(스키마 핑거프린트, 생성 소스 라이터, 고아 정리)과 빌드 최신성 게이트 — 이를 공개하면 베이크 상태를 위조하거나 우회하는 것이 단 한 줄로 가능해질 것이다.
- 임시 SO 오버레이 — "시트가 진실 공급원이다"에는 정확히 하나의 승인된 예외(인스펙터의 테스트 편집 토글)가 있으며, 이는 의도적으로 API로 제공되지 않는다.
어떤 워크플로우가 이들 중 하나를 필요로 하는 것처럼 보인다면, 그것에 필요한 것은 리플렉션이 아니라 기능 요청이다.
6. 생성 코드 배치와 네임스페이스
generatedCodeFolder는 어떤 폴더든 될 수 있다(동반 asmdef 자가 치유가 플러그인 타입 참조를 자동으로 연결한다). 하지만 자신의 패키지 안에 두는 것이(예:Assets/MyDomain/Runtime/Generated) 가장 깔끔하다 — 그러면 생성된 타입이 동반 asmdef 없이도 사용자의 enum/커스텀 타입과 동일한 어셈블리에서 컴파일된다.- 탭별 거처: 생성 타입이 이미 어딘가에 존재하는 탭은 그 자리에서 재생성된다 — 설정이 다른 곳을 가리키더라도 패키지에 커밋된
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 스크립팅 define 심볼을 자체 등록한다.
(a) 컴파일 타임(권장):
- 연동 코드가 자신만의 어셈블리 정의에 있다면, 그 asmdef의 Define Constraints에
SHEETFORGE를 추가한다 — 그러면 그 어셈블리는 SheetForge가 있을 때만 컴파일된다. - SheetForge를 다루는 코드가 무조건 컴파일되어야 하는 코드와 같은 어셈블리를 공유한다면, 해당 부분만
#if SHEETFORGE … #endif로 가드한다.
(b) 에디터 타임(대안): 컴파일 순서에 의존할 수 없는 경우, 리플렉션으로 프로브한다 — 예: System.Type.GetType("SheetForge.Editor.Pipeline.ImportEvents, SheetForge.Editor") != null — 그런 다음 (예를 들어) 완료 버스를 동적으로 연결한다.
SHEETFORGE는 *"SheetForge가 설치되어 있다"*는 뜻이다. 이는 SHEETFORGE_ADDRESSABLES와는 별개다 — 후자는 SheetForge 자체 어셈블리에 있는 내부 버전 정의로, Addressables 패키지가 있는지만 표시한다. 후자를 설치 감지 용도로 사용하지 말 것.
이 define은 이후 SheetForge가 제거되더라도 남아 있는다(이를 해제할 감시자가 없다). Project Settings ▸ Player에서 수동으로 제거하라. 기능 및 제한 참고.
Core 수정이 여전히 필요한 부분
위의 모든 것은 Core 수정 없이 합류한다. 플러그인이 Core 변경 없이는 여전히 할 수 없는 것:
- 마커 값을 생성 코드로 내보내는 것 — 커스텀 마커는 검증/표시용 메타데이터다. 이를 코드젠 상수나 애트리뷰트로 베이크하는 것은 소비자가 필요로 하기 전까지는 범위 밖이다.
- 어떻게 해야 하는지 알려주지 않아도 키 이름 변경 전파가 커스텀 표기법 안에 도달하게 하는 것 — 이름이 변경된 레코드는 Core 스스로
RecordId@Tab셀, 그 리스트, 래퍼 요소 안에서 재작성된다. 자신만의 문법에 대해서는,IReferencingCellType(§4.4a)을 구현하면 페이로드가 보존된 채로 재작성된다 — 이는 옵트인이지 Core 수정이 아니다. 이 옵트인을 거부하면 그 경계는 그대로 남는다: 이름 변경이 조용히 고쳐주는 대신, 사용자의 도메인 검증기가 매달린 키를 보고한다. - 시트로부터 플러그인이 등록한 C# enum에 멤버를 추가하는 것 —
enums.Register<T>()로 등록된 enum은 코드가 소유하므로, enum 정의 시트는 이를 확장할 수 없으며 데이터 스튜디오는 그 행을 제공하지 않는다. 시트가 소유해야 한다면 enum을 enum 시트로 옮겨라(시트 문법 참고).
<> 래퍼 타입(ICellWrapperType, §2 참고)과 커스텀 구조 마커(IStructuralMarkerDefinition, §4.5 참고) 모두 Core 수정 없이 파이프라인을 확장한다.