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:
2025-11-03 10:12:51 +00:00
parent d317fffad7
commit 011901eb8f
148 changed files with 969503 additions and 10746 deletions

View File

@@ -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)
{

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,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
}
}