Compare commits
44 Commits
dd341a1664
...
1c53319961
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c53319961 | ||
|
|
a879badd2a | ||
|
|
a0508ff630 | ||
|
|
1efe6c9974 | ||
|
|
1e7ae22ddc | ||
|
|
ff3dc749f7 | ||
|
|
f855c34bf9 | ||
|
|
6da0a7822c | ||
|
|
e1197b5c0f | ||
|
|
e866e75e86 | ||
|
|
05ecb0916c | ||
|
|
afcb02591d | ||
|
|
436cf6ef56 | ||
|
|
a51145ae2c | ||
|
|
f7473ab016 | ||
|
|
0257529a6f | ||
|
|
c0163d4d1c | ||
|
|
29ebeb57b2 | ||
|
|
23d235dad6 | ||
|
|
48785f9f6a | ||
|
|
210715d0cd | ||
|
|
686dec326e | ||
|
|
e35981d246 | ||
|
|
fac8e9a7f3 | ||
|
|
06d7b0dc75 | ||
|
|
00a3368df5 | ||
|
|
d30120c6cd | ||
|
|
040721c713 | ||
|
|
e4074cf388 | ||
|
|
d3fd0706f1 | ||
|
|
912ad25c67 | ||
|
|
eff2ad1b3a | ||
|
|
4befd6655b | ||
|
|
23905fcbea | ||
|
|
d37faae707 | ||
|
|
dfd1c4cfae | ||
|
|
742b8aba25 | ||
|
|
b1018c120d | ||
|
|
135c5a071d | ||
|
|
49318c50a4 | ||
|
|
76c35d8d99 | ||
|
|
019703d2a8 | ||
|
|
d7b09891dc | ||
|
|
e84f437f40 |
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
Confs
|
||||||
|
Client/ServerData
|
||||||
|
/assetbundles
|
||||||
|
.DS_Store
|
||||||
|
.vscode/
|
||||||
|
/cfgPack/encryptcfg
|
||||||
|
.gemini/
|
||||||
|
.worktrees/
|
||||||
|
GEMINI.md
|
||||||
|
CLAUDE.md
|
||||||
|
AGENTS.md
|
||||||
|
QWEN.md
|
||||||
|
Client/.claude/
|
||||||
|
dev-ab-svr/debugconfig.json
|
||||||
101
My project/.gitignore
vendored
Normal file
101
My project/.gitignore
vendored
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# This .gitignore file should be placed at the root of your Unity project directory
|
||||||
|
#
|
||||||
|
# Get latest from https://github.com/github/gitignore/blob/master/Unity.gitignore
|
||||||
|
#
|
||||||
|
/[Ll]ibrary/
|
||||||
|
/[Tt]emp/
|
||||||
|
/[Oo]bj/
|
||||||
|
/[Bb]uild/
|
||||||
|
/[Bb]uilds/
|
||||||
|
/[Ll]ogs/
|
||||||
|
/[Uu]ser[Ss]ettings/
|
||||||
|
/[Bb]in/
|
||||||
|
/SerializedBuildInfo/
|
||||||
|
|
||||||
|
# ignore all files and folders ends with "~"
|
||||||
|
*~
|
||||||
|
|
||||||
|
# MemoryCaptures can get excessive in size.
|
||||||
|
# They also could contain extremely sensitive data
|
||||||
|
/[Mm]emoryCaptures/
|
||||||
|
|
||||||
|
# Asset meta data should only be ignored when the corresponding asset is also ignored
|
||||||
|
!/[Aa]ssets/**/*.meta
|
||||||
|
|
||||||
|
# Uncomment this line if you wish to ignore the asset store tools plugin
|
||||||
|
# /[Aa]ssets/AssetStoreTools*
|
||||||
|
|
||||||
|
# Autogenerated Jetbrains Rider plugin
|
||||||
|
/[Aa]ssets/Plugins/Editor/JetBrains*
|
||||||
|
|
||||||
|
# Visual Studio cache directory
|
||||||
|
.vs/
|
||||||
|
|
||||||
|
# Gradle cache directory
|
||||||
|
.gradle/
|
||||||
|
|
||||||
|
# Autogenerated VS/MD/Consulo solution and project files
|
||||||
|
ExportedObj/
|
||||||
|
.consulo/
|
||||||
|
*.csproj
|
||||||
|
*.unityproj
|
||||||
|
*.sln
|
||||||
|
*.suo
|
||||||
|
*.tmp
|
||||||
|
*.user
|
||||||
|
*.userprefs
|
||||||
|
*.pidb
|
||||||
|
*.booproj
|
||||||
|
*.svd
|
||||||
|
*.pdb
|
||||||
|
*.mdb
|
||||||
|
*.opendb
|
||||||
|
*.VC.db
|
||||||
|
.vsconfig
|
||||||
|
.vshistory
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# Unity3D generated meta files
|
||||||
|
*.pidb.meta
|
||||||
|
*.pdb.meta
|
||||||
|
*.mdb.meta
|
||||||
|
|
||||||
|
# Unity3D generated file on crash reports
|
||||||
|
sysinfo.txt
|
||||||
|
|
||||||
|
# Builds
|
||||||
|
*.apk
|
||||||
|
*.unitypackage
|
||||||
|
|
||||||
|
# Crashlytics generated file
|
||||||
|
crashlytics-build.properties
|
||||||
|
|
||||||
|
|
||||||
|
# ignore Test Scripts & resources in test folder
|
||||||
|
/[Aa]ssets/[Tt]emp[Tt]est/
|
||||||
|
/[Aa]ssets/[Tt]emp[Tt]est
|
||||||
|
/[Aa]ssets/[Tt]emp[Tt]est.meta
|
||||||
|
|
||||||
|
|
||||||
|
# ignore build info from reporter
|
||||||
|
/[Aa]ssets/StreamingAssets/build_info/
|
||||||
|
/[Aa]ssets/StreamingAssets/build_info
|
||||||
|
/[Aa]ssets/StreamingAssets/build_info.meta
|
||||||
|
|
||||||
|
## ignore generated playfab async scripts
|
||||||
|
#/LocalPackages/asap.playfab.async/Runtime/Gen.meta
|
||||||
|
#/LocalPackages/asap.playfab.async/Runtime/Gen/
|
||||||
|
#/LocalPackages/asap.playfab.async/Runtime/Gen
|
||||||
|
|
||||||
|
# ignore core source code
|
||||||
|
/LocalPackages/GameCore/
|
||||||
|
/LocalPackages/asap.core/
|
||||||
|
|
||||||
|
# HybridCLR Temp project
|
||||||
|
/HybridCLRData/StrippedAOTDllsTempProj
|
||||||
|
|
||||||
|
# ignore idea files
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# ignore new slnx files
|
||||||
|
*.slnx
|
||||||
8
My project/Assets/FlowScope.meta
Normal file
8
My project/Assets/FlowScope.meta
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 6f15a20a42b04f9a86de633ca4831e25
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
8
My project/Assets/FlowScope/Addressables.meta
Normal file
8
My project/Assets/FlowScope/Addressables.meta
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: e74ee61e34d742b9b44057032c6431bb
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using FlowScope.Resources;
|
||||||
|
using UnityEngine.AddressableAssets;
|
||||||
|
|
||||||
|
namespace FlowScope.Addressables
|
||||||
|
{
|
||||||
|
public sealed class AddressablesResourceBackend : IResourceBackend
|
||||||
|
{
|
||||||
|
public string Name => "Addressables";
|
||||||
|
|
||||||
|
public async Task<T> LoadAsync<T>(string key, CancellationToken cancellationToken)
|
||||||
|
where T : class
|
||||||
|
{
|
||||||
|
var handle = Addressables.LoadAssetAsync<T>(key);
|
||||||
|
await WaitWithCancellation(handle.Task, cancellationToken);
|
||||||
|
return handle.Result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Release<T>(string key, T asset)
|
||||||
|
where T : class
|
||||||
|
{
|
||||||
|
Addressables.Release(asset);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WaitWithCancellation(Task task, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!cancellationToken.CanBeCanceled)
|
||||||
|
{
|
||||||
|
await task;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var cancellation = new TaskCompletionSource<bool>();
|
||||||
|
using (cancellationToken.Register(() => cancellation.TrySetResult(true)))
|
||||||
|
{
|
||||||
|
if (await Task.WhenAny(task, cancellation.Task) == cancellation.Task)
|
||||||
|
{
|
||||||
|
throw new OperationCanceledException(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await task;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: f65b4f61d3a34358bf48957eb3a14dd7
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using FlowScope.Addressables;
|
||||||
|
|
||||||
|
namespace FlowScope.Resources
|
||||||
|
{
|
||||||
|
public sealed class AddressablesResourceService : IResourceService, ICompletedResourceService
|
||||||
|
{
|
||||||
|
private readonly ResourceService _inner;
|
||||||
|
|
||||||
|
public AddressablesResourceService()
|
||||||
|
: this(new AddressablesResourceBackend())
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public AddressablesResourceService(IResourceBackend backend)
|
||||||
|
{
|
||||||
|
_inner = new ResourceService(backend);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<IResourceHandle<T>> LoadAsync<T>(string key, CancellationToken cancellationToken)
|
||||||
|
where T : class
|
||||||
|
{
|
||||||
|
return _inner.LoadAsync<T>(key, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IResourceGroup CreateGroup()
|
||||||
|
{
|
||||||
|
return _inner.CreateGroup();
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryLoadCompleted<T>(string key, out IResourceHandle<T> handle)
|
||||||
|
where T : class
|
||||||
|
{
|
||||||
|
return _inner.TryLoadCompleted(key, out handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 62c87792aac14414b46ac0eb3e716f25
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "FlowScope.Addressables",
|
||||||
|
"rootNamespace": "FlowScope.Addressables",
|
||||||
|
"references": [
|
||||||
|
"FlowScope.Runtime",
|
||||||
|
"Unity.Addressables"
|
||||||
|
],
|
||||||
|
"includePlatforms": [],
|
||||||
|
"excludePlatforms": [],
|
||||||
|
"allowUnsafeCode": false,
|
||||||
|
"overrideReferences": false,
|
||||||
|
"precompiledReferences": [],
|
||||||
|
"autoReferenced": true,
|
||||||
|
"defineConstraints": [],
|
||||||
|
"versionDefines": [],
|
||||||
|
"noEngineReferences": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 917e69d4247b4f548a5951d84e7d35db
|
||||||
|
AssemblyDefinitionImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
8
My project/Assets/FlowScope/Runtime.meta
Normal file
8
My project/Assets/FlowScope/Runtime.meta
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: eab40aadc4ea4632930155f91c6faca4
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
8
My project/Assets/FlowScope/Runtime/Audio.meta
Normal file
8
My project/Assets/FlowScope/Runtime/Audio.meta
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: faa0e59581ea46ed8994f743336decdc
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
25
My project/Assets/FlowScope/Runtime/Audio/AudioHandle.cs
Normal file
25
My project/Assets/FlowScope/Runtime/Audio/AudioHandle.cs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace FlowScope.Audio
|
||||||
|
{
|
||||||
|
public sealed class AudioHandle : IAudioHandle
|
||||||
|
{
|
||||||
|
private readonly Func<bool> _isPlaying;
|
||||||
|
private readonly Action<float> _stop;
|
||||||
|
|
||||||
|
internal AudioHandle(Func<bool> isPlaying, Action<float> stop)
|
||||||
|
{
|
||||||
|
_isPlaying = isPlaying ?? throw new ArgumentNullException(nameof(isPlaying));
|
||||||
|
_stop = stop ?? throw new ArgumentNullException(nameof(stop));
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsPlaying => _isPlaying();
|
||||||
|
|
||||||
|
public void Stop(float fadeOut = 0f)
|
||||||
|
{
|
||||||
|
_stop(fadeOut);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static AudioHandle Stopped { get; } = new AudioHandle(() => false, _ => { });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 9e7da42c17284465ab9a68bec5387f19
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
392
My project/Assets/FlowScope/Runtime/Audio/AudioService.cs
Normal file
392
My project/Assets/FlowScope/Runtime/Audio/AudioService.cs
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using FlowScope.Resources;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace FlowScope.Audio
|
||||||
|
{
|
||||||
|
public sealed class AudioService : MonoBehaviour, IAudioService
|
||||||
|
{
|
||||||
|
private readonly List<SfxSlot> _sfxSlots = new();
|
||||||
|
private IResourceService _resources;
|
||||||
|
private AudioServiceConfig _config;
|
||||||
|
private AudioSource _bgmSource;
|
||||||
|
private IResourceHandle<AudioClip> _bgmResource;
|
||||||
|
private Coroutine _bgmFade;
|
||||||
|
private float _bgmVolume = 1f;
|
||||||
|
private float _sfxVolume = 1f;
|
||||||
|
private bool _mute;
|
||||||
|
private int _playSequence;
|
||||||
|
|
||||||
|
public AudioService Initialize(IResourceService resources, AudioServiceConfig config = null)
|
||||||
|
{
|
||||||
|
_resources = resources ?? throw new ArgumentNullException(nameof(resources));
|
||||||
|
_config = config ?? new AudioServiceConfig();
|
||||||
|
if (_config.SfxPoolSize < 0)
|
||||||
|
{
|
||||||
|
_config.SfxPoolSize = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IAudioHandle PlayBgm(string key, bool loop = true, float fadeIn = 0f)
|
||||||
|
{
|
||||||
|
EnsureInitialized();
|
||||||
|
var clipHandle = LoadClipIfCompleted(key);
|
||||||
|
return PlayBgm(clipHandle, loop, fadeIn);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IAudioHandle> PlayBgmAsync(
|
||||||
|
string key,
|
||||||
|
bool loop = true,
|
||||||
|
float fadeIn = 0f,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
EnsureInitialized();
|
||||||
|
var clipHandle = await LoadClipAsync(key, cancellationToken);
|
||||||
|
return PlayBgm(clipHandle, loop, fadeIn);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IAudioHandle PlaySfx(string key)
|
||||||
|
{
|
||||||
|
EnsureInitialized();
|
||||||
|
var clipHandle = LoadClipIfCompleted(key);
|
||||||
|
var slot = GetSfxSlot(key);
|
||||||
|
if (slot == null)
|
||||||
|
{
|
||||||
|
clipHandle.Dispose();
|
||||||
|
return AudioHandle.Stopped;
|
||||||
|
}
|
||||||
|
|
||||||
|
return PlaySfx(slot, clipHandle);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IAudioHandle> PlaySfxAsync(
|
||||||
|
string key,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
EnsureInitialized();
|
||||||
|
var clipHandle = await LoadClipAsync(key, cancellationToken);
|
||||||
|
var slot = GetSfxSlot(key);
|
||||||
|
if (slot == null)
|
||||||
|
{
|
||||||
|
clipHandle.Dispose();
|
||||||
|
return AudioHandle.Stopped;
|
||||||
|
}
|
||||||
|
|
||||||
|
return PlaySfx(slot, clipHandle);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void StopBgm(float fadeOut = 0f)
|
||||||
|
{
|
||||||
|
StopBgmSource(_bgmSource, fadeOut);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetBgmVolume(float volume)
|
||||||
|
{
|
||||||
|
_bgmVolume = Mathf.Clamp01(volume);
|
||||||
|
if (_bgmSource != null)
|
||||||
|
{
|
||||||
|
_bgmSource.volume = EffectiveBgmVolume;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetSfxVolume(float volume)
|
||||||
|
{
|
||||||
|
_sfxVolume = Mathf.Clamp01(volume);
|
||||||
|
foreach (var slot in _sfxSlots)
|
||||||
|
{
|
||||||
|
if (slot.Source != null)
|
||||||
|
{
|
||||||
|
slot.Source.volume = EffectiveSfxVolume;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetMute(bool mute)
|
||||||
|
{
|
||||||
|
_mute = mute;
|
||||||
|
if (_bgmSource != null)
|
||||||
|
{
|
||||||
|
_bgmSource.volume = EffectiveBgmVolume;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var slot in _sfxSlots)
|
||||||
|
{
|
||||||
|
if (slot.Source != null)
|
||||||
|
{
|
||||||
|
slot.Source.volume = EffectiveSfxVolume;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private IAudioHandle PlayBgm(IResourceHandle<AudioClip> clipHandle, bool loop, float fadeIn)
|
||||||
|
{
|
||||||
|
StopBgm();
|
||||||
|
_bgmSource = gameObject.AddComponent<AudioSource>();
|
||||||
|
_bgmResource = clipHandle;
|
||||||
|
_bgmSource.clip = clipHandle.Asset;
|
||||||
|
_bgmSource.loop = loop;
|
||||||
|
_bgmSource.volume = fadeIn > 0f ? 0f : EffectiveBgmVolume;
|
||||||
|
_bgmSource.Play();
|
||||||
|
|
||||||
|
if (fadeIn > 0f)
|
||||||
|
{
|
||||||
|
_bgmFade = StartCoroutine(FadeVolume(_bgmSource, 0f, EffectiveBgmVolume, fadeIn, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
var source = _bgmSource;
|
||||||
|
return new AudioHandle(
|
||||||
|
() => source != null && source.isPlaying,
|
||||||
|
fadeOut => StopBgmSource(source, fadeOut));
|
||||||
|
}
|
||||||
|
|
||||||
|
private IAudioHandle PlaySfx(SfxSlot slot, IResourceHandle<AudioClip> clipHandle)
|
||||||
|
{
|
||||||
|
slot.Release();
|
||||||
|
slot.Resource = clipHandle;
|
||||||
|
slot.Source.clip = clipHandle.Asset;
|
||||||
|
slot.Source.loop = false;
|
||||||
|
slot.Source.volume = EffectiveSfxVolume;
|
||||||
|
slot.Sequence = ++_playSequence;
|
||||||
|
slot.Source.Play();
|
||||||
|
|
||||||
|
return new AudioHandle(
|
||||||
|
() => slot.Source != null && slot.Source.isPlaying,
|
||||||
|
fadeOut => StopSfxSlot(slot, fadeOut));
|
||||||
|
}
|
||||||
|
|
||||||
|
private float EffectiveBgmVolume => _mute ? 0f : _bgmVolume;
|
||||||
|
private float EffectiveSfxVolume => _mute ? 0f : _sfxVolume;
|
||||||
|
|
||||||
|
private void Update()
|
||||||
|
{
|
||||||
|
foreach (var slot in _sfxSlots)
|
||||||
|
{
|
||||||
|
if (slot.Source != null && !slot.Source.isPlaying)
|
||||||
|
{
|
||||||
|
slot.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDestroy()
|
||||||
|
{
|
||||||
|
StopBgm();
|
||||||
|
foreach (var slot in _sfxSlots)
|
||||||
|
{
|
||||||
|
slot.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private IResourceHandle<AudioClip> LoadClipIfCompleted(string key)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!(_resources is ICompletedResourceService completedResources) ||
|
||||||
|
!completedResources.TryLoadCompleted(key, out IResourceHandle<AudioClip> handle))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Audio clip is not loaded synchronously: {key}. Use PlayBgmAsync or PlaySfxAsync.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ValidateLoadedClip(key, handle);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Failed to load audio clip '{key}'.", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IResourceHandle<AudioClip>> LoadClipAsync(
|
||||||
|
string key,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var handle = await _resources.LoadAsync<AudioClip>(key, cancellationToken);
|
||||||
|
return ValidateLoadedClip(key, handle);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Failed to load audio clip '{key}'.", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IResourceHandle<AudioClip> ValidateLoadedClip(
|
||||||
|
string key,
|
||||||
|
IResourceHandle<AudioClip> handle)
|
||||||
|
{
|
||||||
|
if (handle == null || handle.Asset == null)
|
||||||
|
{
|
||||||
|
handle?.Dispose();
|
||||||
|
throw new InvalidOperationException($"Audio clip is missing: {key}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
private SfxSlot GetSfxSlot(string key)
|
||||||
|
{
|
||||||
|
foreach (var slot in _sfxSlots)
|
||||||
|
{
|
||||||
|
if (slot.Source != null && !slot.Source.isPlaying)
|
||||||
|
{
|
||||||
|
return slot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_sfxSlots.Count < _config.SfxPoolSize)
|
||||||
|
{
|
||||||
|
var source = gameObject.AddComponent<AudioSource>();
|
||||||
|
var slot = new SfxSlot(source);
|
||||||
|
_sfxSlots.Add(slot);
|
||||||
|
return slot;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_config.ReuseOldestWhenExhausted && _sfxSlots.Count > 0)
|
||||||
|
{
|
||||||
|
var oldest = _sfxSlots[0];
|
||||||
|
foreach (var slot in _sfxSlots)
|
||||||
|
{
|
||||||
|
if (slot.Sequence < oldest.Sequence)
|
||||||
|
{
|
||||||
|
oldest = slot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
oldest.Source.Stop();
|
||||||
|
return oldest;
|
||||||
|
}
|
||||||
|
|
||||||
|
Debug.LogWarning($"SFX pool exhausted, skipped key: {key}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StopBgmSource(AudioSource source, float fadeOut)
|
||||||
|
{
|
||||||
|
if (source == null || source != _bgmSource)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_bgmFade != null)
|
||||||
|
{
|
||||||
|
StopCoroutine(_bgmFade);
|
||||||
|
_bgmFade = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fadeOut > 0f && source.isPlaying)
|
||||||
|
{
|
||||||
|
_bgmFade = StartCoroutine(FadeVolume(
|
||||||
|
source,
|
||||||
|
source.volume,
|
||||||
|
0f,
|
||||||
|
fadeOut,
|
||||||
|
() => CompleteBgmStop(source)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CompleteBgmStop(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CompleteBgmStop(AudioSource source)
|
||||||
|
{
|
||||||
|
if (source == null || source != _bgmSource)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
source.Stop();
|
||||||
|
source.volume = 0f;
|
||||||
|
Destroy(source);
|
||||||
|
_bgmSource = null;
|
||||||
|
_bgmFade = null;
|
||||||
|
_bgmResource?.Dispose();
|
||||||
|
_bgmResource = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StopSfxSlot(SfxSlot slot, float fadeOut)
|
||||||
|
{
|
||||||
|
if (slot?.Source == null || !slot.Source.isPlaying)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fadeOut > 0f)
|
||||||
|
{
|
||||||
|
StartCoroutine(FadeVolume(
|
||||||
|
slot.Source,
|
||||||
|
slot.Source.volume,
|
||||||
|
0f,
|
||||||
|
fadeOut,
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
slot.Source.Stop();
|
||||||
|
slot.Release();
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
slot.Source.Stop();
|
||||||
|
slot.Release();
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerator FadeVolume(
|
||||||
|
AudioSource source,
|
||||||
|
float from,
|
||||||
|
float to,
|
||||||
|
float duration,
|
||||||
|
Action complete)
|
||||||
|
{
|
||||||
|
var elapsed = 0f;
|
||||||
|
while (elapsed < duration && source != null)
|
||||||
|
{
|
||||||
|
elapsed += Time.deltaTime;
|
||||||
|
source.volume = Mathf.Lerp(from, to, Mathf.Clamp01(elapsed / duration));
|
||||||
|
yield return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source != null)
|
||||||
|
{
|
||||||
|
source.volume = to;
|
||||||
|
}
|
||||||
|
|
||||||
|
complete?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureInitialized()
|
||||||
|
{
|
||||||
|
if (_resources == null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("AudioService must be initialized before use.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class SfxSlot
|
||||||
|
{
|
||||||
|
public SfxSlot(AudioSource source)
|
||||||
|
{
|
||||||
|
Source = source;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AudioSource Source { get; }
|
||||||
|
public IResourceHandle<AudioClip> Resource { get; set; }
|
||||||
|
public int Sequence { get; set; }
|
||||||
|
|
||||||
|
public void Release()
|
||||||
|
{
|
||||||
|
Resource?.Dispose();
|
||||||
|
Resource = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 9e81e8f0ce1a42e6a3b09bf74893b6b6
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace FlowScope.Audio
|
||||||
|
{
|
||||||
|
public sealed class AudioServiceConfig
|
||||||
|
{
|
||||||
|
public int SfxPoolSize = 10;
|
||||||
|
public bool ReuseOldestWhenExhausted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 276b2d62e26a4edcaaeba1a96a3c2917
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace FlowScope.Audio
|
||||||
|
{
|
||||||
|
public interface IAudioHandle
|
||||||
|
{
|
||||||
|
void Stop(float fadeOut = 0f);
|
||||||
|
bool IsPlaying { get; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 780afa085183438eaa9b372219113d62
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
25
My project/Assets/FlowScope/Runtime/Audio/IAudioService.cs
Normal file
25
My project/Assets/FlowScope/Runtime/Audio/IAudioService.cs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace FlowScope.Audio
|
||||||
|
{
|
||||||
|
public interface IAudioService
|
||||||
|
{
|
||||||
|
IAudioHandle PlayBgm(string key, bool loop = true, float fadeIn = 0f);
|
||||||
|
Task<IAudioHandle> PlayBgmAsync(
|
||||||
|
string key,
|
||||||
|
bool loop = true,
|
||||||
|
float fadeIn = 0f,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
IAudioHandle PlaySfx(string key);
|
||||||
|
Task<IAudioHandle> PlaySfxAsync(
|
||||||
|
string key,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
void StopBgm(float fadeOut = 0f);
|
||||||
|
void SetBgmVolume(float volume);
|
||||||
|
void SetSfxVolume(float volume);
|
||||||
|
void SetMute(bool mute);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 2763bff6b4ca4ac2ae5b9e5f764ad984
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
8
My project/Assets/FlowScope/Runtime/Common.meta
Normal file
8
My project/Assets/FlowScope/Runtime/Common.meta
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 76e811c4a8bd4f6497926d5befb64b83
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
22
My project/Assets/FlowScope/Runtime/Common/ResultLog.cs
Normal file
22
My project/Assets/FlowScope/Runtime/Common/ResultLog.cs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace FlowScope.Common
|
||||||
|
{
|
||||||
|
public sealed class ResultLog
|
||||||
|
{
|
||||||
|
private readonly List<string> _warnings = new();
|
||||||
|
private readonly List<string> _errors = new();
|
||||||
|
|
||||||
|
public IReadOnlyList<string> Warnings => _warnings;
|
||||||
|
public IReadOnlyList<string> Errors => _errors;
|
||||||
|
public bool HasErrors => _errors.Count > 0;
|
||||||
|
|
||||||
|
public void AddWarning(string message) => _warnings.Add(message);
|
||||||
|
public void AddError(string message) => _errors.Add(message);
|
||||||
|
public void Clear()
|
||||||
|
{
|
||||||
|
_warnings.Clear();
|
||||||
|
_errors.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
My project/Assets/FlowScope/Runtime/Common/ResultLog.cs.meta
Normal file
11
My project/Assets/FlowScope/Runtime/Common/ResultLog.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: a4063b7b41014f9399532dd3cc83e68f
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
8
My project/Assets/FlowScope/Runtime/Config.meta
Normal file
8
My project/Assets/FlowScope/Runtime/Config.meta
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 2f421e0a585f4a279d8db9410eb82711
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace FlowScope.Config
|
||||||
|
{
|
||||||
|
public sealed class FileConfigSource : IConfigSource
|
||||||
|
{
|
||||||
|
private readonly string _rootPath;
|
||||||
|
|
||||||
|
public FileConfigSource(string rootPath, string name = "File")
|
||||||
|
{
|
||||||
|
_rootPath = string.IsNullOrWhiteSpace(rootPath)
|
||||||
|
? throw new ArgumentException("Config root path is required.", nameof(rootPath))
|
||||||
|
: rootPath;
|
||||||
|
Name = string.IsNullOrWhiteSpace(name)
|
||||||
|
? throw new ArgumentException("Config source name is required.", nameof(name))
|
||||||
|
: name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Name { get; }
|
||||||
|
|
||||||
|
public Task<string> LoadTextAsync(string fileName, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var path = Path.Combine(_rootPath, fileName);
|
||||||
|
return Task.Run(() => File.ReadAllText(path), cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 8d50cd860ba849a1aa7647deba4e71db
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
14
My project/Assets/FlowScope/Runtime/Config/IConfigParser.cs
Normal file
14
My project/Assets/FlowScope/Runtime/Config/IConfigParser.cs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace FlowScope.Config
|
||||||
|
{
|
||||||
|
public interface IConfigParser
|
||||||
|
{
|
||||||
|
string Format { get; }
|
||||||
|
|
||||||
|
IReadOnlyList<T> ParseRows<T>(
|
||||||
|
string fileName,
|
||||||
|
string text)
|
||||||
|
where T : class, IConfigRow;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 0d22229e25964ea29b3c6d153507b1db
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace FlowScope.Config
|
||||||
|
{
|
||||||
|
public interface IConfigProvider
|
||||||
|
{
|
||||||
|
Task LoadAllAsync(CancellationToken cancellationToken);
|
||||||
|
T Get<T>(int id) where T : class, IConfigRow;
|
||||||
|
IReadOnlyList<T> GetAll<T>() where T : class, IConfigRow;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: af3d9a24ab0e4c6ebe3bf906cfd9e2bb
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
7
My project/Assets/FlowScope/Runtime/Config/IConfigRow.cs
Normal file
7
My project/Assets/FlowScope/Runtime/Config/IConfigRow.cs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
namespace FlowScope.Config
|
||||||
|
{
|
||||||
|
public interface IConfigRow
|
||||||
|
{
|
||||||
|
int Id { get; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 20d7e0e8230e4df1b5afa8000ef26fea
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
14
My project/Assets/FlowScope/Runtime/Config/IConfigSource.cs
Normal file
14
My project/Assets/FlowScope/Runtime/Config/IConfigSource.cs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace FlowScope.Config
|
||||||
|
{
|
||||||
|
public interface IConfigSource
|
||||||
|
{
|
||||||
|
string Name { get; }
|
||||||
|
|
||||||
|
Task<string> LoadTextAsync(
|
||||||
|
string fileName,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 708db2a1c26743f3994b5d8beb1a03f8
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace FlowScope.Config
|
||||||
|
{
|
||||||
|
public sealed class InMemoryConfigSource : IConfigSource
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, string> _files;
|
||||||
|
|
||||||
|
public InMemoryConfigSource(
|
||||||
|
IReadOnlyDictionary<string, string> files,
|
||||||
|
string name = "InMemory")
|
||||||
|
{
|
||||||
|
_files = files != null
|
||||||
|
? new Dictionary<string, string>(files)
|
||||||
|
: throw new ArgumentNullException(nameof(files));
|
||||||
|
Name = string.IsNullOrWhiteSpace(name)
|
||||||
|
? throw new ArgumentException("Config source name is required.", nameof(name))
|
||||||
|
: name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Name { get; }
|
||||||
|
|
||||||
|
public Task<string> LoadTextAsync(string fileName, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
if (!_files.TryGetValue(fileName, out var text))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Config file is missing: {fileName}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 607d9b20e7f04565929ea5876e5c25e1
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using FlowScope.Data;
|
||||||
|
|
||||||
|
namespace FlowScope.Config
|
||||||
|
{
|
||||||
|
public sealed class JsonConfigParser : IConfigParser
|
||||||
|
{
|
||||||
|
public string Format => "json";
|
||||||
|
|
||||||
|
public IReadOnlyList<T> ParseRows<T>(string fileName, string text)
|
||||||
|
where T : class, IConfigRow
|
||||||
|
{
|
||||||
|
return ReactivePropertyJsonConverter.DeserializeArray<T>(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: f60f8a8e66394f26a20ed6f5ef314c63
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
166
My project/Assets/FlowScope/Runtime/Config/JsonConfigProvider.cs
Normal file
166
My project/Assets/FlowScope/Runtime/Config/JsonConfigProvider.cs
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
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 Mapping<T>(
|
||||||
|
fileName,
|
||||||
|
new DelegateConfigSource(fileName, loadTextAsync),
|
||||||
|
new JsonConfigParser());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ConfigMapping Mapping<T>(
|
||||||
|
string fileName,
|
||||||
|
IConfigSource source,
|
||||||
|
IConfigParser parser)
|
||||||
|
where T : class, IConfigRow
|
||||||
|
{
|
||||||
|
return new ConfigMapping(typeof(T), fileName, source, parser);
|
||||||
|
}
|
||||||
|
|
||||||
|
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.Source.LoadTextAsync(mapping.FileName, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Failed to load config file '{mapping.FileName}' from source '{mapping.Source.Name}'.",
|
||||||
|
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 MethodInfo _parseRowsMethod;
|
||||||
|
|
||||||
|
internal ConfigMapping(
|
||||||
|
Type rowType,
|
||||||
|
string fileName,
|
||||||
|
IConfigSource source,
|
||||||
|
IConfigParser parser)
|
||||||
|
{
|
||||||
|
RowType = rowType ?? throw new ArgumentNullException(nameof(rowType));
|
||||||
|
FileName = string.IsNullOrWhiteSpace(fileName)
|
||||||
|
? throw new ArgumentException("Config file name is required.", nameof(fileName))
|
||||||
|
: fileName;
|
||||||
|
Source = source ?? throw new ArgumentNullException(nameof(source));
|
||||||
|
Parser = parser ?? throw new ArgumentNullException(nameof(parser));
|
||||||
|
_parseRowsMethod = typeof(IConfigParser)
|
||||||
|
.GetMethod(nameof(IConfigParser.ParseRows))
|
||||||
|
.MakeGenericMethod(rowType);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Type RowType { get; }
|
||||||
|
public string FileName { get; }
|
||||||
|
public IConfigSource Source { get; }
|
||||||
|
public IConfigParser Parser { get; }
|
||||||
|
|
||||||
|
internal object Parse(string json)
|
||||||
|
{
|
||||||
|
var rows = (System.Collections.IEnumerable)_parseRowsMethod.Invoke(
|
||||||
|
Parser,
|
||||||
|
new object[] { FileName, json });
|
||||||
|
var byIdType = typeof(Dictionary<,>).MakeGenericType(typeof(int), RowType);
|
||||||
|
var byId = (System.Collections.IDictionary)Activator.CreateInstance(byIdType);
|
||||||
|
foreach (IConfigRow row in rows)
|
||||||
|
{
|
||||||
|
if (byId.Contains(row.Id))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Duplicate config id {row.Id} for {RowType.Name}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
byId.Add(row.Id, row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return byId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class DelegateConfigSource : IConfigSource
|
||||||
|
{
|
||||||
|
private readonly Func<CancellationToken, Task<string>> _loadTextAsync;
|
||||||
|
|
||||||
|
public DelegateConfigSource(
|
||||||
|
string fileName,
|
||||||
|
Func<CancellationToken, Task<string>> loadTextAsync)
|
||||||
|
{
|
||||||
|
Name = fileName;
|
||||||
|
_loadTextAsync = loadTextAsync ?? throw new ArgumentNullException(nameof(loadTextAsync));
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Name { get; }
|
||||||
|
|
||||||
|
public Task<string> LoadTextAsync(string fileName, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return _loadTextAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/Container.meta
Normal file
8
My project/Assets/FlowScope/Runtime/Container.meta
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 8d24db89928e4010a3c1af79ffb15709
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
261
My project/Assets/FlowScope/Runtime/Container/Container.cs
Normal file
261
My project/Assets/FlowScope/Runtime/Container/Container.cs
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace FlowScope.Container
|
||||||
|
{
|
||||||
|
public sealed class Container : IDisposable
|
||||||
|
{
|
||||||
|
[ThreadStatic]
|
||||||
|
private static Stack<Type> _resolutionStack;
|
||||||
|
|
||||||
|
private readonly Container _parent;
|
||||||
|
private readonly Dictionary<Type, ContainerRegistration> _registrations = new Dictionary<Type, ContainerRegistration>();
|
||||||
|
private readonly List<Container> _children = new List<Container>();
|
||||||
|
private readonly List<IDisposable> _ownedDisposables = new List<IDisposable>();
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public Container()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
private Container(Container parent)
|
||||||
|
{
|
||||||
|
_parent = parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RegisterInstance<T>(T instance)
|
||||||
|
{
|
||||||
|
ThrowIfDisposed();
|
||||||
|
|
||||||
|
if (instance == null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(instance));
|
||||||
|
}
|
||||||
|
|
||||||
|
RegisterLocal(typeof(T), ContainerRegistration.ForInstance(instance));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RegisterFactory<T>(Func<Container, T> factory)
|
||||||
|
{
|
||||||
|
ThrowIfDisposed();
|
||||||
|
|
||||||
|
if (factory == null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(factory));
|
||||||
|
}
|
||||||
|
|
||||||
|
RegisterLocal(typeof(T), ContainerRegistration.ForFactory(this, c => factory(c)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RegisterType<TInterface, TImplementation>() where TImplementation : TInterface
|
||||||
|
{
|
||||||
|
ThrowIfDisposed();
|
||||||
|
|
||||||
|
RegisterType(typeof(TInterface), typeof(TImplementation));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RegisterType<TImplementation>()
|
||||||
|
{
|
||||||
|
ThrowIfDisposed();
|
||||||
|
|
||||||
|
RegisterType(typeof(TImplementation), typeof(TImplementation));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RegisterAssembly()
|
||||||
|
{
|
||||||
|
ThrowIfDisposed();
|
||||||
|
|
||||||
|
GeneratedFactories.RegisterAssembly(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public T Resolve<T>()
|
||||||
|
{
|
||||||
|
ThrowIfDisposed();
|
||||||
|
|
||||||
|
return (T)Resolve(typeof(T));
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryResolve<T>(out T value)
|
||||||
|
{
|
||||||
|
ThrowIfDisposed();
|
||||||
|
|
||||||
|
if (!TryFindRegistration(typeof(T), out var registration))
|
||||||
|
{
|
||||||
|
value = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
value = (T)ResolveRegistration(typeof(T), registration);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Container CreateScope()
|
||||||
|
{
|
||||||
|
ThrowIfDisposed();
|
||||||
|
|
||||||
|
var child = new Container(this);
|
||||||
|
_children.Add(child);
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_disposed = true;
|
||||||
|
var exceptions = new List<Exception>();
|
||||||
|
|
||||||
|
for (var i = _children.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
TryDispose(_children[i], exceptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = _ownedDisposables.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
TryDispose(_ownedDisposables[i], exceptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
_children.Clear();
|
||||||
|
_ownedDisposables.Clear();
|
||||||
|
_registrations.Clear();
|
||||||
|
|
||||||
|
if (_parent != null)
|
||||||
|
{
|
||||||
|
_parent._children.Remove(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exceptions.Count > 0)
|
||||||
|
{
|
||||||
|
throw new AggregateException("One or more container-owned instances failed to dispose.", exceptions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal object Resolve(Type serviceType)
|
||||||
|
{
|
||||||
|
ThrowIfDisposed();
|
||||||
|
|
||||||
|
if (!TryFindRegistration(serviceType, out var registration))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"No registration found for {FormatType(serviceType)}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResolveRegistration(serviceType, registration);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void RegisterFactory(Type serviceType, Func<Container, object> factory)
|
||||||
|
{
|
||||||
|
ThrowIfDisposed();
|
||||||
|
|
||||||
|
if (serviceType == null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(serviceType));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (factory == null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(factory));
|
||||||
|
}
|
||||||
|
|
||||||
|
RegisterLocal(serviceType, ContainerRegistration.ForFactory(this, factory));
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void TrackOwnedDisposable(IDisposable disposable)
|
||||||
|
{
|
||||||
|
_ownedDisposables.Add(disposable);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RegisterType(Type serviceType, Type implementationType)
|
||||||
|
{
|
||||||
|
if (!serviceType.IsAssignableFrom(implementationType))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"{FormatType(implementationType)} cannot be assigned to {FormatType(serviceType)}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!GeneratedFactories.TryGetFactory(implementationType, out var factory))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"No generated factory registered for {FormatType(implementationType)}. Use GeneratedFactories.Register, ReflectionFactoryBuilder.RegisterType, or RegisterFactory before resolving this type.");
|
||||||
|
}
|
||||||
|
|
||||||
|
RegisterFactory(serviceType, factory);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RegisterLocal(Type serviceType, ContainerRegistration registration)
|
||||||
|
{
|
||||||
|
if (_registrations.ContainsKey(serviceType))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"{FormatType(serviceType)} is already registered in this container scope.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_registrations.Add(serviceType, registration);
|
||||||
|
}
|
||||||
|
|
||||||
|
private object ResolveRegistration(Type serviceType, ContainerRegistration registration)
|
||||||
|
{
|
||||||
|
_resolutionStack = _resolutionStack ?? new Stack<Type>();
|
||||||
|
|
||||||
|
if (_resolutionStack.Contains(serviceType))
|
||||||
|
{
|
||||||
|
var chain = _resolutionStack.Reverse()
|
||||||
|
.Concat(new[] { serviceType })
|
||||||
|
.Select(t => t.Name);
|
||||||
|
throw new InvalidOperationException($"Circular dependency detected: {string.Join(" -> ", chain)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
_resolutionStack.Push(serviceType);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return registration.Resolve();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_resolutionStack.Pop();
|
||||||
|
if (_resolutionStack.Count == 0)
|
||||||
|
{
|
||||||
|
_resolutionStack = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryFindRegistration(Type serviceType, out ContainerRegistration registration)
|
||||||
|
{
|
||||||
|
if (_registrations.TryGetValue(serviceType, out registration))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_parent != null)
|
||||||
|
{
|
||||||
|
return _parent.TryFindRegistration(serviceType, out registration);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ThrowIfDisposed()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
throw new ObjectDisposedException(nameof(Container));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TryDispose(IDisposable disposable, List<Exception> exceptions)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
disposable.Dispose();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
exceptions.Add(ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatType(Type type) => type.FullName ?? type.Name;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 554409c4a7e84909a72a89d17aeb2b0b
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace FlowScope.Container
|
||||||
|
{
|
||||||
|
internal sealed class ContainerRegistration
|
||||||
|
{
|
||||||
|
private readonly Container _owner;
|
||||||
|
private readonly Func<Container, object> _factory;
|
||||||
|
private readonly bool _ownsCreatedInstance;
|
||||||
|
private object _instance;
|
||||||
|
private bool _created;
|
||||||
|
|
||||||
|
private ContainerRegistration(Container owner, Func<Container, object> factory, object instance, bool created, bool ownsCreatedInstance)
|
||||||
|
{
|
||||||
|
_owner = owner;
|
||||||
|
_factory = factory;
|
||||||
|
_instance = instance;
|
||||||
|
_created = created;
|
||||||
|
_ownsCreatedInstance = ownsCreatedInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ContainerRegistration ForInstance(object instance)
|
||||||
|
{
|
||||||
|
return new ContainerRegistration(null, null, instance, true, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ContainerRegistration ForFactory(Container owner, Func<Container, object> factory)
|
||||||
|
{
|
||||||
|
return new ContainerRegistration(owner, factory, null, false, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public object Resolve()
|
||||||
|
{
|
||||||
|
if (_created)
|
||||||
|
{
|
||||||
|
return _instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
_instance = _factory(_owner);
|
||||||
|
_created = true;
|
||||||
|
|
||||||
|
if (_ownsCreatedInstance && _instance is IDisposable disposable)
|
||||||
|
{
|
||||||
|
_owner.TrackOwnedDisposable(disposable);
|
||||||
|
}
|
||||||
|
|
||||||
|
return _instance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: d024d7ed8fd642b6948a216b3a676e22
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace FlowScope.Container
|
||||||
|
{
|
||||||
|
public static class GeneratedFactories
|
||||||
|
{
|
||||||
|
private static readonly Dictionary<Type, Func<Container, object>> Factories = new Dictionary<Type, Func<Container, object>>();
|
||||||
|
|
||||||
|
public static void Register<TImplementation>(Func<Container, TImplementation> factory)
|
||||||
|
{
|
||||||
|
if (factory == null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(factory));
|
||||||
|
}
|
||||||
|
|
||||||
|
Factories[typeof(TImplementation)] = c => factory(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryGetFactory(Type implementationType, out Func<Container, object> factory)
|
||||||
|
{
|
||||||
|
if (implementationType == null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(implementationType));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Factories.TryGetValue(implementationType, out factory);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void RegisterAssembly(Container container)
|
||||||
|
{
|
||||||
|
if (container == null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(container));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var pair in Factories)
|
||||||
|
{
|
||||||
|
container.RegisterFactory(pair.Key, pair.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: fc50e3c76ee147b59d2663e872ced1a0
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace FlowScope.Container
|
||||||
|
{
|
||||||
|
[AttributeUsage(AttributeTargets.Class)]
|
||||||
|
public sealed class InjectableAttribute : Attribute
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 039257eaaf6c4ac198a3504795b52df0
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
namespace FlowScope.Container
|
||||||
|
{
|
||||||
|
public static class ReflectionFactoryBuilder
|
||||||
|
{
|
||||||
|
public static void RegisterType<TImplementation>(Container container)
|
||||||
|
{
|
||||||
|
if (container == null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(container));
|
||||||
|
}
|
||||||
|
|
||||||
|
container.RegisterFactory(typeof(TImplementation), BuildFactory(typeof(TImplementation)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void RegisterType<TInterface, TImplementation>(Container container)
|
||||||
|
where TImplementation : TInterface
|
||||||
|
{
|
||||||
|
if (container == null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(container));
|
||||||
|
}
|
||||||
|
|
||||||
|
container.RegisterFactory(typeof(TInterface), BuildFactory(typeof(TImplementation)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Func<Container, object> BuildFactory(Type implementationType)
|
||||||
|
{
|
||||||
|
if (implementationType == null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(implementationType));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Attribute.IsDefined(implementationType, typeof(InjectableAttribute)))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"{FormatType(implementationType)} must be marked with InjectableAttribute for reflection registration.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var constructors = implementationType.GetConstructors(BindingFlags.Public | BindingFlags.Instance);
|
||||||
|
if (constructors.Length != 1)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"{FormatType(implementationType)} must declare exactly one public constructor.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var constructor = constructors[0];
|
||||||
|
var parameters = constructor.GetParameters();
|
||||||
|
|
||||||
|
return container =>
|
||||||
|
{
|
||||||
|
var arguments = parameters
|
||||||
|
.Select(parameter => container.Resolve(parameter.ParameterType))
|
||||||
|
.ToArray();
|
||||||
|
return constructor.Invoke(arguments);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatType(Type type) => type.FullName ?? type.Name;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 1d2827e4c16e4b4ea60a84f709d54665
|
||||||
|
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,731 @@
|
|||||||
|
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) || IsMutableCollection(memberType, currentValue))
|
||||||
|
{
|
||||||
|
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 = GetCollectionItemType(collectionType);
|
||||||
|
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 IsMutableCollection(Type type, object value)
|
||||||
|
{
|
||||||
|
return value != null &&
|
||||||
|
type != typeof(string) &&
|
||||||
|
GetCollectionItemType(type) != null &&
|
||||||
|
type.GetMethod("Clear", Type.EmptyTypes) != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Type GetCollectionItemType(Type type)
|
||||||
|
{
|
||||||
|
if (type.IsArray)
|
||||||
|
{
|
||||||
|
return type.GetElementType();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type.IsGenericType && type.GetGenericArguments().Length == 1)
|
||||||
|
{
|
||||||
|
return type.GetGenericArguments()[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var interfaceType in type.GetInterfaces())
|
||||||
|
{
|
||||||
|
if (interfaceType.IsGenericType &&
|
||||||
|
interfaceType.GetGenericTypeDefinition() == typeof(ICollection<>))
|
||||||
|
{
|
||||||
|
return interfaceType.GetGenericArguments()[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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:
|
||||||
8
My project/Assets/FlowScope/Runtime/Flow.meta
Normal file
8
My project/Assets/FlowScope/Runtime/Flow.meta
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 87d135bef01743c29768ef28ce8acf69
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
31
My project/Assets/FlowScope/Runtime/Flow/FeatureBase.cs
Normal file
31
My project/Assets/FlowScope/Runtime/Flow/FeatureBase.cs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace FlowScope.Flow
|
||||||
|
{
|
||||||
|
public abstract class FeatureBase : IFeature
|
||||||
|
{
|
||||||
|
protected FeatureContext Context { get; private set; }
|
||||||
|
|
||||||
|
public virtual Task LoadAsync(FeatureContext context)
|
||||||
|
{
|
||||||
|
Context = context;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual Task EnterAsync(FeatureContext context)
|
||||||
|
{
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual Task ExitAsync(FeatureContext context)
|
||||||
|
{
|
||||||
|
context?.Disposables.Clear();
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual void Dispose()
|
||||||
|
{
|
||||||
|
Context = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
My project/Assets/FlowScope/Runtime/Flow/FeatureBase.cs.meta
Normal file
11
My project/Assets/FlowScope/Runtime/Flow/FeatureBase.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: a5f4e3fb56394e719bff30e8edd4abaa
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
27
My project/Assets/FlowScope/Runtime/Flow/FeatureContext.cs
Normal file
27
My project/Assets/FlowScope/Runtime/Flow/FeatureContext.cs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
using System.Threading;
|
||||||
|
using FlowScope.Resources;
|
||||||
|
using R3;
|
||||||
|
using ContainerType = FlowScope.Container.Container;
|
||||||
|
|
||||||
|
namespace FlowScope.Flow
|
||||||
|
{
|
||||||
|
public sealed class FeatureContext
|
||||||
|
{
|
||||||
|
public FeatureContext(
|
||||||
|
ContainerType scope,
|
||||||
|
IResourceGroup resources,
|
||||||
|
CompositeDisposable disposables,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Scope = scope;
|
||||||
|
Resources = resources;
|
||||||
|
Disposables = disposables;
|
||||||
|
CancellationToken = cancellationToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ContainerType Scope { get; }
|
||||||
|
public IResourceGroup Resources { get; }
|
||||||
|
public CompositeDisposable Disposables { get; }
|
||||||
|
public CancellationToken CancellationToken { get; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 4196008ec1434af48d1307f18d14b655
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
285
My project/Assets/FlowScope/Runtime/Flow/GameFlow.cs
Normal file
285
My project/Assets/FlowScope/Runtime/Flow/GameFlow.cs
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using FlowScope.Config;
|
||||||
|
using FlowScope.Resources;
|
||||||
|
using R3;
|
||||||
|
using UnityEngine;
|
||||||
|
using ContainerType = FlowScope.Container.Container;
|
||||||
|
|
||||||
|
namespace FlowScope.Flow
|
||||||
|
{
|
||||||
|
public sealed class GameFlow
|
||||||
|
{
|
||||||
|
private ContainerType _root;
|
||||||
|
private IFeature _activeFeature;
|
||||||
|
private FeatureContext _activeContext;
|
||||||
|
|
||||||
|
public GameFlowState State { get; private set; } = GameFlowState.Idle;
|
||||||
|
|
||||||
|
public async Task StartupAsync<TInitialFeature>(
|
||||||
|
ContainerType root,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
where TInitialFeature : IFeature
|
||||||
|
{
|
||||||
|
EnsureState(nameof(StartupAsync), GameFlowState.Idle);
|
||||||
|
_root = root ?? throw new ArgumentNullException(nameof(root));
|
||||||
|
State = GameFlowState.Starting;
|
||||||
|
|
||||||
|
IFeature feature = null;
|
||||||
|
FeatureContext context = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var config = _root.Resolve<IConfigProvider>();
|
||||||
|
await config.LoadAllAsync(cancellationToken);
|
||||||
|
|
||||||
|
(feature, context) = CreateFeature<TInitialFeature>(cancellationToken);
|
||||||
|
await feature.LoadAsync(context);
|
||||||
|
await feature.EnterAsync(context);
|
||||||
|
|
||||||
|
_activeFeature = feature;
|
||||||
|
_activeContext = context;
|
||||||
|
State = GameFlowState.Running;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
BestEffortDisposeFeature(feature);
|
||||||
|
BestEffortDisposeContext(context);
|
||||||
|
_activeFeature = null;
|
||||||
|
_activeContext = null;
|
||||||
|
State = GameFlowState.Idle;
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SwitchToAsync<TFeature>(CancellationToken cancellationToken)
|
||||||
|
where TFeature : IFeature
|
||||||
|
{
|
||||||
|
EnsureState(
|
||||||
|
nameof(SwitchToAsync),
|
||||||
|
GameFlowState.Running,
|
||||||
|
GameFlowState.NoActiveFeature);
|
||||||
|
|
||||||
|
State = GameFlowState.Switching;
|
||||||
|
await CleanupActiveFeatureAsync();
|
||||||
|
|
||||||
|
IFeature nextFeature = null;
|
||||||
|
FeatureContext nextContext = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
(nextFeature, nextContext) = CreateFeature<TFeature>(cancellationToken);
|
||||||
|
await nextFeature.LoadAsync(nextContext);
|
||||||
|
await nextFeature.EnterAsync(nextContext);
|
||||||
|
|
||||||
|
_activeFeature = nextFeature;
|
||||||
|
_activeContext = nextContext;
|
||||||
|
State = GameFlowState.Running;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
BestEffortDisposeFeature(nextFeature);
|
||||||
|
BestEffortDisposeContext(nextContext);
|
||||||
|
_activeFeature = null;
|
||||||
|
_activeContext = null;
|
||||||
|
State = GameFlowState.NoActiveFeature;
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
EnsureState(
|
||||||
|
nameof(ShutdownAsync),
|
||||||
|
GameFlowState.Running,
|
||||||
|
GameFlowState.NoActiveFeature);
|
||||||
|
|
||||||
|
State = GameFlowState.ShuttingDown;
|
||||||
|
Exception cancellationException = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException exception)
|
||||||
|
{
|
||||||
|
cancellationException = exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
await CleanupActiveFeatureAsync();
|
||||||
|
BestEffortDisposeRoot();
|
||||||
|
State = GameFlowState.Disposed;
|
||||||
|
|
||||||
|
if (cancellationException != null)
|
||||||
|
{
|
||||||
|
throw cancellationException;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private (IFeature Feature, FeatureContext Context) CreateFeature<TFeature>(
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
where TFeature : IFeature
|
||||||
|
{
|
||||||
|
ContainerType scope = null;
|
||||||
|
IResourceGroup resources = null;
|
||||||
|
FeatureContext context = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
scope = _root.CreateScope();
|
||||||
|
var resourceService = _root.Resolve<IResourceService>();
|
||||||
|
resources = resourceService.CreateGroup();
|
||||||
|
context = new FeatureContext(
|
||||||
|
scope,
|
||||||
|
resources,
|
||||||
|
new CompositeDisposable(),
|
||||||
|
cancellationToken);
|
||||||
|
var feature = scope.Resolve<TFeature>();
|
||||||
|
return (feature, context);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
if (context != null)
|
||||||
|
{
|
||||||
|
BestEffortDisposeContext(context);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
BestEffortDisposePartialContext(resources, scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CleanupActiveFeatureAsync()
|
||||||
|
{
|
||||||
|
var feature = _activeFeature;
|
||||||
|
var context = _activeContext;
|
||||||
|
_activeFeature = null;
|
||||||
|
_activeContext = null;
|
||||||
|
|
||||||
|
if (feature != null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await feature.ExitAsync(context);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
Debug.LogError(exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
BestEffortDisposeFeature(feature);
|
||||||
|
}
|
||||||
|
|
||||||
|
BestEffortDisposeContext(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BestEffortDisposeFeature(IFeature feature)
|
||||||
|
{
|
||||||
|
if (feature == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
feature.Dispose();
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
Debug.LogError(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BestEffortDisposeContext(FeatureContext context)
|
||||||
|
{
|
||||||
|
if (context == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
context.Disposables.Clear();
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
Debug.LogError(exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
context.Resources?.Dispose();
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
Debug.LogError(exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
context.Scope?.Dispose();
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
Debug.LogError(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BestEffortDisposePartialContext(
|
||||||
|
IResourceGroup resources,
|
||||||
|
ContainerType scope)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
resources?.Dispose();
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
Debug.LogError(exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
scope?.Dispose();
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
Debug.LogError(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BestEffortDisposeRoot()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_root?.Dispose();
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
Debug.LogError(exception);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_root = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureState(string operation, params GameFlowState[] allowedStates)
|
||||||
|
{
|
||||||
|
foreach (var allowedState in allowedStates)
|
||||||
|
{
|
||||||
|
if (State == allowedState)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"{operation} cannot run while GameFlow state is {State}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
My project/Assets/FlowScope/Runtime/Flow/GameFlow.cs.meta
Normal file
11
My project/Assets/FlowScope/Runtime/Flow/GameFlow.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: a533908611b643259dbfcb681369d012
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
13
My project/Assets/FlowScope/Runtime/Flow/GameFlowState.cs
Normal file
13
My project/Assets/FlowScope/Runtime/Flow/GameFlowState.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
namespace FlowScope.Flow
|
||||||
|
{
|
||||||
|
public enum GameFlowState
|
||||||
|
{
|
||||||
|
Idle,
|
||||||
|
Starting,
|
||||||
|
Running,
|
||||||
|
Switching,
|
||||||
|
NoActiveFeature,
|
||||||
|
ShuttingDown,
|
||||||
|
Disposed
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 1f73ffad83dc461db1dde0b4bece5eda
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
12
My project/Assets/FlowScope/Runtime/Flow/IFeature.cs
Normal file
12
My project/Assets/FlowScope/Runtime/Flow/IFeature.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace FlowScope.Flow
|
||||||
|
{
|
||||||
|
public interface IFeature : IDisposable
|
||||||
|
{
|
||||||
|
Task LoadAsync(FeatureContext context);
|
||||||
|
Task EnterAsync(FeatureContext context);
|
||||||
|
Task ExitAsync(FeatureContext context);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
My project/Assets/FlowScope/Runtime/Flow/IFeature.cs.meta
Normal file
11
My project/Assets/FlowScope/Runtime/Flow/IFeature.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 749ce778d6814cf9a9fe89889025bccb
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
16
My project/Assets/FlowScope/Runtime/FlowScope.Runtime.asmdef
Normal file
16
My project/Assets/FlowScope/Runtime/FlowScope.Runtime.asmdef
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "FlowScope.Runtime",
|
||||||
|
"rootNamespace": "FlowScope",
|
||||||
|
"references": [
|
||||||
|
"R3.Unity"
|
||||||
|
],
|
||||||
|
"includePlatforms": [],
|
||||||
|
"excludePlatforms": [],
|
||||||
|
"allowUnsafeCode": false,
|
||||||
|
"overrideReferences": false,
|
||||||
|
"precompiledReferences": [],
|
||||||
|
"autoReferenced": true,
|
||||||
|
"defineConstraints": [],
|
||||||
|
"versionDefines": [],
|
||||||
|
"noEngineReferences": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 4f077ee742b346a188dc0c93b419f3ee
|
||||||
|
AssemblyDefinitionImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
8
My project/Assets/FlowScope/Runtime/Resources.meta
Normal file
8
My project/Assets/FlowScope/Runtime/Resources.meta
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 2a7d670c048e4930b0a0980773a6210b
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace FlowScope.Resources
|
||||||
|
{
|
||||||
|
public interface IResourceBackend
|
||||||
|
{
|
||||||
|
string Name { get; }
|
||||||
|
|
||||||
|
Task<T> LoadAsync<T>(
|
||||||
|
string key,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
where T : class;
|
||||||
|
|
||||||
|
void Release<T>(string key, T asset)
|
||||||
|
where T : class;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 76d9b18ff17949a987f26418e1f1c978
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace FlowScope.Resources
|
||||||
|
{
|
||||||
|
public interface IResourceGroup : IDisposable
|
||||||
|
{
|
||||||
|
void Add<T>(IResourceHandle<T> handle) where T : class;
|
||||||
|
int Count { get; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 0e613834058f49db95c2814ab52d7cc4
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace FlowScope.Resources
|
||||||
|
{
|
||||||
|
public interface IResourceHandle<T> : IDisposable where T : class
|
||||||
|
{
|
||||||
|
string Key { get; }
|
||||||
|
T Asset { get; }
|
||||||
|
bool IsDisposed { get; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: f248abc2018449e3be0905dc01ef7e11
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace FlowScope.Resources
|
||||||
|
{
|
||||||
|
public interface IResourceService
|
||||||
|
{
|
||||||
|
Task<IResourceHandle<T>> LoadAsync<T>(
|
||||||
|
string key,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
where T : class;
|
||||||
|
|
||||||
|
IResourceGroup CreateGroup();
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface ICompletedResourceService
|
||||||
|
{
|
||||||
|
bool TryLoadCompleted<T>(string key, out IResourceHandle<T> handle)
|
||||||
|
where T : class;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 52a701ee3b744171b5e01e927c83b645
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace FlowScope.Resources
|
||||||
|
{
|
||||||
|
internal sealed class ResourceEntry
|
||||||
|
{
|
||||||
|
private readonly Action<string, Type, object> _release;
|
||||||
|
private int _referenceCount;
|
||||||
|
private bool _released;
|
||||||
|
|
||||||
|
public ResourceEntry(string key, object asset, Action<string, object> release)
|
||||||
|
: this(key, asset?.GetType(), asset, (_, _, releasedAsset) => release(key, releasedAsset))
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public ResourceEntry(string key, Type assetType, object asset, Action<string, Type, object> release)
|
||||||
|
{
|
||||||
|
Key = string.IsNullOrWhiteSpace(key)
|
||||||
|
? throw new ArgumentException("Resource key is required.", nameof(key))
|
||||||
|
: key;
|
||||||
|
AssetType = assetType ?? throw new ArgumentNullException(nameof(assetType));
|
||||||
|
Asset = asset ?? throw new ArgumentNullException(nameof(asset));
|
||||||
|
_release = release ?? throw new ArgumentNullException(nameof(release));
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Key { get; }
|
||||||
|
public Type AssetType { get; }
|
||||||
|
public object Asset { get; private set; }
|
||||||
|
public int ReferenceCount => _referenceCount;
|
||||||
|
|
||||||
|
public ResourceHandle<T> CreateHandle<T>() where T : class
|
||||||
|
{
|
||||||
|
if (_released)
|
||||||
|
{
|
||||||
|
throw new ObjectDisposedException(nameof(ResourceEntry));
|
||||||
|
}
|
||||||
|
|
||||||
|
_referenceCount++;
|
||||||
|
return new ResourceHandle<T>(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ReleaseHandle()
|
||||||
|
{
|
||||||
|
if (_released)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_referenceCount--;
|
||||||
|
if (_referenceCount > 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_released = true;
|
||||||
|
var asset = Asset;
|
||||||
|
Asset = null;
|
||||||
|
_release(Key, AssetType, asset);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ReleaseWithoutHandle()
|
||||||
|
{
|
||||||
|
if (_released)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_released = true;
|
||||||
|
var asset = Asset;
|
||||||
|
Asset = null;
|
||||||
|
_release(Key, AssetType, asset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 02477283d77540ed9be0b002bb05f37a
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace FlowScope.Resources
|
||||||
|
{
|
||||||
|
public sealed class ResourceGroup : IResourceGroup
|
||||||
|
{
|
||||||
|
private readonly HashSet<IDisposable> _handles = new();
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public int Count => _handles.Count;
|
||||||
|
|
||||||
|
public void Add<T>(IResourceHandle<T> handle) where T : class
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
throw new ObjectDisposedException(nameof(ResourceGroup));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (handle == null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(handle));
|
||||||
|
}
|
||||||
|
|
||||||
|
_handles.Add(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_disposed = true;
|
||||||
|
foreach (var handle in _handles)
|
||||||
|
{
|
||||||
|
handle.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
_handles.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 896ce428279a4740b44cebeb2c0b635e
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace FlowScope.Resources
|
||||||
|
{
|
||||||
|
public sealed class ResourceHandle<T> : IResourceHandle<T> where T : class
|
||||||
|
{
|
||||||
|
private readonly ResourceEntry _entry;
|
||||||
|
private readonly Action<string, object> _release;
|
||||||
|
private T _asset;
|
||||||
|
|
||||||
|
internal ResourceHandle(ResourceEntry entry)
|
||||||
|
{
|
||||||
|
_entry = entry ?? throw new ArgumentNullException(nameof(entry));
|
||||||
|
Key = entry.Key;
|
||||||
|
_asset = (T)entry.Asset;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ResourceHandle(string key, T asset, Action<string, object> release)
|
||||||
|
{
|
||||||
|
Key = string.IsNullOrWhiteSpace(key)
|
||||||
|
? throw new ArgumentException("Resource key is required.", nameof(key))
|
||||||
|
: key;
|
||||||
|
_asset = asset ?? throw new ArgumentNullException(nameof(asset));
|
||||||
|
_release = release ?? throw new ArgumentNullException(nameof(release));
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Key { get; }
|
||||||
|
public T Asset => IsDisposed ? null : _asset;
|
||||||
|
public bool IsDisposed { get; private set; }
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (IsDisposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
IsDisposed = true;
|
||||||
|
var asset = _asset;
|
||||||
|
_asset = null;
|
||||||
|
|
||||||
|
if (_entry != null)
|
||||||
|
{
|
||||||
|
_entry.ReleaseHandle();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_release(Key, asset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: ab4e98e842c34a09a324062b57b09009
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
239
My project/Assets/FlowScope/Runtime/Resources/ResourceService.cs
Normal file
239
My project/Assets/FlowScope/Runtime/Resources/ResourceService.cs
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace FlowScope.Resources
|
||||||
|
{
|
||||||
|
public sealed class ResourceService : IResourceService, ICompletedResourceService
|
||||||
|
{
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private readonly IResourceBackend _backend;
|
||||||
|
private readonly Dictionary<ResourceKey, ResourceEntry> _entries = new();
|
||||||
|
private readonly Dictionary<ResourceKey, SharedLoad> _loads = new();
|
||||||
|
|
||||||
|
public ResourceService(IResourceBackend backend)
|
||||||
|
{
|
||||||
|
_backend = backend ?? throw new ArgumentNullException(nameof(backend));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IResourceHandle<T>> LoadAsync<T>(
|
||||||
|
string key,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
where T : class
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(key))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Resource key is required.", nameof(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var resourceKey = new ResourceKey(key, typeof(T));
|
||||||
|
SharedLoad sharedLoad;
|
||||||
|
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_entries.TryGetValue(resourceKey, out var entry))
|
||||||
|
{
|
||||||
|
return entry.CreateHandle<T>();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_loads.TryGetValue(resourceKey, out sharedLoad))
|
||||||
|
{
|
||||||
|
sharedLoad = new SharedLoad();
|
||||||
|
_loads.Add(resourceKey, sharedLoad);
|
||||||
|
}
|
||||||
|
|
||||||
|
sharedLoad.WaiterCount++;
|
||||||
|
if (sharedLoad.LoadTask == null)
|
||||||
|
{
|
||||||
|
sharedLoad.LoadTask = LoadAndOwnEntryAsync<T>(resourceKey, key, sharedLoad);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var loaded = await WaitForSharedLoad(sharedLoad.LoadTask, cancellationToken);
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
return loaded.CreateHandle<T>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (sharedLoad.WaiterCount > 0)
|
||||||
|
{
|
||||||
|
sharedLoad.WaiterCount--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryLoadCompleted<T>(string key, out IResourceHandle<T> handle)
|
||||||
|
where T : class
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(key))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Resource key is required.", nameof(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_entries.TryGetValue(new ResourceKey(key, typeof(T)), out var entry))
|
||||||
|
{
|
||||||
|
handle = entry.CreateHandle<T>();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handle = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IResourceGroup CreateGroup()
|
||||||
|
{
|
||||||
|
return new ResourceGroup();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ResourceEntry> LoadEntryAsync<T>(string key)
|
||||||
|
where T : class
|
||||||
|
{
|
||||||
|
T asset;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
asset = await _backend.LoadAsync<T>(key, CancellationToken.None);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Failed to load resource '{key}' as {typeof(T).Name} from backend '{_backend.Name}'.",
|
||||||
|
exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (asset == null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Resource '{key}' returned null for {typeof(T).Name} from backend '{_backend.Name}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ResourceEntry(
|
||||||
|
key,
|
||||||
|
typeof(T),
|
||||||
|
asset,
|
||||||
|
(entryKey, assetType, releasedAsset) =>
|
||||||
|
ReleaseBackendEntry(entryKey, assetType, (T)releasedAsset));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ResourceEntry> LoadAndOwnEntryAsync<T>(
|
||||||
|
ResourceKey resourceKey,
|
||||||
|
string key,
|
||||||
|
SharedLoad sharedLoad)
|
||||||
|
where T : class
|
||||||
|
{
|
||||||
|
ResourceEntry entry = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
entry = await LoadEntryAsync<T>(key);
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_loads.Remove(resourceKey);
|
||||||
|
if (sharedLoad.WaiterCount > 0)
|
||||||
|
{
|
||||||
|
_entries.Add(resourceKey, entry);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ReleaseEntry(entry);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_loads.Remove(resourceKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<ResourceEntry> WaitForSharedLoad(
|
||||||
|
Task<ResourceEntry> loadTask,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!cancellationToken.CanBeCanceled)
|
||||||
|
{
|
||||||
|
return await loadTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
var cancellation = new TaskCompletionSource<bool>();
|
||||||
|
using (cancellationToken.Register(() => cancellation.TrySetResult(true)))
|
||||||
|
{
|
||||||
|
if (await Task.WhenAny(loadTask, cancellation.Task) == cancellation.Task)
|
||||||
|
{
|
||||||
|
throw new OperationCanceledException(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return await loadTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReleaseEntry(ResourceEntry entry)
|
||||||
|
{
|
||||||
|
entry.ReleaseWithoutHandle();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReleaseBackendEntry<T>(string key, Type assetType, T asset)
|
||||||
|
where T : class
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_entries.Remove(new ResourceKey(key, assetType));
|
||||||
|
}
|
||||||
|
|
||||||
|
_backend.Release(key, asset);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class SharedLoad
|
||||||
|
{
|
||||||
|
public Task<ResourceEntry> LoadTask;
|
||||||
|
public int WaiterCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly struct ResourceKey : IEquatable<ResourceKey>
|
||||||
|
{
|
||||||
|
private readonly string _key;
|
||||||
|
private readonly Type _type;
|
||||||
|
|
||||||
|
public ResourceKey(string key, Type type)
|
||||||
|
{
|
||||||
|
_key = key;
|
||||||
|
_type = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Equals(ResourceKey other)
|
||||||
|
{
|
||||||
|
return _key == other._key && _type == other._type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override bool Equals(object obj)
|
||||||
|
{
|
||||||
|
return obj is ResourceKey other && Equals(other);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override int GetHashCode()
|
||||||
|
{
|
||||||
|
unchecked
|
||||||
|
{
|
||||||
|
return ((_key != null ? _key.GetHashCode() : 0) * 397) ^
|
||||||
|
(_type != null ? _type.GetHashCode() : 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: d123a5c6220843b18a09611f77e8cd31
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
8
My project/Assets/FlowScope/Runtime/Save.meta
Normal file
8
My project/Assets/FlowScope/Runtime/Save.meta
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: aade8defc2324dde8776534f5d8acccc
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
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:
|
||||||
11
My project/Assets/FlowScope/Runtime/Save/ISaveMigration.cs
Normal file
11
My project/Assets/FlowScope/Runtime/Save/ISaveMigration.cs
Normal file
@@ -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
My project/Assets/FlowScope/Runtime/Save/ISaveService.cs
Normal file
13
My project/Assets/FlowScope/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:
|
||||||
119
My project/Assets/FlowScope/Runtime/Save/JsonSaveSerializer.cs
Normal file
119
My project/Assets/FlowScope/Runtime/Save/JsonSaveSerializer.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
8
My project/Assets/FlowScope/Runtime/Save/SaveEnvelope.cs
Normal file
8
My project/Assets/FlowScope/Runtime/Save/SaveEnvelope.cs
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user