Unityでセーブデータを扱う

# はじめに
Unityでセーブデータが必要なゲームの制作をすることになったので、自分で実装してみました。
**UniTaskを使用しています。このセーブデータ処理を使用する場合はUniTaskの導入をするか、自分でUniTaskを使わない実装にしてください。**
ご指摘等、お待ちしております。
# 実装の条件
・宣言した時点でセーブデータに使用される値であることが確定する
・宣言したクラスの外に値が漏れ出すことがない
・追加、削除が柔軟に行える
# 現在の問題点
・string型の保存ができない
・保存先をSaveableValue内から直接参照してしまっている
# コード
```c#:SaveFileOperator.cs
    //ファイルを読み書きする
    public static class SaveFileOperator
    {
        //セーブデータはDocuments\#任意のフォルダ名#に
        //0.sd, 1.sd, 2.sdのように保存される
        private static readonly string EXTENSION = ".sd";

        //ローカルのセーブデータを読み込んで、提供する
        //ディレクトリにある.sdファイルを指定の数読み込んで返す
        private static readonly SemaphoreSlim semaphore = new(1);
        public static async UniTask<SaveData> LoadAsync(string fileName)
        {
            await semaphore.WaitAsync();
            //ディレクトリ内の.sdファイルを取得
            DirectoryInfo directoryInfo = new(SaveDataPathGetter.Path);
            if (!directoryInfo.Exists) { directoryInfo.Create(); }
            FileInfo[] fileInfos = directoryInfo.GetFiles('*' + EXTENSION);
            SaveData data = new($"{SaveDataPathGetter.Path}\\{fileName}{EXTENSION}", "");
            for (int i = 0; i < fileInfos.Length; i++)
            {
                var fileInfo = fileInfos[i];
                if (fileInfo.Name == fileName + EXTENSION)
                {
                    try
                    {
                        string filePath = fileInfo.FullName;
                        using (var file = new StreamReader(filePath))
                        {
                            data = new(filePath, await file.ReadToEndAsync());
                        }
                    }
                    catch { }
                }
            }
            semaphore.Release();
            //読み込みに失敗した場合は、空データを返す
            return data;
        }

        //渡されたSaveDataの値をファイルに書き込む
        public static async UniTask<bool> SaveAsync(SaveData value)
        {
            bool isSuccessed = false;
            Debug.Log("セーブ開始");
            await semaphore.WaitAsync();
            try
            {
                //ロジックはすべてのデータを上書きする
                //差分更新はなんだかんだでコストが変わらない気がするので、上書きで実装
                using (var fileStream = new FileStream(value.path, FileMode.Create, FileAccess.Write, FileShare.None))
                using (var writer = new StreamWriter(fileStream))
                {
                    (string, string)[] keyValuePairs = value.GetKeyValuePairs();
                    StringBuilder data = new("");
                    for (int i = 0; i < keyValuePairs.Length; i++)
                    {
                        if (!Application.isPlaying)
                        {
                            throw new System.Exception();
                        }
                        Debug.Log("セーブデータ処理");
                        var keyValuePair = keyValuePairs[i];
                        if (keyValuePair.Item1 == string.Empty && keyValuePair.Item2 == string.Empty) continue;

                        //データを行ごとに書き込む
                        data.Clear();
                        data.Append(keyValuePair.Item1);
                        data.Append(":");
                        data.Append(keyValuePair.Item2);

                        //最後のデータでない場合は、文字列に改行を追加する
                        if (i != keyValuePairs.Length - 1)
                        {
                            data.Append("\n");
                        }
                        writer.Flush();
                        await writer.WriteAsync(data.ToString()).AsUniTask();
                    }
                }
                Debug.Log("セーブ完了");
                isSuccessed = true;
            }
            catch
            {
                Debug.LogError("セーブ失敗");
            }
            semaphore.Release();
            return isSuccessed;
        }
    }

```
```c#:DataServer.cs
    //ゲーム内で使用するセーブデータを提供する
    public class DataServer : MonoBehaviour
    {
        //singleton
        public static DataServer Instance { get; private set; } = null;
        //------
        private static readonly int DATA_MAX = 3;
        private void Awake()
        {
            //Managerシーン以外に存在させない
            if (gameObject.scene != SceneManager.GetSceneByName("Manager"))
            {
                Destroy(gameObject);
                return;
            }

            Instance = this;
            LoadConfigAsync().Forget();
        }
        public async UniTask<bool> SaveGameAsync()
        {
            return await SaveLoad.SaveFileOperator.SaveAsync(Save);
        }
        public async UniTask<bool> SaveConfigAsync()
        {
            return await SaveLoad.SaveFileOperator.SaveAsync(ConfigData);
        }

        //指定した番号のセーブデータを変数に代入する
        public void SetData(int index)
        {
            Save = saves[index];
            //saves = new SaveLoad.SaveData[DATA_MAX];
        }

        //ファイルを保存ファイル数だけ読み込む
        public async UniTask LoadAllGameAsync()
        {
            //セーブファイルの名前は0, 1, 2, ... nみたいな感じ
            for (int i = 0; i < DATA_MAX; i++)
            {
                saves[i] = await SaveLoad.SaveFileOperator.LoadAsync(i.ToString());
            }
        }

        //コンフィグファイルを読み込む
        public async UniTask LoadConfigAsync()
        {
            const string FILE_NAME = "config";
            ConfigData = await SaveLoad.SaveFileOperator.LoadAsync(FILE_NAME);
        }
        private SaveLoad.SaveData[] saves = new SaveLoad.SaveData[DATA_MAX];
        public SaveLoad.SaveData Save { get; private set; }
        public SaveLoad.SaveData ConfigData { get; private set; }
    }

```
```c#:SaveData.cs
    public class SaveData
    {
        public readonly string path;
        private Dictionary<string, string> values = new();
        public SaveData(string path, string data)
        {
            this.path = path;

            //stringをラインごとに区切ってDictionaryに登録する
            string[] lines = data.Split("\n");
            foreach (string line in lines)
            {
                //データはkey:valueのように保存されている
                string[] values = line.Split(':');

                const int LENGTH = 2;
                if (values.Length != LENGTH) continue;

                const int KEY = 0;
                const int VALUE = 1;
                this.values.Add(values[KEY].Trim(), values[VALUE].Trim());
            }
        }
        public string GetValue(Object.SaveKey saveKey) => values.ContainsKey(saveKey.value) ? values[saveKey.value] : string.Empty;
        public void SetValue(Object.SaveKey saveKey, string value)
        {
            //文字列比較をする
            //SaveKeyで比較をする場合、インスタンス破棄後に呼ばれると例外が投げられる(はず)
            if (values.ContainsKey(saveKey.value))
            {
                values[saveKey.value] = value;
            }
            else
            {
                values.Add(saveKey.value, value);
            }
        }

        //ファイルに書き込む際に、KeyとValueのズレが起こる可能性の考慮、
        //そして記述の簡潔化のためにタプル配列で値を返す
        //数値でValueを取り出すことができたなら、こんなメソッド不要なのに
        public (string, string)[] GetKeyValuePairs()
        {
            (string, string)[] data = new (string, string)[values.Count];
            {
                int index = 0;
                foreach (var value in values)
                {
                    data[index].Item1 = value.Key;
                    data[index].Item2 = value.Value;
                    index++;
                }
            }
            return data;
        }
    }

```
```c#:SaveKey.cs
    public class SaveKey
    {
        //キーと呼び出し元クラス名を保持しておく
        //異なるクラスから同じキーでインスタンスが生成されようとする場合、例外を投げる
        //キーさえ一致していればどこからでも呼び出せるセーブデータは実質staticであると考える、それを隠蔽するためにキー重複処理を挟む
        private static Dictionary<string/*saveKey*/, string/*typeName*/> stacks = new();
        public readonly string value;
        public SaveKey(string saveKey)
        {
            this.value = saveKey;

            //呼び出し元のクラス名を取得
            const int SKIP_FRAMES = 1;
            StackFrame stackFrame = new(SKIP_FRAMES);
            string typeName = stackFrame.GetMethod().ReflectedType.Name;
            //dictionaryにキーとクラス名のペアが存在するかを調べ、重複を検知する
            bool isExists = stacks.ContainsKey(saveKey);
            if (isExists)
            {
                string existsTypeName = stacks[saveKey];
                //呼び出し元が同じ場合は正常終了
                if (typeName == existsTypeName)
                {
                    return;
                }
                //呼び出し元が異なる場合
                else
                {
                    UnityEngine.Debug.LogError($"キーが重複しています(Key：{saveKey}, クラス名：{existsTypeName}, {typeName})");
                }
            }
            else
            {
                stacks.Add(saveKey, typeName);
            }
        }
    }

```
```c#:SaveableValue.cs
    public class SaveableValue<T> : IInitializedCallback<T>
    where T : struct
    {
        //セーブデータの保存先
        private SaveData dataRef;

        //セーブするときのアクセスキー
        private readonly SaveKey saveKey;

        //コンフィグに保存するデータであるか
        private bool isConfigData = false;
        private bool isInitialized = false;
        private event Action<T> onInitialized = _ => { };
        private T value;
        public T Value
        {
            get
            {
#if UNITY_EDITOR
                if (!isInitialized)
                {
                    Debug.LogWarning("Getter：セーブデータの取得が完了していません。GetValueAsync()での取得を検討してください。");
                }
#endif
                return value;
            }
            set
            {
#if UNITY_EDITOR
                if (!isInitialized)
                {
                    Debug.LogWarning("Setter：セーブデータの取得が完了していないので、値は保存されません。");
                }
#endif
                //この段階ではセーブデータに値を反映しただけ
                //ファイルに書き込みをしたい場合はSaveLoadOperator.Save(SaveData)を呼ぶ
                this.value = value;

                //dataRefがnullなら処理はしない
                dataRef?.SetValue(saveKey, Converter.TypeConverter.ToString<T>(this.value));
            }
        }

        //宣言と同時に初期化
        public SaveableValue(string saveKey, T TDefaultValue, bool isConfigData = false)
        {
            this.saveKey = new(saveKey);
            value = TDefaultValue;
            this.isConfigData = isConfigData;
        }

        //実行時に初期化(インスタンスフィールドをSaveKeyに使いたい場合)
        public SaveableValue(string saveKey, T TDefaultValue, CancellationToken token, Action<T> callback = null, bool isConfigData = false)
        : this(saveKey, TDefaultValue, isConfigData)
            => GetValueAsync(token, callback).Forget();

        public async UniTaskVoid GetValueAsync(CancellationToken token, Action<T> callback = null)
        {
            //awaitすると、条件を満たしているにも関わらず、1フレームだけ処理が止まってしまう
            //セーブデータは読み込まれているのに、取得ができないというバグを回避するためにnullチェックをしている
            if (isConfigData)
            {
                while (DataServer.Instance?.ConfigData == null)
                {
                    await UniTask.Yield(cancellationToken: token);
                }
                dataRef = DataServer.Instance.ConfigData;
            }
            else
            {
                while (DataServer.Instance?.Save == null)
                {
                    await UniTask.Yield(cancellationToken: token);
                }
                dataRef = DataServer.Instance.Save;
            }

            string value = dataRef.GetValue(saveKey);

            //変換処理をする
            //セーブデータの値が変換できない、セーブデータがない状態だと、デフォルト値が入る
            if (value != string.Empty)
            {
                try
                {
                    this.value = (T)Converter.TypeConverter.FromString<T>(value);
                }
                catch (Exception ex)
                {
                    Debug.LogError(ex);
                }
            }
            isInitialized = true;
            Value = this.value;
            if (callback != null)
            {
                callback(Value);
            }
            onInitialized(Value);
            onInitialized = null;
        }

        /// <summary>
        /// <para>インターフェースの関数</para>
        /// <para>コールバックが長い場合や、外部からコールバックを登録したいときはこちら</para>
        /// </summary>
        public void Add(Action<T> callback)
        {
            if (isInitialized)
            {
                callback(Value);
            }
            else
            {
                onInitialized += callback;
            }
        }
    }

```
```c#:IInitializedCallBack.cs
    //SaveableValueに外部から初期化時の処理を登録したいとき、この型で公開する
    public interface IInitializedCallback<T>
    {
        void Add(System.Action<T> callback);
    }
```
```c#:SaveDataValueConverter.cs
    //interfaceではなくclassじゃないとIsSubOfClassでTrueを受け取れない
    abstract public class SaveDataValueConverter<T>
    where T : struct
    {
        //リフレクションで呼ぶので、同じ名前のメソッドを実装すること
        //private static string ToString<T>(T TValue);
        //private static T FromString(string text);
    }
```
```c#:TypeConverter.cs
    public static class TypeConverter
    {
        public static string ToString<T>(T TValue)
        where T : struct
        {
            //既にToStringを持っている型の場合はそれを返す
            if (TValue is IFormattable)
            {
                return (TValue as IFormattable).ToString();
            }
            if (TValue is IConvertible)
            {
                return (TValue as IConvertible).ToString();
            }

            //自分で実装した変換処理を実行する
            try
            {
                var types = System.Reflection.Assembly.GetAssembly(typeof(SaveDataValueConverter<T>)).GetTypes();
                foreach (var type in types)
                {
                    if (type.IsSubclassOf(typeof(SaveDataValueConverter<T>)))
                    {
                        var method = type.GetMethod(
                            "ToString",
                            bindingAttr:
                                System.Reflection.BindingFlags.Static |
                                System.Reflection.BindingFlags.NonPublic
                        );
                        if (method == null)
                        {
                            throw new Exception($"{type.Name} に {typeof(T)} private static ToString(string) が実装されていません");
                        }
                        return method.Invoke(null, new object[] { TValue }) as string;
                    }
                }
            }
            catch (Exception ex)
            {
                throw ex;
            }
            throw new Exception($"SaveDataValueConverter<{typeof(T)}> を継承するクラスが見つかりませんでした)");
        }
        public static T FromString<T>(string text)
        where T : struct
        {
            //プリミティブ型変換を試みる
            try
            {
                var converter = TypeDescriptor.GetConverter(typeof(T));
                if (converter.GetType() != typeof(TypeConverter))
                {
                    return (T)converter.ConvertFromString(text);
                }
            }
            catch { }

            //自分で実装した変換処理を実行する
            try
            {
                var types = System.Reflection.Assembly.GetAssembly(typeof(SaveDataValueConverter<T>)).GetTypes();
                foreach (var type in types)
                {
                    if (type.IsSubclassOf(typeof(SaveDataValueConverter<T>)))
                    {
                        var method = type.GetMethod(
                            "FromString",
                            bindingAttr:
                                System.Reflection.BindingFlags.Static |
                                System.Reflection.BindingFlags.NonPublic
                        );
                        if (method == null)
                        {
                            throw new Exception($"{type.Name} に {typeof(T)} private static FromString(string) が実装されていません");
                        }
                        return (T)method.Invoke(null, new object[] { text });
                    }
                }
            }
            catch (Exception ex)
            {
                throw ex;
            }
            throw new Exception($"SaveDataValueConverter<{typeof(T)}> を継承するクラスが見つかりませんでした)");
        }
    }
```
```c#:Vector3Converter.cs
    public class Vector3Converter : SaveDataValueConverter<Vector3>
    {
        private static string ToString(Vector3 value) => value.ToString();
        private static Vector3 FromString(string text)
        {
            char[] trimChars = new char[] { '(', ')', ' ' };
            string[] items = text.Trim(trimChars).Split(',');
            var converter = TypeDescriptor.GetConverter(typeof(float));
            Vector3 value = Vector3.zero;
            value.x = (float)converter.ConvertFromString(items[0]);
            value.y = (float)converter.ConvertFromString(items[1]);
            value.z = (float)converter.ConvertFromString(items[2]);
            return value;
        }
    }
```