From 5795e7f104efc80ff65e9cf3046b8e57c76fe5e4 Mon Sep 17 00:00:00 2001 From: "Rei.K" Date: Sat, 13 Jun 2026 18:23:16 +0900 Subject: [PATCH 1/6] =?UTF-8?q?ScriptableDatabase=E6=9C=AC=E4=BD=93?= =?UTF-8?q?=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Assets/Programs/Editor/Database.meta | 8 + .../Database/ScriptableDatabaseBuilder.cs | 158 +++++++++++++++ .../ScriptableDatabaseBuilder.cs.meta | 2 + .../Database/ScriptableTableGenerator.cs | 188 ++++++++++++++++++ .../Database/ScriptableTableGenerator.cs.meta | 2 + .../Shared/ScriptableTableGeneratorTests.cs | 74 +++++++ .../ScriptableTableGeneratorTests.cs.meta | 2 + .../Tests/Shared/ScriptableTableTests.cs | 167 ++++++++++++++++ .../Tests/Shared/ScriptableTableTests.cs.meta | 2 + .../Programs/Runtime/Shared/Scriptable.meta | 8 + .../Runtime/Shared/Scriptable/Database.meta | 8 + .../Scriptable/Database/BinarySearch.cs | 66 ++++++ .../Scriptable/Database/BinarySearch.cs.meta | 2 + .../Shared/Scriptable/Database/Generated.meta | 8 + .../Generated/ScriptableDatabase.g.cs | 13 ++ .../Generated/ScriptableDatabase.g.cs.meta | 2 + .../Generated/WeaponLevelMasterTable.g.cs | 82 ++++++++ .../WeaponLevelMasterTable.g.cs.meta | 2 + .../Shared/Scriptable/Database/Samples.meta | 8 + .../Database/Samples/WeaponLevelMaster.cs | 55 +++++ .../Samples/WeaponLevelMaster.cs.meta | 2 + .../Scriptable/Database/ScriptableTable.cs | 119 +++++++++++ .../Database/ScriptableTable.cs.meta | 2 + .../Database/ScriptableTableAttribute.cs | 41 ++++ .../Database/ScriptableTableAttribute.cs.meta | 2 + .../Database/ScriptableTableRecords.cs | 68 +++++++ .../Database/ScriptableTableRecords.cs.meta | 2 + 27 files changed, 1093 insertions(+) create mode 100644 src/Game.Client/Assets/Programs/Editor/Database.meta create mode 100644 src/Game.Client/Assets/Programs/Editor/Database/ScriptableDatabaseBuilder.cs create mode 100644 src/Game.Client/Assets/Programs/Editor/Database/ScriptableDatabaseBuilder.cs.meta create mode 100644 src/Game.Client/Assets/Programs/Editor/Database/ScriptableTableGenerator.cs create mode 100644 src/Game.Client/Assets/Programs/Editor/Database/ScriptableTableGenerator.cs.meta create mode 100644 src/Game.Client/Assets/Programs/Editor/Tests/Shared/ScriptableTableGeneratorTests.cs create mode 100644 src/Game.Client/Assets/Programs/Editor/Tests/Shared/ScriptableTableGeneratorTests.cs.meta create mode 100644 src/Game.Client/Assets/Programs/Editor/Tests/Shared/ScriptableTableTests.cs create mode 100644 src/Game.Client/Assets/Programs/Editor/Tests/Shared/ScriptableTableTests.cs.meta create mode 100644 src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable.meta create mode 100644 src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database.meta create mode 100644 src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/BinarySearch.cs create mode 100644 src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/BinarySearch.cs.meta create mode 100644 src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/Generated.meta create mode 100644 src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/Generated/ScriptableDatabase.g.cs create mode 100644 src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/Generated/ScriptableDatabase.g.cs.meta create mode 100644 src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/Generated/WeaponLevelMasterTable.g.cs create mode 100644 src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/Generated/WeaponLevelMasterTable.g.cs.meta create mode 100644 src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/Samples.meta create mode 100644 src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/Samples/WeaponLevelMaster.cs create mode 100644 src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/Samples/WeaponLevelMaster.cs.meta create mode 100644 src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/ScriptableTable.cs create mode 100644 src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/ScriptableTable.cs.meta create mode 100644 src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/ScriptableTableAttribute.cs create mode 100644 src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/ScriptableTableAttribute.cs.meta create mode 100644 src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/ScriptableTableRecords.cs create mode 100644 src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/ScriptableTableRecords.cs.meta 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/ScriptableTableGenerator.cs b/src/Game.Client/Assets/Programs/Editor/Database/ScriptableTableGenerator.cs new file mode 100644 index 00000000..92f4bde1 --- /dev/null +++ b/src/Game.Client/Assets/Programs/Editor/Database/ScriptableTableGenerator.cs @@ -0,0 +1,188 @@ +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}private void OnValidate()"); + 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("#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/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..f3068706 --- /dev/null +++ b/src/Game.Client/Assets/Programs/Editor/Tests/Shared/ScriptableTableTests.cs @@ -0,0 +1,167 @@ +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 void Validate() => SortAndValidate(Sel, Cmp); // OnValidate 経路をテストから叩く + } + + 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); // 重複は警告のみで除去はしない + } + } +} 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/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..903b3df5 --- /dev/null +++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/Generated/WeaponLevelMasterTable.g.cs @@ -0,0 +1,82 @@ +// +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 + private void OnValidate() + { + SortAndValidate(_pkSel, _pkCmp); + _idx0 = null; + _idx1 = null; + } +#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..2f01bfe1 --- /dev/null +++ b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/ScriptableTable.cs @@ -0,0 +1,119 @@ +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 : ScriptableObject + { + [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 を 昇順へ整列し、重複キーを警告する。 + /// + 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/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 From 675083b583bc33eacfe00c79be6c27221f8f708a Mon Sep 17 00:00:00 2001 From: "Rei.K" Date: Sat, 13 Jun 2026 18:24:03 +0900 Subject: [PATCH 2/6] =?UTF-8?q?=E3=82=A2=E3=82=BB=E3=83=83=E3=83=88?= =?UTF-8?q?=E7=94=9F=E6=88=90=EF=BC=86Addressables=E7=99=BB=E9=8C=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AssetGroups/MasterData.asset | 5 ++++ .../Assets/ProjectAssets/Database.meta | 8 ++++++ .../Database/ScriptableDatabase.asset | 15 +++++++++++ .../Database/ScriptableDatabase.asset.meta | 8 ++++++ .../Assets/ProjectAssets/Database/Tables.meta | 8 ++++++ .../Tables/WeaponLevelMasterTable.asset | 27 +++++++++++++++++++ .../Tables/WeaponLevelMasterTable.asset.meta | 8 ++++++ 7 files changed, 79 insertions(+) create mode 100644 src/Game.Client/Assets/ProjectAssets/Database.meta create mode 100644 src/Game.Client/Assets/ProjectAssets/Database/ScriptableDatabase.asset create mode 100644 src/Game.Client/Assets/ProjectAssets/Database/ScriptableDatabase.asset.meta create mode 100644 src/Game.Client/Assets/ProjectAssets/Database/Tables.meta create mode 100644 src/Game.Client/Assets/ProjectAssets/Database/Tables/WeaponLevelMasterTable.asset create mode 100644 src/Game.Client/Assets/ProjectAssets/Database/Tables/WeaponLevelMasterTable.asset.meta 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/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: From e44a4d4bf95c8685669af71acd3498911409022a Mon Sep 17 00:00:00 2001 From: "Rei.K" Date: Sat, 13 Jun 2026 18:24:23 +0900 Subject: [PATCH 3/6] ScriptableDatabaseService --- .../Shared/ScriptableDatabaseServiceTests.cs | 39 +++++++++++++++ .../ScriptableDatabaseServiceTests.cs.meta | 2 + .../Services/ScriptableDatabaseService.cs | 50 +++++++++++++++++++ .../ScriptableDatabaseService.cs.meta | 2 + .../Services/IScriptableDatabaseService.cs | 18 +++++++ .../IScriptableDatabaseService.cs.meta | 2 + .../Services/ScriptableDatabaseServiceBase.cs | 48 ++++++++++++++++++ .../ScriptableDatabaseServiceBase.cs.meta | 2 + 8 files changed, 163 insertions(+) create mode 100644 src/Game.Client/Assets/Programs/Editor/Tests/Shared/ScriptableDatabaseServiceTests.cs create mode 100644 src/Game.Client/Assets/Programs/Editor/Tests/Shared/ScriptableDatabaseServiceTests.cs.meta create mode 100644 src/Game.Client/Assets/Programs/Runtime/MVC/Core/Services/ScriptableDatabaseService.cs create mode 100644 src/Game.Client/Assets/Programs/Runtime/MVC/Core/Services/ScriptableDatabaseService.cs.meta create mode 100644 src/Game.Client/Assets/Programs/Runtime/Shared/Services/IScriptableDatabaseService.cs create mode 100644 src/Game.Client/Assets/Programs/Runtime/Shared/Services/IScriptableDatabaseService.cs.meta create mode 100644 src/Game.Client/Assets/Programs/Runtime/Shared/Services/ScriptableDatabaseServiceBase.cs create mode 100644 src/Game.Client/Assets/Programs/Runtime/Shared/Services/ScriptableDatabaseServiceBase.cs.meta 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/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/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 From 697fcb7f16c89a82189fad34af7eb7b891a848b5 Mon Sep 17 00:00:00 2001 From: "Rei.K" Date: Sat, 13 Jun 2026 19:45:08 +0900 Subject: [PATCH 4/6] =?UTF-8?q?=E3=83=90=E3=83=AA=E3=83=87=E3=83=BC?= =?UTF-8?q?=E3=82=B7=E3=83=A7=E3=83=B3=E6=89=8B=E5=8B=95=E5=AE=9F=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Editor/Database/ScriptableTableEditor.cs | 40 +++++++++++++++++++ .../Database/ScriptableTableEditor.cs.meta | 2 + .../Database/ScriptableTableGenerator.cs | 5 ++- .../Tests/Shared/ScriptableTableTests.cs | 15 ++++++- .../Generated/WeaponLevelMasterTable.g.cs | 5 ++- .../Scriptable/Database/ScriptableTable.cs | 14 ++++++- .../Database/ScriptableTableBase.cs | 20 ++++++++++ .../Database/ScriptableTableBase.cs.meta | 2 + 8 files changed, 99 insertions(+), 4 deletions(-) create mode 100644 src/Game.Client/Assets/Programs/Editor/Database/ScriptableTableEditor.cs create mode 100644 src/Game.Client/Assets/Programs/Editor/Database/ScriptableTableEditor.cs.meta create mode 100644 src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/ScriptableTableBase.cs create mode 100644 src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/ScriptableTableBase.cs.meta 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 index 92f4bde1..470365bc 100644 --- a/src/Game.Client/Assets/Programs/Editor/Database/ScriptableTableGenerator.cs +++ b/src/Game.Client/Assets/Programs/Editor/Database/ScriptableTableGenerator.cs @@ -154,11 +154,14 @@ private static string Emit(Type type) sb.AppendLine(); sb.AppendLine("#if UNITY_EDITOR"); - sb.AppendLine($"{i1}private void OnValidate()"); + 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("}"); 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 f3068706..3870a962 100644 --- a/src/Game.Client/Assets/Programs/Editor/Tests/Shared/ScriptableTableTests.cs +++ b/src/Game.Client/Assets/Programs/Editor/Tests/Shared/ScriptableTableTests.cs @@ -32,7 +32,10 @@ private class TestScriptableTable : ScriptableTable 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 void Validate() => SortAndValidate(Sel, Cmp); // OnValidate 経路をテストから叩く + + 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) @@ -163,5 +166,15 @@ public void SortAndValidate_WarnsOnDuplicateKey() 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/Runtime/Shared/Scriptable/Database/Generated/WeaponLevelMasterTable.g.cs b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/Generated/WeaponLevelMasterTable.g.cs index 903b3df5..be50e20f 100644 --- 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 @@ -71,12 +71,15 @@ public bool TryFindByWeaponIdAndLevel((int, int) key, out WeaponLevelMaster reco } #if UNITY_EDITOR - private void OnValidate() + [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/ScriptableTable.cs b/src/Game.Client/Assets/Programs/Runtime/Shared/Scriptable/Database/ScriptableTable.cs index 2f01bfe1..11acf7b7 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 @@ -13,7 +13,7 @@ namespace Game.Shared.Scriptable.Database /// not-found 表現は MasterMemory に準拠: は既定で例外、 /// default+false、 は空時に default。 /// - public abstract class ScriptableTable : ScriptableObject + public abstract class ScriptableTable : ScriptableTableBase { [SerializeField] protected TRecord[] records = Array.Empty(); @@ -91,6 +91,18 @@ protected static ScriptableTableRecords FindRange(TRecord[] idx, /// 生成 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; 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 From 30e137fd717c3d59dbc6fefb7754a96a956cb1ea Mon Sep 17 00:00:00 2001 From: "Rei.K" Date: Sat, 13 Jun 2026 19:50:21 +0900 Subject: [PATCH 5/6] =?UTF-8?q?ScriptableDatabase=E5=88=9D=E6=9C=9F?= =?UTF-8?q?=E5=8C=96=E3=83=AD=E3=83=BC=E3=83=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Runtime/MVC/Horror/HorrorGameLauncher.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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); From d45e62ec9ce1f51544f15f648dbb1829910bcb23 Mon Sep 17 00:00:00 2001 From: "Rei.K" Date: Sat, 13 Jun 2026 19:51:15 +0900 Subject: [PATCH 6/6] =?UTF-8?q?=E3=82=BF=E3=82=A4=E3=83=88=E3=83=AB?= =?UTF-8?q?=E3=81=AB=E6=88=BB=E3=82=8B=E3=81=A8Fade=E3=81=8C=E5=AE=8C?= =?UTF-8?q?=E4=BA=86=E3=81=97=E3=81=AA=E3=81=84=E4=B8=8D=E5=85=B7=E5=90=88?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MVC/Horror/UI/HorrorGameRootController.cs | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) 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);