YAMADA TAISHI’s diary

ゲームについてとか私の日記とか。このブログのあらゆるコードは好きにどうぞ。利用規約があるものは記事内のGitHubのRepositoryのリンクで貼られていると思うので、そちらを参照ください。

【前編】いいかげんエクセルでマスターデータ管理辞めません?UnityでのJson,SQLiteを使ったマスター管理

こんにちは、やまだたいし( https://twitter.com/OrotiYamatano )です。
エクセルが嫌いすぎるので本記事を書くことにしました。

目次


なぜエクセルを辞めたいのか?


現在ゲーム業界ではエクセルにてデータマスターを更新するのが普通になっています。
しかし、正直エクセルデータでは後からのカラム変更がやりづらいなどがあります。
競合が発生したりデータを入力するのも一苦労です。
いい加減15年前以上前から続いているエクセルをやめたいです。
今回は代案の提案です。
SQLiteを使ってみましょう。

前提


今回はUnity前提です。
なおかつWinodws環境で64bit前提。
Unityバージョンは(UnitySQLiteAsyncが2018.3以降対応なので)2018.3以降を想定。(今回は2022.3.10f1を使用)
SQLiteでデータの扱いをします。

インストールが必要なソフトウェア


DBeaver
これだけ。
厳密に言うと他にもあるのだけど、DBeaverを通じて勝手にインストールされ意識する必要がない。
DB構築も設定もコレでできるため、今回はコレを選定。

プログラマーがUnity環境に入れるライブラリ


これは必須ではないが、ゲームとしてSQLiteをそのまま使うのはセキュリティ上、不安が残るため
最終的にMasterMemoryに変換する(またSQLiteは環境依存もあったりするので)
github.com

SQLiteを読み込むためのライブラリ
github.com

導入手順


手順は以下の通り

1. DBeaverのダウンロード
2. SQLiteに必要なセットをインストールする
3. DBeaverでデータをつくる
4. SQLiteとUnityの接続設定
5. スクリプトからの一時データの書き出しとSQLiteデータの更新
6. MasterMemoryの設定
7. MasterMemory用に書き出し
8. MasterMemoryで読み込む

前編では5. スクリプトからの一時データの書き出しとSQLiteデータの更新までやりましょう。

1. DBeaverのダウンロード


コチラよりインストール
dbeaver.io

今回はWindows前提で解説する。

インストーラーは特に気にせずデフォルトで設定.

2. SQLiteに必要なセットをインストールする


新規で開いて、右クリック

SQLiteを選択

するとライブラリ等ダウンロードしていいか聞かれるのでそのままダウンロードする。

適当なフォルダに名前をつけてパスを指定する。

(このようにDBファイルができる)

4. SQLiteとUnityの接続設定


今回使うのはこちら

github.com

UnitySQLiteAsyncです。
UniTaskベースでsqlite-netを使ったライブラリです。
割りと更新が近いのと非同期対応していたので選定しました。
最悪、sqlite-netを使うという手もあります。
まずはこちらのREADMEにあるパッケージをインポートします。

実際に接続する前に
DB読み込みをするための仮データを作ります。

後は適当にぽちぽち作って行きます。

SQLiteの解説は割愛。
とりあえず、今回はテストでIdとTextValというカラムを追加し、
IdにIndex設定をしました。(Index設定したら勝手にPKになるっぽい)

各種カラムや設定を追加しCtrl+Sを押すと↓のようなウィンドウが出てくるので接続をしてDBを更新する

適当にカラム追加。

後は、テーブル構造を定義したクラスと……。

using SQLite;

namespace Tables  // 本記事では参照に必須なので追加しておく
{
    [System.Serializable]  // SQLiteでは必須ではないが本記事では必要なので追加しておく
    public class TestTable
    {
        [AutoIncrement, PrimaryKey, Indexed] public int Id { get; set; }
        [MaxLength(64)] public string TextVal { get; set; }
    }
}

適当なオブジェクトにアクセスするスクリプトをアタッチすると……。

using SQLite;
using UnityEngine;

public class DBLoad : MonoBehaviour
{
    async void Start()
    {
        var path = "C://Test/Testdb";
        var db = new SQLiteAsyncConnection(path);
        TestTable test = await db.GetAsync<TestTable> (1);
        Debug.Log(test.TextVal);
    }
}

ログが表示される。

ここまでくれば文字列で読んだデータを各種必要な情報に変換すれば良いので、
マスターデータとしては最低限の役割は果たしたといえる。

5. スクリプトからの一時データの書き出しとSQLiteデータの更新


UnitySQLiteAsyncを使ったSQLiteの読み込みを行った。
しかし、現状では競合する問題点は解決されていない。
そこで一時データとして書き出しと読み込み更新機能を作っていく。

今回はJsonの書き出しと読み込みのために以下のpackageをPackage Managerから取り込んでおきます。

docs.unity3d.com

com.unity.nuget.newtonsoft-jsonをAdd Package する

JsonUtilityを使いたかったのですが、そちらだとSQLiteのライブラリのアトリビュートが邪魔をしてしまったり、プロパティを利用するとJsonとしての書き出しが出来なかったので入れることにしました。

読み取り書き込みにあたり、
以下のようなエディタ拡張のコードを書きました。

using System;
using System.Collections.Generic;
using System.IO;
using Cysharp.Threading.Tasks;
using Newtonsoft.Json;
using SQLite;
using UnityEditor;
using UnityEngine;

public class TableDataEditor : EditorWindow
{
    [MenuItem("Tools/Table Data Editor")]
    public static void ShowWindow()
    {
        GetWindow<TableDataEditor>("Table Data Editor");
    }

    void OnGUI()
    {
        if (GUILayout.Button("Save Data"))
        {
            SaveData();
        }

        if (GUILayout.Button("Load Data"))
        {
            LoadData();
        }
    }

    private static void SaveData()
    {
        var classes = ClassLoader.LoadAllClasses("Tables"); 

        var path = Application.dataPath+"/db/testdb";  // テーブルの格納先変えました
        var db = new SQLiteAsyncConnection(path);
        foreach (var classType in classes)
        {
            SaveClass(classType, db).Forget();
        }
    }

    private static void LoadData()
    {
        var classes = ClassLoader.LoadAllClasses("Tables"); 

        var path = Application.dataPath+"/db/testdb";  // テーブルの格納先変えました
        var db = new SQLiteAsyncConnection(path);
        foreach (var classType in classes)
        {
            LoadClass(classType, db).Forget();
        }
    }

    private static async UniTask SaveClass(Type classType, SQLiteAsyncConnection db)
    {
        // AsyncTableQuery<T> オブジェクトを取得
        var tableMethod = db.GetType().GetMethod("Table")?.MakeGenericMethod(classType);
        if (tableMethod == null)
        {
            return;
        }

        var tableInstance = tableMethod.Invoke(db, null);
        var toListAsyncMethod = tableInstance.GetType().GetMethod("ToListAsync");
        if (toListAsyncMethod == null) return;

        // UniTask から List<T> を非同期的に取得
        dynamic task = toListAsyncMethod.Invoke(tableInstance, null);
        var listInstance = await task;

        // JSONにシリアライズしてファイルに保存
        var json = JsonConvert.SerializeObject(listInstance, Formatting.Indented);
        var filePath = Path.Combine(Application.dataPath, classType.Name + ".json");
        await File.WriteAllTextAsync(filePath, json);
        Debug.Log("Saved: " + filePath);
    }
    
    private static async UniTask LoadClass(Type classType, SQLiteAsyncConnection db)
    {
        var filePath = Path.Combine(Application.dataPath, classType.Name + ".json");
        if (!File.Exists(filePath))
        {
            Debug.Log("File not found: " + filePath);
            return;
        }

        try
        {
            // テーブルが存在しない場合は作成
            await db.CreateTableAsync(classType);

            var json = await File.ReadAllTextAsync(filePath);
            var listType = typeof(List<>).MakeGenericType(classType);
            var listInstance = JsonConvert.DeserializeObject(json, listType) as System.Collections.IList;

            if (listInstance != null)
            {
                foreach (var item in listInstance)
                {
                    await db.InsertAsync(item);
                }
                Debug.Log("Data loaded and inserted into database.");
            }
        }
        catch (Exception e)
        {
            Debug.LogError("Error loading JSON from file or inserting into database: " + e.Message);
        }
    }
}

このスクリプトはTablesというName Space配下のスクリプトを全て取得し、
Jsonに書き出したり読み込んだりするスクリプトです。

これでJsonをGitベースで管理することでマスターがダイレクトに競合することなく、
実装ができます。
(今考えるとオートインクリメントでの処理は結合時に死ぬことがあるのでやめた方がいいですね)

↓貼り忘れていたClassLoaderクラス

using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;

public static class ClassLoader
{
    public static IEnumerable<Type> LoadAllClasses(string namespaceName)
    {
        var classList = new List<Type>();
        var assemblies = AppDomain.CurrentDomain.GetAssemblies();

        foreach (var assembly in assemblies)
        {
            classList.AddRange(
                assembly.GetTypes().Where(
                    t => t.IsClass && t.Namespace == namespaceName && !t.IsSubclassOf(typeof(MonoBehaviour))
                )
            );
        }

        return classList;
    }
}

まとめ


前編では、一時データの読み込みと、
Jsonへの書き出し、Jsonからの読み込みを行いました。
後編では実際にマスターメモリーのセットアップと、
実際にマスターメモリーでのデータ取扱を行っていこうと思います。