Files
AppleHillsProduction/Assets/Scripts/Interactions/Pickup.cs
tschesky e27bb7bfb6 Refactor interactions, introduce template-method lifecycle management, work on save-load system (#51)
# Lifecycle Management & Save System Revamp

## Overview
Complete overhaul of game lifecycle management, interactable system, and save/load architecture. Introduces centralized `ManagedBehaviour` base class for consistent initialization ordering and lifecycle hooks across all systems.

## Core Architecture

### New Lifecycle System
- **`LifecycleManager`**: Centralized coordinator for all managed objects
- **`ManagedBehaviour`**: Base class replacing ad-hoc initialization patterns
  - `OnManagedAwake()`: Priority-based initialization (0-100, lower = earlier)
  - `OnSceneReady()`: Scene-specific setup after managers ready
  - Replaces `BootCompletionService` (deleted)
- **Priority groups**: Infrastructure (0-20) → Game Systems (30-50) → Data (60-80) → UI/Gameplay (90-100)
- **Editor support**: `EditorLifecycleBootstrap` ensures lifecycle works in editor mode

### Unified SaveID System
- Consistent format: `{ParentName}_{ComponentType}`
- Auto-registration via `AutoRegisterForSave = true`
- New `DebugSaveIds` editor tool for inspection

## Save/Load Improvements

### Enhanced State Management
- **Extended SaveLoadData**: Unlocked minigames, card collection states, combination items, slot occupancy
- **Async loading**: `ApplyCardCollectionState()` waits for card definitions before restoring
- **New `SaveablePlayableDirector`**: Timeline sequences save/restore playback state
- **Fixed race conditions**: Proper initialization ordering prevents data corruption

## Interactable & Pickup System

- Migrated to `OnManagedAwake()` for consistent initialization
- Template method pattern for state restoration (`RestoreInteractionState()`)
- Fixed combination item save/load bugs (items in slots vs. follower hand)
- Dynamic spawning support for combined items on load
- **Breaking**: `Interactable.Awake()` now sealed, use `OnManagedAwake()` instead

##  UI System Changes

- **AlbumViewPage** and **BoosterNotificationDot**: Migrated to `ManagedBehaviour`
- **Fixed menu persistence bug**: Menus no longer reappear after scene transitions
- **Pause Menu**: Now reacts to all scene loads (not just first scene)
- **Orientation Enforcer**: Enforces per-scene via `SceneManagementService`
- **Loading Screen**: Integrated with new lifecycle

## ⚠️ Breaking Changes

1. **`BootCompletionService` removed** → Use `ManagedBehaviour.OnManagedAwake()` with priority
2. **`Interactable.Awake()` sealed** → Override `OnManagedAwake()` instead
3. **SaveID format changed** → Now `{ParentName}_{ComponentType}` consistently
4. **MonoBehaviours needing init ordering** → Must inherit from `ManagedBehaviour`

Co-authored-by: Michal Pikulski <michal.a.pikulski@gmail.com>
Co-authored-by: Michal Pikulski <michal@foolhardyhorizons.com>
Reviewed-on: #51
2025-11-07 15:38:31 +00:00

259 lines
9.1 KiB
C#

using UnityEngine;
using System;
using System.Linq;
using Core;
namespace Interactions
{
/// <summary>
/// Saveable data for Pickup state
/// </summary>
[Serializable]
public class PickupSaveData
{
public bool isPickedUp;
public bool wasHeldByFollower;
public bool wasInSlot; // NEW: Was this pickup in a slot?
public string slotSaveId; // NEW: Which slot held this pickup?
public Vector3 worldPosition;
public Quaternion worldRotation;
public bool isActive;
}
public class Pickup : SaveableInteractable
{
public PickupItemData itemData;
public SpriteRenderer iconRenderer;
public bool IsPickedUp { get; internal set; }
// Track which slot owns this pickup (for bilateral restoration)
internal ItemSlot OwningSlot { get; set; }
public event Action<PickupItemData> OnItemPickedUp;
public event Action<PickupItemData, PickupItemData, PickupItemData> OnItemsCombined;
protected override void Awake()
{
base.Awake(); // Register with save system
if (iconRenderer == null)
iconRenderer = GetComponent<SpriteRenderer>();
ApplyItemData();
}
// Always register with ItemManager, even if picked up
// This allows the save/load system to find held items when restoring state
protected override void OnManagedAwake()
{
ItemManager.Instance?.RegisterPickup(this);
}
protected override void OnDestroy()
{
base.OnDestroy();
// Unregister from ItemManager
ItemManager.Instance?.UnregisterPickup(this);
}
#if UNITY_EDITOR
/// <summary>
/// Unity OnValidate callback. Ensures icon and data are up to date in editor.
/// </summary>
void OnValidate()
{
if (iconRenderer == null)
iconRenderer = GetComponent<SpriteRenderer>();
ApplyItemData();
}
#endif
/// <summary>
/// Applies the item data to the pickup (icon, name, etc).
/// </summary>
public void ApplyItemData()
{
if (itemData != null)
{
if (iconRenderer != null && itemData.mapSprite != null)
{
iconRenderer.sprite = itemData.mapSprite;
}
gameObject.name = itemData.itemName;
}
}
#region Interaction Logic
/// <summary>
/// Main interaction logic: Try combination, then try pickup.
/// </summary>
protected override bool DoInteraction()
{
Logging.Debug("[Pickup] DoInteraction");
// IMPORTANT: Capture held item data BEFORE combination
// TryCombineItems destroys the original items, so we need this data for the event
var heldItemObject = FollowerController?.GetHeldPickupObject();
var heldItemData = heldItemObject?.GetComponent<Pickup>()?.itemData;
// Try combination first
var combinationResult = FollowerController.TryCombineItems(this, out var resultItem);
if (combinationResult == FollowerController.CombinationResult.Successful)
{
// Mark this pickup as picked up (consumed in combination) to prevent restoration
IsPickedUp = true;
// Combination succeeded - original items destroyed, result picked up by TryCombineItems
FireCombinationEvent(resultItem, heldItemData);
return true;
}
// No combination (or unsuccessful) - do regular pickup
FollowerController?.TryPickupItem(gameObject, itemData);
IsPickedUp = true;
OnItemPickedUp?.Invoke(itemData);
return true;
}
/// <summary>
/// Helper method to fire the combination event with correct item data.
/// </summary>
/// <param name="resultItem">The spawned result item</param>
/// <param name="originalHeldItemData">The ORIGINAL held item data (before destruction)</param>
private void FireCombinationEvent(GameObject resultItem, PickupItemData originalHeldItemData)
{
var resultPickup = resultItem?.GetComponent<Pickup>();
// Verify we have all required data
if (resultPickup?.itemData != null && originalHeldItemData != null && itemData != null)
{
OnItemsCombined?.Invoke(itemData, originalHeldItemData, resultPickup.itemData);
}
}
#endregion
#region Save/Load Implementation
protected override object GetSerializableState()
{
// Check if this pickup is currently held by the follower
bool isHeldByFollower = IsPickedUp && !gameObject.activeSelf && transform.parent != null;
// Check if this pickup is in a slot
bool isInSlot = OwningSlot != null;
string slotId = isInSlot && OwningSlot is SaveableInteractable saveableSlot ? saveableSlot.SaveId : "";
return new PickupSaveData
{
isPickedUp = this.IsPickedUp,
wasHeldByFollower = isHeldByFollower,
wasInSlot = isInSlot,
slotSaveId = slotId,
worldPosition = transform.position,
worldRotation = transform.rotation,
isActive = gameObject.activeSelf
};
}
protected override void ApplySerializableState(string serializedData)
{
PickupSaveData data = JsonUtility.FromJson<PickupSaveData>(serializedData);
if (data == null)
{
Debug.LogWarning($"[Pickup] Failed to deserialize save data for {gameObject.name}");
return;
}
// Restore picked up state
IsPickedUp = data.isPickedUp;
if (IsPickedUp)
{
// 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);
}
}
// If this was in a slot, try bilateral restoration with the slot
else if (data.wasInSlot && !string.IsNullOrEmpty(data.slotSaveId))
{
// Try to give this pickup to the slot
var slot = FindSlotBySaveId(data.slotSaveId);
if (slot != null)
{
slot.TryClaimSlottedItem(this);
}
else
{
Debug.LogWarning($"[Pickup] Could not find slot with SaveId: {data.slotSaveId}");
}
}
}
else
{
// Restore position for items that haven't been picked up (they may have moved)
transform.position = data.worldPosition;
transform.rotation = data.worldRotation;
gameObject.SetActive(data.isActive);
}
// Note: We do NOT fire OnItemPickedUp event during restoration
// This prevents duplicate logic execution
}
/// <summary>
/// Find an ItemSlot by its SaveId (for bilateral restoration).
/// </summary>
private ItemSlot FindSlotBySaveId(string slotSaveId)
{
if (string.IsNullOrEmpty(slotSaveId)) return null;
// Get all ItemSlots from ItemManager
var allSlots = ItemManager.Instance?.GetAllItemSlots();
if (allSlots == null) return null;
foreach (var slot in allSlots)
{
if (slot is SaveableInteractable saveable && saveable.SaveId == slotSaveId)
{
return slot;
}
}
return null;
}
/// <summary>
/// Resets the pickup state when the item is dropped back into the world.
/// Called by FollowerController when swapping items.
/// </summary>
public void ResetPickupState()
{
IsPickedUp = false;
gameObject.SetActive(true);
// Re-register with ItemManager if not already registered
if (ItemManager.Instance != null && !ItemManager.Instance.GetAllPickups().Contains(this))
{
ItemManager.Instance.RegisterPickup(this);
}
}
#endregion
}
}