claude-code-arcane 2.3.0 → 2.5.0
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/CHANGELOG.md +23 -0
- package/README.md +131 -41
- package/agents/game/ue-blueprint-specialist.md +125 -0
- package/agents/game/ue-gas-specialist.md +133 -0
- package/agents/game/ue-umg-specialist.md +131 -0
- package/dist/cli.js +642 -478
- package/docs/SKILLS-CATALOG.md +42 -3
- package/docs/USER-GUIDE.md +28 -54
- package/hooks/detect-unreal.sh +90 -0
- package/package.json +2 -1
- package/profiles/agile.yaml +1 -1
- package/profiles/ai.yaml +1 -1
- package/profiles/android-native.yaml +1 -1
- package/profiles/audio.yaml +1 -1
- package/profiles/backend-dotnet.yaml +1 -1
- package/profiles/backend-go.yaml +1 -1
- package/profiles/backend-nestjs.yaml +1 -1
- package/profiles/backend-nextjs.yaml +1 -1
- package/profiles/backend-ts.yaml +1 -1
- package/profiles/business.yaml +1 -1
- package/profiles/clevel.yaml +1 -1
- package/profiles/clickup.yaml +1 -1
- package/profiles/core.yaml +1 -1
- package/profiles/database.yaml +1 -1
- package/profiles/design.yaml +1 -1
- package/profiles/docs.yaml +1 -1
- package/profiles/ecommerce.yaml +1 -1
- package/profiles/finance.yaml +1 -1
- package/profiles/flutter.yaml +1 -1
- package/profiles/freelance.yaml +1 -1
- package/profiles/frontend.yaml +1 -1
- package/profiles/infra.yaml +1 -1
- package/profiles/integrations.yaml +1 -1
- package/profiles/ios-native.yaml +1 -1
- package/profiles/jira.yaml +1 -1
- package/profiles/job-hunt.yaml +1 -1
- package/profiles/marketing.yaml +1 -1
- package/profiles/mobile.yaml +1 -1
- package/profiles/regulatory.yaml +1 -1
- package/profiles/second-brain.yaml +1 -1
- package/profiles/security.yaml +1 -1
- package/profiles/self-improving.yaml +1 -1
- package/profiles/statusline.yaml +1 -1
- package/profiles/testing.yaml +1 -1
- package/profiles/unity-design.yaml +1 -1
- package/profiles/unity-dev.yaml +1 -1
- package/profiles/unreal-dev.yaml +97 -0
- package/profiles/visual-novel.yaml +1 -1
- package/rules/gamedev/unreal-code.md +125 -0
- package/skills/arcane-list/SKILL.md +12 -7
- package/skills/install-mcp/SKILL.md +135 -46
- package/skills/install-mcp/references/unreal-operations.md +55 -0
- package/skills/ue-actor-component-architecture/SKILL.md +509 -0
- package/skills/ue-actor-component-architecture/references/actor-lifecycle.md +301 -0
- package/skills/ue-actor-component-architecture/references/component-types.md +592 -0
- package/skills/ue-ai-navigation/SKILL.md +526 -0
- package/skills/ue-ai-navigation/references/behavior-tree-patterns.md +392 -0
- package/skills/ue-ai-navigation/references/behavior-tree-workflow.md +107 -0
- package/skills/ue-ai-navigation/references/custom-bttask.md +104 -0
- package/skills/ue-ai-navigation/references/eqs-reference.md +423 -0
- package/skills/ue-animation-system/SKILL.md +521 -0
- package/skills/ue-animation-system/references/anim-notify-reference.md +486 -0
- package/skills/ue-animation-system/references/locomotion-setup.md +550 -0
- package/skills/ue-async-threading/SKILL.md +433 -0
- package/skills/ue-async-threading/references/thread-safety-guide.md +213 -0
- package/skills/ue-async-threading/references/threading-patterns.md +345 -0
- package/skills/ue-audio-system/SKILL.md +527 -0
- package/skills/ue-audio-system/references/audio-setup-patterns.md +700 -0
- package/skills/ue-blueprints/SKILL.md +134 -0
- package/skills/ue-blueprints/references/communication.md +82 -0
- package/skills/ue-character-movement/SKILL.md +475 -0
- package/skills/ue-character-movement/references/cmc-extension-patterns.md +335 -0
- package/skills/ue-character-movement/references/movement-pipeline.md +189 -0
- package/skills/ue-cpp-foundations/SKILL.md +533 -0
- package/skills/ue-cpp-foundations/references/components-and-gc.md +107 -0
- package/skills/ue-cpp-foundations/references/container-patterns.md +544 -0
- package/skills/ue-cpp-foundations/references/delegate-patterns.md +497 -0
- package/skills/ue-cpp-foundations/references/gameplay-classes-cpp.md +136 -0
- package/skills/ue-cpp-foundations/references/property-specifiers.md +306 -0
- package/skills/ue-data-assets-tables/SKILL.md +527 -0
- package/skills/ue-data-assets-tables/references/asset-loading-patterns.md +306 -0
- package/skills/ue-data-assets-tables/references/data-driven-design.md +378 -0
- package/skills/ue-editor-tools/SKILL.md +523 -0
- package/skills/ue-editor-tools/references/detail-customization-patterns.md +395 -0
- package/skills/ue-editor-tools/references/editor-module-setup.md +480 -0
- package/skills/ue-game-features/SKILL.md +477 -0
- package/skills/ue-game-features/references/experience-system.md +222 -0
- package/skills/ue-game-features/references/game-feature-patterns.md +308 -0
- package/skills/ue-gameplay-abilities/SKILL.md +518 -0
- package/skills/ue-gameplay-abilities/references/ability-task-reference.md +391 -0
- package/skills/ue-gameplay-abilities/references/gameplay-effect-reference.md +397 -0
- package/skills/ue-gameplay-abilities/references/gas-setup-patterns.md +369 -0
- package/skills/ue-gameplay-framework/SKILL.md +466 -0
- package/skills/ue-gameplay-framework/references/framework-class-map.md +243 -0
- package/skills/ue-input-system/SKILL.md +394 -0
- package/skills/ue-input-system/references/cpp-setup.md +131 -0
- package/skills/ue-input-system/references/enhanced-input-workflow.md +117 -0
- package/skills/ue-input-system/references/input-action-reference.md +731 -0
- package/skills/ue-mass-entity/SKILL.md +476 -0
- package/skills/ue-mass-entity/references/mass-entity-patterns.md +319 -0
- package/skills/ue-mass-entity/references/mass-fragment-reference.md +119 -0
- package/skills/ue-materials-rendering/SKILL.md +526 -0
- package/skills/ue-materials-rendering/references/material-parameter-reference.md +216 -0
- package/skills/ue-materials-rendering/references/post-process-settings.md +383 -0
- package/skills/ue-module-build-system/SKILL.md +487 -0
- package/skills/ue-module-build-system/references/build-cs-reference.md +418 -0
- package/skills/ue-module-build-system/references/common-build-errors.md +381 -0
- package/skills/ue-naming-conventions/SKILL.md +142 -0
- package/skills/ue-naming-conventions/references/asset-name-modifiers.md +233 -0
- package/skills/ue-naming-conventions/references/content-structure.md +83 -0
- package/skills/ue-networking-replication/SKILL.md +452 -0
- package/skills/ue-networking-replication/references/replication-patterns.md +526 -0
- package/skills/ue-networking-replication/references/rpc-decision-guide.md +393 -0
- package/skills/ue-niagara-effects/SKILL.md +531 -0
- package/skills/ue-niagara-effects/references/niagara-data-interfaces.md +491 -0
- package/skills/ue-niagara-effects/references/niagara-parameter-types.md +153 -0
- package/skills/ue-niagara-effects/references/niagara-workflow.md +115 -0
- package/skills/ue-packaging/SKILL.md +116 -0
- package/skills/ue-physics-collision/SKILL.md +423 -0
- package/skills/ue-physics-collision/references/collision-channel-setup.md +218 -0
- package/skills/ue-physics-collision/references/trace-patterns.md +499 -0
- package/skills/ue-procedural-generation/SKILL.md +505 -0
- package/skills/ue-procedural-generation/references/pcg-node-reference.md +381 -0
- package/skills/ue-procedural-generation/references/procedural-mesh-patterns.md +940 -0
- package/skills/ue-project-context/SKILL.md +305 -0
- package/skills/ue-sequencer-cinematics/SKILL.md +506 -0
- package/skills/ue-sequencer-cinematics/references/sequencer-patterns.md +629 -0
- package/skills/ue-serialization-savegames/SKILL.md +524 -0
- package/skills/ue-serialization-savegames/references/save-system-architecture.md +705 -0
- package/skills/ue-state-trees/SKILL.md +489 -0
- package/skills/ue-state-trees/references/state-tree-mass-integration.md +184 -0
- package/skills/ue-state-trees/references/state-tree-patterns.md +321 -0
- package/skills/ue-testing-debugging/SKILL.md +520 -0
- package/skills/ue-testing-debugging/references/automation-test-patterns.md +387 -0
- package/skills/ue-testing-debugging/references/profiling-commands.md +357 -0
- package/skills/ue-ui-umg-slate/SKILL.md +431 -0
- package/skills/ue-ui-umg-slate/references/common-ui-setup.md +484 -0
- package/skills/ue-ui-umg-slate/references/widget-types.md +487 -0
- package/skills/ue-world-level-streaming/SKILL.md +518 -0
- package/skills/ue-world-level-streaming/references/streaming-patterns.md +414 -0
- package/templates/gamedev/unreal-project-context.md +107 -0
|
@@ -0,0 +1,705 @@
|
|
|
1
|
+
# Save System Architecture
|
|
2
|
+
|
|
3
|
+
Complete patterns for production save systems: slot management, save metadata, async pipelines, multi-user handling, and corruption recovery.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Architecture Overview
|
|
8
|
+
|
|
9
|
+
A production save system has three layers:
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
┌─────────────────────────────────────────────────────────┐
|
|
13
|
+
│ Game Code (GameMode, PlayerController, Subsystems) │
|
|
14
|
+
│ -- calls SaveManager -- │
|
|
15
|
+
├─────────────────────────────────────────────────────────┤
|
|
16
|
+
│ Save Manager (UGameInstanceSubsystem) │
|
|
17
|
+
│ -- slot routing, async coordination, migration -- │
|
|
18
|
+
├─────────────────────────────────────────────────────────┤
|
|
19
|
+
│ UGameplayStatics / ISaveGameSystem │
|
|
20
|
+
│ -- platform-agnostic file I/O -- │
|
|
21
|
+
└─────────────────────────────────────────────────────────┘
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The save manager lives on the `UGameInstance` as a `UGameInstanceSubsystem` so it persists across level transitions and map loads.
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## 1. Save Metadata (Slot List)
|
|
29
|
+
|
|
30
|
+
Store a lightweight metadata object in a fixed slot so the UI can display save slot info (timestamp, player name, level, screenshot thumbnail path) without loading the full save payload.
|
|
31
|
+
|
|
32
|
+
```cpp
|
|
33
|
+
// SaveMetadata.h
|
|
34
|
+
#pragma once
|
|
35
|
+
#include "CoreMinimal.h"
|
|
36
|
+
#include "GameFramework/SaveGame.h"
|
|
37
|
+
#include "SaveMetadata.generated.h"
|
|
38
|
+
|
|
39
|
+
USTRUCT(BlueprintType)
|
|
40
|
+
struct FSaveSlotInfo
|
|
41
|
+
{
|
|
42
|
+
GENERATED_BODY()
|
|
43
|
+
|
|
44
|
+
UPROPERTY(SaveGame, BlueprintReadOnly)
|
|
45
|
+
int32 SlotIndex = -1;
|
|
46
|
+
|
|
47
|
+
UPROPERTY(SaveGame, BlueprintReadOnly)
|
|
48
|
+
FString PlayerDisplayName;
|
|
49
|
+
|
|
50
|
+
UPROPERTY(SaveGame, BlueprintReadOnly)
|
|
51
|
+
FString MapName;
|
|
52
|
+
|
|
53
|
+
UPROPERTY(SaveGame, BlueprintReadOnly)
|
|
54
|
+
int32 PlayerLevel = 0;
|
|
55
|
+
|
|
56
|
+
UPROPERTY(SaveGame, BlueprintReadOnly)
|
|
57
|
+
FDateTime LastSaveTime;
|
|
58
|
+
|
|
59
|
+
UPROPERTY(SaveGame, BlueprintReadOnly)
|
|
60
|
+
float TotalPlayTimeSeconds = 0.f;
|
|
61
|
+
|
|
62
|
+
UPROPERTY(SaveGame, BlueprintReadOnly)
|
|
63
|
+
bool bIsValid = false;
|
|
64
|
+
|
|
65
|
+
// Path to a thumbnail screenshot stored in Saved/Screenshots/
|
|
66
|
+
UPROPERTY(SaveGame, BlueprintReadOnly)
|
|
67
|
+
FString ThumbnailPath;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
UCLASS()
|
|
71
|
+
class MYGAME_API USaveMetadataBank : public USaveGame
|
|
72
|
+
{
|
|
73
|
+
GENERATED_BODY()
|
|
74
|
+
|
|
75
|
+
public:
|
|
76
|
+
static constexpr int32 MaxSlots = 10;
|
|
77
|
+
static const FString SlotName; // "SaveMetadataBank"
|
|
78
|
+
static constexpr int32 UserIndex = 0;
|
|
79
|
+
|
|
80
|
+
UPROPERTY(SaveGame)
|
|
81
|
+
TArray<FSaveSlotInfo> Slots;
|
|
82
|
+
|
|
83
|
+
// Initialize with empty slots
|
|
84
|
+
void Init()
|
|
85
|
+
{
|
|
86
|
+
Slots.SetNum(MaxSlots);
|
|
87
|
+
for (int32 i = 0; i < MaxSlots; ++i)
|
|
88
|
+
{
|
|
89
|
+
Slots[i].SlotIndex = i;
|
|
90
|
+
Slots[i].bIsValid = false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const FSaveSlotInfo* GetSlot(int32 Index) const
|
|
95
|
+
{
|
|
96
|
+
if (Slots.IsValidIndex(Index)) { return &Slots[Index]; }
|
|
97
|
+
return nullptr;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
void UpdateSlot(int32 Index, const FSaveSlotInfo& Info)
|
|
101
|
+
{
|
|
102
|
+
if (Slots.IsValidIndex(Index))
|
|
103
|
+
{
|
|
104
|
+
Slots[Index] = Info;
|
|
105
|
+
Slots[Index].SlotIndex = Index;
|
|
106
|
+
Slots[Index].bIsValid = true;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
void ClearSlot(int32 Index)
|
|
111
|
+
{
|
|
112
|
+
if (Slots.IsValidIndex(Index))
|
|
113
|
+
{
|
|
114
|
+
Slots[Index] = FSaveSlotInfo();
|
|
115
|
+
Slots[Index].SlotIndex = Index;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const FString USaveMetadataBank::SlotName = TEXT("SaveMetadataBank");
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
## 2. Save Manager Subsystem
|
|
126
|
+
|
|
127
|
+
```cpp
|
|
128
|
+
// SaveManager.h
|
|
129
|
+
#pragma once
|
|
130
|
+
#include "CoreMinimal.h"
|
|
131
|
+
#include "Subsystems/GameInstanceSubsystem.h"
|
|
132
|
+
#include "SaveMetadata.h"
|
|
133
|
+
#include "MyGameSaveGame.h"
|
|
134
|
+
#include "SaveManager.generated.h"
|
|
135
|
+
|
|
136
|
+
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnSaveComplete, int32, SlotIndex, bool, bSuccess);
|
|
137
|
+
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnLoadComplete, int32, SlotIndex, bool, bSuccess);
|
|
138
|
+
|
|
139
|
+
UCLASS()
|
|
140
|
+
class MYGAME_API USaveManager : public UGameInstanceSubsystem
|
|
141
|
+
{
|
|
142
|
+
GENERATED_BODY()
|
|
143
|
+
|
|
144
|
+
public:
|
|
145
|
+
// USubsystem interface
|
|
146
|
+
virtual void Initialize(FSubsystemCollectionBase& Collection) override;
|
|
147
|
+
virtual void Deinitialize() override;
|
|
148
|
+
|
|
149
|
+
// -- Public API --
|
|
150
|
+
|
|
151
|
+
/** Load the metadata bank (slot list). Call once at game startup. */
|
|
152
|
+
UFUNCTION(BlueprintCallable, Category = "Save")
|
|
153
|
+
void LoadMetadataBank();
|
|
154
|
+
|
|
155
|
+
/** Get info for all slots without loading save payloads. */
|
|
156
|
+
UFUNCTION(BlueprintCallable, Category = "Save")
|
|
157
|
+
TArray<FSaveSlotInfo> GetAllSlotInfo() const;
|
|
158
|
+
|
|
159
|
+
/** Async save to a slot. Fires OnSaveComplete when done. */
|
|
160
|
+
UFUNCTION(BlueprintCallable, Category = "Save")
|
|
161
|
+
void AsyncSaveToSlot(int32 SlotIndex);
|
|
162
|
+
|
|
163
|
+
/** Async load from a slot. Fires OnLoadComplete when done. */
|
|
164
|
+
UFUNCTION(BlueprintCallable, Category = "Save")
|
|
165
|
+
void AsyncLoadFromSlot(int32 SlotIndex);
|
|
166
|
+
|
|
167
|
+
/** Synchronous load — blocks game thread. Use only during initial boot. */
|
|
168
|
+
UFUNCTION(BlueprintCallable, Category = "Save")
|
|
169
|
+
bool SyncLoadFromSlot(int32 SlotIndex);
|
|
170
|
+
|
|
171
|
+
/** Delete a save slot and clear its metadata entry. */
|
|
172
|
+
UFUNCTION(BlueprintCallable, Category = "Save")
|
|
173
|
+
void DeleteSlot(int32 SlotIndex);
|
|
174
|
+
|
|
175
|
+
/** True if an async save is currently in flight. */
|
|
176
|
+
UFUNCTION(BlueprintCallable, Category = "Save")
|
|
177
|
+
bool IsSaveInProgress() const { return bSaveInProgress; }
|
|
178
|
+
|
|
179
|
+
/** The currently active save data (loaded slot). */
|
|
180
|
+
UFUNCTION(BlueprintCallable, Category = "Save")
|
|
181
|
+
UMyGameSaveGame* GetCurrentSave() const { return CurrentSave; }
|
|
182
|
+
|
|
183
|
+
// -- Events --
|
|
184
|
+
UPROPERTY(BlueprintAssignable, Category = "Save")
|
|
185
|
+
FOnSaveComplete OnSaveComplete;
|
|
186
|
+
|
|
187
|
+
UPROPERTY(BlueprintAssignable, Category = "Save")
|
|
188
|
+
FOnLoadComplete OnLoadComplete;
|
|
189
|
+
|
|
190
|
+
// -- Auto-Save --
|
|
191
|
+
UFUNCTION(BlueprintCallable, Category = "Save")
|
|
192
|
+
void StartAutoSave(int32 SlotIndex, float IntervalSeconds = 300.f);
|
|
193
|
+
|
|
194
|
+
UFUNCTION(BlueprintCallable, Category = "Save")
|
|
195
|
+
void StopAutoSave();
|
|
196
|
+
|
|
197
|
+
private:
|
|
198
|
+
static FString MakeSlotName(int32 SlotIndex);
|
|
199
|
+
void PopulateSaveData(UMyGameSaveGame* Save);
|
|
200
|
+
void ApplySaveData(UMyGameSaveGame* Save);
|
|
201
|
+
void SaveMetadataBank();
|
|
202
|
+
FSaveSlotInfo BuildSlotInfo(int32 SlotIndex, const UMyGameSaveGame* Save) const;
|
|
203
|
+
|
|
204
|
+
// Callbacks
|
|
205
|
+
UFUNCTION()
|
|
206
|
+
void OnAsyncSaveComplete(const FString& SlotName, int32 UserIndex, bool bSuccess);
|
|
207
|
+
UFUNCTION()
|
|
208
|
+
void OnAsyncLoadComplete(const FString& SlotName, int32 UserIndex, USaveGame* SaveGame);
|
|
209
|
+
|
|
210
|
+
UPROPERTY()
|
|
211
|
+
USaveMetadataBank* MetadataBank = nullptr;
|
|
212
|
+
|
|
213
|
+
UPROPERTY()
|
|
214
|
+
UMyGameSaveGame* CurrentSave = nullptr;
|
|
215
|
+
|
|
216
|
+
int32 ActiveSlotIndex = -1;
|
|
217
|
+
bool bSaveInProgress = false;
|
|
218
|
+
|
|
219
|
+
FTimerHandle AutoSaveTimerHandle;
|
|
220
|
+
int32 AutoSaveSlotIndex = 0;
|
|
221
|
+
};
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
```cpp
|
|
225
|
+
// SaveManager.cpp
|
|
226
|
+
#include "SaveManager.h"
|
|
227
|
+
#include "Kismet/GameplayStatics.h"
|
|
228
|
+
#include "Engine/World.h"
|
|
229
|
+
#include "TimerManager.h"
|
|
230
|
+
|
|
231
|
+
void USaveManager::Initialize(FSubsystemCollectionBase& Collection)
|
|
232
|
+
{
|
|
233
|
+
Super::Initialize(Collection);
|
|
234
|
+
LoadMetadataBank();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
void USaveManager::Deinitialize()
|
|
238
|
+
{
|
|
239
|
+
StopAutoSave();
|
|
240
|
+
Super::Deinitialize();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
FString USaveManager::MakeSlotName(int32 SlotIndex)
|
|
244
|
+
{
|
|
245
|
+
return FString::Printf(TEXT("GameSave_%02d"), SlotIndex);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
void USaveManager::LoadMetadataBank()
|
|
249
|
+
{
|
|
250
|
+
if (UGameplayStatics::DoesSaveGameExist(USaveMetadataBank::SlotName,
|
|
251
|
+
USaveMetadataBank::UserIndex))
|
|
252
|
+
{
|
|
253
|
+
MetadataBank = Cast<USaveMetadataBank>(
|
|
254
|
+
UGameplayStatics::LoadGameFromSlot(
|
|
255
|
+
USaveMetadataBank::SlotName, USaveMetadataBank::UserIndex));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (!MetadataBank)
|
|
259
|
+
{
|
|
260
|
+
MetadataBank = Cast<USaveMetadataBank>(
|
|
261
|
+
UGameplayStatics::CreateSaveGameObject(USaveMetadataBank::StaticClass()));
|
|
262
|
+
MetadataBank->Init();
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
TArray<FSaveSlotInfo> USaveManager::GetAllSlotInfo() const
|
|
267
|
+
{
|
|
268
|
+
if (MetadataBank)
|
|
269
|
+
{
|
|
270
|
+
return MetadataBank->Slots;
|
|
271
|
+
}
|
|
272
|
+
return {};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
void USaveManager::AsyncSaveToSlot(int32 SlotIndex)
|
|
276
|
+
{
|
|
277
|
+
if (bSaveInProgress)
|
|
278
|
+
{
|
|
279
|
+
UE_LOG(LogTemp, Warning, TEXT("SaveManager: Save already in progress, ignoring request."));
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (!CurrentSave)
|
|
284
|
+
{
|
|
285
|
+
CurrentSave = Cast<UMyGameSaveGame>(
|
|
286
|
+
UGameplayStatics::CreateSaveGameObject(UMyGameSaveGame::StaticClass()));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
PopulateSaveData(CurrentSave);
|
|
290
|
+
ActiveSlotIndex = SlotIndex;
|
|
291
|
+
bSaveInProgress = true;
|
|
292
|
+
|
|
293
|
+
FAsyncSaveGameToSlotDelegate Delegate;
|
|
294
|
+
Delegate.BindUObject(this, &USaveManager::OnAsyncSaveComplete);
|
|
295
|
+
UGameplayStatics::AsyncSaveGameToSlot(
|
|
296
|
+
CurrentSave, MakeSlotName(SlotIndex), USaveMetadataBank::UserIndex, Delegate);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
void USaveManager::OnAsyncSaveComplete(
|
|
300
|
+
const FString& SlotName, int32 UserIndex, bool bSuccess)
|
|
301
|
+
{
|
|
302
|
+
bSaveInProgress = false;
|
|
303
|
+
|
|
304
|
+
if (bSuccess && MetadataBank)
|
|
305
|
+
{
|
|
306
|
+
FSaveSlotInfo Info = BuildSlotInfo(ActiveSlotIndex, CurrentSave);
|
|
307
|
+
MetadataBank->UpdateSlot(ActiveSlotIndex, Info);
|
|
308
|
+
SaveMetadataBank();
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
OnSaveComplete.Broadcast(ActiveSlotIndex, bSuccess);
|
|
312
|
+
|
|
313
|
+
if (!bSuccess)
|
|
314
|
+
{
|
|
315
|
+
UE_LOG(LogTemp, Error, TEXT("SaveManager: Async save failed for slot %d"), ActiveSlotIndex);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
void USaveManager::AsyncLoadFromSlot(int32 SlotIndex)
|
|
320
|
+
{
|
|
321
|
+
ActiveSlotIndex = SlotIndex;
|
|
322
|
+
|
|
323
|
+
FAsyncLoadGameFromSlotDelegate Delegate;
|
|
324
|
+
Delegate.BindUObject(this, &USaveManager::OnAsyncLoadComplete);
|
|
325
|
+
UGameplayStatics::AsyncLoadGameFromSlot(
|
|
326
|
+
MakeSlotName(SlotIndex), USaveMetadataBank::UserIndex, Delegate);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
void USaveManager::OnAsyncLoadComplete(
|
|
330
|
+
const FString& SlotName, int32 UserIndex, USaveGame* SaveGame)
|
|
331
|
+
{
|
|
332
|
+
CurrentSave = Cast<UMyGameSaveGame>(SaveGame);
|
|
333
|
+
|
|
334
|
+
if (!CurrentSave)
|
|
335
|
+
{
|
|
336
|
+
UE_LOG(LogTemp, Warning,
|
|
337
|
+
TEXT("SaveManager: Load failed or no file for slot %d; using fresh save."),
|
|
338
|
+
ActiveSlotIndex);
|
|
339
|
+
CurrentSave = Cast<UMyGameSaveGame>(
|
|
340
|
+
UGameplayStatics::CreateSaveGameObject(UMyGameSaveGame::StaticClass()));
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
RunMigrations(CurrentSave);
|
|
344
|
+
ApplySaveData(CurrentSave);
|
|
345
|
+
|
|
346
|
+
OnLoadComplete.Broadcast(ActiveSlotIndex, SaveGame != nullptr);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
bool USaveManager::SyncLoadFromSlot(int32 SlotIndex)
|
|
350
|
+
{
|
|
351
|
+
// Use only at startup before the first frame renders
|
|
352
|
+
USaveGame* Loaded = UGameplayStatics::LoadGameFromSlot(
|
|
353
|
+
MakeSlotName(SlotIndex), USaveMetadataBank::UserIndex);
|
|
354
|
+
|
|
355
|
+
CurrentSave = Cast<UMyGameSaveGame>(Loaded);
|
|
356
|
+
if (!CurrentSave)
|
|
357
|
+
{
|
|
358
|
+
CurrentSave = Cast<UMyGameSaveGame>(
|
|
359
|
+
UGameplayStatics::CreateSaveGameObject(UMyGameSaveGame::StaticClass()));
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
RunMigrations(CurrentSave);
|
|
364
|
+
ApplySaveData(CurrentSave);
|
|
365
|
+
ActiveSlotIndex = SlotIndex;
|
|
366
|
+
return true;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
void USaveManager::DeleteSlot(int32 SlotIndex)
|
|
370
|
+
{
|
|
371
|
+
UGameplayStatics::DeleteGameInSlot(MakeSlotName(SlotIndex), USaveMetadataBank::UserIndex);
|
|
372
|
+
|
|
373
|
+
if (MetadataBank)
|
|
374
|
+
{
|
|
375
|
+
MetadataBank->ClearSlot(SlotIndex);
|
|
376
|
+
SaveMetadataBank();
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
void USaveManager::SaveMetadataBank()
|
|
381
|
+
{
|
|
382
|
+
UGameplayStatics::SaveGameToSlot(
|
|
383
|
+
MetadataBank, USaveMetadataBank::SlotName, USaveMetadataBank::UserIndex);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
FSaveSlotInfo USaveManager::BuildSlotInfo(int32 SlotIndex, const UMyGameSaveGame* Save) const
|
|
387
|
+
{
|
|
388
|
+
FSaveSlotInfo Info;
|
|
389
|
+
Info.SlotIndex = SlotIndex;
|
|
390
|
+
Info.bIsValid = true;
|
|
391
|
+
Info.LastSaveTime = FDateTime::Now();
|
|
392
|
+
|
|
393
|
+
if (Save)
|
|
394
|
+
{
|
|
395
|
+
Info.PlayerDisplayName = Save->PlayerDisplayName;
|
|
396
|
+
Info.PlayerLevel = Save->PlayerLevel;
|
|
397
|
+
Info.TotalPlayTimeSeconds = Save->TotalPlayTimeSeconds;
|
|
398
|
+
// MapName from world:
|
|
399
|
+
if (UWorld* World = GetGameInstance() ? GetGameInstance()->GetWorld() : nullptr)
|
|
400
|
+
{
|
|
401
|
+
Info.MapName = World->GetMapName();
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return Info;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
void USaveManager::StartAutoSave(int32 SlotIndex, float IntervalSeconds)
|
|
409
|
+
{
|
|
410
|
+
AutoSaveSlotIndex = SlotIndex;
|
|
411
|
+
if (UWorld* World = GetGameInstance()->GetWorld())
|
|
412
|
+
{
|
|
413
|
+
World->GetTimerManager().SetTimer(
|
|
414
|
+
AutoSaveTimerHandle,
|
|
415
|
+
FTimerDelegate::CreateUObject(this, &USaveManager::AsyncSaveToSlot, SlotIndex),
|
|
416
|
+
IntervalSeconds,
|
|
417
|
+
/*bLoop=*/true);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
void USaveManager::StopAutoSave()
|
|
422
|
+
{
|
|
423
|
+
if (UWorld* World = GetGameInstance() ? GetGameInstance()->GetWorld() : nullptr)
|
|
424
|
+
{
|
|
425
|
+
World->GetTimerManager().ClearTimer(AutoSaveTimerHandle);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
---
|
|
431
|
+
|
|
432
|
+
## 3. Migration Pipeline
|
|
433
|
+
|
|
434
|
+
Centralize all migration logic so it runs consistently on every load path (sync and async):
|
|
435
|
+
|
|
436
|
+
```cpp
|
|
437
|
+
// Add to SaveManager or to UMyGameSaveGame
|
|
438
|
+
|
|
439
|
+
// Version constants
|
|
440
|
+
namespace ESaveVersion
|
|
441
|
+
{
|
|
442
|
+
enum Type : int32
|
|
443
|
+
{
|
|
444
|
+
Initial = 0,
|
|
445
|
+
AddedInventory = 1,
|
|
446
|
+
AddedAbilitySaveData = 2,
|
|
447
|
+
AddedPlayTime = 3,
|
|
448
|
+
SoftRefForWeapon = 4,
|
|
449
|
+
|
|
450
|
+
VersionPlusOne,
|
|
451
|
+
Latest = VersionPlusOne - 1
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
void USaveManager::RunMigrations(UMyGameSaveGame* Save)
|
|
456
|
+
{
|
|
457
|
+
if (!Save) { return; }
|
|
458
|
+
|
|
459
|
+
const int32 LoadedVersion = Save->SaveVersion;
|
|
460
|
+
|
|
461
|
+
if (LoadedVersion == ESaveVersion::Latest)
|
|
462
|
+
{
|
|
463
|
+
return; // Already up to date, fast path
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
UE_LOG(LogTemp, Log, TEXT("Migrating save from version %d to %d"),
|
|
467
|
+
LoadedVersion, (int32)ESaveVersion::Latest);
|
|
468
|
+
|
|
469
|
+
if (LoadedVersion < ESaveVersion::AddedInventory)
|
|
470
|
+
{
|
|
471
|
+
// No inventory existed; leave InventoryItems empty (already default)
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (LoadedVersion < ESaveVersion::AddedAbilitySaveData)
|
|
475
|
+
{
|
|
476
|
+
// Ability data didn't exist; initialize defaults
|
|
477
|
+
Save->SavedAbilities.Reset();
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (LoadedVersion < ESaveVersion::AddedPlayTime)
|
|
481
|
+
{
|
|
482
|
+
// Can't recover old play time; set to 0
|
|
483
|
+
Save->TotalPlayTimeSeconds = 0.f;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (LoadedVersion < ESaveVersion::SoftRefForWeapon)
|
|
487
|
+
{
|
|
488
|
+
// Old save used a FName; new format uses FSoftObjectPath.
|
|
489
|
+
// If OldWeaponID_Deprecated was populated, convert it.
|
|
490
|
+
// Save->LastEquippedWeaponPath = ResolveWeaponIDToPath(Save->OldWeaponID_Deprecated);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Stamp new version
|
|
494
|
+
Save->SaveVersion = ESaveVersion::Latest;
|
|
495
|
+
}
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
---
|
|
499
|
+
|
|
500
|
+
## 4. Binary Blob Pattern (FArchive Inside USaveGame)
|
|
501
|
+
|
|
502
|
+
For complex subsystem data that warrants manual layout control, store it as `TArray<uint8>` inside the `USaveGame` and serialize/deserialize with `FMemoryWriter`/`FMemoryReader`:
|
|
503
|
+
|
|
504
|
+
```cpp
|
|
505
|
+
// In UMyGameSaveGame:
|
|
506
|
+
UPROPERTY(SaveGame)
|
|
507
|
+
TArray<uint8> QuestSystemBlob; // Manually serialized binary payload
|
|
508
|
+
|
|
509
|
+
// In your quest subsystem:
|
|
510
|
+
bool UQuestSubsystem::SerializeToBlob(TArray<uint8>& OutBlob) const
|
|
511
|
+
{
|
|
512
|
+
FMemoryWriter Writer(OutBlob, /*bIsPersistent=*/true);
|
|
513
|
+
|
|
514
|
+
int32 BlobVersion = 2;
|
|
515
|
+
Writer << BlobVersion;
|
|
516
|
+
|
|
517
|
+
int32 NumActiveQuests = ActiveQuests.Num();
|
|
518
|
+
Writer << NumActiveQuests;
|
|
519
|
+
|
|
520
|
+
for (const FQuestState& Quest : ActiveQuests)
|
|
521
|
+
{
|
|
522
|
+
FName QID = Quest.QuestID;
|
|
523
|
+
int32 Stage = Quest.CurrentStage;
|
|
524
|
+
Writer << QID;
|
|
525
|
+
Writer << Stage;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
return !Writer.IsError();
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
bool UQuestSubsystem::DeserializeFromBlob(const TArray<uint8>& Blob)
|
|
532
|
+
{
|
|
533
|
+
if (Blob.IsEmpty()) { return true; } // No quest data saved yet
|
|
534
|
+
|
|
535
|
+
FMemoryReader Reader(Blob, /*bIsPersistent=*/true);
|
|
536
|
+
|
|
537
|
+
int32 BlobVersion = 0;
|
|
538
|
+
Reader << BlobVersion;
|
|
539
|
+
|
|
540
|
+
if (BlobVersion < 1 || Reader.IsError())
|
|
541
|
+
{
|
|
542
|
+
UE_LOG(LogTemp, Error, TEXT("QuestSubsystem: Invalid blob version %d"), BlobVersion);
|
|
543
|
+
return false;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
int32 NumQuests = 0;
|
|
547
|
+
Reader << NumQuests;
|
|
548
|
+
|
|
549
|
+
ActiveQuests.Empty(NumQuests);
|
|
550
|
+
|
|
551
|
+
for (int32 i = 0; i < NumQuests; ++i)
|
|
552
|
+
{
|
|
553
|
+
FName QID;
|
|
554
|
+
int32 Stage;
|
|
555
|
+
Reader << QID;
|
|
556
|
+
Reader << Stage;
|
|
557
|
+
|
|
558
|
+
if (Reader.IsError()) { return false; }
|
|
559
|
+
|
|
560
|
+
FQuestState& Q = ActiveQuests.AddDefaulted_GetRef();
|
|
561
|
+
Q.QuestID = QID;
|
|
562
|
+
Q.CurrentStage = Stage;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
return !Reader.IsError();
|
|
566
|
+
}
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
---
|
|
570
|
+
|
|
571
|
+
## 5. Multi-User (Local Multiplayer) Pattern
|
|
572
|
+
|
|
573
|
+
Each local player gets their own save slot. Use `ULocalPlayerSaveGame` and pass the `ULocalPlayer` or `APlayerController`.
|
|
574
|
+
|
|
575
|
+
```cpp
|
|
576
|
+
// In your player controller or local player subsystem:
|
|
577
|
+
void AMyPlayerController::SavePlayerData()
|
|
578
|
+
{
|
|
579
|
+
if (!LocalPlayerSave)
|
|
580
|
+
{
|
|
581
|
+
UE_LOG(LogTemp, Warning, TEXT("No local player save — was it loaded?"));
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// Populate
|
|
586
|
+
LocalPlayerSave->AbilityLevels.Empty();
|
|
587
|
+
for (auto& [AbilityName, Level] : AbilityComponent->GetAllLevels())
|
|
588
|
+
{
|
|
589
|
+
LocalPlayerSave->AbilityLevels.Add(AbilityName, Level);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Async save — hooks HandlePostSave for result
|
|
593
|
+
LocalPlayerSave->AsyncSaveGameToSlotForLocalPlayer();
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
void AMyPlayerController::LoadPlayerData()
|
|
597
|
+
{
|
|
598
|
+
// Async load or create a fresh save if none exists
|
|
599
|
+
bool bScheduled = ULocalPlayerSaveGame::AsyncLoadOrCreateSaveGameForLocalPlayer(
|
|
600
|
+
UMyLocalPlayerSave::StaticClass(),
|
|
601
|
+
this, // APlayerController* LocalPlayerController
|
|
602
|
+
TEXT("PlayerData"),
|
|
603
|
+
FOnLocalPlayerSaveGameLoadedNative::CreateUObject(
|
|
604
|
+
this, &AMyPlayerController::OnPlayerDataLoaded));
|
|
605
|
+
|
|
606
|
+
if (!bScheduled)
|
|
607
|
+
{
|
|
608
|
+
UE_LOG(LogTemp, Error, TEXT("Failed to schedule async load for local player save."));
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
void AMyPlayerController::OnPlayerDataLoaded(ULocalPlayerSaveGame* SaveGame)
|
|
613
|
+
{
|
|
614
|
+
LocalPlayerSave = Cast<UMyLocalPlayerSave>(SaveGame);
|
|
615
|
+
if (!LocalPlayerSave)
|
|
616
|
+
{
|
|
617
|
+
UE_LOG(LogTemp, Error, TEXT("LocalPlayerSave cast failed after load."));
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// Apply
|
|
622
|
+
for (auto& [AbilityName, Level] : LocalPlayerSave->AbilityLevels)
|
|
623
|
+
{
|
|
624
|
+
AbilityComponent->SetAbilityLevel(AbilityName, Level);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
```
|
|
628
|
+
|
|
629
|
+
For the save slot name per player, derive it from the player index or platform user ID so each user has isolated data:
|
|
630
|
+
|
|
631
|
+
```cpp
|
|
632
|
+
FString GetPlayerSlotName(const APlayerController* PC)
|
|
633
|
+
{
|
|
634
|
+
if (!PC) { return TEXT("PlayerData_0"); }
|
|
635
|
+
if (const ULocalPlayer* LP = PC->GetLocalPlayer())
|
|
636
|
+
{
|
|
637
|
+
return FString::Printf(TEXT("PlayerData_%d"), LP->GetControllerId());
|
|
638
|
+
}
|
|
639
|
+
return TEXT("PlayerData_0");
|
|
640
|
+
}
|
|
641
|
+
```
|
|
642
|
+
|
|
643
|
+
---
|
|
644
|
+
|
|
645
|
+
## 6. Platform Considerations
|
|
646
|
+
|
|
647
|
+
| Platform | Key Difference |
|
|
648
|
+
|---|---|
|
|
649
|
+
| PC / Mac | `UGameplayStatics` writes to `Saved/SaveGames/`. No user index required in most cases. |
|
|
650
|
+
| Console (PS5, Xbox) | User index maps to a controller/account. Always use `GetPlatformUserIndex()` from `ULocalPlayerSaveGame`. |
|
|
651
|
+
| Mobile (iOS, Android) | Save path is app-specific sandbox; `UGameplayStatics` handles it. Watch for size limits. |
|
|
652
|
+
| PIE | Saves go to the project's `Saved/SaveGames/`. Clean between test runs with `DeleteGameInSlot`. |
|
|
653
|
+
|
|
654
|
+
To get the platform user index correctly:
|
|
655
|
+
|
|
656
|
+
```cpp
|
|
657
|
+
int32 GetUserIndex(const APlayerController* PC)
|
|
658
|
+
{
|
|
659
|
+
if (const ULocalPlayer* LP = PC ? PC->GetLocalPlayer() : nullptr)
|
|
660
|
+
{
|
|
661
|
+
return LP->GetPlatformUserIndex(); // returns int32 directly
|
|
662
|
+
}
|
|
663
|
+
return 0;
|
|
664
|
+
}
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
---
|
|
668
|
+
|
|
669
|
+
## 7. Save File Path Reference (Debug Only)
|
|
670
|
+
|
|
671
|
+
Never rely on these paths in shipping code. They are useful for wiping saves in automation tests or editor utilities:
|
|
672
|
+
|
|
673
|
+
```cpp
|
|
674
|
+
FString GetSaveGamePath(const FString& SlotName, int32 UserIndex)
|
|
675
|
+
{
|
|
676
|
+
// Only valid on PC / development. Do not use in shipping builds.
|
|
677
|
+
ISaveGameSystem* SaveSystem = IPlatformFeaturesModule::Get().GetSaveGameSystem();
|
|
678
|
+
// ISaveGameSystem does not expose paths directly; use FPaths for debug only:
|
|
679
|
+
return FPaths::ProjectSavedDir()
|
|
680
|
+
/ TEXT("SaveGames")
|
|
681
|
+
/ FString::Printf(TEXT("%s.sav"), *SlotName);
|
|
682
|
+
}
|
|
683
|
+
```
|
|
684
|
+
|
|
685
|
+
---
|
|
686
|
+
|
|
687
|
+
## 8. Save System Registration in GameInstance
|
|
688
|
+
|
|
689
|
+
Register the save manager and trigger initial metadata load in `UGameInstance::Init()`:
|
|
690
|
+
|
|
691
|
+
```cpp
|
|
692
|
+
// MyGameInstance.cpp
|
|
693
|
+
void UMyGameInstance::Init()
|
|
694
|
+
{
|
|
695
|
+
Super::Init();
|
|
696
|
+
// Subsystem is created automatically; access it via GetSubsystem<>
|
|
697
|
+
// No manual construction needed.
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// In any game code:
|
|
701
|
+
USaveManager* SM = GetGameInstance()->GetSubsystem<USaveManager>();
|
|
702
|
+
SM->AsyncSaveToSlot(0);
|
|
703
|
+
```
|
|
704
|
+
|
|
705
|
+
Because `USaveManager` is a `UGameInstanceSubsystem`, UE creates it automatically when the `GameInstance` initializes. `Initialize()` on the subsystem fires before the first level loads, making it the correct place to load the metadata bank.
|