diff --git a/Packages/com.flowscope.gamecore/Addressables/AddressablesResourceBackend.cs b/Packages/com.flowscope.gamecore/Addressables/AddressablesResourceBackend.cs new file mode 100644 index 0000000..c59d7f8 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Addressables/AddressablesResourceBackend.cs @@ -0,0 +1,70 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using FlowScope.Resources; +using UnityEngine.ResourceManagement.AsyncOperations; +using UnityAddressables = UnityEngine.AddressableAssets.Addressables; + +namespace FlowScope.Addressables +{ + public sealed class AddressablesResourceBackend : IResourceBackend + { + public string Name => "Addressables"; + + public async Task LoadAsync(string key, CancellationToken cancellationToken) + where T : class + { + cancellationToken.ThrowIfCancellationRequested(); + + var handle = UnityAddressables.LoadAssetAsync(key); + try + { + await WaitWithCancellation(handle.Task, cancellationToken); + } + catch (OperationCanceledException) + { + UnityAddressables.Release(handle); + throw; + } + + if (handle.OperationException != null || + handle.Status != AsyncOperationStatus.Succeeded || + handle.Result == null) + { + var exception = new InvalidOperationException( + $"Failed to load Addressables resource '{key}' as {typeof(T).Name} from backend '{Name}'.", + handle.OperationException); + UnityAddressables.Release(handle); + throw exception; + } + + return handle.Result; + } + + public void Release(string key, T asset) + where T : class + { + UnityAddressables.Release(asset); + } + + private static async Task WaitWithCancellation(Task task, CancellationToken cancellationToken) + { + if (!cancellationToken.CanBeCanceled) + { + await task; + return; + } + + var cancellation = new TaskCompletionSource(); + using (cancellationToken.Register(() => cancellation.TrySetResult(true))) + { + if (await Task.WhenAny(task, cancellation.Task) == cancellation.Task) + { + throw new OperationCanceledException(cancellationToken); + } + } + + await task; + } + } +} diff --git a/Packages/com.flowscope.gamecore/Addressables/AddressablesResourceBackend.cs.meta b/Packages/com.flowscope.gamecore/Addressables/AddressablesResourceBackend.cs.meta new file mode 100644 index 0000000..d8f38df --- /dev/null +++ b/Packages/com.flowscope.gamecore/Addressables/AddressablesResourceBackend.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f65b4f61d3a34358bf48957eb3a14dd7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Addressables/AddressablesResourceService.cs b/Packages/com.flowscope.gamecore/Addressables/AddressablesResourceService.cs new file mode 100644 index 0000000..d715a0c --- /dev/null +++ b/Packages/com.flowscope.gamecore/Addressables/AddressablesResourceService.cs @@ -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> LoadAsync(string key, CancellationToken cancellationToken) + where T : class + { + return _inner.LoadAsync(key, cancellationToken); + } + + public IResourceGroup CreateGroup() + { + return _inner.CreateGroup(); + } + + public bool TryLoadCompleted(string key, out IResourceHandle handle) + where T : class + { + return _inner.TryLoadCompleted(key, out handle); + } + } +} diff --git a/Packages/com.flowscope.gamecore/Addressables/AddressablesResourceService.cs.meta b/Packages/com.flowscope.gamecore/Addressables/AddressablesResourceService.cs.meta new file mode 100644 index 0000000..b66f952 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Addressables/AddressablesResourceService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 62c87792aac14414b46ac0eb3e716f25 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Addressables/FlowScope.Addressables.asmdef b/Packages/com.flowscope.gamecore/Addressables/FlowScope.Addressables.asmdef new file mode 100644 index 0000000..ab7704a --- /dev/null +++ b/Packages/com.flowscope.gamecore/Addressables/FlowScope.Addressables.asmdef @@ -0,0 +1,18 @@ +{ + "name": "FlowScope.Addressables", + "rootNamespace": "FlowScope.Addressables", + "references": [ + "FlowScope.Runtime", + "Unity.Addressables", + "Unity.ResourceManager" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Packages/com.flowscope.gamecore/Addressables/FlowScope.Addressables.asmdef.meta b/Packages/com.flowscope.gamecore/Addressables/FlowScope.Addressables.asmdef.meta new file mode 100644 index 0000000..b4194a7 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Addressables/FlowScope.Addressables.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 917e69d4247b4f548a5951d84e7d35db +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/CHANGELOG.md b/Packages/com.flowscope.gamecore/CHANGELOG.md new file mode 100644 index 0000000..1f4fac5 --- /dev/null +++ b/Packages/com.flowscope.gamecore/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog + +## 0.2.0-preview.1 + +- Added the first Git-consumable FlowScope Game Core package layout. +- Mirrored Runtime, Addressables, and Editor sources from `Assets/FlowScope`. +- Added package metadata and Git installation notes for consuming Unity projects. + diff --git a/Packages/com.flowscope.gamecore/Documentation~/installation.md b/Packages/com.flowscope.gamecore/Documentation~/installation.md new file mode 100644 index 0000000..97da325 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Documentation~/installation.md @@ -0,0 +1,27 @@ +# FlowScope Git Package Installation + +## Consumer Manifest + +Add FlowScope and its Git-hosted R3 dependency to the consuming project's `Packages/manifest.json`: + +```json +{ + "dependencies": { + "com.cysharp.r3": "https://github.com/Cysharp/R3.git?path=src/R3.Unity/Assets/R3.Unity#1.3.0", + "com.flowscope.gamecore": "ssh://git@192.168.9.108:2222/Developer/BombGameFramework.git?path=/Packages/com.flowscope.gamecore#codex/pre-p2-architecture-fixes" + } +} +``` + +Keep the R3 dependency in the project manifest. Unity Package Manager can install a Git package from the project manifest, but it does not support Git dependencies declared inside another package's `package.json`. + +## Validation + +After Unity resolves the package: + +1. Confirm `FlowScope.Runtime`, `FlowScope.Addressables`, and `FlowScope.Editor` compile. +2. Open `Tools/FlowScope/Diagnostics`. +3. Confirm Addressables and UGUI dependencies are present. +4. Add a small probe script in the consuming project that references `FlowScope.Container.Container` and `FlowScope.Flow.GameFlow`. + +Do not copy `Assets/FlowScope` into the consuming project when testing the Git package path. That would validate project-internal source import rather than package consumption. diff --git a/Packages/com.flowscope.gamecore/Editor/FlowScope.Editor.asmdef b/Packages/com.flowscope.gamecore/Editor/FlowScope.Editor.asmdef new file mode 100644 index 0000000..63cff93 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Editor/FlowScope.Editor.asmdef @@ -0,0 +1,16 @@ +{ + "name": "FlowScope.Editor", + "rootNamespace": "FlowScope.Editor", + "references": [], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Packages/com.flowscope.gamecore/Editor/FlowScope.Editor.asmdef.meta b/Packages/com.flowscope.gamecore/Editor/FlowScope.Editor.asmdef.meta new file mode 100644 index 0000000..46174e8 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Editor/FlowScope.Editor.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: f3ee591f10b84cf7a39d50219698312e +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Editor/FlowScopeDiagnosticsWindow.cs b/Packages/com.flowscope.gamecore/Editor/FlowScopeDiagnosticsWindow.cs new file mode 100644 index 0000000..ede97d0 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Editor/FlowScopeDiagnosticsWindow.cs @@ -0,0 +1,283 @@ +using System; +using System.Collections.Generic; +using UnityEditor; +using UnityEditor.PackageManager; +using UnityEditor.PackageManager.Requests; +using UnityEngine; + +namespace FlowScope.Editor +{ + public sealed class FlowScopeDiagnosticsWindow : EditorWindow + { + private const string PackageRuntimeAsmdefPath = "Packages/com.flowscope.gamecore/Runtime/FlowScope.Runtime.asmdef"; + private const string PackageAddressablesAsmdefPath = "Packages/com.flowscope.gamecore/Addressables/FlowScope.Addressables.asmdef"; + private const string AssetsRuntimeAsmdefPath = "Assets/FlowScope/Runtime/FlowScope.Runtime.asmdef"; + private const string AssetsAddressablesAsmdefPath = "Assets/FlowScope/Addressables/FlowScope.Addressables.asmdef"; + private const string AssetsMainMenuScenePath = "Assets/FlowScope/Samples/MainMenuP0/Scenes/MainMenuP0.unity"; + private const string AssetsMainMenuPanelPrefabPath = "Assets/FlowScope/Samples/MainMenuP0/Prefabs/MainMenuPanel.prefab"; + private const string AddressablesPackageName = "com.unity.addressables"; + + private static readonly IReadOnlyList AssetChecks = new[] + { + new AssetCheck("Runtime asmdef", PackageRuntimeAsmdefPath, AssetsRuntimeAsmdefPath), + new AssetCheck("Addressables asmdef", PackageAddressablesAsmdefPath, AssetsAddressablesAsmdefPath), + new AssetCheck("MainMenuP0 scene", AssetsMainMenuScenePath), + new AssetCheck("MainMenuPanel prefab", AssetsMainMenuPanelPrefabPath), + }; + + private readonly List _assetResults = new List(); + + private ListRequest _packageListRequest; + private DiagnosticResult _addressablesPackageResult = DiagnosticResult.Pending( + "Addressables package", + "Package status has not been refreshed."); + + private Vector2 _scrollPosition; + + [MenuItem("Tools/FlowScope/Diagnostics")] + public static void Open() + { + FlowScopeDiagnosticsWindow window = GetWindow("FlowScope Diagnostics"); + window.minSize = new Vector2(520f, 320f); + window.RefreshAll(); + window.Show(); + } + + private void OnEnable() + { + RefreshAssetChecks(); + RefreshPackageStatus(); + } + + private void OnDisable() + { + EditorApplication.update -= PollPackageListRequest; + } + + private void OnGUI() + { + using (new EditorGUILayout.VerticalScope()) + { + EditorGUILayout.LabelField("FlowScope Diagnostics", EditorStyles.boldLabel); + EditorGUILayout.HelpBox("Read-only diagnostics. This window checks project state and does not create, move, delete, or repair user assets.", MessageType.Info); + + using (new EditorGUILayout.HorizontalScope()) + { + if (GUILayout.Button("Refresh", GUILayout.Width(96f))) + { + RefreshAll(); + } + + GUILayout.Label( + _packageListRequest != null && !_packageListRequest.IsCompleted + ? "Refreshing package status..." + : "Package status is complete or has not been requested.", + EditorStyles.miniLabel); + } + + EditorGUILayout.Space(); + + _scrollPosition = EditorGUILayout.BeginScrollView(_scrollPosition); + DrawResult(_addressablesPackageResult); + + foreach (DiagnosticResult result in _assetResults) + { + DrawResult(result); + } + + EditorGUILayout.EndScrollView(); + } + } + + private void RefreshAll() + { + RefreshAssetChecks(); + RefreshPackageStatus(); + Repaint(); + } + + private void RefreshAssetChecks() + { + _assetResults.Clear(); + + foreach (AssetCheck check in AssetChecks) + { + string foundPath = FindExistingAssetPath(check.Paths); + _assetResults.Add(foundPath != null + ? DiagnosticResult.Pass(check.Label, foundPath) + : DiagnosticResult.Fail(check.Label, string.Join(" or ", check.Paths))); + } + } + + private static string FindExistingAssetPath(IReadOnlyList paths) + { + foreach (string path in paths) + { + if (AssetDatabase.LoadAssetAtPath(path) != null) + { + return path; + } + } + + return null; + } + + private void RefreshPackageStatus() + { + EditorApplication.update -= PollPackageListRequest; + + _addressablesPackageResult = DiagnosticResult.Pending( + "Addressables package", + "Querying com.unity.addressables through Unity Package Manager."); + + _packageListRequest = Client.List(true); + EditorApplication.update += PollPackageListRequest; + } + + private void PollPackageListRequest() + { + if (_packageListRequest == null || !_packageListRequest.IsCompleted) + { + return; + } + + EditorApplication.update -= PollPackageListRequest; + + if (_packageListRequest.Status == StatusCode.Success) + { + bool found = false; + foreach (UnityEditor.PackageManager.PackageInfo packageInfo in _packageListRequest.Result) + { + if (string.Equals(packageInfo.name, AddressablesPackageName, StringComparison.Ordinal)) + { + found = true; + _addressablesPackageResult = DiagnosticResult.Pass( + "Addressables package", + packageInfo.name + " " + packageInfo.version); + break; + } + } + + if (!found) + { + _addressablesPackageResult = DiagnosticResult.Fail( + "Addressables package", + "com.unity.addressables was not found in the current Package Manager list."); + } + } + else + { + string errorMessage = _packageListRequest.Error != null + ? _packageListRequest.Error.message + : "Package Manager returned an unknown error."; + _addressablesPackageResult = DiagnosticResult.Warning("Addressables package", errorMessage); + } + + _packageListRequest = null; + Repaint(); + } + + private static void DrawResult(DiagnosticResult result) + { + MessageType messageType = ToMessageType(result.Status); + + using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox)) + { + using (new EditorGUILayout.HorizontalScope()) + { + GUILayout.Label(ToStatusText(result.Status), GUILayout.Width(72f)); + EditorGUILayout.LabelField(result.Label, EditorStyles.boldLabel); + } + + EditorGUILayout.HelpBox(result.Detail, messageType); + } + } + + private static MessageType ToMessageType(DiagnosticStatus status) + { + switch (status) + { + case DiagnosticStatus.Pass: + return MessageType.Info; + case DiagnosticStatus.Fail: + return MessageType.Error; + case DiagnosticStatus.Warning: + return MessageType.Warning; + default: + return MessageType.None; + } + } + + private static string ToStatusText(DiagnosticStatus status) + { + switch (status) + { + case DiagnosticStatus.Pass: + return "OK"; + case DiagnosticStatus.Fail: + return "Missing"; + case DiagnosticStatus.Warning: + return "Warning"; + default: + return "Pending"; + } + } + + private readonly struct AssetCheck + { + public AssetCheck(string label, params string[] paths) + { + Label = label; + Paths = paths; + } + + public string Label { get; } + + public IReadOnlyList Paths { get; } + } + + private readonly struct DiagnosticResult + { + private DiagnosticResult(DiagnosticStatus status, string label, string detail) + { + Status = status; + Label = label; + Detail = detail; + } + + public DiagnosticStatus Status { get; } + + public string Label { get; } + + public string Detail { get; } + + public static DiagnosticResult Pass(string label, string detail) + { + return new DiagnosticResult(DiagnosticStatus.Pass, label, detail); + } + + public static DiagnosticResult Fail(string label, string detail) + { + return new DiagnosticResult(DiagnosticStatus.Fail, label, detail); + } + + public static DiagnosticResult Warning(string label, string detail) + { + return new DiagnosticResult(DiagnosticStatus.Warning, label, detail); + } + + public static DiagnosticResult Pending(string label, string detail) + { + return new DiagnosticResult(DiagnosticStatus.Pending, label, detail); + } + } + + private enum DiagnosticStatus + { + Pending, + Pass, + Warning, + Fail, + } + } +} diff --git a/Packages/com.flowscope.gamecore/Editor/FlowScopeDiagnosticsWindow.cs.meta b/Packages/com.flowscope.gamecore/Editor/FlowScopeDiagnosticsWindow.cs.meta new file mode 100644 index 0000000..7afa200 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Editor/FlowScopeDiagnosticsWindow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 599ef528ec0c4331a77311f6ef7ef56b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/README.md b/Packages/com.flowscope.gamecore/README.md new file mode 100644 index 0000000..ad6355a --- /dev/null +++ b/Packages/com.flowscope.gamecore/README.md @@ -0,0 +1,29 @@ +# FlowScope Game Core + +FlowScope Game Core is a lightweight Unity runtime foundation for feature flow, dependency registration, resources, config, save, audio, and UGUI panel management. + +This package is currently a P2 preview package. Runtime, Addressables, and Editor sources are mirrored from the project-internal `Assets/FlowScope` implementation so a consuming Unity project can validate Git-based package installation. + +## Install From Git + +Add the package to the consuming project's `Packages/manifest.json`: + +```json +{ + "dependencies": { + "com.cysharp.r3": "https://github.com/Cysharp/R3.git?path=src/R3.Unity/Assets/R3.Unity#1.3.0", + "com.flowscope.gamecore": "ssh://git@192.168.9.108:2222/Developer/BombGameFramework.git?path=/Packages/com.flowscope.gamecore#codex/pre-p2-architecture-fixes" + } +} +``` + +`com.cysharp.r3` must be declared by the consuming project because Unity Package Manager does not support Git dependencies inside a package's `package.json`. + +## Assembly Boundaries + +- `FlowScope.Runtime` references `R3.Unity`. +- `FlowScope.Addressables` references `FlowScope.Runtime`, `Unity.Addressables`, and `Unity.ResourceManager`. +- `FlowScope.Editor` is Editor-only and only provides read-only diagnostics. + +Runtime must not depend on Addressables, Editor, Samples, or Tests. + diff --git a/Packages/com.flowscope.gamecore/Runtime/Audio.meta b/Packages/com.flowscope.gamecore/Runtime/Audio.meta new file mode 100644 index 0000000..ac38d9b --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Audio.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: faa0e59581ea46ed8994f743336decdc +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/Audio/AudioHandle.cs b/Packages/com.flowscope.gamecore/Runtime/Audio/AudioHandle.cs new file mode 100644 index 0000000..1d8c61d --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Audio/AudioHandle.cs @@ -0,0 +1,25 @@ +using System; + +namespace FlowScope.Audio +{ + public sealed class AudioHandle : IAudioHandle + { + private readonly Func _isPlaying; + private readonly Action _stop; + + internal AudioHandle(Func isPlaying, Action 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, _ => { }); + } +} diff --git a/Packages/com.flowscope.gamecore/Runtime/Audio/AudioHandle.cs.meta b/Packages/com.flowscope.gamecore/Runtime/Audio/AudioHandle.cs.meta new file mode 100644 index 0000000..270aa05 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Audio/AudioHandle.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9e7da42c17284465ab9a68bec5387f19 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/Audio/AudioService.cs b/Packages/com.flowscope.gamecore/Runtime/Audio/AudioService.cs new file mode 100644 index 0000000..f3ea89d --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Audio/AudioService.cs @@ -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 _sfxSlots = new(); + private IResourceService _resources; + private AudioServiceConfig _config; + private AudioSource _bgmSource; + private IResourceHandle _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 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 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 clipHandle, bool loop, float fadeIn) + { + StopBgm(); + _bgmSource = gameObject.AddComponent(); + _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 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 LoadClipIfCompleted(string key) + { + try + { + if (!(_resources is ICompletedResourceService completedResources) || + !completedResources.TryLoadCompleted(key, out IResourceHandle 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> LoadClipAsync( + string key, + CancellationToken cancellationToken) + { + try + { + var handle = await _resources.LoadAsync(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 ValidateLoadedClip( + string key, + IResourceHandle 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(); + 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 Resource { get; set; } + public int Sequence { get; set; } + + public void Release() + { + Resource?.Dispose(); + Resource = null; + } + } + } +} diff --git a/Packages/com.flowscope.gamecore/Runtime/Audio/AudioService.cs.meta b/Packages/com.flowscope.gamecore/Runtime/Audio/AudioService.cs.meta new file mode 100644 index 0000000..05968b6 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Audio/AudioService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9e81e8f0ce1a42e6a3b09bf74893b6b6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/Audio/AudioServiceConfig.cs b/Packages/com.flowscope.gamecore/Runtime/Audio/AudioServiceConfig.cs new file mode 100644 index 0000000..d5430ca --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Audio/AudioServiceConfig.cs @@ -0,0 +1,8 @@ +namespace FlowScope.Audio +{ + public sealed class AudioServiceConfig + { + public int SfxPoolSize = 10; + public bool ReuseOldestWhenExhausted = true; + } +} diff --git a/Packages/com.flowscope.gamecore/Runtime/Audio/AudioServiceConfig.cs.meta b/Packages/com.flowscope.gamecore/Runtime/Audio/AudioServiceConfig.cs.meta new file mode 100644 index 0000000..886ea73 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Audio/AudioServiceConfig.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 276b2d62e26a4edcaaeba1a96a3c2917 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/Audio/IAudioHandle.cs b/Packages/com.flowscope.gamecore/Runtime/Audio/IAudioHandle.cs new file mode 100644 index 0000000..5bc19f4 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Audio/IAudioHandle.cs @@ -0,0 +1,8 @@ +namespace FlowScope.Audio +{ + public interface IAudioHandle + { + void Stop(float fadeOut = 0f); + bool IsPlaying { get; } + } +} diff --git a/Packages/com.flowscope.gamecore/Runtime/Audio/IAudioHandle.cs.meta b/Packages/com.flowscope.gamecore/Runtime/Audio/IAudioHandle.cs.meta new file mode 100644 index 0000000..efcdb8e --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Audio/IAudioHandle.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 780afa085183438eaa9b372219113d62 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/Audio/IAudioService.cs b/Packages/com.flowscope.gamecore/Runtime/Audio/IAudioService.cs new file mode 100644 index 0000000..7bf8038 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Audio/IAudioService.cs @@ -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 PlayBgmAsync( + string key, + bool loop = true, + float fadeIn = 0f, + CancellationToken cancellationToken = default); + + IAudioHandle PlaySfx(string key); + Task PlaySfxAsync( + string key, + CancellationToken cancellationToken = default); + + void StopBgm(float fadeOut = 0f); + void SetBgmVolume(float volume); + void SetSfxVolume(float volume); + void SetMute(bool mute); + } +} diff --git a/Packages/com.flowscope.gamecore/Runtime/Audio/IAudioService.cs.meta b/Packages/com.flowscope.gamecore/Runtime/Audio/IAudioService.cs.meta new file mode 100644 index 0000000..a7da8dc --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Audio/IAudioService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2763bff6b4ca4ac2ae5b9e5f764ad984 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/Common.meta b/Packages/com.flowscope.gamecore/Runtime/Common.meta new file mode 100644 index 0000000..51e39c3 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Common.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 76e811c4a8bd4f6497926d5befb64b83 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/Common/ResultLog.cs b/Packages/com.flowscope.gamecore/Runtime/Common/ResultLog.cs new file mode 100644 index 0000000..1944e35 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Common/ResultLog.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; + +namespace FlowScope.Common +{ + public sealed class ResultLog + { + private readonly List _warnings = new(); + private readonly List _errors = new(); + + public IReadOnlyList Warnings => _warnings; + public IReadOnlyList 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(); + } + } +} diff --git a/Packages/com.flowscope.gamecore/Runtime/Common/ResultLog.cs.meta b/Packages/com.flowscope.gamecore/Runtime/Common/ResultLog.cs.meta new file mode 100644 index 0000000..d1c7401 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Common/ResultLog.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a4063b7b41014f9399532dd3cc83e68f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/Config.meta b/Packages/com.flowscope.gamecore/Runtime/Config.meta new file mode 100644 index 0000000..5d6f27b --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Config.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 2f421e0a585f4a279d8db9410eb82711 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/Config/FileConfigSource.cs b/Packages/com.flowscope.gamecore/Runtime/Config/FileConfigSource.cs new file mode 100644 index 0000000..be05e9a --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Config/FileConfigSource.cs @@ -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 LoadTextAsync(string fileName, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var path = Path.Combine(_rootPath, fileName); + return Task.Run(() => File.ReadAllText(path), cancellationToken); + } + } +} diff --git a/Packages/com.flowscope.gamecore/Runtime/Config/FileConfigSource.cs.meta b/Packages/com.flowscope.gamecore/Runtime/Config/FileConfigSource.cs.meta new file mode 100644 index 0000000..1fbea3e --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Config/FileConfigSource.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8d50cd860ba849a1aa7647deba4e71db +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/Config/IConfigParser.cs b/Packages/com.flowscope.gamecore/Runtime/Config/IConfigParser.cs new file mode 100644 index 0000000..b620c14 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Config/IConfigParser.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; + +namespace FlowScope.Config +{ + public interface IConfigParser + { + string Format { get; } + + IReadOnlyList ParseRows( + string fileName, + string text) + where T : class, IConfigRow; + } +} diff --git a/Packages/com.flowscope.gamecore/Runtime/Config/IConfigParser.cs.meta b/Packages/com.flowscope.gamecore/Runtime/Config/IConfigParser.cs.meta new file mode 100644 index 0000000..90e757e --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Config/IConfigParser.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0d22229e25964ea29b3c6d153507b1db +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/Config/IConfigProvider.cs b/Packages/com.flowscope.gamecore/Runtime/Config/IConfigProvider.cs new file mode 100644 index 0000000..7d3ce88 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Config/IConfigProvider.cs @@ -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(int id) where T : class, IConfigRow; + IReadOnlyList GetAll() where T : class, IConfigRow; + } +} diff --git a/Packages/com.flowscope.gamecore/Runtime/Config/IConfigProvider.cs.meta b/Packages/com.flowscope.gamecore/Runtime/Config/IConfigProvider.cs.meta new file mode 100644 index 0000000..2ae68b0 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Config/IConfigProvider.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: af3d9a24ab0e4c6ebe3bf906cfd9e2bb +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/Config/IConfigRow.cs b/Packages/com.flowscope.gamecore/Runtime/Config/IConfigRow.cs new file mode 100644 index 0000000..58fef28 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Config/IConfigRow.cs @@ -0,0 +1,7 @@ +namespace FlowScope.Config +{ + public interface IConfigRow + { + int Id { get; } + } +} diff --git a/Packages/com.flowscope.gamecore/Runtime/Config/IConfigRow.cs.meta b/Packages/com.flowscope.gamecore/Runtime/Config/IConfigRow.cs.meta new file mode 100644 index 0000000..420e72b --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Config/IConfigRow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 20d7e0e8230e4df1b5afa8000ef26fea +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/Config/IConfigSource.cs b/Packages/com.flowscope.gamecore/Runtime/Config/IConfigSource.cs new file mode 100644 index 0000000..4fec809 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Config/IConfigSource.cs @@ -0,0 +1,14 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace FlowScope.Config +{ + public interface IConfigSource + { + string Name { get; } + + Task LoadTextAsync( + string fileName, + CancellationToken cancellationToken); + } +} diff --git a/Packages/com.flowscope.gamecore/Runtime/Config/IConfigSource.cs.meta b/Packages/com.flowscope.gamecore/Runtime/Config/IConfigSource.cs.meta new file mode 100644 index 0000000..e84d3af --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Config/IConfigSource.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 708db2a1c26743f3994b5d8beb1a03f8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/Config/InMemoryConfigSource.cs b/Packages/com.flowscope.gamecore/Runtime/Config/InMemoryConfigSource.cs new file mode 100644 index 0000000..55ad6a2 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Config/InMemoryConfigSource.cs @@ -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 _files; + + public InMemoryConfigSource( + IReadOnlyDictionary files, + string name = "InMemory") + { + _files = files != null + ? new Dictionary(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 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); + } + } +} diff --git a/Packages/com.flowscope.gamecore/Runtime/Config/InMemoryConfigSource.cs.meta b/Packages/com.flowscope.gamecore/Runtime/Config/InMemoryConfigSource.cs.meta new file mode 100644 index 0000000..dcdce36 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Config/InMemoryConfigSource.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 607d9b20e7f04565929ea5876e5c25e1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/Config/JsonConfigParser.cs b/Packages/com.flowscope.gamecore/Runtime/Config/JsonConfigParser.cs new file mode 100644 index 0000000..2cb4263 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Config/JsonConfigParser.cs @@ -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 ParseRows(string fileName, string text) + where T : class, IConfigRow + { + return ReactivePropertyJsonConverter.DeserializeArray(text); + } + } +} diff --git a/Packages/com.flowscope.gamecore/Runtime/Config/JsonConfigParser.cs.meta b/Packages/com.flowscope.gamecore/Runtime/Config/JsonConfigParser.cs.meta new file mode 100644 index 0000000..cf41d48 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Config/JsonConfigParser.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f60f8a8e66394f26a20ed6f5ef314c63 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/Config/JsonConfigProvider.cs b/Packages/com.flowscope.gamecore/Runtime/Config/JsonConfigProvider.cs new file mode 100644 index 0000000..1e495b4 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Config/JsonConfigProvider.cs @@ -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 _mappings; + private readonly Dictionary _rowsByType = new(); + + public JsonConfigProvider(IReadOnlyList mappings) + { + _mappings = mappings ?? throw new ArgumentNullException(nameof(mappings)); + } + + public static ConfigMapping Mapping(string fileName, Func> loadTextAsync) + where T : class, IConfigRow + { + return Mapping( + fileName, + new DelegateConfigSource(fileName, loadTextAsync), + new JsonConfigParser()); + } + + public static ConfigMapping Mapping( + 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(); + + 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(int id) where T : class, IConfigRow + { + return _rowsByType.TryGetValue(typeof(T), out var table) && + table is Dictionary rows && + rows.TryGetValue(id, out var row) + ? row + : null; + } + + public IReadOnlyList GetAll() where T : class, IConfigRow + { + if (!_rowsByType.TryGetValue(typeof(T), out var table) || table is not Dictionary rows) + { + return Array.Empty(); + } + + return new List(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> _loadTextAsync; + + public DelegateConfigSource( + string fileName, + Func> loadTextAsync) + { + Name = fileName; + _loadTextAsync = loadTextAsync ?? throw new ArgumentNullException(nameof(loadTextAsync)); + } + + public string Name { get; } + + public Task LoadTextAsync(string fileName, CancellationToken cancellationToken) + { + return _loadTextAsync(cancellationToken); + } + } + } +} diff --git a/Packages/com.flowscope.gamecore/Runtime/Config/JsonConfigProvider.cs.meta b/Packages/com.flowscope.gamecore/Runtime/Config/JsonConfigProvider.cs.meta new file mode 100644 index 0000000..08df55f --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Config/JsonConfigProvider.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0b6c05d7a1d74798901d3efcc51cb7c4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/Container.meta b/Packages/com.flowscope.gamecore/Runtime/Container.meta new file mode 100644 index 0000000..f7d417a --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Container.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 8d24db89928e4010a3c1af79ffb15709 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/Container/Container.cs b/Packages/com.flowscope.gamecore/Runtime/Container/Container.cs new file mode 100644 index 0000000..e067c4e --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Container/Container.cs @@ -0,0 +1,301 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace FlowScope.Container +{ + public sealed class Container : IDisposable + { + [ThreadStatic] + private static Stack _resolutionStack; + + private readonly Container _parent; + private readonly Dictionary _registrations = new Dictionary(); + private readonly List _children = new List(); + private readonly List _ownedDisposables = new List(); + private bool _disposed; + + public Container() + { + } + + private Container(Container parent) + { + _parent = parent; + } + + public void RegisterInstance(T instance) + { + ThrowIfDisposed(); + + if (instance == null) + { + throw new ArgumentNullException(nameof(instance)); + } + + RegisterLocal(typeof(T), ContainerRegistration.ForInstance(instance)); + } + + public void RegisterFactory(Func factory) + { + RegisterSingletonFactory(factory); + } + + public void RegisterSingletonFactory(Func factory) + { + ThrowIfDisposed(); + + if (factory == null) + { + throw new ArgumentNullException(nameof(factory)); + } + + RegisterLocal(typeof(T), ContainerRegistration.ForSingletonFactory(this, c => factory(c))); + } + + public void RegisterScoped(Func factory) + { + ThrowIfDisposed(); + + if (factory == null) + { + throw new ArgumentNullException(nameof(factory)); + } + + RegisterLocal(typeof(T), ContainerRegistration.ForScopedFactory(this, c => factory(c))); + } + + public void RegisterTransient(Func factory) + { + ThrowIfDisposed(); + + if (factory == null) + { + throw new ArgumentNullException(nameof(factory)); + } + + RegisterLocal(typeof(T), ContainerRegistration.ForTransientFactory(this, c => factory(c))); + } + + public void RegisterType() where TImplementation : TInterface + { + ThrowIfDisposed(); + + RegisterType(typeof(TInterface), typeof(TImplementation)); + } + + public void RegisterType() + { + ThrowIfDisposed(); + + RegisterType(typeof(TImplementation), typeof(TImplementation)); + } + + public void RegisterAssembly() + { + ThrowIfDisposed(); + + GeneratedFactories.RegisterAssembly(this); + } + + public T Resolve() + { + ThrowIfDisposed(); + + return (T)Resolve(typeof(T)); + } + + public bool TryResolve(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(); + + 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(); + ForgetScopedInstancesFor(this); + _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 factory) + { + ThrowIfDisposed(); + + if (serviceType == null) + { + throw new ArgumentNullException(nameof(serviceType)); + } + + if (factory == null) + { + throw new ArgumentNullException(nameof(factory)); + } + + RegisterLocal(serviceType, ContainerRegistration.ForTransientFactory(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(); + + 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(this); + } + 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 ForgetScopedInstancesFor(Container scope) + { + foreach (var registration in _registrations.Values) + { + registration.ForgetScope(scope); + } + + _parent?.ForgetScopedInstancesFor(scope); + } + + private void ThrowIfDisposed() + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(Container)); + } + } + + private static void TryDispose(IDisposable disposable, List exceptions) + { + try + { + disposable.Dispose(); + } + catch (Exception ex) + { + exceptions.Add(ex); + } + } + + private static string FormatType(Type type) => type.FullName ?? type.Name; + } +} diff --git a/Packages/com.flowscope.gamecore/Runtime/Container/Container.cs.meta b/Packages/com.flowscope.gamecore/Runtime/Container/Container.cs.meta new file mode 100644 index 0000000..37b5a33 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Container/Container.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 554409c4a7e84909a72a89d17aeb2b0b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/Container/ContainerRegistration.cs b/Packages/com.flowscope.gamecore/Runtime/Container/ContainerRegistration.cs new file mode 100644 index 0000000..c414d22 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Container/ContainerRegistration.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; + +namespace FlowScope.Container +{ + internal sealed class ContainerRegistration + { + private enum Lifetime + { + Instance, + Singleton, + Scoped, + Transient + } + + private readonly Container _owner; + private readonly Func _factory; + private readonly Lifetime _lifetime; + private readonly Dictionary _scopedInstances; + private object _instance; + private bool _created; + + private ContainerRegistration( + Container owner, + Func factory, + object instance, + bool created, + Lifetime lifetime) + { + _owner = owner; + _factory = factory; + _instance = instance; + _created = created; + _lifetime = lifetime; + _scopedInstances = lifetime == Lifetime.Scoped + ? new Dictionary() + : null; + } + + public static ContainerRegistration ForInstance(object instance) + { + return new ContainerRegistration(null, null, instance, true, Lifetime.Instance); + } + + public static ContainerRegistration ForSingletonFactory(Container owner, Func factory) + { + return new ContainerRegistration(owner, factory, null, false, Lifetime.Singleton); + } + + public static ContainerRegistration ForScopedFactory(Container owner, Func factory) + { + return new ContainerRegistration(owner, factory, null, false, Lifetime.Scoped); + } + + public static ContainerRegistration ForTransientFactory(Container owner, Func factory) + { + return new ContainerRegistration(owner, factory, null, false, Lifetime.Transient); + } + + public object Resolve(Container requestScope) + { + switch (_lifetime) + { + case Lifetime.Instance: + return _instance; + case Lifetime.Singleton: + return ResolveSingleton(); + case Lifetime.Scoped: + return ResolveScoped(requestScope); + case Lifetime.Transient: + return CreateOwnedInstance(requestScope); + default: + throw new InvalidOperationException($"Unsupported container lifetime {_lifetime}."); + } + } + + public void ForgetScope(Container scope) + { + _scopedInstances?.Remove(scope); + } + + private object ResolveSingleton() + { + if (!_created) + { + _instance = CreateOwnedInstance(_owner); + _created = true; + } + + return _instance; + } + + private object ResolveScoped(Container requestScope) + { + if (_scopedInstances.TryGetValue(requestScope, out var instance)) + { + return instance; + } + + instance = CreateOwnedInstance(requestScope); + _scopedInstances.Add(requestScope, instance); + return instance; + } + + private object CreateOwnedInstance(Container owner) + { + var instance = _factory(owner); + + if (instance is IDisposable disposable) + { + owner.TrackOwnedDisposable(disposable); + } + + return instance; + } + } +} diff --git a/Packages/com.flowscope.gamecore/Runtime/Container/ContainerRegistration.cs.meta b/Packages/com.flowscope.gamecore/Runtime/Container/ContainerRegistration.cs.meta new file mode 100644 index 0000000..4af7558 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Container/ContainerRegistration.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d024d7ed8fd642b6948a216b3a676e22 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/Container/GeneratedFactories.cs b/Packages/com.flowscope.gamecore/Runtime/Container/GeneratedFactories.cs new file mode 100644 index 0000000..c786b9c --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Container/GeneratedFactories.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; + +namespace FlowScope.Container +{ + public static class GeneratedFactories + { + private static readonly Dictionary> Factories = new Dictionary>(); + + public static void Register(Func factory) + { + if (factory == null) + { + throw new ArgumentNullException(nameof(factory)); + } + + Factories[typeof(TImplementation)] = c => factory(c); + } + + public static bool TryGetFactory(Type implementationType, out Func 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); + } + } + } +} diff --git a/Packages/com.flowscope.gamecore/Runtime/Container/GeneratedFactories.cs.meta b/Packages/com.flowscope.gamecore/Runtime/Container/GeneratedFactories.cs.meta new file mode 100644 index 0000000..4a88501 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Container/GeneratedFactories.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: fc50e3c76ee147b59d2663e872ced1a0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/Container/InjectableAttribute.cs b/Packages/com.flowscope.gamecore/Runtime/Container/InjectableAttribute.cs new file mode 100644 index 0000000..72b4ff4 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Container/InjectableAttribute.cs @@ -0,0 +1,9 @@ +using System; + +namespace FlowScope.Container +{ + [AttributeUsage(AttributeTargets.Class)] + public sealed class InjectableAttribute : Attribute + { + } +} diff --git a/Packages/com.flowscope.gamecore/Runtime/Container/InjectableAttribute.cs.meta b/Packages/com.flowscope.gamecore/Runtime/Container/InjectableAttribute.cs.meta new file mode 100644 index 0000000..9cc5a56 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Container/InjectableAttribute.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 039257eaaf6c4ac198a3504795b52df0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/Container/ReflectionFactoryBuilder.cs b/Packages/com.flowscope.gamecore/Runtime/Container/ReflectionFactoryBuilder.cs new file mode 100644 index 0000000..6f802d4 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Container/ReflectionFactoryBuilder.cs @@ -0,0 +1,62 @@ +using System; +using System.Linq; +using System.Reflection; + +namespace FlowScope.Container +{ + public static class ReflectionFactoryBuilder + { + public static void RegisterType(Container container) + { + if (container == null) + { + throw new ArgumentNullException(nameof(container)); + } + + container.RegisterFactory(typeof(TImplementation), BuildFactory(typeof(TImplementation))); + } + + public static void RegisterType(Container container) + where TImplementation : TInterface + { + if (container == null) + { + throw new ArgumentNullException(nameof(container)); + } + + container.RegisterFactory(typeof(TInterface), BuildFactory(typeof(TImplementation))); + } + + public static Func 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; + } +} diff --git a/Packages/com.flowscope.gamecore/Runtime/Container/ReflectionFactoryBuilder.cs.meta b/Packages/com.flowscope.gamecore/Runtime/Container/ReflectionFactoryBuilder.cs.meta new file mode 100644 index 0000000..68d1a48 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Container/ReflectionFactoryBuilder.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1d2827e4c16e4b4ea60a84f709d54665 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/Data.meta b/Packages/com.flowscope.gamecore/Runtime/Data.meta new file mode 100644 index 0000000..c9cbd31 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Data.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 67eb0ac45b8b4cf8bafcb604dd6e0e11 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/Data/ReactivePropertyJsonConverter.cs b/Packages/com.flowscope.gamecore/Runtime/Data/ReactivePropertyJsonConverter.cs new file mode 100644 index 0000000..1fa94dc --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Data/ReactivePropertyJsonConverter.cs @@ -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(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 values) + { + throw new InvalidOperationException($"JSON root must be an object for {target.GetType().Name}."); + } + + PopulateFromDictionary(values, target); + } + + public static IReadOnlyList DeserializeArray(string json) where T : class + { + if (SimpleJson.Parse(json) is not List rows) + { + throw new InvalidOperationException("JSON root must be an array."); + } + + var results = new List(rows.Count); + foreach (var row in rows) + { + if (row is not Dictionary 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(); + foreach (var item in (IEnumerable)value) + { + array.Add(ToJsonValue(item)); + } + + return array; + } + + var result = new Dictionary(); + 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 nested) + { + var instance = Activator.CreateInstance(targetType, true); + PopulateFromDictionary(nested, instance); + return instance; + } + + throw TypeMismatch(fieldName, targetType, value); + } + + private static void PopulateFromDictionary(IReadOnlyDictionary 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 rows) + { + throw TypeMismatch(fieldName, collectionType, value); + } + + var itemType = GetCollectionItemType(collectionType); + collectionType.GetMethod("Clear", Type.EmptyTypes)?.Invoke(collection, Array.Empty()); + 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 GetWritableMembers(Type type) + { + var members = new Dictionary(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 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 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 map: + WriteObject(builder, map); + break; + case IEnumerable 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 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 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 ParseObject() + { + Expect('{'); + var map = new Dictionary(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 ParseArray() + { + Expect('['); + var array = new List(); + 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++; + } + } + } + } +} diff --git a/Packages/com.flowscope.gamecore/Runtime/Data/ReactivePropertyJsonConverter.cs.meta b/Packages/com.flowscope.gamecore/Runtime/Data/ReactivePropertyJsonConverter.cs.meta new file mode 100644 index 0000000..c89b997 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Data/ReactivePropertyJsonConverter.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0c9f18f342e54f8d9a4dbdc61b74a2d1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/Flow.meta b/Packages/com.flowscope.gamecore/Runtime/Flow.meta new file mode 100644 index 0000000..250b3ec --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Flow.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 87d135bef01743c29768ef28ce8acf69 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/Flow/FeatureBase.cs b/Packages/com.flowscope.gamecore/Runtime/Flow/FeatureBase.cs new file mode 100644 index 0000000..5c01986 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Flow/FeatureBase.cs @@ -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; + } + } +} diff --git a/Packages/com.flowscope.gamecore/Runtime/Flow/FeatureBase.cs.meta b/Packages/com.flowscope.gamecore/Runtime/Flow/FeatureBase.cs.meta new file mode 100644 index 0000000..7ba1d72 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Flow/FeatureBase.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a5f4e3fb56394e719bff30e8edd4abaa +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/Flow/FeatureContext.cs b/Packages/com.flowscope.gamecore/Runtime/Flow/FeatureContext.cs new file mode 100644 index 0000000..d9e5d4e --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Flow/FeatureContext.cs @@ -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; } + } +} diff --git a/Packages/com.flowscope.gamecore/Runtime/Flow/FeatureContext.cs.meta b/Packages/com.flowscope.gamecore/Runtime/Flow/FeatureContext.cs.meta new file mode 100644 index 0000000..6b6a9e6 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Flow/FeatureContext.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4196008ec1434af48d1307f18d14b655 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/Flow/GameFlow.cs b/Packages/com.flowscope.gamecore/Runtime/Flow/GameFlow.cs new file mode 100644 index 0000000..d0b2eb4 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Flow/GameFlow.cs @@ -0,0 +1,264 @@ +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( + 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(); + await config.LoadAllAsync(cancellationToken); + + (feature, context) = CreateFeature(cancellationToken); + await feature.LoadAsync(context); + await feature.EnterAsync(context); + + _activeFeature = feature; + _activeContext = context; + State = GameFlowState.Running; + } + catch + { + BestEffortDisposeContext(context); + _activeFeature = null; + _activeContext = null; + State = GameFlowState.Idle; + throw; + } + } + + public async Task SwitchToAsync(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(cancellationToken); + await nextFeature.LoadAsync(nextContext); + await nextFeature.EnterAsync(nextContext); + + _activeFeature = nextFeature; + _activeContext = nextContext; + State = GameFlowState.Running; + } + catch + { + 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( + CancellationToken cancellationToken) + where TFeature : IFeature + { + ContainerType scope = null; + IResourceGroup resources = null; + FeatureContext context = null; + + try + { + scope = _root.CreateScope(); + var resourceService = _root.Resolve(); + resources = resourceService.CreateGroup(); + context = new FeatureContext( + scope, + resources, + new CompositeDisposable(), + cancellationToken); + var feature = scope.Resolve(); + 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); + } + } + + BestEffortDisposeContext(context); + } + + private void BestEffortDisposeContext(FeatureContext context) + { + if (context == null) + { + return; + } + + try + { + context.Disposables.Clear(); + } + catch (Exception exception) + { + Debug.LogError(exception); + } + + try + { + context.Scope?.Dispose(); + } + catch (Exception exception) + { + Debug.LogError(exception); + } + + try + { + context.Resources?.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}."); + } + } +} diff --git a/Packages/com.flowscope.gamecore/Runtime/Flow/GameFlow.cs.meta b/Packages/com.flowscope.gamecore/Runtime/Flow/GameFlow.cs.meta new file mode 100644 index 0000000..56ab886 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Flow/GameFlow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a533908611b643259dbfcb681369d012 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/Flow/GameFlowState.cs b/Packages/com.flowscope.gamecore/Runtime/Flow/GameFlowState.cs new file mode 100644 index 0000000..e9f3c81 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Flow/GameFlowState.cs @@ -0,0 +1,13 @@ +namespace FlowScope.Flow +{ + public enum GameFlowState + { + Idle, + Starting, + Running, + Switching, + NoActiveFeature, + ShuttingDown, + Disposed + } +} diff --git a/Packages/com.flowscope.gamecore/Runtime/Flow/GameFlowState.cs.meta b/Packages/com.flowscope.gamecore/Runtime/Flow/GameFlowState.cs.meta new file mode 100644 index 0000000..0eac168 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Flow/GameFlowState.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1f73ffad83dc461db1dde0b4bece5eda +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/Flow/IFeature.cs b/Packages/com.flowscope.gamecore/Runtime/Flow/IFeature.cs new file mode 100644 index 0000000..3cb3e97 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Flow/IFeature.cs @@ -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); + } +} diff --git a/Packages/com.flowscope.gamecore/Runtime/Flow/IFeature.cs.meta b/Packages/com.flowscope.gamecore/Runtime/Flow/IFeature.cs.meta new file mode 100644 index 0000000..6d06d58 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Flow/IFeature.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 749ce778d6814cf9a9fe89889025bccb +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/FlowScope.Runtime.asmdef b/Packages/com.flowscope.gamecore/Runtime/FlowScope.Runtime.asmdef new file mode 100644 index 0000000..a4fae8f --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/FlowScope.Runtime.asmdef @@ -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 +} diff --git a/Packages/com.flowscope.gamecore/Runtime/FlowScope.Runtime.asmdef.meta b/Packages/com.flowscope.gamecore/Runtime/FlowScope.Runtime.asmdef.meta new file mode 100644 index 0000000..ffe0950 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/FlowScope.Runtime.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 4f077ee742b346a188dc0c93b419f3ee +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/Resources.meta b/Packages/com.flowscope.gamecore/Runtime/Resources.meta new file mode 100644 index 0000000..f4a06ba --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Resources.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 2a7d670c048e4930b0a0980773a6210b +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/Resources/IResourceBackend.cs b/Packages/com.flowscope.gamecore/Runtime/Resources/IResourceBackend.cs new file mode 100644 index 0000000..c66cc17 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Resources/IResourceBackend.cs @@ -0,0 +1,18 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace FlowScope.Resources +{ + public interface IResourceBackend + { + string Name { get; } + + Task LoadAsync( + string key, + CancellationToken cancellationToken) + where T : class; + + void Release(string key, T asset) + where T : class; + } +} diff --git a/Packages/com.flowscope.gamecore/Runtime/Resources/IResourceBackend.cs.meta b/Packages/com.flowscope.gamecore/Runtime/Resources/IResourceBackend.cs.meta new file mode 100644 index 0000000..d89acd9 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Resources/IResourceBackend.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 76d9b18ff17949a987f26418e1f1c978 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/Resources/IResourceGroup.cs b/Packages/com.flowscope.gamecore/Runtime/Resources/IResourceGroup.cs new file mode 100644 index 0000000..14de14e --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Resources/IResourceGroup.cs @@ -0,0 +1,10 @@ +using System; + +namespace FlowScope.Resources +{ + public interface IResourceGroup : IDisposable + { + void Add(IResourceHandle handle) where T : class; + int Count { get; } + } +} diff --git a/Packages/com.flowscope.gamecore/Runtime/Resources/IResourceGroup.cs.meta b/Packages/com.flowscope.gamecore/Runtime/Resources/IResourceGroup.cs.meta new file mode 100644 index 0000000..b4e64cf --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Resources/IResourceGroup.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0e613834058f49db95c2814ab52d7cc4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/Resources/IResourceHandle.cs b/Packages/com.flowscope.gamecore/Runtime/Resources/IResourceHandle.cs new file mode 100644 index 0000000..f293100 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Resources/IResourceHandle.cs @@ -0,0 +1,11 @@ +using System; + +namespace FlowScope.Resources +{ + public interface IResourceHandle : IDisposable where T : class + { + string Key { get; } + T Asset { get; } + bool IsDisposed { get; } + } +} diff --git a/Packages/com.flowscope.gamecore/Runtime/Resources/IResourceHandle.cs.meta b/Packages/com.flowscope.gamecore/Runtime/Resources/IResourceHandle.cs.meta new file mode 100644 index 0000000..7577ddb --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Resources/IResourceHandle.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f248abc2018449e3be0905dc01ef7e11 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/Resources/IResourceService.cs b/Packages/com.flowscope.gamecore/Runtime/Resources/IResourceService.cs new file mode 100644 index 0000000..93f5478 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Resources/IResourceService.cs @@ -0,0 +1,21 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace FlowScope.Resources +{ + public interface IResourceService + { + Task> LoadAsync( + string key, + CancellationToken cancellationToken) + where T : class; + + IResourceGroup CreateGroup(); + } + + public interface ICompletedResourceService + { + bool TryLoadCompleted(string key, out IResourceHandle handle) + where T : class; + } +} diff --git a/Packages/com.flowscope.gamecore/Runtime/Resources/IResourceService.cs.meta b/Packages/com.flowscope.gamecore/Runtime/Resources/IResourceService.cs.meta new file mode 100644 index 0000000..9828ddb --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Resources/IResourceService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 52a701ee3b744171b5e01e927c83b645 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/Resources/ResourceEntry.cs b/Packages/com.flowscope.gamecore/Runtime/Resources/ResourceEntry.cs new file mode 100644 index 0000000..d3f0d77 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Resources/ResourceEntry.cs @@ -0,0 +1,74 @@ +using System; + +namespace FlowScope.Resources +{ + internal sealed class ResourceEntry + { + private readonly Action _release; + private int _referenceCount; + private bool _released; + + public ResourceEntry(string key, object asset, Action release) + : this(key, asset?.GetType(), asset, (_, _, releasedAsset) => release(key, releasedAsset)) + { + } + + public ResourceEntry(string key, Type assetType, object asset, Action 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 CreateHandle() where T : class + { + if (_released) + { + throw new ObjectDisposedException(nameof(ResourceEntry)); + } + + _referenceCount++; + return new ResourceHandle(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); + } + } +} diff --git a/Packages/com.flowscope.gamecore/Runtime/Resources/ResourceEntry.cs.meta b/Packages/com.flowscope.gamecore/Runtime/Resources/ResourceEntry.cs.meta new file mode 100644 index 0000000..f535b2d --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Resources/ResourceEntry.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 02477283d77540ed9be0b002bb05f37a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/Resources/ResourceGroup.cs b/Packages/com.flowscope.gamecore/Runtime/Resources/ResourceGroup.cs new file mode 100644 index 0000000..bdfcac4 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Resources/ResourceGroup.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; + +namespace FlowScope.Resources +{ + public sealed class ResourceGroup : IResourceGroup + { + private readonly HashSet _handles = new(); + private bool _disposed; + + public int Count => _handles.Count; + + public void Add(IResourceHandle 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(); + } + } +} diff --git a/Packages/com.flowscope.gamecore/Runtime/Resources/ResourceGroup.cs.meta b/Packages/com.flowscope.gamecore/Runtime/Resources/ResourceGroup.cs.meta new file mode 100644 index 0000000..88d4a88 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Resources/ResourceGroup.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 896ce428279a4740b44cebeb2c0b635e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/Resources/ResourceHandle.cs b/Packages/com.flowscope.gamecore/Runtime/Resources/ResourceHandle.cs new file mode 100644 index 0000000..b571d57 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Resources/ResourceHandle.cs @@ -0,0 +1,51 @@ +using System; + +namespace FlowScope.Resources +{ + public sealed class ResourceHandle : IResourceHandle where T : class + { + private readonly ResourceEntry _entry; + private readonly Action _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 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); + } + } +} diff --git a/Packages/com.flowscope.gamecore/Runtime/Resources/ResourceHandle.cs.meta b/Packages/com.flowscope.gamecore/Runtime/Resources/ResourceHandle.cs.meta new file mode 100644 index 0000000..fd1cc36 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Resources/ResourceHandle.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ab4e98e842c34a09a324062b57b09009 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/Resources/ResourceService.cs b/Packages/com.flowscope.gamecore/Runtime/Resources/ResourceService.cs new file mode 100644 index 0000000..39c5faf --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Resources/ResourceService.cs @@ -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 _entries = new(); + private readonly Dictionary _loads = new(); + + public ResourceService(IResourceBackend backend) + { + _backend = backend ?? throw new ArgumentNullException(nameof(backend)); + } + + public async Task> LoadAsync( + 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(); + } + + if (!_loads.TryGetValue(resourceKey, out sharedLoad)) + { + sharedLoad = new SharedLoad(); + _loads.Add(resourceKey, sharedLoad); + } + + sharedLoad.WaiterCount++; + if (sharedLoad.LoadTask == null) + { + sharedLoad.LoadTask = LoadAndOwnEntryAsync(resourceKey, key, sharedLoad); + } + } + + try + { + var loaded = await WaitForSharedLoad(sharedLoad.LoadTask, cancellationToken); + lock (_gate) + { + return loaded.CreateHandle(); + } + } + catch (OperationCanceledException) + { + lock (_gate) + { + if (sharedLoad.WaiterCount > 0) + { + sharedLoad.WaiterCount--; + } + } + + throw; + } + } + + public bool TryLoadCompleted(string key, out IResourceHandle 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(); + return true; + } + } + + handle = null; + return false; + } + + public IResourceGroup CreateGroup() + { + return new ResourceGroup(); + } + + private async Task LoadEntryAsync(string key) + where T : class + { + T asset; + try + { + asset = await _backend.LoadAsync(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 LoadAndOwnEntryAsync( + ResourceKey resourceKey, + string key, + SharedLoad sharedLoad) + where T : class + { + ResourceEntry entry = null; + try + { + entry = await LoadEntryAsync(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 WaitForSharedLoad( + Task loadTask, + CancellationToken cancellationToken) + { + if (!cancellationToken.CanBeCanceled) + { + return await loadTask; + } + + var cancellation = new TaskCompletionSource(); + 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(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 LoadTask; + public int WaiterCount; + } + + private readonly struct ResourceKey : IEquatable + { + 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); + } + } + } + } +} diff --git a/Packages/com.flowscope.gamecore/Runtime/Resources/ResourceService.cs.meta b/Packages/com.flowscope.gamecore/Runtime/Resources/ResourceService.cs.meta new file mode 100644 index 0000000..622cec2 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Resources/ResourceService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d123a5c6220843b18a09611f77e8cd31 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/Save.meta b/Packages/com.flowscope.gamecore/Runtime/Save.meta new file mode 100644 index 0000000..fb1bcc9 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Save.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: aade8defc2324dde8776534f5d8acccc +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/Save/FileSaveStorage.cs b/Packages/com.flowscope.gamecore/Runtime/Save/FileSaveStorage.cs new file mode 100644 index 0000000..92be2e5 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Save/FileSaveStorage.cs @@ -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 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 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"); + } + } +} diff --git a/Packages/com.flowscope.gamecore/Runtime/Save/FileSaveStorage.cs.meta b/Packages/com.flowscope.gamecore/Runtime/Save/FileSaveStorage.cs.meta new file mode 100644 index 0000000..059eef1 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Save/FileSaveStorage.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 59beaa2c506548ea9f526f3784f5f938 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/Save/ISaveMigration.cs b/Packages/com.flowscope.gamecore/Runtime/Save/ISaveMigration.cs new file mode 100644 index 0000000..3396055 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Save/ISaveMigration.cs @@ -0,0 +1,11 @@ +namespace FlowScope.Save +{ + public interface ISaveMigration + { + string Key { get; } + int FromVersion { get; } + int ToVersion { get; } + + string Migrate(string json); + } +} diff --git a/Packages/com.flowscope.gamecore/Runtime/Save/ISaveMigration.cs.meta b/Packages/com.flowscope.gamecore/Runtime/Save/ISaveMigration.cs.meta new file mode 100644 index 0000000..bc11b72 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Save/ISaveMigration.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 689eab5eef8647009da3f56423bd0a3d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/Save/ISaveService.cs b/Packages/com.flowscope.gamecore/Runtime/Save/ISaveService.cs new file mode 100644 index 0000000..f4f0256 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Save/ISaveService.cs @@ -0,0 +1,13 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace FlowScope.Save +{ + public interface ISaveService + { + Task SaveAsync(string key, T data, CancellationToken cancellationToken); + Task LoadAsync(string key, T defaultValue, CancellationToken cancellationToken); + void Delete(string key); + bool Exists(string key); + } +} diff --git a/Packages/com.flowscope.gamecore/Runtime/Save/ISaveService.cs.meta b/Packages/com.flowscope.gamecore/Runtime/Save/ISaveService.cs.meta new file mode 100644 index 0000000..369bd54 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Save/ISaveService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 02c1b05d79f4432dbb64923943158c57 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/Save/JsonSaveSerializer.cs b/Packages/com.flowscope.gamecore/Runtime/Save/JsonSaveSerializer.cs new file mode 100644 index 0000000..ff99832 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Save/JsonSaveSerializer.cs @@ -0,0 +1,119 @@ +using System; +using System.Text; +using FlowScope.Data; + +namespace FlowScope.Save +{ + public interface ISaveSerializer + { + byte[] Serialize(T data); + T Deserialize(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 data) + { + return Encoding.UTF8.GetBytes(SerializeToString(data)); + } + + public T Deserialize(byte[] bytes) + { + if (bytes == null) + { + throw new ArgumentNullException(nameof(bytes)); + } + + return DeserializeFromString(Encoding.UTF8.GetString(bytes)); + } + + public string SerializeToString(T data) + { + var payload = ReactivePropertyJsonConverter.SerializeObject(data); + if (_currentVersion <= 1) + { + return payload; + } + + return ReactivePropertyJsonConverter.SerializeObject(new SaveEnvelope + { + Version = _currentVersion, + Payload = payload + }); + } + + public T DeserializeFromString(string json) + { + var instance = (T)Activator.CreateInstance(typeof(T), true); + PopulateFromString(ResolvePayload(json), instance); + return instance; + } + + public void PopulateFromString(string json, T target) + { + ReactivePropertyJsonConverter.PopulateObject(ResolvePayload(json), target); + } + + private string ResolvePayload(string json) + { + if (!TryReadEnvelope(json, out var envelope)) + { + return _currentVersion > 1 + ? _migrations.Migrate(GetMigrationKey(), 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(), + envelope.Version, + _currentVersion, + envelope.Payload); + } + + private static bool TryReadEnvelope(string json, out SaveEnvelope envelope) + { + envelope = null; + try + { + envelope = ReactivePropertyJsonConverter.DeserializeObject(json); + return envelope.Version > 0 && envelope.Payload != null; + } + catch + { + return false; + } + } + + private static string GetMigrationKey() + { + return typeof(T).FullName; + } + } +} diff --git a/Packages/com.flowscope.gamecore/Runtime/Save/JsonSaveSerializer.cs.meta b/Packages/com.flowscope.gamecore/Runtime/Save/JsonSaveSerializer.cs.meta new file mode 100644 index 0000000..8edd90b --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Save/JsonSaveSerializer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6823b62f8e7044d0a8f7e85fc33ca10b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/Save/PlayerPrefsSaveStorage.cs b/Packages/com.flowscope.gamecore/Runtime/Save/PlayerPrefsSaveStorage.cs new file mode 100644 index 0000000..2598348 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Save/PlayerPrefsSaveStorage.cs @@ -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 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; + } + } +} diff --git a/Packages/com.flowscope.gamecore/Runtime/Save/PlayerPrefsSaveStorage.cs.meta b/Packages/com.flowscope.gamecore/Runtime/Save/PlayerPrefsSaveStorage.cs.meta new file mode 100644 index 0000000..4a382ad --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Save/PlayerPrefsSaveStorage.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d421a058fe6d4927ad7b3f48b3060529 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/Save/SaveEnvelope.cs b/Packages/com.flowscope.gamecore/Runtime/Save/SaveEnvelope.cs new file mode 100644 index 0000000..f2013f0 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Save/SaveEnvelope.cs @@ -0,0 +1,8 @@ +namespace FlowScope.Save +{ + internal sealed class SaveEnvelope + { + public int Version { get; set; } + public string Payload { get; set; } + } +} diff --git a/Packages/com.flowscope.gamecore/Runtime/Save/SaveEnvelope.cs.meta b/Packages/com.flowscope.gamecore/Runtime/Save/SaveEnvelope.cs.meta new file mode 100644 index 0000000..11acd8a --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Save/SaveEnvelope.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 67ada4b73d47420db150f34ab12a236a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/Save/SaveMigrationRegistry.cs b/Packages/com.flowscope.gamecore/Runtime/Save/SaveMigrationRegistry.cs new file mode 100644 index 0000000..4ee5fa6 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Save/SaveMigrationRegistry.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; + +namespace FlowScope.Save +{ + public sealed class SaveMigrationRegistry + { + private readonly Dictionary _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 + { + 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; + } + } + } + } +} diff --git a/Packages/com.flowscope.gamecore/Runtime/Save/SaveMigrationRegistry.cs.meta b/Packages/com.flowscope.gamecore/Runtime/Save/SaveMigrationRegistry.cs.meta new file mode 100644 index 0000000..e74593f --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Save/SaveMigrationRegistry.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3f2c3dd694c94ed59d08bcdf88d830a7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/Save/SaveService.cs b/Packages/com.flowscope.gamecore/Runtime/Save/SaveService.cs new file mode 100644 index 0000000..7eb65fb --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Save/SaveService.cs @@ -0,0 +1,81 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using UnityEngine; + +namespace FlowScope.Save +{ + public sealed class SaveService : ISaveService + { + private readonly ISaveStorage _storage; + private readonly ISaveSerializer _serializer; + + public SaveService(ISaveStorage storage, ISaveSerializer serializer) + { + _storage = storage ?? throw new ArgumentNullException(nameof(storage)); + _serializer = serializer ?? throw new ArgumentNullException(nameof(serializer)); + } + + public async Task SaveAsync(string key, T data, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + var bytes = _serializer.Serialize(data); + await _storage.WriteAsync(key, bytes, cancellationToken); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + throw new InvalidOperationException($"Failed to write save key '{key}'.", exception); + } + } + + public async Task LoadAsync(string key, T defaultValue, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + byte[] bytes; + try + { + bytes = await _storage.ReadAsync(key, cancellationToken); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + Debug.LogWarning($"Failed to read save key '{key}': {exception.Message}"); + return defaultValue; + } + + if (bytes == null) + { + return defaultValue; + } + + try + { + return _serializer.Deserialize(bytes); + } + catch (Exception exception) + { + Debug.LogWarning($"Failed to deserialize save key '{key}': {exception.Message}"); + return defaultValue; + } + } + + public void Delete(string key) + { + _storage.Delete(key); + } + + public bool Exists(string key) + { + return _storage.Exists(key); + } + } +} diff --git a/Packages/com.flowscope.gamecore/Runtime/Save/SaveService.cs.meta b/Packages/com.flowscope.gamecore/Runtime/Save/SaveService.cs.meta new file mode 100644 index 0000000..7051a49 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/Save/SaveService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c71e93285f6c4f8287c96785fe1975d6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/UI.meta b/Packages/com.flowscope.gamecore/Runtime/UI.meta new file mode 100644 index 0000000..db9d363 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/UI.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3e730e1406e843dd957ffddac73ac8be +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/UI/IUIPreloadService.cs b/Packages/com.flowscope.gamecore/Runtime/UI/IUIPreloadService.cs new file mode 100644 index 0000000..d605ce2 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/UI/IUIPreloadService.cs @@ -0,0 +1,16 @@ +using System.Threading; +using System.Threading.Tasks; +using FlowScope.Resources; +using UnityEngine; + +namespace FlowScope.UI +{ + public interface IUIPreloadService + { + Task PreloadAsync(CancellationToken cancellationToken); + bool IsPreloaded(); + bool TryGetPreloadedHandle(out IResourceHandle handle); + void Release(); + void ReleaseAll(); + } +} diff --git a/Packages/com.flowscope.gamecore/Runtime/UI/IUIPreloadService.cs.meta b/Packages/com.flowscope.gamecore/Runtime/UI/IUIPreloadService.cs.meta new file mode 100644 index 0000000..b37529e --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/UI/IUIPreloadService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 34f5e8174a844438842b8e839ba4bb64 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/UI/IUIScreenNavigator.cs b/Packages/com.flowscope.gamecore/Runtime/UI/IUIScreenNavigator.cs new file mode 100644 index 0000000..61e06cb --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/UI/IUIScreenNavigator.cs @@ -0,0 +1,19 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace FlowScope.UI +{ + public interface IUIScreenNavigator + { + Task PushAsync( + TViewModel viewModel, + CancellationToken cancellationToken) + where TPanel : UIPanelBase; + + bool CanGoBack { get; } + + void GoBack(); + + void Clear(); + } +} diff --git a/Packages/com.flowscope.gamecore/Runtime/UI/IUIScreenNavigator.cs.meta b/Packages/com.flowscope.gamecore/Runtime/UI/IUIScreenNavigator.cs.meta new file mode 100644 index 0000000..41a739b --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/UI/IUIScreenNavigator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d76da54cf887479d8448849a5ddb7dd8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/UI/PanelStrategy.cs b/Packages/com.flowscope.gamecore/Runtime/UI/PanelStrategy.cs new file mode 100644 index 0000000..1141c60 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/UI/PanelStrategy.cs @@ -0,0 +1,8 @@ +namespace FlowScope.UI +{ + public enum PanelStrategy + { + Destroy, + Cache + } +} diff --git a/Packages/com.flowscope.gamecore/Runtime/UI/PanelStrategy.cs.meta b/Packages/com.flowscope.gamecore/Runtime/UI/PanelStrategy.cs.meta new file mode 100644 index 0000000..aa0cadc --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/UI/PanelStrategy.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: be444a62bb9241da99d5a4fa2ff8be69 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/UI/UILayer.cs b/Packages/com.flowscope.gamecore/Runtime/UI/UILayer.cs new file mode 100644 index 0000000..ea2e6af --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/UI/UILayer.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace FlowScope.UI +{ + internal sealed class UILayer + { + private readonly Stack _stack = new(); + private readonly Dictionary _cachedPanels = new(); + + public UILayer(string name, Canvas canvas, int sortOrder, PanelStrategy strategy) + { + Name = name; + Canvas = canvas; + Strategy = strategy; + Canvas.sortingOrder = sortOrder; + } + + public string Name { get; } + public Canvas Canvas { get; } + public PanelStrategy Strategy { get; } + public int Count => _stack.Count; + + public void Push(UIPanelRecord record) + { + record.IsOpen = true; + _stack.Push(record); + } + + public UIPanelRecord Peek() + { + return _stack.Peek(); + } + + public UIPanelRecord Pop() + { + var record = _stack.Pop(); + record.IsOpen = false; + return record; + } + + public bool TryGetCached(Type panelType, out UIPanelRecord record) + { + return _cachedPanels.TryGetValue(panelType, out record); + } + + public void Cache(UIPanelRecord record) + { + _cachedPanels[record.PanelType] = record; + } + + public void RemoveCached(Type panelType) + { + _cachedPanels.Remove(panelType); + } + + public bool ContainsOpen(Type panelType, out UIPanelRecord record) + { + foreach (var current in _stack) + { + if (current.PanelType == panelType) + { + record = current; + return true; + } + } + + record = null; + return false; + } + } +} diff --git a/Packages/com.flowscope.gamecore/Runtime/UI/UILayer.cs.meta b/Packages/com.flowscope.gamecore/Runtime/UI/UILayer.cs.meta new file mode 100644 index 0000000..3780179 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/UI/UILayer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8bf04ebbbc2f4785b5daf63b334d1a6a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/UI/UIManager.cs b/Packages/com.flowscope.gamecore/Runtime/UI/UIManager.cs new file mode 100644 index 0000000..db71f29 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/UI/UIManager.cs @@ -0,0 +1,266 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FlowScope.Resources; +using UnityEngine; + +namespace FlowScope.UI +{ + public sealed class UIManager + { + private readonly IResourceService _resources; + private readonly IUIPreloadService _preloadService; + private readonly Dictionary _layers = new(); + + public UIManager(IResourceService resources, IUIPreloadService preloadService = null) + { + _resources = resources ?? throw new ArgumentNullException(nameof(resources)); + _preloadService = preloadService; + } + + public void RegisterLayer( + string name, + Canvas canvas, + int sortOrder, + PanelStrategy strategy = PanelStrategy.Destroy) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("Layer name is required.", nameof(name)); + } + + if (canvas == null) + { + throw new ArgumentNullException(nameof(canvas)); + } + + _layers[name] = new UILayer(name, canvas, sortOrder, strategy); + } + + public async Task OpenAsync( + TViewModel viewModel, + CancellationToken cancellationToken) + where TPanel : UIPanelBase + { + cancellationToken.ThrowIfCancellationRequested(); + + var attribute = GetPanelAttribute(typeof(TPanel)); + if (!_layers.TryGetValue(attribute.Layer, out var layer)) + { + throw new InvalidOperationException($"UI layer is not registered: {attribute.Layer}"); + } + + var strategy = attribute.OverrideStrategy ?? layer.Strategy; + if (strategy == PanelStrategy.Cache && + layer.TryGetCached(typeof(TPanel), out var cached) && + !cached.IsOpen) + { + return ReopenCached(cached, layer, viewModel); + } + + var path = ResolvePath(typeof(TPanel), attribute); + var handle = await LoadPanelHandleAsync(path, cancellationToken); + if (handle == null || handle.Asset == null) + { + handle?.Dispose(); + throw new InvalidOperationException($"UI panel resource is missing: {path}"); + } + + GameObject instance = null; + var ownsHandle = !IsPreloadedHandle(handle); + try + { + instance = UnityEngine.Object.Instantiate(handle.Asset, layer.Canvas.transform, false); + var panel = instance.GetComponent(); + if (panel == null) + { + throw new InvalidOperationException( + $"UI panel prefab '{path}' does not contain component {typeof(TPanel).Name}."); + } + + panel.Bind(viewModel); + instance.SetActive(true); + + var recordHandle = ownsHandle ? (IDisposable)handle : RetainedPanelHandle.Instance; + var record = new UIPanelRecord(typeof(TPanel), layer.Name, panel, recordHandle, strategy); + if (strategy == PanelStrategy.Cache) + { + layer.Cache(record); + } + + layer.Push(record); + return panel; + } + catch + { + if (instance != null) + { + UnityEngine.Object.Destroy(instance); + } + + if (ownsHandle) + { + handle.Dispose(); + } + + throw; + } + } + + public void Close() + { + var panelType = typeof(TPanel); + foreach (var layer in _layers.Values) + { + if (!layer.ContainsOpen(panelType, out var record)) + { + continue; + } + + if (!ReferenceEquals(layer.Peek(), record)) + { + throw new InvalidOperationException( + $"Cannot close non-top UI panel: {panelType.Name}"); + } + + CloseTop(layer); + return; + } + } + + public void CloseLayer(string layerName) + { + if (!_layers.TryGetValue(layerName, out var layer)) + { + throw new InvalidOperationException($"UI layer is not registered: {layerName}"); + } + + while (layer.Count > 0) + { + CloseTop(layer); + } + } + + public void CloseAll() + { + foreach (var layer in _layers.Values) + { + while (layer.Count > 0) + { + CloseTop(layer); + } + } + } + + public bool IsOpen() + { + var panelType = typeof(TPanel); + foreach (var layer in _layers.Values) + { + if (layer.ContainsOpen(panelType, out _)) + { + return true; + } + } + + return false; + } + + private static TPanel ReopenCached( + UIPanelRecord record, + UILayer layer, + TViewModel viewModel) + where TPanel : UIPanelBase + { + var panel = (TPanel)record.Panel; + panel.Bind(viewModel); + panel.gameObject.SetActive(true); + layer.Push(record); + return panel; + } + + private static void CloseTop(UILayer layer) + { + var record = layer.Pop(); + record.Panel.Unbind(); + + if (record.Strategy == PanelStrategy.Cache) + { + record.Panel.gameObject.SetActive(false); + return; + } + + layer.RemoveCached(record.PanelType); + UnityEngine.Object.Destroy(record.Panel.gameObject); + record.Handle.Dispose(); + } + + private async Task> LoadPanelHandleAsync( + string path, + CancellationToken cancellationToken) + { + if (_preloadService != null && + _preloadService.TryGetPreloadedHandle(out var preloadedHandle)) + { + return preloadedHandle; + } + + return await _resources.LoadAsync(path, cancellationToken); + } + + private bool IsPreloadedHandle(IResourceHandle handle) + { + return _preloadService != null && + _preloadService.TryGetPreloadedHandle(out var preloadedHandle) && + ReferenceEquals(preloadedHandle, handle); + } + + internal static UIPanelAttribute GetPanelAttribute(Type panelType) + { + var attribute = (UIPanelAttribute)Attribute.GetCustomAttribute( + panelType, + typeof(UIPanelAttribute)); + + if (attribute == null) + { + throw new InvalidOperationException( + $"UI panel must declare UIPanelAttribute: {panelType.Name}"); + } + + if (string.IsNullOrWhiteSpace(attribute.Layer)) + { + throw new InvalidOperationException( + $"UI panel layer is required: {panelType.Name}"); + } + + return attribute; + } + + internal static string ResolvePath(Type panelType, UIPanelAttribute attribute) + { + if (!string.IsNullOrWhiteSpace(attribute.Path)) + { + return attribute.Path; + } + + var name = panelType.Name; + const string suffix = "Panel"; + if (name.EndsWith(suffix, StringComparison.Ordinal)) + { + name = name.Substring(0, name.Length - suffix.Length); + } + + return $"UI/{name}/Prefab"; + } + + private sealed class RetainedPanelHandle : IDisposable + { + public static readonly RetainedPanelHandle Instance = new(); + + public void Dispose() + { + } + } + } +} diff --git a/Packages/com.flowscope.gamecore/Runtime/UI/UIManager.cs.meta b/Packages/com.flowscope.gamecore/Runtime/UI/UIManager.cs.meta new file mode 100644 index 0000000..1ae0cc5 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/UI/UIManager.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c83637921703494f8fd6eb6e2eda6c70 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/UI/UIPanelAttribute.cs b/Packages/com.flowscope.gamecore/Runtime/UI/UIPanelAttribute.cs new file mode 100644 index 0000000..bee8408 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/UI/UIPanelAttribute.cs @@ -0,0 +1,42 @@ +using System; + +namespace FlowScope.UI +{ + [AttributeUsage(AttributeTargets.Class)] + public sealed class UIPanelAttribute : Attribute + { + public UIPanelAttribute(string layer) + : this(layer, null, null) + { + } + + public UIPanelAttribute(string layer, string path) + : this(layer, path, null) + { + } + + public UIPanelAttribute(string layer, PanelStrategy overrideStrategy) + : this(layer, null, overrideStrategy) + { + } + + public UIPanelAttribute(string layer, string path, PanelStrategy overrideStrategy) + : this(layer, path, (PanelStrategy?)overrideStrategy) + { + } + + private UIPanelAttribute( + string layer, + string path, + PanelStrategy? overrideStrategy) + { + Layer = layer; + Path = path; + OverrideStrategy = overrideStrategy; + } + + public string Layer { get; } + public string Path { get; } + public PanelStrategy? OverrideStrategy { get; } + } +} diff --git a/Packages/com.flowscope.gamecore/Runtime/UI/UIPanelAttribute.cs.meta b/Packages/com.flowscope.gamecore/Runtime/UI/UIPanelAttribute.cs.meta new file mode 100644 index 0000000..38986a9 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/UI/UIPanelAttribute.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b6b9266c245947bebd6cdf8c445cf712 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/UI/UIPanelBase.cs b/Packages/com.flowscope.gamecore/Runtime/UI/UIPanelBase.cs new file mode 100644 index 0000000..e4cec66 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/UI/UIPanelBase.cs @@ -0,0 +1,31 @@ +using R3; +using UnityEngine; + +namespace FlowScope.UI +{ + internal interface IUIPanel + { + GameObject gameObject { get; } + Transform transform { get; } + void Unbind(); + } + + public abstract class UIPanelBase : MonoBehaviour, IUIPanel + { + protected TViewModel ViewModel { get; private set; } + protected CompositeDisposable Disposables { get; } = new(); + + public void Bind(TViewModel viewModel) + { + ViewModel = viewModel; + OnBind(viewModel); + } + + public virtual void Unbind() + { + Disposables.Clear(); + } + + protected abstract void OnBind(TViewModel viewModel); + } +} diff --git a/Packages/com.flowscope.gamecore/Runtime/UI/UIPanelBase.cs.meta b/Packages/com.flowscope.gamecore/Runtime/UI/UIPanelBase.cs.meta new file mode 100644 index 0000000..b41d0fc --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/UI/UIPanelBase.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2c766b1543a347c2bec5d5f1b34b99ae +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/UI/UIPanelRecord.cs b/Packages/com.flowscope.gamecore/Runtime/UI/UIPanelRecord.cs new file mode 100644 index 0000000..ddc9dcf --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/UI/UIPanelRecord.cs @@ -0,0 +1,28 @@ +using System; + +namespace FlowScope.UI +{ + internal sealed class UIPanelRecord + { + public UIPanelRecord( + Type panelType, + string layerName, + IUIPanel panel, + IDisposable handle, + PanelStrategy strategy) + { + PanelType = panelType; + LayerName = layerName; + Panel = panel; + Handle = handle; + Strategy = strategy; + } + + public Type PanelType { get; } + public string LayerName { get; } + public IUIPanel Panel { get; } + public IDisposable Handle { get; } + public PanelStrategy Strategy { get; } + public bool IsOpen { get; set; } + } +} diff --git a/Packages/com.flowscope.gamecore/Runtime/UI/UIPanelRecord.cs.meta b/Packages/com.flowscope.gamecore/Runtime/UI/UIPanelRecord.cs.meta new file mode 100644 index 0000000..45adfff --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/UI/UIPanelRecord.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6a5aee9195e642a1a6f72d4ce5390885 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/UI/UIPreloadService.cs b/Packages/com.flowscope.gamecore/Runtime/UI/UIPreloadService.cs new file mode 100644 index 0000000..4a59c70 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/UI/UIPreloadService.cs @@ -0,0 +1,274 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FlowScope.Resources; +using UnityEngine; + +namespace FlowScope.UI +{ + public sealed class UIPreloadService : IUIPreloadService, IDisposable + { + private readonly object _gate = new(); + private readonly IResourceService _resources; + private readonly Dictionary> _handles = new(); + private readonly Dictionary _inFlightLoads = new(); + private int _releaseGeneration; + private bool _disposed; + + public UIPreloadService(IResourceService resources) + { + _resources = resources ?? throw new ArgumentNullException(nameof(resources)); + } + + public async Task PreloadAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var panelType = typeof(TPanel); + InFlightPreload inFlightLoad; + bool startLoad = false; + string path = null; + lock (_gate) + { + ThrowIfDisposed(); + + if (TryGetPreloadedHandle(panelType, out _)) + { + return; + } + + if (!_inFlightLoads.TryGetValue(panelType, out inFlightLoad) || + inFlightLoad.Invalidated || + inFlightLoad.ReleaseGeneration != _releaseGeneration) + { + var attribute = UIManager.GetPanelAttribute(panelType); + path = UIManager.ResolvePath(panelType, attribute); + inFlightLoad = new InFlightPreload(); + inFlightLoad.ReleaseGeneration = _releaseGeneration; + _inFlightLoads[panelType] = inFlightLoad; + startLoad = true; + } + + inFlightLoad.WaiterCount++; + if (startLoad) + { + inFlightLoad.Task = LoadAndStoreAsync(panelType, path, inFlightLoad); + } + } + + try + { + await WaitForSharedLoadAsync(inFlightLoad.Task, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + ForgetWaitingCaller(panelType, inFlightLoad); + throw; + } + } + + public bool IsPreloaded() + { + lock (_gate) + { + if (_disposed) + { + return false; + } + + return TryGetPreloadedHandle(typeof(TPanel), out _); + } + } + + public bool TryGetPreloadedHandle(out IResourceHandle handle) + { + lock (_gate) + { + if (_disposed) + { + handle = null; + return false; + } + + return TryGetPreloadedHandle(typeof(TPanel), out handle); + } + } + + public void Release() + { + var panelType = typeof(TPanel); + IResourceHandle handle = null; + lock (_gate) + { + if (_disposed) + { + return; + } + + InvalidateInFlightLoad(panelType); + if (_handles.TryGetValue(panelType, out handle)) + { + _handles.Remove(panelType); + } + } + + handle?.Dispose(); + } + + public void ReleaseAll() + { + List> handles; + lock (_gate) + { + _releaseGeneration++; + handles = new List>(_handles.Values); + _handles.Clear(); + } + + foreach (var handle in handles) + { + handle.Dispose(); + } + } + + public void Dispose() + { + lock (_gate) + { + if (_disposed) + { + return; + } + + _disposed = true; + } + + ReleaseAll(); + } + + private async Task LoadAndStoreAsync( + Type panelType, + string path, + InFlightPreload inFlightLoad) + { + IResourceHandle handle = null; + try + { + handle = await _resources.LoadAsync(path, CancellationToken.None); + if (handle == null || handle.Asset == null) + { + handle?.Dispose(); + throw new InvalidOperationException($"UI panel resource is missing: {path}"); + } + + var shouldCache = false; + lock (_gate) + { + ForgetInFlightLoad(panelType, inFlightLoad); + if (!_disposed && + !inFlightLoad.Invalidated && + inFlightLoad.ReleaseGeneration == _releaseGeneration && + inFlightLoad.WaiterCount > 0) + { + _handles[panelType] = handle; + shouldCache = true; + } + } + + if (!shouldCache) + { + handle.Dispose(); + } + } + catch + { + lock (_gate) + { + ForgetInFlightLoad(panelType, inFlightLoad); + } + + throw; + } + } + + private void ForgetInFlightLoad(Type panelType, InFlightPreload inFlightLoad) + { + if (_inFlightLoads.TryGetValue(panelType, out var current) && + ReferenceEquals(current, inFlightLoad)) + { + _inFlightLoads.Remove(panelType); + } + } + + private void InvalidateInFlightLoad(Type panelType) + { + if (_inFlightLoads.TryGetValue(panelType, out var inFlightLoad)) + { + inFlightLoad.Invalidated = true; + } + } + + private void ForgetWaitingCaller(Type panelType, InFlightPreload inFlightLoad) + { + lock (_gate) + { + if (_inFlightLoads.TryGetValue(panelType, out var current) && + ReferenceEquals(current, inFlightLoad) && + inFlightLoad.WaiterCount > 0) + { + inFlightLoad.WaiterCount--; + } + } + } + + private bool TryGetPreloadedHandle( + Type panelType, + out IResourceHandle handle) + { + return _handles.TryGetValue(panelType, out handle) && + handle != null && + !handle.IsDisposed && + handle.Asset != null; + } + + private void ThrowIfDisposed() + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(UIPreloadService)); + } + } + + private static async Task WaitForSharedLoadAsync( + Task loadTask, + CancellationToken cancellationToken) + { + if (!cancellationToken.CanBeCanceled) + { + await loadTask; + return; + } + + var cancellation = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + using (cancellationToken.Register(() => cancellation.TrySetResult(true))) + { + if (await Task.WhenAny(loadTask, cancellation.Task) == cancellation.Task) + { + throw new OperationCanceledException(cancellationToken); + } + } + + await loadTask; + } + + private sealed class InFlightPreload + { + public Task Task { get; set; } + public int WaiterCount { get; set; } + public int ReleaseGeneration { get; set; } + public bool Invalidated { get; set; } + } + } +} diff --git a/Packages/com.flowscope.gamecore/Runtime/UI/UIPreloadService.cs.meta b/Packages/com.flowscope.gamecore/Runtime/UI/UIPreloadService.cs.meta new file mode 100644 index 0000000..1e636c3 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/UI/UIPreloadService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6b2cbe6065884f82838b7c882ad5085a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/Runtime/UI/UIScreenNavigator.cs b/Packages/com.flowscope.gamecore/Runtime/UI/UIScreenNavigator.cs new file mode 100644 index 0000000..aef83b3 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/UI/UIScreenNavigator.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace FlowScope.UI +{ + public sealed class UIScreenNavigator : IUIScreenNavigator + { + private readonly UIManager _uiManager; + private readonly Stack _closeStack = new(); + + public UIScreenNavigator(UIManager uiManager) + { + _uiManager = uiManager ?? throw new ArgumentNullException(nameof(uiManager)); + } + + public bool CanGoBack => _closeStack.Count > 1; + + public async Task PushAsync( + TViewModel viewModel, + CancellationToken cancellationToken) + where TPanel : UIPanelBase + { + var panel = await _uiManager.OpenAsync(viewModel, cancellationToken); + _closeStack.Push(() => _uiManager.Close()); + return panel; + } + + public void GoBack() + { + if (!CanGoBack) + { + return; + } + + var close = _closeStack.Pop(); + close(); + } + + public void Clear() + { + while (_closeStack.Count > 0) + { + var close = _closeStack.Pop(); + close(); + } + } + } +} diff --git a/Packages/com.flowscope.gamecore/Runtime/UI/UIScreenNavigator.cs.meta b/Packages/com.flowscope.gamecore/Runtime/UI/UIScreenNavigator.cs.meta new file mode 100644 index 0000000..296aee5 --- /dev/null +++ b/Packages/com.flowscope.gamecore/Runtime/UI/UIScreenNavigator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f991669426b4444098f1973daeb8fb58 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/com.flowscope.gamecore/package.json b/Packages/com.flowscope.gamecore/package.json new file mode 100644 index 0000000..718470d --- /dev/null +++ b/Packages/com.flowscope.gamecore/package.json @@ -0,0 +1,21 @@ +{ + "name": "com.flowscope.gamecore", + "displayName": "FlowScope Game Core", + "version": "0.2.0-preview.1", + "unity": "2022.3", + "description": "A lightweight Unity game flow, resource, config, save, audio, and UI runtime foundation.", + "keywords": [ + "gameflow", + "runtime", + "resources", + "ui", + "addressables" + ], + "author": { + "name": "FlowScope" + }, + "dependencies": { + "com.unity.addressables": "1.21.21", + "com.unity.ugui": "1.0.0" + } +}