### 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
393 lines
16 KiB
C#
393 lines
16 KiB
C#
using System.Collections.Generic;
|
|
using UnityEngine;
|
|
using UnityEngine.Events;
|
|
using System; // for Action<T>
|
|
using Core; // register with ItemManager
|
|
using AppleHills.Core.Settings; // Added for IInteractionSettings
|
|
|
|
namespace Interactions
|
|
{
|
|
// New enum describing possible states for the slotted item
|
|
public enum ItemSlotState
|
|
{
|
|
None,
|
|
Correct,
|
|
Incorrect,
|
|
Forbidden
|
|
}
|
|
|
|
/// <summary>
|
|
/// Saveable data for ItemSlot state
|
|
/// </summary>
|
|
[System.Serializable]
|
|
public class ItemSlotSaveData
|
|
{
|
|
public PickupSaveData pickupData; // Base pickup state
|
|
public ItemSlotState slotState; // Current slot validation state
|
|
public string slottedItemSaveId; // Save ID of slotted item (if any)
|
|
public string slottedItemDataAssetPath; // Asset path to PickupItemData
|
|
}
|
|
|
|
// TODO: Remove this ridiculous inheritance from Pickup if possible
|
|
/// <summary>
|
|
/// Interaction requirement that allows slotting, swapping, or picking up items in a slot.
|
|
/// </summary>
|
|
public class ItemSlot : Pickup
|
|
{
|
|
// Tracks the current state of the slotted item
|
|
private ItemSlotState _currentState = ItemSlotState.None;
|
|
|
|
// Settings reference
|
|
private IInteractionSettings _interactionSettings;
|
|
private IPlayerFollowerSettings _playerFollowerSettings;
|
|
|
|
/// <summary>
|
|
/// Read-only access to the current slotted item state.
|
|
/// </summary>
|
|
public ItemSlotState CurrentSlottedState => _currentState;
|
|
|
|
public UnityEvent onItemSlotted;
|
|
public UnityEvent onItemSlotRemoved;
|
|
// Native C# event alternative for code-only subscribers
|
|
public event Action<PickupItemData> OnItemSlotRemoved;
|
|
|
|
public UnityEvent onCorrectItemSlotted;
|
|
// Native C# event alternative to the UnityEvent for code-only subscribers
|
|
public event Action<PickupItemData, PickupItemData> OnCorrectItemSlotted;
|
|
|
|
public UnityEvent onIncorrectItemSlotted;
|
|
// Native C# event alternative for code-only subscribers
|
|
public event Action<PickupItemData, PickupItemData> OnIncorrectItemSlotted;
|
|
|
|
public UnityEvent onForbiddenItemSlotted;
|
|
// Native C# event alternative for code-only subscribers
|
|
public event Action<PickupItemData, PickupItemData> OnForbiddenItemSlotted;
|
|
|
|
private PickupItemData _currentlySlottedItemData;
|
|
public SpriteRenderer slottedItemRenderer;
|
|
private GameObject _currentlySlottedItemObject;
|
|
|
|
public GameObject GetSlottedObject()
|
|
{
|
|
return _currentlySlottedItemObject;
|
|
}
|
|
|
|
public void SetSlottedObject(GameObject obj)
|
|
{
|
|
_currentlySlottedItemObject = obj;
|
|
if (_currentlySlottedItemObject != null)
|
|
{
|
|
_currentlySlottedItemObject.SetActive(false);
|
|
}
|
|
}
|
|
|
|
protected override void Awake()
|
|
{
|
|
base.Awake();
|
|
|
|
// Initialize settings references
|
|
_interactionSettings = GameManager.GetSettingsObject<IInteractionSettings>();
|
|
_playerFollowerSettings = GameManager.GetSettingsObject<IPlayerFollowerSettings>();
|
|
}
|
|
|
|
protected override void OnCharacterArrived()
|
|
{
|
|
Logging.Debug("[ItemSlot] OnCharacterArrived");
|
|
|
|
var heldItemData = _followerController.CurrentlyHeldItemData;
|
|
var heldItemObj = _followerController.GetHeldPickupObject();
|
|
var config = _interactionSettings?.GetSlotItemConfig(itemData);
|
|
var forbidden = config?.forbiddenItems ?? new List<PickupItemData>();
|
|
|
|
// Held item, slot empty -> try to slot item
|
|
if (heldItemData != null && _currentlySlottedItemObject == null)
|
|
{
|
|
// First check for forbidden items at the very start so we don't continue unnecessarily
|
|
if (PickupItemData.ListContainsEquivalent(forbidden, heldItemData))
|
|
{
|
|
DebugUIMessage.Show("Can't place that here.", Color.red);
|
|
onForbiddenItemSlotted?.Invoke();
|
|
OnForbiddenItemSlotted?.Invoke(itemData, heldItemData);
|
|
_currentState = ItemSlotState.Forbidden;
|
|
CompleteInteraction(false);
|
|
return;
|
|
}
|
|
|
|
SlotItem(heldItemObj, heldItemData, true);
|
|
return;
|
|
}
|
|
|
|
// Either pickup or swap items
|
|
if ((heldItemData == null && _currentlySlottedItemObject != null)
|
|
|| (heldItemData != null && _currentlySlottedItemObject != null))
|
|
{
|
|
// If both held and slotted items exist, attempt combination via follower (reuse existing logic from Pickup)
|
|
if (heldItemData != null && _currentlySlottedItemData != null)
|
|
{
|
|
var slottedPickup = _currentlySlottedItemObject?.GetComponent<Pickup>();
|
|
if (slottedPickup != null)
|
|
{
|
|
var comboResult = _followerController.TryCombineItems(slottedPickup, out var combinationResultItem);
|
|
if (combinationResultItem != null && comboResult == FollowerController.CombinationResult.Successful)
|
|
{
|
|
// Combination succeeded: fire slot-removed events and clear internals (don't call SlotItem to avoid duplicate events)
|
|
onItemSlotRemoved?.Invoke();
|
|
OnItemSlotRemoved?.Invoke(_currentlySlottedItemData);
|
|
_currentState = ItemSlotState.None;
|
|
|
|
// Clear internal references and visuals
|
|
_currentlySlottedItemObject = null;
|
|
_currentlySlottedItemData = null;
|
|
UpdateSlottedSprite();
|
|
|
|
CompleteInteraction(false);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// No combination (or not applicable) -> perform normal swap/pickup behavior
|
|
_followerController.TryPickupItem(_currentlySlottedItemObject, _currentlySlottedItemData, false);
|
|
onItemSlotRemoved?.Invoke();
|
|
OnItemSlotRemoved?.Invoke(_currentlySlottedItemData);
|
|
_currentState = ItemSlotState.None;
|
|
SlotItem(heldItemObj, heldItemData, _currentlySlottedItemObject == null);
|
|
return;
|
|
}
|
|
|
|
// No held item, slot empty -> show warning
|
|
if (heldItemData == null && _currentlySlottedItemObject == null)
|
|
{
|
|
DebugUIMessage.Show("This requires an item.", Color.red);
|
|
return;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Updates the sprite and scale for the currently slotted item.
|
|
/// </summary>
|
|
private void UpdateSlottedSprite()
|
|
{
|
|
if (slottedItemRenderer != null && _currentlySlottedItemData != null && _currentlySlottedItemData.mapSprite != null)
|
|
{
|
|
slottedItemRenderer.sprite = _currentlySlottedItemData.mapSprite;
|
|
// Scale sprite to desired height, preserve aspect ratio, compensate for parent scale
|
|
float desiredHeight = _playerFollowerSettings?.HeldIconDisplayHeight ?? 2.0f;
|
|
var sprite = _currentlySlottedItemData.mapSprite;
|
|
float spriteHeight = sprite.bounds.size.y;
|
|
Vector3 parentScale = slottedItemRenderer.transform.parent != null
|
|
? slottedItemRenderer.transform.parent.localScale
|
|
: Vector3.one;
|
|
if (spriteHeight > 0f)
|
|
{
|
|
float uniformScale = desiredHeight / spriteHeight;
|
|
float scale = uniformScale / Mathf.Max(parentScale.x, parentScale.y);
|
|
slottedItemRenderer.transform.localScale = new Vector3(scale, scale, 1f);
|
|
}
|
|
}
|
|
else if (slottedItemRenderer != null)
|
|
{
|
|
slottedItemRenderer.sprite = null;
|
|
}
|
|
}
|
|
|
|
// Register with ItemManager when enabled
|
|
protected override void Start()
|
|
{
|
|
base.Start(); // This calls Pickup.Start() which registers with save system
|
|
|
|
// Additionally register as ItemSlot
|
|
ItemManager.Instance?.RegisterItemSlot(this);
|
|
}
|
|
|
|
protected override void OnDestroy()
|
|
{
|
|
base.OnDestroy(); // Unregister from save system and pickup manager
|
|
|
|
// Unregister from slot manager
|
|
ItemManager.Instance?.UnregisterItemSlot(this);
|
|
}
|
|
|
|
#region Save/Load Implementation
|
|
|
|
protected override object GetSerializableState()
|
|
{
|
|
// Get base pickup state
|
|
PickupSaveData baseData = base.GetSerializableState() as PickupSaveData;
|
|
|
|
// Get slotted item save ID if there's a slotted item
|
|
string slottedSaveId = "";
|
|
string slottedAssetPath = "";
|
|
|
|
if (_currentlySlottedItemObject != null)
|
|
{
|
|
var slottedPickup = _currentlySlottedItemObject.GetComponent<Pickup>();
|
|
if (slottedPickup is SaveableInteractable saveablePickup)
|
|
{
|
|
slottedSaveId = saveablePickup.GetSaveId();
|
|
}
|
|
|
|
if (_currentlySlottedItemData != null)
|
|
{
|
|
#if UNITY_EDITOR
|
|
slottedAssetPath = UnityEditor.AssetDatabase.GetAssetPath(_currentlySlottedItemData);
|
|
#endif
|
|
}
|
|
}
|
|
|
|
return new ItemSlotSaveData
|
|
{
|
|
pickupData = baseData,
|
|
slotState = _currentState,
|
|
slottedItemSaveId = slottedSaveId,
|
|
slottedItemDataAssetPath = slottedAssetPath
|
|
};
|
|
}
|
|
|
|
protected override void ApplySerializableState(string serializedData)
|
|
{
|
|
ItemSlotSaveData data = JsonUtility.FromJson<ItemSlotSaveData>(serializedData);
|
|
if (data == null)
|
|
{
|
|
Debug.LogWarning($"[ItemSlot] Failed to deserialize save data for {gameObject.name}");
|
|
return;
|
|
}
|
|
|
|
// First restore base pickup state
|
|
if (data.pickupData != null)
|
|
{
|
|
string pickupJson = JsonUtility.ToJson(data.pickupData);
|
|
base.ApplySerializableState(pickupJson);
|
|
}
|
|
|
|
// Restore slot state
|
|
_currentState = data.slotState;
|
|
|
|
// Restore slotted item if there was one
|
|
if (!string.IsNullOrEmpty(data.slottedItemSaveId))
|
|
{
|
|
RestoreSlottedItem(data.slottedItemSaveId, data.slottedItemDataAssetPath);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Restore a slotted item from save data.
|
|
/// This is called during load restoration and should NOT trigger events.
|
|
/// </summary>
|
|
private void RestoreSlottedItem(string slottedItemSaveId, string slottedItemDataAssetPath)
|
|
{
|
|
// Try to find the item in the scene by its save ID via ItemManager
|
|
GameObject slottedObject = ItemManager.Instance?.FindPickupBySaveId(slottedItemSaveId);
|
|
|
|
if (slottedObject == null)
|
|
{
|
|
Debug.LogWarning($"[ItemSlot] Could not find slotted item with save ID: {slottedItemSaveId}");
|
|
return;
|
|
}
|
|
|
|
// Get the item data
|
|
PickupItemData slottedData = null;
|
|
#if UNITY_EDITOR
|
|
if (!string.IsNullOrEmpty(slottedItemDataAssetPath))
|
|
{
|
|
slottedData = UnityEditor.AssetDatabase.LoadAssetAtPath<PickupItemData>(slottedItemDataAssetPath);
|
|
}
|
|
#endif
|
|
|
|
if (slottedData == null)
|
|
{
|
|
var pickup = slottedObject.GetComponent<Pickup>();
|
|
if (pickup != null)
|
|
{
|
|
slottedData = pickup.itemData;
|
|
}
|
|
}
|
|
|
|
// Silently slot the item (no events, no interaction completion)
|
|
ApplySlottedItemState(slottedObject, slottedData, triggerEvents: false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Core logic for slotting an item. Can be used both for normal slotting and silent restoration.
|
|
/// </summary>
|
|
/// <param name="itemToSlot">The item GameObject to slot (or null to clear)</param>
|
|
/// <param name="itemToSlotData">The PickupItemData for the item</param>
|
|
/// <param name="triggerEvents">Whether to fire events and complete interaction</param>
|
|
/// <param name="clearFollowerHeldItem">Whether to clear the follower's held item</param>
|
|
private void ApplySlottedItemState(GameObject itemToSlot, PickupItemData itemToSlotData, bool triggerEvents, bool clearFollowerHeldItem = true)
|
|
{
|
|
// Cache the previous item data before clearing, needed for OnItemSlotRemoved event
|
|
var previousItemData = _currentlySlottedItemData;
|
|
bool wasSlotCleared = _currentlySlottedItemObject != null && itemToSlot == null;
|
|
|
|
if (itemToSlot == null)
|
|
{
|
|
_currentlySlottedItemObject = null;
|
|
_currentlySlottedItemData = null;
|
|
_currentState = ItemSlotState.None;
|
|
|
|
// Fire native event for slot clearing (only if triggering events)
|
|
if (wasSlotCleared && triggerEvents)
|
|
{
|
|
OnItemSlotRemoved?.Invoke(previousItemData);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
itemToSlot.SetActive(false);
|
|
itemToSlot.transform.SetParent(null);
|
|
SetSlottedObject(itemToSlot);
|
|
_currentlySlottedItemData = itemToSlotData;
|
|
}
|
|
|
|
if (clearFollowerHeldItem && _followerController != null)
|
|
{
|
|
_followerController.ClearHeldItem();
|
|
}
|
|
UpdateSlottedSprite();
|
|
|
|
// Only validate and trigger events if requested
|
|
if (triggerEvents)
|
|
{
|
|
// Once an item is slotted, we know it is not forbidden, so we can skip that check, but now check if it was
|
|
// the correct item we're looking for
|
|
var config = _interactionSettings?.GetSlotItemConfig(itemData);
|
|
var allowed = config?.allowedItems ?? new List<PickupItemData>();
|
|
if (itemToSlotData != null && PickupItemData.ListContainsEquivalent(allowed, itemToSlotData))
|
|
{
|
|
if (itemToSlot != null)
|
|
{
|
|
DebugUIMessage.Show("You correctly slotted " + itemToSlotData.itemName + " into: " + itemData.itemName, Color.green);
|
|
onCorrectItemSlotted?.Invoke();
|
|
OnCorrectItemSlotted?.Invoke(itemData, _currentlySlottedItemData);
|
|
_currentState = ItemSlotState.Correct;
|
|
}
|
|
|
|
CompleteInteraction(true);
|
|
}
|
|
else
|
|
{
|
|
if (itemToSlot != null)
|
|
{
|
|
DebugUIMessage.Show("I'm not sure this works.", Color.yellow);
|
|
onIncorrectItemSlotted?.Invoke();
|
|
OnIncorrectItemSlotted?.Invoke(itemData, _currentlySlottedItemData);
|
|
_currentState = ItemSlotState.Incorrect;
|
|
}
|
|
CompleteInteraction(false);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Public API for slotting items during gameplay.
|
|
/// </summary>
|
|
public void SlotItem(GameObject itemToSlot, PickupItemData itemToSlotData, bool clearFollowerHeldItem = true)
|
|
{
|
|
ApplySlottedItemState(itemToSlot, itemToSlotData, triggerEvents: true, clearFollowerHeldItem);
|
|
}
|
|
|
|
#endregion
|
|
}
|
|
}
|