1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Unityでセーブデータを扱う

1
Last updated at Posted at 2022-10-30

はじめに

Unityでセーブデータが必要なゲームの制作をすることになったので、自分で実装してみました。
UniTaskを使用しています。このセーブデータ処理を使用する場合はUniTaskの導入をするか、自分でUniTaskを使わない実装にしてください。
ご指摘等、お待ちしております。

実装の条件

・宣言した時点でセーブデータに使用される値であることが確定する
・宣言したクラスの外に値が漏れ出すことがない
・追加、削除が柔軟に行える

現在の問題点

・string型の保存ができない
・保存先をSaveableValue内から直接参照してしまっている

コード

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;
        }
    }

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; }
    }

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;
        }
    }

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);
            }
        }
    }

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;
            }
        }
    }

IInitializedCallBack.cs
    //SaveableValueに外部から初期化時の処理を登録したいとき、この型で公開する
    public interface IInitializedCallback<T>
    {
        void Add(System.Action<T> callback);
    }
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);
    }
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)}> を継承するクラスが見つかりませんでした)");
        }
    }
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;
        }
    }
1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?