// UTF-8 with BOM - required for Chinese character paths using System.Collections.Generic; using System.IO; using System.Text; using BrewMonster.Scripts.ECModel; using UnityEditor; using UnityEngine; public class CombineActHolderFolderAssigner : EditorWindow { private const string MenuPath = "Tools/Brew Monster/Combine Act Holder Folder Assigner"; private const int ProgressBarInterval = 15; private const int MemoryCleanupInterval = 250; private static readonly string LogDir = Path.GetFullPath("Logs/CombineActHolderAssigner"); private DefaultAsset _folder; private bool _isDryRun = true; private Vector2 _scrollPos; private readonly List _log = new(); private string _lastLogFile; private static readonly GUIStyle HeaderStyle = new(EditorStyles.boldLabel) { fontSize = 14 }; [MenuItem(MenuPath)] public static void ShowWindow() { var win = GetWindow(false, "Combine Act Holder Assigner", true); win.minSize = new Vector2(520, 480); } private void OnGUI() { DrawHeader(); DrawFolderInput(); DrawOptions(); DrawButtons(); DrawLog(); } private void DrawHeader() { EditorGUILayout.Space(6); EditorGUILayout.LabelField("Combine Act Holder Folder Assigner", HeaderStyle); EditorGUILayout.LabelField( "Scans CombinedActionSO assets under a folder and pairs each with a same-name prefab in the same directory.", EditorStyles.miniLabel); DrawSeparator(); } private void DrawFolderInput() { EditorGUILayout.LabelField("Folder", EditorStyles.boldLabel); DrawFolderDropArea(); var newFolder = (DefaultAsset)EditorGUILayout.ObjectField("Project Folder", _folder, typeof(DefaultAsset), false); if (newFolder != _folder) { _folder = newFolder; _log.Clear(); _lastLogFile = null; } string folderPath = GetFolderPath(); bool validFolder = !string.IsNullOrEmpty(folderPath); if (_folder != null && !validFolder) EditorGUILayout.HelpBox("Assign a folder from the Project window (not a file).", MessageType.Warning); if (validFolder) EditorGUILayout.LabelField("Scan root", folderPath, EditorStyles.miniLabel); DrawSeparator(); } private void DrawFolderDropArea() { Event evt = Event.current; Rect dropRect = GUILayoutUtility.GetRect(0f, 50f, GUILayout.ExpandWidth(true)); GUI.Box(dropRect, "Drag Project Folder Here"); if (!dropRect.Contains(evt.mousePosition)) return; if (evt.type != EventType.DragUpdated && evt.type != EventType.DragPerform) return; DragAndDrop.visualMode = DragAndDropVisualMode.Copy; if (evt.type == EventType.DragPerform) { DragAndDrop.AcceptDrag(); foreach (Object obj in DragAndDrop.objectReferences) { if (TrySetFolderFromObject(obj)) break; } } evt.Use(); } private void DrawOptions() { EditorGUILayout.LabelField("Options", EditorStyles.boldLabel); _isDryRun = EditorGUILayout.Toggle("Dry Run (no writes)", _isDryRun); if (_isDryRun) EditorGUILayout.HelpBox("Dry Run ON - matches will be logged but nothing will be saved to disk.", MessageType.Warning); else EditorGUILayout.HelpBox("Dry Run OFF - prefabs WILL be modified and saved.", MessageType.Error); DrawSeparator(); } private void DrawButtons() { string folderPath = GetFolderPath(); bool validFolder = !string.IsNullOrEmpty(folderPath); EditorGUI.BeginDisabledGroup(!validFolder); GUI.backgroundColor = _isDryRun ? Color.yellow : Color.red; if (GUILayout.Button(_isDryRun ? "Run (Dry)" : "Run (LIVE - writes disk)", GUILayout.Height(30))) RunAssignment(folderPath); GUI.backgroundColor = Color.white; EditorGUI.EndDisabledGroup(); EditorGUILayout.Space(4); } private void DrawLog() { EditorGUILayout.BeginHorizontal(); EditorGUILayout.LabelField("Log", EditorStyles.boldLabel); if (!string.IsNullOrEmpty(_lastLogFile) && GUILayout.Button("Open Log File", GUILayout.Width(100))) EditorUtility.RevealInFinder(_lastLogFile); if (GUILayout.Button("Clear", GUILayout.Width(60))) { _log.Clear(); _lastLogFile = null; } EditorGUILayout.EndHorizontal(); if (!string.IsNullOrEmpty(_lastLogFile)) EditorGUILayout.LabelField(_lastLogFile, EditorStyles.miniLabel); _scrollPos = EditorGUILayout.BeginScrollView(_scrollPos, GUILayout.ExpandHeight(true)); foreach (var line in _log) EditorGUILayout.LabelField(line, EditorStyles.wordWrappedLabel); EditorGUILayout.EndScrollView(); } private static void DrawSeparator() { EditorGUILayout.Space(4); Rect r = EditorGUILayout.GetControlRect(false, 1); EditorGUI.DrawRect(r, new Color(0.5f, 0.5f, 0.5f, 0.5f)); EditorGUILayout.Space(4); } private string GetFolderPath() { if (_folder == null) return null; string path = AssetDatabase.GetAssetPath(_folder); if (string.IsNullOrEmpty(path) || !AssetDatabase.IsValidFolder(path)) return null; return path.Replace('\\', '/'); } private bool TrySetFolderFromObject(Object obj) { if (obj is not DefaultAsset) return false; string path = AssetDatabase.GetAssetPath(obj); if (string.IsNullOrEmpty(path) || !AssetDatabase.IsValidFolder(path)) return false; _folder = (DefaultAsset)obj; _log.Clear(); _lastLogFile = null; return true; } private void RunAssignment(string folderPath) { _log.Clear(); string header = _isDryRun ? "=== DRY RUN ===" : "=== LIVE RUN ==="; _log.Add(header); _log.Add($"Folder: {folderPath}"); string[] guids = AssetDatabase.FindAssets("t:CombinedActionSO", new[] { folderPath }); int matched = 0, skippedNoPrefab = 0, skippedAlready = 0, failed = 0; var fileLines = new List { $"{header} {Timestamp()}", $"Folder: {folderPath}" }; bool liveRun = !_isDryRun; if (liveRun) AssetDatabase.StartAssetEditing(); try { for (int i = 0; i < guids.Length; i++) { string soPath = AssetDatabase.GUIDToAssetPath(guids[i]); string soName = Path.GetFileNameWithoutExtension(soPath); string prefabPath = BuildMatchingPrefabPath(soPath); if (i % ProgressBarInterval == 0 || i == guids.Length - 1) { EditorUtility.DisplayProgressBar( "Combine Act Holder Folder Assigner", soName, guids.Length > 0 ? (float)(i + 1) / guids.Length : 1f); } if (!PrefabExistsAtPath(prefabPath)) { string skipLine = $"[SKIP] {soName} → no prefab: {prefabPath}"; fileLines.Add(skipLine); _log.Add(skipLine); skippedNoPrefab++; continue; } if (_isDryRun) { string matchLine = $"[MATCH] {soName} → {prefabPath}"; fileLines.Add(matchLine); _log.Add(matchLine); matched++; continue; } if (IsAlreadyAssignedOnPrefabAsset(prefabPath, soPath)) { string okLine = $"[OK] {soName} (already assigned)"; fileLines.Add(okLine); skippedAlready++; continue; } CombinedActionSO so = AssetDatabase.LoadAssetAtPath(soPath); if (so == null) { string errLine = $"[ERROR] {soName} - could not load SO: {soPath}"; _log.Add(errLine); fileLines.Add(errLine); failed++; continue; } if (!TryAssign(prefabPath, so, out string error)) { string errLine = $"[ERROR] {soName} - {error}"; _log.Add(errLine); fileLines.Add(errLine); failed++; } else { fileLines.Add($"[OK] {soName}"); matched++; } if (i > 0 && i % MemoryCleanupInterval == 0) Resources.UnloadUnusedAssets(); } } finally { if (liveRun) AssetDatabase.StopAssetEditing(); EditorUtility.ClearProgressBar(); } if (liveRun) AssetDatabase.SaveAssets(); string summary = $"matched: {matched}, no prefab: {skippedNoPrefab}, already assigned: {skippedAlready}, failed: {failed} (total SOs: {guids.Length})"; string summaryLine = $"=== DONE - {summary} ==="; _log.Add(summaryLine); fileLines.Add(summaryLine); _lastLogFile = WriteLogFile(_isDryRun ? "DryRun" : "LiveRun", fileLines); Debug.Log($"[Combine Act Holder Assigner] {(_isDryRun ? "DRY RUN" : "LIVE")} - {summary} | Log: {_lastLogFile}"); if (skippedNoPrefab > 0) Debug.LogWarning( $"[Combine Act Holder Assigner] {skippedNoPrefab} SO(s) without a matching prefab - see log: {_lastLogFile}"); Repaint(); } private static bool PrefabExistsAtPath(string prefabPath) => !string.IsNullOrEmpty(AssetDatabase.AssetPathToGUID(prefabPath)); private static bool IsAlreadyAssignedOnPrefabAsset(string prefabPath, string soPath) { if (AssetDatabase.GetMainAssetTypeAtPath(prefabPath) != typeof(GameObject)) return false; GameObject prefabAsset = (GameObject)AssetDatabase.LoadMainAssetAtPath(prefabPath); if (prefabAsset == null) return false; CombineActHolder holder = prefabAsset.GetComponent(); if (holder == null || holder.ActionSO == null) return false; string assignedPath = AssetDatabase.GetAssetPath(holder.ActionSO); if (string.IsNullOrEmpty(assignedPath)) return false; return assignedPath.Replace('\\', '/') == soPath.Replace('\\', '/'); } private static bool TryAssign(string prefabPath, CombinedActionSO so, out string error) { error = null; GameObject root = null; try { root = PrefabUtility.LoadPrefabContents(prefabPath); CombineActHolder holder = root.GetComponent() ?? root.AddComponent(); if (holder.ActionSO == so) return true; using SerializedObject serializedHolder = new(holder); SerializedProperty prop = serializedHolder.FindProperty("combinedActionSO"); if (prop == null) { error = "SerializedProperty 'combinedActionSO' not found"; return false; } prop.objectReferenceValue = so; serializedHolder.ApplyModifiedPropertiesWithoutUndo(); PrefabUtility.SaveAsPrefabAsset(root, prefabPath); return true; } catch (System.Exception ex) { error = ex.Message; return false; } finally { if (root != null) PrefabUtility.UnloadPrefabContents(root); } } private static string BuildMatchingPrefabPath(string soPath) { soPath = soPath.Replace('\\', '/'); string dir = Path.GetDirectoryName(soPath)?.Replace('\\', '/'); string name = Path.GetFileNameWithoutExtension(soPath); return $"{dir}/{name}.prefab"; } private static string WriteLogFile(string prefix, List lines) { Directory.CreateDirectory(LogDir); string fileName = $"{prefix}_{System.DateTime.Now:yyyy-MM-dd_HH-mm-ss}.log"; string fullPath = Path.Combine(LogDir, fileName); File.WriteAllLines(fullPath, lines, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true)); return fullPath; } private static string Timestamp() => System.DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"); }