はじめに
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;
}
}