diff --git a/src/Game.Client/Assets/Programs/Editor/Database/ScriptableDatabaseBuilder.cs b/src/Game.Client/Assets/Programs/Editor/Database/ScriptableDatabaseBuilder.cs
index 24bd4033..544ca811 100644
--- a/src/Game.Client/Assets/Programs/Editor/Database/ScriptableDatabaseBuilder.cs
+++ b/src/Game.Client/Assets/Programs/Editor/Database/ScriptableDatabaseBuilder.cs
@@ -4,7 +4,6 @@
using System.Linq;
using System.Reflection;
using System.Text;
-using Game.Shared.Scriptable.Database;
using UnityEditor;
using UnityEngine;
@@ -23,13 +22,12 @@ namespace Game.Shared.Scriptable.Database.EditorTools
public static class ScriptableDatabaseBuilder
{
private const string OutDir = "Assets/Programs/Runtime/Shared/Scriptable/Database/Generated";
- private const string DatabaseAssetPath = "Assets/ProjectAssets/Database/ScriptableDatabase.asset";
+ internal const string DatabaseAssetPath = "Assets/ProjectAssets/Scriptable/Database/ScriptableDatabase.asset";
private const string DatabaseClassName = "ScriptableDatabase";
private const string DatabaseNamespace = "Game.Shared.Scriptable.Database";
// ---- コマンド①: コンテナクラス生成 ----
- [MenuItem("Tools/Scriptable Database/Build")]
public static void Build()
{
Directory.CreateDirectory(OutDir);
@@ -75,13 +73,12 @@ private static string TableTypeName(Type recordType)
// ---- コマンド②: テーブル資産の自動登録 ----
- [MenuItem("Tools/Scriptable Database/Register")]
public static void Register()
{
var dbType = FindDatabaseType();
if (dbType == null)
{
- Debug.LogError("[ScriptableDatabaseBuilder] ScriptableDatabase 型が見つかりません。先に 'Tools/Scriptable Database/Build' を実行してください。");
+ Debug.LogError("[ScriptableDatabaseBuilder] ScriptableDatabase 型が見つかりません。先に ScriptableDatabaseWindow の 'Build' を実行してください。");
return;
}
@@ -123,7 +120,7 @@ public static void Register()
Debug.Log($"[ScriptableDatabaseBuilder] Register 完了: 結線 {wired} 件 / 欠落 {missing} 件。");
}
- private static Type FindDatabaseType() =>
+ internal static Type FindDatabaseType() =>
AppDomain.CurrentDomain.GetAssemblies()
.SelectMany(SafeTypes)
.FirstOrDefault(t => t.FullName == $"{DatabaseNamespace}.{DatabaseClassName}");
diff --git a/src/Game.Client/Assets/Programs/Editor/Database/ScriptableDatabaseEditor.cs b/src/Game.Client/Assets/Programs/Editor/Database/ScriptableDatabaseEditor.cs
new file mode 100644
index 00000000..36d02bae
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Editor/Database/ScriptableDatabaseEditor.cs
@@ -0,0 +1,45 @@
+using Game.Shared.Scriptable.Database;
+using UnityEditor;
+using UnityEngine;
+
+namespace Game.Shared.Scriptable.Database.EditorTools
+{
+ ///
+ /// ScriptableDatabase.asset の Inspector に一括 CSV/TSV 入出力ボタンを表示する。
+ /// 実処理は に委譲する(target を ScriptableObject として渡す)。
+ ///
+ [CustomEditor(typeof(ScriptableDatabase))]
+ public class ScriptableDatabaseEditor : UnityEditor.Editor
+ {
+ public override void OnInspectorGUI()
+ {
+ DrawDefaultInspector();
+
+ var database = (ScriptableObject)target;
+
+ EditorGUILayout.Space();
+ EditorGUILayout.LabelField("一括 CSV / TSV", EditorStyles.boldLabel);
+
+ EditorGUILayout.LabelField("Export", EditorStyles.miniBoldLabel);
+ using (new EditorGUILayout.HorizontalScope())
+ {
+ if (GUILayout.Button("Export All (CSV)")) ScriptableDatabaseIO.BatchExport(database, "csv");
+ if (GUILayout.Button("Export All (TSV)")) ScriptableDatabaseIO.BatchExport(database, "tsv");
+ }
+
+ EditorGUILayout.LabelField("Import (Replace)", EditorStyles.miniBoldLabel);
+ using (new EditorGUILayout.HorizontalScope())
+ {
+ if (GUILayout.Button("Import All (CSV)")) ScriptableDatabaseIO.BatchImport(database, "csv", mergeByPrimaryKey: false);
+ if (GUILayout.Button("Import All (TSV)")) ScriptableDatabaseIO.BatchImport(database, "tsv", mergeByPrimaryKey: false);
+ }
+
+ EditorGUILayout.LabelField("Import (Merge by PrimaryKey)", EditorStyles.miniBoldLabel);
+ using (new EditorGUILayout.HorizontalScope())
+ {
+ if (GUILayout.Button("Import All (CSV)")) ScriptableDatabaseIO.BatchImport(database, "csv", mergeByPrimaryKey: true);
+ if (GUILayout.Button("Import All (TSV)")) ScriptableDatabaseIO.BatchImport(database, "tsv", mergeByPrimaryKey: true);
+ }
+ }
+ }
+}
diff --git a/src/Game.Client/Assets/Programs/Editor/Database/ScriptableDatabaseEditor.cs.meta b/src/Game.Client/Assets/Programs/Editor/Database/ScriptableDatabaseEditor.cs.meta
new file mode 100644
index 00000000..9de56083
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Editor/Database/ScriptableDatabaseEditor.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 1b44b17974181454d98781f22af7b9da
\ No newline at end of file
diff --git a/src/Game.Client/Assets/Programs/Editor/Database/ScriptableDatabaseIO.cs b/src/Game.Client/Assets/Programs/Editor/Database/ScriptableDatabaseIO.cs
new file mode 100644
index 00000000..1b8eab70
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Editor/Database/ScriptableDatabaseIO.cs
@@ -0,0 +1,124 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Reflection;
+using Game.Shared.Scriptable.Database;
+using UnityEditor;
+using UnityEngine;
+
+namespace Game.Shared.Scriptable.Database.EditorTools
+{
+ ///
+ /// ScriptableDatabase 配下の全テーブルを一括で CSV/TSV 入出力するエディタ機能。
+ /// テーブル列挙はリフレクション(ScriptableTableBase フィールド)で行い、ScriptableDatabase 型へ
+ /// コンパイル時依存しない(未生成でも壊れないビルダー方針と同じ)。ファイル名規約は {TableType.Name}.{ext}。
+ /// 文字列⇔ファイルの変換は 、副作用(ダイアログ/Undo/保存)はここで担う。
+ ///
+ public static class ScriptableDatabaseIO
+ {
+ // ---- 一括処理本体(ScriptableDatabase 型に依存せず ScriptableObject + リフレクションで扱う) ----
+
+ public static void BatchExport(ScriptableObject database, string extension)
+ {
+ if (database == null) return;
+
+ var dir = EditorUtility.SaveFolderPanel("Export All Tables", ScriptableTableIO.DefaultDirectory(), string.Empty);
+ if (string.IsNullOrEmpty(dir)) return;
+
+ try
+ {
+ int n = 0;
+ foreach (var table in Tables(database))
+ {
+ var path = Path.Combine(dir, table.GetType().Name + "." + extension);
+ ScriptableTableFileIO.ExportToFile(table, path, ScriptableTableIO.Utf8NoBom);
+ n++;
+ }
+ Debug.Log($"[ScriptableDatabaseIO] {n} テーブルを書き出しました({extension.ToUpperInvariant()}): {dir}", database);
+ }
+ catch (Exception e)
+ {
+ Debug.LogError($"[ScriptableDatabaseIO] 一括エクスポートに失敗しました: {e}", database);
+ EditorUtility.DisplayDialog("Export All 失敗", e.Message, "OK");
+ }
+ }
+
+ public static void BatchImport(ScriptableObject database, string extension, bool mergeByPrimaryKey)
+ {
+ if (database == null) return;
+
+ var dir = EditorUtility.OpenFolderPanel("Import All Tables", ScriptableTableIO.DefaultDirectory(), string.Empty);
+ if (string.IsNullOrEmpty(dir)) return;
+
+ try
+ {
+ int imported = 0, skipped = 0;
+ foreach (var table in Tables(database))
+ {
+ var path = Path.Combine(dir, table.GetType().Name + "." + extension);
+ if (!File.Exists(path))
+ {
+ skipped++;
+ Debug.LogWarning($"[ScriptableDatabaseIO] {Path.GetFileName(path)} が見つかりません。スキップします。", table);
+ continue;
+ }
+
+ Undo.RecordObject(table, "Import All Tables");
+ ScriptableTableFileIO.ImportFromFile(table, path, mergeByPrimaryKey);
+ EditorUtility.SetDirty(table);
+ imported++;
+ }
+
+ AssetDatabase.SaveAssets();
+ Debug.Log($"[ScriptableDatabaseIO] 取り込み {imported} 件 / スキップ {skipped} 件" +
+ $"({(mergeByPrimaryKey ? "Merge" : "Replace")}, {extension.ToUpperInvariant()}): {dir}", database);
+ }
+ catch (Exception e)
+ {
+ Debug.LogError($"[ScriptableDatabaseIO] 一括インポートに失敗しました: {e}", database);
+ EditorUtility.DisplayDialog("Import All 失敗", e.Message, "OK");
+ }
+ }
+
+ // database が保持する ScriptableTableBase フィールドを列挙する(null/未結線は警告してスキップ)。
+ private static IEnumerable Tables(ScriptableObject database)
+ {
+ var fields = database.GetType().GetFields(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
+ foreach (var field in fields)
+ {
+ if (!typeof(ScriptableTableBase).IsAssignableFrom(field.FieldType)) continue;
+
+ if (field.GetValue(database) is ScriptableTableBase table)
+ {
+ yield return table;
+ }
+ else
+ {
+ Debug.LogWarning($"[ScriptableDatabaseIO] フィールド '{field.Name}' が未結線です。スキップします。(Register を実行してください)", database);
+ }
+ }
+ }
+
+ // ---- 対象 ScriptableDatabase の解決(ScriptableDatabaseWindow から利用) ----
+
+ // 固定パスの ScriptableDatabase.asset をロードしてアクションを実行する(型はリフレクションで解決)。
+ internal static void RunWithDatabase(Action action)
+ {
+ var dbType = ScriptableDatabaseBuilder.FindDatabaseType();
+ if (dbType == null)
+ {
+ Debug.LogError("[ScriptableDatabaseIO] ScriptableDatabase 型が見つかりません。先に ScriptableDatabaseWindow の 'Build' を実行してください。");
+ return;
+ }
+
+ var database = AssetDatabase.LoadAssetAtPath(ScriptableDatabaseBuilder.DatabaseAssetPath, dbType) as ScriptableObject;
+ if (database == null)
+ {
+ Debug.LogError($"[ScriptableDatabaseIO] {ScriptableDatabaseBuilder.DatabaseAssetPath} が見つかりません。先に ScriptableDatabaseWindow の 'Register' を実行してください。");
+ return;
+ }
+
+ action(database);
+ }
+ }
+}
diff --git a/src/Game.Client/Assets/Programs/Editor/Database/ScriptableDatabaseIO.cs.meta b/src/Game.Client/Assets/Programs/Editor/Database/ScriptableDatabaseIO.cs.meta
new file mode 100644
index 00000000..377e06e3
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Editor/Database/ScriptableDatabaseIO.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: d466730374562af4a856aee68fccc37d
\ No newline at end of file
diff --git a/src/Game.Client/Assets/Programs/Editor/Database/ScriptableDatabaseWindow.cs b/src/Game.Client/Assets/Programs/Editor/Database/ScriptableDatabaseWindow.cs
new file mode 100644
index 00000000..977cf7ef
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Editor/Database/ScriptableDatabaseWindow.cs
@@ -0,0 +1,56 @@
+using UnityEditor;
+using UnityEngine;
+
+namespace Game.Shared.Scriptable.Database.EditorTools
+{
+ ///
+ /// Scriptable Database の生成・登録・一括 CSV/TSV 入出力を集約したエディタウィンドウ。
+ /// 各ボタンは既存の static コマンド(Generator / Builder / IO)を呼ぶだけで、ロジックは持たない。
+ ///
+ public class ScriptableDatabaseWindow : EditorWindow
+ {
+ [MenuItem("Project/Database/ScriptableDatabaseWindow")]
+ public static void Open() => GetWindow("Scriptable Database");
+
+ private void OnGUI()
+ {
+ EditorGUILayout.LabelField("コード生成", EditorStyles.boldLabel);
+ if (GUILayout.Button("Generate(テーブルクラス {Type}Table.g.cs)")) ScriptableTableGenerator.Generate();
+ if (GUILayout.Button("Build(コンテナクラス ScriptableDatabase.g.cs)")) ScriptableDatabaseBuilder.Build();
+
+ EditorGUILayout.Space();
+ EditorGUILayout.LabelField("資産登録", EditorStyles.boldLabel);
+ if (GUILayout.Button("Register(テーブル資産を .asset へ結線)")) ScriptableDatabaseBuilder.Register();
+
+ EditorGUILayout.Space();
+ EditorGUILayout.LabelField("一括 Export", EditorStyles.boldLabel);
+ using (new EditorGUILayout.HorizontalScope())
+ {
+ if (GUILayout.Button("Export All (CSV)"))
+ ScriptableDatabaseIO.RunWithDatabase(db => ScriptableDatabaseIO.BatchExport(db, "csv"));
+ if (GUILayout.Button("Export All (TSV)"))
+ ScriptableDatabaseIO.RunWithDatabase(db => ScriptableDatabaseIO.BatchExport(db, "tsv"));
+ }
+
+ EditorGUILayout.Space();
+ EditorGUILayout.LabelField("一括 Import (Replace)", EditorStyles.boldLabel);
+ using (new EditorGUILayout.HorizontalScope())
+ {
+ if (GUILayout.Button("Import All (CSV)"))
+ ScriptableDatabaseIO.RunWithDatabase(db => ScriptableDatabaseIO.BatchImport(db, "csv", mergeByPrimaryKey: false));
+ if (GUILayout.Button("Import All (TSV)"))
+ ScriptableDatabaseIO.RunWithDatabase(db => ScriptableDatabaseIO.BatchImport(db, "tsv", mergeByPrimaryKey: false));
+ }
+
+ EditorGUILayout.Space();
+ EditorGUILayout.LabelField("一括 Import (Merge by PrimaryKey)", EditorStyles.boldLabel);
+ using (new EditorGUILayout.HorizontalScope())
+ {
+ if (GUILayout.Button("Import All (CSV)"))
+ ScriptableDatabaseIO.RunWithDatabase(db => ScriptableDatabaseIO.BatchImport(db, "csv", mergeByPrimaryKey: true));
+ if (GUILayout.Button("Import All (TSV)"))
+ ScriptableDatabaseIO.RunWithDatabase(db => ScriptableDatabaseIO.BatchImport(db, "tsv", mergeByPrimaryKey: true));
+ }
+ }
+ }
+}
diff --git a/src/Game.Client/Assets/Programs/Editor/Database/ScriptableDatabaseWindow.cs.meta b/src/Game.Client/Assets/Programs/Editor/Database/ScriptableDatabaseWindow.cs.meta
new file mode 100644
index 00000000..c389aed7
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Editor/Database/ScriptableDatabaseWindow.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: bccdce805efb5b44a98aa4090617100e
\ No newline at end of file
diff --git a/src/Game.Client/Assets/Programs/Editor/Database/ScriptableTableEditor.cs b/src/Game.Client/Assets/Programs/Editor/Database/ScriptableTableEditor.cs
index 33fdc07c..851d0838 100644
--- a/src/Game.Client/Assets/Programs/Editor/Database/ScriptableTableEditor.cs
+++ b/src/Game.Client/Assets/Programs/Editor/Database/ScriptableTableEditor.cs
@@ -1,4 +1,3 @@
-using Game.Shared.Scriptable.Database;
using UnityEditor;
using UnityEngine;
@@ -25,6 +24,8 @@ public override void OnInspectorGUI()
}
EditorGUILayout.Space();
+ EditorGUILayout.LabelField("Validation", EditorStyles.boldLabel);
+
if (GUILayout.Button("Sort & Validate"))
{
foreach (var o in targets)
@@ -35,6 +36,43 @@ public override void OnInspectorGUI()
EditorUtility.SetDirty(tb);
}
}
+
+ EditorGUILayout.Space();
+ EditorGUILayout.LabelField("CSV / TSV", EditorStyles.boldLabel);
+
+ // Import は対象アセットが一意に定まる単一選択時のみ。
+ if (targets.Length == 1)
+ {
+ using (new EditorGUILayout.HorizontalScope())
+ {
+ if (GUILayout.Button("Import (Replace)"))
+ ScriptableTableIO.Import(table, mergeByPrimaryKey: false);
+ if (GUILayout.Button("Import (Merge by PrimaryKey)"))
+ ScriptableTableIO.Import(table, mergeByPrimaryKey: true);
+ }
+ }
+ else
+ {
+ EditorGUILayout.HelpBox("Import は単一アセット選択時のみ実行できます。", MessageType.Info);
+ }
+
+ EditorGUILayout.Space();
+
+ // Export は複数選択でも各アセットを個別に書き出す。
+ // SaveFilePanel は単一拡張子しか扱えないため形式ごとにボタンを分ける。
+ using (new EditorGUILayout.HorizontalScope())
+ {
+ if (GUILayout.Button("Export to CSV"))
+ {
+ foreach (var o in targets)
+ ScriptableTableIO.Export((ScriptableTableBase)o, "csv");
+ }
+ if (GUILayout.Button("Export to TSV"))
+ {
+ foreach (var o in targets)
+ ScriptableTableIO.Export((ScriptableTableBase)o, "tsv");
+ }
+ }
}
}
}
diff --git a/src/Game.Client/Assets/Programs/Editor/Database/ScriptableTableGenerator.cs b/src/Game.Client/Assets/Programs/Editor/Database/ScriptableTableGenerator.cs
index 470365bc..f317714b 100644
--- a/src/Game.Client/Assets/Programs/Editor/Database/ScriptableTableGenerator.cs
+++ b/src/Game.Client/Assets/Programs/Editor/Database/ScriptableTableGenerator.cs
@@ -4,7 +4,6 @@
using System.Linq;
using System.Reflection;
using System.Text;
-using Game.Shared.Scriptable.Database;
using UnityEditor;
using UnityEngine;
@@ -18,7 +17,6 @@ public static class ScriptableTableGenerator
{
private const string OutDir = "Assets/Programs/Runtime/Shared/Scriptable/Database/Generated";
- [MenuItem("Tools/Scriptable Database/Generate")]
public static void Generate()
{
Directory.CreateDirectory(OutDir);
diff --git a/src/Game.Client/Assets/Programs/Editor/Database/ScriptableTableIO.cs b/src/Game.Client/Assets/Programs/Editor/Database/ScriptableTableIO.cs
new file mode 100644
index 00000000..98714734
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Editor/Database/ScriptableTableIO.cs
@@ -0,0 +1,71 @@
+using System;
+using System.IO;
+using System.Text;
+using UnityEditor;
+using UnityEngine;
+
+namespace Game.Shared.Scriptable.Database.EditorTools
+{
+ ///
+ /// ScriptableTable の CSV/TSV インポート/エクスポートを、ファイルダイアログ+ファイル I/O 経由で実行する薄いファサード。
+ /// 文字列 ⇔ 行列の変換と records への反映は と
+ /// 側に委ね、ここは UI とファイル入出力に専念する。
+ ///
+ public static class ScriptableTableIO
+ {
+ /// UTF-8 (BOM なし)。一括側とも共有する出力エンコーディング。
+ internal static readonly Encoding Utf8NoBom = new UTF8Encoding(false);
+
+ // ScriptableTable 専用の入出力先(masterdata/raw の MasterMemory パイプラインとは非干渉)。
+ // 無ければ作成してダイアログの初期ディレクトリ・書き込み先として使えるようにする。
+ internal static string DefaultDirectory()
+ {
+ var dir = Path.GetFullPath(Path.Combine(Application.dataPath, "ProjectAssets", "Scriptable", "Database", "Raw"));
+ Directory.CreateDirectory(dir);
+ return dir;
+ }
+
+ public static void Import(ScriptableTableBase table, bool mergeByPrimaryKey)
+ {
+ if (table == null) return;
+
+ var path = EditorUtility.OpenFilePanel("Import Table from CSV/TSV", DefaultDirectory(), "tsv,csv");
+ if (string.IsNullOrEmpty(path)) return;
+
+ try
+ {
+ Undo.RecordObject(table, "Import Table");
+ ScriptableTableFileIO.ImportFromFile(table, path, mergeByPrimaryKey);
+ EditorUtility.SetDirty(table);
+ AssetDatabase.SaveAssets();
+ Debug.Log($"[ScriptableTableIO] 取り込みました({(mergeByPrimaryKey ? "Merge" : "Replace")}): {path}", table);
+ }
+ catch (Exception e)
+ {
+ Debug.LogError($"[ScriptableTableIO] インポートに失敗しました: {e}", table);
+ EditorUtility.DisplayDialog("Import 失敗", e.Message, "OK");
+ }
+ }
+
+ // extension は "tsv" / "csv"。SaveFilePanel は単一拡張子しか扱えないため形式ごとに呼び分ける。
+ public static void Export(ScriptableTableBase table, string extension)
+ {
+ if (table == null) return;
+
+ var defaultName = table.GetType().Name + "." + extension;
+ var path = EditorUtility.SaveFilePanel("Export Table", DefaultDirectory(), defaultName, extension);
+ if (string.IsNullOrEmpty(path)) return;
+
+ try
+ {
+ ScriptableTableFileIO.ExportToFile(table, path, Utf8NoBom);
+ Debug.Log($"[ScriptableTableIO] 書き出しました: {path}", table);
+ }
+ catch (Exception e)
+ {
+ Debug.LogError($"[ScriptableTableIO] エクスポートに失敗しました: {e}", table);
+ EditorUtility.DisplayDialog("Export 失敗", e.Message, "OK");
+ }
+ }
+ }
+}
diff --git a/src/Game.Client/Assets/Programs/Editor/Database/ScriptableTableIO.cs.meta b/src/Game.Client/Assets/Programs/Editor/Database/ScriptableTableIO.cs.meta
new file mode 100644
index 00000000..9c6005d3
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Editor/Database/ScriptableTableIO.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: e0849f07f0ef0414cb452ea4b587e40b
\ No newline at end of file
diff --git a/src/Game.Client/Assets/Programs/Editor/Tests/Shared/ScriptableTableTests.cs b/src/Game.Client/Assets/Programs/Editor/Tests/Shared/ScriptableTableTests.cs
index 3870a962..95c4da1c 100644
--- a/src/Game.Client/Assets/Programs/Editor/Tests/Shared/ScriptableTableTests.cs
+++ b/src/Game.Client/Assets/Programs/Editor/Tests/Shared/ScriptableTableTests.cs
@@ -1,6 +1,8 @@
using System;
using System.Collections.Generic;
+using System.IO;
using System.Linq;
+using System.Text;
using System.Text.RegularExpressions;
using Game.Shared.Scriptable.Database;
using NUnit.Framework;
@@ -176,5 +178,335 @@ public void EditorIsSorted_DetectsUnsorted_ThenSortFixes()
Assert.IsTrue(t.EditorIsSorted());
Assert.AreEqual(1, t.All[0].id);
}
+
+ // ===== CSV/TSV インポート/エクスポート =====
+
+ private enum Element { Fire, Water, Wind }
+
+ // enum/bool/float を含む検証用レコード(主キーは [PrimaryKey] でマージ対象を特定)。
+ [Serializable]
+ private class Rec2
+ {
+ [PrimaryKey] public int Id { get; set; }
+ public string Name { get; set; }
+ public Element Kind { get; set; }
+ public bool Active { get; set; }
+ public float Weight { get; set; }
+
+ public Rec2() { } // Activator.CreateInstance 用の既定コンストラクタ
+
+ public Rec2(int id, string name, Element kind, bool active, float weight)
+ {
+ Id = id; Name = name; Kind = kind; Active = active; Weight = weight;
+ }
+ }
+
+ private class TestScriptableTable2 : ScriptableTable
+ {
+ private static readonly Func Sel = r => r.Id;
+ private static readonly IComparer Cmp = Comparer.Default;
+
+ public override void EditorSortAndValidate() => SortAndValidate(Sel, Cmp);
+ public override bool EditorIsSorted() => IsSortedByKey(Sel, Cmp);
+ public void Set(params Rec2[] rs) => records = rs;
+ }
+
+ private static TestScriptableTable2 MakeTable2(params Rec2[] rs)
+ {
+ var t = ScriptableObject.CreateInstance();
+ t.Set(rs);
+ return t;
+ }
+
+ private static void AssertRec2Equal(Rec2 expected, Rec2 actual)
+ {
+ Assert.AreEqual(expected.Id, actual.Id);
+ Assert.AreEqual(expected.Name, actual.Name);
+ Assert.AreEqual(expected.Kind, actual.Kind);
+ Assert.AreEqual(expected.Active, actual.Active);
+ Assert.AreEqual(expected.Weight, actual.Weight);
+ }
+
+ // export → 文字列化 → 解析 → import(Replace) で全列が保持されることを各区切りで確認する。
+ // import 後は主キー昇順に整列されるため、期待値も Id 昇順に並べて比較する(順序非依存)。
+ private static void AssertRoundTrips(char delimiter, params Rec2[] source)
+ {
+ var src = MakeTable2(source);
+ var (headers, rows) = src.EditorExportRows();
+ var text = ScriptableTableTextSerializer.WriteDocument(headers, rows, delimiter);
+ var (h2, r2) = ScriptableTableTextSerializer.ParseDocument(text, delimiter);
+
+ var dst = MakeTable2();
+ dst.EditorImportRows(h2, r2, mergeByPrimaryKey: false);
+
+ var expected = source.OrderBy(r => r.Id).ToArray();
+ Assert.AreEqual(expected.Length, dst.All.Count);
+ for (int i = 0; i < expected.Length; i++) AssertRec2Equal(expected[i], dst.All[i]);
+ }
+
+ [Test]
+ public void RoundTrip_Tsv_PreservesAllColumns()
+ => AssertRoundTrips('\t',
+ new Rec2(2, "beta", Element.Water, true, 2.5f),
+ new Rec2(1, "alpha", Element.Fire, false, 0.25f));
+
+ [Test]
+ public void RoundTrip_Csv_PreservesAllColumns()
+ => AssertRoundTrips(',',
+ new Rec2(2, "beta", Element.Water, true, 2.5f),
+ new Rec2(1, "alpha", Element.Fire, false, 0.25f));
+
+ [Test]
+ public void RoundTrip_Csv_EscapesCommaNewlineQuote()
+ => AssertRoundTrips(',',
+ new Rec2(1, "a,b", Element.Fire, true, 1f),
+ new Rec2(2, "line1\nline2", Element.Water, false, 2f),
+ new Rec2(3, "quote\"x", Element.Wind, true, 3f));
+
+ [Test]
+ public void ParseValue_Enum_ByName()
+ => Assert.AreEqual((int)Element.Water, ScriptableTableTextSerializer.ParseValue(typeof(Element), "Water"));
+
+ [Test]
+ public void ParseValue_Bool_AcceptsNumericAndText()
+ {
+ Assert.AreEqual(true, ScriptableTableTextSerializer.ParseValue(typeof(bool), "1"));
+ Assert.AreEqual(true, ScriptableTableTextSerializer.ParseValue(typeof(bool), "true"));
+ Assert.AreEqual(false, ScriptableTableTextSerializer.ParseValue(typeof(bool), "0"));
+ }
+
+ [Test]
+ public void ParseValue_Float_InvariantCulture()
+ => Assert.AreEqual(1.5f, ScriptableTableTextSerializer.ParseValue(typeof(float), "1.5"));
+
+ [Test]
+ public void FormatValue_RoundTripConventions()
+ {
+ Assert.AreEqual("true", ScriptableTableTextSerializer.FormatValue(true));
+ Assert.AreEqual("false", ScriptableTableTextSerializer.FormatValue(false));
+ Assert.AreEqual("1.5", ScriptableTableTextSerializer.FormatValue(1.5f));
+ Assert.AreEqual("Water", ScriptableTableTextSerializer.FormatValue(Element.Water));
+ Assert.AreEqual(string.Empty, ScriptableTableTextSerializer.FormatValue(null));
+ }
+
+ [Test]
+ public void Import_Replace_TotallyReplacesRecords()
+ {
+ var t = MakeTable2(
+ new Rec2(1, "a", Element.Fire, true, 1f),
+ new Rec2(2, "b", Element.Water, false, 2f),
+ new Rec2(3, "c", Element.Wind, true, 3f));
+
+ var headers = new[] { "Id", "Name", "Kind", "Active", "Weight" };
+ var rows = new List { new[] { "9", "z", "Fire", "true", "9" } };
+ t.EditorImportRows(headers, rows, mergeByPrimaryKey: false);
+
+ Assert.AreEqual(1, t.All.Count);
+ Assert.AreEqual(9, t.All[0].Id);
+ }
+
+ [Test]
+ public void Import_MergeByPrimaryKey_UpdatesAddsKeepsExisting()
+ {
+ var t = MakeTable2(
+ new Rec2(1, "a", Element.Fire, true, 1f),
+ new Rec2(2, "b", Element.Water, false, 2f),
+ new Rec2(3, "c", Element.Wind, true, 3f));
+
+ var headers = new[] { "Id", "Name", "Kind", "Active", "Weight" };
+ var rows = new List
+ {
+ new[] { "2", "B2", "Fire", "true", "2.5" }, // 既存キー → 更新
+ new[] { "4", "d", "Wind", "false", "4" }, // 新規キー → 追加
+ };
+ t.EditorImportRows(headers, rows, mergeByPrimaryKey: true);
+
+ Assert.AreEqual(4, t.All.Count); // 1,2,3 は保持+4 追加
+ Assert.AreEqual("B2", t.All[1].Name); // id=2 が更新
+ Assert.AreEqual(Element.Fire, t.All[1].Kind);
+ Assert.AreEqual("c", t.All[2].Name); // id=3 は保持
+ Assert.AreEqual(4, t.All[3].Id);
+ }
+
+ [Test]
+ public void Import_UnknownColumn_Warns()
+ {
+ var t = MakeTable2();
+ var headers = new[] { "Id", "Bogus" };
+ var rows = new List { new[] { "5", "ignored" } };
+
+ LogAssert.Expect(LogType.Warning, new Regex("未知の列"));
+ t.EditorImportRows(headers, rows, mergeByPrimaryKey: false);
+
+ Assert.AreEqual(1, t.All.Count);
+ Assert.AreEqual(5, t.All[0].Id);
+ }
+
+ [Test]
+ public void Import_InvalidValue_Throws()
+ {
+ var t = MakeTable2();
+ var headers = new[] { "Id" };
+ var rows = new List { new[] { "not-an-int" } };
+
+ Assert.Throws(
+ () => t.EditorImportRows(headers, rows, mergeByPrimaryKey: false));
+ }
+
+ [Test]
+ public void Import_SortsAfterImport()
+ {
+ var t = MakeTable2();
+ var headers = new[] { "Id", "Name", "Kind", "Active", "Weight" };
+ var rows = new List
+ {
+ new[] { "3", "c", "Wind", "true", "3" },
+ new[] { "1", "a", "Fire", "true", "1" },
+ new[] { "2", "b", "Water", "true", "2" },
+ };
+ t.EditorImportRows(headers, rows, mergeByPrimaryKey: false);
+
+ Assert.IsTrue(t.EditorIsSorted());
+ Assert.AreEqual(1, t.All[0].Id);
+ Assert.AreEqual(3, t.All[2].Id);
+ }
+
+ // ===== エクスポート書き込み(1世代 .bak +アトミック置換) =====
+
+ private static readonly Encoding Utf8NoBom = new UTF8Encoding(false);
+
+ // 一時ディレクトリを作り、action 実行後に必ず後始末する。
+ private static void InTempDir(Action action)
+ {
+ var dir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
+ Directory.CreateDirectory(dir);
+ try { action(dir); }
+ finally { if (Directory.Exists(dir)) Directory.Delete(dir, true); }
+ }
+
+ [Test]
+ public void WriteWithBackup_NewFile_WritesWithoutBackup()
+ {
+ InTempDir(dir =>
+ {
+ var path = Path.Combine(dir, "table.tsv");
+ ScriptableTableFileWriter.WriteWithBackup(path, "v1", Utf8NoBom);
+
+ Assert.AreEqual("v1", File.ReadAllText(path));
+ Assert.IsFalse(File.Exists(path + ".bak")); // 既存なし → .bak は作らない
+ Assert.IsFalse(File.Exists(path + ".tmp")); // temp を残さない
+ });
+ }
+
+ [Test]
+ public void WriteWithBackup_ExistingFile_BacksUpOldContent()
+ {
+ InTempDir(dir =>
+ {
+ var path = Path.Combine(dir, "table.tsv");
+ File.WriteAllText(path, "old", Utf8NoBom);
+
+ ScriptableTableFileWriter.WriteWithBackup(path, "new", Utf8NoBom);
+
+ Assert.AreEqual("new", File.ReadAllText(path)); // 本体は更新
+ Assert.AreEqual("old", File.ReadAllText(path + ".bak")); // 旧内容が退避
+ Assert.IsFalse(File.Exists(path + ".tmp"));
+ });
+ }
+
+ [Test]
+ public void WriteWithBackup_TwiceOverwrite_BakKeepsOnlyPrevious()
+ {
+ InTempDir(dir =>
+ {
+ var path = Path.Combine(dir, "table.tsv");
+ File.WriteAllText(path, "v1", Utf8NoBom);
+
+ ScriptableTableFileWriter.WriteWithBackup(path, "v2", Utf8NoBom);
+ ScriptableTableFileWriter.WriteWithBackup(path, "v3", Utf8NoBom);
+
+ Assert.AreEqual("v3", File.ReadAllText(path)); // 最新
+ Assert.AreEqual("v2", File.ReadAllText(path + ".bak")); // 1世代=直前のみ
+ });
+ }
+
+ // ===== table ⇔ ファイル変換コア(一括処理が共用) =====
+
+ [Test]
+ public void FileIO_RoundTrip_Tsv()
+ {
+ InTempDir(dir =>
+ {
+ var src = MakeTable2(
+ new Rec2(2, "b", Element.Water, true, 2.5f),
+ new Rec2(1, "a", Element.Fire, false, 0.25f));
+ var path = Path.Combine(dir, "t.tsv");
+ ScriptableTableFileIO.ExportToFile(src, path, Utf8NoBom);
+
+ var dst = MakeTable2();
+ ScriptableTableFileIO.ImportFromFile(dst, path, mergeByPrimaryKey: false);
+
+ var expected = new[]
+ {
+ new Rec2(1, "a", Element.Fire, false, 0.25f),
+ new Rec2(2, "b", Element.Water, true, 2.5f),
+ };
+ Assert.AreEqual(expected.Length, dst.All.Count);
+ for (int i = 0; i < expected.Length; i++) AssertRec2Equal(expected[i], dst.All[i]);
+ });
+ }
+
+ [Test]
+ public void FileIO_RoundTrip_Csv()
+ {
+ InTempDir(dir =>
+ {
+ var src = MakeTable2(new Rec2(1, "a,b", Element.Fire, true, 1f)); // カンマ含む → CSV エスケープ
+ var path = Path.Combine(dir, "t.csv");
+ ScriptableTableFileIO.ExportToFile(src, path, Utf8NoBom);
+
+ var dst = MakeTable2();
+ ScriptableTableFileIO.ImportFromFile(dst, path, mergeByPrimaryKey: false);
+
+ Assert.AreEqual(1, dst.All.Count);
+ AssertRec2Equal(new Rec2(1, "a,b", Element.Fire, true, 1f), dst.All[0]);
+ });
+ }
+
+ [Test]
+ public void FileIO_ImportFromFile_Merge()
+ {
+ InTempDir(dir =>
+ {
+ var fileTable = MakeTable2(
+ new Rec2(2, "B2", Element.Fire, true, 2.5f), // 既存キー → 更新
+ new Rec2(4, "d", Element.Wind, false, 4f)); // 新規キー → 追加
+ var path = Path.Combine(dir, "t.tsv");
+ ScriptableTableFileIO.ExportToFile(fileTable, path, Utf8NoBom);
+
+ var target = MakeTable2(
+ new Rec2(1, "a", Element.Fire, true, 1f),
+ new Rec2(2, "b", Element.Water, false, 2f),
+ new Rec2(3, "c", Element.Wind, true, 3f));
+ ScriptableTableFileIO.ImportFromFile(target, path, mergeByPrimaryKey: true);
+
+ Assert.AreEqual(4, target.All.Count); // 1,2,3 保持+4 追加
+ Assert.AreEqual("B2", target.All[1].Name); // id=2 更新
+ Assert.AreEqual(4, target.All[3].Id); // id=4 追加
+ });
+ }
+
+ [Test]
+ public void FileIO_ExportToFile_BacksUpExisting()
+ {
+ InTempDir(dir =>
+ {
+ var path = Path.Combine(dir, "t.tsv");
+ ScriptableTableFileIO.ExportToFile(MakeTable2(new Rec2(1, "a", Element.Fire, true, 1f)), path, Utf8NoBom);
+ ScriptableTableFileIO.ExportToFile(MakeTable2(new Rec2(2, "b", Element.Water, false, 2f)), path, Utf8NoBom);
+
+ Assert.IsTrue(File.Exists(path + ".bak")); // 一括でも既存ファイルは .bak へ退避される
+ });
+ }
}
}
diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/ScriptableTable.cs b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/ScriptableTable.cs
index 11acf7b7..9c8eea4c 100644
--- a/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/ScriptableTable.cs
+++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/ScriptableTable.cs
@@ -1,5 +1,9 @@
using System;
using System.Collections.Generic;
+#if UNITY_EDITOR
+using System.Linq;
+using System.Reflection;
+#endif
using UnityEngine;
namespace Game.Shared.Scriptable.Database
@@ -126,6 +130,117 @@ protected void SortAndValidate(Func sel, IComparer cm
Debug.LogWarning($"[{name}] 主キー {keys[i]} が重複しています。", this);
}
}
+
+ // ---- CSV/TSV インポート/エクスポート(型非依存。基底の抽象を実装) ----------
+
+ ///
+ /// records を CSV/TSV 出力用のヘッダ+行へ変換する。
+ /// 列は public プロパティ/フィールド(読取可能)を宣言順(MetadataToken)で並べる。
+ ///
+ public override (string[] headers, List rows) EditorExportRows()
+ {
+ var cols = Columns().Where(IsReadable).ToList();
+ var headers = cols.Select(MemberName).ToArray();
+
+ var rows = new List();
+ if (records != null)
+ {
+ foreach (var record in records)
+ {
+ if (record == null) continue;
+ object boxed = record;
+ var row = new string[cols.Count];
+ for (int i = 0; i < cols.Count; i++)
+ row[i] = ScriptableTableTextSerializer.FormatValue(GetMember(cols[i], boxed));
+ rows.Add(row);
+ }
+ }
+ return (headers, rows);
+ }
+
+ ///
+ /// CSV/TSV から解析した行を records へ反映する。列名はメンバ名と完全一致でマッピングし、
+ /// 未知列は警告して無視、ファイルに無い列は既定値のままとする。反映後に整列・検証する。
+ ///
+ public override void EditorImportRows(IReadOnlyList headers, IReadOnlyList> rows, bool mergeByPrimaryKey)
+ {
+ var writable = Columns().Where(IsWritable).ToDictionary(MemberName);
+ var headerColumns = new MemberInfo[headers.Count];
+ for (int i = 0; i < headers.Count; i++)
+ {
+ if (writable.TryGetValue(headers[i], out var member)) headerColumns[i] = member;
+ else Debug.LogWarning($"[{name}] 未知の列「{headers[i]}」を無視します。", this);
+ }
+
+ var parsed = new List(rows.Count);
+ foreach (var row in rows)
+ {
+ object boxed = Activator.CreateInstance(typeof(TRecord));
+ for (int i = 0; i < headerColumns.Length && i < row.Count; i++)
+ {
+ var member = headerColumns[i];
+ if (member == null) continue;
+ var value = ScriptableTableTextSerializer.ParseValue(MemberType(member), row[i]);
+ SetMember(member, boxed, value);
+ }
+ parsed.Add((TRecord)boxed);
+ }
+
+ records = mergeByPrimaryKey
+ ? MergeByPrimaryKey(records, parsed)
+ : parsed.ToArray();
+
+ EditorSortAndValidate();
+ }
+
+ /// 既存 records とインポート行を主キーでマージする(一致=更新/新規=追加/ファイル外=保持、初出順を保つ)。
+ private TRecord[] MergeByPrimaryKey(TRecord[] existing, List incoming)
+ {
+ var primaryKey = Columns().FirstOrDefault(IsPrimaryKey);
+ if (primaryKey == null)
+ {
+ Debug.LogWarning($"[{name}] 主キーが無いため Replace として扱います。", this);
+ return incoming.ToArray();
+ }
+
+ var byKey = new Dictionary