diff --git a/src/Game.Client/Assets/AddressableAssetsData/AssetGroups/MasterData.asset b/src/Game.Client/Assets/AddressableAssetsData/AssetGroups/MasterData.asset
index 0757b55f..89191259 100644
--- a/src/Game.Client/Assets/AddressableAssetsData/AssetGroups/MasterData.asset
+++ b/src/Game.Client/Assets/AddressableAssetsData/AssetGroups/MasterData.asset
@@ -20,6 +20,11 @@ MonoBehaviour:
m_ReadOnly: 0
m_SerializedLabels: []
FlaggedDuringContentUpdateRestriction: 0
+ - m_GUID: e4959a26874fd1a4081de17f7a1ada62
+ m_Address: ScriptableDatabase
+ m_ReadOnly: 0
+ m_SerializedLabels: []
+ FlaggedDuringContentUpdateRestriction: 0
m_ReadOnly: 0
m_Settings: {fileID: 11400000, guid: be8c7bfaed8f7eb428b95bd26a52c525, type: 2}
m_SchemaSet:
diff --git a/src/Game.Client/Assets/Programs/Editor/Database.meta b/src/Game.Client/Assets/Programs/Editor/Database.meta
new file mode 100644
index 00000000..6f9588c2
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Editor/Database.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 6385581e9a819a74eae0ef2f0d50d699
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/src/Game.Client/Assets/Programs/Editor/Database/ScriptableDatabaseBuilder.cs b/src/Game.Client/Assets/Programs/Editor/Database/ScriptableDatabaseBuilder.cs
new file mode 100644
index 00000000..24bd4033
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Editor/Database/ScriptableDatabaseBuilder.cs
@@ -0,0 +1,158 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using System.Text;
+using Game.Shared.Scriptable.Database;
+using UnityEditor;
+using UnityEngine;
+
+namespace Game.Shared.Scriptable.Database.EditorTools
+{
+ ///
+ /// [ScriptableTable] 型を集約するコンテナ ScriptableDatabase を扱うエディタコマンド。
+ /// テーブルクラス生成(ScriptableTableGenerator)とは責務が別のため独立コマンドにしている。
+ /// SO は「型」と「実体(.asset)」の2側面を持つため、両者を個別に実行できる2コマンドに分ける:
+ /// - Build : コンテナ「クラス」ScriptableDatabase.g.cs を生成(テーブル型の増減時・再コンパイル発生)。
+ /// - Register : ScriptableDatabase.asset を作成/更新し各 {Type}Table 資産を自動結線(資産の増減時・コード変更なし)。
+ /// Register が Build 済みの型を CreateInstance するため同一実行にはできず、分離が必然。
+ /// 本ビルダー自身が ScriptableDatabase を未生成段階でも壊れないよう、Register はコンテナ型を
+ /// リフレクションで参照する(コンパイル時依存を持たない)。
+ ///
+ public static class ScriptableDatabaseBuilder
+ {
+ private const string OutDir = "Assets/Programs/Runtime/Shared/Scriptable/Database/Generated";
+ private const string DatabaseAssetPath = "Assets/ProjectAssets/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);
+ var recordTypes = TableTypes().OrderBy(t => t.Name).ToList();
+ File.WriteAllText($"{OutDir}/{DatabaseClassName}.g.cs", Emit(recordTypes));
+ AssetDatabase.Refresh();
+ Debug.Log($"[ScriptableDatabaseBuilder] {DatabaseClassName}.g.cs generated with {recordTypes.Count} table(s).");
+ }
+
+ private static string Emit(IReadOnlyList recordTypes)
+ {
+ var sb = new StringBuilder();
+ sb.AppendLine("// ");
+ sb.AppendLine("using UnityEngine;");
+ sb.AppendLine();
+ sb.AppendLine($"namespace {DatabaseNamespace}");
+ sb.AppendLine("{");
+ sb.AppendLine(" /// 全 ScriptableTable を集約するコンテナ(Build コマンドで生成)。");
+ sb.AppendLine(" [CreateAssetMenu(menuName = \"Scriptable Database/Database\")]");
+ sb.AppendLine($" public sealed partial class {DatabaseClassName} : ScriptableObject");
+ sb.AppendLine(" {");
+ for (int i = 0; i < recordTypes.Count; i++)
+ {
+ var rec = recordTypes[i];
+ string tableType = TableTypeName(rec); // global::{ns}.{Name}Table
+ string prop = rec.Name + "Table"; // WeaponLevelMasterTable
+ string field = char.ToLowerInvariant(prop[0]) + prop.Substring(1); // weaponLevelMasterTable
+
+ if (i > 0) sb.AppendLine();
+ sb.AppendLine($" [SerializeField] private {tableType} {field};");
+ sb.AppendLine($" public {tableType} {prop} => {field};");
+ }
+ sb.AppendLine(" }");
+ sb.AppendLine("}");
+ return sb.ToString();
+ }
+
+ private static string TableTypeName(Type recordType)
+ {
+ string name = recordType.Name + "Table";
+ return recordType.Namespace != null ? $"global::{recordType.Namespace}.{name}" : $"global::{name}";
+ }
+
+ // ---- コマンド②: テーブル資産の自動登録 ----
+
+ [MenuItem("Tools/Scriptable Database/Register")]
+ public static void Register()
+ {
+ var dbType = FindDatabaseType();
+ if (dbType == null)
+ {
+ Debug.LogError("[ScriptableDatabaseBuilder] ScriptableDatabase 型が見つかりません。先に 'Tools/Scriptable Database/Build' を実行してください。");
+ return;
+ }
+
+ var database = AssetDatabase.LoadAssetAtPath(DatabaseAssetPath, dbType) as ScriptableObject;
+ if (database == null)
+ {
+ database = ScriptableObject.CreateInstance(dbType);
+ AssetDatabase.CreateAsset(database, DatabaseAssetPath);
+ Debug.Log($"[ScriptableDatabaseBuilder] Created {DatabaseAssetPath}.");
+ }
+
+ var so = new SerializedObject(database);
+ int wired = 0, missing = 0;
+ foreach (var field in TableFields(dbType))
+ {
+ var prop = so.FindProperty(field.Name);
+ if (prop == null) continue;
+
+ var guids = AssetDatabase.FindAssets($"t:{field.FieldType.Name}");
+ if (guids.Length == 0)
+ {
+ missing++;
+ Debug.LogWarning($"[ScriptableDatabaseBuilder] {field.FieldType.Name} の資産が見つかりません(フィールド '{field.Name}' は未結線)。");
+ continue;
+ }
+ if (guids.Length > 1)
+ {
+ Debug.LogWarning($"[ScriptableDatabaseBuilder] {field.FieldType.Name} の資産が複数あります。先頭を採用します。");
+ }
+
+ var path = AssetDatabase.GUIDToAssetPath(guids[0]);
+ prop.objectReferenceValue = AssetDatabase.LoadAssetAtPath(path, field.FieldType);
+ wired++;
+ }
+
+ so.ApplyModifiedProperties();
+ EditorUtility.SetDirty(database);
+ AssetDatabase.SaveAssets();
+ Debug.Log($"[ScriptableDatabaseBuilder] Register 完了: 結線 {wired} 件 / 欠落 {missing} 件。");
+ }
+
+ private static Type FindDatabaseType() =>
+ AppDomain.CurrentDomain.GetAssemblies()
+ .SelectMany(SafeTypes)
+ .FirstOrDefault(t => t.FullName == $"{DatabaseNamespace}.{DatabaseClassName}");
+
+ private static IEnumerable TableFields(Type dbType) =>
+ dbType.GetFields(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)
+ .Where(f => IsScriptableTable(f.FieldType));
+
+ private static bool IsScriptableTable(Type t)
+ {
+ for (var b = t; b != null; b = b.BaseType)
+ {
+ if (b.IsGenericType && b.GetGenericTypeDefinition() == typeof(ScriptableTable<>))
+ return true;
+ }
+ return false;
+ }
+
+ // ---- 走査ヘルパ(ScriptableTableGenerator と同等) ----
+
+ private static IEnumerable TableTypes() =>
+ AppDomain.CurrentDomain.GetAssemblies()
+ .SelectMany(SafeTypes)
+ .Where(t => t.IsClass && t.GetCustomAttribute() != null);
+
+ private static IEnumerable SafeTypes(Assembly a)
+ {
+ try { return a.GetTypes(); }
+ catch (ReflectionTypeLoadException e) { return e.Types.Where(x => x != null); }
+ }
+ }
+}
diff --git a/src/Game.Client/Assets/Programs/Editor/Database/ScriptableDatabaseBuilder.cs.meta b/src/Game.Client/Assets/Programs/Editor/Database/ScriptableDatabaseBuilder.cs.meta
new file mode 100644
index 00000000..a39c0a31
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Editor/Database/ScriptableDatabaseBuilder.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 5f14a3f4beaf370448532eda094e75e7
\ 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
new file mode 100644
index 00000000..33fdc07c
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Editor/Database/ScriptableTableEditor.cs
@@ -0,0 +1,40 @@
+using Game.Shared.Scriptable.Database;
+using UnityEditor;
+using UnityEngine;
+
+namespace Game.Shared.Scriptable.Database.EditorTools
+{
+ ///
+ /// ScriptableTable 派生アセット共通の Inspector。
+ /// 既定の描画に加え、未整列時の警告と「Sort & Validate」ボタンを表示する
+ /// (整列は OnValidate 自動実行をやめ、ここから手動実行する)。
+ ///
+ [CustomEditor(typeof(ScriptableTableBase), editorForChildClasses: true)]
+ public class ScriptableTableEditor : UnityEditor.Editor
+ {
+ public override void OnInspectorGUI()
+ {
+ DrawDefaultInspector();
+
+ var table = (ScriptableTableBase)target;
+ if (!table.EditorIsSorted())
+ {
+ EditorGUILayout.HelpBox(
+ "records が主キー昇順に整列されていません(空要素を含む場合あり)。実行時の検索が誤動作します。『Sort & Validate』を実行してください。",
+ MessageType.Warning);
+ }
+
+ EditorGUILayout.Space();
+ if (GUILayout.Button("Sort & Validate"))
+ {
+ foreach (var o in targets)
+ {
+ var tb = (ScriptableTableBase)o;
+ Undo.RecordObject(tb, "Sort & Validate");
+ tb.EditorSortAndValidate();
+ EditorUtility.SetDirty(tb);
+ }
+ }
+ }
+ }
+}
diff --git a/src/Game.Client/Assets/Programs/Editor/Database/ScriptableTableEditor.cs.meta b/src/Game.Client/Assets/Programs/Editor/Database/ScriptableTableEditor.cs.meta
new file mode 100644
index 00000000..d3d9f3c2
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Editor/Database/ScriptableTableEditor.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 181eb4cbf2803ec4092f39278b59ba78
\ No newline at end of file
diff --git a/src/Game.Client/Assets/Programs/Editor/Database/ScriptableTableGenerator.cs b/src/Game.Client/Assets/Programs/Editor/Database/ScriptableTableGenerator.cs
new file mode 100644
index 00000000..470365bc
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Editor/Database/ScriptableTableGenerator.cs
@@ -0,0 +1,191 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using System.Text;
+using Game.Shared.Scriptable.Database;
+using UnityEditor;
+using UnityEngine;
+
+namespace Game.Shared.Scriptable.Database.EditorTools
+{
+ ///
+ /// [Table] 付きマスターデータクラスを走査し、二次/複合キーの型付きファインダを持つ
+ /// 部分テーブルクラス({型名}Table.g.cs)を生成するエディタツール。
+ ///
+ 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);
+ int n = 0;
+ foreach (var type in TableTypes())
+ {
+ File.WriteAllText($"{OutDir}/{type.Name}Table.g.cs", Emit(type));
+ n++;
+ }
+ AssetDatabase.Refresh();
+ Debug.Log($"[TableGenerator] {n} table(s) generated.");
+ }
+
+ private static IEnumerable TableTypes() =>
+ AppDomain.CurrentDomain.GetAssemblies()
+ .SelectMany(SafeTypes)
+ .Where(t => t.IsClass && t.GetCustomAttribute() != null);
+
+ private static IEnumerable SafeTypes(Assembly a)
+ {
+ try { return a.GetTypes(); }
+ catch (ReflectionTypeLoadException e) { return e.Types.Where(x => x != null); }
+ }
+
+ private static (string name, Type type) NameType(MemberInfo m) =>
+ m is FieldInfo f ? (f.Name, f.FieldType) : (m.Name, ((PropertyInfo)m).PropertyType);
+
+ private sealed class Col
+ {
+ public string Name;
+ public Type Type;
+ public int Order;
+ public bool NonUnique;
+ }
+
+ private static string Emit(Type type)
+ {
+ var members = type.GetMembers(BindingFlags.Public | BindingFlags.Instance)
+ .Where(m => m is FieldInfo || (m is PropertyInfo p && p.CanRead))
+ .ToList();
+
+ var (pkName, pkType) = NameType(members.First(m => m.GetCustomAttribute() != null));
+ string pkT = TypeName(pkType);
+
+ var indices = new SortedDictionary>();
+ foreach (var m in members)
+ {
+ var (name, memberType) = NameType(m);
+ foreach (var sk in m.GetCustomAttributes())
+ {
+ if (!indices.TryGetValue(sk.IndexNo, out var list)) indices[sk.IndexNo] = list = new List();
+ list.Add(new Col { Name = name, Type = memberType, Order = sk.KeyOrder, NonUnique = sk.NonUnique });
+ }
+ }
+
+ string table = type.Name + "Table";
+ string ns = type.Namespace;
+ string menu = type.GetCustomAttribute().Name ?? type.Name;
+
+ string i0 = ns != null ? " " : ""; // クラスのインデント
+ string i1 = i0 + " "; // メンバのインデント
+ string i2 = i1 + " "; // メソッド本体のインデント
+
+ var sb = new StringBuilder();
+ sb.AppendLine("// ");
+ sb.AppendLine("using System;");
+ sb.AppendLine("using System.Collections.Generic;");
+ sb.AppendLine("using UnityEngine;");
+ sb.AppendLine("using Game.Shared.Scriptable.Database;");
+ sb.AppendLine();
+ if (ns != null)
+ {
+ sb.AppendLine($"namespace {ns}");
+ sb.AppendLine("{");
+ }
+ sb.AppendLine($"{i0}[CreateAssetMenu(menuName = \"{menu}\")]");
+ sb.AppendLine($"{i0}public sealed partial class {table} : ScriptableTable<{type.Name}>");
+ sb.AppendLine($"{i0}{{");
+
+ // PrimaryKey: records 自体が主キー昇順のソート済みインデックス(OnValidate で整列)。
+ sb.AppendLine($"{i1}// PrimaryKey: {pkName}");
+ sb.AppendLine($"{i1}private static readonly System.Func<{type.Name}, {pkT}> _pkSel = r => r.{pkName};");
+ sb.AppendLine($"{i1}private static readonly IComparer<{pkT}> _pkCmp = Comparer<{pkT}>.Default;");
+ EmitMethod(sb, i1, i2, type.Name, $"FindBy{pkName}", $"{pkT} key",
+ $"FindUnique(records, key, _pkSel, _pkCmp)");
+ EmitMethod(sb, i1, i2, "bool", $"TryFindBy{pkName}", $"{pkT} key, out {type.Name} record",
+ $"TryFindUnique(records, key, _pkSel, _pkCmp, out record)");
+ EmitMethod(sb, i1, i2, type.Name, $"FindClosestBy{pkName}", $"{pkT} key, bool lower = true",
+ $"FindClosest(records, key, _pkSel, _pkCmp, lower)");
+ EmitMethod(sb, i1, i2, $"ScriptableTableRecords<{type.Name}>", $"FindRangeBy{pkName}", $"{pkT} min, {pkT} max, bool asc = true",
+ $"FindRange(records, min, max, _pkSel, _pkCmp, asc)");
+
+ foreach (var kv in indices)
+ {
+ var cols = kv.Value.OrderBy(c => c.Order).ToList();
+ bool many = cols.Any(c => c.NonUnique);
+ string keyT = cols.Count == 1
+ ? TypeName(cols[0].Type)
+ : "(" + string.Join(", ", cols.Select(c => TypeName(c.Type))) + ")";
+ string sel = cols.Count == 1
+ ? $"r => r.{cols[0].Name}"
+ : "r => (" + string.Join(", ", cols.Select(c => "r." + c.Name)) + ")";
+ string s = string.Join("And", cols.Select(c => c.Name));
+ string selF = $"_sel{kv.Key}"; // セレクタ(静的・無キャプチャ)
+ string cmpF = $"_cmp{kv.Key}"; // コンパレータ(静的)
+ string idxF = $"_idx{kv.Key}"; // インデックス配列キャッシュ(インスタンス)
+ string idxP = $"Idx{kv.Key}";
+
+ sb.AppendLine();
+ sb.AppendLine($"{i1}// SecondaryKey index {kv.Key}: {string.Join(", ", cols.Select(c => c.Name))}");
+ sb.AppendLine($"{i1}private static readonly System.Func<{type.Name}, {keyT}> {selF} = {sel};");
+ sb.AppendLine($"{i1}private static readonly IComparer<{keyT}> {cmpF} = Comparer<{keyT}>.Default;");
+ sb.AppendLine($"{i1}private {type.Name}[] {idxF};");
+ sb.AppendLine($"{i1}private {type.Name}[] {idxP} => {idxF} ??= BuildSortedIndex(records, {selF}, {cmpF});");
+
+ if (many)
+ {
+ EmitMethod(sb, i1, i2, $"ScriptableTableRecords<{type.Name}>", $"FindBy{s}", $"{keyT} key",
+ $"FindMany({idxP}, {selF}, {cmpF}, key)");
+ EmitMethod(sb, i1, i2, $"ScriptableTableRecords<{type.Name}>", $"FindRangeBy{s}", $"{keyT} min, {keyT} max, bool asc = true",
+ $"FindRange({idxP}, min, max, {selF}, {cmpF}, asc)");
+ EmitMethod(sb, i1, i2, $"ScriptableTableRecords<{type.Name}>", $"FindClosestBy{s}", $"{keyT} key, bool lower = true",
+ $"FindManyClosest({idxP}, key, {selF}, {cmpF}, lower)");
+ }
+ else
+ {
+ EmitMethod(sb, i1, i2, type.Name, $"FindBy{s}", $"{keyT} key",
+ $"FindUnique({idxP}, key, {selF}, {cmpF})");
+ EmitMethod(sb, i1, i2, "bool", $"TryFindBy{s}", $"{keyT} key, out {type.Name} record",
+ $"TryFindUnique({idxP}, key, {selF}, {cmpF}, out record)");
+ }
+ }
+
+ sb.AppendLine();
+ sb.AppendLine("#if UNITY_EDITOR");
+ sb.AppendLine($"{i1}[ContextMenu(\"Sort & Validate\")]");
+ sb.AppendLine($"{i1}public override void EditorSortAndValidate()");
+ sb.AppendLine($"{i1}{{");
+ sb.AppendLine($"{i2}SortAndValidate(_pkSel, _pkCmp);");
+ foreach (var k in indices.Keys) sb.AppendLine($"{i2}_idx{k} = null;");
+ sb.AppendLine($"{i1}}}");
+ sb.AppendLine();
+ sb.AppendLine($"{i1}public override bool EditorIsSorted() => IsSortedByKey(_pkSel, _pkCmp);");
+ sb.AppendLine("#endif");
+ sb.AppendLine($"{i0}}}");
+ if (ns != null) sb.AppendLine("}");
+ return sb.ToString();
+ }
+
+ // 1 メソッドをブロック本体(複数行)で出力する。可読性のため式本体の 1 行化は避ける。
+ private static void EmitMethod(StringBuilder sb, string i1, string i2, string ret, string name, string args, string body)
+ {
+ sb.AppendLine();
+ sb.AppendLine($"{i1}public {ret} {name}({args})");
+ sb.AppendLine($"{i1}{{");
+ sb.AppendLine($"{i2}return {body};");
+ sb.AppendLine($"{i1}}}");
+ }
+
+ private static readonly Dictionary Builtin = new Dictionary
+ {
+ { typeof(int), "int" }, { typeof(long), "long" }, { typeof(short), "short" }, { typeof(byte), "byte" },
+ { typeof(bool), "bool" }, { typeof(float), "float" }, { typeof(double), "double" },
+ { typeof(string), "string" }, { typeof(char), "char" },
+ };
+
+ private static string TypeName(Type t) =>
+ Builtin.TryGetValue(t, out var s) ? s : "global::" + t.FullName.Replace('+', '.');
+ }
+}
diff --git a/src/Game.Client/Assets/Programs/Editor/Database/ScriptableTableGenerator.cs.meta b/src/Game.Client/Assets/Programs/Editor/Database/ScriptableTableGenerator.cs.meta
new file mode 100644
index 00000000..922cb926
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Editor/Database/ScriptableTableGenerator.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 614ca70e62c86aa4b9d7d736a8d0fde2
\ No newline at end of file
diff --git a/src/Game.Client/Assets/Programs/Editor/Tests/Shared/ScriptableDatabaseServiceTests.cs b/src/Game.Client/Assets/Programs/Editor/Tests/Shared/ScriptableDatabaseServiceTests.cs
new file mode 100644
index 00000000..90cbe28a
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Editor/Tests/Shared/ScriptableDatabaseServiceTests.cs
@@ -0,0 +1,39 @@
+using Cysharp.Threading.Tasks;
+using Game.Shared.Exceptions;
+using Game.Shared.Scriptable.Database;
+using Game.Shared.Services;
+using NUnit.Framework;
+using UnityEngine;
+
+namespace Game.Tests.Shared
+{
+ public class ScriptableDatabaseServiceTests
+ {
+ // Addressables 非依存で基底フロー(LoadAsync)を検証するための fake。
+ private sealed class FakeService : ScriptableDatabaseServiceBase
+ {
+ private readonly ScriptableDatabase _db;
+ public FakeService(ScriptableDatabase db) => _db = db;
+ protected override UniTask LoadDatabaseAssetAsync() => UniTask.FromResult(_db);
+ }
+
+ [Test]
+ public void LoadAsync_Success_SetsDatabase()
+ {
+ var db = ScriptableObject.CreateInstance();
+ var service = new FakeService(db);
+
+ service.LoadAsync().GetAwaiter().GetResult();
+
+ Assert.AreSame(db, service.Database);
+ }
+
+ [Test]
+ public void LoadAsync_NullAsset_Throws()
+ {
+ var service = new FakeService(null);
+
+ Assert.Throws(() => service.LoadAsync().GetAwaiter().GetResult());
+ }
+ }
+}
diff --git a/src/Game.Client/Assets/Programs/Editor/Tests/Shared/ScriptableDatabaseServiceTests.cs.meta b/src/Game.Client/Assets/Programs/Editor/Tests/Shared/ScriptableDatabaseServiceTests.cs.meta
new file mode 100644
index 00000000..8a88a091
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Editor/Tests/Shared/ScriptableDatabaseServiceTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 10f4b174cb52f1f418a04232246e374c
\ No newline at end of file
diff --git a/src/Game.Client/Assets/Programs/Editor/Tests/Shared/ScriptableTableGeneratorTests.cs b/src/Game.Client/Assets/Programs/Editor/Tests/Shared/ScriptableTableGeneratorTests.cs
new file mode 100644
index 00000000..ce43edee
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Editor/Tests/Shared/ScriptableTableGeneratorTests.cs
@@ -0,0 +1,74 @@
+using System.Collections.Generic;
+using System.Reflection;
+using Game.Shared.Scriptable.Database;
+using Game.Shared.Scriptable.Database.Samples;
+using NUnit.Framework;
+using UnityEngine;
+
+namespace Game.Tests.Shared
+{
+ public class ScriptableTableGeneratorTests
+ {
+ // sealed な生成テーブルには編集 API が無いため、protected records をリフレクションで投入する。
+ private static WeaponLevelMasterTable Make()
+ {
+ var t = ScriptableObject.CreateInstance();
+ var data = new[]
+ {
+ new WeaponLevelMaster { Id = 1, WeaponId = 10, Level = 1 },
+ new WeaponLevelMaster { Id = 2, WeaponId = 10, Level = 2 },
+ new WeaponLevelMaster { Id = 3, WeaponId = 20, Level = 1 },
+ new WeaponLevelMaster { Id = 4, WeaponId = 10, Level = 3 },
+ };
+ typeof(ScriptableTable)
+ .GetField("records", BindingFlags.Instance | BindingFlags.NonPublic)
+ .SetValue(t, data);
+ return t;
+ }
+
+ [Test]
+ public void FindById_Generated()
+ => Assert.AreEqual(20, Make().FindById(3).WeaponId);
+
+ [Test]
+ public void FindByWeaponId_Many()
+ => Assert.AreEqual(3, Make().FindByWeaponId(10).Count);
+
+ [Test]
+ public void FindByWeaponId_Single()
+ => Assert.AreEqual(1, Make().FindByWeaponId(20).Count);
+
+ [Test]
+ public void FindByWeaponId_None_IsEmpty()
+ => Assert.IsTrue(Make().FindByWeaponId(99).IsEmpty);
+
+ // index1 (WeaponId, Level) は複合ユニーク → 単一レコードを返す。
+ [Test]
+ public void FindByCompositeKey_Exact()
+ => Assert.AreEqual(2, Make().FindByWeaponIdAndLevel((10, 2)).Id);
+
+ [Test]
+ public void TryFindByCompositeKey_Hit()
+ {
+ Assert.IsTrue(Make().TryFindByWeaponIdAndLevel((10, 2), out var r));
+ Assert.AreEqual(2, r.Id);
+ }
+
+ [Test]
+ public void FindByCompositeKey_Missing_Throws()
+ => Assert.Throws(() => Make().FindByWeaponIdAndLevel((10, 99)));
+
+ [Test]
+ public void FindRangeByWeaponId()
+ => Assert.AreEqual(3, Make().FindRangeByWeaponId(10, 10).Count);
+
+ [Test]
+ public void FindClosestByWeaponId_Lower_ReturnsAllOfClosestKey()
+ {
+ // WeaponId=15 は無いので最近傍(下側)は 10。WeaponId=10 の全 3 件が返る(非ユニーク)。
+ var range = Make().FindClosestByWeaponId(15, lower: true);
+ Assert.AreEqual(3, range.Count);
+ Assert.AreEqual(10, range[0].WeaponId);
+ }
+ }
+}
diff --git a/src/Game.Client/Assets/Programs/Editor/Tests/Shared/ScriptableTableGeneratorTests.cs.meta b/src/Game.Client/Assets/Programs/Editor/Tests/Shared/ScriptableTableGeneratorTests.cs.meta
new file mode 100644
index 00000000..6396a1ec
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Editor/Tests/Shared/ScriptableTableGeneratorTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 127bc4c7772849042a1dbdf8919fddf8
\ 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
new file mode 100644
index 00000000..3870a962
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Editor/Tests/Shared/ScriptableTableTests.cs
@@ -0,0 +1,180 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.RegularExpressions;
+using Game.Shared.Scriptable.Database;
+using NUnit.Framework;
+using UnityEngine;
+using UnityEngine.TestTools;
+
+namespace Game.Tests.Shared
+{
+ public class ScriptableTableTests
+ {
+ [System.Serializable]
+ private class Rec
+ {
+ public int id;
+ public string name;
+ public Rec(int id, string name) { this.id = id; this.name = name; }
+ }
+
+ // 生成器を使わない手書きテーブル。基底のキー非依存コアを直接呼んで主キー検索を実装する
+ // (=生成器が自動化しているのと同じ配線を手書きで再現し、コアの正しさを検証する)。
+ private class TestScriptableTable : ScriptableTable
+ {
+ private static readonly Func Sel = r => r.id;
+ private static readonly IComparer Cmp = Comparer.Default;
+
+ public Rec FindById(int id) => FindUnique(records, id, Sel, Cmp);
+ public bool TryFindById(int id, out Rec record) => TryFindUnique(records, id, Sel, Cmp, out record);
+ public Rec FindClosestById(int id, bool selectLower = true) => FindClosest(records, id, Sel, Cmp, selectLower);
+ public ScriptableTableRecords FindRangeById(int min, int max, bool ascending = true) => FindRange(records, min, max, Sel, Cmp, ascending);
+
+ public void Set(params Rec[] rs) => records = rs; // 主キー昇順で渡す
+
+ public override void EditorSortAndValidate() => SortAndValidate(Sel, Cmp);
+ public override bool EditorIsSorted() => IsSortedByKey(Sel, Cmp);
+ public void Validate() => EditorSortAndValidate(); // 既存テストの呼び口を維持
+ }
+
+ private static TestScriptableTable Make(params Rec[] rs)
+ {
+ var t = ScriptableObject.CreateInstance();
+ t.Set(rs);
+ return t;
+ }
+
+ [Test]
+ public void FindById_Existing_ReturnsRecord()
+ => Assert.AreEqual("b", Make(new Rec(1, "a"), new Rec(3, "b")).FindById(3).name);
+
+ [Test]
+ public void FindById_Missing_Throws()
+ => Assert.Throws(() => Make(new Rec(1, "a"), new Rec(3, "b")).FindById(2));
+
+ [Test]
+ public void TryFindById_Existing_ReturnsTrue()
+ {
+ Assert.IsTrue(Make(new Rec(5, "x")).TryFindById(5, out var r));
+ Assert.AreEqual("x", r.name);
+ }
+
+ [Test]
+ public void TryFindById_Missing_ReturnsFalse()
+ {
+ Assert.IsFalse(Make(new Rec(1, "a")).TryFindById(9, out var r));
+ Assert.IsNull(r);
+ }
+
+ [Test]
+ public void FindRangeById_Inclusive()
+ {
+ var range = Make(new Rec(1, "a"), new Rec(2, "b"), new Rec(3, "c"), new Rec(4, "d")).FindRangeById(2, 3);
+ Assert.AreEqual(2, range.Count);
+ Assert.AreEqual("b", range[0].name);
+ Assert.AreEqual("c", range[1].name);
+ }
+
+ [Test]
+ public void FindRangeById_Descending()
+ {
+ var range = Make(new Rec(1, "a"), new Rec(2, "b"), new Rec(3, "c")).FindRangeById(1, 3, ascending: false);
+ Assert.AreEqual("c", range[0].name);
+ Assert.AreEqual("a", range[2].name);
+ }
+
+ [Test]
+ public void FindRangeById_Empty()
+ => Assert.AreEqual(0, Make(new Rec(1, "a"), new Rec(2, "b")).FindRangeById(10, 20).Count);
+
+ [Test]
+ public void FindClosestById_Lower()
+ => Assert.AreEqual(3, Make(new Rec(1, "a"), new Rec(3, "c"), new Rec(5, "e")).FindClosestById(4, selectLower: true).id);
+
+ [Test]
+ public void FindClosestById_Upper()
+ => Assert.AreEqual(5, Make(new Rec(1, "a"), new Rec(3, "c"), new Rec(5, "e")).FindClosestById(4, selectLower: false).id);
+
+ [Test]
+ public void FindClosestById_Exact()
+ => Assert.AreEqual(3, Make(new Rec(1, "a"), new Rec(3, "c"), new Rec(5, "e")).FindClosestById(3).id);
+
+ // floor/ceiling 境界:該当側に要素が無ければ null(MasterMemory 準拠、端へクランプしない)。
+ [Test]
+ public void FindClosestById_BelowAll_Lower_ReturnsNull()
+ => Assert.IsNull(Make(new Rec(3, "c"), new Rec(5, "e")).FindClosestById(1, selectLower: true));
+
+ [Test]
+ public void FindClosestById_BelowAll_Upper_ReturnsFirst()
+ => Assert.AreEqual(3, Make(new Rec(3, "c"), new Rec(5, "e")).FindClosestById(1, selectLower: false).id);
+
+ [Test]
+ public void FindClosestById_AboveAll_Upper_ReturnsNull()
+ => Assert.IsNull(Make(new Rec(1, "a"), new Rec(3, "c")).FindClosestById(9, selectLower: false));
+
+ [Test]
+ public void FindClosestById_AboveAll_Lower_ReturnsLast()
+ => Assert.AreEqual(3, Make(new Rec(1, "a"), new Rec(3, "c")).FindClosestById(9, selectLower: true).id);
+
+ [Test]
+ public void Linq_Where_Filters_OnAllView()
+ => Assert.AreEqual(2, Make(new Rec(1, "a"), new Rec(2, "b"), new Rec(3, "a")).All.Where(r => r.name == "a").Count());
+
+ [Test]
+ public void Foreach_Iterates_OnAllView()
+ {
+ int n = 0;
+ foreach (var _ in Make(new Rec(1, "a"), new Rec(2, "b")).All) n++;
+ Assert.AreEqual(2, n);
+ }
+
+ [Test]
+ public void AllReverse_IteratesDescending()
+ {
+ var t = Make(new Rec(1, "a"), new Rec(2, "b"), new Rec(3, "c"));
+ Assert.AreEqual("c", t.AllReverse[0].name);
+ Assert.AreEqual("a", t.AllReverse[2].name);
+ }
+
+ [Test]
+ public void SortAndValidate_SortsAscending_PreservingAll()
+ {
+ var t = Make(new Rec(3, "c"), new Rec(1, "a"), new Rec(2, "b")); // 未ソート入力
+ t.Validate();
+ Assert.AreEqual(3, t.All.Count);
+ Assert.AreEqual(1, t.All[0].id);
+ Assert.AreEqual(2, t.All[1].id);
+ Assert.AreEqual(3, t.All[2].id);
+ }
+
+ [Test]
+ public void SortAndValidate_RemovesNullSlots()
+ {
+ var t = Make(new Rec(2, "b"), null, new Rec(1, "a")); // 空スロット混在
+ t.Validate();
+ Assert.AreEqual(2, t.All.Count); // null は除去され切り詰められる
+ Assert.AreEqual(1, t.All[0].id);
+ Assert.AreEqual(2, t.All[1].id);
+ }
+
+ [Test]
+ public void SortAndValidate_WarnsOnDuplicateKey()
+ {
+ var t = Make(new Rec(1, "a"), new Rec(1, "dup"), new Rec(2, "b"));
+ LogAssert.Expect(LogType.Warning, new Regex("主キー 1 が重複"));
+ t.Validate();
+ Assert.AreEqual(3, t.All.Count); // 重複は警告のみで除去はしない
+ }
+
+ [Test]
+ public void EditorIsSorted_DetectsUnsorted_ThenSortFixes()
+ {
+ var t = Make(new Rec(3, "c"), new Rec(1, "a"), new Rec(2, "b")); // 未整列
+ Assert.IsFalse(t.EditorIsSorted());
+ t.EditorSortAndValidate();
+ Assert.IsTrue(t.EditorIsSorted());
+ Assert.AreEqual(1, t.All[0].id);
+ }
+ }
+}
diff --git a/src/Game.Client/Assets/Programs/Editor/Tests/Shared/ScriptableTableTests.cs.meta b/src/Game.Client/Assets/Programs/Editor/Tests/Shared/ScriptableTableTests.cs.meta
new file mode 100644
index 00000000..ddedbc10
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Editor/Tests/Shared/ScriptableTableTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: d36b72d6927daa54599afc6232787152
\ No newline at end of file
diff --git a/src/Game.Client/Assets/Programs/Runtime/MVC/Core/Services/ScriptableDatabaseService.cs b/src/Game.Client/Assets/Programs/Runtime/MVC/Core/Services/ScriptableDatabaseService.cs
new file mode 100644
index 00000000..e14a7c61
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Runtime/MVC/Core/Services/ScriptableDatabaseService.cs
@@ -0,0 +1,50 @@
+using Cysharp.Threading.Tasks;
+using Game.Shared.Exceptions;
+using Game.Shared.Scriptable.Database;
+using Game.Shared.Services;
+
+namespace Game.Core.Services
+{
+ ///
+ /// MVC 用 ScriptableDatabase ロードサービス。GameServiceManager 経由で使用。
+ /// MVC MasterDataService と同じく Addressables からコンテナ資産をロードする。
+ ///
+ public class ScriptableDatabaseService : ScriptableDatabaseServiceBase, IGameService
+ {
+ private const string AssetAddress = "ScriptableDatabase";
+
+ private IAddressableAssetService _assetService;
+
+ public ScriptableDatabaseService()
+ {
+ }
+
+ public ScriptableDatabaseService(IAddressableAssetService assetService)
+ {
+ _assetService = assetService;
+ }
+
+ public void Startup()
+ {
+ }
+
+ public void Shutdown()
+ {
+ }
+
+ protected override async UniTask LoadDatabaseAssetAsync()
+ {
+ _assetService ??= GameServiceManager.Get();
+
+ if (_assetService == null)
+ {
+ throw new DependencyInjectionException(
+ typeof(IAddressableAssetService),
+ DIErrorType.ServiceNotRegistered,
+ "IAddressableAssetService not available in ScriptableDatabaseService");
+ }
+
+ return await _assetService.LoadAssetAsync(AssetAddress);
+ }
+ }
+}
diff --git a/src/Game.Client/Assets/Programs/Runtime/MVC/Core/Services/ScriptableDatabaseService.cs.meta b/src/Game.Client/Assets/Programs/Runtime/MVC/Core/Services/ScriptableDatabaseService.cs.meta
new file mode 100644
index 00000000..97ff65fc
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Runtime/MVC/Core/Services/ScriptableDatabaseService.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: f9e05b7a16d79cc489aa2b55272cabfb
\ No newline at end of file
diff --git a/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/HorrorGameLauncher.cs b/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/HorrorGameLauncher.cs
index 939f61e3..4e8f9478 100644
--- a/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/HorrorGameLauncher.cs
+++ b/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/HorrorGameLauncher.cs
@@ -22,19 +22,19 @@ public async UniTask StartupAsync()
GameServiceManager.Instance.StartUp();
// 2. 各種サービス取得・初期化
+ var dbService = GameServiceManager.Get();
GameServiceManager.Add();
- var audioService = GameServiceManager.Get();
+ GameServiceManager.Add();
var gameSceneService = GameServiceManager.Get();
// 3. 共通オブジェクト読み込み
await HorrorGameRootController.LoadAssetAsync();
- // 4. オーディオ設定読み込み
- var saveDataStorage = new SaveDataStorage();
- var audioSaveService = new AudioSaveService(saveDataStorage, audioService);
- await audioSaveService.LoadAsync();
+ // 4. マスターデータ
+ await dbService.LoadAsync();
- // 4-2. オプション設定: ロード → 共有登録 → 起動時の静的適用
+ // 5. オプション設定: ロード → 共有登録 → 起動時の静的適用
+ var saveDataStorage = new SaveDataStorage();
var optionSaveService = new HorrorOptionSaveService(saveDataStorage);
await optionSaveService.LoadAsync();
GameServiceManager.Register(optionSaveService);
diff --git a/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/UI/HorrorGameRootController.cs b/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/UI/HorrorGameRootController.cs
index c16ffebc..b9426001 100644
--- a/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/UI/HorrorGameRootController.cs
+++ b/src/Game.Client/Assets/Programs/Runtime/MVC/Horror/UI/HorrorGameRootController.cs
@@ -19,7 +19,7 @@ public class HorrorGameRootController : MonoBehaviour
{
private const string Address = "HorrorGameRootController";
- private static HorrorGameRootController _instance;
+ private static GameObject _instance;
public static async UniTask LoadAssetAsync()
{
@@ -31,7 +31,7 @@ public static async UniTask LoadAssetAsync()
var go = Instantiate(prefab);
if (go.TryGetComponent(out var gameRootController))
{
- _instance = gameRootController;
+ _instance = go;
DontDestroyOnLoad(go);
gameRootController.Initialize();
}
@@ -45,7 +45,6 @@ public static async UniTask LoadAssetAsync()
public static async UniTask UnloadAsync()
{
_instance.SafeDestroy();
- _instance = null;
await UniTask.Yield();
}
@@ -53,31 +52,28 @@ public static async UniTask UnloadAsync()
[SerializeField] private PlayerInput _playerInput;
[SerializeField] private Image _fadeImage;
- private IMessagePipeService _messagePipeService;
- private IMessagePipeService MessagePipeService => _messagePipeService ??= GameServiceManager.Get();
-
- private InputSystemService _inputService;
- private InputSystemService InputService => _inputService ??= GameServiceManager.Get();
-
private void Initialize()
{
// playerInput.controlsChangedEvent.AddListener(UpdateControlScheme);
// InputSystem.onEvent += (inputEventPtr, device) => { Debug.Log($"InputSystem InputDevice: {device}"); };
// Keyboard.current / Mouse.current / Gamepad.current / Pointer.current / Touchscreen.current;
// playerInput.SwitchCurrentControlScheme(InputConstants.Gamepad);
+
+ var inputService = GameServiceManager.Get();
_playerInput.controlsChangedEvent.AsObservable()
- .Subscribe(x => InputService.UpdateControlScheme(x.currentControlScheme))
+ .Subscribe(x => inputService.UpdateControlScheme(x.currentControlScheme))
.AddTo(this);
// GameScene
- MessagePipeService.SubscribeAsync(MessageKey.GameScene.FadeOut, async (_, _) =>
+ var messagePipeService = GameServiceManager.Get();
+ messagePipeService.SubscribeAsync(MessageKey.GameScene.FadeOut, async (_, _) =>
{
var tcs = new UniTaskCompletionSource();
DoFade(UIAnimationConstants.AlphaOpaque, UIAnimationConstants.SceneTransitionFadeInDuration, tcs);
await tcs.Task;
})
.AddTo(this);
- MessagePipeService.SubscribeAsync(MessageKey.GameScene.FadeIn, async (_, _) =>
+ messagePipeService.SubscribeAsync(MessageKey.GameScene.FadeIn, async (_, _) =>
{
var tcs = new UniTaskCompletionSource();
DoFade(UIAnimationConstants.AlphaTransparent, UIAnimationConstants.SceneTransitionFadeOutDuration, tcs);
diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable.meta b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable.meta
new file mode 100644
index 00000000..6dd829ac
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 916f4e0a83e6d4f41af1ac8e002fef09
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database.meta b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database.meta
new file mode 100644
index 00000000..ba60eb08
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: daa634374ed976f4f9120ded9618ad49
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/BinarySearch.cs b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/BinarySearch.cs
new file mode 100644
index 00000000..1744ac90
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/BinarySearch.cs
@@ -0,0 +1,66 @@
+using System;
+using System.Collections.Generic;
+
+namespace Game.Shared.Scriptable.Database
+{
+ /// ソート済み配列への二分探索群。FindById・範囲・近傍はこれらで構成する。
+ internal static class BinarySearch
+ {
+ /// key と一致する最初の要素の index。見つからなければ -1。
+ public static int FindFirst(T[] a, TKey key, Func sel, IComparer cmp)
+ {
+ int lo = 0, hi = a.Length - 1;
+ while (lo <= hi)
+ {
+ int mid = lo + ((hi - lo) >> 1);
+ int c = cmp.Compare(sel(a[mid]), key);
+ if (c == 0) return mid;
+ if (c < 0) lo = mid + 1; else hi = mid - 1;
+ }
+ return -1;
+ }
+
+ /// sel(a[i]) >= key となる最小 index(無ければ a.Length)。
+ public static int LowerBound(T[] a, TKey key, Func sel, IComparer cmp)
+ {
+ int lo = 0, hi = a.Length;
+ while (lo < hi)
+ {
+ int mid = lo + ((hi - lo) >> 1);
+ if (cmp.Compare(sel(a[mid]), key) < 0) lo = mid + 1; else hi = mid;
+ }
+ return lo;
+ }
+
+ /// sel(a[i]) > key となる最小 index(無ければ a.Length)。
+ public static int UpperBound(T[] a, TKey key, Func sel, IComparer cmp)
+ {
+ int lo = 0, hi = a.Length;
+ while (lo < hi)
+ {
+ int mid = lo + ((hi - lo) >> 1);
+ if (cmp.Compare(sel(a[mid]), key) <= 0) lo = mid + 1; else hi = mid;
+ }
+ return lo;
+ }
+
+ ///
+ /// 近傍要素の index を返す(MasterMemory.BinarySearch.FindClosest 準拠)。
+ /// 完全一致があればその index。無ければ selectLower=true で key 未満の最大要素の index(無ければ -1)、
+ /// selectLower=false で key 超の最小要素の index(無ければ a.Length)。空配列は -1。
+ ///
+ public static int FindClosest(T[] a, TKey key, Func sel, IComparer cmp, bool selectLower)
+ {
+ if (a.Length == 0) return -1;
+ int lo = -1, hi = a.Length;
+ while (hi - lo > 1)
+ {
+ int mid = lo + ((hi - lo) >> 1);
+ int found = cmp.Compare(sel(a[mid]), key);
+ if (found == 0) { lo = hi = mid; break; }
+ if (found >= 1) hi = mid; else lo = mid;
+ }
+ return selectLower ? lo : hi;
+ }
+ }
+}
diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/BinarySearch.cs.meta b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/BinarySearch.cs.meta
new file mode 100644
index 00000000..c101b4ea
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/BinarySearch.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: c3c839d9a3ca6f340b6678d628dfdc8d
\ No newline at end of file
diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/Generated.meta b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/Generated.meta
new file mode 100644
index 00000000..e678f4a1
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/Generated.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: a282f73dc0f5ef146a7101c2f9605616
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/Generated/ScriptableDatabase.g.cs b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/Generated/ScriptableDatabase.g.cs
new file mode 100644
index 00000000..3076e03c
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/Generated/ScriptableDatabase.g.cs
@@ -0,0 +1,13 @@
+//
+using UnityEngine;
+
+namespace Game.Shared.Scriptable.Database
+{
+ /// 全 ScriptableTable を集約するコンテナ(Build コマンドで生成)。
+ [CreateAssetMenu(menuName = "Scriptable Database/Database")]
+ public sealed partial class ScriptableDatabase : ScriptableObject
+ {
+ [SerializeField] private global::Game.Shared.Scriptable.Database.Samples.WeaponLevelMasterTable weaponLevelMasterTable;
+ public global::Game.Shared.Scriptable.Database.Samples.WeaponLevelMasterTable WeaponLevelMasterTable => weaponLevelMasterTable;
+ }
+}
diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/Generated/ScriptableDatabase.g.cs.meta b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/Generated/ScriptableDatabase.g.cs.meta
new file mode 100644
index 00000000..20352c2b
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/Generated/ScriptableDatabase.g.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: f92d211b74040944ba7223b31e695f31
\ No newline at end of file
diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/Generated/WeaponLevelMasterTable.g.cs b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/Generated/WeaponLevelMasterTable.g.cs
new file mode 100644
index 00000000..be50e20f
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/Generated/WeaponLevelMasterTable.g.cs
@@ -0,0 +1,85 @@
+//
+using System;
+using System.Collections.Generic;
+using UnityEngine;
+using Game.Shared.Scriptable.Database;
+
+namespace Game.Shared.Scriptable.Database.Samples
+{
+ [CreateAssetMenu(menuName = "Scriptable Database/Table/WeaponLevelMasterTable")]
+ public sealed partial class WeaponLevelMasterTable : ScriptableTable
+ {
+ // PrimaryKey: Id
+ private static readonly System.Func _pkSel = r => r.Id;
+ private static readonly IComparer _pkCmp = Comparer.Default;
+
+ public WeaponLevelMaster FindById(int key)
+ {
+ return FindUnique(records, key, _pkSel, _pkCmp);
+ }
+
+ public bool TryFindById(int key, out WeaponLevelMaster record)
+ {
+ return TryFindUnique(records, key, _pkSel, _pkCmp, out record);
+ }
+
+ public WeaponLevelMaster FindClosestById(int key, bool lower = true)
+ {
+ return FindClosest(records, key, _pkSel, _pkCmp, lower);
+ }
+
+ public ScriptableTableRecords FindRangeById(int min, int max, bool asc = true)
+ {
+ return FindRange(records, min, max, _pkSel, _pkCmp, asc);
+ }
+
+ // SecondaryKey index 0: WeaponId
+ private static readonly System.Func _sel0 = r => r.WeaponId;
+ private static readonly IComparer _cmp0 = Comparer.Default;
+ private WeaponLevelMaster[] _idx0;
+ private WeaponLevelMaster[] Idx0 => _idx0 ??= BuildSortedIndex(records, _sel0, _cmp0);
+
+ public ScriptableTableRecords FindByWeaponId(int key)
+ {
+ return FindMany(Idx0, _sel0, _cmp0, key);
+ }
+
+ public ScriptableTableRecords FindRangeByWeaponId(int min, int max, bool asc = true)
+ {
+ return FindRange(Idx0, min, max, _sel0, _cmp0, asc);
+ }
+
+ public ScriptableTableRecords FindClosestByWeaponId(int key, bool lower = true)
+ {
+ return FindManyClosest(Idx0, key, _sel0, _cmp0, lower);
+ }
+
+ // SecondaryKey index 1: WeaponId, Level
+ private static readonly System.Func _sel1 = r => (r.WeaponId, r.Level);
+ private static readonly IComparer<(int, int)> _cmp1 = Comparer<(int, int)>.Default;
+ private WeaponLevelMaster[] _idx1;
+ private WeaponLevelMaster[] Idx1 => _idx1 ??= BuildSortedIndex(records, _sel1, _cmp1);
+
+ public WeaponLevelMaster FindByWeaponIdAndLevel((int, int) key)
+ {
+ return FindUnique(Idx1, key, _sel1, _cmp1);
+ }
+
+ public bool TryFindByWeaponIdAndLevel((int, int) key, out WeaponLevelMaster record)
+ {
+ return TryFindUnique(Idx1, key, _sel1, _cmp1, out record);
+ }
+
+#if UNITY_EDITOR
+ [ContextMenu("Sort & Validate")]
+ public override void EditorSortAndValidate()
+ {
+ SortAndValidate(_pkSel, _pkCmp);
+ _idx0 = null;
+ _idx1 = null;
+ }
+
+ public override bool EditorIsSorted() => IsSortedByKey(_pkSel, _pkCmp);
+#endif
+ }
+}
diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/Generated/WeaponLevelMasterTable.g.cs.meta b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/Generated/WeaponLevelMasterTable.g.cs.meta
new file mode 100644
index 00000000..4f28e425
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/Generated/WeaponLevelMasterTable.g.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: d5228feb3f37e5941a71b81755654ae9
\ No newline at end of file
diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/Samples.meta b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/Samples.meta
new file mode 100644
index 00000000..ab51906b
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/Samples.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: ef138668821287a47ac3b7ceb701bbbf
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/Samples/WeaponLevelMaster.cs b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/Samples/WeaponLevelMaster.cs
new file mode 100644
index 00000000..c8935662
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/Samples/WeaponLevelMaster.cs
@@ -0,0 +1,55 @@
+using System;
+using UnityEngine;
+
+namespace Game.Shared.Scriptable.Database.Samples
+{
+ ///
+ /// テーブルシステムの動作確認用サンプル。主キー+単一二次キー+複合二次キーを持つ。
+ /// (Unity が List 要素としてシリアライズできるよう [Serializable])
+ ///
+ [Serializable]
+ [ScriptableTable(Name = "Scriptable Database/Table/WeaponLevelMasterTable")]
+ public partial class WeaponLevelMaster
+ {
+ #region SerializeField
+
+ [SerializeField] private int _id;
+ [SerializeField] private int _weaponId;
+ [SerializeField] private int _level;
+ [SerializeField] private string _assetName;
+
+ #endregion
+
+ #region Database
+
+ [PrimaryKey]
+ public int Id
+ {
+ get => _id;
+ set => _id = value;
+ }
+
+ [SecondaryKey(0, nonUnique: true)] // WeaponId 単独: 非ユニーク
+ [SecondaryKey(1, keyOrder: 0)] // 複合 index1: ユニーク
+ public int WeaponId
+ {
+ get => _weaponId;
+ set => _weaponId = value;
+ }
+
+ [SecondaryKey(1, keyOrder: 1)]
+ public int Level
+ {
+ get => _level;
+ set => _level = value;
+ }
+
+ public string AssetName
+ {
+ get => _assetName;
+ set => _assetName = value;
+ }
+
+ #endregion
+ }
+}
diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/Samples/WeaponLevelMaster.cs.meta b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/Samples/WeaponLevelMaster.cs.meta
new file mode 100644
index 00000000..3f592102
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/Samples/WeaponLevelMaster.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 4857ec9cb4a937e4c912cbbc4437497e
\ No newline at end of file
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
new file mode 100644
index 00000000..11acf7b7
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/ScriptableTable.cs
@@ -0,0 +1,131 @@
+using System;
+using System.Collections.Generic;
+using UnityEngine;
+
+namespace Game.Shared.Scriptable.Database
+{
+ ///
+ /// ScriptableObject ベースのテーブル基底。レコードを主キー昇順のソート済み配列で保持する。
+ /// 全件列挙・LINQ は が返すビュー()上で行う。
+ /// 主キー(FindById 等)・二次/複合キーの型付きファインダ、および編集時の
+ /// 主キー整列(OnValidate)は、いずれも生成 partial が本クラスの共通コアを呼んで実装する。
+ /// 本基底はキー型・レコード型(参照/値)に一切依存しない。
+ /// not-found 表現は MasterMemory に準拠: は既定で例外、
+ /// は default+false、 は空時に default。
+ ///
+ public abstract class ScriptableTable : ScriptableTableBase
+ {
+ [SerializeField] protected TRecord[] records = Array.Empty();
+
+ public ScriptableTableRecords All => new(records, 0, records.Length - 1, true);
+ public ScriptableTableRecords AllReverse => new(records, 0, records.Length - 1, false);
+
+ protected static TRecord[] BuildSortedIndex(TRecord[] src, Func sel, IComparer cmp)
+ {
+ var items = new TRecord[src.Length];
+ var keys = new TKey[src.Length];
+ for (int i = 0; i < src.Length; i++)
+ {
+ items[i] = src[i];
+ keys[i] = sel(src[i]);
+ }
+ Array.Sort(keys, items, 0, items.Length, cmp);
+ return items;
+ }
+
+ /// 一意キーで検索。見つからなければ既定で例外。throwIfNotFound=false なら default。
+ protected static TRecord FindUnique(TRecord[] idx, TKey key, Func sel, IComparer cmp, bool throwIfNotFound = true)
+ {
+ int i = BinarySearch.FindFirst(idx, key, sel, cmp);
+ if (i >= 0) return idx[i];
+ if (throwIfNotFound) ThrowKeyNotFound(key);
+ return default;
+ }
+
+ protected static bool TryFindUnique(TRecord[] idx, TKey key, Func sel, IComparer cmp, out TRecord record)
+ {
+ int i = BinarySearch.FindFirst(idx, key, sel, cmp);
+ if (i >= 0) { record = idx[i]; return true; }
+ record = default;
+ return false;
+ }
+
+ protected static ScriptableTableRecords FindMany(TRecord[] idx, Func sel, IComparer cmp, TKey key)
+ {
+ int lo = BinarySearch.LowerBound(idx, key, sel, cmp);
+ int hi = BinarySearch.UpperBound(idx, key, sel, cmp) - 1;
+ return new ScriptableTableRecords(idx, lo, hi, true);
+ }
+
+ ///
+ /// ユニークキーの近傍 1 件
+ /// floor/ceiling 意味論:該当側に要素が無ければ default。
+ ///
+ protected static TRecord FindClosest(TRecord[] idx, TKey key, Func sel, IComparer cmp, bool selectLower)
+ {
+ int i = BinarySearch.FindClosest(idx, key, sel, cmp, selectLower);
+ return (i >= 0 && i < idx.Length) ? idx[i] : default;
+ }
+
+ ///
+ /// 非ユニークキーの近傍:最近傍キー値を求め、そのキーを持つ全件を返す
+ ///
+ protected static ScriptableTableRecords FindManyClosest(TRecord[] idx, TKey key, Func sel, IComparer cmp, bool selectLower)
+ {
+ int i = BinarySearch.FindClosest(idx, key, sel, cmp, selectLower);
+ if (i < 0 || i >= idx.Length) return default;
+ return FindMany(idx, sel, cmp, sel(idx[i]));
+ }
+
+ protected static ScriptableTableRecords FindRange(TRecord[] idx, TKey min, TKey max, Func sel, IComparer cmp, bool ascending)
+ {
+ int lo = BinarySearch.LowerBound(idx, min, sel, cmp);
+ int hi = BinarySearch.UpperBound(idx, max, sel, cmp) - 1;
+ return new ScriptableTableRecords(idx, lo, hi, ascending);
+ }
+
+ private static TRecord ThrowKeyNotFound(TKey key) => throw new KeyNotFoundException($"DataType: {typeof(TRecord).FullName}, Key: {key}");
+
+#if UNITY_EDITOR
+ ///
+ /// 生成 partial の OnValidate が主キーセレクタ付きで呼ぶ、編集時の整列+重複警告コア。
+ /// records を 昇順へ整列し、重複キーを警告する。
+ ///
+ /// records が 昇順・空要素なしに整っているか(手動整列の要否判定用)。
+ protected bool IsSortedByKey(Func sel, IComparer cmp)
+ {
+ if (records == null) return true;
+ for (int i = 1; i < records.Length; i++)
+ {
+ if (records[i - 1] == null || records[i] == null) return false;
+ if (cmp.Compare(sel(records[i - 1]), sel(records[i])) > 0) return false;
+ }
+ return true;
+ }
+
+ protected void SortAndValidate(Func sel, IComparer cmp)
+ {
+ if (records == null) return;
+
+ // 編集中の空スロット(null)を非 null を前方へ詰めて取り除く(切り詰めるため All/索引に null が漏れない)。
+ // 値型レコードでは null が無いため count==Length となり Resize は no-op。
+ int count = 0;
+ for (int i = 0; i < records.Length; i++)
+ if (records[i] != null) records[count++] = records[i];
+ if (count != records.Length) Array.Resize(ref records, count);
+
+ // キーを一度だけ計算して key/value ソート(比較ごとの再評価を避ける)。
+ var keys = new TKey[count];
+ for (int i = 0; i < count; i++) keys[i] = sel(records[i]);
+ Array.Sort(keys, records, 0, count, cmp);
+
+ // ソート済みキー配列で隣接重複を検出(sel の再評価なし)。
+ for (int i = 1; i < count; i++)
+ {
+ if (cmp.Compare(keys[i], keys[i - 1]) == 0)
+ Debug.LogWarning($"[{name}] 主キー {keys[i]} が重複しています。", this);
+ }
+ }
+#endif
+ }
+}
diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/ScriptableTable.cs.meta b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/ScriptableTable.cs.meta
new file mode 100644
index 00000000..85af4ad2
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/ScriptableTable.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: b9a73bdec5424f949b7d05ddb87d4d64
\ No newline at end of file
diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/ScriptableTableAttribute.cs b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/ScriptableTableAttribute.cs
new file mode 100644
index 00000000..6027b2a6
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/ScriptableTableAttribute.cs
@@ -0,0 +1,41 @@
+using System;
+
+namespace Game.Shared.Scriptable.Database
+{
+ /// テーブル化するマスターデータクラスに付与する。生成テーブルの CreateAssetMenu 名に使う。
+ [AttributeUsage(AttributeTargets.Class)]
+ public sealed class ScriptableTableAttribute : Attribute
+ {
+ public string Name { get; set; }
+ }
+
+ /// int 主キー。1 つのクラスに 1 つ。
+ [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
+ public sealed class PrimaryKeyAttribute : Attribute
+ {
+ }
+
+ ///
+ /// 二次キー。同じ を複数プロパティに付与すると複合キー( で列順)。
+ /// AllowMultiple のため 1 プロパティが複数インデックスに参加できる。
+ /// 非ユニーク性はこの属性の で **index ごとに** 指定する
+ /// (プロパティ単位の別属性にすると複数 index に波及するため。リフレクションは角括弧グループを区別できない)。
+ /// 複合キーは構成列のいずれかが =true なら index 全体が非ユニーク(OR 集約)。
+ ///
+ [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field, AllowMultiple = true)]
+ public sealed class SecondaryKeyAttribute : Attribute
+ {
+ public int IndexNo { get; }
+ public int KeyOrder { get; }
+
+ /// この index が重複値を許す(多件ヒット)か。無指定は一意。
+ public bool NonUnique { get; }
+
+ public SecondaryKeyAttribute(int indexNo, int keyOrder = 0, bool nonUnique = false)
+ {
+ IndexNo = indexNo;
+ KeyOrder = keyOrder;
+ NonUnique = nonUnique;
+ }
+ }
+}
diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/ScriptableTableAttribute.cs.meta b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/ScriptableTableAttribute.cs.meta
new file mode 100644
index 00000000..a0da1a9b
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/ScriptableTableAttribute.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 6c31d41b4cc3f5644b6dbd078b47e89b
\ No newline at end of file
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
new file mode 100644
index 00000000..c30c6bd3
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/ScriptableTableBase.cs
@@ -0,0 +1,20 @@
+using UnityEngine;
+
+namespace Game.Shared.Scriptable.Database
+{
+ ///
+ /// の非ジェネリック基底。
+ /// CustomEditor が子クラス全体(editorForChildClasses)を対象にできるようにするための型。
+ /// 整列・検証は OnValidate での自動実行を行わず、Inspector のボタン / ⋮メニューから手動実行する。
+ ///
+ public abstract class ScriptableTableBase : ScriptableObject
+ {
+#if UNITY_EDITOR
+ /// 主キー昇順整列+空要素除去+重複警告+索引キャッシュ無効化(生成 partial が実装)。
+ public abstract void EditorSortAndValidate();
+
+ /// records が主キー昇順・空要素なしに整っているか(生成 partial が実装)。
+ public abstract bool EditorIsSorted();
+#endif
+ }
+}
diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/ScriptableTableBase.cs.meta b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/ScriptableTableBase.cs.meta
new file mode 100644
index 00000000..a33ffd62
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/ScriptableTableBase.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 384828973485c584abeb34a36fcabfa9
\ No newline at end of file
diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/ScriptableTableRecords.cs b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/ScriptableTableRecords.cs
new file mode 100644
index 00000000..e12649c7
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/ScriptableTableRecords.cs
@@ -0,0 +1,68 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+
+namespace Game.Shared.Scriptable.Database
+{
+ ///
+ /// ソート済み配列の連続区間を指すゼロアロケーションなビュー。
+ /// struct 列挙子を持つため foreach は追加アロケーションなしで列挙でき、
+ /// 実装により LINQ もこのビュー上で行える。
+ ///
+ public readonly struct ScriptableTableRecords : IReadOnlyList
+ {
+ private readonly T[] _records;
+ private readonly int _left;
+ private readonly int _right;
+ private readonly bool _ascending;
+
+ public ScriptableTableRecords(T[] records, int left, int right, bool ascending)
+ {
+ bool ok = records != null && left <= right && left >= 0 && right < records.Length;
+ _records = ok ? records : null;
+ _left = ok ? left : 0;
+ _right = ok ? right : -1;
+ _ascending = ascending;
+ }
+
+ public int Count => _records == null ? 0 : _right - _left + 1;
+
+ public bool IsEmpty => Count == 0;
+
+ public T this[int index]
+ {
+ get
+ {
+ if (index >= Count) throw new ArgumentOutOfRangeException(nameof(index));
+ return _ascending ? _records[_left + index] : _records[_right - index];
+ }
+ }
+
+ /// foreach 用のゼロアロケーション列挙子(struct)。
+ public Enumerator GetEnumerator() => new Enumerator(this);
+
+ // LINQ 等が辿る interface 経路。boxing する代わりにビュー本来の用途(foreach)は上の struct 版が処理する。
+ IEnumerator IEnumerable.GetEnumerator()
+ {
+ for (int i = 0; i < Count; i++) yield return this[i];
+ }
+
+ IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable)this).GetEnumerator();
+
+ public struct Enumerator
+ {
+ private readonly ScriptableTableRecords _range;
+ private int _index;
+
+ public Enumerator(ScriptableTableRecords range)
+ {
+ _range = range;
+ _index = -1;
+ }
+
+ public bool MoveNext() => ++_index < _range.Count;
+
+ public T Current => _range[_index];
+ }
+ }
+}
diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/ScriptableTableRecords.cs.meta b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/ScriptableTableRecords.cs.meta
new file mode 100644
index 00000000..bead89a1
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/ScriptableTableRecords.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 7cd6a2f2d315e8148ae4876cd43523a6
\ No newline at end of file
diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Services/IScriptableDatabaseService.cs b/src/Game.Client/Assets/Programs/Runtime/Shared/Services/IScriptableDatabaseService.cs
new file mode 100644
index 00000000..1d1ca726
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Services/IScriptableDatabaseService.cs
@@ -0,0 +1,18 @@
+using Cysharp.Threading.Tasks;
+using Game.Shared.Scriptable.Database;
+
+namespace Game.Shared.Services
+{
+ ///
+ /// ScriptableTable コンテナ()をロードして提供するサービスの共通インターフェース。
+ /// MasterMemory の IMasterDataService(MemoryDatabase)に対応する ScriptableObject 版。
+ ///
+ public interface IScriptableDatabaseService
+ {
+ /// ロード済みのテーブルコンテナ。
+ ScriptableDatabase Database { get; }
+
+ /// コンテナ資産を非同期でロードする。
+ UniTask LoadAsync();
+ }
+}
diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Services/IScriptableDatabaseService.cs.meta b/src/Game.Client/Assets/Programs/Runtime/Shared/Services/IScriptableDatabaseService.cs.meta
new file mode 100644
index 00000000..18c02601
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Services/IScriptableDatabaseService.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: eb431f4e9d7fa1b4ca43ef28d8ae118e
\ No newline at end of file
diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Services/ScriptableDatabaseServiceBase.cs b/src/Game.Client/Assets/Programs/Runtime/Shared/Services/ScriptableDatabaseServiceBase.cs
new file mode 100644
index 00000000..59a053b5
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Services/ScriptableDatabaseServiceBase.cs
@@ -0,0 +1,48 @@
+using System;
+using Cysharp.Threading.Tasks;
+using Game.Shared.Exceptions;
+using Game.Shared.Scriptable.Database;
+using UnityEngine;
+
+namespace Game.Shared.Services
+{
+ ///
+ /// ScriptableDatabase ロードサービスの共通基底。
+ /// MasterDataServiceBase(binary → MemoryDatabase)に対応し、SO コンテナ資産をロードして保持する。
+ ///
+ public abstract class ScriptableDatabaseServiceBase : IScriptableDatabaseService
+ {
+ public ScriptableDatabase Database { get; private set; }
+
+ /// コンテナ資産を読み込む(派生クラスでロード機構を実装)。
+ protected abstract UniTask LoadDatabaseAssetAsync();
+
+ public async UniTask LoadAsync()
+ {
+ try
+ {
+ var database = await LoadDatabaseAssetAsync();
+ if (database == null)
+ {
+ throw new MasterDataLoadException(
+ nameof(ScriptableDatabase),
+ "ScriptableDatabase asset returned null");
+ }
+
+ Database = database;
+ Debug.Log($"[{GetType().Name}] ScriptableDatabase loaded successfully.");
+ }
+ catch (MasterDataLoadException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ throw new MasterDataLoadException(
+ nameof(ScriptableDatabase),
+ $"Failed to load ScriptableDatabase: {ex.Message}",
+ ex);
+ }
+ }
+ }
+}
diff --git a/src/Game.Client/Assets/Programs/Runtime/Shared/Services/ScriptableDatabaseServiceBase.cs.meta b/src/Game.Client/Assets/Programs/Runtime/Shared/Services/ScriptableDatabaseServiceBase.cs.meta
new file mode 100644
index 00000000..1acad856
--- /dev/null
+++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Services/ScriptableDatabaseServiceBase.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: ccc1cf57bc247d349a2eb6f84a1c215b
\ No newline at end of file
diff --git a/src/Game.Client/Assets/ProjectAssets/Database.meta b/src/Game.Client/Assets/ProjectAssets/Database.meta
new file mode 100644
index 00000000..5b8cb9ee
--- /dev/null
+++ b/src/Game.Client/Assets/ProjectAssets/Database.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 7ca3694110e7c144d8f15445caed2840
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/src/Game.Client/Assets/ProjectAssets/Database/ScriptableDatabase.asset b/src/Game.Client/Assets/ProjectAssets/Database/ScriptableDatabase.asset
new file mode 100644
index 00000000..21808cc0
--- /dev/null
+++ b/src/Game.Client/Assets/ProjectAssets/Database/ScriptableDatabase.asset
@@ -0,0 +1,15 @@
+%YAML 1.1
+%TAG !u! tag:unity3d.com,2011:
+--- !u!114 &11400000
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 0}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: f92d211b74040944ba7223b31e695f31, type: 3}
+ m_Name: ScriptableDatabase
+ m_EditorClassIdentifier: Game.Shared::Game.Shared.Scriptable.Database.ScriptableDatabase
+ weaponLevelMasterTable: {fileID: 11400000, guid: 3a507dbfc69213d40b7a3976e725fb96, type: 2}
diff --git a/src/Game.Client/Assets/ProjectAssets/Database/ScriptableDatabase.asset.meta b/src/Game.Client/Assets/ProjectAssets/Database/ScriptableDatabase.asset.meta
new file mode 100644
index 00000000..67820ba9
--- /dev/null
+++ b/src/Game.Client/Assets/ProjectAssets/Database/ScriptableDatabase.asset.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: e4959a26874fd1a4081de17f7a1ada62
+NativeFormatImporter:
+ externalObjects: {}
+ mainObjectFileID: 11400000
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/src/Game.Client/Assets/ProjectAssets/Database/Tables.meta b/src/Game.Client/Assets/ProjectAssets/Database/Tables.meta
new file mode 100644
index 00000000..52cb94c1
--- /dev/null
+++ b/src/Game.Client/Assets/ProjectAssets/Database/Tables.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 711531a3dcbbddc41b248d8a406cde8d
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/src/Game.Client/Assets/ProjectAssets/Database/Tables/WeaponLevelMasterTable.asset b/src/Game.Client/Assets/ProjectAssets/Database/Tables/WeaponLevelMasterTable.asset
new file mode 100644
index 00000000..619b0883
--- /dev/null
+++ b/src/Game.Client/Assets/ProjectAssets/Database/Tables/WeaponLevelMasterTable.asset
@@ -0,0 +1,27 @@
+%YAML 1.1
+%TAG !u! tag:unity3d.com,2011:
+--- !u!114 &11400000
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 0}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: d5228feb3f37e5941a71b81755654ae9, type: 3}
+ m_Name: WeaponLevelMasterTable
+ m_EditorClassIdentifier: Game.Shared:Game.Shared.Scriptable.Database.Samples:WeaponLevelMasterTable
+ records:
+ - _id: 1
+ _weaponId: 1000
+ _level: 1
+ _assetName:
+ - _id: 2
+ _weaponId: 2000
+ _level: 2
+ _assetName:
+ - _id: 3
+ _weaponId: 3000
+ _level: 3
+ _assetName:
diff --git a/src/Game.Client/Assets/ProjectAssets/Database/Tables/WeaponLevelMasterTable.asset.meta b/src/Game.Client/Assets/ProjectAssets/Database/Tables/WeaponLevelMasterTable.asset.meta
new file mode 100644
index 00000000..684b34ee
--- /dev/null
+++ b/src/Game.Client/Assets/ProjectAssets/Database/Tables/WeaponLevelMasterTable.asset.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 3a507dbfc69213d40b7a3976e725fb96
+NativeFormatImporter:
+ externalObjects: {}
+ mainObjectFileID: 11400000
+ userData:
+ assetBundleName:
+ assetBundleVariant: