合并 P0 数据配置和存档
# Conflicts: # My project/Assets/FlowScope/Tests.meta # My project/Assets/FlowScope/Tests/EditMode.meta
This commit is contained in:
132
My project/Assets/FlowScope/Runtime/Config/JsonConfigProvider.cs
Normal file
132
My project/Assets/FlowScope/Runtime/Config/JsonConfigProvider.cs
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using FlowScope.Data;
|
||||||
|
|
||||||
|
namespace FlowScope.Config
|
||||||
|
{
|
||||||
|
public sealed class JsonConfigProvider : IConfigProvider
|
||||||
|
{
|
||||||
|
private readonly IReadOnlyList<ConfigMapping> _mappings;
|
||||||
|
private readonly Dictionary<Type, object> _rowsByType = new();
|
||||||
|
|
||||||
|
public JsonConfigProvider(IReadOnlyList<ConfigMapping> mappings)
|
||||||
|
{
|
||||||
|
_mappings = mappings ?? throw new ArgumentNullException(nameof(mappings));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ConfigMapping Mapping<T>(string fileName, Func<CancellationToken, Task<string>> loadTextAsync)
|
||||||
|
where T : class, IConfigRow
|
||||||
|
{
|
||||||
|
return new ConfigMapping(typeof(T), fileName, loadTextAsync);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task LoadAllAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var loadedRows = new Dictionary<Type, object>();
|
||||||
|
|
||||||
|
foreach (var mapping in _mappings)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
string json;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
json = await mapping.LoadTextAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Failed to load config file '{mapping.FileName}'.", exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
loadedRows[mapping.RowType] = mapping.Parse(json);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Failed to parse config file '{mapping.FileName}': {exception.GetBaseException().Message}",
|
||||||
|
exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_rowsByType.Clear();
|
||||||
|
foreach (var pair in loadedRows)
|
||||||
|
{
|
||||||
|
_rowsByType[pair.Key] = pair.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public T Get<T>(int id) where T : class, IConfigRow
|
||||||
|
{
|
||||||
|
return _rowsByType.TryGetValue(typeof(T), out var table) &&
|
||||||
|
table is Dictionary<int, T> rows &&
|
||||||
|
rows.TryGetValue(id, out var row)
|
||||||
|
? row
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<T> GetAll<T>() where T : class, IConfigRow
|
||||||
|
{
|
||||||
|
if (!_rowsByType.TryGetValue(typeof(T), out var table) || table is not Dictionary<int, T> rows)
|
||||||
|
{
|
||||||
|
return Array.Empty<T>();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new List<T>(rows.Values);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ConfigMapping
|
||||||
|
{
|
||||||
|
private readonly Func<string, object> _parse;
|
||||||
|
|
||||||
|
internal ConfigMapping(Type rowType, string fileName, Func<CancellationToken, Task<string>> loadTextAsync)
|
||||||
|
{
|
||||||
|
RowType = rowType ?? throw new ArgumentNullException(nameof(rowType));
|
||||||
|
FileName = string.IsNullOrWhiteSpace(fileName)
|
||||||
|
? throw new ArgumentException("Config file name is required.", nameof(fileName))
|
||||||
|
: fileName;
|
||||||
|
LoadTextAsync = loadTextAsync ?? throw new ArgumentNullException(nameof(loadTextAsync));
|
||||||
|
_parse = CreateParser(rowType);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Type RowType { get; }
|
||||||
|
public string FileName { get; }
|
||||||
|
public Func<CancellationToken, Task<string>> LoadTextAsync { get; }
|
||||||
|
|
||||||
|
internal object Parse(string json) => _parse(json);
|
||||||
|
|
||||||
|
private static Func<string, object> CreateParser(Type rowType)
|
||||||
|
{
|
||||||
|
var method = typeof(ConfigMapping)
|
||||||
|
.GetMethod(nameof(ParseRows), System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic)
|
||||||
|
.MakeGenericMethod(rowType);
|
||||||
|
|
||||||
|
return json => method.Invoke(null, new object[] { json });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object ParseRows<T>(string json) where T : class, IConfigRow
|
||||||
|
{
|
||||||
|
var rows = ReactivePropertyJsonConverter.DeserializeArray<T>(json);
|
||||||
|
var byId = new Dictionary<int, T>();
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
if (byId.ContainsKey(row.Id))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Duplicate config id {row.Id} for {typeof(T).Name}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
byId.Add(row.Id, row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return byId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 0b6c05d7a1d74798901d3efcc51cb7c4
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
8
My project/Assets/FlowScope/Runtime/Data.meta
Normal file
8
My project/Assets/FlowScope/Runtime/Data.meta
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 67eb0ac45b8b4cf8bafcb604dd6e0e11
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,699 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace FlowScope.Data
|
||||||
|
{
|
||||||
|
public static class ReactivePropertyJsonConverter
|
||||||
|
{
|
||||||
|
public static string SerializeObject(object value)
|
||||||
|
{
|
||||||
|
return SimpleJson.Write(ToJsonValue(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static T DeserializeObject<T>(string json)
|
||||||
|
{
|
||||||
|
var instance = (T)Activator.CreateInstance(typeof(T), true);
|
||||||
|
PopulateObject(json, instance);
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void PopulateObject(string json, object target)
|
||||||
|
{
|
||||||
|
if (target == null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(target));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (SimpleJson.Parse(json) is not Dictionary<string, object> values)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"JSON root must be an object for {target.GetType().Name}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
PopulateFromDictionary(values, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IReadOnlyList<T> DeserializeArray<T>(string json) where T : class
|
||||||
|
{
|
||||||
|
if (SimpleJson.Parse(json) is not List<object> rows)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("JSON root must be an array.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var results = new List<T>(rows.Count);
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
if (row is not Dictionary<string, object> rowValues)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"JSON row must be an object for {typeof(T).Name}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var instance = (T)Activator.CreateInstance(typeof(T), true);
|
||||||
|
PopulateFromDictionary(rowValues, instance);
|
||||||
|
results.Add(instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static object ToJsonValue(object value)
|
||||||
|
{
|
||||||
|
if (value == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var type = value.GetType();
|
||||||
|
if (IsSimple(type))
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TryGetReactivePropertyValue(value, out var reactiveValue))
|
||||||
|
{
|
||||||
|
return ToJsonValue(reactiveValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsReactiveCollection(type) || value is IEnumerable && value is not string)
|
||||||
|
{
|
||||||
|
var array = new List<object>();
|
||||||
|
foreach (var item in (IEnumerable)value)
|
||||||
|
{
|
||||||
|
array.Add(ToJsonValue(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
return array;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = new Dictionary<string, object>();
|
||||||
|
foreach (var property in GetSerializableProperties(type))
|
||||||
|
{
|
||||||
|
result[property.Name] = ToJsonValue(property.GetValue(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var field in GetSerializableFields(type))
|
||||||
|
{
|
||||||
|
result[field.Name] = ToJsonValue(field.GetValue(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static object ConvertValue(object value, Type targetType, string fieldName)
|
||||||
|
{
|
||||||
|
if (value == null)
|
||||||
|
{
|
||||||
|
if (targetType.IsValueType && Nullable.GetUnderlyingType(targetType) == null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Field '{fieldName}' cannot be null.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var nullableType = Nullable.GetUnderlyingType(targetType);
|
||||||
|
if (nullableType != null)
|
||||||
|
{
|
||||||
|
return ConvertValue(value, nullableType, fieldName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetType == typeof(string))
|
||||||
|
{
|
||||||
|
if (value is string text)
|
||||||
|
{
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw TypeMismatch(fieldName, targetType, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetType == typeof(bool))
|
||||||
|
{
|
||||||
|
if (value is bool boolValue)
|
||||||
|
{
|
||||||
|
return boolValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw TypeMismatch(fieldName, targetType, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetType.IsEnum)
|
||||||
|
{
|
||||||
|
if (value is string enumName)
|
||||||
|
{
|
||||||
|
return Enum.Parse(targetType, enumName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Enum.ToObject(targetType, Convert.ToInt32(value, CultureInfo.InvariantCulture));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsNumeric(targetType))
|
||||||
|
{
|
||||||
|
if (value is string || value is bool)
|
||||||
|
{
|
||||||
|
throw TypeMismatch(fieldName, targetType, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Convert.ChangeType(value, targetType, CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value is Dictionary<string, object> nested)
|
||||||
|
{
|
||||||
|
var instance = Activator.CreateInstance(targetType, true);
|
||||||
|
PopulateFromDictionary(nested, instance);
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw TypeMismatch(fieldName, targetType, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void PopulateFromDictionary(IReadOnlyDictionary<string, object> values, object target)
|
||||||
|
{
|
||||||
|
var members = GetWritableMembers(target.GetType());
|
||||||
|
foreach (var pair in values)
|
||||||
|
{
|
||||||
|
if (!members.TryGetValue(pair.Key, out var member))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
PopulateMember(target, member, pair.Value, pair.Key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void PopulateMember(object target, MemberInfo member, object value, string fieldName)
|
||||||
|
{
|
||||||
|
var memberType = GetMemberType(member);
|
||||||
|
var currentValue = GetMemberValue(member, target);
|
||||||
|
|
||||||
|
if (IsReactiveProperty(memberType))
|
||||||
|
{
|
||||||
|
var valueProperty = memberType.GetProperty("Value", BindingFlags.Instance | BindingFlags.Public);
|
||||||
|
var converted = ConvertValue(value, valueProperty.PropertyType, fieldName);
|
||||||
|
valueProperty.SetValue(currentValue, converted);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsReactiveCollection(memberType))
|
||||||
|
{
|
||||||
|
PopulateCollection(currentValue, memberType, value, fieldName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (CanSet(member))
|
||||||
|
{
|
||||||
|
SetMemberValue(member, target, ConvertValue(value, memberType, fieldName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void PopulateCollection(object collection, Type collectionType, object value, string fieldName)
|
||||||
|
{
|
||||||
|
if (value is not List<object> rows)
|
||||||
|
{
|
||||||
|
throw TypeMismatch(fieldName, collectionType, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
var itemType = collectionType.GetGenericArguments()[0];
|
||||||
|
collectionType.GetMethod("Clear", Type.EmptyTypes)?.Invoke(collection, Array.Empty<object>());
|
||||||
|
var addMethod = collectionType.GetMethod("Add", new[] { itemType });
|
||||||
|
if (addMethod == null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Field '{fieldName}' collection does not expose Add.");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
addMethod.Invoke(collection, new[] { ConvertValue(row, itemType, fieldName) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, MemberInfo> GetWritableMembers(Type type)
|
||||||
|
{
|
||||||
|
var members = new Dictionary<string, MemberInfo>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var property in type.GetProperties(BindingFlags.Instance | BindingFlags.Public))
|
||||||
|
{
|
||||||
|
if (property.GetIndexParameters().Length == 0)
|
||||||
|
{
|
||||||
|
members[property.Name] = property;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var field in type.GetFields(BindingFlags.Instance | BindingFlags.Public))
|
||||||
|
{
|
||||||
|
members[field.Name] = field;
|
||||||
|
}
|
||||||
|
|
||||||
|
return members;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<PropertyInfo> GetSerializableProperties(Type type)
|
||||||
|
{
|
||||||
|
foreach (var property in type.GetProperties(BindingFlags.Instance | BindingFlags.Public))
|
||||||
|
{
|
||||||
|
if (property.GetIndexParameters().Length == 0 && property.CanRead)
|
||||||
|
{
|
||||||
|
yield return property;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<FieldInfo> GetSerializableFields(Type type)
|
||||||
|
{
|
||||||
|
return type.GetFields(BindingFlags.Instance | BindingFlags.Public);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Type GetMemberType(MemberInfo member)
|
||||||
|
{
|
||||||
|
return member switch
|
||||||
|
{
|
||||||
|
PropertyInfo property => property.PropertyType,
|
||||||
|
FieldInfo field => field.FieldType,
|
||||||
|
_ => throw new NotSupportedException(member.MemberType.ToString())
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object GetMemberValue(MemberInfo member, object target)
|
||||||
|
{
|
||||||
|
return member switch
|
||||||
|
{
|
||||||
|
PropertyInfo property => property.GetValue(target),
|
||||||
|
FieldInfo field => field.GetValue(target),
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SetMemberValue(MemberInfo member, object target, object value)
|
||||||
|
{
|
||||||
|
switch (member)
|
||||||
|
{
|
||||||
|
case PropertyInfo property:
|
||||||
|
property.SetValue(target, value);
|
||||||
|
break;
|
||||||
|
case FieldInfo field:
|
||||||
|
field.SetValue(target, value);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool CanSet(MemberInfo member)
|
||||||
|
{
|
||||||
|
return member switch
|
||||||
|
{
|
||||||
|
PropertyInfo property => property.CanWrite,
|
||||||
|
FieldInfo field => !field.IsInitOnly,
|
||||||
|
_ => false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryGetReactivePropertyValue(object value, out object reactiveValue)
|
||||||
|
{
|
||||||
|
reactiveValue = null;
|
||||||
|
if (!IsReactiveProperty(value.GetType()))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
reactiveValue = value.GetType().GetProperty("Value", BindingFlags.Instance | BindingFlags.Public)?.GetValue(value);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsReactiveProperty(Type type)
|
||||||
|
{
|
||||||
|
return type.IsGenericType && type.GetGenericTypeDefinition().FullName == "R3.ReactiveProperty`1";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsReactiveCollection(Type type)
|
||||||
|
{
|
||||||
|
return type.IsGenericType && type.GetGenericTypeDefinition().FullName == "R3.ReactiveCollection`1";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsSimple(Type type)
|
||||||
|
{
|
||||||
|
type = Nullable.GetUnderlyingType(type) ?? type;
|
||||||
|
return type.IsPrimitive || type.IsEnum || type == typeof(string) || type == typeof(decimal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsNumeric(Type type)
|
||||||
|
{
|
||||||
|
type = Nullable.GetUnderlyingType(type) ?? type;
|
||||||
|
return type == typeof(byte) || type == typeof(sbyte) ||
|
||||||
|
type == typeof(short) || type == typeof(ushort) ||
|
||||||
|
type == typeof(int) || type == typeof(uint) ||
|
||||||
|
type == typeof(long) || type == typeof(ulong) ||
|
||||||
|
type == typeof(float) || type == typeof(double) ||
|
||||||
|
type == typeof(decimal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static InvalidOperationException TypeMismatch(string fieldName, Type targetType, object value)
|
||||||
|
{
|
||||||
|
return new InvalidOperationException(
|
||||||
|
$"Field '{fieldName}' expected {targetType.Name}, but JSON value was {value.GetType().Name}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class SimpleJson
|
||||||
|
{
|
||||||
|
public static object Parse(string json)
|
||||||
|
{
|
||||||
|
var parser = new Parser(json);
|
||||||
|
var value = parser.ParseValue();
|
||||||
|
parser.SkipWhitespace();
|
||||||
|
if (!parser.IsEnd)
|
||||||
|
{
|
||||||
|
throw new FormatException("Unexpected trailing JSON content.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string Write(object value)
|
||||||
|
{
|
||||||
|
var builder = new StringBuilder();
|
||||||
|
WriteValue(builder, value);
|
||||||
|
return builder.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteValue(StringBuilder builder, object value)
|
||||||
|
{
|
||||||
|
switch (value)
|
||||||
|
{
|
||||||
|
case null:
|
||||||
|
builder.Append("null");
|
||||||
|
break;
|
||||||
|
case string text:
|
||||||
|
WriteString(builder, text);
|
||||||
|
break;
|
||||||
|
case bool boolValue:
|
||||||
|
builder.Append(boolValue ? "true" : "false");
|
||||||
|
break;
|
||||||
|
case IDictionary<string, object> map:
|
||||||
|
WriteObject(builder, map);
|
||||||
|
break;
|
||||||
|
case IEnumerable<object> array:
|
||||||
|
WriteArray(builder, array);
|
||||||
|
break;
|
||||||
|
case float or double or decimal:
|
||||||
|
builder.Append(Convert.ToString(value, CultureInfo.InvariantCulture));
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
if (value.GetType().IsEnum)
|
||||||
|
{
|
||||||
|
WriteString(builder, value.ToString());
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
builder.Append(Convert.ToString(value, CultureInfo.InvariantCulture));
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteObject(StringBuilder builder, IDictionary<string, object> map)
|
||||||
|
{
|
||||||
|
builder.Append('{');
|
||||||
|
var first = true;
|
||||||
|
foreach (var pair in map)
|
||||||
|
{
|
||||||
|
if (!first)
|
||||||
|
{
|
||||||
|
builder.Append(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
first = false;
|
||||||
|
WriteString(builder, pair.Key);
|
||||||
|
builder.Append(':');
|
||||||
|
WriteValue(builder, pair.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.Append('}');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteArray(StringBuilder builder, IEnumerable<object> values)
|
||||||
|
{
|
||||||
|
builder.Append('[');
|
||||||
|
var first = true;
|
||||||
|
foreach (var value in values)
|
||||||
|
{
|
||||||
|
if (!first)
|
||||||
|
{
|
||||||
|
builder.Append(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
first = false;
|
||||||
|
WriteValue(builder, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.Append(']');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteString(StringBuilder builder, string value)
|
||||||
|
{
|
||||||
|
builder.Append('"');
|
||||||
|
foreach (var character in value)
|
||||||
|
{
|
||||||
|
switch (character)
|
||||||
|
{
|
||||||
|
case '"':
|
||||||
|
builder.Append("\\\"");
|
||||||
|
break;
|
||||||
|
case '\\':
|
||||||
|
builder.Append("\\\\");
|
||||||
|
break;
|
||||||
|
case '\n':
|
||||||
|
builder.Append("\\n");
|
||||||
|
break;
|
||||||
|
case '\r':
|
||||||
|
builder.Append("\\r");
|
||||||
|
break;
|
||||||
|
case '\t':
|
||||||
|
builder.Append("\\t");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
builder.Append(character);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.Append('"');
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Parser
|
||||||
|
{
|
||||||
|
private readonly string _json;
|
||||||
|
private int _index;
|
||||||
|
|
||||||
|
public Parser(string json)
|
||||||
|
{
|
||||||
|
_json = json ?? throw new ArgumentNullException(nameof(json));
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsEnd => _index >= _json.Length;
|
||||||
|
|
||||||
|
public object ParseValue()
|
||||||
|
{
|
||||||
|
SkipWhitespace();
|
||||||
|
if (IsEnd)
|
||||||
|
{
|
||||||
|
throw new FormatException("Unexpected end of JSON.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return _json[_index] switch
|
||||||
|
{
|
||||||
|
'{' => ParseObject(),
|
||||||
|
'[' => ParseArray(),
|
||||||
|
'"' => ParseString(),
|
||||||
|
't' => ParseLiteral("true", true),
|
||||||
|
'f' => ParseLiteral("false", false),
|
||||||
|
'n' => ParseLiteral("null", null),
|
||||||
|
_ => ParseNumber()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SkipWhitespace()
|
||||||
|
{
|
||||||
|
while (!IsEnd && char.IsWhiteSpace(_json[_index]))
|
||||||
|
{
|
||||||
|
_index++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Dictionary<string, object> ParseObject()
|
||||||
|
{
|
||||||
|
Expect('{');
|
||||||
|
var map = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
SkipWhitespace();
|
||||||
|
if (TryConsume('}'))
|
||||||
|
{
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
SkipWhitespace();
|
||||||
|
var key = ParseString();
|
||||||
|
SkipWhitespace();
|
||||||
|
Expect(':');
|
||||||
|
map[key] = ParseValue();
|
||||||
|
SkipWhitespace();
|
||||||
|
if (TryConsume('}'))
|
||||||
|
{
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
Expect(',');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<object> ParseArray()
|
||||||
|
{
|
||||||
|
Expect('[');
|
||||||
|
var array = new List<object>();
|
||||||
|
SkipWhitespace();
|
||||||
|
if (TryConsume(']'))
|
||||||
|
{
|
||||||
|
return array;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
array.Add(ParseValue());
|
||||||
|
SkipWhitespace();
|
||||||
|
if (TryConsume(']'))
|
||||||
|
{
|
||||||
|
return array;
|
||||||
|
}
|
||||||
|
|
||||||
|
Expect(',');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string ParseString()
|
||||||
|
{
|
||||||
|
Expect('"');
|
||||||
|
var builder = new StringBuilder();
|
||||||
|
while (!IsEnd)
|
||||||
|
{
|
||||||
|
var character = _json[_index++];
|
||||||
|
if (character == '"')
|
||||||
|
{
|
||||||
|
return builder.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (character != '\\')
|
||||||
|
{
|
||||||
|
builder.Append(character);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsEnd)
|
||||||
|
{
|
||||||
|
throw new FormatException("Unexpected end of JSON escape sequence.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var escaped = _json[_index++];
|
||||||
|
builder.Append(escaped switch
|
||||||
|
{
|
||||||
|
'"' => '"',
|
||||||
|
'\\' => '\\',
|
||||||
|
'/' => '/',
|
||||||
|
'b' => '\b',
|
||||||
|
'f' => '\f',
|
||||||
|
'n' => '\n',
|
||||||
|
'r' => '\r',
|
||||||
|
't' => '\t',
|
||||||
|
_ => throw new FormatException($"Unsupported JSON escape '\\{escaped}'.")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new FormatException("Unterminated JSON string.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private object ParseNumber()
|
||||||
|
{
|
||||||
|
var start = _index;
|
||||||
|
if (!IsEnd && _json[_index] == '-')
|
||||||
|
{
|
||||||
|
_index++;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (!IsEnd && char.IsDigit(_json[_index]))
|
||||||
|
{
|
||||||
|
_index++;
|
||||||
|
}
|
||||||
|
|
||||||
|
var isFloatingPoint = false;
|
||||||
|
if (!IsEnd && _json[_index] == '.')
|
||||||
|
{
|
||||||
|
isFloatingPoint = true;
|
||||||
|
_index++;
|
||||||
|
while (!IsEnd && char.IsDigit(_json[_index]))
|
||||||
|
{
|
||||||
|
_index++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsEnd && (_json[_index] == 'e' || _json[_index] == 'E'))
|
||||||
|
{
|
||||||
|
isFloatingPoint = true;
|
||||||
|
_index++;
|
||||||
|
if (!IsEnd && (_json[_index] == '+' || _json[_index] == '-'))
|
||||||
|
{
|
||||||
|
_index++;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (!IsEnd && char.IsDigit(_json[_index]))
|
||||||
|
{
|
||||||
|
_index++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var text = _json.Substring(start, _index - start);
|
||||||
|
if (string.IsNullOrEmpty(text) || text == "-")
|
||||||
|
{
|
||||||
|
throw new FormatException("Invalid JSON number.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return isFloatingPoint
|
||||||
|
? double.Parse(text, CultureInfo.InvariantCulture)
|
||||||
|
: long.Parse(text, CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
private object ParseLiteral(string literal, object value)
|
||||||
|
{
|
||||||
|
if (_index + literal.Length > _json.Length ||
|
||||||
|
string.CompareOrdinal(_json, _index, literal, 0, literal.Length) != 0)
|
||||||
|
{
|
||||||
|
throw new FormatException($"Invalid JSON literal near index {_index}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_index += literal.Length;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryConsume(char expected)
|
||||||
|
{
|
||||||
|
if (!IsEnd && _json[_index] == expected)
|
||||||
|
{
|
||||||
|
_index++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Expect(char expected)
|
||||||
|
{
|
||||||
|
if (IsEnd || _json[_index] != expected)
|
||||||
|
{
|
||||||
|
throw new FormatException($"Expected '{expected}' near index {_index}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_index++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 0c9f18f342e54f8d9a4dbdc61b74a2d1
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
93
My project/Assets/FlowScope/Runtime/Save/FileSaveStorage.cs
Normal file
93
My project/Assets/FlowScope/Runtime/Save/FileSaveStorage.cs
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace FlowScope.Save
|
||||||
|
{
|
||||||
|
public interface ISaveStorage
|
||||||
|
{
|
||||||
|
Task WriteAsync(string key, byte[] data, CancellationToken cancellationToken);
|
||||||
|
Task<byte[]> ReadAsync(string key, CancellationToken cancellationToken);
|
||||||
|
void Delete(string key);
|
||||||
|
bool Exists(string key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class FileSaveStorage : ISaveStorage
|
||||||
|
{
|
||||||
|
private readonly string _rootDirectory;
|
||||||
|
|
||||||
|
public FileSaveStorage(string rootDirectory = null)
|
||||||
|
{
|
||||||
|
_rootDirectory = string.IsNullOrWhiteSpace(rootDirectory)
|
||||||
|
? Path.Combine(Application.persistentDataPath, "FlowScopeSaves")
|
||||||
|
: rootDirectory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task WriteAsync(string key, byte[] data, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
if (data == null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
Directory.CreateDirectory(_rootDirectory);
|
||||||
|
var path = GetPath(key);
|
||||||
|
using var stream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, 4096, true);
|
||||||
|
await stream.WriteAsync(data, 0, data.Length, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<byte[]> ReadAsync(string key, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var path = GetPath(key);
|
||||||
|
if (!File.Exists(path))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, true);
|
||||||
|
var data = new byte[stream.Length];
|
||||||
|
var read = 0;
|
||||||
|
while (read < data.Length)
|
||||||
|
{
|
||||||
|
var count = await stream.ReadAsync(data, read, data.Length - read, cancellationToken);
|
||||||
|
if (count == 0)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
read += count;
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Delete(string key)
|
||||||
|
{
|
||||||
|
var path = GetPath(key);
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
File.Delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Exists(string key)
|
||||||
|
{
|
||||||
|
return File.Exists(GetPath(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetPath(string key)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(key))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Save key is required.", nameof(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Path.Combine(_rootDirectory, Convert.ToBase64String(Encoding.UTF8.GetBytes(key)) + ".json");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 59beaa2c506548ea9f526f3784f5f938
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using System;
|
||||||
|
using System.Text;
|
||||||
|
using FlowScope.Data;
|
||||||
|
|
||||||
|
namespace FlowScope.Save
|
||||||
|
{
|
||||||
|
public interface ISaveSerializer
|
||||||
|
{
|
||||||
|
byte[] Serialize<T>(T data);
|
||||||
|
T Deserialize<T>(byte[] bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class JsonSaveSerializer : ISaveSerializer
|
||||||
|
{
|
||||||
|
public byte[] Serialize<T>(T data)
|
||||||
|
{
|
||||||
|
return Encoding.UTF8.GetBytes(SerializeToString(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
public T Deserialize<T>(byte[] bytes)
|
||||||
|
{
|
||||||
|
if (bytes == null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(bytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
return DeserializeFromString<T>(Encoding.UTF8.GetString(bytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
public string SerializeToString<T>(T data)
|
||||||
|
{
|
||||||
|
return ReactivePropertyJsonConverter.SerializeObject(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public T DeserializeFromString<T>(string json)
|
||||||
|
{
|
||||||
|
var instance = (T)Activator.CreateInstance(typeof(T), true);
|
||||||
|
PopulateFromString(json, instance);
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void PopulateFromString<T>(string json, T target)
|
||||||
|
{
|
||||||
|
ReactivePropertyJsonConverter.PopulateObject(json, target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 6823b62f8e7044d0a8f7e85fc33ca10b
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace FlowScope.Save
|
||||||
|
{
|
||||||
|
public sealed class PlayerPrefsSaveStorage : ISaveStorage
|
||||||
|
{
|
||||||
|
private const string Prefix = "FlowScope.Save.";
|
||||||
|
|
||||||
|
public Task WriteAsync(string key, byte[] data, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
if (data == null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
PlayerPrefs.SetString(ToPrefsKey(key), Convert.ToBase64String(data));
|
||||||
|
PlayerPrefs.Save();
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<byte[]> ReadAsync(string key, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var prefsKey = ToPrefsKey(key);
|
||||||
|
return Task.FromResult(PlayerPrefs.HasKey(prefsKey)
|
||||||
|
? Convert.FromBase64String(PlayerPrefs.GetString(prefsKey))
|
||||||
|
: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Delete(string key)
|
||||||
|
{
|
||||||
|
PlayerPrefs.DeleteKey(ToPrefsKey(key));
|
||||||
|
PlayerPrefs.Save();
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Exists(string key)
|
||||||
|
{
|
||||||
|
return PlayerPrefs.HasKey(ToPrefsKey(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ToPrefsKey(string key)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(key))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Save key is required.", nameof(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Prefix + key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: d421a058fe6d4927ad7b3f48b3060529
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
81
My project/Assets/FlowScope/Runtime/Save/SaveService.cs
Normal file
81
My project/Assets/FlowScope/Runtime/Save/SaveService.cs
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace FlowScope.Save
|
||||||
|
{
|
||||||
|
public sealed class SaveService : ISaveService
|
||||||
|
{
|
||||||
|
private readonly ISaveStorage _storage;
|
||||||
|
private readonly ISaveSerializer _serializer;
|
||||||
|
|
||||||
|
public SaveService(ISaveStorage storage, ISaveSerializer serializer)
|
||||||
|
{
|
||||||
|
_storage = storage ?? throw new ArgumentNullException(nameof(storage));
|
||||||
|
_serializer = serializer ?? throw new ArgumentNullException(nameof(serializer));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SaveAsync<T>(string key, T data, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var bytes = _serializer.Serialize(data);
|
||||||
|
await _storage.WriteAsync(key, bytes, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Failed to write save key '{key}'.", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<T> LoadAsync<T>(string key, T defaultValue, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
byte[] bytes;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
bytes = await _storage.ReadAsync(key, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
Debug.LogWarning($"Failed to read save key '{key}': {exception.Message}");
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bytes == null)
|
||||||
|
{
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return _serializer.Deserialize<T>(bytes);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
Debug.LogWarning($"Failed to deserialize save key '{key}': {exception.Message}");
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Delete(string key)
|
||||||
|
{
|
||||||
|
_storage.Delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Exists(string key)
|
||||||
|
{
|
||||||
|
return _storage.Exists(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
My project/Assets/FlowScope/Runtime/Save/SaveService.cs.meta
Normal file
11
My project/Assets/FlowScope/Runtime/Save/SaveService.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: c71e93285f6c4f8287c96785fe1975d6
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
8
My project/Assets/FlowScope/Tests/EditMode/Config.meta
Normal file
8
My project/Assets/FlowScope/Tests/EditMode/Config.meta
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: c2c7341087f04116b19ce4c0b982ae51
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using FlowScope.Config;
|
||||||
|
using NUnit.Framework;
|
||||||
|
|
||||||
|
namespace FlowScope.Tests.EditMode.Config
|
||||||
|
{
|
||||||
|
public sealed class JsonConfigProviderTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public async Task LoadAllAsync_LoadsRegisteredJsonRows()
|
||||||
|
{
|
||||||
|
var provider = new JsonConfigProvider(new[]
|
||||||
|
{
|
||||||
|
JsonConfigProvider.Mapping<WeaponConfig>("weapons.json", _ => Task.FromResult("[{\"id\":101,\"name\":\"Axe\"}]"))
|
||||||
|
});
|
||||||
|
|
||||||
|
await provider.LoadAllAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.That(provider.Get<WeaponConfig>(101).Name, Is.EqualTo("Axe"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Get_MissingId_ReturnsNull()
|
||||||
|
{
|
||||||
|
var provider = new JsonConfigProvider(new[]
|
||||||
|
{
|
||||||
|
JsonConfigProvider.Mapping<WeaponConfig>("weapons.json", _ => Task.FromResult("[{\"id\":101,\"name\":\"Axe\"}]"))
|
||||||
|
});
|
||||||
|
|
||||||
|
await provider.LoadAllAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.That(provider.Get<WeaponConfig>(999), Is.Null);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GetAll_UnloadedType_ReturnsEmptyList()
|
||||||
|
{
|
||||||
|
var provider = new JsonConfigProvider(Array.Empty<JsonConfigProvider.ConfigMapping>());
|
||||||
|
|
||||||
|
await provider.LoadAllAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.That(provider.GetAll<WeaponConfig>(), Is.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void LoadAllAsync_DuplicateId_ThrowsWithId()
|
||||||
|
{
|
||||||
|
var provider = new JsonConfigProvider(new[]
|
||||||
|
{
|
||||||
|
JsonConfigProvider.Mapping<WeaponConfig>("weapons.json", _ => Task.FromResult("[{\"id\":101,\"name\":\"Axe\"},{\"id\":101,\"name\":\"Sword\"}]"))
|
||||||
|
});
|
||||||
|
|
||||||
|
var exception = Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
async () => await provider.LoadAllAsync(CancellationToken.None));
|
||||||
|
|
||||||
|
Assert.That(exception.Message, Does.Contain("101"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void LoadAllAsync_MalformedJson_ThrowsWithFileName()
|
||||||
|
{
|
||||||
|
var provider = new JsonConfigProvider(new[]
|
||||||
|
{
|
||||||
|
JsonConfigProvider.Mapping<WeaponConfig>("weapons.json", _ => Task.FromResult("[{\"id\":101"))
|
||||||
|
});
|
||||||
|
|
||||||
|
var exception = Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
async () => await provider.LoadAllAsync(CancellationToken.None));
|
||||||
|
|
||||||
|
Assert.That(exception.Message, Does.Contain("weapons.json"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void LoadAllAsync_CanceledToken_ThrowsOperationCanceledException()
|
||||||
|
{
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
cts.Cancel();
|
||||||
|
var provider = new JsonConfigProvider(new[]
|
||||||
|
{
|
||||||
|
JsonConfigProvider.Mapping<WeaponConfig>("weapons.json", _ => Task.FromResult("[]"))
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.ThrowsAsync<OperationCanceledException>(
|
||||||
|
async () => await provider.LoadAllAsync(cts.Token));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class WeaponConfig : IConfigRow
|
||||||
|
{
|
||||||
|
public int id;
|
||||||
|
public string name;
|
||||||
|
|
||||||
|
public int Id => id;
|
||||||
|
public string Name => name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 08b0393564b64127a76abf9480fcf28b
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
8
My project/Assets/FlowScope/Tests/EditMode/Data.meta
Normal file
8
My project/Assets/FlowScope/Tests/EditMode/Data.meta
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 3b65c31816084c438fa4c0182033c9fc
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
using System;
|
||||||
|
using FlowScope.Save;
|
||||||
|
using NUnit.Framework;
|
||||||
|
using R3;
|
||||||
|
|
||||||
|
namespace FlowScope.Tests.EditMode.Data
|
||||||
|
{
|
||||||
|
public sealed class DataSerializationTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public void Serialize_ReactiveProperty_WritesValueOnly()
|
||||||
|
{
|
||||||
|
var serializer = new JsonSaveSerializer();
|
||||||
|
var data = new PlayerData();
|
||||||
|
|
||||||
|
data.Gold.Value = 25;
|
||||||
|
|
||||||
|
var json = serializer.SerializeToString(data);
|
||||||
|
|
||||||
|
Assert.That(json, Does.Contain("\"Gold\":25"));
|
||||||
|
Assert.That(json, Does.Not.Contain("ReactiveProperty"));
|
||||||
|
Assert.That(json, Does.Not.Contain("CurrentValue"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Deserialize_ReactiveProperty_UpdatesExistingDataInstance()
|
||||||
|
{
|
||||||
|
var serializer = new JsonSaveSerializer();
|
||||||
|
var data = new PlayerData();
|
||||||
|
|
||||||
|
serializer.PopulateFromString("{\"Gold\":42,\"Level\":7}", data);
|
||||||
|
|
||||||
|
Assert.That(data.Gold.Value, Is.EqualTo(42));
|
||||||
|
Assert.That(data.Level.Value, Is.EqualTo(7));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Serialize_ReactiveCollection_WritesJsonArray()
|
||||||
|
{
|
||||||
|
var serializer = new JsonSaveSerializer();
|
||||||
|
var data = new InventoryData();
|
||||||
|
|
||||||
|
data.ItemIds.Add(3);
|
||||||
|
data.ItemIds.Add(5);
|
||||||
|
|
||||||
|
var json = serializer.SerializeToString(data);
|
||||||
|
|
||||||
|
Assert.That(json, Does.Contain("\"ItemIds\":[3,5]"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Deserialize_ReactiveCollection_ReplacesCollectionContents()
|
||||||
|
{
|
||||||
|
var serializer = new JsonSaveSerializer();
|
||||||
|
var data = new InventoryData();
|
||||||
|
|
||||||
|
data.ItemIds.Add(99);
|
||||||
|
serializer.PopulateFromString("{\"ItemIds\":[1,2,3]}", data);
|
||||||
|
|
||||||
|
Assert.That(data.ItemIds, Is.EqualTo(new[] { 1, 2, 3 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Populate_TypeMismatch_ThrowsWithFieldName()
|
||||||
|
{
|
||||||
|
var serializer = new JsonSaveSerializer();
|
||||||
|
|
||||||
|
var exception = Assert.Throws<InvalidOperationException>(
|
||||||
|
() => serializer.PopulateFromString("{\"Gold\":\"wrong\"}", new PlayerData()));
|
||||||
|
|
||||||
|
Assert.That(exception.Message, Does.Contain("Gold"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class PlayerData
|
||||||
|
{
|
||||||
|
public ReactiveProperty<int> Gold { get; } = new(0);
|
||||||
|
public ReactiveProperty<int> Level { get; } = new(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class InventoryData
|
||||||
|
{
|
||||||
|
public ReactiveCollection<int> ItemIds { get; } = new();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 6e4a5ec0b75042a78bf3a035173f4f40
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
8
My project/Assets/FlowScope/Tests/EditMode/Save.meta
Normal file
8
My project/Assets/FlowScope/Tests/EditMode/Save.meta
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 0f6957e7aa224958a64f86fd324ef2d4
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using FlowScope.Save;
|
||||||
|
using NUnit.Framework;
|
||||||
|
using R3;
|
||||||
|
|
||||||
|
namespace FlowScope.Tests.EditMode.Save
|
||||||
|
{
|
||||||
|
public sealed class SaveServiceTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public async Task SaveAsync_ThenLoadAsync_ReturnsEquivalentData()
|
||||||
|
{
|
||||||
|
var service = new SaveService(new MemorySaveStorage(), new JsonSaveSerializer());
|
||||||
|
var data = new PlayerData();
|
||||||
|
data.Gold.Value = 88;
|
||||||
|
|
||||||
|
await service.SaveAsync("player", data, CancellationToken.None);
|
||||||
|
var loaded = await service.LoadAsync("player", new PlayerData(), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.That(loaded.Gold.Value, Is.EqualTo(88));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task LoadAsync_MissingKey_ReturnsDefaultValue()
|
||||||
|
{
|
||||||
|
var service = new SaveService(new MemorySaveStorage(), new JsonSaveSerializer());
|
||||||
|
var fallback = new PlayerData();
|
||||||
|
fallback.Gold.Value = 5;
|
||||||
|
|
||||||
|
var loaded = await service.LoadAsync("missing", fallback, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.That(loaded, Is.SameAs(fallback));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Delete_RemovesExistingKey()
|
||||||
|
{
|
||||||
|
var service = new SaveService(new MemorySaveStorage(), new JsonSaveSerializer());
|
||||||
|
|
||||||
|
await service.SaveAsync("player", new PlayerData(), CancellationToken.None);
|
||||||
|
service.Delete("player");
|
||||||
|
|
||||||
|
Assert.That(service.Exists("player"), Is.False);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task LoadAsync_DeserializeFailure_ReturnsDefaultValue()
|
||||||
|
{
|
||||||
|
var storage = new MemorySaveStorage();
|
||||||
|
storage.Seed("player", new byte[] { 123, 34, 71, 111, 108, 100 });
|
||||||
|
var service = new SaveService(storage, new JsonSaveSerializer());
|
||||||
|
var fallback = new PlayerData();
|
||||||
|
fallback.Gold.Value = 9;
|
||||||
|
|
||||||
|
var loaded = await service.LoadAsync("player", fallback, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.That(loaded, Is.SameAs(fallback));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void SaveAsync_WriteFailure_ThrowsWithKey()
|
||||||
|
{
|
||||||
|
var service = new SaveService(new FailingWriteStorage(), new JsonSaveSerializer());
|
||||||
|
|
||||||
|
var exception = Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
async () => await service.SaveAsync("player", new PlayerData(), CancellationToken.None));
|
||||||
|
|
||||||
|
Assert.That(exception.Message, Does.Contain("player"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void SaveAsync_CanceledToken_ThrowsOperationCanceledException()
|
||||||
|
{
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
cts.Cancel();
|
||||||
|
var service = new SaveService(new MemorySaveStorage(), new JsonSaveSerializer());
|
||||||
|
|
||||||
|
Assert.ThrowsAsync<OperationCanceledException>(
|
||||||
|
async () => await service.SaveAsync("player", new PlayerData(), cts.Token));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class PlayerData
|
||||||
|
{
|
||||||
|
public ReactiveProperty<int> Gold { get; } = new(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class MemorySaveStorage : ISaveStorage
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, byte[]> _entries = new();
|
||||||
|
|
||||||
|
public Task WriteAsync(string key, byte[] data, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
_entries[key] = data;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<byte[]> ReadAsync(string key, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
return Task.FromResult(_entries.TryGetValue(key, out var data) ? data : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Delete(string key) => _entries.Remove(key);
|
||||||
|
public bool Exists(string key) => _entries.ContainsKey(key);
|
||||||
|
public void Seed(string key, byte[] data) => _entries[key] = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FailingWriteStorage : ISaveStorage
|
||||||
|
{
|
||||||
|
public Task WriteAsync(string key, byte[] data, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
throw new Exception("write failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<byte[]> ReadAsync(string key, CancellationToken cancellationToken) => Task.FromResult<byte[]>(null);
|
||||||
|
public void Delete(string key) { }
|
||||||
|
public bool Exists(string key) => false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 934dbfb274c942f4ae780e66c390b5c1
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
Reference in New Issue
Block a user