C# / Unity
생성되는 것
<Path>/
<AccessorName>.cs 접근자 — 테이블 프로퍼티, ReadAllAsync, 참조 연결
tabbit/TabbitBinaryReader.cs 바이너리 리더 (함께 생성됩니다)
tabbit/TabbitHelpers.cs 예외 타입과 보조 함수
tabbit/TabbitUnityAdapter.cs 유니티에서만 컴파일되는 읽기 경로
tabbit/TabbitUpdater.cs 데이터 갱신 (WriteUpdater를 켰을 때만)
tables/<Table>Table.cs 테이블당 하나
enums/<Enum>.cs enum당 하나
constants/<Set>.cs 상수 세트당 하나
테이블 파일 하나에 타입 둘이 들어갑니다 — 행 하나를 담는 <Table>Record와 그 행들을
담는 <Table>Table입니다. 둘 다 네임스페이스 바로 아래에 있어 ItemRecord·ItemTable로
부릅니다. 다른 언어와 같은 이름입니다.
필요한 것
| 항목 | 값 |
|---|---|
| C# 언어 버전 | 9.0 이상 |
| .NET | netstandard2.1 이상 (또는 .NET Core 3.0+, .NET 5+) |
| Unity | 6.0 이상 (Unity 6). 그 미만은 지원하지 않습니다 |
| 외부 패키지 | 없음. UniTask도, Newtonsoft도 필요 없습니다 |
유니티에서 설정할 것이 없습니다. 생성된 코드가 유니티 내장 정의(UNITY_5_3_OR_NEWER,
UNITY_WEBGL)로 스스로 판별합니다. 별도의 심볼을 프로젝트에 추가할 필요가 없습니다.
유니티를 아는 파일은 tabbit/TabbitUnityAdapter.cs 하나뿐입니다. 유니티 밖에서는 몸통이
통째로 심볼 뒤에 있어 아무것도 컴파일되지 않고, 유니티 안에서는 첫 씬이 뜨기 전에 스스로
설치됩니다 — Android의 APK 안이나 WebGL의 HTTP처럼 File API로 읽을 수 없는 경로를
UnityWebRequest로 읽는 것이 그 내용입니다. 접근자를 비롯한 나머지 파일은 엔진을 모릅니다.
recipe 설정
"Targets": [
{
"Type": "csharp",
"Path": "Assets/Scripts/Generated",
"Namespace": "MyGame.Data", // 비우면 전역 네임스페이스
"AccessorName": "GameData", // 기본값 Tables. 타입과 파일 이름이 모두 이것입니다
"Output": "source", // "assembly" 로 두면 .dll 하나로 나옵니다
"BinaryTableFileExtension": ".bytes",
"WriteUpdater": false, // CDN에서 데이터를 갱신할 거라면 true
"Sweep": true,
"TargetSide": "c"
}
]
프로젝트에 넣기
생성 폴더를 그대로 프로젝트에 두면 끝입니다. 유니티라면 Assets/ 아래 아무 곳이나 됩니다.
소스 대신 어셈블리로 받기
"Output": "assembly"로 두면 .cs 파일들 대신 .dll 하나가 나옵니다. 체크아웃해서 쓰는
사람에게 생성 소스 100여 개는 모든 diff와 모든 검색에 끼는 노이즈인데, 그것이 없어집니다.
<Path>/
<AssemblyName>.dll 생성 코드 전부 — 접근자·테이블·enum·리더
<AssemblyName>.xml 요약 문서
tabbit/TabbitUnityAdapter.cs 유니티가 직접 컴파일해야 하는 한 장
| 성질 | 내용 |
|---|---|
| 배타입니다 | 소스와 dll 중 하나입니다. 코드를 읽는 것은 IDE의 디컴파일러가 하고, 심볼이 어셈블리 안에 들어 있어 단계 실행도 그대로 되므로 둘 다 둘 이유가 없습니다 |
AssemblyName | 비우면 Namespace, 그것도 비우면 접근자 이름을 씁니다 |
| 유니티 어댑터는 소스로 남습니다 | UnityEngine을 참조하는데 그것은 엔진의 컴파일러만 해석합니다. WriteUpdater를 켰다면 업데이터도 같은 이유로 소스입니다 |
| 컴파일 대상 | netstandard2.1입니다 — 유니티 6과 일반 .NET이 모두 받는 표면입니다 |
| 결정적입니다 | 같은 데이터에서 같은 바이트가 나옵니다. 저장소에 커밋해 두어도 아무것도 바뀌지 않은 실행이 변경으로 보이지 않습니다 |
쓰는 법
정적으로 쓰는 것이 기본입니다. 대부분의 프로젝트는 데이터를 한 벌만 두므로 이걸로 끝입니다.
using MyGame.Data;
await GameData.ReadAllAsync(Application.streamingAssetsPath);
var sword = GameData.Item.FindByIndex(1);
if (sword != null)
{
// 참조는 로드 후 실제 레코드로 연결되어 있습니다.
Debug.Log($"{sword.Name} / {sword.ItemCategoryByCategoryId.Name}");
}
foreach (var row in GameData.Item.Records)
Debug.Log(row.Name);
패키징 과정에서 확장자가 바뀌었다면 두 번째 인자로 넘깁니다.
await GameData.ReadAllAsync(Application.streamingAssetsPath, ".bytes");
데이터를 여러 벌 두기
ReadAllAsync는 두 가지를 이어서 합니다 — 읽어서 연결하고, 그것을 정적 멤버가 보는
자리에 올립니다. 둘은 따로 부를 수도 있습니다.
// 읽고 연결하기만 합니다. 올리지 않으므로 다른 코드가 보던 데이터는 그대로입니다.
GameData.Snapshot next = await GameData.LoadAsync(newPath);
// 준비가 되었을 때 올립니다.
GameData.Publish(next);
| 쓰는 곳 | 어떻게 |
|---|---|
| 테스트가 자기 데이터를 씀 | LoadAsync로 받아 그 인스턴스만 봅니다. 전역 상태를 건드리지 않으므로 병렬로 돌아도 서로 간섭하지 않습니다 |
| 서버가 두 버전을 동시에 엶 | 인스턴스를 둘 들고 각각 조회합니다 |
| 핫 리로드 | 다음 세대를 LoadAsync로 읽는 동안 현재 세대는 계속 읽힙니다. 다 읽은 뒤 Publish |
GameData.Current가 지금 올라가 있는 인스턴스이고, GameData.Item 같은 정적 프로퍼티는 그것을
가리킵니다. 한 번도 읽지 않았으면 Current는 null이라, 로드 전에 테이블을 만지면 그 자리에서
드러납니다.
인스턴스 안에서도 참조 연결은 그 인스턴스 안에서만 일어납니다. 두 벌을 동시에 들고 있어도 한쪽의 행이 다른 쪽의 행을 가리키는 일은 없습니다.
파일을 어디서 읽을지 바꾸기
ReadAllBytesAsync가 교체 가능한 델리게이트입니다. 팩 파일, CDN, Addressables 등에서 읽으려면
ReadAllAsync를 부르기 전에 자기 것을 넣으세요.
GameData.ReadAllBytesAsync = async filename =>
{
var handle = Addressables.LoadAssetAsync<TextAsset>(filename);
var asset = await handle.Task;
return asset.bytes;
};
await GameData.ReadAllAsync("");