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

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

## Core Architecture

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

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

## Save/Load Improvements

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

## Interactable & Pickup System

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

##  UI System Changes

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

## ⚠️ Breaking Changes

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

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

575 lines
22 KiB
C#

using UnityEditor;
using UnityEngine;
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEditor.SceneManagement;
using UnityEngine.SceneManagement;
using UnityEditor.IMGUI.Controls;
namespace Editor.Tools
{
public class ComponentSearchReplaceWindow : EditorWindow
{
private Type selectedSearchType;
private Type selectedReplaceType;
private List<ComponentInfo> foundComponents = new List<ComponentInfo>();
private Vector2 scrollPos;
private int selectedComponentIndex = -1;
private string searchTypeName = "Select a Component...";
private string replaceTypeName = "Select a Component...";
private List<Type> allMonoBehaviourTypes = new List<Type>();
private bool includeDerivedTypes = true;
[MenuItem("Tools/Component Search & Replace")]
public static void ShowWindow()
{
var window = GetWindow<ComponentSearchReplaceWindow>("Component Search & Replace");
window.minSize = new Vector2(700, 500);
window.maxSize = new Vector2(1400, 1000);
window.position = new Rect(120, 120, 800, 600);
}
private void OnEnable()
{
CacheMonoBehaviourTypes();
EditorSceneManager.sceneOpened += (scene, mode) => ClearSearch();
SceneManager.activeSceneChanged += (oldScene, newScene) => ClearSearch();
}
private void OnDisable()
{
EditorSceneManager.sceneOpened -= (scene, mode) => ClearSearch();
SceneManager.activeSceneChanged -= (oldScene, newScene) => ClearSearch();
}
private void CacheMonoBehaviourTypes()
{
allMonoBehaviourTypes.Clear();
var assemblies = AppDomain.CurrentDomain.GetAssemblies();
foreach (var assembly in assemblies)
{
try
{
var types = assembly.GetTypes()
.Where(t => (t.IsSubclassOf(typeof(MonoBehaviour)) || t.IsSubclassOf(typeof(Component)))
&& !t.IsAbstract
&& t != typeof(Transform)) // Exclude Transform as it can't be removed
.OrderBy(t => t.Name);
allMonoBehaviourTypes.AddRange(types);
}
catch (Exception)
{
// Some assemblies may throw exceptions when getting types
continue;
}
}
}
private void ClearSearch()
{
foundComponents.Clear();
selectedComponentIndex = -1;
Repaint();
}
private void OnGUI()
{
GUILayout.Space(10);
// Search Section
EditorGUILayout.BeginVertical("box");
EditorGUILayout.LabelField("Search for Component", EditorStyles.boldLabel);
GUILayout.Space(5);
EditorGUILayout.BeginHorizontal();
EditorGUILayout.LabelField("Component Type:", GUILayout.Width(120));
if (GUILayout.Button(searchTypeName, EditorStyles.popup, GUILayout.ExpandWidth(true)))
{
var dropdown = new ComponentTypeDropdown(
new AdvancedDropdownState(),
(type) =>
{
selectedSearchType = type;
searchTypeName = type != null ? type.Name : "Select a Component...";
ClearSearch();
Repaint();
});
dropdown.Show(GUILayoutUtility.GetLastRect());
}
EditorGUILayout.EndHorizontal();
GUILayout.Space(5);
// Include Derived Types checkbox
includeDerivedTypes = EditorGUILayout.Toggle(
new GUIContent("Include Derived Types",
"When enabled, searches for the selected type and all types that inherit from it. " +
"When disabled, searches only for the exact type."),
includeDerivedTypes);
GUILayout.Space(5);
EditorGUI.BeginDisabledGroup(selectedSearchType == null);
if (GUILayout.Button("Search Scene", GUILayout.Height(30)))
{
SearchForComponents();
}
EditorGUI.EndDisabledGroup();
EditorGUILayout.EndVertical();
GUILayout.Space(10);
// Results Section
EditorGUILayout.BeginVertical("box");
EditorGUILayout.LabelField($"Search Results ({foundComponents.Count} found)", EditorStyles.boldLabel);
GUILayout.Space(5);
scrollPos = EditorGUILayout.BeginScrollView(scrollPos, GUILayout.ExpandHeight(true));
if (foundComponents.Count == 0)
{
EditorGUILayout.HelpBox("No components found. Select a component type and click 'Search Scene'.", MessageType.Info);
}
else
{
for (int i = 0; i < foundComponents.Count; i++)
{
var info = foundComponents[i];
if (info.component == null) continue;
bool isSelected = selectedComponentIndex == i;
Color originalColor = GUI.backgroundColor;
if (isSelected)
{
GUI.backgroundColor = new Color(0.3f, 0.6f, 1f, 0.5f);
}
EditorGUILayout.BeginVertical("box");
GUI.backgroundColor = originalColor;
EditorGUILayout.BeginHorizontal();
// Selection toggle
bool newSelected = GUILayout.Toggle(isSelected, "", GUILayout.Width(20));
if (newSelected != isSelected)
{
selectedComponentIndex = newSelected ? i : -1;
}
EditorGUILayout.BeginVertical();
EditorGUILayout.LabelField($"GameObject: {info.gameObject.name}", EditorStyles.boldLabel);
EditorGUILayout.LabelField($"Path: {info.hierarchyPath}", EditorStyles.miniLabel);
EditorGUILayout.LabelField($"Component: {info.component.GetType().Name}", EditorStyles.miniLabel);
EditorGUILayout.EndVertical();
if (GUILayout.Button("Ping", GUILayout.Width(80)))
{
Selection.activeObject = info.gameObject;
EditorGUIUtility.PingObject(info.gameObject);
SceneView.FrameLastActiveSceneView();
}
EditorGUILayout.EndHorizontal();
EditorGUILayout.EndVertical();
GUILayout.Space(2);
}
}
EditorGUILayout.EndScrollView();
EditorGUILayout.EndVertical();
GUILayout.Space(10);
// Replace Section
EditorGUILayout.BeginVertical("box");
EditorGUILayout.LabelField("Replace Component", EditorStyles.boldLabel);
GUILayout.Space(5);
EditorGUILayout.BeginHorizontal();
EditorGUILayout.LabelField("Replace With:", GUILayout.Width(120));
if (GUILayout.Button(replaceTypeName, EditorStyles.popup, GUILayout.ExpandWidth(true)))
{
var dropdown = new ComponentTypeDropdown(
new AdvancedDropdownState(),
(type) =>
{
selectedReplaceType = type;
replaceTypeName = type != null ? type.Name : "Select a Component...";
Repaint();
});
dropdown.Show(GUILayoutUtility.GetLastRect());
}
EditorGUILayout.EndHorizontal();
GUILayout.Space(5);
bool canReplace = selectedComponentIndex >= 0 &&
selectedComponentIndex < foundComponents.Count &&
selectedReplaceType != null;
EditorGUI.BeginDisabledGroup(!canReplace);
if (!canReplace && selectedComponentIndex >= 0 && selectedReplaceType == null)
{
EditorGUILayout.HelpBox("Select a component type to replace with.", MessageType.Warning);
}
else if (!canReplace && selectedReplaceType != null)
{
EditorGUILayout.HelpBox("Select an object from the search results above.", MessageType.Warning);
}
if (GUILayout.Button("Replace Selected Component", GUILayout.Height(30)))
{
ReplaceComponent();
}
EditorGUI.EndDisabledGroup();
EditorGUILayout.EndVertical();
GUILayout.Space(5);
}
private void SearchForComponents()
{
if (selectedSearchType == null)
{
Debug.LogWarning("No component type selected for search.");
return;
}
foundComponents.Clear();
selectedComponentIndex = -1;
var allObjects = FindObjectsByType<GameObject>(FindObjectsInactive.Include, FindObjectsSortMode.None);
foreach (var go in allObjects)
{
Component component = null;
if (includeDerivedTypes)
{
// Search for the type and all derived types
component = go.GetComponent(selectedSearchType);
}
else
{
// Search for exact type only
var components = go.GetComponents<Component>();
component = components.FirstOrDefault(c => c != null && c.GetType() == selectedSearchType);
}
if (component != null)
{
foundComponents.Add(new ComponentInfo
{
gameObject = go,
component = component,
hierarchyPath = GetHierarchyPath(go)
});
}
}
foundComponents = foundComponents.OrderBy(c => c.hierarchyPath).ToList();
string searchMode = includeDerivedTypes ? "including derived types" : "exact type only";
Debug.Log($"Found {foundComponents.Count} objects with component type '{selectedSearchType.Name}' ({searchMode})");
Repaint();
}
private void ReplaceComponent()
{
if (selectedComponentIndex < 0 || selectedComponentIndex >= foundComponents.Count)
{
Debug.LogError("No component selected to replace.");
return;
}
if (selectedReplaceType == null)
{
Debug.LogError("No replacement component type selected.");
return;
}
var info = foundComponents[selectedComponentIndex];
var go = info.gameObject;
var oldComponent = info.component;
if (go == null || oldComponent == null)
{
Debug.LogError("Selected component or GameObject is null.");
foundComponents.RemoveAt(selectedComponentIndex);
selectedComponentIndex = -1;
Repaint();
return;
}
// Confirm the replacement
bool confirmed = EditorUtility.DisplayDialog(
"Replace Component",
$"Are you sure you want to replace '{selectedSearchType.Name}' with '{selectedReplaceType.Name}' on GameObject '{go.name}'?\n\n" +
"This action can be undone with Ctrl+Z.",
"Replace",
"Cancel"
);
if (!confirmed)
{
return;
}
// Record undo
Undo.RegisterCompleteObjectUndo(go, "Replace Component");
// Copy serialized data if possible (optional enhancement)
// This attempts to preserve common fields between components
var serializedOldComponent = new SerializedObject(oldComponent);
// Remove old component
Undo.DestroyObjectImmediate(oldComponent);
// Add new component
var newComponent = Undo.AddComponent(go, selectedReplaceType);
// Try to copy matching serialized properties
var serializedNewComponent = new SerializedObject(newComponent);
CopyMatchingSerializedProperties(serializedOldComponent, serializedNewComponent);
serializedNewComponent.ApplyModifiedProperties();
// Mark scene as dirty
EditorSceneManager.MarkSceneDirty(go.scene);
Debug.Log($"Replaced '{selectedSearchType.Name}' with '{selectedReplaceType.Name}' on '{go.name}'");
// Update the found components list
foundComponents[selectedComponentIndex] = new ComponentInfo
{
gameObject = go,
component = newComponent,
hierarchyPath = info.hierarchyPath
};
// Update search type if needed
if (selectedSearchType != selectedReplaceType)
{
foundComponents.RemoveAt(selectedComponentIndex);
selectedComponentIndex = -1;
}
Repaint();
}
private void CopyMatchingSerializedProperties(SerializedObject source, SerializedObject destination)
{
SerializedProperty iterator = source.GetIterator();
bool enterChildren = true;
while (iterator.NextVisible(enterChildren))
{
enterChildren = false;
// Skip script reference
if (iterator.propertyPath == "m_Script")
continue;
SerializedProperty destProp = destination.FindProperty(iterator.propertyPath);
if (destProp != null && destProp.propertyType == iterator.propertyType)
{
try
{
destination.CopyFromSerializedProperty(iterator);
}
catch
{
// Some properties may not be copyable
}
}
}
}
private string GetHierarchyPath(GameObject go)
{
string path = go.name;
Transform t = go.transform.parent;
while (t != null)
{
path = t.name + "/" + path;
t = t.parent;
}
return path;
}
private class ComponentInfo
{
public GameObject gameObject;
public Component component;
public string hierarchyPath;
}
}
// Advanced Dropdown for Component Type Selection (including Unity built-in components)
public class ComponentTypeDropdown : AdvancedDropdown
{
private Action<Type> onTypeSelected;
private static List<Type> cachedTypes;
public ComponentTypeDropdown(AdvancedDropdownState state, Action<Type> onTypeSelected)
: base(state)
{
this.onTypeSelected = onTypeSelected;
minimumSize = new Vector2(300, 400);
if (cachedTypes == null)
{
CacheTypes();
}
}
private static void CacheTypes()
{
cachedTypes = new List<Type>();
var assemblies = AppDomain.CurrentDomain.GetAssemblies();
foreach (var assembly in assemblies)
{
try
{
var types = assembly.GetTypes()
.Where(t => (t.IsSubclassOf(typeof(MonoBehaviour)) || t.IsSubclassOf(typeof(Component)))
&& !t.IsAbstract
&& t != typeof(Transform)) // Exclude Transform as it can't be removed
.OrderBy(t => t.Name);
cachedTypes.AddRange(types);
}
catch (Exception)
{
// Some assemblies may throw exceptions when getting types
}
}
}
protected override AdvancedDropdownItem BuildRoot()
{
var root = new AdvancedDropdownItem("Component Types");
// Add "None" option
var noneItem = new TypeDropdownItem("None", null);
root.AddChild(noneItem);
root.AddSeparator();
// Separate Unity built-in components from custom ones
var unityComponents = cachedTypes.Where(t =>
t.Namespace != null && t.Namespace.StartsWith("UnityEngine")).ToList();
var customComponents = cachedTypes.Except(unityComponents).ToList();
// Add Unity Components section
if (unityComponents.Any())
{
var unitySection = new AdvancedDropdownItem("Unity Components");
// Group Unity components by category (second part of namespace)
var unityGrouped = unityComponents.GroupBy(t =>
{
if (t.Namespace == "UnityEngine")
return "Core";
var parts = t.Namespace.Split('.');
return parts.Length > 1 ? parts[1] : "Other";
}).OrderBy(g => g.Key);
foreach (var group in unityGrouped)
{
if (group.Count() <= 5)
{
// Add directly if few items
foreach (var type in group.OrderBy(t => t.Name))
{
var typeItem = new TypeDropdownItem(type.Name, type);
unitySection.AddChild(typeItem);
}
}
else
{
// Create subcategory
var categoryItem = new AdvancedDropdownItem(group.Key);
foreach (var type in group.OrderBy(t => t.Name))
{
var typeItem = new TypeDropdownItem(type.Name, type);
categoryItem.AddChild(typeItem);
}
unitySection.AddChild(categoryItem);
}
}
root.AddChild(unitySection);
}
// Add Custom Components section
if (customComponents.Any())
{
root.AddSeparator();
// Group by namespace
var groupedTypes = customComponents.GroupBy(t =>
string.IsNullOrEmpty(t.Namespace) ? "(No Namespace)" : t.Namespace);
foreach (var group in groupedTypes.OrderBy(g => g.Key))
{
// For small namespaces or no namespace, add directly to root
if (group.Count() <= 3 || group.Key == "(No Namespace)")
{
foreach (var type in group)
{
var typeItem = new TypeDropdownItem(
group.Key == "(No Namespace)" ? type.Name : $"{group.Key}.{type.Name}",
type);
root.AddChild(typeItem);
}
}
else
{
// Create namespace folder
var namespaceItem = new AdvancedDropdownItem(group.Key);
foreach (var type in group)
{
var typeItem = new TypeDropdownItem(type.Name, type);
namespaceItem.AddChild(typeItem);
}
root.AddChild(namespaceItem);
}
}
}
return root;
}
protected override void ItemSelected(AdvancedDropdownItem item)
{
var typeItem = item as TypeDropdownItem;
if (typeItem != null)
{
onTypeSelected?.Invoke(typeItem.Type);
}
}
private class TypeDropdownItem : AdvancedDropdownItem
{
public Type Type { get; private set; }
public TypeDropdownItem(string name, Type type) : base(name)
{
Type = type;
}
}
}
}