// 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 CombineActionSOAssigner : EditorWindow { // Fixed — this is always where the weapon prefabs live private const string PrefabRootPath = "Assets/ModelRenderer/Art/Models/models/weapons"; // Log files land next to the project's Logs/ folder private static readonly string LogDir = Path.GetFullPath("Logs/SOAssigner"); private string _soRootPath = "Assets/ModelRenderer/Art/Models/models/weapons"; 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("Tools/Brew Monster/Combine Action SO Assigner")] public static void ShowWindow() { var win = GetWindow(false, "SO Assigner", true); win.minSize = new Vector2(520, 480); } private void OnGUI() { DrawHeader(); DrawConfig(); DrawButtons(); DrawLog(); } // ── UI sections ────────────────────────────────────────────────────────── private void DrawHeader() { EditorGUILayout.Space(6); EditorGUILayout.LabelField("Combine Action SO Assigner", HeaderStyle); EditorGUILayout.LabelField("Batch-assigns CombinedActionSO assets to weapon prefabs.", EditorStyles.miniLabel); DrawSeparator(); } private void DrawConfig() { EditorGUILayout.LabelField("Paths", EditorStyles.boldLabel); using (new EditorGUI.DisabledScope(true)) EditorGUILayout.TextField("Prefab Root (fixed)", PrefabRootPath); _soRootPath = EditorGUILayout.TextField("SO Root Path", _soRootPath); EditorGUILayout.HelpBox( "SO Root must mirror the prefab folder tree.\n" + "e.g. SO: {SO Root}/人物/刀剑/15品单刀/15品单刀.asset\n" + " Prefab: " + PrefabRootPath + "/人物/刀剑/15品单刀/15品单刀.prefab", MessageType.Info); DrawSeparator(); 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() { EditorGUILayout.BeginHorizontal(); if (GUILayout.Button("Orphan Check", GUILayout.Height(30))) RunOrphanCheck(); GUI.backgroundColor = _isDryRun ? Color.yellow : Color.red; if (GUILayout.Button(_isDryRun ? "Run (Dry)" : "Run (LIVE — writes disk)", GUILayout.Height(30))) RunAssignment(); GUI.backgroundColor = Color.white; EditorGUILayout.EndHorizontal(); 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); } // ── Core logic ─────────────────────────────────────────────────────────── private void RunOrphanCheck() { _log.Clear(); _log.Add("=== ORPHAN CHECK ==="); string[] guids = AssetDatabase.FindAssets("t:Prefab", new[] { PrefabRootPath }); int orphans = 0; var fileLines = new List { $"=== ORPHAN CHECK {Timestamp()} ===" }; foreach (string guid in guids) { string prefabPath = AssetDatabase.GUIDToAssetPath(guid); string soPath = BuildSOPath(prefabPath); if (AssetDatabase.LoadAssetAtPath(soPath) == null) { string line1 = $"[ORPHAN] {Path.GetFileNameWithoutExtension(prefabPath)}"; string line2 = $" expected SO: {soPath}"; _log.Add(line1); _log.Add(line2); fileLines.Add(line1); fileLines.Add(line2); orphans++; } } string summary = $"=== {orphans} orphan(s) out of {guids.Length} prefab(s) ==="; _log.Add(summary); fileLines.Add(summary); _lastLogFile = WriteLogFile("OrphanCheck", fileLines); Debug.Log($"[SO Assigner] Orphan Check — {orphans}/{guids.Length} orphans. Log: {_lastLogFile}"); Repaint(); } private void RunAssignment() { _log.Clear(); string header = _isDryRun ? "=== DRY RUN ===" : "=== LIVE RUN ==="; _log.Add(header); string[] guids = AssetDatabase.FindAssets("t:Prefab", new[] { PrefabRootPath }); int matched = 0, skipped = 0, failed = 0; var fileLines = new List { $"{header} {Timestamp()}" }; try { for (int i = 0; i < guids.Length; i++) { string prefabPath = AssetDatabase.GUIDToAssetPath(guids[i]); string prefabName = Path.GetFileNameWithoutExtension(prefabPath); string soPath = BuildSOPath(prefabPath); EditorUtility.DisplayProgressBar( "Assigning CombinedActionSO", prefabName, (float)i / guids.Length); CombinedActionSO so = AssetDatabase.LoadAssetAtPath(soPath); if (so == null) { string skipLine = $"[SKIP] {prefabName} → {soPath}"; fileLines.Add(skipLine); if (!_isDryRun) _log.Add(skipLine); // window only gets skips in live mode skipped++; continue; } if (_isDryRun) { // File gets every match; window stays quiet fileLines.Add($"[MATCH] {prefabName} → {soPath}"); matched++; } else { if (!TryAssign(prefabPath, so, out string error)) { string errLine = $"[ERROR] {prefabName} — {error}"; _log.Add(errLine); fileLines.Add(errLine); failed++; } else { fileLines.Add($"[OK] {prefabName}"); matched++; } } // Memory management every 100 items if (i > 0 && i % 100 == 0) { Resources.UnloadUnusedAssets(); System.GC.Collect(); } } } finally { EditorUtility.ClearProgressBar(); } if (!_isDryRun) AssetDatabase.SaveAssets(); string summary = $"matched: {matched}, skipped: {skipped}, failed: {failed} (total: {guids.Length})"; string summaryLine = $"=== DONE — {summary} ==="; _log.Add(summaryLine); fileLines.Add(summaryLine); _lastLogFile = WriteLogFile(_isDryRun ? "DryRun" : "LiveRun", fileLines); Debug.Log($"[SO Assigner] {(_isDryRun ? "DRY RUN" : "LIVE")} — {summary} | Log: {_lastLogFile}"); if (skipped > 0) Debug.LogWarning($"[SO Assigner] {skipped} unmatched prefab(s) — see log file for details: {_lastLogFile}"); Repaint(); } 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(); if (holder == null) { error = "CombineActHolder not found on root GameObject"; return false; } 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); } } // ── File logging ───────────────────────────────────────────────────────── 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"); // ── Path helpers ───────────────────────────────────────────────────────── /// /// Maps a prefab path to its expected SO path using mirror structure. /// Prefab: Assets/ModelRenderer/Art/Models/models/weapons/X/Y/Z.prefab /// SO: {_soRootPath}/X/Y/Z.asset /// private string BuildSOPath(string prefabPath) { string relative = prefabPath.Substring(PrefabRootPath.Length).TrimStart('/'); string assetRelative = Path.ChangeExtension(relative, ".asset").Replace('\\', '/'); return _soRootPath.TrimEnd('/') + "/" + assetRelative; } }