using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; using System.Text; using UnityEditor; using UnityEditor.AddressableAssets; using UnityEditor.AddressableAssets.Settings; using UnityEditor.AddressableAssets.Settings.GroupSchemas; using UnityEngine; using BaseGames.Core.Assets; namespace BaseGames.Editor { /// /// Addressables 统一管理工具。 /// 规范来源:AddressablesLabelSpec.md §3 | AssetFolderSpec.md §8。 /// /// 菜单:BaseGames → Addressables → Addressables Manager(总入口) /// BaseGames → Addressables → Addressable Batch Tool(直达批量注册 Tab) /// BaseGames → Addressables → Rule Sync(直达规则校验 Tab) /// public sealed class AddressableManagerWindow : EditorWindow { // ── Tabs ────────────────────────────────────────────────────────────── private static readonly GUIContent[] TabContents = { new GUIContent(" 📊 总览 "), new GUIContent(" 📦 批量注册 "), new GUIContent(" 🔑 键同步 "), new GUIContent(" 🔧 规则校验 "), }; private const int TabDashboard = 0; private const int TabRegister = 1; private const int TabKeySync = 2; private const int TabRuleSync = 3; // ── Dashboard State ─────────────────────────────────────────────────── private int _dTotal, _dOk, _dIssue, _dWarn; private bool _dReady; private string _dTime = ""; // ── Register State ──────────────────────────────────────────────────── private int _regSrc; // 0=AllGame 1=Folder 2=Selection private DefaultAsset _regFolderAsset; private string _regSearch = ""; private bool _regOnlyNew = true; private readonly bool[] _regTypeOn = { true, true, true, false, false }; // Prefab/Scene/SO/Audio/Tex private List _regEntries; private Vector2 _regScroll; // ── Key Sync State ──────────────────────────────────────────────────── private List _keyEntries; private bool _keyOnlyMissing = true; private Vector2 _keyScroll; // ── Rule Sync State ─────────────────────────────────────────────────── private List _ruleEntries; private bool _ruleShowOk; private bool _ruleScanned; private string _ruleSearch = ""; private Vector2 _ruleScroll; // ── Shared Options ──────────────────────────────────────────────────── private bool _applyRules = true; private bool _overwrite; private string _extraLabel = ""; private string _newGroupName = ""; // ── Tab ─────────────────────────────────────────────────────────────── [SerializeField] private int _tab; // ── Styles / Colors ─────────────────────────────────────────────────── private GUIStyle _sBold, _sLink, _sCard, _sEvenRow, _sCenGrey; private bool _stylesReady; private static readonly Color CG = new Color(0.25f, 0.82f, 0.40f); // green OK private static readonly Color CY = new Color(0.95f, 0.76f, 0.12f); // yellow warn private static readonly Color CR = new Color(0.90f, 0.28f, 0.22f); // red error private static readonly Color CD = new Color(0.55f, 0.55f, 0.55f); // dim private static readonly Color CE = new Color(0.18f, 0.18f, 0.18f, 0.35f); // even row bg // ── Column widths ───────────────────────────────────────────────────── private const float CW_Path = 272f; private const float CW_Addr = 212f; private const float CW_Group = 118f; private const float CW_Labels = 152f; // ── Menu ────────────────────────────────────────────────────────────── [MenuItem("BaseGames/Addressables/Addressables Manager", priority = 100)] public static void Open() => OpenAt(TabDashboard); [MenuItem("BaseGames/Addressables/Addressable Batch Tool", priority = 200)] public static void OpenBatch() => OpenAt(TabRegister); [MenuItem("BaseGames/Addressables/Rule Sync", priority = 110)] public static void OpenRuleSync() => OpenAt(TabRuleSync); public static void OpenAt(int tab) { var win = GetWindow("Addressables Manager"); win.minSize = new Vector2(1060, 600); win._tab = tab; win.Show(); win.Focus(); } // ── Lifecycle ───────────────────────────────────────────────────────── private void OnGUI() { EnsureStyles(); if (AddressableAssetSettingsDefaultObject.Settings == null) { EditorGUILayout.Space(12); EditorGUILayout.HelpBox( "Addressable Settings 未初始化。\n" + "请先执行:Window → Asset Management → Addressables → Groups → Create Addressables Settings", MessageType.Error); return; } DrawWindowBar(); EditorGUILayout.Space(2); _tab = GUILayout.Toolbar(_tab, TabContents, GUILayout.Height(28)); EditorGUILayout.Space(4); switch (_tab) { case TabDashboard: DrawDashboard(); break; case TabRegister: DrawRegister(); break; case TabKeySync: DrawKeySync(); break; case TabRuleSync: DrawRuleSync(); break; } } // ── Window toolbar ──────────────────────────────────────────────────── private void DrawWindowBar() { using (new EditorGUILayout.HorizontalScope(EditorStyles.toolbar)) { GUILayout.Label("⚙ Addressables Manager", _sBold, GUILayout.Width(210)); GUILayout.FlexibleSpace(); if (GUILayout.Button("Groups 窗口", EditorStyles.toolbarButton, GUILayout.Width(90))) EditorApplication.ExecuteMenuItem("Window/Asset Management/Addressables/Groups"); if (GUILayout.Button("🔍 验证 AddressKeys", EditorStyles.toolbarButton, GUILayout.Width(124))) AddressKeyValidator.ValidateAll(); } } // ═══════════════════════════════════════════════════════════════════════ // Tab 0 — 总览 (Dashboard) // ═══════════════════════════════════════════════════════════════════════ private void DrawDashboard() { EditorGUILayout.Space(10); using (new EditorGUILayout.HorizontalScope()) { GUILayout.FlexibleSpace(); if (GUILayout.Button("⚡ 全量扫描并注册", GUILayout.Width(168), GUILayout.Height(30))) { _regSrc = 0; _regOnlyNew = true; ScanRegisterEntries(); _tab = TabRegister; } GUILayout.Space(10); if (GUILayout.Button("🔧 扫描并修复规则", GUILayout.Width(168), GUILayout.Height(30))) { RunRuleScan(); _tab = TabRuleSync; } GUILayout.FlexibleSpace(); } EditorGUILayout.Space(14); if (_dReady) { using (new EditorGUILayout.HorizontalScope()) { GUILayout.FlexibleSpace(); DrawStatCard("📦 总资产", _dTotal.ToString(), Color.white); GUILayout.Space(10); DrawStatCard("✅ 符合规范", _dOk.ToString(), CG); GUILayout.Space(10); DrawStatCard("❌ 需修复", _dIssue.ToString(), _dIssue > 0 ? CR : CG); GUILayout.Space(10); DrawStatCard("⚠ 自定义标签", _dWarn.ToString(), _dWarn > 0 ? CY : CD); GUILayout.FlexibleSpace(); } EditorGUILayout.Space(6); GUILayout.Label($"上次扫描:{_dTime}", _sCenGrey); } EditorGUILayout.Space(18); EditorGUILayout.LabelField("── 推荐工作流 ──", EditorStyles.boldLabel); EditorGUILayout.Space(4); EditorGUILayout.HelpBox( "① 按命名规范(前缀_描述)为新资产命名,放置到正确文件夹\n" + "② 在 AddressKeys.cs 中添加对应 const 字符串常量\n" + "③「批量注册」→「⚡ 全量扫描 _Game/」→「注册所有未注册项」\n" + "④「规则校验」→「▶ 扫描全部」→「✦ 修复所有问题」\n" + "⑤ 点击「验证 AddressKeys」确认无遗漏", MessageType.None); } private void DrawStatCard(string label, string value, Color valueColor) { using (new EditorGUILayout.VerticalScope(_sCard, GUILayout.Width(138), GUILayout.Height(68))) { EditorGUILayout.Space(4); var prev = GUI.color; GUI.color = valueColor; GUILayout.Label(value, new GUIStyle(EditorStyles.largeLabel) { fontSize = 26, alignment = TextAnchor.MiddleCenter, fontStyle = FontStyle.Bold, }, GUILayout.Height(34), GUILayout.ExpandWidth(true)); GUI.color = prev; GUILayout.Label(label, EditorStyles.centeredGreyMiniLabel, GUILayout.ExpandWidth(true)); } } // ═══════════════════════════════════════════════════════════════════════ // Tab 1 — 批量注册 (Batch Register) // ═══════════════════════════════════════════════════════════════════════ private void DrawRegister() { // ── Source bar ──────────────────────────────────────────────────── using (new EditorGUILayout.HorizontalScope(EditorStyles.toolbar)) { GUILayout.Label("数据源", EditorStyles.toolbarButton, GUILayout.Width(48)); string[] srcLabels = { "⚡ 全量扫描 _Game/", "📁 指定文件夹", "🖱 当前选中" }; int[] srcWidths = { 130, 100, 86 }; for (int i = 0; i < 3; i++) { bool was = _regSrc == i; bool now = GUILayout.Toggle(was, srcLabels[i], EditorStyles.toolbarButton, GUILayout.Width(srcWidths[i])); if (now && !was) { _regSrc = i; _regEntries = null; } } GUILayout.FlexibleSpace(); if (GUILayout.Button("🔄 刷新", EditorStyles.toolbarButton, GUILayout.Width(60))) _regEntries = null; if (GUILayout.Button("▶ 扫描", EditorStyles.toolbarButton, GUILayout.Width(60))) ScanRegisterEntries(); } // ── Folder picker ───────────────────────────────────────────────── if (_regSrc == 1) { using (new EditorGUILayout.HorizontalScope()) _regFolderAsset = (DefaultAsset)EditorGUILayout.ObjectField( "目标文件夹", _regFolderAsset, typeof(DefaultAsset), false); } // ── Type filters + search ───────────────────────────────────────── using (new EditorGUILayout.HorizontalScope()) { GUILayout.Label("类型:", EditorStyles.miniLabel, GUILayout.Width(36)); string[] typeLabels = { "Prefab", "Scene", "SO/Asset", "Audio", "Texture" }; for (int i = 0; i < typeLabels.Length; i++) _regTypeOn[i] = GUILayout.Toggle(_regTypeOn[i], typeLabels[i], "Button", GUILayout.Width(62)); GUILayout.Space(10); GUILayout.Label("搜索:", EditorStyles.miniLabel, GUILayout.Width(36)); _regSearch = GUILayout.TextField(_regSearch, GUILayout.Width(160)); GUILayout.Space(6); _regOnlyNew = GUILayout.Toggle(_regOnlyNew, "仅未注册", GUILayout.Width(68)); GUILayout.FlexibleSpace(); } EditorGUILayout.Space(2); // ── Table header ────────────────────────────────────────────────── using (new EditorGUILayout.HorizontalScope(EditorStyles.toolbar)) { GUILayout.Label("资产路径", _sBold, GUILayout.Width(CW_Path)); GUILayout.Label("Addressable 地址", _sBold, GUILayout.Width(CW_Addr)); GUILayout.Label("期望分组(规则)", _sBold, GUILayout.Width(CW_Group)); GUILayout.Label("期望标签(规则)", _sBold, GUILayout.Width(CW_Labels)); GUILayout.Label("状态 / 操作", _sBold); } // ── Content ─────────────────────────────────────────────────────── if (_regEntries == null) { EditorGUILayout.HelpBox( _regSrc == 2 ? "在 Project 窗口选中资产或文件夹,再点击「▶ 扫描」。" : "点击「▶ 扫描」加载资产列表。", MessageType.Info); DrawSharedOptions(); return; } var display = FilterRegEntries(_regEntries); _regScroll = EditorGUILayout.BeginScrollView(_regScroll); for (int i = 0; i < display.Count; i++) DrawRegRow(display[i], i); EditorGUILayout.EndScrollView(); // ── Footer ──────────────────────────────────────────────────────── int newCnt = display.Count(e => !e.Registered); using (new EditorGUILayout.HorizontalScope()) { GUILayout.Label( $"显示 {display.Count} 个条目,其中 {newCnt} 个未注册", EditorStyles.miniLabel); GUILayout.FlexibleSpace(); GUI.enabled = newCnt > 0; if (GUILayout.Button($"注册所有未注册项 ({newCnt})", GUILayout.Width(186))) { RegisterAll(display); SaveAssets(); } GUI.enabled = true; } DrawSharedOptions(); } private List FilterRegEntries(List src) { return src .Where(e => !_regOnlyNew || !e.Registered) .Where(e => string.IsNullOrEmpty(_regSearch) || e.Path.IndexOf(_regSearch, StringComparison.OrdinalIgnoreCase) >= 0 || e.Addr.IndexOf(_regSearch, StringComparison.OrdinalIgnoreCase) >= 0) .ToList(); } private void DrawRegRow(RegEntry e, int idx) { using (new EditorGUILayout.HorizontalScope( idx % 2 == 0 ? _sEvenRow : GUIStyle.none, GUILayout.Height(20))) { string disp = e.Path.Length > 46 ? "…" + e.Path.Substring(e.Path.Length - 43) : e.Path; if (GUILayout.Button(new GUIContent(disp, e.Path), _sLink, GUILayout.Width(CW_Path))) PingAt(e.Path); e.Addr = EditorGUILayout.TextField(e.Addr, GUILayout.Width(CW_Addr)); GUILayout.Label(e.Group ?? "Default", GUILayout.Width(CW_Group)); GUILayout.Label(e.Labels, GUILayout.Width(CW_Labels)); if (e.Registered) Clr("✅ 已注册", CG, GUILayout.Width(90)); else if (GUILayout.Button("注册", EditorStyles.miniButton, GUILayout.Width(50))) { RegisterOne(e); SaveAssets(); } } } // ═══════════════════════════════════════════════════════════════════════ // Tab 2 — 键同步 (Key Sync) // ═══════════════════════════════════════════════════════════════════════ private void DrawKeySync() { using (new EditorGUILayout.HorizontalScope(EditorStyles.toolbar)) { if (GUILayout.Button("🔄 刷新", EditorStyles.toolbarButton, GUILayout.Width(65))) LoadKeyEntries(); GUILayout.Space(6); _keyOnlyMissing = GUILayout.Toggle( _keyOnlyMissing, "仅未注册", EditorStyles.toolbarButton, GUILayout.Width(72)); GUILayout.FlexibleSpace(); if (_keyEntries != null) { int reg = _keyEntries.Count(e => e.Registered); GUILayout.Label( $"{reg}/{_keyEntries.Count} 已注册", EditorStyles.toolbarButton, GUILayout.Width(98)); } bool canRegAll = _keyEntries?.Any(e => !e.Registered && e.FoundPath != null) == true; GUI.enabled = canRegAll; if (GUILayout.Button("注册所有已匹配", EditorStyles.toolbarButton, GUILayout.Width(110))) { RegisterAllMatchedKeys(); SaveAssets(); } GUI.enabled = true; } if (_keyEntries == null) LoadKeyEntries(); // ── Table header ────────────────────────────────────────────────── using (new EditorGUILayout.HorizontalScope(EditorStyles.toolbar)) { GUILayout.Label("常量名", _sBold, GUILayout.Width(190)); GUILayout.Label("地址 Key", _sBold, GUILayout.Width(200)); GUILayout.Label("期望分组", _sBold, GUILayout.Width(110)); GUILayout.Label("期望标签", _sBold, GUILayout.Width(148)); GUILayout.Label("状态 / 资产 / 操作", _sBold); } var display = (_keyOnlyMissing ? _keyEntries.Where(e => !e.Registered) : _keyEntries).ToList(); _keyScroll = EditorGUILayout.BeginScrollView(_keyScroll); for (int i = 0; i < display.Count; i++) DrawKeyRow(display[i], i); EditorGUILayout.EndScrollView(); if (_keyEntries != null) { int r = _keyEntries.Count(e => e.Registered); int m = _keyEntries.Count(e => !e.Registered && e.FoundPath != null); int u = _keyEntries.Count(e => !e.Registered && e.FoundPath == null); EditorGUILayout.LabelField( $"共 {_keyEntries.Count} 个 Key · 已注册 {r} · 已找到待注册 {m} · 未找到 {u}", EditorStyles.miniLabel); } DrawSharedOptions(); } private void DrawKeyRow(KeyEntry e, int idx) { using (new EditorGUILayout.HorizontalScope( idx % 2 == 0 ? _sEvenRow : GUIStyle.none, GUILayout.Height(20))) { GUILayout.Label(e.Field, GUILayout.Width(190)); GUILayout.Label(e.Key, GUILayout.Width(200)); GUILayout.Label(AddressableRules.GetExpectedGroup(e.Key) ?? "Default", GUILayout.Width(110)); GUILayout.Label(FmtLabels(AddressableRules.GetExpectedLabels(e.Key)), GUILayout.Width(148)); if (e.Registered) { Clr("✅ 已注册", CG, GUILayout.Width(75)); if (GUILayout.Button( new GUIContent(e.ExistingPath ?? "—", e.ExistingPath), _sLink)) PingAt(e.ExistingPath); } else if (e.FoundPath != null) { Clr("⚠ 已找到", CY, GUILayout.Width(75)); GUILayout.Label(e.FoundPath, GUILayout.ExpandWidth(true)); if (GUILayout.Button("注册", EditorStyles.miniButton, GUILayout.Width(46))) { RegisterKey(e); SaveAssets(); } } else { Clr("❌ 未找到", CR, GUILayout.Width(75)); e.ManualObj = EditorGUILayout.ObjectField( e.ManualObj, typeof(UnityEngine.Object), false); if (e.ManualObj != null && GUILayout.Button("注册", EditorStyles.miniButton, GUILayout.Width(46))) { RegisterKeyManual(e); SaveAssets(); } } } } // ═══════════════════════════════════════════════════════════════════════ // Tab 3 — 规则校验 (Rule Sync) // ═══════════════════════════════════════════════════════════════════════ private void DrawRuleSync() { // ── Toolbar ─────────────────────────────────────────────────────── using (new EditorGUILayout.HorizontalScope(EditorStyles.toolbar)) { if (GUILayout.Button("▶ 扫描全部", EditorStyles.toolbarButton, GUILayout.Width(76))) RunRuleScan(); GUILayout.Space(6); _ruleShowOk = GUILayout.Toggle( _ruleShowOk, "显示正常项", EditorStyles.toolbarButton, GUILayout.Width(76)); GUILayout.Space(6); _ruleSearch = GUILayout.TextField( _ruleSearch, EditorStyles.toolbarSearchField, GUILayout.Width(180)); GUILayout.FlexibleSpace(); if (_ruleScanned && _ruleEntries != null) { int iss = _ruleEntries.Count(r => !r.Ok); int wrn = _ruleEntries.Count(r => r.Ok && r.HasWarn); var pc = GUI.color; GUI.color = iss > 0 ? CR : CD; GUILayout.Label($"❌ {iss}", EditorStyles.toolbarButton, GUILayout.Width(46)); GUI.color = wrn > 0 ? CY : CD; GUILayout.Label($"⚠ {wrn}", EditorStyles.toolbarButton, GUILayout.Width(46)); GUI.color = pc; } bool hasIssues = _ruleEntries?.Any(r => !r.Ok) == true; GUI.enabled = _ruleScanned && hasIssues; if (GUILayout.Button("✦ 修复所有问题", EditorStyles.toolbarButton, GUILayout.Width(108))) FixAll(); GUI.enabled = _ruleScanned; if (GUILayout.Button("导出 CSV", EditorStyles.toolbarButton, GUILayout.Width(68))) ExportCsv(); GUI.enabled = true; } // ── Stats bar ───────────────────────────────────────────────────── if (_ruleScanned && _ruleEntries != null) { int tot = _ruleEntries.Count; int ok = _ruleEntries.Count(r => r.Ok); int iss = tot - ok; int wrn = _ruleEntries.Count(r => r.HasWarn); using (new EditorGUILayout.HorizontalScope()) { GUILayout.Label($"共 {tot} 条目", EditorStyles.miniLabel, GUILayout.Width(72)); GUILayout.Space(6); Clr($"✅ 正常 {ok}", CG); GUILayout.Space(8); Clr($"❌ 问题 {iss}", iss > 0 ? CR : CG); GUILayout.Space(8); Clr($"⚠ 自定义标签 {wrn}", wrn > 0 ? CY : CD); GUILayout.FlexibleSpace(); } } // ── Table header ────────────────────────────────────────────────── using (new EditorGUILayout.HorizontalScope(EditorStyles.toolbar)) { GUILayout.Label("Address", _sBold, GUILayout.Width(210)); GUILayout.Label("当前分组", _sBold, GUILayout.Width(110)); GUILayout.Label("期望分组", _sBold, GUILayout.Width(110)); GUILayout.Label("缺失标签", _sBold, GUILayout.Width(118)); GUILayout.Label("多余规则标签", _sBold, GUILayout.Width(108)); GUILayout.Label("自定义标签", _sBold, GUILayout.Width(100)); GUILayout.Label("状态", _sBold); } // ── Rows ────────────────────────────────────────────────────────── _ruleScroll = EditorGUILayout.BeginScrollView(_ruleScroll); if (!_ruleScanned) { EditorGUILayout.HelpBox( "点击「▶ 扫描全部」分析已注册资产与规范的差异。", MessageType.Info); } else { var show = _ruleEntries .Where(r => _ruleShowOk || !r.Ok) .Where(r => string.IsNullOrEmpty(_ruleSearch) || r.Address.IndexOf(_ruleSearch, StringComparison.OrdinalIgnoreCase) >= 0) .ToList(); if (show.Count == 0) EditorGUILayout.HelpBox("✅ 所有已注册资产均符合规范!", MessageType.Info); for (int i = 0; i < show.Count; i++) DrawRuleRow(show[i], i); } EditorGUILayout.EndScrollView(); // ── Footer hint ─────────────────────────────────────────────────── EditorGUILayout.HelpBox( "规则来源:AddressablesLabelSpec.md §3 | 分组规则:AssetFolderSpec.md §8.1\n" + "「修复所有问题」修正分组与标签;不注册新资产;不删除自定义标签(⚠ 黄色)。", MessageType.None); } private void DrawRuleRow(RuleEntry r, int idx) { using (new EditorGUILayout.HorizontalScope( idx % 2 == 0 ? _sEvenRow : GUIStyle.none, GUILayout.Height(20))) { if (GUILayout.Button(r.Address, _sLink, GUILayout.Width(210))) PingAt(r.AssetPath); Clr(r.CurGroup ?? "—", r.GroupOk ? CG : CR, GUILayout.Width(110)); GUILayout.Label(r.ExpGroup ?? "(未覆盖)", GUILayout.Width(110)); Clr(r.Missing.Length > 0 ? Jn(r.Missing) : "—", r.Missing.Length > 0 ? CR : CG, GUILayout.Width(118)); Clr(r.Extra.Length > 0 ? Jn(r.Extra) : "—", r.Extra.Length > 0 ? CR : CD, GUILayout.Width(108)); Clr(r.Unknown.Length > 0 ? Jn(r.Unknown) : "—", r.Unknown.Length > 0 ? CY : CD, GUILayout.Width(100)); if (r.Ok) Clr(r.HasWarn ? "⚠ 自定义标签" : "✅ 正常", r.HasWarn ? CY : CG); else { Clr("❌ 需修复", CR, GUILayout.Width(62)); if (GUILayout.Button("修复", EditorStyles.miniButton, GUILayout.Width(40))) { FixOne(r); SaveAssets(); RunRuleScan(); } } } } // ── Shared Options ──────────────────────────────────────────────────── private void DrawSharedOptions() { EditorGUILayout.Space(4); using (new EditorGUILayout.HorizontalScope()) { _applyRules = GUILayout.Toggle(_applyRules, "自动应用分组/标签规则"); GUILayout.Space(14); _overwrite = GUILayout.Toggle(_overwrite, "覆盖已有地址"); GUILayout.Space(14); GUILayout.Label("附加标签:", GUILayout.Width(58)); _extraLabel = GUILayout.TextField(_extraLabel, GUILayout.Width(90)); GUILayout.Space(14); GUILayout.Label("新建分组:", GUILayout.Width(58)); _newGroupName = GUILayout.TextField(_newGroupName, GUILayout.Width(110)); if (GUILayout.Button("创建", EditorStyles.miniButton, GUILayout.Width(40)) && !string.IsNullOrWhiteSpace(_newGroupName)) { var s = AddressableAssetSettingsDefaultObject.Settings; if (s != null) { EnsureGroup(s, _newGroupName.Trim()); SaveAssets(); } } GUILayout.FlexibleSpace(); } } // ═══════════════════════════════════════════════════════════════════════ // Register Logic // ═══════════════════════════════════════════════════════════════════════ private void ScanRegisterEntries() { _regEntries = new List(); var settings = AddressableAssetSettingsDefaultObject.Settings; if (settings == null) return; var regGuids = CollectAllGuids(settings); var files = GatherFiles(); try { for (int i = 0; i < files.Count; i++) { if (i % 20 == 0) EditorUtility.DisplayProgressBar( "扫描资产", Path.GetFileName(files[i]), (float)i / files.Count); string p = files[i]; if (!IsManageableType(p) || ShouldExclude(p) || !PassesTypeFilter(p)) continue; string guid = AssetDatabase.AssetPathToGUID(p); if (string.IsNullOrEmpty(guid)) continue; string addr = BuildAddr(p); _regEntries.Add(new RegEntry { Path = p, Guid = guid, Addr = addr, Registered = regGuids.Contains(guid), Group = AddressableRules.GetExpectedGroup(addr), Labels = FmtLabels(AddressableRules.GetExpectedLabels(addr)), }); } } finally { EditorUtility.ClearProgressBar(); } _regEntries = _regEntries .GroupBy(e => e.Guid) .Select(g => g.First()) .OrderBy(e => e.Registered ? 1 : 0) .ThenBy(e => e.Addr) .ToList(); } private List GatherFiles() { var result = new List(); if (_regSrc == 0) { foreach (string g in AssetDatabase.FindAssets("t:Object", new[] { "Assets/_Game" })) { string p = AssetDatabase.GUIDToAssetPath(g); if (!AssetDatabase.IsValidFolder(p)) result.Add(p); } return result; } if (_regSrc == 1) { string fp = _regFolderAsset != null ? AssetDatabase.GetAssetPath(_regFolderAsset) : ""; if (string.IsNullOrEmpty(fp) || !AssetDatabase.IsValidFolder(fp)) return result; foreach (string g in AssetDatabase.FindAssets("t:Object", new[] { fp })) { string p = AssetDatabase.GUIDToAssetPath(g); if (!AssetDatabase.IsValidFolder(p)) result.Add(p); } return result; } // Selection foreach (string guid in Selection.assetGUIDs) { string p = AssetDatabase.GUIDToAssetPath(guid); if (AssetDatabase.IsValidFolder(p)) { foreach (string sg in AssetDatabase.FindAssets("t:Object", new[] { p })) { string sp = AssetDatabase.GUIDToAssetPath(sg); if (!AssetDatabase.IsValidFolder(sp)) result.Add(sp); } } else { result.Add(p); } } return result; } private bool PassesTypeFilter(string p) { bool anyOn = _regTypeOn.Any(v => v); if (!anyOn) return true; string ext = Path.GetExtension(p).ToLowerInvariant(); if (_regTypeOn[0] && ext == ".prefab") return true; if (_regTypeOn[1] && ext == ".unity") return true; if (_regTypeOn[2] && ext == ".asset") return true; if (_regTypeOn[3] && (ext == ".mp3" || ext == ".wav" || ext == ".ogg")) return true; if (_regTypeOn[4] && (ext == ".png" || ext == ".jpg" || ext == ".tga")) return true; return false; } private void RegisterOne(RegEntry e) { if (e.Registered && !_overwrite) return; var s = AddressableAssetSettingsDefaultObject.Settings; if (s == null) return; if (!ConfirmAddressConflict(s, e.Addr, e.Guid)) return; var grp = _applyRules && e.Group != null ? EnsureGroup(s, e.Group) : s.DefaultGroup; var entry = s.FindAssetEntry(e.Guid) ?? s.CreateOrMoveEntry(e.Guid, grp, false, false); if (entry == null) return; entry.address = e.Addr; s.MoveEntry(entry, grp, false, false); if (_applyRules) foreach (var lbl in AddressableRules.GetExpectedLabels(e.Addr)) SetLabel(s, entry, lbl); if (!string.IsNullOrWhiteSpace(_extraLabel)) SetLabel(s, entry, _extraLabel.Trim()); e.Registered = true; } private void RegisterAll(List entries) { int cnt = 0; foreach (var e in entries.Where(e => !e.Registered)) { RegisterOne(e); cnt++; } Debug.Log($"[AddressablesManager] 批量注册完成:{cnt} 个资产"); } // ═══════════════════════════════════════════════════════════════════════ // Key Sync Logic // ═══════════════════════════════════════════════════════════════════════ private void LoadKeyEntries() { _keyEntries = new List(); var s = AddressableAssetSettingsDefaultObject.Settings; if (s == null) return; // Build address → path map from all registered entries var addrMap = new Dictionary(StringComparer.Ordinal); foreach (var g in s.groups) if (g != null) foreach (var e in g.entries) if (e != null) addrMap[e.address] = e.AssetPath; var fields = typeof(AddressKeys) .GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy) .Where(f => f.IsLiteral && !f.IsInitOnly && f.FieldType == typeof(string)); foreach (var f in fields) { var key = (string)f.GetRawConstantValue(); var ke = new KeyEntry { Field = f.Name, Key = key }; if (addrMap.TryGetValue(key, out string ep)) { ke.Registered = true; ke.ExistingPath = ep; } else { // Auto-search by the last segment of the key (handles "Config/Name") string searchTerm = key.Contains('/') ? key.Substring(key.LastIndexOf('/') + 1) : key; string[] guids = AssetDatabase.FindAssets(searchTerm); string best = guids .Select(AssetDatabase.GUIDToAssetPath) .Where(p => !AssetDatabase.IsValidFolder(p) && IsManageableType(p)) .OrderBy(p => Path.GetFileNameWithoutExtension(p) == searchTerm ? 0 : 1) .FirstOrDefault(); if (best != null) { ke.FoundPath = best; ke.FoundGuid = AssetDatabase.AssetPathToGUID(best); } } _keyEntries.Add(ke); } } private void RegisterKey(KeyEntry e) { if (e.FoundGuid == null) return; string grp = _applyRules ? AddressableRules.GetExpectedGroup(e.Key) : null; DoRegister(e.FoundGuid, e.Key, grp); e.Registered = true; e.ExistingPath = e.FoundPath; e.FoundPath = null; } private void RegisterKeyManual(KeyEntry e) { string p = AssetDatabase.GetAssetPath(e.ManualObj); string guid = AssetDatabase.AssetPathToGUID(p); string grp = _applyRules ? AddressableRules.GetExpectedGroup(e.Key) : null; DoRegister(guid, e.Key, grp); e.Registered = true; e.ExistingPath = p; e.ManualObj = null; } private void RegisterAllMatchedKeys() { int cnt = 0; foreach (var e in _keyEntries.Where(e => !e.Registered && e.FoundPath != null)) { RegisterKey(e); cnt++; } Debug.Log($"[AddressablesManager] 键同步完成:注册 {cnt} 个 Key"); } // ═══════════════════════════════════════════════════════════════════════ // Rule Sync Logic // ═══════════════════════════════════════════════════════════════════════ private void RunRuleScan() { _ruleEntries = new List(); var s = AddressableAssetSettingsDefaultObject.Settings; if (s == null) return; foreach (var grp in s.groups) { if (grp == null) continue; foreach (var e in grp.entries) { if (e == null) continue; var expG = AddressableRules.GetExpectedGroup(e.address); var expL = AddressableRules.GetExpectedLabels(e.address); var curL = e.labels.ToArray(); var notExp = curL.Except(expL, StringComparer.Ordinal).ToArray(); _ruleEntries.Add(new RuleEntry { Address = e.address, AssetPath = e.AssetPath, CurGroup = grp.name, ExpGroup = expG, Missing = expL.Except(curL, StringComparer.Ordinal).ToArray(), Extra = notExp.Where(l => AddressableRules.KnownLabels.Contains(l)).ToArray(), Unknown = notExp.Where(l => !AddressableRules.KnownLabels.Contains(l)).ToArray(), }); } } _ruleEntries = _ruleEntries .OrderBy(r => r.Ok ? 1 : 0) .ThenBy(r => r.Address, StringComparer.Ordinal) .ToList(); _ruleScanned = true; // Sync dashboard counters _dTotal = _ruleEntries.Count; _dOk = _ruleEntries.Count(r => r.Ok); _dIssue = _ruleEntries.Count(r => !r.Ok); _dWarn = _ruleEntries.Count(r => r.HasWarn); _dReady = true; _dTime = DateTime.Now.ToString("HH:mm:ss"); Debug.Log( $"[AddressablesManager] 规则扫描:{_ruleEntries.Count} 条 · " + $"{_dIssue} 需修复 · {_dWarn} 含自定义标签"); Repaint(); } private void FixAll() { var issues = _ruleEntries.Where(r => !r.Ok).ToList(); if (issues.Count == 0) return; int moves = issues.Count(r => !r.GroupOk); int adds = issues.Sum(r => r.Missing.Length); int rems = issues.Sum(r => r.Extra.Length); if (!EditorUtility.DisplayDialog("确认修复所有问题", $"即将对 {issues.Count} 个条目执行:\n\n" + $" • 移动分组:{moves} 个\n" + $" • 添加标签:{adds} 个\n" + $" • 移除多余规则标签:{rems} 个\n\n" + "⚠ 自定义标签(黄色⚠)不会被删除。此操作不可撤销。", "确认修复", "取消")) return; int cnt = 0; foreach (var r in issues) if (FixOne(r)) cnt++; SaveAssets(); RunRuleScan(); Debug.Log($"[AddressablesManager] 修复完成:共处理 {cnt} 个条目"); } private bool FixOne(RuleEntry r) { var s = AddressableAssetSettingsDefaultObject.Settings; if (s == null) return false; var entry = FindByAddr(s, r.Address); if (entry == null) return false; bool changed = false; if (!r.GroupOk && r.ExpGroup != null) { var grp = EnsureGroup(s, r.ExpGroup); if (grp != null && entry.parentGroup != grp) { s.MoveEntry(entry, grp, false, false); changed = true; } } foreach (var lbl in r.Missing) { SetLabel(s, entry, lbl); changed = true; } foreach (var lbl in r.Extra) { entry.SetLabel(lbl, false, true); changed = true; } return changed; } private void ExportCsv() { if (_ruleEntries == null) return; string path = EditorUtility.SaveFilePanel( "导出规则报告", "", "AddressableRuleReport.csv", "csv"); if (string.IsNullOrEmpty(path)) return; var sb = new StringBuilder(); sb.AppendLine("Address,CurrentGroup,ExpectedGroup,GroupOk,Missing,Extra,Unknown,Status"); foreach (var r in _ruleEntries) sb.AppendLine( $"\"{r.Address}\",\"{r.CurGroup}\",\"{r.ExpGroup ?? ""}\"," + $"{r.GroupOk},\"{Jn(r.Missing)}\",\"{Jn(r.Extra)}\",\"{Jn(r.Unknown)}\"," + $"{(r.Ok ? "OK" : "ISSUE")}"); File.WriteAllText(path, sb.ToString(), Encoding.UTF8); Debug.Log($"[AddressablesManager] CSV 已导出:{path}"); } // ═══════════════════════════════════════════════════════════════════════ // Core Register Primitive // ═══════════════════════════════════════════════════════════════════════ private void DoRegister(string guid, string addr, string groupName) { var s = AddressableAssetSettingsDefaultObject.Settings; if (s == null || string.IsNullOrEmpty(guid)) return; if (!ConfirmAddressConflict(s, addr, guid)) return; var grp = groupName != null ? EnsureGroup(s, groupName) : s.DefaultGroup; var entry = s.FindAssetEntry(guid) ?? s.CreateOrMoveEntry(guid, grp, false, false); if (entry == null) return; entry.address = addr; s.MoveEntry(entry, grp, false, false); if (_applyRules) foreach (var lbl in AddressableRules.GetExpectedLabels(addr)) SetLabel(s, entry, lbl); if (!string.IsNullOrWhiteSpace(_extraLabel)) SetLabel(s, entry, _extraLabel.Trim()); } private static bool ConfirmAddressConflict(AddressableAssetSettings s, string addr, string guid) { var dup = FindByAddr(s, addr); if (dup == null || dup.guid == guid) return true; return EditorUtility.DisplayDialog( "⚠ 地址冲突", $"地址 \"{addr}\" 已绑定:\n{dup.AssetPath}\n\n继续将覆盖原绑定。", "继续", "取消"); } // ═══════════════════════════════════════════════════════════════════════ // Helpers — Asset Discovery // ═══════════════════════════════════════════════════════════════════════ private static bool IsManageableType(string p) { string ext = Path.GetExtension(p).ToLowerInvariant(); return ext is ".prefab" or ".unity" or ".asset" or ".png" or ".jpg" or ".tga" or ".mp3" or ".wav" or ".ogg" or ".controller"; } /// /// 不应注册为 Addressable 的资产(由 Prefab 依赖链加载,或仅供编辑器引用)。 /// private static bool ShouldExclude(string p) { string lp = p.Replace('\\', '/').ToLowerInvariant(); string name = Path.GetFileNameWithoutExtension(p); string ext = Path.GetExtension(p).ToLowerInvariant(); if (lp.Contains("/scenes/testings/")) return true; if (name.StartsWith("EVT_", StringComparison.Ordinal)) return true; if (ext == ".spriteatlas") return true; if (ext == ".mat") return true; // ENV_* Prefab 由场景直接引用,不注册 Addressable(AssetFolderSpec §4.1) if (name.StartsWith("ENV_", StringComparison.OrdinalIgnoreCase)) return true; // Build_* 是不符合规范命名的装饰性环境 Prefab,不纳入 Addressables 管理 if (name.StartsWith("Build_", StringComparison.OrdinalIgnoreCase)) return true; // HitBox / HurtBox 子 Prefab 不单独注册(名称中含关键词即排除) if (name.IndexOf("HitBox", StringComparison.OrdinalIgnoreCase) >= 0) return true; if (name.IndexOf("HurtBox", StringComparison.OrdinalIgnoreCase) >= 0) return true; return false; } /// /// 从资产路径推导 Addressable 地址。 /// 有已知前缀 → 直接用文件名;Data 文件夹内无前缀 SO → "Config/{name}"。 /// private static string BuildAddr(string p) { string name = Path.GetFileNameWithoutExtension(p); foreach (var (prefix, _) in AddressableRules.PrefixGroupMap) { if (prefix.EndsWith('/')) continue; // "Config/" 是地址前缀,不是文件名前缀 if (name.StartsWith(prefix, StringComparison.Ordinal)) return name; } // Room_ / Boss_ 动态分组(不在 PrefixGroupMap 中,但地址就是文件名) if (name.StartsWith("Room_", StringComparison.Ordinal)) return name; if (name.StartsWith("Boss_", StringComparison.Ordinal)) return name; // Data / Config 文件夹内无标准前缀的 SO → Config/Name string np = p.Replace('\\', '/'); if ((np.Contains("/_Game/Data/") || np.Contains("/_Game/Config/")) && Path.GetExtension(p).ToLowerInvariant() == ".asset") return $"Config/{name}"; return name; } // ═══════════════════════════════════════════════════════════════════════ // Helpers — Addressables API // ═══════════════════════════════════════════════════════════════════════ private static HashSet CollectAllGuids(AddressableAssetSettings s) { var set = new HashSet(StringComparer.Ordinal); foreach (var g in s.groups) if (g != null) foreach (var e in g.entries) if (e != null) set.Add(e.guid); return set; } private static AddressableAssetEntry FindByAddr(AddressableAssetSettings s, string addr) { foreach (var g in s.groups) if (g != null) foreach (var e in g.entries) if (e?.address == addr) return e; return null; } private static AddressableAssetGroup EnsureGroup(AddressableAssetSettings s, string name) { var existing = s.groups.FirstOrDefault(g => g?.name == name); if (existing != null) return existing; var tmpl = s.GroupTemplateObjects.FirstOrDefault() as AddressableAssetGroupTemplate; var schemas = tmpl != null ? new List(tmpl.SchemaObjects) : null; var created = s.CreateGroup(name, false, false, true, schemas); if (created != null) Debug.Log($"[AddressablesManager] 已自动创建分组:{name}"); return created ?? s.DefaultGroup; } private static void SetLabel(AddressableAssetSettings s, AddressableAssetEntry entry, string label) { if (!s.GetLabels().Contains(label)) { s.AddLabel(label, true); Debug.Log($"[AddressablesManager] 已创建标签:{label}"); } entry.SetLabel(label, true, true); } private static void SaveAssets() { AssetDatabase.SaveAssets(); AddressableAssetSettingsDefaultObject.Settings?.SetDirty( AddressableAssetSettings.ModificationEvent.EntryModified, null, true); } private static void PingAt(string assetPath) { if (string.IsNullOrEmpty(assetPath)) return; var obj = AssetDatabase.LoadMainAssetAtPath(assetPath); if (obj != null) EditorGUIUtility.PingObject(obj); } // ═══════════════════════════════════════════════════════════════════════ // Helpers — Formatting / UI // ═══════════════════════════════════════════════════════════════════════ private static string FmtLabels(string[] labels) => labels.Length == 0 ? "—" : string.Join(", ", labels); private static string Jn(string[] arr) => arr is { Length: > 0 } ? string.Join("; ", arr) : ""; private void Clr(string text, Color c, params GUILayoutOption[] opts) { var prev = GUI.color; GUI.color = c; GUILayout.Label(text, opts); GUI.color = prev; } // ── Styles ──────────────────────────────────────────────────────────── private void EnsureStyles() { if (_stylesReady) return; _sBold = new GUIStyle(EditorStyles.boldLabel) { fontSize = 11 }; _sLink = new GUIStyle(EditorStyles.linkLabel); _sCenGrey = new GUIStyle(EditorStyles.centeredGreyMiniLabel); _sCard = new GUIStyle("HelpBox"); _sEvenRow = new GUIStyle { normal = { background = MkTex(CE) } }; _stylesReady = true; } private static Texture2D MkTex(Color c) { var t = new Texture2D(1, 1, TextureFormat.RGBA32, false); t.SetPixel(0, 0, c); t.Apply(); return t; } // ═══════════════════════════════════════════════════════════════════════ // Data Types // ═══════════════════════════════════════════════════════════════════════ private class RegEntry { public string Path, Guid, Addr, Group, Labels; public bool Registered; } private class KeyEntry { public string Field, Key; public bool Registered; public string ExistingPath, FoundPath, FoundGuid; public UnityEngine.Object ManualObj; } private class RuleEntry { public string Address, AssetPath, CurGroup, ExpGroup; public string[] Missing = Array.Empty(); public string[] Extra = Array.Empty(); public string[] Unknown = Array.Empty(); public bool GroupOk => ExpGroup == null || CurGroup == ExpGroup; public bool LabelsOk => Missing.Length == 0 && Extra.Length == 0; public bool Ok => GroupOk && LabelsOk; public bool HasWarn => Unknown.Length > 0; } } }