Refactoring of the interaction system and preliminary integration of save/load functionality across the game. (#44)
### Interactables Architecture Refactor - Converted composition to inheritance, moved from component-based to class-based interactables. No more requirement for chain of "Interactable -> Item" etc. - Created `InteractableBase` abstract base class with common functionality that replaces the old component - Specialized child classes: `Pickup`, `ItemSlot`, `LevelSwitch`, `MinigameSwitch`, `CombinationItem`, `OneClickInteraction` are now children classes - Light updates to the interactable inspector, moved some things arround, added collapsible inspector sections in the UI for better editor experience ### State Machine Integration - Custom `AppleMachine` inheritong from Pixelplacement's StateMachine which implements our own interface for saving, easy place for future improvements - Replaced all previous StateMachines by `AppleMachine` - Custom `AppleState` extends from default `State`. Added serialization, split state logic into "EnterState", "RestoreState", "ExitState" allowing for separate logic when triggering in-game vs loading game - Restores directly to target state without triggering transitional logic - Migration tool converts existing instances ### Prefab Organization - Saved changes from scenes into prefabs - Cleaned up duplicated components, confusing prefabs hierarchies - Created prefab variants where possible - Consolidated Environment prefabs and moved them out of Placeholders subfolder into main Environment folder - Organized item prefabs from PrefabsPLACEHOLDER into proper Items folder - Updated prefab references - All scene references updated to new locations - Removed placeholder files from Characters, Levels, UI, and Minigames folders ### Scene Updates - Quarry scene with major updates - Saved multiple working versions (Quarry, Quarry_Fixed, Quarry_OLD) - Added proper lighting data - Updated all interactable components to new architecture ### Minor editor tools - New tool for testing cards from an editor window (no in-scene object required) - Updated Interactable Inspector - New debug option to opt in-and-out of the save/load system - Tooling for easier migration Co-authored-by: Michal Pikulski <michal.a.pikulski@gmail.com> Reviewed-on: #44
This commit is contained in:
@@ -8,7 +8,7 @@ namespace PuzzleS
|
||||
/// <summary>
|
||||
/// Manages the state and interactions for a single puzzle step, including unlock/lock logic and event handling.
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(Interactable))]
|
||||
[RequireComponent(typeof(InteractableBase))]
|
||||
public class ObjectiveStepBehaviour : MonoBehaviour, IPuzzlePrompt
|
||||
{
|
||||
/// <summary>
|
||||
@@ -20,7 +20,7 @@ namespace PuzzleS
|
||||
[SerializeField] private GameObject puzzleIndicator;
|
||||
[SerializeField] private bool drawPromptRangeGizmo = true;
|
||||
|
||||
private Interactable _interactable;
|
||||
private InteractableBase _interactable;
|
||||
private bool _isUnlocked = false;
|
||||
private bool _isCompleted = false;
|
||||
private IPuzzlePrompt _indicator;
|
||||
@@ -33,7 +33,7 @@ namespace PuzzleS
|
||||
|
||||
void Awake()
|
||||
{
|
||||
_interactable = GetComponent<Interactable>();
|
||||
_interactable = GetComponent<InteractableBase>();
|
||||
|
||||
// Initialize the indicator if it exists, but ensure it's hidden initially
|
||||
if (puzzleIndicator != null)
|
||||
@@ -60,7 +60,7 @@ namespace PuzzleS
|
||||
void OnEnable()
|
||||
{
|
||||
if (_interactable == null)
|
||||
_interactable = GetComponent<Interactable>();
|
||||
_interactable = GetComponent<InteractableBase>();
|
||||
|
||||
if (_interactable != null)
|
||||
{
|
||||
|
||||
@@ -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,26 @@ 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>();
|
||||
|
||||
// Track pending unlocks for steps that were unlocked before their behavior registered
|
||||
private HashSet<string> _pendingUnlocks = new HashSet<string>();
|
||||
|
||||
/// <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 +98,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 +131,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 +336,27 @@ 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)
|
||||
// Check if this step has a pending unlock
|
||||
if (_pendingUnlocks.Contains(behaviour.stepData.stepId))
|
||||
{
|
||||
// Step was unlocked before behavior registered - unlock it now!
|
||||
behaviour.UnlockStep();
|
||||
_pendingUnlocks.Remove(behaviour.stepData.stepId);
|
||||
Logging.Debug($"[Puzzles] Fulfilled pending unlock for step: {behaviour.stepData.stepId}");
|
||||
}
|
||||
else 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 +368,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 +404,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 +426,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 +470,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,14 +487,21 @@ 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))
|
||||
{
|
||||
// Behavior exists - unlock it immediately
|
||||
behaviour.UnlockStep();
|
||||
Logging.Debug($"[Puzzles] Step unlocked: {step.stepId}");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Behavior hasn't registered yet - add to pending unlocks
|
||||
_pendingUnlocks.Add(step.stepId);
|
||||
Logging.Debug($"[Puzzles] Step unlocked but behavior not registered yet, added to pending: {step.stepId}");
|
||||
}
|
||||
Logging.Debug($"[Puzzles] Step unlocked: {step.stepId}");
|
||||
|
||||
// Broadcast unlock
|
||||
OnStepUnlocked?.Invoke(step);
|
||||
@@ -452,7 +514,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 +539,7 @@ namespace PuzzleS
|
||||
/// </summary>
|
||||
public bool IsStepUnlocked(PuzzleStepSO step)
|
||||
{
|
||||
return _unlockedSteps.Contains(step);
|
||||
return step != null && _unlockedSteps.Contains(step.stepId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -476,7 +549,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 +568,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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user