cz.xprees.core 1.0.34 → 1.0.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Editor/DescriptionTextAreaDrawer.cs +82 -0
- package/Editor/DescriptionTextAreaDrawer.cs.meta +2 -0
- package/Editor/StatePlayModeLifecycleHook.cs +78 -0
- package/Editor/StatePlayModeLifecycleHook.cs.meta +3 -0
- package/Editor/cz.xprees.core.editor.asmdef +18 -0
- package/Editor/cz.xprees.core.editor.asmdef.meta +3 -0
- package/Editor.meta +3 -0
- package/README.md +80 -7
- package/Runtime/DescriptionAttribute/DescriptionTextAreaAttribute.cs +18 -0
- package/Runtime/DescriptionAttribute/DescriptionTextAreaAttribute.cs.meta +2 -0
- package/Runtime/DescriptionAttribute.meta +3 -0
- package/Runtime/DescriptionBaseSO.cs +15 -1
- package/Runtime/IRuntimeStateOwner.cs +11 -0
- package/Runtime/IRuntimeStateOwner.cs.meta +3 -0
- package/Runtime/PlayModeStateTracker.cs +33 -0
- package/Runtime/PlayModeStateTracker.cs.meta +3 -0
- package/Runtime/ResetOnPlayModeAttribute.cs +33 -0
- package/Runtime/ResetOnPlayModeAttribute.cs.meta +3 -0
- package/Runtime/StateLifetime.cs +55 -0
- package/Runtime/StateLifetime.cs.meta +3 -0
- package/Runtime/StateLifetimeExtensions.cs +91 -0
- package/Runtime/StateLifetimeExtensions.cs.meta +3 -0
- package/Runtime/StateSnapshotService.cs +270 -0
- package/Runtime/StateSnapshotService.cs.meta +3 -0
- package/Tests/Editor/StateSnapshotServiceTests.cs +195 -0
- package/Tests/Editor/StateSnapshotServiceTests.cs.meta +3 -0
- package/Tests/Editor/cz.xprees.core.Tests.Editor.asmdef +24 -0
- package/Tests/Editor/cz.xprees.core.Tests.Editor.asmdef.meta +3 -0
- package/Tests/Editor.meta +3 -0
- package/Tests.meta +3 -0
- package/package.json +1 -1
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
using UnityEditor;
|
|
2
|
+
using UnityEngine;
|
|
3
|
+
using Xprees.Core.DescriptionAttribute;
|
|
4
|
+
|
|
5
|
+
namespace Xprees.Core.Editor
|
|
6
|
+
{
|
|
7
|
+
/// Draws a string field as a foldout: a single-line preview when collapsed,
|
|
8
|
+
/// and a full-text area when expanded.
|
|
9
|
+
[CustomPropertyDrawer(typeof(DescriptionTextAreaAttribute))]
|
|
10
|
+
public class DescriptionTextAreaDrawer : PropertyDrawer
|
|
11
|
+
{
|
|
12
|
+
// Tweak this to change the font size of both the expanded text area and the collapsed preview.
|
|
13
|
+
private const int textFontSize = 12;
|
|
14
|
+
|
|
15
|
+
// Separator line drawn below the field, separating it from the next one in the Inspector.
|
|
16
|
+
private const float separatorHeight = 1f;
|
|
17
|
+
private const float separatorSpacing = 4f;
|
|
18
|
+
private readonly static Color separatorColor = new(0.5f, 0.5f, 0.5f, 0.5f);
|
|
19
|
+
|
|
20
|
+
private static float LineHeight => EditorGUIUtility.singleLineHeight;
|
|
21
|
+
private static float SeparatorTotalHeight => separatorSpacing + separatorHeight;
|
|
22
|
+
|
|
23
|
+
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
|
|
24
|
+
{
|
|
25
|
+
if (property.propertyType != SerializedPropertyType.String)
|
|
26
|
+
{
|
|
27
|
+
EditorGUI.PropertyField(position, property, label, true);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
var foldoutRect = new Rect(position.x, position.y, position.width, LineHeight);
|
|
32
|
+
property.isExpanded = EditorGUI.Foldout(foldoutRect, property.isExpanded, label, true);
|
|
33
|
+
|
|
34
|
+
var contentRect = new Rect(position.x, foldoutRect.yMax, position.width,
|
|
35
|
+
position.height - LineHeight - SeparatorTotalHeight);
|
|
36
|
+
if (property.isExpanded)
|
|
37
|
+
{
|
|
38
|
+
var textAreaStyle = new GUIStyle(EditorStyles.textArea) { fontSize = textFontSize };
|
|
39
|
+
|
|
40
|
+
EditorGUI.BeginChangeCheck();
|
|
41
|
+
var newValue = EditorGUI.TextArea(contentRect, property.stringValue, textAreaStyle);
|
|
42
|
+
if (EditorGUI.EndChangeCheck())
|
|
43
|
+
{
|
|
44
|
+
property.stringValue = newValue;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
else if (!string.IsNullOrEmpty(property.stringValue))
|
|
48
|
+
{
|
|
49
|
+
var previewStyle = new GUIStyle(EditorStyles.wordWrappedMiniLabel) { fontSize = textFontSize };
|
|
50
|
+
EditorGUI.LabelField(contentRect, GetPreview(property.stringValue), previewStyle);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
var separatorRect = new Rect(position.x, position.yMax - separatorHeight, position.width, separatorHeight);
|
|
54
|
+
EditorGUI.DrawRect(separatorRect, separatorColor);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
|
|
58
|
+
{
|
|
59
|
+
if (property.propertyType != SerializedPropertyType.String)
|
|
60
|
+
{
|
|
61
|
+
return EditorGUI.GetPropertyHeight(property, label, true);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!property.isExpanded)
|
|
65
|
+
{
|
|
66
|
+
var previewLines = string.IsNullOrEmpty(property.stringValue) ? 1 : 2;
|
|
67
|
+
return previewLines * LineHeight + SeparatorTotalHeight;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
var textAreaAttribute = (DescriptionTextAreaAttribute) attribute;
|
|
71
|
+
var lineCount = string.IsNullOrEmpty(property.stringValue) ? 1 : property.stringValue.Split('\n').Length;
|
|
72
|
+
lineCount = Mathf.Clamp(lineCount + 1, textAreaAttribute.expandedMinLines, textAreaAttribute.expandedMaxLines);
|
|
73
|
+
return LineHeight + lineCount * LineHeight + SeparatorTotalHeight;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private static string GetPreview(string value)
|
|
77
|
+
{
|
|
78
|
+
var firstLine = value.Split('\n')[0];
|
|
79
|
+
return firstLine.Length > 100 ? firstLine[..100] + "…" : firstLine;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
using System;
|
|
2
|
+
using System.Collections.Concurrent;
|
|
3
|
+
using System.Reflection;
|
|
4
|
+
using UnityEditor;
|
|
5
|
+
using UnityEngine;
|
|
6
|
+
|
|
7
|
+
namespace Xprees.Core.Editor
|
|
8
|
+
{
|
|
9
|
+
/// Centralized Editor runner that coordinates Play Mode entry and exit transitions for stateful ScriptableObjects.
|
|
10
|
+
/// Resets objects marked with [ResetOnPlayMode] and restores mutated assets upon exiting Play Mode.
|
|
11
|
+
/// Can be migrated to future Unity lifecycle hooks in future versions.
|
|
12
|
+
[InitializeOnLoad]
|
|
13
|
+
public static class StatePlayModeLifecycleHook
|
|
14
|
+
{
|
|
15
|
+
private readonly static ConcurrentDictionary<Type, PlayModeResetTiming> timingCache = new();
|
|
16
|
+
|
|
17
|
+
static StatePlayModeLifecycleHook()
|
|
18
|
+
{
|
|
19
|
+
EditorApplication.playModeStateChanged -= OnPlayModeStateChanged;
|
|
20
|
+
EditorApplication.playModeStateChanged += OnPlayModeStateChanged;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
private static void OnPlayModeStateChanged(PlayModeStateChange change)
|
|
24
|
+
{
|
|
25
|
+
switch (change)
|
|
26
|
+
{
|
|
27
|
+
case PlayModeStateChange.EnteredPlayMode:
|
|
28
|
+
ResetPlayModeObjects(PlayModeResetTiming.EnterPlayMode);
|
|
29
|
+
break;
|
|
30
|
+
|
|
31
|
+
case PlayModeStateChange.ExitingPlayMode:
|
|
32
|
+
// Automatically revert all mutated ScriptableObjects to baseline before returning to Edit Mode
|
|
33
|
+
StateSnapshotService.RestoreAll();
|
|
34
|
+
ResetPlayModeObjects(PlayModeResetTiming.ExitPlayMode);
|
|
35
|
+
break;
|
|
36
|
+
|
|
37
|
+
case PlayModeStateChange.EnteredEditMode:
|
|
38
|
+
StateSnapshotService.ClearAll();
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/// Resets loaded ScriptableObjects decorated with [ResetOnPlayMode] matching the specified timing.
|
|
44
|
+
/// Respects StateLifetime (skips StateLifetime.Persistent).
|
|
45
|
+
public static void ResetPlayModeObjects(PlayModeResetTiming timing)
|
|
46
|
+
{
|
|
47
|
+
var loadedObjects = Resources.FindObjectsOfTypeAll<ScriptableObject>();
|
|
48
|
+
foreach (var so in loadedObjects)
|
|
49
|
+
{
|
|
50
|
+
if (!so || so.IsStateless() || so.GetStateLifetime() == StateLifetime.Persistent) continue;
|
|
51
|
+
|
|
52
|
+
var type = so.GetType();
|
|
53
|
+
var configuredTiming = GetPlayModeTiming(type);
|
|
54
|
+
if ((configuredTiming & timing) == 0) continue;
|
|
55
|
+
|
|
56
|
+
var resetMethod = type.GetMethod("ResetToDefault", BindingFlags.Public | BindingFlags.Instance);
|
|
57
|
+
if (resetMethod != null)
|
|
58
|
+
{
|
|
59
|
+
resetMethod.Invoke(so, null);
|
|
60
|
+
}
|
|
61
|
+
else if (so is IRuntimeStateOwner stateOwner)
|
|
62
|
+
{
|
|
63
|
+
stateOwner.ClearTransientState();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
public static PlayModeResetTiming GetPlayModeTiming(Type type)
|
|
69
|
+
{
|
|
70
|
+
if (timingCache.TryGetValue(type, out var timing)) return timing;
|
|
71
|
+
|
|
72
|
+
var attr = type.GetCustomAttribute<ResetOnPlayModeAttribute>(true);
|
|
73
|
+
timing = attr?.Timing ?? PlayModeResetTiming.None;
|
|
74
|
+
timingCache[type] = timing;
|
|
75
|
+
return timing;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cz.xprees.core.editor",
|
|
3
|
+
"rootNamespace": "Xprees.Core.Editor",
|
|
4
|
+
"references": [
|
|
5
|
+
"GUID:999c2ca78ab34358a1222750376a501c"
|
|
6
|
+
],
|
|
7
|
+
"includePlatforms": [
|
|
8
|
+
"Editor"
|
|
9
|
+
],
|
|
10
|
+
"excludePlatforms": [],
|
|
11
|
+
"allowUnsafeCode": false,
|
|
12
|
+
"overrideReferences": false,
|
|
13
|
+
"precompiledReferences": [],
|
|
14
|
+
"autoReferenced": true,
|
|
15
|
+
"defineConstraints": [],
|
|
16
|
+
"versionDefines": [],
|
|
17
|
+
"noEngineReferences": false
|
|
18
|
+
}
|
package/Editor.meta
ADDED
package/README.md
CHANGED
|
@@ -1,15 +1,12 @@
|
|
|
1
|
-
# Unity Core classes
|
|
1
|
+
# Unity Core classes - cz.xprees.core
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/cz.xprees.core)
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
This is base package containing a few common classes for all of my Unity projects.
|
|
5
|
+
Base package containing core systems, state lifecycle orchestration, and ScriptableObject utilities for Unity projects.
|
|
7
6
|
|
|
8
7
|
## Installation
|
|
9
8
|
|
|
10
|
-
Install the package using npm scoped registry in `Project Settings > Package Manager > Scoped Registries
|
|
11
|
-
|
|
12
|
-
[Unity Docs - Install a UPM package from a Git URL](https://docs.unity3d.com/6000.1/Documentation/Manual/upm-ui-giturl.html)
|
|
9
|
+
Install the package using npm scoped registry in `Project Settings > Package Manager > Scoped Registries`:
|
|
13
10
|
|
|
14
11
|
```json
|
|
15
12
|
{
|
|
@@ -19,7 +16,83 @@ Install the package using npm scoped registry in `Project Settings > Package Man
|
|
|
19
16
|
"cz.xprees"
|
|
20
17
|
]
|
|
21
18
|
}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Then install `cz.xprees.core` via the Unity Package Manager.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## State Management & Lifecycle Architecture
|
|
26
|
+
|
|
27
|
+
The package provides automated, zero-boilerplate state snapshotting and restoration for ScriptableObjects across scenario boundaries and Editor play
|
|
28
|
+
mode transitions.
|
|
29
|
+
|
|
30
|
+
### 1. State Lifetime (`StateLifetime`)
|
|
31
|
+
|
|
32
|
+
Every stateful ScriptableObject inherits from `DescriptionBaseSO` or declares its lifecycle scope via `[StatefulLifetime]`:
|
|
33
|
+
|
|
34
|
+
- **`StateLifetime.Scenario` (Default)**:
|
|
35
|
+
Scoped strictly to the active scenario. Captured before mutations and automatically restored to its baseline on scenario start/restart.
|
|
36
|
+
- **`StateLifetime.Session`**:
|
|
37
|
+
Persists across scenarios throughout a play session. Restored only on game/session boundaries or play mode exit.
|
|
38
|
+
- **`StateLifetime.Persistent`**:
|
|
39
|
+
Excluded from state snapshots. Ideal for immutable databases, user settings, or content saved externally to disk.
|
|
40
|
+
|
|
41
|
+
```csharp
|
|
42
|
+
// Per-instance: Set the "Lifetime" dropdown in the Inspector.
|
|
43
|
+
// Per-class: Override via attribute:
|
|
44
|
+
[StatefulLifetime(StateLifetime.Persistent)]
|
|
45
|
+
public class GlobalPlayerConfigSO : DescriptionBaseSO { }
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### 2. State Snapshot Service (`StateSnapshotService`)
|
|
49
|
+
|
|
50
|
+
A centralized, pure runtime service that handles:
|
|
51
|
+
|
|
52
|
+
- **`EnsureCaptured(ScriptableObject target)`**: Serializes the baseline JSON state prior to the first mutation.
|
|
53
|
+
- **`Restore(ScriptableObject target)`**: Reverts serialized fields in-place from baseline JSON and triggers
|
|
54
|
+
`IRuntimeStateOwner.ClearTransientState()`.
|
|
55
|
+
- **`RestoreAll(IEnumerable<ScriptableObject> targets, StateLifetime maxLifetime)`**: Multi-pass restoration that ensures all targets are
|
|
56
|
+
baseline-captured before restoring, preventing inter-object dependency races.
|
|
57
|
+
- **`[SnapshotIgnore]`**: Decorate fields to preserve their values across snapshot restorations.
|
|
22
58
|
|
|
59
|
+
### 3. Editor PlayMode Lifecycle (`StatePlayModeLifecycleHook`)
|
|
60
|
+
|
|
61
|
+
Located in `cz.xprees.core.editor`, this centralized runner hooks into `EditorApplication.playModeStateChanged`:
|
|
62
|
+
|
|
63
|
+
- **`EnteredPlayMode`**: Resets stateful objects decorated with `[ResetOnPlayMode(PlayModeResetTiming.EnterPlayMode)]` (skips `Persistent` objects).
|
|
64
|
+
- **`ExitingPlayMode`**: Restores all mutated ScriptableObjects in-place back to their baseline via `StateSnapshotService.RestoreAll()` and clears
|
|
65
|
+
editor dirty flags before returning to edit mode.
|
|
66
|
+
- **`EnteredEditMode`**: Purges snapshot dictionaries.
|
|
67
|
+
|
|
68
|
+
### 4. Play Mode Reset Attribute (`ResetOnPlayModeAttribute`)
|
|
69
|
+
|
|
70
|
+
Control Play Mode transition resets declaratively on classes:
|
|
71
|
+
|
|
72
|
+
```csharp
|
|
73
|
+
[Flags]
|
|
74
|
+
public enum PlayModeResetTiming
|
|
75
|
+
{
|
|
76
|
+
None = 0,
|
|
77
|
+
EnterPlayMode = 1 << 0,
|
|
78
|
+
ExitPlayMode = 1 << 1,
|
|
79
|
+
Both = EnterPlayMode | ExitPlayMode
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Example: opt-out of play mode reset
|
|
83
|
+
[ResetOnPlayMode(PlayModeResetTiming.None)]
|
|
84
|
+
public class PersistentSettingsSO : ScriptableObject { }
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### 5. Runtime State Ownership (`IRuntimeStateOwner`)
|
|
88
|
+
|
|
89
|
+
Implement `IRuntimeStateOwner` on objects with non-serialized runtime state (active handles, listeners, temporary collections):
|
|
90
|
+
|
|
91
|
+
```csharp
|
|
92
|
+
public interface IRuntimeStateOwner
|
|
93
|
+
{
|
|
94
|
+
void ClearTransientState();
|
|
95
|
+
}
|
|
23
96
|
```
|
|
24
97
|
|
|
25
|
-
|
|
98
|
+
Invoked automatically after serialized fields are restored by `StateSnapshotService`.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
using UnityEngine;
|
|
2
|
+
|
|
3
|
+
namespace Xprees.Core.DescriptionAttribute
|
|
4
|
+
{
|
|
5
|
+
/// Marks a multiline string field to be drawn as a collapsible foldout in the Inspector,
|
|
6
|
+
/// showing a single-line preview when collapsed and a full text area when expanded.
|
|
7
|
+
public class DescriptionTextAreaAttribute : PropertyAttribute
|
|
8
|
+
{
|
|
9
|
+
public readonly int expandedMinLines;
|
|
10
|
+
public readonly int expandedMaxLines;
|
|
11
|
+
|
|
12
|
+
public DescriptionTextAreaAttribute(int expandedMinLines = 3, int expandedMaxLines = 10)
|
|
13
|
+
{
|
|
14
|
+
this.expandedMinLines = expandedMinLines;
|
|
15
|
+
this.expandedMaxLines = expandedMaxLines;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -1,16 +1,30 @@
|
|
|
1
1
|
using UnityEngine;
|
|
2
|
+
using Xprees.Core.DescriptionAttribute;
|
|
2
3
|
|
|
3
4
|
namespace Xprees.Core
|
|
4
5
|
{
|
|
5
6
|
/// Base class for ScriptableObjects with a public description field visible only in Editor.
|
|
7
|
+
/// Supports lifecycle-scoped state management.
|
|
6
8
|
public class DescriptionBaseSO : ScriptableObject, IResettable
|
|
7
9
|
{
|
|
8
10
|
#if UNITY_EDITOR
|
|
11
|
+
[SnapshotIgnore]
|
|
9
12
|
[Tooltip("Description of the ScriptableObject. Editor only.")]
|
|
10
|
-
[
|
|
13
|
+
[DescriptionTextArea]
|
|
11
14
|
public string description;
|
|
12
15
|
#endif
|
|
13
16
|
|
|
17
|
+
[Tooltip("Defines how long this ScriptableObject's runtime state persists before being automatically restored.")]
|
|
18
|
+
[SerializeField] private StateLifetime lifetime = StateLifetime.Scenario;
|
|
19
|
+
|
|
20
|
+
public StateLifetime ConfiguredLifetime => lifetime;
|
|
21
|
+
|
|
22
|
+
public StateLifetime Lifetime
|
|
23
|
+
{
|
|
24
|
+
get => this.GetStateLifetime();
|
|
25
|
+
set => lifetime = value;
|
|
26
|
+
}
|
|
27
|
+
|
|
14
28
|
public virtual void ResetState()
|
|
15
29
|
{
|
|
16
30
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
namespace Xprees.Core
|
|
2
|
+
{
|
|
3
|
+
/// Implemented by objects that have transient, non-serialized runtime state
|
|
4
|
+
/// (e.g., event subscriptions, CancellationTokenSources, active caches, UI transient variables)
|
|
5
|
+
/// that should be cleared during a scenario reset.
|
|
6
|
+
public interface IRuntimeStateOwner
|
|
7
|
+
{
|
|
8
|
+
/// Clears transient non-serialized state (subscriptions, cancellation tokens, transient caches).
|
|
9
|
+
void ClearTransientState();
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#if UNITY_EDITOR
|
|
2
|
+
using UnityEditor;
|
|
3
|
+
#endif
|
|
4
|
+
|
|
5
|
+
namespace Xprees.Core
|
|
6
|
+
{
|
|
7
|
+
/// Thread-safe and serialization-safe tracker for play mode state.
|
|
8
|
+
/// Accessing Unity's Application.isPlaying during ISerializationCallbackReceiver throws UnityException.
|
|
9
|
+
/// This pure C# static field can be safely read from any thread or serialization callback.
|
|
10
|
+
public static class PlayModeStateTracker
|
|
11
|
+
{
|
|
12
|
+
#if UNITY_EDITOR
|
|
13
|
+
public static bool IsPlaying { get; private set; }
|
|
14
|
+
|
|
15
|
+
[InitializeOnLoadMethod]
|
|
16
|
+
private static void Init()
|
|
17
|
+
{
|
|
18
|
+
IsPlaying = EditorApplication.isPlaying;
|
|
19
|
+
EditorApplication.playModeStateChanged -= OnPlayModeChanged;
|
|
20
|
+
EditorApplication.playModeStateChanged += OnPlayModeChanged;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
private static void OnPlayModeChanged(PlayModeStateChange change)
|
|
24
|
+
{
|
|
25
|
+
IsPlaying = change is
|
|
26
|
+
PlayModeStateChange.EnteredPlayMode or PlayModeStateChange.ExitingEditMode;
|
|
27
|
+
}
|
|
28
|
+
#else
|
|
29
|
+
// We must have runtime fallback to code to compile
|
|
30
|
+
public static bool IsPlaying => true;
|
|
31
|
+
#endif
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
using System;
|
|
2
|
+
|
|
3
|
+
namespace Xprees.Core
|
|
4
|
+
{
|
|
5
|
+
[Flags]
|
|
6
|
+
public enum PlayModeResetTiming
|
|
7
|
+
{
|
|
8
|
+
/// No reset will occur on Play Mode transitions
|
|
9
|
+
None = 0,
|
|
10
|
+
|
|
11
|
+
/// Reset state when entering Play Mode
|
|
12
|
+
EnterPlayMode = 1 << 0,
|
|
13
|
+
|
|
14
|
+
/// Reset state when exiting Play Mode
|
|
15
|
+
ExitPlayMode = 1 << 1,
|
|
16
|
+
|
|
17
|
+
/// Reset state on both entering and exiting Play Mode
|
|
18
|
+
Both = EnterPlayMode | ExitPlayMode,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/// Controls whether and when a stateful ScriptableObject (such as a Variable) resets to its authored default value during Editor Play Mode transitions.
|
|
22
|
+
/// Hooked and executed centrally by StateSnapshotService.
|
|
23
|
+
[AttributeUsage(AttributeTargets.Class, Inherited = true)]
|
|
24
|
+
public sealed class ResetOnPlayModeAttribute : Attribute
|
|
25
|
+
{
|
|
26
|
+
public PlayModeResetTiming Timing { get; }
|
|
27
|
+
|
|
28
|
+
public ResetOnPlayModeAttribute(PlayModeResetTiming timing = PlayModeResetTiming.EnterPlayMode)
|
|
29
|
+
{
|
|
30
|
+
Timing = timing;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
using System;
|
|
2
|
+
|
|
3
|
+
namespace Xprees.Core
|
|
4
|
+
{
|
|
5
|
+
/// Defines the retention lifecycle and reset scope of a ScriptableObject's runtime state.
|
|
6
|
+
public enum StateLifetime
|
|
7
|
+
{
|
|
8
|
+
/// Scoped strictly to the active Scenario.
|
|
9
|
+
/// Automatically captured and restored to its pristine baseline every time a Scenario starts or restarts.
|
|
10
|
+
/// Example: Scenario-specific variables, emails, dialog progression, and interactables.
|
|
11
|
+
Scenario = 0,
|
|
12
|
+
|
|
13
|
+
/// Persists across multiple scenarios during the entire game execution session,
|
|
14
|
+
/// restored only when quitting the application or starting a new game session from the Main Menu.
|
|
15
|
+
/// Example: global player profile, per session settings etc.
|
|
16
|
+
Session = 1,
|
|
17
|
+
|
|
18
|
+
/// Completely ignored by the runtime state snapshot system.
|
|
19
|
+
/// Represents purely static/constant authored content or data persisted externally (e.g., disk save files / cloud saves).
|
|
20
|
+
/// Example: Audio clips, localization tables, immutable item database definitions.
|
|
21
|
+
Persistent = 2,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/// Explicitly declares the state lifetime for an entire ScriptableObject type.
|
|
25
|
+
/// Overrides per-instance serialized lifetime fields.
|
|
26
|
+
[AttributeUsage(AttributeTargets.Class, Inherited = true)]
|
|
27
|
+
public sealed class StatefulLifetimeAttribute : Attribute
|
|
28
|
+
{
|
|
29
|
+
public StateLifetime Lifetime { get; }
|
|
30
|
+
|
|
31
|
+
public StatefulLifetimeAttribute(StateLifetime lifetime)
|
|
32
|
+
{
|
|
33
|
+
Lifetime = lifetime;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/// Marks a field on a ScriptableObject to be excluded from automated state snapshots.
|
|
38
|
+
[AttributeUsage(AttributeTargets.Field)]
|
|
39
|
+
public sealed class SnapshotIgnoreAttribute : Attribute
|
|
40
|
+
{
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/// Marks a ScriptableObject type as entirely stateless/immutable, skipping it from state snapshots and scenario resetting.
|
|
44
|
+
[AttributeUsage(AttributeTargets.Class, Inherited = true)]
|
|
45
|
+
public sealed class StatelessAssetAttribute : Attribute
|
|
46
|
+
{
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/// Marks a ScriptableObject type or field as entirely stateless/immutable, skipping it from state snapshots and scenario resetting.
|
|
50
|
+
/// Convenient alias for [StatelessAsset].
|
|
51
|
+
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Field, Inherited = true)]
|
|
52
|
+
public sealed class StatelessAttribute : Attribute
|
|
53
|
+
{
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
using System;
|
|
2
|
+
using System.Collections.Concurrent;
|
|
3
|
+
using System.Reflection;
|
|
4
|
+
using UnityEngine;
|
|
5
|
+
#if UNITY_EDITOR
|
|
6
|
+
using UnityEditor;
|
|
7
|
+
#endif
|
|
8
|
+
|
|
9
|
+
namespace Xprees.Core
|
|
10
|
+
{
|
|
11
|
+
/// Utility extensions for resolving lifecycle and stateless status of ScriptableObjects.
|
|
12
|
+
public static class StateLifetimeExtensions
|
|
13
|
+
{
|
|
14
|
+
private readonly static ConcurrentDictionary<Type, StateLifetime?> _lifetimeCache = new();
|
|
15
|
+
private readonly static ConcurrentDictionary<Type, bool> _statelessCache = new();
|
|
16
|
+
|
|
17
|
+
public static StateLifetime GetStateLifetime(this ScriptableObject so)
|
|
18
|
+
{
|
|
19
|
+
if (!so) return StateLifetime.Persistent;
|
|
20
|
+
|
|
21
|
+
var type = so.GetType();
|
|
22
|
+
if (_lifetimeCache.TryGetValue(type, out var cached) && cached.HasValue)
|
|
23
|
+
{
|
|
24
|
+
return cached.Value;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
#if UNITY_EDITOR
|
|
28
|
+
var attrTypes = TypeCache.GetTypesWithAttribute<StatefulLifetimeAttribute>();
|
|
29
|
+
if (attrTypes.Contains(type))
|
|
30
|
+
{
|
|
31
|
+
var attr = (StatefulLifetimeAttribute) Attribute.GetCustomAttribute(type, typeof(StatefulLifetimeAttribute), true);
|
|
32
|
+
if (attr != null)
|
|
33
|
+
{
|
|
34
|
+
_lifetimeCache[type] = attr.Lifetime;
|
|
35
|
+
return attr.Lifetime;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
#endif
|
|
39
|
+
var fallbackAttr = type.GetCustomAttribute<StatefulLifetimeAttribute>(true);
|
|
40
|
+
if (fallbackAttr != null)
|
|
41
|
+
{
|
|
42
|
+
_lifetimeCache[type] = fallbackAttr.Lifetime;
|
|
43
|
+
return fallbackAttr.Lifetime;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (so is DescriptionBaseSO descSo)
|
|
47
|
+
{
|
|
48
|
+
return descSo.ConfiguredLifetime;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
_lifetimeCache[type] = StateLifetime.Persistent;
|
|
52
|
+
return StateLifetime.Persistent;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
public static bool IsStateless(this ScriptableObject so)
|
|
56
|
+
{
|
|
57
|
+
if (!so) return true;
|
|
58
|
+
|
|
59
|
+
var type = so.GetType();
|
|
60
|
+
if (_statelessCache.TryGetValue(type, out var cached))
|
|
61
|
+
{
|
|
62
|
+
return cached;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
#if UNITY_EDITOR
|
|
66
|
+
var statelessTypes = TypeCache.GetTypesWithAttribute<StatelessAssetAttribute>();
|
|
67
|
+
var statelessTypesAlt = TypeCache.GetTypesWithAttribute<StatelessAttribute>();
|
|
68
|
+
if (statelessTypes.Contains(type) || statelessTypesAlt.Contains(type))
|
|
69
|
+
{
|
|
70
|
+
_statelessCache[type] = true;
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
#endif
|
|
74
|
+
var hasStatelessAttr = type.GetCustomAttribute<StatelessAssetAttribute>(true) != null ||
|
|
75
|
+
type.GetCustomAttribute<StatelessAttribute>(true) != null;
|
|
76
|
+
if (hasStatelessAttr)
|
|
77
|
+
{
|
|
78
|
+
_statelessCache[type] = true;
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Assets are only stateful if they inherit from DescriptionBaseSO or are explicitly marked with StatefulLifetime
|
|
83
|
+
var hasStatefulAttr = type.GetCustomAttribute<StatefulLifetimeAttribute>(true) != null;
|
|
84
|
+
var isStateful = so is DescriptionBaseSO || hasStatefulAttr;
|
|
85
|
+
|
|
86
|
+
var isStateless = !isStateful;
|
|
87
|
+
_statelessCache[type] = isStateless;
|
|
88
|
+
return isStateless;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
using System;
|
|
2
|
+
using System.Collections.Concurrent;
|
|
3
|
+
using System.Collections.Generic;
|
|
4
|
+
using System.Linq;
|
|
5
|
+
using System.Reflection;
|
|
6
|
+
using UnityEngine;
|
|
7
|
+
using Object = UnityEngine.Object;
|
|
8
|
+
#if UNITY_EDITOR
|
|
9
|
+
using UnityEditor;
|
|
10
|
+
#endif
|
|
11
|
+
|
|
12
|
+
namespace Xprees.Core
|
|
13
|
+
{
|
|
14
|
+
// TODO later consider adding limits to not blow up memory - probably not an issue
|
|
15
|
+
// TODO consider using OdinSerializer for more robust serialization in future
|
|
16
|
+
|
|
17
|
+
/// Core engine service for zero-boilerplate capturing and in-place restoration
|
|
18
|
+
/// of ScriptableObject serialized state across scenario and session boundaries.
|
|
19
|
+
public static class StateSnapshotService
|
|
20
|
+
{
|
|
21
|
+
private readonly static Dictionary<int, string> snapshots = new();
|
|
22
|
+
private readonly static HashSet<int> restoringEntities = new();
|
|
23
|
+
private readonly static ConcurrentDictionary<Type, FieldInfo[]> snapshotIgnoreFieldsCache = new();
|
|
24
|
+
#if UNITY_EDITOR
|
|
25
|
+
private readonly static Dictionary<int, ScriptableObject> trackedTargets = new();
|
|
26
|
+
#endif
|
|
27
|
+
|
|
28
|
+
/// Total number of currently tracked baseline snapshots in memory.
|
|
29
|
+
public static int SnapshotCount => snapshots.Count;
|
|
30
|
+
|
|
31
|
+
/// Captures the baseline-serialized state of the ScriptableObject if not already captured.
|
|
32
|
+
public static bool EnsureCaptured(ScriptableObject target)
|
|
33
|
+
{
|
|
34
|
+
if (!target || target.IsStateless() || target.GetStateLifetime() == StateLifetime.Persistent)
|
|
35
|
+
{
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
var entityId = target.GetEntityId();
|
|
40
|
+
if (snapshots.ContainsKey(entityId))
|
|
41
|
+
{
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
try
|
|
46
|
+
{
|
|
47
|
+
var json = JsonUtility.ToJson(target);
|
|
48
|
+
snapshots[entityId] = json;
|
|
49
|
+
#if UNITY_EDITOR
|
|
50
|
+
trackedTargets[entityId] = target;
|
|
51
|
+
#endif
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
catch (Exception ex)
|
|
55
|
+
{
|
|
56
|
+
Debug.LogError($"[StateSnapshotService] Failed to capture snapshot for '{target.name}' ({target.GetType().Name}): {ex.Message}",
|
|
57
|
+
target);
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/// Restores the ScriptableObject to its captured baseline state in-place.
|
|
63
|
+
/// If no baseline has been captured yet, the current state is captured as baseline.
|
|
64
|
+
public static bool Restore(ScriptableObject target)
|
|
65
|
+
{
|
|
66
|
+
if (!target || target.IsStateless() || target.GetStateLifetime() == StateLifetime.Persistent)
|
|
67
|
+
{
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
var entityId = target.GetEntityId();
|
|
72
|
+
if (!snapshots.TryGetValue(entityId, out var json))
|
|
73
|
+
{
|
|
74
|
+
EnsureCaptured(target);
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Re-entrancy guard to prevent infinite mutual recursion (e.g. scenario resetting itself)
|
|
79
|
+
if (!restoringEntities.Add(entityId)) return false;
|
|
80
|
+
|
|
81
|
+
try
|
|
82
|
+
{
|
|
83
|
+
RestoreFromJson(json, target);
|
|
84
|
+
|
|
85
|
+
if (target is IRuntimeStateOwner stateOwner)
|
|
86
|
+
{
|
|
87
|
+
stateOwner.ClearTransientState();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
catch (Exception ex)
|
|
93
|
+
{
|
|
94
|
+
Debug.LogError($"[StateSnapshotService] Failed to restore snapshot for '{target.name}' ({target.GetType().Name}): {ex.Message}",
|
|
95
|
+
target);
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
finally
|
|
99
|
+
{
|
|
100
|
+
restoringEntities.Remove(entityId);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/// Restores a collection of ScriptableObjects to their baseline state up to the specified max lifetime.
|
|
105
|
+
/// Multi-pass restoration:
|
|
106
|
+
/// Pass 1 captures the baseline for any uncaptured objects before modifying state.
|
|
107
|
+
/// Pass 2 restores serialized fields and triggers IRuntimeStateOwner.ClearTransientState.
|
|
108
|
+
public static void RestoreAll(IEnumerable<ScriptableObject> targets, StateLifetime maxLifetime = StateLifetime.Scenario)
|
|
109
|
+
{
|
|
110
|
+
if (targets == null) return;
|
|
111
|
+
|
|
112
|
+
var targetsList = targets as ScriptableObject[] ?? targets.ToArray();
|
|
113
|
+
|
|
114
|
+
// Pass 1: Ensure baseline is captured for ALL objects before any object is restored
|
|
115
|
+
foreach (var so in targetsList)
|
|
116
|
+
{
|
|
117
|
+
if (!so || so.IsStateless() || so.GetStateLifetime() > maxLifetime) continue;
|
|
118
|
+
|
|
119
|
+
EnsureCaptured(so);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Pass 2: Restore all objects to baseline (and clear transient state via Restore)
|
|
123
|
+
foreach (var so in targetsList)
|
|
124
|
+
{
|
|
125
|
+
if (!so || so.IsStateless() || so.GetStateLifetime() > maxLifetime) continue;
|
|
126
|
+
|
|
127
|
+
Restore(so);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/// Restores all currently tracked ScriptableObjects to their baseline state.
|
|
132
|
+
public static int RestoreAll()
|
|
133
|
+
{
|
|
134
|
+
var restoredCount = 0;
|
|
135
|
+
#if UNITY_EDITOR
|
|
136
|
+
foreach (var kvp in trackedTargets)
|
|
137
|
+
{
|
|
138
|
+
var target = kvp.Value;
|
|
139
|
+
if (!target)
|
|
140
|
+
{
|
|
141
|
+
target = EditorUtility.EntityIdToObject(kvp.Key) as ScriptableObject;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (!target || !snapshots.TryGetValue(kvp.Key, out var json)) continue;
|
|
145
|
+
if (!restoringEntities.Add(kvp.Key)) continue;
|
|
146
|
+
|
|
147
|
+
try
|
|
148
|
+
{
|
|
149
|
+
RestoreFromJson(json, target);
|
|
150
|
+
|
|
151
|
+
if (target is IRuntimeStateOwner stateOwner)
|
|
152
|
+
{
|
|
153
|
+
stateOwner.ClearTransientState();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
EditorUtility.ClearDirty(target);
|
|
157
|
+
restoredCount++;
|
|
158
|
+
}
|
|
159
|
+
catch (Exception ex)
|
|
160
|
+
{
|
|
161
|
+
Debug.LogError($"[StateSnapshotService] Failed to restore target '{target.name}': {ex.Message}", target);
|
|
162
|
+
}
|
|
163
|
+
finally
|
|
164
|
+
{
|
|
165
|
+
restoringEntities.Remove(kvp.Key);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
#endif
|
|
169
|
+
return restoredCount;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/// Restores serialized fields from JSON while preserving fields marked with [SnapshotIgnore].
|
|
173
|
+
private static void RestoreFromJson(string json, ScriptableObject target)
|
|
174
|
+
{
|
|
175
|
+
var ignoredFields = GetSnapshotIgnoreFields(target.GetType());
|
|
176
|
+
object[] preservedValues = null;
|
|
177
|
+
if (ignoredFields.Length > 0)
|
|
178
|
+
{
|
|
179
|
+
preservedValues = new object[ignoredFields.Length];
|
|
180
|
+
for (var i = 0; i < ignoredFields.Length; i++)
|
|
181
|
+
{
|
|
182
|
+
preservedValues[i] = ignoredFields[i].GetValue(target);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
JsonUtility.FromJsonOverwrite(json, target);
|
|
187
|
+
if (ignoredFields.Length <= 0 || preservedValues == null) return;
|
|
188
|
+
|
|
189
|
+
for (var i = 0; i < ignoredFields.Length; i++)
|
|
190
|
+
{
|
|
191
|
+
ignoredFields[i].SetValue(target, preservedValues[i]);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private static FieldInfo[] GetSnapshotIgnoreFields(Type type)
|
|
196
|
+
{
|
|
197
|
+
if (snapshotIgnoreFieldsCache.TryGetValue(type, out var fields))
|
|
198
|
+
{
|
|
199
|
+
return fields;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
var list = new List<FieldInfo>();
|
|
203
|
+
var current = type;
|
|
204
|
+
while (current != null && current != typeof(ScriptableObject) && current != typeof(Object) && current != typeof(object))
|
|
205
|
+
{
|
|
206
|
+
var currentFields =
|
|
207
|
+
current.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly);
|
|
208
|
+
foreach (var field in currentFields)
|
|
209
|
+
{
|
|
210
|
+
if (Attribute.IsDefined(field, typeof(SnapshotIgnoreAttribute)))
|
|
211
|
+
{
|
|
212
|
+
list.Add(field);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
current = current.BaseType;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
var result = list.Count > 0 ? list.ToArray() : Array.Empty<FieldInfo>();
|
|
220
|
+
snapshotIgnoreFieldsCache[type] = result;
|
|
221
|
+
return result;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/// Checks whether a baseline snapshot currently exists for the specified ScriptableObject.
|
|
225
|
+
public static bool HasSnapshot(ScriptableObject target) => target && snapshots.ContainsKey(target.GetEntityId());
|
|
226
|
+
|
|
227
|
+
/// Gets the raw JSON snapshot string for debugging / state inspector inspection.
|
|
228
|
+
public static string GetSnapshotJson(ScriptableObject target)
|
|
229
|
+
{
|
|
230
|
+
if (target && snapshots.TryGetValue(target.GetEntityId(), out var json))
|
|
231
|
+
{
|
|
232
|
+
return json;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/// Evicts a specific ScriptableObject snapshot from memory.
|
|
239
|
+
public static void Evict(ScriptableObject target)
|
|
240
|
+
{
|
|
241
|
+
if (target == null) return;
|
|
242
|
+
var entityId = target.GetEntityId();
|
|
243
|
+
snapshots.Remove(entityId);
|
|
244
|
+
restoringEntities.Remove(entityId);
|
|
245
|
+
#if UNITY_EDITOR
|
|
246
|
+
trackedTargets.Remove(entityId);
|
|
247
|
+
#endif
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/// Clears all stored state snapshots.
|
|
251
|
+
public static void ClearAll()
|
|
252
|
+
{
|
|
253
|
+
snapshots.Clear();
|
|
254
|
+
restoringEntities.Clear();
|
|
255
|
+
#if UNITY_EDITOR
|
|
256
|
+
trackedTargets.Clear();
|
|
257
|
+
#endif
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/// Alias for ClearAll to provide clear naming.
|
|
261
|
+
public static void ClearAllSnapshots() => ClearAll();
|
|
262
|
+
|
|
263
|
+
/// Automatically called by Unity when Enter Play Mode Options (Disable Domain Reload) is active,
|
|
264
|
+
/// or when the game initializes.
|
|
265
|
+
/// SubsystemRegistration executes before any Awake/OnEnable calls when entering Play Mode,
|
|
266
|
+
/// ensuring static state dictionaries from previous play sessions or editor runs are completely purged.
|
|
267
|
+
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
|
|
268
|
+
private static void ResetStaticFields() => ClearAll();
|
|
269
|
+
}
|
|
270
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
using NUnit.Framework;
|
|
2
|
+
using UnityEngine;
|
|
3
|
+
|
|
4
|
+
namespace Xprees.Core.Tests
|
|
5
|
+
{
|
|
6
|
+
public class StateSnapshotServiceTests
|
|
7
|
+
{
|
|
8
|
+
[StatefulLifetime(StateLifetime.Scenario)]
|
|
9
|
+
private class TestStatefulSO : ScriptableObject, IRuntimeStateOwner
|
|
10
|
+
{
|
|
11
|
+
public int intValue = 10;
|
|
12
|
+
public string stringValue = "initial";
|
|
13
|
+
public bool transientFlag = false;
|
|
14
|
+
|
|
15
|
+
public void ClearTransientState()
|
|
16
|
+
{
|
|
17
|
+
transientFlag = false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
[StatefulLifetime(StateLifetime.Scenario)]
|
|
22
|
+
private class TestSnapshotIgnoreSO : ScriptableObject
|
|
23
|
+
{
|
|
24
|
+
public int capturedValue = 10;
|
|
25
|
+
[SnapshotIgnore] public string ignoredValue = "initial";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
private class TestPlainSO : ScriptableObject
|
|
29
|
+
{
|
|
30
|
+
public int count = 5;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
[StatelessAsset]
|
|
34
|
+
private class TestStatelessSO : ScriptableObject
|
|
35
|
+
{
|
|
36
|
+
public int count = 5;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
[Stateless]
|
|
40
|
+
private class TestStatelessAliasSO : ScriptableObject
|
|
41
|
+
{
|
|
42
|
+
public int count = 5;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
[StatefulLifetime(StateLifetime.Persistent)]
|
|
46
|
+
private class TestPersistentSO : ScriptableObject
|
|
47
|
+
{
|
|
48
|
+
public int score = 100;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
[SetUp]
|
|
52
|
+
public void Setup()
|
|
53
|
+
{
|
|
54
|
+
StateSnapshotService.ClearAllSnapshots();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
[TearDown]
|
|
58
|
+
public void TearDown()
|
|
59
|
+
{
|
|
60
|
+
StateSnapshotService.ClearAllSnapshots();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
[Test]
|
|
64
|
+
public void EnsureCaptured_And_Restore_RevertsMutatedState()
|
|
65
|
+
{
|
|
66
|
+
var so = ScriptableObject.CreateInstance<TestStatefulSO>();
|
|
67
|
+
so.intValue = 42;
|
|
68
|
+
so.stringValue = "pristine";
|
|
69
|
+
|
|
70
|
+
StateSnapshotService.EnsureCaptured(so);
|
|
71
|
+
Assert.IsTrue(StateSnapshotService.HasSnapshot(so));
|
|
72
|
+
|
|
73
|
+
// Mutate
|
|
74
|
+
so.intValue = 999;
|
|
75
|
+
so.stringValue = "corrupted";
|
|
76
|
+
|
|
77
|
+
// Restore
|
|
78
|
+
StateSnapshotService.Restore(so);
|
|
79
|
+
|
|
80
|
+
Assert.AreEqual(42, so.intValue);
|
|
81
|
+
Assert.AreEqual("pristine", so.stringValue);
|
|
82
|
+
|
|
83
|
+
Object.DestroyImmediate(so);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
[Test]
|
|
87
|
+
public void SnapshotIgnore_PreservesFieldValueAcrossRestore()
|
|
88
|
+
{
|
|
89
|
+
var so = ScriptableObject.CreateInstance<TestSnapshotIgnoreSO>();
|
|
90
|
+
so.capturedValue = 10;
|
|
91
|
+
so.ignoredValue = "before_capture";
|
|
92
|
+
|
|
93
|
+
StateSnapshotService.EnsureCaptured(so);
|
|
94
|
+
|
|
95
|
+
// Mutate both
|
|
96
|
+
so.capturedValue = 99;
|
|
97
|
+
so.ignoredValue = "mutated_after_capture";
|
|
98
|
+
|
|
99
|
+
StateSnapshotService.Restore(so);
|
|
100
|
+
|
|
101
|
+
// capturedValue should be restored to baseline (10)
|
|
102
|
+
Assert.AreEqual(10, so.capturedValue);
|
|
103
|
+
// ignoredValue was excluded from overwrite, so it retains its mutated value ("mutated_after_capture")
|
|
104
|
+
Assert.AreEqual("mutated_after_capture", so.ignoredValue);
|
|
105
|
+
|
|
106
|
+
Object.DestroyImmediate(so);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
[Test]
|
|
110
|
+
public void RestoreAll_RestoresAllTrackedTargets()
|
|
111
|
+
{
|
|
112
|
+
var so1 = ScriptableObject.CreateInstance<TestStatefulSO>();
|
|
113
|
+
so1.intValue = 10;
|
|
114
|
+
so1.stringValue = "first";
|
|
115
|
+
|
|
116
|
+
var so2 = ScriptableObject.CreateInstance<TestStatefulSO>();
|
|
117
|
+
so2.intValue = 20;
|
|
118
|
+
so2.stringValue = "second";
|
|
119
|
+
|
|
120
|
+
StateSnapshotService.EnsureCaptured(so1);
|
|
121
|
+
StateSnapshotService.EnsureCaptured(so2);
|
|
122
|
+
|
|
123
|
+
// Mutate both
|
|
124
|
+
so1.intValue = 111;
|
|
125
|
+
so2.intValue = 222;
|
|
126
|
+
|
|
127
|
+
// RestoreAll
|
|
128
|
+
var restored = StateSnapshotService.RestoreAll();
|
|
129
|
+
Assert.AreEqual(2, restored);
|
|
130
|
+
Assert.AreEqual(10, so1.intValue);
|
|
131
|
+
Assert.AreEqual(20, so2.intValue);
|
|
132
|
+
|
|
133
|
+
Object.DestroyImmediate(so1);
|
|
134
|
+
Object.DestroyImmediate(so2);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
[Test]
|
|
138
|
+
public void PlainScriptableObject_WithoutAttributeOrDescriptionBaseSO_IsStatelessAndIgnored()
|
|
139
|
+
{
|
|
140
|
+
var so = ScriptableObject.CreateInstance<TestPlainSO>();
|
|
141
|
+
Assert.IsTrue(so.IsStateless());
|
|
142
|
+
Assert.AreEqual(StateLifetime.Persistent, so.GetStateLifetime());
|
|
143
|
+
Assert.IsFalse(StateSnapshotService.EnsureCaptured(so));
|
|
144
|
+
Object.DestroyImmediate(so);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
[Test]
|
|
148
|
+
public void StatelessAsset_IsIgnoredBySnapshotEngine()
|
|
149
|
+
{
|
|
150
|
+
var so = ScriptableObject.CreateInstance<TestStatelessSO>();
|
|
151
|
+
so.count = 50;
|
|
152
|
+
|
|
153
|
+
StateSnapshotService.EnsureCaptured(so);
|
|
154
|
+
Assert.IsFalse(StateSnapshotService.HasSnapshot(so));
|
|
155
|
+
|
|
156
|
+
Object.DestroyImmediate(so);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
[Test]
|
|
160
|
+
public void StatelessAttribute_IsIgnoredBySnapshotEngine()
|
|
161
|
+
{
|
|
162
|
+
var so = ScriptableObject.CreateInstance<TestStatelessAliasSO>();
|
|
163
|
+
so.count = 50;
|
|
164
|
+
|
|
165
|
+
StateSnapshotService.EnsureCaptured(so);
|
|
166
|
+
Assert.IsFalse(StateSnapshotService.HasSnapshot(so));
|
|
167
|
+
|
|
168
|
+
Object.DestroyImmediate(so);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
[Test]
|
|
172
|
+
public void PersistentLifetime_IsIgnoredBySnapshotEngine()
|
|
173
|
+
{
|
|
174
|
+
var so = ScriptableObject.CreateInstance<TestPersistentSO>();
|
|
175
|
+
so.score = 200;
|
|
176
|
+
|
|
177
|
+
StateSnapshotService.EnsureCaptured(so);
|
|
178
|
+
Assert.IsFalse(StateSnapshotService.HasSnapshot(so));
|
|
179
|
+
|
|
180
|
+
Object.DestroyImmediate(so);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
[Test]
|
|
184
|
+
public void ClearTransientState_ResetsNonSerializedFlags()
|
|
185
|
+
{
|
|
186
|
+
var so = ScriptableObject.CreateInstance<TestStatefulSO>();
|
|
187
|
+
so.transientFlag = true;
|
|
188
|
+
|
|
189
|
+
so.ClearTransientState();
|
|
190
|
+
Assert.IsFalse(so.transientFlag);
|
|
191
|
+
|
|
192
|
+
Object.DestroyImmediate(so);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cz.xprees.core.Tests.Editor",
|
|
3
|
+
"rootNamespace": "Xprees.Core.Tests",
|
|
4
|
+
"references": [
|
|
5
|
+
"cz.xprees.core",
|
|
6
|
+
"UnityEngine.TestRunner",
|
|
7
|
+
"UnityEditor.TestRunner"
|
|
8
|
+
],
|
|
9
|
+
"includePlatforms": [
|
|
10
|
+
"Editor"
|
|
11
|
+
],
|
|
12
|
+
"excludePlatforms": [],
|
|
13
|
+
"allowUnsafeCode": false,
|
|
14
|
+
"overrideReferences": true,
|
|
15
|
+
"precompiledReferences": [
|
|
16
|
+
"nunit.framework.dll"
|
|
17
|
+
],
|
|
18
|
+
"autoReferenced": false,
|
|
19
|
+
"defineConstraints": [
|
|
20
|
+
"UNITY_INCLUDE_TESTS"
|
|
21
|
+
],
|
|
22
|
+
"versionDefines": [],
|
|
23
|
+
"noEngineReferences": false
|
|
24
|
+
}
|
package/Tests.meta
ADDED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cz.xprees.core",
|
|
3
3
|
"displayName": "Core Classes",
|
|
4
|
-
"version": "1.0.
|
|
4
|
+
"version": "1.0.40",
|
|
5
5
|
"unity": "2021.3",
|
|
6
6
|
"description": "This package contains core classes and interfaces for Unity projects. It is intended to be used as a dependency for other packages. By providing base classes in a separate package, you can easily update them across all your projects.",
|
|
7
7
|
"license": "Apache-2.0",
|