diff --git a/Assets/Prefabs/Managers/PlayerHUD.prefab b/Assets/Prefabs/Managers/PlayerHUD.prefab
index b6661425..d10dc50c 100644
--- a/Assets/Prefabs/Managers/PlayerHUD.prefab
+++ b/Assets/Prefabs/Managers/PlayerHUD.prefab
@@ -1185,6 +1185,7 @@ MonoBehaviour:
CinematicBackground: {fileID: 1256355336041814197}
eagleEye: {fileID: 8093509920149135307}
ramaSjangButton: {fileID: 4599222264323240281}
+ scrabBookButton: {fileID: 2880351836456325619}
cinematicSprites: {fileID: 0}
cinematicBackgroundSprites: {fileID: 0}
currentCinematicPlayer: {fileID: 0}
diff --git a/Assets/Scenes/Levels/Quarry.unity b/Assets/Scenes/Levels/Quarry.unity
index 7f1908bc..a9b002da 100644
--- a/Assets/Scenes/Levels/Quarry.unity
+++ b/Assets/Scenes/Levels/Quarry.unity
@@ -453761,6 +453761,10 @@ PrefabInstance:
propertyPath: m_PlayOnAwake
value: 0
objectReference: {fileID: 0}
+ - target: {fileID: 8545106365577783398, guid: ead4e790fa3a1924ebd1586c93cd5479, type: 3}
+ propertyPath: isOneTime
+ value: 1
+ objectReference: {fileID: 0}
m_RemovedComponents: []
m_RemovedGameObjects: []
m_AddedGameObjects: []
diff --git a/Assets/Scripts/Core/Lifecycle/LifecycleManager.cs b/Assets/Scripts/Core/Lifecycle/LifecycleManager.cs
index 82bbd221..2ac71c73 100644
--- a/Assets/Scripts/Core/Lifecycle/LifecycleManager.cs
+++ b/Assets/Scripts/Core/Lifecycle/LifecycleManager.cs
@@ -469,6 +469,34 @@ namespace Core.Lifecycle
LogDebug($"Restored scene data to {restoredCount} components");
}
+ ///
+ /// Broadcasts scene restore completed event to all registered components.
+ /// Called AFTER all OnSceneRestoreRequested calls complete.
+ ///
+ public void BroadcastSceneRestoreCompleted()
+ {
+ LogDebug("Broadcasting SceneRestoreCompleted");
+
+ // Create a copy to avoid collection modification during iteration
+ var componentsCopy = new List(managedAwakeList);
+
+ foreach (var component in componentsCopy)
+ {
+ if (component == null) continue;
+
+ try
+ {
+ component.InvokeSceneRestoreCompleted();
+ }
+ catch (Exception ex)
+ {
+ Debug.LogError($"[LifecycleManager] Exception during scene restore completed for {component.SaveId}: {ex}");
+ }
+ }
+
+ LogDebug("SceneRestoreCompleted broadcast complete");
+ }
+
///
/// Broadcasts global restore request to all registered components that opt-in.
/// Distributes serialized data to matching components by SaveId.
diff --git a/Assets/Scripts/Core/Lifecycle/ManagedBehaviour.cs b/Assets/Scripts/Core/Lifecycle/ManagedBehaviour.cs
index 085797be..5281424c 100644
--- a/Assets/Scripts/Core/Lifecycle/ManagedBehaviour.cs
+++ b/Assets/Scripts/Core/Lifecycle/ManagedBehaviour.cs
@@ -89,6 +89,7 @@ namespace Core.Lifecycle
public void InvokeSceneReady() => OnSceneReady();
public string InvokeSceneSaveRequested() => OnSceneSaveRequested();
public void InvokeSceneRestoreRequested(string data) => OnSceneRestoreRequested(data);
+ public void InvokeSceneRestoreCompleted() => OnSceneRestoreCompleted();
public string InvokeGlobalSaveRequested() => OnGlobalSaveRequested();
public void InvokeGlobalRestoreRequested(string data) => OnGlobalRestoreRequested(data);
public void InvokeManagedDestroy() => OnManagedDestroy();
@@ -202,6 +203,10 @@ namespace Core.Lifecycle
/// Called during scene transitions to restore scene-specific state.
/// Receives previously serialized data (from OnSceneSaveRequested).
///
+ /// IMPORTANT: This method MUST be synchronous. Do not use coroutines or async/await.
+ /// OnSceneRestoreCompleted is called immediately after all restore calls complete,
+ /// so any async operations would still be running when it fires.
+ ///
/// TIMING:
/// - Called AFTER scene load, during OnSceneReady phase
/// - Frequency: Every scene transition
@@ -212,6 +217,29 @@ namespace Core.Lifecycle
// Default: no-op
}
+ ///
+ /// Called after all scene restore operations complete.
+ /// Does NOT receive data - use OnSceneRestoreRequested for that.
+ ///
+ /// GUARANTEE:
+ /// - ALWAYS called after scene load, whether there's save data or not
+ /// - All OnSceneRestoreRequested() calls have RETURNED (but async operations may still be running)
+ /// - Safe for synchronous restore operations (JSON deserialization, setting fields, etc.)
+ ///
+ /// TIMING:
+ /// - Called AFTER all OnSceneRestoreRequested calls complete (or immediately if no save data exists)
+ /// - Frequency: Every scene transition
+ /// - Use for: Post-restore initialization, first-time initialization, triggering events after state is restored
+ ///
+ /// COMMON PATTERN:
+ /// Use this to perform actions that depend on whether data was restored or not.
+ /// Example: Play one-time audio only if it hasn't been played before (_hasPlayed == false).
+ ///
+ protected virtual void OnSceneRestoreCompleted()
+ {
+ // Default: no-op
+ }
+
///
/// Called once on game boot to restore global persistent state.
/// Receives data that was saved via OnGlobalSaveRequested.
diff --git a/Assets/Scripts/Core/SaveLoad/SaveLoadManager.cs b/Assets/Scripts/Core/SaveLoad/SaveLoadManager.cs
index ecbeb0ca..86a8ed76 100644
--- a/Assets/Scripts/Core/SaveLoad/SaveLoadManager.cs
+++ b/Assets/Scripts/Core/SaveLoad/SaveLoadManager.cs
@@ -504,9 +504,19 @@ namespace Core.SaveLoad
///
public void RestoreSceneData()
{
+ if (Lifecycle.LifecycleManager.Instance == null)
+ {
+ Logging.Warning("[SaveLoadManager] LifecycleManager not available for scene restore");
+ return;
+ }
+
if (currentSaveData == null || currentSaveData.participantStates == null)
{
- Logging.Debug("[SaveLoadManager] No scene data to restore");
+ Logging.Debug("[SaveLoadManager] No scene data to restore (first visit or no save data)");
+
+ // Still broadcast restore completed so components can initialize properly
+ Lifecycle.LifecycleManager.Instance.BroadcastSceneRestoreCompleted();
+ Logging.Debug($"[SaveLoadManager] Scene restore completed (no data)");
return;
}
@@ -520,11 +530,12 @@ namespace Core.SaveLoad
}
// Restore scene data via LifecycleManager
- if (Lifecycle.LifecycleManager.Instance != null)
- {
- Lifecycle.LifecycleManager.Instance.BroadcastSceneRestoreRequested(saveDataDict);
- Logging.Debug($"[SaveLoadManager] Broadcast scene restore to LifecycleManager");
- }
+ Lifecycle.LifecycleManager.Instance.BroadcastSceneRestoreRequested(saveDataDict);
+ Logging.Debug($"[SaveLoadManager] Broadcast scene restore to LifecycleManager");
+
+ // Broadcast scene restore completed - ALWAYS called, whether there's data or not
+ Lifecycle.LifecycleManager.Instance.BroadcastSceneRestoreCompleted();
+ Logging.Debug($"[SaveLoadManager] Scene restore completed");
}
///
diff --git a/Assets/Scripts/Sound/LevelAudioObject.cs b/Assets/Scripts/Sound/LevelAudioObject.cs
index ed74b94d..23fab489 100644
--- a/Assets/Scripts/Sound/LevelAudioObject.cs
+++ b/Assets/Scripts/Sound/LevelAudioObject.cs
@@ -1,22 +1,80 @@
using UnityEngine;
using UnityEngine.Audio;
using System;
+using Core.Lifecycle;
-public class LevelAudioObject : MonoBehaviour
+[Serializable]
+public class LevelAudioObjectSaveData
{
+ public bool hasPlayed;
+}
+
+public class LevelAudioObject : ManagedBehaviour
+{
+ [Header("Audio Settings")]
public AppleAudioSource narratorAudioSource;
public AudioResource firstNarration;
+
+ [Header("Playback Settings")]
+ [Tooltip("If true, the audio will only play once and never again after being played")]
+ public bool isOneTime;
- // Start is called once before the first execution of Update after the MonoBehaviour is created
- void Start()
+ private bool _hasPlayed;
+
+ #region ManagedBehaviour Overrides
+
+ public override bool AutoRegisterForSave => isOneTime; // Only save if one-time audio
+
+ protected override string OnSceneSaveRequested()
{
- PlayNarrationAudio();
+ if (!isOneTime)
+ return null; // No need to save if not one-time
+
+ LevelAudioObjectSaveData saveData = new LevelAudioObjectSaveData
+ {
+ hasPlayed = _hasPlayed
+ };
+
+ return JsonUtility.ToJson(saveData);
}
- void PlayNarrationAudio()
+ protected override void OnSceneRestoreRequested(string serializedData)
{
+ if (!isOneTime || string.IsNullOrEmpty(serializedData))
+ return;
+
+ try
+ {
+ LevelAudioObjectSaveData saveData = JsonUtility.FromJson(serializedData);
+ _hasPlayed = saveData.hasPlayed;
+ }
+ catch (Exception e)
+ {
+ Debug.LogWarning($"[LevelAudioObject] Failed to restore audio state: {e.Message}");
+ }
+ }
+
+ protected override void OnSceneRestoreCompleted()
+ {
+ if (isOneTime && !_hasPlayed)
+ {
+ PlayNarrationAudio();
+ }
+ }
+
+ #endregion
+
+ private void PlayNarrationAudio()
+ {
+ if (narratorAudioSource == null || firstNarration == null)
+ {
+ Debug.LogWarning($"[LevelAudioObject] Missing audio source or narration resource on {gameObject.name}");
+ return;
+ }
+
narratorAudioSource.audioSource.resource = firstNarration;
narratorAudioSource.Play(0);
+
+ _hasPlayed = true;
}
-
}
diff --git a/Assets/Scripts/UI/CardSystem/MinigameBoosterGiver.cs b/Assets/Scripts/UI/CardSystem/MinigameBoosterGiver.cs
index c395d718..1ba5e86a 100644
--- a/Assets/Scripts/UI/CardSystem/MinigameBoosterGiver.cs
+++ b/Assets/Scripts/UI/CardSystem/MinigameBoosterGiver.cs
@@ -10,7 +10,8 @@ namespace UI.CardSystem
///
/// Singleton UI component for granting booster packs from minigames.
/// Displays a booster pack with glow effect, waits for user to click continue,
- /// then animates the pack flying to bottom-left corner before granting the reward.
+ /// then shows the scrapbook button and animates the pack flying to it before granting the reward.
+ /// The scrapbook button is automatically hidden after the animation completes.
///
public class MinigameBoosterGiver : MonoBehaviour
{
@@ -187,23 +188,61 @@ namespace UI.CardSystem
yield break;
}
- // Calculate bottom-left corner position in local space
- RectTransform canvasRect = GetComponentInParent