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

@@ -1,21 +1,32 @@
using Input;
using UnityEngine;
using System; // added for Action<T>
using System;
using System.Linq;
using Bootstrap; // added for Action<T>
using Core; // register with ItemManager
namespace Interactions
{
[RequireComponent(typeof(Interactable))]
public class Pickup : MonoBehaviour
/// <summary>
/// Saveable data for Pickup state
/// </summary>
[System.Serializable]
public class PickupSaveData
{
public bool isPickedUp;
public bool wasHeldByFollower; // Track if held by follower for bilateral restoration
public Vector3 worldPosition;
public Quaternion worldRotation;
public bool isActive;
}
public class Pickup : SaveableInteractable
{
public PickupItemData itemData;
public SpriteRenderer iconRenderer;
protected Interactable Interactable;
private PlayerTouchController _playerRef;
protected FollowerController FollowerController;
// Track if the item has been picked up
public bool isPickedUp { get; private set; }
public bool IsPickedUp { get; internal set; }
// Event: invoked when the item was picked up successfully
public event Action<PickupItemData> OnItemPickedUp;
@@ -24,19 +35,14 @@ namespace Interactions
public event Action<PickupItemData, PickupItemData, PickupItemData> OnItemsCombined;
/// <summary>
/// Unity Awake callback. Sets up icon, interactable, and event handlers.
/// Unity Awake callback. Sets up icon and applies item data.
/// </summary>
public virtual void Awake()
protected override void Awake()
{
base.Awake(); // Register with save system
if (iconRenderer == null)
iconRenderer = GetComponent<SpriteRenderer>();
Interactable = GetComponent<Interactable>();
if (Interactable != null)
{
Interactable.interactionStarted.AddListener(OnInteractionStarted);
Interactable.characterArrived.AddListener(OnCharacterArrived);
}
ApplyItemData();
}
@@ -44,22 +50,26 @@ namespace Interactions
/// <summary>
/// Register with ItemManager on Start
/// </summary>
void Start()
protected override void Start()
{
ItemManager.Instance?.RegisterPickup(this);
base.Start(); // Register with save system
// Always register with ItemManager, even if picked up
// This allows the save/load system to find held items when restoring state
BootCompletionService.RegisterInitAction(() =>
{
ItemManager.Instance?.RegisterPickup(this);
});
}
/// <summary>
/// Unity OnDestroy callback. Cleans up event handlers.
/// Unity OnDestroy callback. Unregisters from ItemManager.
/// </summary>
void OnDestroy()
protected override void OnDestroy()
{
if (Interactable != null)
{
Interactable.interactionStarted.RemoveListener(OnInteractionStarted);
Interactable.characterArrived.RemoveListener(OnCharacterArrived);
}
base.OnDestroy(); // Unregister from save system
// Unregister from ItemManager
ItemManager.Instance?.UnregisterPickup(this);
}
@@ -76,6 +86,7 @@ namespace Interactions
}
#endif
/// <summary>
/// Applies the item data to the pickup (icon, name, etc).
/// </summary>
@@ -93,22 +104,17 @@ namespace Interactions
}
/// <summary>
/// Handles the start of an interaction (for feedback/UI only).
/// Override: Called when character arrives at the interaction point.
/// Handles item pickup and combination logic.
/// </summary>
private void OnInteractionStarted(PlayerTouchController playerRef, FollowerController followerRef)
{
_playerRef = playerRef;
FollowerController = followerRef;
}
protected virtual void OnCharacterArrived()
protected override void OnCharacterArrived()
{
Logging.Debug("[Pickup] OnCharacterArrived");
var combinationResult = FollowerController.TryCombineItems(this, out var combinationResultItem);
var combinationResult = _followerController.TryCombineItems(this, out var combinationResultItem);
if (combinationResultItem != null)
{
Interactable.BroadcastInteractionComplete(true);
CompleteInteraction(true);
// Fire the combination event when items are successfully combined
if (combinationResult == FollowerController.CombinationResult.Successful)
@@ -118,7 +124,7 @@ namespace Interactions
{
// Get the combined item data
var resultItemData = resultPickup.itemData;
var heldItem = FollowerController.GetHeldPickupObject();
var heldItem = _followerController.GetHeldPickupObject();
if (heldItem != null)
{
@@ -135,25 +141,101 @@ namespace Interactions
return;
}
FollowerController?.TryPickupItem(gameObject, itemData);
_followerController?.TryPickupItem(gameObject, itemData);
var step = GetComponent<PuzzleS.ObjectiveStepBehaviour>();
if (step != null && !step.IsStepUnlocked())
{
Interactable.BroadcastInteractionComplete(false);
CompleteInteraction(false);
return;
}
bool wasPickedUp = (combinationResult == FollowerController.CombinationResult.NotApplicable
|| combinationResult == FollowerController.CombinationResult.Unsuccessful);
Interactable.BroadcastInteractionComplete(wasPickedUp);
CompleteInteraction(wasPickedUp);
// Update pickup state and invoke event when the item was picked up successfully
if (wasPickedUp)
{
isPickedUp = true;
IsPickedUp = true;
OnItemPickedUp?.Invoke(itemData);
}
}
#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;
return new PickupSaveData
{
isPickedUp = this.IsPickedUp,
wasHeldByFollower = isHeldByFollower,
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);
}
}
}
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>
/// 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
}
}