新增 FlowScope Git 包雏形
This commit is contained in:
@@ -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,11 @@
|
||||
namespace FlowScope.Save
|
||||
{
|
||||
public interface ISaveMigration
|
||||
{
|
||||
string Key { get; }
|
||||
int FromVersion { get; }
|
||||
int ToVersion { get; }
|
||||
|
||||
string Migrate(string json);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 689eab5eef8647009da3f56423bd0a3d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
13
Packages/com.flowscope.gamecore/Runtime/Save/ISaveService.cs
Normal file
13
Packages/com.flowscope.gamecore/Runtime/Save/ISaveService.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace FlowScope.Save
|
||||
{
|
||||
public interface ISaveService
|
||||
{
|
||||
Task SaveAsync<T>(string key, T data, CancellationToken cancellationToken);
|
||||
Task<T> LoadAsync<T>(string key, T defaultValue, CancellationToken cancellationToken);
|
||||
void Delete(string key);
|
||||
bool Exists(string key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 02c1b05d79f4432dbb64923943158c57
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,119 @@
|
||||
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
|
||||
{
|
||||
private readonly int _currentVersion;
|
||||
private readonly SaveMigrationRegistry _migrations;
|
||||
|
||||
public JsonSaveSerializer(
|
||||
int currentVersion = 1,
|
||||
SaveMigrationRegistry migrations = null)
|
||||
{
|
||||
if (currentVersion < 1)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(currentVersion));
|
||||
}
|
||||
|
||||
_currentVersion = currentVersion;
|
||||
_migrations = migrations ?? new SaveMigrationRegistry();
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
var payload = ReactivePropertyJsonConverter.SerializeObject(data);
|
||||
if (_currentVersion <= 1)
|
||||
{
|
||||
return payload;
|
||||
}
|
||||
|
||||
return ReactivePropertyJsonConverter.SerializeObject(new SaveEnvelope
|
||||
{
|
||||
Version = _currentVersion,
|
||||
Payload = payload
|
||||
});
|
||||
}
|
||||
|
||||
public T DeserializeFromString<T>(string json)
|
||||
{
|
||||
var instance = (T)Activator.CreateInstance(typeof(T), true);
|
||||
PopulateFromString(ResolvePayload<T>(json), instance);
|
||||
return instance;
|
||||
}
|
||||
|
||||
public void PopulateFromString<T>(string json, T target)
|
||||
{
|
||||
ReactivePropertyJsonConverter.PopulateObject(ResolvePayload<T>(json), target);
|
||||
}
|
||||
|
||||
private string ResolvePayload<T>(string json)
|
||||
{
|
||||
if (!TryReadEnvelope(json, out var envelope))
|
||||
{
|
||||
return _currentVersion > 1
|
||||
? _migrations.Migrate(GetMigrationKey<T>(), 1, _currentVersion, json)
|
||||
: json;
|
||||
}
|
||||
|
||||
if (envelope.Version == _currentVersion)
|
||||
{
|
||||
return envelope.Payload;
|
||||
}
|
||||
|
||||
if (envelope.Version > _currentVersion)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Save version {envelope.Version} is newer than current version {_currentVersion}.");
|
||||
}
|
||||
|
||||
return _migrations.Migrate(
|
||||
GetMigrationKey<T>(),
|
||||
envelope.Version,
|
||||
_currentVersion,
|
||||
envelope.Payload);
|
||||
}
|
||||
|
||||
private static bool TryReadEnvelope(string json, out SaveEnvelope envelope)
|
||||
{
|
||||
envelope = null;
|
||||
try
|
||||
{
|
||||
envelope = ReactivePropertyJsonConverter.DeserializeObject<SaveEnvelope>(json);
|
||||
return envelope.Version > 0 && envelope.Payload != null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetMigrationKey<T>()
|
||||
{
|
||||
return typeof(T).FullName;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace FlowScope.Save
|
||||
{
|
||||
internal sealed class SaveEnvelope
|
||||
{
|
||||
public int Version { get; set; }
|
||||
public string Payload { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 67ada4b73d47420db150f34ab12a236a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,74 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace FlowScope.Save
|
||||
{
|
||||
public sealed class SaveMigrationRegistry
|
||||
{
|
||||
private readonly Dictionary<MigrationKey, ISaveMigration> _migrations = new();
|
||||
|
||||
public void Register(ISaveMigration migration)
|
||||
{
|
||||
if (migration == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(migration));
|
||||
}
|
||||
|
||||
_migrations[new MigrationKey(migration.Key, migration.FromVersion)] = migration;
|
||||
}
|
||||
|
||||
public string Migrate(
|
||||
string key,
|
||||
int fromVersion,
|
||||
int currentVersion,
|
||||
string json)
|
||||
{
|
||||
var current = fromVersion;
|
||||
var payload = json;
|
||||
while (current < currentVersion)
|
||||
{
|
||||
if (!_migrations.TryGetValue(new MigrationKey(key, current), out var migration) ||
|
||||
migration.ToVersion != current + 1)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Missing save migration for key '{key}' from version {current}.");
|
||||
}
|
||||
|
||||
payload = migration.Migrate(payload);
|
||||
current = migration.ToVersion;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
private readonly struct MigrationKey : IEquatable<MigrationKey>
|
||||
{
|
||||
private readonly string _key;
|
||||
private readonly int _fromVersion;
|
||||
|
||||
public MigrationKey(string key, int fromVersion)
|
||||
{
|
||||
_key = key ?? string.Empty;
|
||||
_fromVersion = fromVersion;
|
||||
}
|
||||
|
||||
public bool Equals(MigrationKey other)
|
||||
{
|
||||
return _key == other._key && _fromVersion == other._fromVersion;
|
||||
}
|
||||
|
||||
public override bool Equals(object obj)
|
||||
{
|
||||
return obj is MigrationKey other && Equals(other);
|
||||
}
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
unchecked
|
||||
{
|
||||
return ((_key != null ? _key.GetHashCode() : 0) * 397) ^ _fromVersion;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3f2c3dd694c94ed59d08bcdf88d830a7
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
81
Packages/com.flowscope.gamecore/Runtime/Save/SaveService.cs
Normal file
81
Packages/com.flowscope.gamecore/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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c71e93285f6c4f8287c96785fe1975d6
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user