diff --git a/Assets/Editor/InteractionSystem.meta b/Assets/Editor/InteractionSystem.meta new file mode 100644 index 00000000..65748ff5 --- /dev/null +++ b/Assets/Editor/InteractionSystem.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: cb41c852d70c4066bf510792ee19b3f5 +timeCreated: 1762866335 \ No newline at end of file diff --git a/Assets/Editor/InteractionSystem/InteractableEditorWindow.cs b/Assets/Editor/InteractionSystem/InteractableEditorWindow.cs new file mode 100644 index 00000000..923b1336 --- /dev/null +++ b/Assets/Editor/InteractionSystem/InteractableEditorWindow.cs @@ -0,0 +1,748 @@ +using System.Collections.Generic; +using System.Linq; +using UnityEditor; +using UnityEngine; +using Interactions; +using System.Reflection; +using System; + +namespace AppleHills.Editor.InteractionSystem +{ + /// + /// Editor utility for managing and debugging interactable objects in the scene. + /// Provides scene object locator, inspector editing, and runtime debugging capabilities. + /// + public class InteractableEditorWindow : EditorWindow + { + // Tab management + private int _selectedTab = 0; + private readonly string[] _tabNames = { "Scene", "Debug" }; + + // Scene interactables tracking + private List _sceneInteractables = new List(); + private InteractableBase _selectedInteractable; + private GameObject _selectedGameObject; + + // UI state + private Vector2 _listScrollPosition; + private Vector2 _inspectorScrollPosition; + private Vector2 _debugScrollPosition; + private string _searchQuery = ""; + + // Runtime state + private bool _isPlaying = false; + + // Editor for selected interactable + private UnityEditor.Editor _cachedEditor; + + // Available interactable types for adding + private static readonly Type[] AvailableInteractableTypes = new Type[] + { + typeof(OneClickInteraction), + typeof(Pickup), + typeof(ItemSlot), + typeof(SaveableInteractable), + typeof(InteractableBase) + }; + + [MenuItem("AppleHills/Interactable Editor")] + public static void ShowWindow() + { + var window = GetWindow("Interactable Editor"); + window.minSize = new Vector2(900, 600); + window.Show(); + } + + private void OnEnable() + { + RefreshSceneInteractables(); + + // Register for scene and selection changes + UnityEditor.SceneManagement.EditorSceneManager.sceneOpened += OnSceneOpened; + UnityEditor.SceneManagement.EditorSceneManager.sceneClosed += OnSceneClosed; + Selection.selectionChanged += OnSelectionChanged; + EditorApplication.playModeStateChanged += OnPlayModeStateChanged; + EditorApplication.hierarchyChanged += OnHierarchyChanged; + } + + private void OnDisable() + { + UnityEditor.SceneManagement.EditorSceneManager.sceneOpened -= OnSceneOpened; + UnityEditor.SceneManagement.EditorSceneManager.sceneClosed -= OnSceneClosed; + Selection.selectionChanged -= OnSelectionChanged; + EditorApplication.playModeStateChanged -= OnPlayModeStateChanged; + EditorApplication.hierarchyChanged -= OnHierarchyChanged; + + // Clean up cached editor + if (_cachedEditor != null) + { + DestroyImmediate(_cachedEditor); + _cachedEditor = null; + } + } + + private void OnSceneOpened(UnityEngine.SceneManagement.Scene scene, UnityEditor.SceneManagement.OpenSceneMode mode) + { + RefreshSceneInteractables(); + } + + private void OnSceneClosed(UnityEngine.SceneManagement.Scene scene) + { + RefreshSceneInteractables(); + } + + private void OnHierarchyChanged() + { + RefreshSceneInteractables(); + } + + private void OnSelectionChanged() + { + // Check if selected object has changed + if (Selection.activeGameObject != null && Selection.activeGameObject != _selectedGameObject) + { + var interactable = Selection.activeGameObject.GetComponent(); + if (interactable != null) + { + // GameObject has an interactable - select it + SelectInteractable(interactable); + } + else + { + // GameObject doesn't have an interactable - track it for add menu + _selectedGameObject = Selection.activeGameObject; + _selectedInteractable = null; + + // Clear cached editor + if (_cachedEditor != null) + { + DestroyImmediate(_cachedEditor); + _cachedEditor = null; + } + } + Repaint(); + } + else if (Selection.activeGameObject == null) + { + // Nothing selected - clear selection + _selectedGameObject = null; + _selectedInteractable = null; + + if (_cachedEditor != null) + { + DestroyImmediate(_cachedEditor); + _cachedEditor = null; + } + Repaint(); + } + } + + private void OnPlayModeStateChanged(PlayModeStateChange state) + { + _isPlaying = EditorApplication.isPlaying; + if (_isPlaying) + { + RefreshSceneInteractables(); + } + Repaint(); + } + + private void OnGUI() + { + DrawHeader(); + + _selectedTab = GUILayout.Toolbar(_selectedTab, _tabNames); + + EditorGUILayout.Space(); + + switch (_selectedTab) + { + case 0: // Scene tab + DrawSceneTab(); + break; + case 1: // Debug tab + DrawDebugTab(); + break; + } + } + + #region Header UI + + private void DrawHeader() + { + EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); + + if (GUILayout.Button("Refresh", EditorStyles.toolbarButton, GUILayout.Width(60))) + { + RefreshSceneInteractables(); + } + + GUILayout.FlexibleSpace(); + + // Tab-specific toolbar options + if (_selectedTab == 0) // Scene tab + { + EditorGUILayout.LabelField($"Found: {_sceneInteractables.Count} interactables", EditorStyles.toolbarButton, GUILayout.Width(150)); + } + else if (_selectedTab == 1) // Debug tab + { + EditorGUILayout.LabelField(_isPlaying ? "Runtime Active" : "Editor Mode", EditorStyles.toolbarButton, GUILayout.Width(100)); + } + + EditorGUILayout.EndHorizontal(); + } + + #endregion + + #region Scene Tab + + private void DrawSceneTab() + { + EditorGUILayout.BeginHorizontal(); + + // Left panel - interactable list + EditorGUILayout.BeginVertical(GUILayout.Width(300)); + DrawInteractableListPanel(); + EditorGUILayout.EndVertical(); + + // Separator + EditorGUILayout.Space(5); + + // Right panel - inspector/editor + EditorGUILayout.BeginVertical(); + DrawInspectorPanel(); + EditorGUILayout.EndVertical(); + + EditorGUILayout.EndHorizontal(); + } + + private void DrawInteractableListPanel() + { + // Search field + EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); + _searchQuery = EditorGUILayout.TextField(_searchQuery, EditorStyles.toolbarSearchField); + if (GUILayout.Button("×", EditorStyles.toolbarButton, GUILayout.Width(20)) && !string.IsNullOrEmpty(_searchQuery)) + { + _searchQuery = ""; + GUI.FocusControl(null); + } + EditorGUILayout.EndHorizontal(); + + _listScrollPosition = EditorGUILayout.BeginScrollView(_listScrollPosition); + + // Filter interactables by search query + var filteredInteractables = string.IsNullOrEmpty(_searchQuery) + ? _sceneInteractables + : _sceneInteractables.Where(i => i != null && i.gameObject.name.ToLower().Contains(_searchQuery.ToLower())).ToList(); + + if (filteredInteractables.Count == 0) + { + EditorGUILayout.HelpBox("No interactables found in scene", MessageType.Info); + } + else + { + foreach (var interactable in filteredInteractables) + { + if (interactable == null) continue; + + if (DrawInteractableListItem(interactable)) + { + SelectInteractable(interactable); + } + } + } + + EditorGUILayout.EndScrollView(); + } + + private bool DrawInteractableListItem(InteractableBase interactable) + { + bool isSelected = interactable == _selectedInteractable; + + Color originalColor = GUI.backgroundColor; + if (isSelected) + GUI.backgroundColor = new Color(0.3f, 0.5f, 0.8f); + + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + GUI.backgroundColor = originalColor; + + EditorGUILayout.BeginHorizontal(); + + // Interactable info + EditorGUILayout.BeginVertical(); + EditorGUILayout.LabelField(interactable.gameObject.name, EditorStyles.boldLabel); + EditorGUILayout.LabelField(interactable.GetType().Name, EditorStyles.miniLabel); + + // Show additional info for specific types + if (interactable is Pickup pickup && pickup.itemData != null) + { + EditorGUILayout.LabelField($"Item: {pickup.itemData.itemName}", EditorStyles.miniLabel); + } + else if (interactable is ItemSlot slot && slot.itemData != null) + { + EditorGUILayout.LabelField($"Slot: {slot.itemData.itemName}", EditorStyles.miniLabel); + } + + EditorGUILayout.EndVertical(); + + GUILayout.FlexibleSpace(); + + // Ping button + if (GUILayout.Button("Ping", GUILayout.Width(50))) + { + EditorGUIUtility.PingObject(interactable.gameObject); + Selection.activeGameObject = interactable.gameObject; + } + + EditorGUILayout.EndHorizontal(); + EditorGUILayout.EndVertical(); + + Rect itemRect = GUILayoutUtility.GetLastRect(); + bool wasClicked = Event.current.type == EventType.MouseDown && Event.current.button == 0 + && itemRect.Contains(Event.current.mousePosition); + + if (wasClicked) + { + Event.current.Use(); + } + + return wasClicked; + } + + private void DrawInspectorPanel() + { + _inspectorScrollPosition = EditorGUILayout.BeginScrollView(_inspectorScrollPosition); + + if (_selectedInteractable == null && _selectedGameObject == null) + { + EditorGUILayout.HelpBox("Select an interactable from the list or in the scene hierarchy", MessageType.Info); + } + else if (_selectedGameObject != null && _selectedInteractable == null) + { + // Selected object doesn't have an interactable - show add menu + DrawAddInteractableMenu(); + } + else if (_selectedInteractable != null) + { + // Draw custom inspector for the selected interactable + DrawInteractableInspector(); + } + + EditorGUILayout.EndScrollView(); + } + + private void DrawAddInteractableMenu() + { + EditorGUILayout.HelpBox($"GameObject '{_selectedGameObject.name}' doesn't have an Interactable component", MessageType.Info); + + EditorGUILayout.Space(); + EditorGUILayout.LabelField("Add Interactable Component:", EditorStyles.boldLabel); + + foreach (var interactableType in AvailableInteractableTypes) + { + if (GUILayout.Button($"Add {interactableType.Name}", GUILayout.Height(30))) + { + Undo.RecordObject(_selectedGameObject, $"Add {interactableType.Name}"); + var component = _selectedGameObject.AddComponent(interactableType) as InteractableBase; + EditorUtility.SetDirty(_selectedGameObject); + SelectInteractable(component); + RefreshSceneInteractables(); + } + } + } + + private void DrawInteractableInspector() + { + // Header + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField("Editing:", EditorStyles.boldLabel, GUILayout.Width(60)); + EditorGUILayout.LabelField(_selectedInteractable.gameObject.name, EditorStyles.boldLabel); + + GUILayout.FlexibleSpace(); + + if (GUILayout.Button("Ping", GUILayout.Width(50))) + { + EditorGUIUtility.PingObject(_selectedInteractable.gameObject); + Selection.activeGameObject = _selectedInteractable.gameObject; + } + + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(); + + // Draw default inspector using Editor + if (_cachedEditor == null || _cachedEditor.target != _selectedInteractable) + { + if (_cachedEditor != null) + { + DestroyImmediate(_cachedEditor); + } + _cachedEditor = UnityEditor.Editor.CreateEditor(_selectedInteractable); + } + + if (_cachedEditor != null) + { + EditorGUI.BeginChangeCheck(); + _cachedEditor.OnInspectorGUI(); + if (EditorGUI.EndChangeCheck()) + { + EditorUtility.SetDirty(_selectedInteractable); + } + } + + EditorGUILayout.Space(); + + // Additional info section + DrawAdditionalInfo(); + } + + private void DrawAdditionalInfo() + { + EditorGUILayout.LabelField("Additional Information", EditorStyles.boldLabel); + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + + // Show component information + EditorGUILayout.LabelField("Type:", _selectedInteractable.GetType().Name); + + // Show attached actions + var actions = _selectedInteractable.GetComponents(); + if (actions.Length > 0) + { + EditorGUILayout.LabelField($"Actions: {actions.Length}"); + EditorGUI.indentLevel++; + foreach (var action in actions) + { + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField(action.GetType().Name, EditorStyles.miniLabel); + if (GUILayout.Button("Ping", GUILayout.Width(50))) + { + EditorGUIUtility.PingObject(action); + } + EditorGUILayout.EndHorizontal(); + } + EditorGUI.indentLevel--; + } + + // Show specific type info + if (_selectedInteractable is Pickup pickup) + { + EditorGUILayout.Space(); + EditorGUILayout.LabelField("Pickup Info:", EditorStyles.boldLabel); + if (pickup.itemData != null) + { + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField("Item Data:", GUILayout.Width(100)); + EditorGUILayout.ObjectField(pickup.itemData, typeof(PickupItemData), false); + if (GUILayout.Button("Ping", GUILayout.Width(50))) + { + EditorGUIUtility.PingObject(pickup.itemData); + } + EditorGUILayout.EndHorizontal(); + } + } + else if (_selectedInteractable is ItemSlot slot) + { + EditorGUILayout.Space(); + EditorGUILayout.LabelField("Slot Info:", EditorStyles.boldLabel); + if (slot.itemData != null) + { + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField("Slot Data:", GUILayout.Width(100)); + EditorGUILayout.ObjectField(slot.itemData, typeof(PickupItemData), false); + if (GUILayout.Button("Ping", GUILayout.Width(50))) + { + EditorGUIUtility.PingObject(slot.itemData); + } + EditorGUILayout.EndHorizontal(); + } + + if (_isPlaying) + { + var slottedObject = slot.GetSlottedObject(); + if (slottedObject != null) + { + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField("Slotted Object:", GUILayout.Width(100)); + EditorGUILayout.ObjectField(slottedObject, typeof(GameObject), true); + if (GUILayout.Button("Ping", GUILayout.Width(50))) + { + EditorGUIUtility.PingObject(slottedObject); + } + EditorGUILayout.EndHorizontal(); + } + } + } + + EditorGUILayout.EndVertical(); + } + + #endregion + + #region Debug Tab + + private void DrawDebugTab() + { + if (!_isPlaying) + { + EditorGUILayout.HelpBox("Enter Play Mode to debug interactables at runtime", MessageType.Info); + return; + } + + _debugScrollPosition = EditorGUILayout.BeginScrollView(_debugScrollPosition); + + EditorGUILayout.LabelField("Scene Interactables", EditorStyles.boldLabel); + EditorGUILayout.HelpBox("Test interactions and trigger individual events", MessageType.Info); + + EditorGUILayout.Space(); + + foreach (var interactable in _sceneInteractables) + { + if (interactable == null) continue; + + DrawDebugInteractableItem(interactable); + } + + EditorGUILayout.EndScrollView(); + } + + private void DrawDebugInteractableItem(InteractableBase interactable) + { + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + + // Header + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField(interactable.gameObject.name, EditorStyles.boldLabel); + EditorGUILayout.LabelField($"({interactable.GetType().Name})", EditorStyles.miniLabel); + + GUILayout.FlexibleSpace(); + + if (GUILayout.Button("Ping", GUILayout.Width(50))) + { + EditorGUIUtility.PingObject(interactable.gameObject); + Selection.activeGameObject = interactable.gameObject; + } + + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(); + + // Interaction buttons + EditorGUILayout.LabelField("Trigger Interaction:", EditorStyles.boldLabel); + + EditorGUILayout.BeginHorizontal(); + + if (GUILayout.Button("Full Interaction", GUILayout.Height(25))) + { + TriggerFullInteraction(interactable); + } + + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(); + + // Event buttons + EditorGUILayout.LabelField("Trigger Individual Events:", EditorStyles.boldLabel); + + EditorGUILayout.BeginHorizontal(); + + if (GUILayout.Button("Started")) + { + TriggerEvent(interactable, "OnInteractionStarted"); + TriggerUnityEvent(interactable, "interactionStarted"); + } + + if (GUILayout.Button("Arrived")) + { + TriggerEvent(interactable, "OnInteractingCharacterArrived"); + TriggerUnityEvent(interactable, "characterArrived"); + } + + if (GUILayout.Button("Do Interaction")) + { + TriggerEvent(interactable, "DoInteraction"); + } + + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.BeginHorizontal(); + + if (GUILayout.Button("Complete (Success)")) + { + TriggerEventWithParam(interactable, "OnInteractionFinished", true); + TriggerUnityEventWithParam(interactable, "interactionComplete", true); + } + + if (GUILayout.Button("Complete (Fail)")) + { + TriggerEventWithParam(interactable, "OnInteractionFinished", false); + TriggerUnityEventWithParam(interactable, "interactionComplete", false); + } + + if (GUILayout.Button("Interrupted")) + { + TriggerUnityEvent(interactable, "interactionInterrupted"); + } + + EditorGUILayout.EndHorizontal(); + + // Show registered actions + var actions = interactable.GetComponents(); + if (actions.Length > 0) + { + EditorGUILayout.Space(); + EditorGUILayout.LabelField($"Registered Actions ({actions.Length}):", EditorStyles.boldLabel); + + foreach (var action in actions) + { + EditorGUILayout.BeginHorizontal(EditorStyles.helpBox); + EditorGUILayout.LabelField(action.GetType().Name); + + if (action.respondToEvents != null && action.respondToEvents.Count > 0) + { + string events = string.Join(", ", action.respondToEvents); + EditorGUILayout.LabelField($"Events: {events}", EditorStyles.miniLabel); + } + + EditorGUILayout.EndHorizontal(); + } + } + + EditorGUILayout.EndVertical(); + EditorGUILayout.Space(); + } + + #endregion + + #region Data Management + + private void RefreshSceneInteractables() + { + _sceneInteractables.Clear(); + + // Find all interactables in the scene + var allInteractables = FindObjectsByType(FindObjectsSortMode.None); + _sceneInteractables.AddRange(allInteractables); + + // Sort by name for easier browsing + _sceneInteractables.Sort((a, b) => + { + if (a == null || b == null) return 0; + return string.Compare(a.gameObject.name, b.gameObject.name, StringComparison.Ordinal); + }); + } + + private void SelectInteractable(InteractableBase interactable) + { + _selectedInteractable = interactable; + _selectedGameObject = interactable?.gameObject; + + // Clear cached editor to force recreation + if (_cachedEditor != null) + { + DestroyImmediate(_cachedEditor); + _cachedEditor = null; + } + } + + #endregion + + #region Debug Helpers + + private void TriggerFullInteraction(InteractableBase interactable) + { + if (!_isPlaying || interactable == null) return; + + // Simulate a tap on the interactable + Vector3 worldPos = interactable.transform.position; + interactable.OnTap(new Vector2(worldPos.x, worldPos.y)); + + Debug.Log($"[Interactable Editor] Triggered full interaction on {interactable.gameObject.name}"); + } + + private void TriggerEvent(InteractableBase interactable, string methodName) + { + if (!_isPlaying || interactable == null) return; + + Type type = interactable.GetType(); + MethodInfo method = type.GetMethod(methodName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + + if (method != null) + { + try + { + method.Invoke(interactable, null); + Debug.Log($"[Interactable Editor] Invoked {methodName} on {interactable.gameObject.name}"); + } + catch (Exception e) + { + Debug.LogError($"[Interactable Editor] Error invoking {methodName}: {e.Message}"); + } + } + else + { + Debug.LogWarning($"[Interactable Editor] Method {methodName} not found on {type.Name}"); + } + } + + private void TriggerEventWithParam(InteractableBase interactable, string methodName, object param) + { + if (!_isPlaying || interactable == null) return; + + Type type = interactable.GetType(); + MethodInfo method = type.GetMethod(methodName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + + if (method != null) + { + try + { + method.Invoke(interactable, new object[] { param }); + Debug.Log($"[Interactable Editor] Invoked {methodName}({param}) on {interactable.gameObject.name}"); + } + catch (Exception e) + { + Debug.LogError($"[Interactable Editor] Error invoking {methodName}: {e.Message}"); + } + } + else + { + Debug.LogWarning($"[Interactable Editor] Method {methodName} not found on {type.Name}"); + } + } + + private void TriggerUnityEvent(InteractableBase interactable, string fieldName) + { + if (!_isPlaying || interactable == null) return; + + Type type = interactable.GetType(); + FieldInfo field = type.GetField(fieldName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + + if (field != null && field.GetValue(interactable) is UnityEngine.Events.UnityEventBase unityEvent) + { + // Use reflection to invoke the protected Invoke method + MethodInfo invokeMethod = unityEvent.GetType().GetMethod("Invoke", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + if (invokeMethod != null) + { + invokeMethod.Invoke(unityEvent, null); + Debug.Log($"[Interactable Editor] Invoked UnityEvent {fieldName} on {interactable.gameObject.name}"); + } + } + } + + private void TriggerUnityEventWithParam(InteractableBase interactable, string fieldName, bool param) + { + if (!_isPlaying || interactable == null) return; + + Type type = interactable.GetType(); + FieldInfo field = type.GetField(fieldName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + + if (field != null && field.GetValue(interactable) is UnityEngine.Events.UnityEvent unityEvent) + { + unityEvent.Invoke(param); + Debug.Log($"[Interactable Editor] Invoked UnityEvent {fieldName}({param}) on {interactable.gameObject.name}"); + } + } + + #endregion + } +} + diff --git a/Assets/Editor/InteractionSystem/InteractableEditorWindow.cs.meta b/Assets/Editor/InteractionSystem/InteractableEditorWindow.cs.meta new file mode 100644 index 00000000..1f438cd6 --- /dev/null +++ b/Assets/Editor/InteractionSystem/InteractableEditorWindow.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 3045d5bcf3e04203bfe060f80d8913ca +timeCreated: 1762866335 \ No newline at end of file diff --git a/Assets/Editor/PuzzleSystem/PuzzleEditorWindow.cs b/Assets/Editor/PuzzleSystem/PuzzleEditorWindow.cs index dac05fa0..50d87638 100644 --- a/Assets/Editor/PuzzleSystem/PuzzleEditorWindow.cs +++ b/Assets/Editor/PuzzleSystem/PuzzleEditorWindow.cs @@ -560,6 +560,15 @@ namespace AppleHills.Editor.PuzzleSystem EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); EditorGUILayout.LabelField($"Current Level: {_runtimeLevelData.levelId}", EditorStyles.boldLabel); + + GUILayout.FlexibleSpace(); + + // Unlock All button + if (GUILayout.Button("Unlock All", EditorStyles.toolbarButton, GUILayout.Width(100))) + { + UnlockAllPuzzles(); + } + EditorGUILayout.EndHorizontal(); _debugScrollPosition = EditorGUILayout.BeginScrollView(_debugScrollPosition); @@ -870,6 +879,121 @@ namespace AppleHills.Editor.PuzzleSystem UpdateRuntimeData(); } + private void UnlockAllPuzzles() + { + if (!_isPlaying || _runtimeLevelData == null) return; + + PuzzleManager puzzleManager = Object.FindFirstObjectByType(); + if (puzzleManager == null) + { + Debug.LogError("[Puzzle Editor] Cannot find PuzzleManager in scene"); + return; + } + + Debug.Log("[Puzzle Editor] Starting to unlock all puzzles..."); + + // Get all steps from the level data + List allSteps = new List(_runtimeLevelData.allSteps); + + // Track which steps we've processed + HashSet processedSteps = new HashSet(); + bool madeProgress = true; + int maxIterations = 100; // Safety limit to prevent infinite loops + int iteration = 0; + + // Keep iterating until no more steps can be unlocked/completed + while (madeProgress && iteration < maxIterations) + { + madeProgress = false; + iteration++; + + foreach (var step in allSteps) + { + if (step == null || processedSteps.Contains(step.stepId)) + continue; + + // Check if already completed + if (puzzleManager.IsPuzzleStepCompleted(step.stepId)) + { + processedSteps.Add(step.stepId); + continue; + } + + // Check if step is unlocked or can be unlocked + bool isUnlocked = puzzleManager.IsStepUnlocked(step); + + if (!isUnlocked) + { + // Try to unlock it if dependencies are met + // We need to check if all dependencies are completed + bool canUnlock = CanUnlockStep(step, puzzleManager); + + if (canUnlock) + { + // Unlock the step using reflection + System.Type managerType = puzzleManager.GetType(); + System.Reflection.MethodInfo unlockMethod = managerType.GetMethod("UnlockStep", + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.Public | + System.Reflection.BindingFlags.NonPublic); + + if (unlockMethod != null) + { + unlockMethod.Invoke(puzzleManager, new object[] { step }); + Debug.Log($"[Puzzle Editor] Unlocked step: {step.stepId}"); + isUnlocked = true; + } + } + } + + // If unlocked, complete it + if (isUnlocked && !puzzleManager.IsPuzzleStepCompleted(step.stepId)) + { + puzzleManager.MarkPuzzleStepCompleted(step); + Debug.Log($"[Puzzle Editor] Completed step: {step.stepId}"); + processedSteps.Add(step.stepId); + madeProgress = true; + } + } + } + + if (iteration >= maxIterations) + { + Debug.LogWarning($"[Puzzle Editor] Reached maximum iterations ({maxIterations}). Some steps may not have been completed."); + } + + Debug.Log($"[Puzzle Editor] Unlock all complete. Processed {processedSteps.Count} steps in {iteration} iterations."); + + // Update runtime data to reflect all changes + UpdateRuntimeData(); + } + + /// + /// Checks if a step can be unlocked by verifying all its dependencies are completed + /// + private bool CanUnlockStep(PuzzleStepSO step, PuzzleManager puzzleManager) + { + if (step == null || _runtimeLevelData == null) return false; + + // Initial steps can always be unlocked + if (_runtimeLevelData.IsInitialStep(step)) + return true; + + // Check if all dependencies are completed + if (_runtimeLevelData.stepDependencies.TryGetValue(step.stepId, out string[] dependencies)) + { + foreach (var depId in dependencies) + { + if (!puzzleManager.IsPuzzleStepCompleted(depId)) + { + return false; + } + } + } + + return true; + } + #endregion } } diff --git a/Assets/Prefabs/Puzzles/Picnic.prefab b/Assets/Prefabs/Puzzles/Picnic.prefab index 9edb087b..aab47252 100644 --- a/Assets/Prefabs/Puzzles/Picnic.prefab +++ b/Assets/Prefabs/Puzzles/Picnic.prefab @@ -260,6 +260,7 @@ MonoBehaviour: getFlirtyMin: 4 getFlirtyMax: 5 fakeChocolate: {fileID: 2391935521422290070} + realChocolate: {fileID: 0} distractedAudioClips: {fileID: 6418180475301049370, guid: 956d8d84e8dd1de4e94ba48c041dc6ec, type: 2} angryAudioClips: {fileID: 6418180475301049370, guid: 22e6e844862e5b94989b572cb70c1eff, type: 2} feederClips: {fileID: 6418180475301049370, guid: 2e607d3f32c25a14ea074850dd2f8ac5, type: 2} diff --git a/docs/interactables/code_reference.md b/docs/interactables/code_reference.md new file mode 100644 index 00000000..5ebd36dc --- /dev/null +++ b/docs/interactables/code_reference.md @@ -0,0 +1,573 @@ +# Interactables System - Code Reference + +## Table of Contents + +1. [Overview](#overview) +2. [Class Hierarchy](#class-hierarchy) +3. [InteractableBase - The Template Method](#interactablebase---the-template-method) + - [Interaction Flow](#interaction-flow) + - [Virtual Methods to Override](#virtual-methods-to-override) +4. [Creating Custom Interactables](#creating-custom-interactables) + - [Example 1: Simple Button (OneClickInteraction)](#example-1-simple-button-oneclickinteraction) + - [Example 2: Item Pickup](#example-2-item-pickup) + - [Example 3: Item Slot with Validation](#example-3-item-slot-with-validation) +5. [Character Movement](#character-movement) +6. [Action Component System](#action-component-system) +7. [Events System](#events-system) +8. [Save/Load System Integration](#saveload-system-integration) +9. [Integration with Puzzle System](#integration-with-puzzle-system) +10. [Advanced Patterns](#advanced-patterns) + +--- + +## Overview + +Simple, centrally orchestrated interaction system for player and follower characters. + +### Core Concepts + +- **Template Method Pattern**: `InteractableBase` defines the interaction flow; subclasses override specific steps +- **Action Component System**: Modular actions respond to interaction events independently +- **Async/Await Flow**: Character movement and timeline playback use async patterns +- **Save/Load Integration**: `SaveableInteractable` provides persistence for interaction state + +--- + +## Class Hierarchy + +``` +ManagedBehaviour + └── InteractableBase + ├── OneClickInteraction + └── SaveableInteractable + ├── Pickup + └── ItemSlot +``` + +### Class Descriptions + +- **InteractableBase** - Abstract base class that orchestrates the complete interaction flow using the Template Method pattern. Handles tap input, character movement, validation, and event dispatching for all interactables. + +- **SaveableInteractable** - Extends InteractableBase with save/load capabilities, integrating with the ManagedBehaviour save system. Provides abstract methods for JSON serialization and deserialization of state. + +- **OneClickInteraction** - Simplest concrete interactable that completes immediately when character arrives with no additional logic. All functionality comes from UnityEvents configured in the Inspector. + +- **Pickup** - Represents items that can be picked up by the follower, handling item combination and state tracking. Integrates with ItemManager and supports bilateral restoration with ItemSlots. + +- **ItemSlot** - Container that accepts specific items with validation for correct/incorrect/forbidden items. Manages item placement, swapping, and supports combination with special puzzle integration that allows swapping when locked. + +--- + +## InteractableBase - The Template Method + +### Interaction Flow + +When a player taps an interactable, the following flow executes: + +```csharp +OnTap() → CanBeClicked() → StartInteractionFlowAsync() + ↓ + 1. Find Characters (player, follower) + 2. OnInteractionStarted() [Virtual Hook] + 3. Fire interactionStarted events + 4. MoveCharactersAsync() + 5. OnInteractingCharacterArrived() [Virtual Hook] + 6. Fire characterArrived events + 7. ValidateInteraction() + 8. DoInteraction() [Virtual Hook - OVERRIDE THIS] + 9. OnInteractionFinished() [Virtual Hook] + 10. Fire interactionComplete events +``` + +### Virtual Methods to Override + +#### 1. `CanBeClicked()` - Pre-Interaction Validation +```csharp +protected virtual bool CanBeClicked() +{ + if (!isActive) return false; + // Add custom checks here + return true; +} +``` +**When to override:** Add high-level validation before interaction starts (cooldowns, prerequisites, etc.) + +#### 2. `OnInteractionStarted()` - Setup Logic +```csharp +protected virtual void OnInteractionStarted() +{ + // Called after characters found, before movement + // Setup animations, sound effects, etc. +} +``` +**When to override:** Perform setup that needs to happen before character movement + +#### 3. `DoInteraction()` - Main Logic ⭐ **OVERRIDE THIS** +```csharp +protected override bool DoInteraction() +{ + // Your interaction logic here + return true; // Return true for success, false for failure +} +``` +**When to override:** **Always override this** - this is your main interaction logic + +#### 4. `OnInteractingCharacterArrived()` - Arrival Reaction +```csharp +protected virtual void OnInteractingCharacterArrived() +{ + // Called when character reaches interaction point + // Trigger arrival animations, sounds, etc. +} +``` +**When to override:** React to character arrival with visuals/audio + +#### 5. `OnInteractionFinished()` - Cleanup Logic +```csharp +protected virtual void OnInteractionFinished(bool success) +{ + // Called after interaction completes + // Cleanup, reset state, etc. +} +``` +**When to override:** Perform cleanup after interaction completes + +#### 6. `CanProceedWithInteraction()` - Validation +```csharp +protected virtual (bool canProceed, string errorMessage) CanProceedWithInteraction() +{ + // Validate if interaction can proceed + // Return error message to show to player + return (true, null); +} +``` +**When to override:** Add validation that shows error messages to player + +--- + +## Creating Custom Interactables + +### Example 1: Simple Button (OneClickInteraction) + +The simplest interactable just completes when the character arrives: + +```csharp +using Interactions; + +public class OneClickInteraction : InteractableBase +{ + protected override bool DoInteraction() + { + // Simply return success - no additional logic needed + return true; + } +} +``` + +**Use Case:** Triggers, pressure plates, simple activators + +**Configuration:** +- Set `characterToInteract` to define which character activates it +- Use UnityEvents in inspector to trigger game logic + +--- + +### Example 2: Item Pickup + +From `Pickup.cs` - demonstrates validation and follower interaction: + +```csharp +public class Pickup : SaveableInteractable +{ + public PickupItemData itemData; + public bool IsPickedUp { get; internal set; } + + protected override bool DoInteraction() + { + // Try combination first if follower is holding something + var heldItemObject = FollowerController?.GetHeldPickupObject(); + var heldItemData = heldItemObject?.GetComponent()?.itemData; + + var combinationResult = FollowerController.TryCombineItems( + this, out var resultItem + ); + + if (combinationResult == FollowerController.CombinationResult.Successful) + { + IsPickedUp = true; + FireCombinationEvent(resultItem, heldItemData); + return true; + } + + // No combination - do regular pickup + FollowerController?.TryPickupItem(gameObject, itemData); + IsPickedUp = true; + OnItemPickedUp?.Invoke(itemData); + return true; + } +} +``` + +**Key Patterns:** +- Access `FollowerController` directly (set by base class) +- Return `true` for successful pickup +- Use custom events (`OnItemPickedUp`) for specific notifications + +--- + +### Example 3: Item Slot with Validation + +From `ItemSlot.cs` - demonstrates complex validation and state management: + +```csharp +public class ItemSlot : SaveableInteractable +{ + public PickupItemData itemData; // What item should go here + private ItemSlotState currentState = ItemSlotState.None; + + protected override (bool canProceed, string errorMessage) CanProceedWithInteraction() + { + var heldItem = FollowerController?.CurrentlyHeldItemData; + + // Can't interact with empty slot and no item + if (heldItem == null && currentlySlottedItemObject == null) + return (false, "This requires an item."); + + // Check forbidden items + if (heldItem != null && currentlySlottedItemObject == null) + { + var config = interactionSettings?.GetSlotItemConfig(itemData); + var forbidden = config?.forbiddenItems ?? new List(); + + if (PickupItemData.ListContainsEquivalent(forbidden, heldItem)) + return (false, "Can't place that here."); + } + + return (true, null); + } + + protected override bool DoInteraction() + { + var heldItemData = FollowerController.CurrentlyHeldItemData; + var heldItemObj = FollowerController.GetHeldPickupObject(); + + // Scenario 1: Slot empty + holding item = Slot it + if (heldItemData != null && currentlySlottedItemObject == null) + { + SlotItem(heldItemObj, heldItemData); + FollowerController.ClearHeldItem(); + return IsSlottedItemCorrect(); // Returns true only if correct item + } + + // Scenario 2: Slot full + holding item = Try combine or swap + if (currentlySlottedItemObject != null) + { + // Try combination... + // Or swap items... + } + + return false; + } +} +``` + +**Key Patterns:** +- `CanProceedWithInteraction()` shows error messages to player +- `DoInteraction()` returns true only for correct item (affects puzzle completion) +- Access settings via `GameManager.GetSettingsObject()` + +--- + +## Character Movement + +### Character Types + +```csharp +public enum CharacterToInteract +{ + None, // No character movement + Trafalgar, // Player only + Pulver, // Follower only (player moves to range first) + Both // Both characters move +} +``` + +Set in Inspector on `InteractableBase`. + +### Custom Movement Targets + +Add `CharacterMoveToTarget` component as child of your interactable: + +```csharp +// Automatically used if present +var moveTarget = GetComponentInChildren(); +Vector3 targetPos = moveTarget.GetTargetPosition(); +``` + +See [Editor Reference](editor_reference.md#character-movement-targets) for details. + +--- + +## Action Component System + +Add modular behaviors to interactables via `InteractionActionBase` components. + +### Creating an Action Component + +```csharp +using Interactions; +using System.Threading.Tasks; + +public class MyCustomAction : InteractionActionBase +{ + protected override async Task ExecuteAsync( + InteractionEventType eventType, + PlayerTouchController player, + FollowerController follower) + { + // Your action logic here + + if (eventType == InteractionEventType.InteractionStarted) + { + // Play sound, spawn VFX, etc. + await Task.Delay(1000); // Simulate async work + } + + return true; // Return success + } + + protected override bool ShouldExecute( + InteractionEventType eventType, + PlayerTouchController player, + FollowerController follower) + { + // Add conditions for when this action should run + return base.ShouldExecute(eventType, player, follower); + } +} +``` + +### Configuring in Inspector + +![Action Component Setup](../media/interactable_action_component_inspector.png) + +- **Respond To Events**: Select which events trigger this action +- **Pause Interaction Flow**: If true, interaction waits for this action to complete + +### Built-in Action: Timeline Playback + +`InteractionTimelineAction` plays Unity Timeline sequences in response to events: + +```csharp +// Automatically configured via Inspector +// See Editor Reference for details +``` + +**Features:** +- Character binding to timeline tracks +- Sequential timeline playback +- Loop options (loop all, loop last) +- Timeout protection + +--- + +## Events System + +### UnityEvents (Inspector-Configurable) + +Available on all `InteractableBase`: + +```csharp +[Header("Interaction Events")] +public UnityEvent interactionStarted; +public UnityEvent interactionInterrupted; +public UnityEvent characterArrived; +public UnityEvent interactionComplete; // bool = success +``` + +### C# Events (Code Subscribers) + +Pickup example: +```csharp +public event Action OnItemPickedUp; +public event Action OnItemsCombined; +``` + +ItemSlot example: +```csharp +public event Action OnItemSlotRemoved; +public event Action OnCorrectItemSlotted; +public event Action OnIncorrectItemSlotted; +``` + +### Subscribing to Events + +```csharp +void Start() +{ + var pickup = GetComponent(); + pickup.OnItemPickedUp += HandleItemPickedUp; +} + +void HandleItemPickedUp(PickupItemData itemData) +{ + Debug.Log($"Picked up: {itemData.itemName}"); +} + +void OnDestroy() +{ + var pickup = GetComponent(); + if (pickup != null) + pickup.OnItemPickedUp -= HandleItemPickedUp; +} +``` + +--- + +## Save/Load System Integration + +### Making an Interactable Saveable + +1. Inherit from `SaveableInteractable` instead of `InteractableBase` +2. Define a serializable data structure +3. Override `GetSerializableState()` and `ApplySerializableState()` + +### Example Implementation + +```csharp +using Interactions; +using UnityEngine; + +// 1. Define save data structure +[System.Serializable] +public class MyInteractableSaveData +{ + public bool hasBeenActivated; + public int activationCount; +} + +// 2. Inherit from SaveableInteractable +public class MyInteractable : SaveableInteractable +{ + private bool hasBeenActivated = false; + private int activationCount = 0; + + // 3. Serialize state + protected override object GetSerializableState() + { + return new MyInteractableSaveData + { + hasBeenActivated = this.hasBeenActivated, + activationCount = this.activationCount + }; + } + + // 4. Deserialize state + protected override void ApplySerializableState(string serializedData) + { + var data = JsonUtility.FromJson(serializedData); + if (data == null) return; + + this.hasBeenActivated = data.hasBeenActivated; + this.activationCount = data.activationCount; + + // IMPORTANT: Don't fire events during restoration + // Don't re-run initialization logic + } + + protected override bool DoInteraction() + { + hasBeenActivated = true; + activationCount++; + return true; + } +} +``` +--- + +## Integration with Puzzle System + +Interactables can be puzzle steps by adding `ObjectiveStepBehaviour`: + +```csharp +// On GameObject with Interactable component +var stepBehaviour = gameObject.AddComponent(); +stepBehaviour.stepData = myPuzzleStepSO; +``` + +### Automatic Puzzle Integration + +`InteractableBase` automatically checks for puzzle locks: + +```csharp +private (bool, string) ValidateInteractionBase() +{ + var step = GetComponent(); + if (step != null && !step.IsStepUnlocked()) + { + // Special case: ItemSlots can swap even when locked + if (!(this is ItemSlot)) + { + return (false, "This step is locked!"); + } + } + return (true, null); +} +``` + +**Result:** Locked puzzle steps can't be interacted with (except ItemSlots for item swapping). + +--- + +## Advanced Patterns + +### Async Validation + +For complex validation that requires async operations: + +```csharp +protected override (bool canProceed, string errorMessage) CanProceedWithInteraction() +{ + // Synchronous validation only + // Async validation should be done in OnInteractionStarted + return (true, null); +} + +protected override void OnInteractionStarted() +{ + // Can perform async checks here if needed + // But interaction flow continues automatically +} +``` + +### Interrupting Interactions + +Interactions auto-interrupt if player cancels movement: + +```csharp +// Automatically handled in MoveCharactersAsync() +playerRef.OnMoveToCancelled += () => { + interactionInterrupted?.Invoke(); + // Flow stops here +}; +``` + +### One-Time Interactions + +```csharp +[Header("Interaction Settings")] +public bool isOneTime = true; + +// Automatically disabled after first successful interaction +// No override needed +``` + +### Cooldown Systems + +```csharp +[Header("Interaction Settings")] +public float cooldown = 5f; // Seconds + +// Automatically handled by base class +// Interaction disabled for 5 seconds after completion +``` \ No newline at end of file diff --git a/docs/interactables/editor_reference.md b/docs/interactables/editor_reference.md new file mode 100644 index 00000000..18d92a3a --- /dev/null +++ b/docs/interactables/editor_reference.md @@ -0,0 +1,305 @@ +# Interactables System - Editor Reference + +## Table of Contents + +1. [Overview](#overview) +2. [Adding Interactables to Scene](#adding-interactables-to-scene) +3. [InteractableBase Inspector](#interactablebase-inspector) + - [Interaction Settings](#interaction-settings) + - [Interaction Events](#interaction-events-unityevents) +4. [Character Movement Targets](#character-movement-targets) +5. [Pickup Inspector](#pickup-inspector) +6. [ItemSlot Inspector](#itemslot-inspector) +7. [OneClickInteraction Inspector](#oneclickinteraction-inspector) +8. [Interaction Action Components](#interaction-action-components) +9. [Custom Action Components](#custom-action-components) +10. [Puzzle Integration](#puzzle-integration) +11. [Save System Configuration](#save-system-configuration) + +--- + +## Overview + +This guide covers configuring interactables using the Unity Inspector and scene tools. It might be helpful, although + not necessary to be familiar with the code architecture covered in the [Code Reference](code_reference.md). + +--- + +## Adding Interactables to Scene + +### Method 1: Add Component Manually +Select GameObject → Add Component → Search "Interactable" → Choose type + +### Method 2: Use Interactable Editor +`AppleHills > Interactable Editor` → Scene tab → Select GameObject → Click button for desired type + +See [Editor Tools Reference](editor_tools_reference.md#interactable-editor) for details. + +--- + +## InteractableBase Inspector + +![InteractableBase Inspector](../media/interactable_base_inspector.png) + +### Interaction Settings + +**Is One Time** - Disable after first successful interaction (switches, consumables) + +**Cooldown** - Temporarily disable after use, in seconds. `-1` = no cooldown (levers, buttons) + +**Character To Interact** - Which character(s) move to activate: +- **None** - No movement, instant interaction +- **Trafalgar** - Player moves to point +- **Pulver** - Follower moves (player moves to range first) +- **Both** - Both characters move + +### Interaction Events (UnityEvents) + +![Interaction Events](../media/interactable_events_inspector.png) + +**Interaction Started** `` - Fires after tap, before movement + +**Interaction Interrupted** - Player cancels or validation fails + +**Character Arrived** - Character reaches destination + +**Interaction Complete** `` - After DoInteraction(), bool = success + +### Example Event Configuration + +![Event Configuration Example](../media/interactable_event_configuration_example.png) + +**Door that opens when player arrives:** +- Character To Interact: `Trafalgar` +- Character Arrived: `DoorAnimator.SetTrigger("Open")`, `AudioSource.Play()` +- Interaction Complete: `PuzzleStep.CompleteStep()` (if success) + +--- + +## Character Movement Targets + +### Default Movement + +Without `CharacterMoveToTarget`, characters move to default distances configured in `GameManager`: +- `PlayerStopDistance` - Follower interactions (~1.5 units) +- `PlayerStopDistanceDirectInteraction` - Player interactions (~0.5 units) + +### Custom Movement Targets + +Add `CharacterMoveToTarget` component to child GameObject: + +![Character Move Target Setup](../media/character_move_target_setup.png) + +**Fields:** +- **Character Type** - Which character (Trafalgar/Pulver/Both/None) +- **Position Offset** - Offset from transform position + +### Scene Gizmos + +![Movement Target Gizmos](../media/movement_target_gizmos.png) + +**Colors:** 🔵 Blue (Trafalgar), 🟠 Orange (Pulver), 🟣 Purple (Both), ⚪ Gray (None) + +--- + +## Pickup Inspector + +![Pickup Inspector](../media/pickup_inspector.png) + +**Required Fields:** + +**Item Data** - `PickupItemData` ScriptableObject defining the item. Create via `Assets > Create > AppleHills > Items + Puzzles > Pickup Item Data` + +**Icon Renderer** - `SpriteRenderer` displaying item icon (auto-assigned if not set) + +### PickupItemData ScriptableObject + +![PickupItemData Inspector](../media/pickup_item_data_inspector.png) + +**Fields:** Item Name, Description, Map Sprite, Pick Up Sound, Drop Sound + +**Item ID** (Read-Only) - Auto-generated unique identifier for save/load + +--- + +## ItemSlot Inspector + +![ItemSlot Inspector](../media/item_slot_inspector.png) + +**Required Fields:** + +**Item Data** - `PickupItemData` defining the **correct** item for this slot + +**Icon Renderer** - `SpriteRenderer` showing slot icon (background/outline) + +**Slotted Item Renderer** - `SpriteRenderer` showing currently slotted item (usually child GameObject) + +### Slot Events + +![ItemSlot Events](../media/item_slot_events.png) + +**On Item Slotted** - Any item placed +**On Item Slot Removed** - Item removed +**On Correct Item Slotted** - Correct item placed (also fires `interactionComplete(true)`) +**On Incorrect Item Slotted** - Wrong item placed +**On Forbidden Item Slotted** - Forbidden item attempted + + +### Slot Item Configuration (Settings) + +![Slot Item Config Settings](../media/slot_item_config_settings.png) + +Configured in `InteractionSettings` at `Assets/Settings/InteractionSettings`: +- **Correct Items** - List of accepted items +- **Forbidden Items** - Items that can't be placed +- **Incorrect Items** - Items that slot but aren't correct + + +--- + +## OneClickInteraction Inspector + +![OneClickInteraction Inspector](../media/oneclick_inspector.png) + +**No additional fields** - only inherits `InteractableBase` settings. + +### Typical Configuration + +- **Character To Interact:** `Trafalgar` or `Pulver` +- **Is One Time:** Depends on use case +- **Interaction Complete Event:** Configure to trigger game logic + +### Example Use Cases + +**Pressure Plate:** +- Character To Interact: `Pulver` +- Is One Time: `false` +- Interaction Complete: Call `Door.Open()` + +**Tutorial Trigger:** +- Character To Interact: `None` +- Is One Time: `true` +- Interaction Started: Call `TutorialManager.ShowTip()` + +**Dialogue Starter:** +- Character To Interact: `Both` +- Is One Time: `false` +- Character Arrived: Call `DialogueManager.StartDialogue()` + +--- + +## Interaction Action Components + +### InteractionTimelineAction + +![InteractionTimelineAction Inspector](../media/interaction_timeline_action_inspector.png) + +Plays Unity Timeline sequences in response to interaction events. + +#### Required Fields + +**Playable Director** +- **Type:** `PlayableDirector` component +- **Purpose:** Timeline player +- **Setup:** Auto-assigned from same GameObject if present + +**Timeline Mappings** (Array) +Each element maps an interaction event to timeline(s): + +![Timeline Mapping Element](../media/timeline_mapping_element.png) + +##### Event Type +- **Type:** `InteractionEventType` enum +- **Options:** + - `InteractionStarted` + - `PlayerArrived` + - `InteractingCharacterArrived` + - `InteractionComplete` + - `InteractionInterrupted` +- **Purpose:** When to play this timeline + +##### Timelines (Array) +- **Type:** `PlayableAsset[]` +- **Purpose:** Timeline(s) to play for this event +- **Note:** Plays sequentially if multiple + +##### Bind Player Character +- **Type:** `bool` +- **Purpose:** Automatically bind player to timeline track +- **Track Name:** `Player` (customizable via Player Track Name field) + +##### Bind Pulver Character +- **Type:** `bool` +- **Purpose:** Automatically bind follower to timeline track +- **Track Name:** `Pulver` (customizable via Pulver Track Name field) + +##### Player Track Name / Pulver Track Name +- **Type:** `string` +- **Default:** `"Player"` / `"Pulver"` +- **Purpose:** Name of timeline track to bind character to +- **Note:** Must match track name in Timeline asset exactly + +##### Timeout Seconds +- **Type:** `float` +- **Default:** `30` +- **Purpose:** Safety timeout - auto-complete if timeline doesn't finish +- **Use Case:** Prevent stuck interactions if timeline errors + +##### Loop Last / Loop All +- **Type:** `bool` +- **Purpose:** Loop behavior for timeline sequence +- **Loop Last:** Replays final timeline on next interaction +- **Loop All:** Cycles through all timelines repeatedly + +--- + +## Custom Action Components + +See [Code Reference - Action Component System](code_reference.md#action-component-system). + +**Base Fields:** +- **Respond To Events** - Which events trigger this action +- **Pause Interaction Flow** - Wait for completion (`true`) or run in background (`false`) + +--- + +## Puzzle Integration + +### Adding Puzzle Step to Interactable + +1. Select interactable GameObject +2. Add Component → `ObjectiveStepBehaviour` +3. Assign `Step Data` (PuzzleStepSO asset) + +![Puzzle Step Integration](../media/puzzle_step_integration.png) + +**GameObject has two components:** +- **ItemSlot** (or other Interactable type) +- **ObjectiveStepBehaviour** + +**Behavior:** +- Interactable locked until puzzle step unlocked +- Successful interaction (return `true` from `DoInteraction()`) completes puzzle step +- ItemSlots can still swap items when locked (special case) + +### Automatic Step Completion + +**For Pickup:** +```csharp +protected override bool DoInteraction() +{ + // ...pickup logic... + return true; // Automatically completes puzzle step if present +} +``` + +**For ItemSlot:** +```csharp +protected override bool DoInteraction() +{ + // ...slot logic... + return IsSlottedItemCorrect(); // Only completes if correct item +} +``` + +No additional code needed - `InteractableBase` handles step completion automatically. \ No newline at end of file diff --git a/docs/interactables/editor_tools_reference.md b/docs/interactables/editor_tools_reference.md new file mode 100644 index 00000000..a43214e1 --- /dev/null +++ b/docs/interactables/editor_tools_reference.md @@ -0,0 +1,251 @@ +# Editor Tools Reference + +## Overview + +AppleHills provides two specialized editor tools for managing puzzles and interactables: +- **Interactable Editor** - Manage scene interactables and debug interactions +- **Puzzle Editor** - Manage puzzle steps and debug puzzle flow + +Both tools are accessible via the `AppleHills` menu and follow a consistent two-tab design pattern for editing and debugging. + +--- + +## Interactable Editor + +**Menu:** `AppleHills > Interactable Editor` + +The Interactable Editor provides scene-based management and runtime debugging for interactable objects in your scenes. + +### Edit Tab + +![Interactable Editor - Edit Tab](../media/interactable_editor_edit.png) + +The Edit tab lets you browse, select, and modify all interactables in the current scene with a real-time inspector. + +#### Left Panel - Scene Interactable List + +**Refresh Button** - Manual refresh (auto-refreshes on scene changes, hierarchy changes, and play mode toggle) + +**Found Count** - Displays total number of interactables found in the active scene + +**Search Field** - Filter interactables by GameObject name for quick access + +**Interactable Cards** - Each card shows: +- GameObject name +- Interactable component type +- Item/Slot data reference (for Pickup and ItemSlot types) +- Ping button to highlight in scene hierarchy + +**Auto-Discovery:** Automatically scans for all `InteractableBase` components when: +- Scene loads or unloads +- GameObjects or components are added/removed +- Play mode is toggled + +#### Right Panel - Dynamic Inspector + +The right panel adapts based on what's selected: + +**Nothing Selected State:** +- Shows a help message prompting you to select an interactable from the list + +**GameObject Without Interactable State:** +- Displays "Add Interactable Component" buttons for: + - OneClickInteraction + - Pickup + - ItemSlot + - SaveableInteractable + - InteractableBase +- Clicking any button adds the component with full undo support and auto-refreshes the list + +**Interactable Selected State:** +- Shows full Unity inspector for the selected component +- All changes auto-save with undo/redo support (Ctrl+Z / Ctrl+Y) +- Additional Information section displays: + - Component type + - Attached action components with Ping buttons + - Type-specific data (Item Data for Pickup, Slot Data for ItemSlot) + - In Play Mode: shows slotted object for ItemSlot types + +**Selection Synchronization:** +- Bidirectional sync between editor list and scene hierarchy +- Selecting in the list highlights in hierarchy and vice versa +- Selecting a GameObject without an interactable shows the "Add Component" interface + +--- + +![Interactable Editor - Debug Tab](../media/interactable_editor_debug.png) + +### Debug Tab + +**Availability:** Play Mode only + +The Debug tab provides runtime testing tools for triggering interactions and events on interactables. + +#### Interactable Debug Cards + +Each debug card represents one interactable in the scene and includes: + +**Header Section:** +- GameObject name (bold text) +- Interactable component type (gray text) +- Ping button (locates object in hierarchy) + +**Full Interaction Button:** +- Simulates complete `OnTap()` flow +- Triggers character movement and full event chain +- Tests end-to-end interaction behavior + +**Individual Event Triggers:** +- **Started** - Calls `OnInteractionStarted()` and fires `interactionStarted` event +- **Arrived** - Calls `OnInteractingCharacterArrived()` and fires `characterArrived` event +- **Do Interaction** - Calls `DoInteraction()` directly to test core interaction logic +- **Complete (Success)** - Calls `OnInteractionFinished(true)` and triggers puzzle completion +- **Complete (Fail)** - Calls `OnInteractionFinished(false)` to test failure handling +- **Interrupted** - Invokes `interactionInterrupted` event + +**Registered Actions Display:** +- Lists all action components registered to this interactable +- Shows which events each action responds to + +#### Common Testing Workflows + +**Test Full Interaction:** +1. Enter Play Mode +2. Find target interactable in debug list +3. Click **Full Interaction** button +4. Verify complete behavior chain + +**Test Specific Event:** +1. Enter Play Mode +2. Locate interactable +3. Click individual event button (e.g., **Started** or **Arrived**) +4. Verify specific event behavior + +**Test Event Sequence:** +1. Click **Started** +2. Click **Arrived** +3. Click **Do Interaction** +4. Click **Complete (Success)** +5. Verify full event chain executes correctly + +**Test Action Integration:** +1. Find interactable with timeline or dialogue action +2. Check Registered Actions to confirm action is attached +3. Click **Started** or appropriate event trigger +4. Verify action executes (timeline plays, dialogue shows, etc.) + +**Test Puzzle Integration:** +1. Open both Interactable Editor and Puzzle Editor +2. Verify required puzzle step is unlocked in Puzzle Editor +3. Click **Full Interaction** in Interactable Editor +4. Switch to Puzzle Editor and verify step marked as completed + +--- + +## Puzzle Editor + +**Menu:** `AppleHills > Puzzle Editor` + +![Puzzle Editor - Edit Tab](../media/puzzle_editor_edit.png) + +The Puzzle Editor manages puzzle step assets and provides runtime debugging for the puzzle progression system. + +### Edit Tab + +The Edit tab displays all `PuzzleStepSO` assets in your project with full editing capabilities. + +#### Left Panel - Puzzle Step List + +**Search Field** - Filter puzzle steps by name + +**Folder Organization:** +- Steps are grouped by their asset folder location +- Click folder headers to expand/collapse groups +- Helps organize large numbers of puzzle steps + +**Step Cards** - Each card displays: +- Display name (user-friendly identifier) +- Step ID (unique technical identifier) +- Dependency information (unlocked by / unlocks) + +**Toolbar Actions:** +- **Refresh** - Reloads all puzzle step assets from project +- **Create New** - Opens creation dialog + +**Creating New Steps:** +1. Click **Create New** button +2. Enter step name (stepId auto-generates from name) +3. Select destination folder +4. Click Create +5. New step appears in list and is auto-selected + +#### Right Panel - Step Inspector + +When a puzzle step is selected, the inspector shows: + +**Basic Properties:** +- **Display Name** - Editable user-friendly name for the step +- **Step ID** - Read-only unique identifier (lowercase, underscored format) + +**Dependencies Configuration:** +- **Unlocked By** - List of steps that must complete before this step unlocks + - Drag and drop `PuzzleStepSO` assets to add dependencies + - Empty list means this is an initial step (unlocked by default) +- **Unlocks** - List of steps that this step will unlock when completed + - Bidirectional relationship (automatically syncs with "Unlocked By" on other steps) + - Edit from either side of the relationship + +**Asset Management:** +- **Asset Path** - Shows full file path to the .asset file +- **Delete Button** - Permanently deletes the step asset + - Shows confirmation dialog before deletion + - Cannot be undone after confirmation + +**Auto-Save:** All changes save automatically to the asset with full undo/redo support (Ctrl+Z / Ctrl+Y) + +![Puzzle Editor - Debug Tab](../media/puzzle_editor_debug.png) + +--- + +### Debug Tab + +**Availability:** Play Mode only + +The Debug tab provides runtime testing and debugging tools for the puzzle progression system. + +#### Toolbar + +**Current Level Display:** +- Shows the name of the currently loaded puzzle level +- Updates automatically when scenes change +- Displays "No level loaded" if puzzle system is inactive + +**Unlock All Button:** +- Unlocks and completes all puzzle steps in the current level +- Processes steps in dependency order using iterative algorithm +- Logs progression to console for debugging +- Useful for testing late-game content or verifying completion flow + +#### Step List + +Each step in the current level displays: + +**Step Header:** +- Display name in bold text +- Step ID in gray text below name + +**State Indicators:** +- 🔒 **Locked** (gray background) - Dependencies not met, step unavailable +- 🔓 **Unlocked** (yellow background) - Available for interaction but not completed +- ✅ **Completed** (green background) - Successfully completed + +**Action Buttons:** +- **Toggle Lock** - Manually lock/unlock the step + - Bypasses normal dependency requirements + - Useful for testing specific scenarios + - Does not affect dependent steps automatically +- **Complete** - Marks step as completed + - Only enabled when step is unlocked + - Fires completion events + - Automatically unlocks dependent steps + - Updates state indicators in real-time diff --git a/docs/interactables_readme.md b/docs/interactables_readme.md deleted file mode 100644 index 06900bbc..00000000 --- a/docs/interactables_readme.md +++ /dev/null @@ -1,253 +0,0 @@ -# Apple Hills Interaction System - -A concise, code-first guide to creating and extending interactions using `Interactable` and modular action/requirement components. Designed to match the style of the other updated docs (TOC, inline code, case studies). - -## Table of Contents -- [What This Solves](#what-this-solves) -- [Architecture at a Glance](#architecture-at-a-glance) -- [Quick Start (Code-First)](#quick-start-code-first) - - [Subscribe to Interaction Events](#subscribe-to-interaction-events) - - [Create a Custom Action](#create-a-custom-action) - - [Trigger Programmatically](#trigger-programmatically) -- [Core Components](#core-components) - - [`Interactable`](#interactable) - - [`CharacterMoveToTarget`](#charactermovetotarget) - - [`InteractionActionBase` and concrete actions](#interactionactionbase-and-concrete-actions) - - [`InteractionRequirementBase`](#interactionrequirementbase) -- [Interaction Event Flow](#interaction-event-flow) -- [Case Studies](#case-studies) - - [Open a Door on Arrival](#open-a-door-on-arrival) - - [Pick Up an Item then Play Timeline](#pick-up-an-item-then-play-timeline) - - [Kick Off Dialogue When Player Arrives](#kick-off-dialogue-when-player-arrives) -- [Troubleshooting / FAQ](#troubleshooting--faq) -- [Paths & Namespaces](#paths--namespaces) -- [Change Log](#change-log) - -## What This Solves -- Standardized interaction lifecycle with reliable events (`InteractionStarted`, `PlayerArrived`, `InteractingCharacterArrived`, `InteractionComplete`, `InteractionInterrupted`). -- Composable behavior via components derived from `InteractionActionBase` and `InteractionRequirementBase`. -- Clean separation of input, locomotion-to-target, cinematic timelines, and game logic. - -## Architecture at a Glance -- Driver: `Interactable` — owns lifecycle, input hook, character selection via `CharacterToInteract`, one‑shot/cooldown, and event dispatch. -- Targets: `CharacterMoveToTarget` — editor-authored world points for `Trafalgar`/`Pulver` to path to before executing actions. -- Actions: `InteractionActionBase` (abstract) — modular responses to specific `InteractionEventType` values; can pause the flow with async tasks. -- Requirements: `InteractionRequirementBase` (abstract) — gatekeepers for availability; multiple can be attached. -- Cinematics: `InteractionTimelineAction` — plays one or more `PlayableAsset` timelines per event; optional character auto-binding. - -## Quick Start (Code-First) - -### Subscribe to Interaction Events -```csharp -using Interactions; -using UnityEngine; - -public class InteractDebugHooks : MonoBehaviour -{ - [SerializeField] private Interactable interactable; - - private void OnEnable() - { - interactable.interactionStarted.AddListener(OnStarted); - interactable.characterArrived.AddListener(OnCharacterArrived); - interactable.interactionInterrupted.AddListener(OnInterrupted); - interactable.interactionComplete.AddListener(OnComplete); - } - - private void OnDisable() - { - interactable.interactionStarted.RemoveListener(OnStarted); - interactable.characterArrived.RemoveListener(OnCharacterArrived); - interactable.interactionInterrupted.RemoveListener(OnInterrupted); - interactable.interactionComplete.RemoveListener(OnComplete); - } - - private void OnStarted(Input.PlayerTouchController player, FollowerController follower) - => Debug.Log("Interaction started"); - - private void OnCharacterArrived() => Debug.Log("Character arrived"); - private void OnInterrupted() => Debug.Log("Interaction interrupted"); - private void OnComplete(bool success) => Debug.Log($"Interaction complete: {success}"); -} -``` - -### Create a Custom Action -```csharp -using System.Threading.Tasks; -using Interactions; -using Input; -using UnityEngine; - -public class PlaySfxOnArrivalAction : InteractionActionBase -{ - [SerializeField] private AudioSource sfx; - - private void Reset() - { - // React to the arrival event; don't block the flow - respondToEvents = new() { InteractionEventType.InteractingCharacterArrived }; - pauseInteractionFlow = false; - } - - protected override bool ShouldExecute(InteractionEventType evt, PlayerTouchController player, FollowerController follower) - { - return sfx != null; - } - - protected override async Task ExecuteAsync(InteractionEventType evt, PlayerTouchController player, FollowerController follower) - { - sfx.Play(); - // non-blocking action returns immediately when pauseInteractionFlow == false - return true; - } -} -``` -Attach this component under the same hierarchy as an `Interactable`. Registration is automatic via `OnEnable()`/`OnDisable()` in `InteractionActionBase`. - -### Trigger Programmatically -Normally input goes through `ITouchInputConsumer.OnTap(...)`. For testing, you can call the public tap handler: -```csharp -using UnityEngine; -using Interactions; - -public class TestTrigger : MonoBehaviour -{ - [SerializeField] private Interactable interactable; - - [ContextMenu("Trigger Interact (dev)")] - private void Trigger() - { - interactable.OnTap(interactable.transform.position); - } -} -``` - -## Core Components - -### `Interactable` -- Handles input, cooldowns (`cooldown`), one‑shot (`isOneTime`), and which character participates (`characterToInteract`). -- Exposes events: `interactionStarted`, `characterArrived`, `interactionInterrupted`, `interactionComplete`. -- Discovers and dispatches to child `InteractionActionBase` components; awaits those that request to pause. - -![Interactable Inspector](media/interactable_inspector.png) - -### `CharacterMoveToTarget` -Defines the world positions characters should reach before actions evaluate. -- Can target `Trafalgar`, `Pulver`, or `Both` via configuration. -- Supports offsets and editor gizmos; multiple instances allowed. - -![Character Move Target Inspector](media/character_move_target_inspector.png) - -### `InteractionActionBase` and concrete actions -- Filter by `InteractionEventType` using `respondToEvents`. -- Control flow with `pauseInteractionFlow` and async `ExecuteAsync(...)`. -- Built‑in example: `InteractionTimelineAction` for cinematics. - -![InteractionTimelineAction Inspector](media/interaction_timeline_action_inspector.png) - -### `InteractionRequirementBase` -- Attach one or more to gate the interaction based on items, puzzles, proximity, etc. - -## Interaction Event Flow -1. `InteractionStarted` -2. `PlayerArrived` -3. `InteractingCharacterArrived` -4. `InteractionComplete` (bool success) -5. `InteractionInterrupted` - -Actions receive these events in order and may run concurrently; those with `pauseInteractionFlow` true are awaited. - -## Case Studies - -### Open a Door on Arrival -```csharp -using System.Threading.Tasks; -using Interactions; -using Input; -using UnityEngine; - -public class DoorOpenOnArrival : InteractionActionBase -{ - [SerializeField] private Animator animator; // expects a bool parameter "Open" - - private void Reset() - { - respondToEvents = new() { InteractionEventType.InteractingCharacterArrived }; - pauseInteractionFlow = false; - } - - protected override async Task ExecuteAsync(InteractionEventType evt, PlayerTouchController p, FollowerController f) - { - animator.SetBool("Open", true); - return true; - } -} -``` - -### Pick Up an Item then Play Timeline -Attach two actions: your `PickupItemAction` that pauses until the item is collected, and an `InteractionTimelineAction` mapped to `InteractionEventType.InteractionComplete` to celebrate. - -### Kick Off Dialogue When Player Arrives -```csharp -using System.Threading.Tasks; -using Dialogue; -using Input; -using Interactions; -using UnityEngine; - -public class StartDialogueOnArrival : InteractionActionBase -{ - [SerializeField] private DialogueComponent dialogue; - - private void Reset() - { - respondToEvents = new() { InteractionEventType.PlayerArrived }; - pauseInteractionFlow = false; - } - - protected override async Task ExecuteAsync(InteractionEventType evt, PlayerTouchController p, FollowerController f) - { - dialogue.StartDialogue(); - return true; - } -} -``` - -## Troubleshooting / FAQ -- Interaction doesn’t fire: - - Confirm `Interactable` is active and not in cooldown or already completed (`isOneTime`). - - Ensure `CharacterMoveToTarget` exists for the selected `CharacterToInteract`. -- Actions not running: - - Verify `respondToEvents` includes the lifecycle moment you expect. - - Check that the component sits under the same hierarchy so it registers with the `Interactable`. -- Timeline never finishes: - - Make sure `InteractionTimelineAction` has valid `PlayableAsset` entries and binding flags. -- Double triggers: - - Guard reentry in your actions or check `_interactionInProgress` usage in `Interactable` by following logs. - -## Paths & Namespaces -- Scripts: `Assets/Scripts/Interactions/` - - `Interactable.cs` - - `InteractionActionBase.cs` - - `InteractionTimelineAction.cs` - - `InteractionEventType.cs` - - `InteractionRequirementBase.cs` -- Editor tooling: `Assets/Editor/InteractableEditor.cs` -- Primary namespace: `Interactions` - -## Additional Editor Visuals -- Timeline mapping configuration UI: - -![Timeline Mapping Editor](media/timeline_mapping_editor.png) - -- Unity Timeline editor when authoring cinematics for interactions: - -![Timeline Editor](media/timeline_editor.png) - -- Example target placement in Scene view: - -![Target Positioning In Scene](media/target_positioning_scene.png) - -## Change Log -- v1.1: Added Table of Contents, code-first snippets, case studies, standardized inline code references, preserved existing editor images, and added troubleshooting/paths. -- v1.0: Original overview and setup guide. diff --git a/docs/media/character_move_target_setup.png b/docs/media/character_move_target_setup.png new file mode 100644 index 00000000..909d5b1b Binary files /dev/null and b/docs/media/character_move_target_setup.png differ diff --git a/docs/media/interactable_base_inspector.png b/docs/media/interactable_base_inspector.png new file mode 100644 index 00000000..58b63813 Binary files /dev/null and b/docs/media/interactable_base_inspector.png differ diff --git a/docs/media/interactable_editor_debug.png b/docs/media/interactable_editor_debug.png new file mode 100644 index 00000000..678d3eda Binary files /dev/null and b/docs/media/interactable_editor_debug.png differ diff --git a/docs/media/interactable_editor_edit.png b/docs/media/interactable_editor_edit.png new file mode 100644 index 00000000..fa7a3d20 Binary files /dev/null and b/docs/media/interactable_editor_edit.png differ diff --git a/docs/media/interactable_event_configuration_example.png b/docs/media/interactable_event_configuration_example.png new file mode 100644 index 00000000..f81cabf5 Binary files /dev/null and b/docs/media/interactable_event_configuration_example.png differ diff --git a/docs/media/interactable_events_inspector.png b/docs/media/interactable_events_inspector.png new file mode 100644 index 00000000..b35c6bb3 Binary files /dev/null and b/docs/media/interactable_events_inspector.png differ diff --git a/docs/media/item_slot_events.png b/docs/media/item_slot_events.png new file mode 100644 index 00000000..c8abe95b Binary files /dev/null and b/docs/media/item_slot_events.png differ diff --git a/docs/media/item_slot_inspector.png b/docs/media/item_slot_inspector.png new file mode 100644 index 00000000..a68254cf Binary files /dev/null and b/docs/media/item_slot_inspector.png differ diff --git a/docs/media/movement_target_gizmos.png b/docs/media/movement_target_gizmos.png new file mode 100644 index 00000000..ec73ba20 Binary files /dev/null and b/docs/media/movement_target_gizmos.png differ diff --git a/docs/media/oneclick_inspector.png b/docs/media/oneclick_inspector.png new file mode 100644 index 00000000..e177d3aa Binary files /dev/null and b/docs/media/oneclick_inspector.png differ diff --git a/docs/media/pickup_inspector.png b/docs/media/pickup_inspector.png new file mode 100644 index 00000000..6cabf5cf Binary files /dev/null and b/docs/media/pickup_inspector.png differ diff --git a/docs/media/pickup_item_data_inspector.png b/docs/media/pickup_item_data_inspector.png new file mode 100644 index 00000000..be08ba06 Binary files /dev/null and b/docs/media/pickup_item_data_inspector.png differ diff --git a/docs/media/puzzle_editor_debug.png b/docs/media/puzzle_editor_debug.png new file mode 100644 index 00000000..04e5856d Binary files /dev/null and b/docs/media/puzzle_editor_debug.png differ diff --git a/docs/media/puzzle_editor_edit.png b/docs/media/puzzle_editor_edit.png new file mode 100644 index 00000000..f698c870 Binary files /dev/null and b/docs/media/puzzle_editor_edit.png differ diff --git a/docs/media/puzzle_step_integration.png b/docs/media/puzzle_step_integration.png new file mode 100644 index 00000000..2d7ba952 Binary files /dev/null and b/docs/media/puzzle_step_integration.png differ diff --git a/docs/media/slot_item_config_settings.png b/docs/media/slot_item_config_settings.png new file mode 100644 index 00000000..b16ebe20 Binary files /dev/null and b/docs/media/slot_item_config_settings.png differ diff --git a/docs/media/timeline_mapping_element.png b/docs/media/timeline_mapping_element.png new file mode 100644 index 00000000..1b3e0cd7 Binary files /dev/null and b/docs/media/timeline_mapping_element.png differ