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(); + var order = new List(); + + void Put(TRecord record) + { + if (record == null) return; + var key = GetMember(primaryKey, record); + if (!byKey.ContainsKey(key)) order.Add(key); + byKey[key] = record; + } + + if (existing != null) + foreach (var record in existing) Put(record); + foreach (var record in incoming) Put(record); // 同一キーは上書き、新規は末尾へ追加 + + var result = new TRecord[order.Count]; + for (int i = 0; i < order.Count; i++) result[i] = byKey[order[i]]; + return result; + } + + // 列対象 = public プロパティ(非インデクサ)/ public フィールド。宣言順を安定再現するため MetadataToken 昇順。 + private static IEnumerable Columns() => + typeof(TRecord) + .GetMembers(BindingFlags.Public | BindingFlags.Instance) + .Where(m => m is FieldInfo || (m is PropertyInfo p && p.GetIndexParameters().Length == 0)) + .OrderBy(m => m.MetadataToken); + + private static string MemberName(MemberInfo m) => m.Name; + private static Type MemberType(MemberInfo m) => m is FieldInfo f ? f.FieldType : ((PropertyInfo)m).PropertyType; + private static bool IsReadable(MemberInfo m) => m is FieldInfo || ((PropertyInfo)m).CanRead; + private static bool IsWritable(MemberInfo m) => m is FieldInfo f ? !f.IsInitOnly : ((PropertyInfo)m).CanWrite; + private static bool IsPrimaryKey(MemberInfo m) => m.GetCustomAttribute() != null; + private static object GetMember(MemberInfo m, object obj) => m is FieldInfo f ? f.GetValue(obj) : ((PropertyInfo)m).GetValue(obj); + private static void SetMember(MemberInfo m, object obj, object value) + { + if (m is FieldInfo f) f.SetValue(obj, value); + else ((PropertyInfo)m).SetValue(obj, value); + } #endif } } diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/ScriptableTableBase.cs b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/ScriptableTableBase.cs index c30c6bd3..2a5f864f 100644 --- a/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/ScriptableTableBase.cs +++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/ScriptableTableBase.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using UnityEngine; namespace Game.Shared.Scriptable.Database @@ -15,6 +16,16 @@ public abstract class ScriptableTableBase : ScriptableObject /// records が主キー昇順・空要素なしに整っているか(生成 partial が実装)。 public abstract bool EditorIsSorted(); + + /// + /// CSV/TSV から解析した行を records へ反映する(型非依存。ジェネリック基底が実装)。 + /// が true なら主キーマージ(一致=更新・新規=追加・ファイル外=保持)、 + /// false なら総入れ替え。反映後に 相当の整列を行う。 + /// + public abstract void EditorImportRows(IReadOnlyList headers, IReadOnlyList> rows, bool mergeByPrimaryKey); + + /// records を CSV/TSV 出力用のヘッダ+行へ変換する(型非依存。ジェネリック基底が実装)。 + public abstract (string[] headers, List rows) EditorExportRows(); #endif } } diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/ScriptableTableFileIO.cs b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/ScriptableTableFileIO.cs new file mode 100644 index 00000000..1b6ecb14 --- /dev/null +++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/ScriptableTableFileIO.cs @@ -0,0 +1,37 @@ +#if UNITY_EDITOR +using System.IO; +using System.Text; + +namespace Game.Shared.Scriptable.Database +{ + /// + /// ScriptableTable と CSV/TSV ファイルの相互変換コア(ダイアログ非依存・副作用最小)。 + /// 区切りは拡張子(.csv/.tsv)で判定する。Undo/SetDirty/SaveAssets/ダイアログ等の + /// UnityEditor 副作用は呼び出し側(Editor のファサード)の責務とし、ここには持ち込まない。 + /// 単一テーブル(ScriptableTableIO)と一括処理(ScriptableDatabaseIO)の双方から共用する。 + /// + public static class ScriptableTableFileIO + { + /// table の内容を へ書き出す(拡張子で CSV/TSV 判定、既存は .bak 退避)。 + public static void ExportToFile(ScriptableTableBase table, string path, Encoding encoding) + { + var delimiter = ScriptableTableTextSerializer.DelimiterFromExtension(path); + var (headers, rows) = table.EditorExportRows(); + var text = ScriptableTableTextSerializer.WriteDocument(headers, rows, delimiter); + ScriptableTableFileWriter.WriteWithBackup(path, text, encoding); + } + + /// + /// の CSV/TSV を table へ取り込む(拡張子で判定)。 + /// records への反映のみ行い、Undo 記録・ダーティ化・保存は呼び出し側に委ねる。 + /// + public static void ImportFromFile(ScriptableTableBase table, string path, bool mergeByPrimaryKey) + { + var text = File.ReadAllText(path); + var delimiter = ScriptableTableTextSerializer.DelimiterFromExtension(path); + var (headers, rows) = ScriptableTableTextSerializer.ParseDocument(text, delimiter); + table.EditorImportRows(headers, rows, mergeByPrimaryKey); + } + } +} +#endif diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/ScriptableTableFileIO.cs.meta b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/ScriptableTableFileIO.cs.meta new file mode 100644 index 00000000..ddecaa53 --- /dev/null +++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/ScriptableTableFileIO.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6d3835c934b6df94cbfcc762ccf8c944 \ No newline at end of file diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/ScriptableTableFileWriter.cs b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/ScriptableTableFileWriter.cs new file mode 100644 index 00000000..8e83b976 --- /dev/null +++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/ScriptableTableFileWriter.cs @@ -0,0 +1,47 @@ +#if UNITY_EDITOR +using System.IO; +using System.Text; + +namespace Game.Shared.Scriptable.Database +{ + /// + /// ScriptableTable のエクスポート書き込みヘルパ(ファイル I/O のみ。Unity 非依存)。 + /// 既存ファイルは 1 世代 .bak へ退避しつつアトミックに置換し、誤エクスポートでの上書き消失と + /// 書き込み途中中断によるファイル破損を防ぐ。 + /// + public static class ScriptableTableFileWriter + { + /// + /// を書き込む。 + /// 既存ファイルがあれば {path}.bak(1 世代・毎回上書き)へ退避し、 + /// 一時ファイル経由で によりアトミックに置換する。 + /// + public static void WriteWithBackup(string path, string contents, Encoding encoding) + { + var dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + + // 既存なし → 退避不要。単純書き込み。 + if (!File.Exists(path)) + { + File.WriteAllText(path, contents, encoding); + return; + } + + var temp = path + ".tmp"; + var backup = path + ".bak"; // 1 世代。File.Replace が既存 .bak を上書きする。 + try + { + File.WriteAllText(temp, contents, encoding); + // 既存を backup へ退避しつつ temp を path へアトミック置換(temp/path/backup は同一ディレクトリ)。 + File.Replace(temp, path, backup); + } + finally + { + // 置換失敗時に temp を残さない(成功時は Replace で temp が消費されるため no-op)。 + if (File.Exists(temp)) File.Delete(temp); + } + } + } +} +#endif diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/ScriptableTableFileWriter.cs.meta b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/ScriptableTableFileWriter.cs.meta new file mode 100644 index 00000000..cec24949 --- /dev/null +++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/ScriptableTableFileWriter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d6bf28fdfdb99f7428e8f8c4d3920245 \ No newline at end of file diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/ScriptableTableTextSerializer.cs b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/ScriptableTableTextSerializer.cs new file mode 100644 index 00000000..55d81981 --- /dev/null +++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/ScriptableTableTextSerializer.cs @@ -0,0 +1,252 @@ +#if UNITY_EDITOR +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text; + +namespace Game.Shared.Scriptable.Database +{ + /// + /// ScriptableTable の CSV/TSV インポート/エクスポート用の、ファイル I/O 非依存な変換ロジック。 + /// セル値の型変換(/)と、 + /// ドキュメント文字列 ⇔ 行列(/)を担う。 + /// 区切り文字を切り替えるだけで CSV/TSV 両対応。CSV はクオート/エスケープ(RFC 4180)に対応する。 + /// 型変換規約は既存 MasterDataHelper.ParseValue(masterdata/raw TSV)に準拠し、InvariantCulture を厳守する。 + /// + public static class ScriptableTableTextSerializer + { + public const char TabDelimiter = '\t'; + public const char CommaDelimiter = ','; + + /// 拡張子から区切り文字を判定する(.csv はカンマ、それ以外は TAB)。 + public static char DelimiterFromExtension(string path) + { + var ext = Path.GetExtension(path); + return string.Equals(ext, ".csv", StringComparison.OrdinalIgnoreCase) ? CommaDelimiter : TabDelimiter; + } + + // ---- セル値変換 ------------------------------------------------------ + + /// + /// 文字列セルを指定型へ変換する。string/Nullable/enum(名前)/bool(true|false|1|0)/ + /// 整数・浮動小数・decimal・DateTime・DateTimeOffset・TimeSpan・Guid を InvariantCulture で解釈する。 + /// + public static object ParseValue(Type type, string rawValue) + { + if (type == typeof(string)) return rawValue; + + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>)) + { + if (string.IsNullOrWhiteSpace(rawValue)) return null; + return ParseValue(type.GenericTypeArguments[0], rawValue); + } + + if (type.IsEnum) + { + // 基底型へ変換しておく(PropertyInfo.SetValue は enum 型で受けるため値としては等価)。 + var value = Enum.Parse(type, rawValue); + var underlyingType = Enum.GetUnderlyingType(type); + return Convert.ChangeType(value, underlyingType, CultureInfo.InvariantCulture); + } + + switch (Type.GetTypeCode(type)) + { + case TypeCode.Boolean: + // "1"/"0" と "true"/"false" の両方を受理する。 + if (int.TryParse(rawValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intBool)) + return Convert.ToBoolean(intBool); + return bool.Parse(rawValue); + case TypeCode.Char: + return char.Parse(rawValue); + case TypeCode.SByte: + return sbyte.Parse(rawValue, CultureInfo.InvariantCulture); + case TypeCode.Byte: + return byte.Parse(rawValue, CultureInfo.InvariantCulture); + case TypeCode.Int16: + return short.Parse(rawValue, CultureInfo.InvariantCulture); + case TypeCode.UInt16: + return ushort.Parse(rawValue, CultureInfo.InvariantCulture); + case TypeCode.Int32: + return int.Parse(rawValue, CultureInfo.InvariantCulture); + case TypeCode.UInt32: + return uint.Parse(rawValue, CultureInfo.InvariantCulture); + case TypeCode.Int64: + return long.Parse(rawValue, CultureInfo.InvariantCulture); + case TypeCode.UInt64: + return ulong.Parse(rawValue, CultureInfo.InvariantCulture); + case TypeCode.Single: + return float.Parse(rawValue, NumberStyles.Float, CultureInfo.InvariantCulture); + case TypeCode.Double: + return double.Parse(rawValue, NumberStyles.Float, CultureInfo.InvariantCulture); + case TypeCode.Decimal: + return decimal.Parse(rawValue, NumberStyles.Float, CultureInfo.InvariantCulture); + case TypeCode.DateTime: + return DateTime.Parse(rawValue, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind); + default: + if (type == typeof(DateTimeOffset)) + return DateTimeOffset.Parse(rawValue, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind); + if (type == typeof(TimeSpan)) + return TimeSpan.Parse(rawValue, CultureInfo.InvariantCulture); + if (type == typeof(Guid)) + return Guid.Parse(rawValue); + throw new NotSupportedException($"未対応の型です: {type.FullName}"); + } + } + + /// + /// セル値を文字列へ変換する。 と往復対称になるよう + /// 浮動小数は往復書式("R")、DateTime 系は "O"、bool は小文字、null は空セルとする。 + /// + public static string FormatValue(object value) + { + switch (value) + { + case null: return string.Empty; + case string s: return s; + case bool b: return b ? "true" : "false"; + case float f: return f.ToString("R", CultureInfo.InvariantCulture); + case double d: return d.ToString("R", CultureInfo.InvariantCulture); + case decimal m: return m.ToString(CultureInfo.InvariantCulture); + case DateTime dt: return dt.ToString("O", CultureInfo.InvariantCulture); + case DateTimeOffset dto: return dto.ToString("O", CultureInfo.InvariantCulture); + case TimeSpan ts: return ts.ToString("c", CultureInfo.InvariantCulture); + case IFormattable formattable: return formattable.ToString(null, CultureInfo.InvariantCulture); + default: return value.ToString(); + } + } + + // ---- ドキュメント変換 ------------------------------------------------ + + /// + /// CSV/TSV テキストをヘッダ行+データ行へ分解する。空行はスキップする。 + /// CSV(delimiter==',')はクオート対応("…" 内の区切り・改行・"" エスケープ)。 + /// TSV(delimiter=='\t')はクオート解釈せず行ごとに単純分割する。 + /// + public static (string[] headers, List rows) ParseDocument(string text, char delimiter) + { + var records = delimiter == CommaDelimiter ? ParseCsv(text) : ParseTsv(text); + if (records.Count == 0) return (Array.Empty(), new List()); + + var headers = records[0]; + var rows = new List(records.Count - 1); + for (int i = 1; i < records.Count; i++) rows.Add(records[i]); + return (headers, rows); + } + + /// + /// ヘッダ+行を CSV/TSV テキストへ直列化する。 + /// CSV は区切り・改行・引用符を含むセルを "…" でクオートし " を "" にエスケープする。 + /// TSV はエスケープせず素の連結(masterdata/raw 規約)。 + /// + public static string WriteDocument(IReadOnlyList headers, IReadOnlyList rows, char delimiter) + { + bool csv = delimiter == CommaDelimiter; + var sb = new StringBuilder(); + AppendLine(sb, headers, delimiter, csv); + foreach (var row in rows) AppendLine(sb, row, delimiter, csv); + return sb.ToString(); + } + + private static void AppendLine(StringBuilder sb, IReadOnlyList cells, char delimiter, bool csv) + { + for (int i = 0; i < cells.Count; i++) + { + if (i > 0) sb.Append(delimiter); + sb.Append(csv ? EscapeCsvCell(cells[i] ?? string.Empty, delimiter) : cells[i] ?? string.Empty); + } + sb.Append('\n'); + } + + private static string EscapeCsvCell(string cell, char delimiter) + { + bool needsQuote = cell.IndexOf(delimiter) >= 0 || cell.IndexOf('"') >= 0 + || cell.IndexOf('\n') >= 0 || cell.IndexOf('\r') >= 0; + if (!needsQuote) return cell; + return "\"" + cell.Replace("\"", "\"\"") + "\""; + } + + private static List ParseTsv(string text) + { + var result = new List(); + using var reader = new StringReader(text); + string line; + while ((line = reader.ReadLine()) != null) + { + if (string.IsNullOrWhiteSpace(line)) continue; + result.Add(line.Split(TabDelimiter)); + } + return result; + } + + // RFC 4180 準拠の状態機械。クオート内の区切り・改行・"" を正しく扱う。 + private static List ParseCsv(string text) + { + var result = new List(); + var fields = new List(); + var field = new StringBuilder(); + bool inQuotes = false; + bool fieldStarted = false; // 行にセルが1つでも出現したか(空行スキップ判定用) + + void EndField() + { + fields.Add(field.ToString()); + field.Clear(); + } + + void EndRecord() + { + EndField(); + // 全セルが空の行(区切りも無い)はスキップする。 + bool allEmpty = fields.Count == 1 && fields[0].Length == 0; + if (!allEmpty) result.Add(fields.ToArray()); + fields.Clear(); + fieldStarted = false; + } + + for (int i = 0; i < text.Length; i++) + { + char c = text[i]; + if (inQuotes) + { + if (c == '"') + { + if (i + 1 < text.Length && text[i + 1] == '"') { field.Append('"'); i++; } + else inQuotes = false; + } + else field.Append(c); + continue; + } + + switch (c) + { + case '"': + inQuotes = true; + fieldStarted = true; + break; + case CommaDelimiter: + EndField(); + fieldStarted = true; + break; + case '\r': + // 続く \n と合わせて 1 改行として扱う。 + if (i + 1 < text.Length && text[i + 1] == '\n') i++; + if (fieldStarted || field.Length > 0) EndRecord(); + break; + case '\n': + if (fieldStarted || field.Length > 0) EndRecord(); + break; + default: + field.Append(c); + fieldStarted = true; + break; + } + } + + // 末尾改行が無い場合の最終レコード。 + if (fieldStarted || field.Length > 0) EndRecord(); + return result; + } + } +} +#endif diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/ScriptableTableTextSerializer.cs.meta b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/ScriptableTableTextSerializer.cs.meta new file mode 100644 index 00000000..cd1e3f42 --- /dev/null +++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/ScriptableTableTextSerializer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c7835442a9f97484e96635b01be99c7e \ No newline at end of file diff --git a/src/Game.Client/Assets/ProjectAssets/Scriptable.meta b/src/Game.Client/Assets/ProjectAssets/Scriptable.meta new file mode 100644 index 00000000..b63437e3 --- /dev/null +++ b/src/Game.Client/Assets/ProjectAssets/Scriptable.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 9ae437729cd0e5b49b8c4ed4b09beff6 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/Game.Client/Assets/ProjectAssets/Database.meta b/src/Game.Client/Assets/ProjectAssets/Scriptable/Database.meta similarity index 100% rename from src/Game.Client/Assets/ProjectAssets/Database.meta rename to src/Game.Client/Assets/ProjectAssets/Scriptable/Database.meta diff --git a/src/Game.Client/Assets/ProjectAssets/Scriptable/Database/Raw.meta b/src/Game.Client/Assets/ProjectAssets/Scriptable/Database/Raw.meta new file mode 100644 index 00000000..8ae1cb78 --- /dev/null +++ b/src/Game.Client/Assets/ProjectAssets/Scriptable/Database/Raw.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: dd5c3fadbf5a7624d8b024c88aa21393 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/Game.Client/Assets/ProjectAssets/Scriptable/Database/Raw/WeaponLevelMasterTable.tsv b/src/Game.Client/Assets/ProjectAssets/Scriptable/Database/Raw/WeaponLevelMasterTable.tsv new file mode 100644 index 00000000..791e3d49 --- /dev/null +++ b/src/Game.Client/Assets/ProjectAssets/Scriptable/Database/Raw/WeaponLevelMasterTable.tsv @@ -0,0 +1,4 @@ +Id WeaponId Level AssetName +1 1000 1 +2 2000 2 +3 3000 3 diff --git a/src/Game.Client/Assets/ProjectAssets/Scriptable/Database/Raw/WeaponLevelMasterTable.tsv.meta b/src/Game.Client/Assets/ProjectAssets/Scriptable/Database/Raw/WeaponLevelMasterTable.tsv.meta new file mode 100644 index 00000000..aefbe103 --- /dev/null +++ b/src/Game.Client/Assets/ProjectAssets/Scriptable/Database/Raw/WeaponLevelMasterTable.tsv.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: fa4b5afd7a8f0344ab9cd95ead833a80 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/Game.Client/Assets/ProjectAssets/Database/ScriptableDatabase.asset b/src/Game.Client/Assets/ProjectAssets/Scriptable/Database/ScriptableDatabase.asset similarity index 100% rename from src/Game.Client/Assets/ProjectAssets/Database/ScriptableDatabase.asset rename to src/Game.Client/Assets/ProjectAssets/Scriptable/Database/ScriptableDatabase.asset diff --git a/src/Game.Client/Assets/ProjectAssets/Database/ScriptableDatabase.asset.meta b/src/Game.Client/Assets/ProjectAssets/Scriptable/Database/ScriptableDatabase.asset.meta similarity index 100% rename from src/Game.Client/Assets/ProjectAssets/Database/ScriptableDatabase.asset.meta rename to src/Game.Client/Assets/ProjectAssets/Scriptable/Database/ScriptableDatabase.asset.meta diff --git a/src/Game.Client/Assets/ProjectAssets/Database/Tables.meta b/src/Game.Client/Assets/ProjectAssets/Scriptable/Database/Tables.meta similarity index 100% rename from src/Game.Client/Assets/ProjectAssets/Database/Tables.meta rename to src/Game.Client/Assets/ProjectAssets/Scriptable/Database/Tables.meta diff --git a/src/Game.Client/Assets/ProjectAssets/Database/Tables/WeaponLevelMasterTable.asset b/src/Game.Client/Assets/ProjectAssets/Scriptable/Database/Tables/WeaponLevelMasterTable.asset similarity index 100% rename from src/Game.Client/Assets/ProjectAssets/Database/Tables/WeaponLevelMasterTable.asset rename to src/Game.Client/Assets/ProjectAssets/Scriptable/Database/Tables/WeaponLevelMasterTable.asset diff --git a/src/Game.Client/Assets/ProjectAssets/Database/Tables/WeaponLevelMasterTable.asset.meta b/src/Game.Client/Assets/ProjectAssets/Scriptable/Database/Tables/WeaponLevelMasterTable.asset.meta similarity index 100% rename from src/Game.Client/Assets/ProjectAssets/Database/Tables/WeaponLevelMasterTable.asset.meta rename to src/Game.Client/Assets/ProjectAssets/Scriptable/Database/Tables/WeaponLevelMasterTable.asset.meta