Working gardener saveable behavior tree

This commit is contained in:
Michal Pikulski
2025-11-03 01:34:34 +01:00
parent 14416e141e
commit 3b7bc76757
23 changed files with 2373 additions and 61 deletions

View File

@@ -2,6 +2,7 @@
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Bootstrap;
using UnityEngine;
@@ -26,6 +27,9 @@ namespace Core.SaveLoad
// Participant registry
private readonly Dictionary<string, ISaveParticipant> participants = new Dictionary<string, ISaveParticipant>();
// Pending participants (registered during restoration)
private readonly List<ISaveParticipant> pendingParticipants = new List<ISaveParticipant>();
// State
public bool IsSaving { get; private set; }
@@ -48,7 +52,10 @@ namespace Core.SaveLoad
private void Start()
{
#if UNITY_EDITOR
OnSceneLoadCompleted("RestoreInEditor");
#endif
Load();
}
private void OnApplicationQuit()
@@ -67,12 +74,6 @@ namespace Core.SaveLoad
SceneManagerService.Instance.SceneUnloadStarted += OnSceneUnloadStarted;
Logging.Debug("[SaveLoadManager] Subscribed to SceneManagerService events");
}
#if UNITY_EDITOR
OnSceneLoadCompleted("RestoreInEditor");
#endif
Load();
}
void OnDestroy()
@@ -119,11 +120,20 @@ namespace Core.SaveLoad
participants[saveId] = participant;
Logging.Debug($"[SaveLoadManager] Registered participant: {saveId}");
// If we have save data loaded and we're not currently restoring, restore this participant's state immediately
// BUT only if the participant hasn't already been restored (prevents double-restoration when inactive objects become active)
if (IsSaveDataLoaded && !IsRestoringState && currentSaveData != null && !participant.HasBeenRestored)
// If we have save data loaded and the participant hasn't been restored yet
if (IsSaveDataLoaded && currentSaveData != null && !participant.HasBeenRestored)
{
RestoreParticipantState(participant);
if (IsRestoringState)
{
// We're currently restoring - queue this participant for later restoration
pendingParticipants.Add(participant);
Logging.Debug($"[SaveLoadManager] Queued participant for pending restoration: {saveId}");
}
else
{
// Not currently restoring - restore this participant's state immediately
RestoreParticipantState(participant);
}
}
}
@@ -229,6 +239,7 @@ namespace Core.SaveLoad
/// <summary>
/// Restores state for all currently registered participants.
/// Called after loading save data.
/// Uses pending queue to handle participants that register during restoration.
/// </summary>
private void RestoreAllParticipantStates()
{
@@ -238,7 +249,12 @@ namespace Core.SaveLoad
IsRestoringState = true;
int restoredCount = 0;
foreach (var kvp in participants)
// Clear pending queue at the start
pendingParticipants.Clear();
// Create a snapshot to avoid collection modification during iteration
// (RestoreState can trigger GameObject activation which can register new participants)
foreach (var kvp in participants.ToList())
{
string saveId = kvp.Key;
ISaveParticipant participant = kvp.Value;
@@ -260,8 +276,48 @@ namespace Core.SaveLoad
}
}
// Process pending participants that registered during the main restoration loop
const int maxPendingPasses = 10;
int pendingPass = 0;
int totalPendingRestored = 0;
while (pendingParticipants.Count > 0 && pendingPass < maxPendingPasses)
{
pendingPass++;
// Take snapshot of current pending list and clear the main list
// (restoring pending participants might add more pending participants)
var currentPending = new List<ISaveParticipant>(pendingParticipants);
pendingParticipants.Clear();
int passRestored = 0;
foreach (var participant in currentPending)
{
try
{
RestoreParticipantState(participant);
passRestored++;
totalPendingRestored++;
}
catch (Exception ex)
{
Logging.Warning($"[SaveLoadManager] Exception while restoring pending participant: {ex}");
}
}
Logging.Debug($"[SaveLoadManager] Pending pass {pendingPass}: Restored {passRestored} participants");
}
if (pendingParticipants.Count > 0)
{
Logging.Warning($"[SaveLoadManager] Reached maximum pending passes ({maxPendingPasses}). {pendingParticipants.Count} participants remain unrestored.");
}
// Final cleanup
pendingParticipants.Clear();
IsRestoringState = false;
Logging.Debug($"[SaveLoadManager] Restored state for {restoredCount} participants");
Logging.Debug($"[SaveLoadManager] Restored state for {restoredCount} participants + {totalPendingRestored} pending participants");
OnParticipantStatesRestored?.Invoke();
}
@@ -334,8 +390,9 @@ namespace Core.SaveLoad
}
// Capture state from all registered participants directly into the list
// Create a snapshot to avoid collection modification during iteration
int savedCount = 0;
foreach (var kvp in participants)
foreach (var kvp in participants.ToList())
{
string saveId = kvp.Key;
ISaveParticipant participant = kvp.Value;

View File

@@ -0,0 +1,47 @@
using Pixelplacement;
namespace Core.SaveLoad
{
/// <summary>
/// Base class for states that need save/load functionality.
/// Inherit from this instead of Pixelplacement.State for states in SaveableStateMachines.
/// </summary>
public class SaveableState : State
{
/// <summary>
/// Called when this state is entered during normal gameplay.
/// Override this method to implement state initialization logic
/// (animations, player movement, event subscriptions, etc.).
/// This is NOT called when restoring from a save file.
/// </summary>
public virtual void OnEnterState()
{
// Default: Do nothing
// States override this to implement their entry logic
}
/// <summary>
/// Called when this state is being restored from a save file.
/// Override this method to restore state from saved data without
/// playing animations or triggering side effects.
/// </summary>
/// <param name="data">Serialized state data from SerializeState()</param>
public virtual void OnRestoreState(string data)
{
// Default: Do nothing
// States override this to implement their restoration logic
}
/// <summary>
/// Called when the state machine is being saved.
/// Override this method to serialize this state's internal data.
/// </summary>
/// <returns>Serialized state data as a string (JSON recommended)</returns>
public virtual string SerializeState()
{
// Default: No state data to save
return "";
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 95e46aacea5b42888ee7881894193c11
timeCreated: 1762121675

View File

@@ -0,0 +1,257 @@
using UnityEngine;
using Pixelplacement;
using Bootstrap;
namespace Core.SaveLoad
{
// SaveableStateMachine - Inherits from StateMachine, uses SaveableState for states
// Auto-generates Save ID from scene name and hierarchy path (like SaveableInteractable)
/// <summary>
/// Extended StateMachine that integrates with the AppleHills save/load system.
/// Inherits from Pixelplacement.StateMachine and adds save/load functionality.
/// Use SaveableState (not State) for child states to get save/load hooks.
/// </summary>
public class SaveableStateMachine : StateMachine, ISaveParticipant
{
[SerializeField]
[Tooltip("Optional custom save ID. If empty, will auto-generate from scene name and hierarchy path.")]
private string customSaveId = "";
/// <summary>
/// Is this state machine currently being restored from a save file?
/// </summary>
public bool IsRestoring { get; private set; }
/// <summary>
/// Has this state machine been restored from save data?
/// </summary>
public bool HasBeenRestored { get; private set; }
// Override ChangeState to call OnEnterState on SaveableState components
public new GameObject ChangeState(GameObject state)
{
var result = base.ChangeState(state);
// If not restoring and change was successful, call OnEnterState
if (!IsRestoring && result != null && currentState != null)
{
var saveableState = currentState.GetComponent<SaveableState>();
if (saveableState != null)
{
saveableState.OnEnterState();
}
}
return result;
}
public new GameObject ChangeState(string state)
{
var result = base.ChangeState(state);
// If not restoring and change was successful, call OnEnterState
if (!IsRestoring && result != null && currentState != null)
{
var saveableState = currentState.GetComponent<SaveableState>();
if (saveableState != null)
{
saveableState.OnEnterState();
}
}
return result;
}
public new GameObject ChangeState(int childIndex)
{
var result = base.ChangeState(childIndex);
// If not restoring and change was successful, call OnEnterState
if (!IsRestoring && result != null && currentState != null)
{
var saveableState = currentState.GetComponent<SaveableState>();
if (saveableState != null)
{
saveableState.OnEnterState();
}
}
return result;
}
private void Start()
{
// Register with save system (no validation needed - we auto-generate ID)
BootCompletionService.RegisterInitAction(() =>
{
if (SaveLoadManager.Instance != null)
{
SaveLoadManager.Instance.RegisterParticipant(this);
}
else
{
Debug.LogWarning($"[SaveableStateMachine] SaveLoadManager.Instance is null, cannot register '{name}'", this);
}
});
}
#if UNITY_EDITOR
private void OnValidate()
{
// Optional: Log the auto-generated ID in verbose mode
if (verbose && string.IsNullOrEmpty(customSaveId))
{
Debug.Log($"[SaveableStateMachine] '{name}' will use auto-generated Save ID: {GetSaveId()}", this);
}
}
#endif
private void OnDestroy()
{
// Unregister from save system
if (SaveLoadManager.Instance != null)
{
SaveLoadManager.Instance.UnregisterParticipant(GetSaveId());
}
}
#region ISaveParticipant Implementation
public string GetSaveId()
{
string sceneName = GetSceneName();
if (!string.IsNullOrEmpty(customSaveId))
{
return $"{sceneName}/{customSaveId}";
}
// Auto-generate from hierarchy path
string hierarchyPath = GetHierarchyPath();
return $"{sceneName}/StateMachine_{hierarchyPath}";
}
private string GetSceneName()
{
return gameObject.scene.name;
}
private string GetHierarchyPath()
{
string path = gameObject.name;
Transform parent = transform.parent;
while (parent != null)
{
path = parent.name + "/" + path;
parent = parent.parent;
}
return path;
}
public string SerializeState()
{
if (currentState == null)
{
return JsonUtility.ToJson(new StateMachineSaveData { stateName = "", stateData = "" });
}
SaveableState saveableState = currentState.GetComponent<SaveableState>();
string stateData = saveableState?.SerializeState() ?? "";
var saveData = new StateMachineSaveData
{
stateName = currentState.name,
stateData = stateData
};
return JsonUtility.ToJson(saveData);
}
public void RestoreState(string data)
{
if (string.IsNullOrEmpty(data))
{
if (verbose)
{
Debug.LogWarning($"[SaveableStateMachine] No data to restore for '{name}'", this);
}
return;
}
try
{
StateMachineSaveData saveData = JsonUtility.FromJson<StateMachineSaveData>(data);
if (string.IsNullOrEmpty(saveData.stateName))
{
if (verbose)
{
Debug.LogWarning($"[SaveableStateMachine] No state name in save data for '{name}'", this);
}
return;
}
// Set IsRestoring flag so we won't call OnEnterState
IsRestoring = true;
// Change to the saved state
ChangeState(saveData.stateName);
// Now explicitly call OnRestoreState with the saved data
if (currentState != null)
{
SaveableState saveableState = currentState.GetComponent<SaveableState>();
if (saveableState != null)
{
saveableState.OnRestoreState(saveData.stateData);
}
}
HasBeenRestored = true;
IsRestoring = false;
if (verbose)
{
Debug.Log($"[SaveableStateMachine] Restored '{name}' to state: {saveData.stateName}", this);
}
}
catch (System.Exception ex)
{
Debug.LogError($"[SaveableStateMachine] Exception restoring '{name}': {ex.Message}", this);
IsRestoring = false;
}
}
#endregion
#region Editor Utilities
#if UNITY_EDITOR
[ContextMenu("Log Save ID")]
private void LogSaveId()
{
Debug.Log($"Save ID: {GetSaveId()}", this);
}
[ContextMenu("Test Serialize")]
private void TestSerialize()
{
string serialized = SerializeState();
Debug.Log($"Serialized state: {serialized}", this);
}
#endif
#endregion
[System.Serializable]
private class StateMachineSaveData
{
public string stateName;
public string stateData;
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 6f56763d30b94bf6873d395a6c116eb5
timeCreated: 1762116611