新增 FlowScope Git 包雏形

This commit is contained in:
JSD\13999
2026-06-04 14:54:49 +08:00
parent 37e6d4ca57
commit f5887da9fb
127 changed files with 5330 additions and 0 deletions

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

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 59beaa2c506548ea9f526f3784f5f938
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,11 @@
namespace FlowScope.Save
{
public interface ISaveMigration
{
string Key { get; }
int FromVersion { get; }
int ToVersion { get; }
string Migrate(string json);
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 689eab5eef8647009da3f56423bd0a3d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

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

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 02c1b05d79f4432dbb64923943158c57
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

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

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6823b62f8e7044d0a8f7e85fc33ca10b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

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

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d421a058fe6d4927ad7b3f48b3060529
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
namespace FlowScope.Save
{
internal sealed class SaveEnvelope
{
public int Version { get; set; }
public string Payload { get; set; }
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 67ada4b73d47420db150f34ab12a236a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

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

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 3f2c3dd694c94ed59d08bcdf88d830a7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

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

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c71e93285f6c4f8287c96785fe1975d6
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: