Fome further save load work

This commit is contained in:
Michal Pikulski
2025-11-03 10:08:44 +01:00
parent f0897c3e4a
commit cb7889b257
11 changed files with 1817 additions and 64 deletions

View File

@@ -3,6 +3,7 @@ using System.Collections.Generic;
using UnityEngine;
using Interactions;
using Bootstrap;
using Core.SaveLoad;
namespace Core
{
@@ -59,6 +60,7 @@ namespace Core
{
// Subscribe to scene load completed so we can clear registrations when scenes change
SceneManagerService.Instance.SceneLoadStarted += OnSceneLoadStarted;
Logging.Debug("[ItemManager] Subscribed to SceneManagerService events");
}

View File

@@ -3,6 +3,7 @@ using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using AppleHills.Core.Settings;
using Bootstrap;
using UnityEngine;
@@ -55,12 +56,18 @@ namespace Core.SaveLoad
#if UNITY_EDITOR
OnSceneLoadCompleted("RestoreInEditor");
#endif
Load();
if (DeveloperSettingsProvider.Instance.GetSettings<DebugSettings>().useSaveLoadSystem)
{
Load();
}
}
private void OnApplicationQuit()
{
Save();
if (DeveloperSettingsProvider.Instance.GetSettings<DebugSettings>().useSaveLoadSystem)
{
Save();
}
}
private void InitializePostBoot()

View File

@@ -28,6 +28,10 @@ namespace AppleHills.Core.Settings
[Tooltip("Should Time.timeScale be set to 0 when the game is paused")]
[SerializeField] public bool pauseTimeOnPauseGame = true;
[Header("Save Load Options")]
[Tooltip("Should use save laod system?")]
[SerializeField] public bool useSaveLoadSystem = true;
[Header("Logging Options")]
[Tooltip("Logging level for bootstrap services")]
[SerializeField] public LogVerbosity bootstrapLogVerbosity = LogVerbosity.Warning;

View File

@@ -1,7 +1,8 @@
using Input;
using UnityEngine;
using System;
using System.Linq; // added for Action<T>
using System.Linq;
using Bootstrap; // added for Action<T>
using Core; // register with ItemManager
namespace Interactions
@@ -13,6 +14,7 @@ namespace Interactions
public class PickupSaveData
{
public bool isPickedUp;
public bool wasHeldByFollower; // Track if held by follower for bilateral restoration
public Vector3 worldPosition;
public Quaternion worldRotation;
public bool isActive;
@@ -52,11 +54,13 @@ namespace Interactions
{
base.Start(); // Register with save system
// Don't register with ItemManager if already picked up (restored from save)
if (!IsPickedUp)
// Always register with ItemManager, even if picked up
// This allows the save/load system to find held items when restoring state
BootCompletionService.RegisterInitAction(() =>
{
ItemManager.Instance?.RegisterPickup(this);
}
});
}
/// <summary>
@@ -162,9 +166,13 @@ namespace Interactions
protected override object GetSerializableState()
{
// Check if this pickup is currently held by the follower
bool isHeldByFollower = IsPickedUp && !gameObject.activeSelf && transform.parent != null;
return new PickupSaveData
{
isPickedUp = this.IsPickedUp,
wasHeldByFollower = isHeldByFollower,
worldPosition = transform.position,
worldRotation = transform.rotation,
isActive = gameObject.activeSelf
@@ -187,6 +195,18 @@ namespace Interactions
{
// Hide the pickup if it was already picked up
gameObject.SetActive(false);
// If this was held by the follower, try bilateral restoration
if (data.wasHeldByFollower)
{
// Try to give this pickup to the follower
// This might succeed or fail depending on timing
var follower = FollowerController.FindInstance();
if (follower != null)
{
follower.TryClaimHeldItem(this);
}
}
}
else
{

View File

@@ -14,6 +14,15 @@ namespace Interactions
[SerializeField]
[Tooltip("Optional custom save ID. If empty, will auto-generate from hierarchy path.")]
private string customSaveId = "";
/// <summary>
/// Sets a custom save ID for this interactable.
/// Used when spawning dynamic objects that need stable save IDs.
/// </summary>
public void SetCustomSaveId(string saveId)
{
customSaveId = saveId;
}
/// <summary>
/// Flag to indicate we're currently restoring from save data.
@@ -258,4 +267,3 @@ namespace Interactions
#endregion
}

View File

@@ -100,6 +100,8 @@ public class FollowerController : MonoBehaviour, ISaveParticipant
// Save system tracking
private bool hasBeenRestored;
private bool _hasRestoredHeldItem; // Track if held item restoration completed
private string _expectedHeldItemSaveId; // Expected saveId during restoration
void Awake()
{
@@ -785,10 +787,11 @@ public class FollowerController : MonoBehaviour, ISaveParticipant
transform.position = saveData.worldPosition;
transform.rotation = saveData.worldRotation;
// Restore held item if any
// Try bilateral restoration of held item
if (!string.IsNullOrEmpty(saveData.heldItemSaveId))
{
RestoreHeldItem(saveData.heldItemSaveId, saveData.heldItemDataAssetPath);
_expectedHeldItemSaveId = saveData.heldItemSaveId;
TryRestoreHeldItem(saveData.heldItemSaveId, saveData.heldItemDataAssetPath);
}
hasBeenRestored = true;
@@ -801,48 +804,110 @@ public class FollowerController : MonoBehaviour, ISaveParticipant
}
}
private void RestoreHeldItem(string heldItemSaveId, string heldItemDataAssetPath)
/// <summary>
/// Bilateral restoration: Follower tries to find and claim the held item.
/// If pickup doesn't exist yet, it will try to claim us when it restores.
/// </summary>
private void TryRestoreHeldItem(string heldItemSaveId, string heldItemDataAssetPath)
{
// Try to find the item by its save ID using ItemManager
GameObject heldObject = ItemManager.Instance?.FindPickupBySaveId(heldItemSaveId);
if (heldObject == null)
if (_hasRestoredHeldItem)
{
Logging.Warning($"[FollowerController] Could not find held item with save ID: {heldItemSaveId}");
Logging.Debug("[FollowerController] Held item already restored");
return;
}
// Get the item data
PickupItemData heldData = null;
#if UNITY_EDITOR
if (!string.IsNullOrEmpty(heldItemDataAssetPath))
// Try to find the pickup immediately
GameObject heldObject = ItemManager.Instance?.FindPickupBySaveId(heldItemSaveId);
if (heldObject == null)
{
heldData = UnityEditor.AssetDatabase.LoadAssetAtPath<PickupItemData>(heldItemDataAssetPath);
Logging.Debug($"[FollowerController] Held item not found yet: {heldItemSaveId}, waiting for pickup to restore");
return; // Pickup will find us when it restores
}
var pickup = heldObject.GetComponent<Pickup>();
if (pickup == null)
{
Logging.Warning($"[FollowerController] Found object but no Pickup component: {heldItemSaveId}");
return;
}
// Claim the pickup
TakeOwnership(pickup, heldItemDataAssetPath);
}
/// <summary>
/// Bilateral restoration entry point: Pickup calls this to offer itself to the Follower.
/// Returns true if claim was successful, false if Follower already has an item or wrong pickup.
/// </summary>
public bool TryClaimHeldItem(Pickup pickup)
{
if (pickup == null)
return false;
if (_hasRestoredHeldItem)
{
Logging.Debug("[FollowerController] Already restored held item, rejecting claim");
return false;
}
// Verify this is the expected pickup
if (pickup is SaveableInteractable saveable)
{
if (saveable.GetSaveId() != _expectedHeldItemSaveId)
{
Logging.Warning($"[FollowerController] Pickup tried to claim but saveId mismatch: {saveable.GetSaveId()} != {_expectedHeldItemSaveId}");
return false;
}
}
// Claim the pickup
TakeOwnership(pickup, null);
return true;
}
/// <summary>
/// Takes ownership of a pickup during restoration. Called by both restoration paths.
/// </summary>
private void TakeOwnership(Pickup pickup, string itemDataAssetPath)
{
if (_hasRestoredHeldItem)
return; // Already claimed
// Get the item data
PickupItemData heldData = pickup.itemData;
#if UNITY_EDITOR
// Try loading from asset path if available and pickup doesn't have data
if (heldData == null && !string.IsNullOrEmpty(itemDataAssetPath))
{
heldData = UnityEditor.AssetDatabase.LoadAssetAtPath<PickupItemData>(itemDataAssetPath);
}
#endif
if (heldData == null)
{
var pickup = heldObject.GetComponent<Pickup>();
if (pickup != null)
{
heldData = pickup.itemData;
}
Logging.Warning($"[FollowerController] Could not get item data for pickup: {pickup.gameObject.name}");
return;
}
// Restore the held item state
if (heldData != null)
{
var pickup = heldObject.GetComponent<Pickup>();
if (pickup != null)
{
_cachedPickupObject = heldObject;
_cachedPickupObject.SetActive(false); // Held items should be hidden
SetHeldItem(heldData, pickup.iconRenderer);
_animator.SetBool("IsCarrying", true);
Logging.Debug($"[FollowerController] Restored held item: {heldData.itemName}");
}
}
// Setup the held item
_cachedPickupObject = pickup.gameObject;
_cachedPickupObject.SetActive(false); // Held items should be hidden
SetHeldItem(heldData, pickup.iconRenderer);
_animator.SetBool("IsCarrying", true);
_hasRestoredHeldItem = true;
Logging.Debug($"[FollowerController] Successfully restored held item: {heldData.itemName}");
}
/// <summary>
/// Static method to find the FollowerController instance in the scene.
/// Used by Pickup during bilateral restoration.
/// </summary>
public static FollowerController FindInstance()
{
return FindObjectOfType<FollowerController>();
}
#endregion ISaveParticipant Implementation

View File

@@ -7,16 +7,28 @@ using UnityEngine.SceneManagement;
using AppleHills.Core.Settings;
using Bootstrap;
using Core;
using Core.SaveLoad;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;
using Utils;
namespace PuzzleS
{
/// <summary>
/// Save data structure for puzzle progress
/// </summary>
[Serializable]
public class PuzzleSaveData
{
public string levelId;
public List<string> completedStepIds;
public List<string> unlockedStepIds;
}
/// <summary>
/// Manages puzzle step registration, dependency management, and step completion for the puzzle system.
/// </summary>
public class PuzzleManager : MonoBehaviour
public class PuzzleManager : MonoBehaviour, ISaveParticipant
{
private static PuzzleManager _instance;
@@ -48,10 +60,23 @@ namespace PuzzleS
public event Action<PuzzleLevelDataSO> OnLevelDataLoaded;
public event Action<PuzzleLevelDataSO> OnAllPuzzlesComplete;
private HashSet<PuzzleStepSO> _completedSteps = new HashSet<PuzzleStepSO>();
private HashSet<PuzzleStepSO> _unlockedSteps = new HashSet<PuzzleStepSO>();
// Save/Load state tracking - string-based for timing independence
private HashSet<string> _completedSteps = new HashSet<string>();
private HashSet<string> _unlockedSteps = new HashSet<string>();
// Save/Load restoration tracking
private bool _isDataRestored = false;
private bool _hasBeenRestored = false;
private List<ObjectiveStepBehaviour> _pendingRegistrations = new List<ObjectiveStepBehaviour>();
// Registration for ObjectiveStepBehaviour
private Dictionary<PuzzleStepSO, ObjectiveStepBehaviour> _stepBehaviours = new Dictionary<PuzzleStepSO, ObjectiveStepBehaviour>();
/// <summary>
/// Returns true if this participant has already had its state restored.
/// Used by SaveLoadManager to prevent double-restoration.
/// </summary>
public bool HasBeenRestored => _hasBeenRestored;
void Awake()
{
@@ -70,6 +95,13 @@ namespace PuzzleS
SceneManagerService.Instance.SceneLoadCompleted += OnSceneLoadCompleted;
SceneManagerService.Instance.SceneLoadStarted += OnSceneLoadStarted;
// Register with save/load system
BootCompletionService.RegisterInitAction(() =>
{
SaveLoadManager.Instance.RegisterParticipant(this);
Logging.Debug("[PuzzleManager] Registered with SaveLoadManager");
});
// Find player transform
_playerTransform = GameObject.FindGameObjectWithTag("Player")?.transform;
@@ -96,6 +128,11 @@ namespace PuzzleS
SceneManagerService.Instance.SceneLoadStarted -= OnSceneLoadStarted;
}
// Unregister from save/load system
SaveLoadManager.Instance.UnregisterParticipant(GetSaveId());
Logging.Debug("[PuzzleManager] Unregistered from SaveLoadManager");
// Release addressable handle if needed
if (_levelDataLoadOperation.IsValid())
{
@@ -296,12 +333,20 @@ namespace PuzzleS
_stepBehaviours.Add(behaviour.stepData, behaviour);
Logging.Debug($"[Puzzles] Registered step: {behaviour.stepData.stepId} on {behaviour.gameObject.name}");
// Only update state if data is already loaded
if (_isDataLoaded && _currentLevelData != null)
// Use pending registration pattern for save/load timing independence
if (_isDataRestored)
{
// Data already restored - update immediately
UpdateStepState(behaviour);
}
// Otherwise, the state will be updated when data loads in UpdateAllRegisteredBehaviors
else
{
// Data not restored yet - add to pending queue
if (!_pendingRegistrations.Contains(behaviour))
{
_pendingRegistrations.Add(behaviour);
}
}
}
}
@@ -313,11 +358,11 @@ namespace PuzzleS
if (behaviour?.stepData == null) return;
// If step is already completed, ignore
if (_completedSteps.Contains(behaviour.stepData))
if (_completedSteps.Contains(behaviour.stepData.stepId))
return;
// If step is already unlocked, update the behaviour
if (_unlockedSteps.Contains(behaviour.stepData))
if (_unlockedSteps.Contains(behaviour.stepData.stepId))
{
behaviour.UnlockStep();
}
@@ -349,6 +394,13 @@ namespace PuzzleS
{
if (_currentLevelData == null) return;
// Don't unlock initial steps if we've restored from save
if (_isDataRestored)
{
Logging.Debug("[Puzzles] Skipping UnlockInitialSteps - data was restored from save");
return;
}
// Unlock initial steps
foreach (var step in _currentLevelData.initialSteps)
{
@@ -364,10 +416,10 @@ namespace PuzzleS
/// <param name="step">The completed step.</param>
public void MarkPuzzleStepCompleted(PuzzleStepSO step)
{
if (_completedSteps.Contains(step)) return;
if (_completedSteps.Contains(step.stepId)) return;
if (_currentLevelData == null) return;
_completedSteps.Add(step);
_completedSteps.Add(step.stepId);
Logging.Debug($"[Puzzles] Step completed: {step.stepId}");
// Broadcast completion
@@ -408,18 +460,11 @@ namespace PuzzleS
{
foreach (var depId in dependencies)
{
// Find the dependency step
bool dependencyMet = false;
foreach (var completedStep in _completedSteps)
// Check if dependency is in completed steps
if (!_completedSteps.Contains(depId))
{
if (completedStep.stepId == depId)
{
dependencyMet = true;
break;
}
return false;
}
if (!dependencyMet) return false;
}
}
@@ -432,8 +477,8 @@ namespace PuzzleS
/// <param name="step">The step to unlock.</param>
private void UnlockStep(PuzzleStepSO step)
{
if (_unlockedSteps.Contains(step)) return;
_unlockedSteps.Add(step);
if (_unlockedSteps.Contains(step.stepId)) return;
_unlockedSteps.Add(step.stepId);
if (_stepBehaviours.TryGetValue(step, out var behaviour))
{
@@ -452,7 +497,18 @@ namespace PuzzleS
{
if (_currentLevelData == null) return;
if (_currentLevelData.IsLevelComplete(_completedSteps))
// Check if all steps are completed
bool allComplete = true;
foreach (var step in _currentLevelData.allSteps)
{
if (step != null && !_completedSteps.Contains(step.stepId))
{
allComplete = false;
break;
}
}
if (allComplete)
{
Logging.Debug("[Puzzles] All puzzles complete! Level finished.");
@@ -466,7 +522,7 @@ namespace PuzzleS
/// </summary>
public bool IsStepUnlocked(PuzzleStepSO step)
{
return _unlockedSteps.Contains(step);
return step != null && _unlockedSteps.Contains(step.stepId);
}
/// <summary>
@@ -476,7 +532,7 @@ namespace PuzzleS
/// <returns>True if the step has been completed, false otherwise</returns>
public bool IsPuzzleStepCompleted(string stepId)
{
return _completedSteps.Any(step => step.stepId == stepId);
return _completedSteps.Contains(stepId); // O(1) lookup!
}
/// <summary>
@@ -495,5 +551,89 @@ namespace PuzzleS
{
return _isDataLoaded;
}
#region ISaveParticipant Implementation
/// <summary>
/// Get unique save ID for this puzzle manager instance
/// </summary>
public string GetSaveId()
{
string sceneName = SceneManager.GetActiveScene().name;
return $"{sceneName}/PuzzleManager";
}
/// <summary>
/// Serialize current puzzle state to JSON
/// </summary>
public string SerializeState()
{
if (_currentLevelData == null)
{
Logging.Warning("[PuzzleManager] Cannot serialize state - no level data loaded");
return "{}";
}
var saveData = new PuzzleSaveData
{
levelId = _currentLevelData.levelId,
completedStepIds = _completedSteps.ToList(),
unlockedStepIds = _unlockedSteps.ToList()
};
string json = JsonUtility.ToJson(saveData);
Logging.Debug($"[PuzzleManager] Serialized puzzle state: {_completedSteps.Count} completed, {_unlockedSteps.Count} unlocked");
return json;
}
/// <summary>
/// Restore puzzle state from serialized JSON data
/// </summary>
public void RestoreState(string data)
{
if (string.IsNullOrEmpty(data) || data == "{}")
{
Logging.Debug("[PuzzleManager] No puzzle save data to restore");
_isDataRestored = true;
_hasBeenRestored = true;
return;
}
try
{
var saveData = JsonUtility.FromJson<PuzzleSaveData>(data);
if (saveData == null)
{
Logging.Warning("[PuzzleManager] Failed to deserialize puzzle save data");
_isDataRestored = true;
_hasBeenRestored = true;
return;
}
// Restore step IDs directly - no timing dependency on level data!
_completedSteps = new HashSet<string>(saveData.completedStepIds ?? new List<string>());
_unlockedSteps = new HashSet<string>(saveData.unlockedStepIds ?? new List<string>());
_isDataRestored = true;
_hasBeenRestored = true;
Logging.Debug($"[PuzzleManager] Restored puzzle state: {_completedSteps.Count} completed, {_unlockedSteps.Count} unlocked steps");
// Update any behaviors that registered before RestoreState was called
foreach (var behaviour in _pendingRegistrations)
{
UpdateStepState(behaviour);
}
_pendingRegistrations.Clear();
}
catch (System.Exception e)
{
Debug.LogError($"[PuzzleManager] Error restoring puzzle state: {e.Message}");
_isDataRestored = true;
_hasBeenRestored = true;
}
}
#endregion
}
}