From f1c0b65737d4ba74a94c7e0ea38ea9954842a735 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Fri, 22 May 2026 13:34:47 +0800 Subject: [PATCH] feat: Enhance Addressable tools with improved scanning and filtering features - Updated AddressReferenceGraphWindow to scan for AddressKeys in the _Game directory and added a warning for missing directories. - Enhanced AddressableBatchTool with new filters for asset types (Prefab, Scene, ScriptableObject, Texture, Audio) and improved UI layout for better usability. - Introduced automatic application of grouping and labeling rules during registration in AddressableBatchTool. - Added functionality to quickly scan the _Game folder and improved address building logic. - Updated AddressableRuleSyncWindow to include handling for custom labels and improved reporting of issues. - Enhanced AddressableRules with a whitelist for known labels and refined grouping and labeling logic based on asset prefixes. --- .../AddressReferenceGraphWindow.cs | 14 +- .../Addressables/AddressableBatchTool.cs | 331 ++++++++++++++---- .../Addressables/AddressableRuleSyncWindow.cs | 125 ++++--- .../Editor/Addressables/AddressableRules.cs | 58 ++- 4 files changed, 416 insertions(+), 112 deletions(-) diff --git a/Assets/_Game/Scripts/Editor/Addressables/AddressReferenceGraphWindow.cs b/Assets/_Game/Scripts/Editor/Addressables/AddressReferenceGraphWindow.cs index 5763206..e389c0b 100644 --- a/Assets/_Game/Scripts/Editor/Addressables/AddressReferenceGraphWindow.cs +++ b/Assets/_Game/Scripts/Editor/Addressables/AddressReferenceGraphWindow.cs @@ -200,11 +200,15 @@ namespace BaseGames.Editor foreach (var kv in keyDict) kv.Value.ExistsInAddressables = registeredAddressValues.Contains(kv.Value.Value); - // 3. 扫描 .cs 文件引用 - var csFiles = Directory.GetFiles( - Path.Combine(Application.dataPath, "Scripts"), - "*.cs", - SearchOption.AllDirectories); + // 3. 扫描 _Game/ 下所有 .cs 文件中对 AddressKeys 的引用 + string gameDir = Path.Combine(Application.dataPath, "_Game"); + if (!Directory.Exists(gameDir)) + { + Debug.LogWarning($"[AddressReferenceGraph] 未找到游戏目录:{gameDir}"); + foreach (var kv in keyDict) _entries.Add(kv.Value); + return; + } + var csFiles = Directory.GetFiles(gameDir, "*.cs", SearchOption.AllDirectories); foreach (var file in csFiles) { diff --git a/Assets/_Game/Scripts/Editor/Addressables/AddressableBatchTool.cs b/Assets/_Game/Scripts/Editor/Addressables/AddressableBatchTool.cs index 3cad8b2..9b2337b 100644 --- a/Assets/_Game/Scripts/Editor/Addressables/AddressableBatchTool.cs +++ b/Assets/_Game/Scripts/Editor/Addressables/AddressableBatchTool.cs @@ -60,6 +60,11 @@ namespace BaseGames.Editor // Tab ③ private List _selectionEntries; private Vector2 _selectionScrollPos; + private bool _selFilterPrefab = true; + private bool _selFilterScene = true; + private bool _selFilterSO = true; + private bool _selFilterTexture; + private bool _selFilterAudio; // 共用 private int _targetGroupIndex; @@ -67,6 +72,7 @@ namespace BaseGames.Editor private string _newGroupName = "New Group"; private string _newLabel = ""; private bool _overwriteAddress; + private bool _applyRulesOnRegister = true; // ── 样式(惰性初始化)──────────────────────────────────────────────── private GUIStyle _headerStyle; @@ -81,7 +87,7 @@ namespace BaseGames.Editor public static void OpenWindow() { var win = GetWindow(Title); - win.minSize = new Vector2(600, 460); + win.minSize = new Vector2(920, 520); win.Show(); } @@ -144,9 +150,11 @@ namespace BaseGames.Editor // 列表表头 using (new EditorGUILayout.HorizontalScope(EditorStyles.toolbar)) { - EditorGUILayout.LabelField("常量名", _boldStyle, GUILayout.Width(200)); - EditorGUILayout.LabelField("地址 Key", _boldStyle, GUILayout.Width(180)); - EditorGUILayout.LabelField("状态", _boldStyle, GUILayout.Width(100)); + EditorGUILayout.LabelField("常量名", _boldStyle, GUILayout.Width(180)); + EditorGUILayout.LabelField("地址 Key", _boldStyle, GUILayout.Width(160)); + EditorGUILayout.LabelField("期望分组", _boldStyle, GUILayout.Width(110)); + EditorGUILayout.LabelField("期望标签", _boldStyle, GUILayout.Width(140)); + EditorGUILayout.LabelField("状态", _boldStyle, GUILayout.Width(80)); EditorGUILayout.LabelField("匹配资产", _boldStyle); } @@ -160,24 +168,30 @@ namespace BaseGames.Editor { using (new EditorGUILayout.HorizontalScope(GUILayout.Height(20))) { - EditorGUILayout.LabelField(entry.FieldName, GUILayout.Width(200)); - EditorGUILayout.LabelField(entry.AddressKey, GUILayout.Width(180)); + EditorGUILayout.LabelField(entry.FieldName, GUILayout.Width(180)); + EditorGUILayout.LabelField(entry.AddressKey, GUILayout.Width(160)); + EditorGUILayout.LabelField( + AddressableRules.GetExpectedGroup(entry.AddressKey) ?? "Default", + GUILayout.Width(110)); + EditorGUILayout.LabelField( + FormatLabels(AddressableRules.GetExpectedLabels(entry.AddressKey)), + GUILayout.Width(140)); if (entry.IsRegistered) { - EditorGUILayout.LabelField("✅ 已注册", _okStyle, GUILayout.Width(100)); + EditorGUILayout.LabelField("✅ 已注册", _okStyle, GUILayout.Width(80)); EditorGUILayout.LabelField(entry.ExistingAssetPath ?? "—"); } else if (entry.FoundAssetPath != null) { - EditorGUILayout.LabelField("⚠ 未注册", _warnStyle, GUILayout.Width(100)); + EditorGUILayout.LabelField("⚠ 未注册", _warnStyle, GUILayout.Width(80)); EditorGUILayout.LabelField(entry.FoundAssetPath, GUILayout.ExpandWidth(true)); if (GUILayout.Button("注册", GUILayout.Width(50))) RegisterKeyEntry(entry); } else { - EditorGUILayout.LabelField("❌ 未找到", _warnStyle, GUILayout.Width(100)); + EditorGUILayout.LabelField("❌ 未找到", _warnStyle, GUILayout.Width(80)); entry.ManualAsset = (UnityEngine.Object)EditorGUILayout.ObjectField( entry.ManualAsset, typeof(UnityEngine.Object), false); if (entry.ManualAsset != null) @@ -247,6 +261,8 @@ namespace BaseGames.Editor using (new EditorGUILayout.HorizontalScope()) { + if (GUILayout.Button("⚡ 全量扫描 _Game/", GUILayout.Width(150))) + QuickScanGameFolder(); GUILayout.FlexibleSpace(); GUI.enabled = !string.IsNullOrEmpty(_folderPath); if (GUILayout.Button("扫描文件夹", GUILayout.Width(100))) @@ -266,9 +282,14 @@ namespace BaseGames.Editor EditorGUILayout.Space(4); using (new EditorGUILayout.HorizontalScope(EditorStyles.toolbar)) { - EditorGUILayout.LabelField("资产路径", _boldStyle, GUILayout.ExpandWidth(true)); - EditorGUILayout.LabelField("预计地址", _boldStyle, GUILayout.Width(200)); - EditorGUILayout.LabelField("状态", _boldStyle, GUILayout.Width(80)); + EditorGUILayout.LabelField("资产路径", _boldStyle, GUILayout.Width(180)); + EditorGUILayout.LabelField("地址", _boldStyle, GUILayout.Width(150)); + if (_applyRulesOnRegister) + { + EditorGUILayout.LabelField("分组(规则)", _boldStyle, GUILayout.Width(120)); + EditorGUILayout.LabelField("标签(规则)", _boldStyle, GUILayout.Width(150)); + } + EditorGUILayout.LabelField("状态", _boldStyle, GUILayout.Width(70)); } _folderScrollPos = EditorGUILayout.BeginScrollView(_folderScrollPos, GUILayout.ExpandHeight(true)); @@ -276,11 +297,16 @@ namespace BaseGames.Editor { using (new EditorGUILayout.HorizontalScope(GUILayout.Height(18))) { - EditorGUILayout.LabelField(entry.AssetPath, GUILayout.ExpandWidth(true)); - entry.Address = EditorGUILayout.TextField(entry.Address, GUILayout.Width(200)); + EditorGUILayout.LabelField(entry.AssetPath, GUILayout.Width(180)); + entry.Address = EditorGUILayout.TextField(entry.Address, GUILayout.Width(150)); + if (_applyRulesOnRegister) + { + EditorGUILayout.LabelField(entry.PredictedGroup ?? "Default", GUILayout.Width(120)); + EditorGUILayout.LabelField(entry.PredictedLabels ?? "—", GUILayout.Width(150)); + } var label = entry.AlreadyRegistered ? "✅ 已有" : "待注册"; var style = entry.AlreadyRegistered ? _okStyle : EditorStyles.miniLabel; - EditorGUILayout.LabelField(label, style, GUILayout.Width(80)); + EditorGUILayout.LabelField(label, style, GUILayout.Width(70)); } } EditorGUILayout.EndScrollView(); @@ -296,6 +322,18 @@ namespace BaseGames.Editor EditorGUILayout.LabelField("在 Project 窗口中选中资产或文件夹,然后点击「读取选中项」。", EditorStyles.wordWrappedMiniLabel); EditorGUILayout.Space(4); + // 资产类型筛选(与 Tab ② 一致,防止误注册不该 Addressable 的文件类型) + EditorGUILayout.LabelField("资产类型筛选", EditorStyles.boldLabel); + using (new EditorGUILayout.HorizontalScope()) + { + _selFilterPrefab = GUILayout.Toggle(_selFilterPrefab, "Prefab", GUILayout.Width(70)); + _selFilterScene = GUILayout.Toggle(_selFilterScene, "Scene", GUILayout.Width(70)); + _selFilterSO = GUILayout.Toggle(_selFilterSO, "SO/Asset", GUILayout.Width(80)); + _selFilterTexture = GUILayout.Toggle(_selFilterTexture, "Texture", GUILayout.Width(70)); + _selFilterAudio = GUILayout.Toggle(_selFilterAudio, "Audio", GUILayout.Width(70)); + } + EditorGUILayout.Space(4); + using (new EditorGUILayout.HorizontalScope()) { GUILayout.FlexibleSpace(); @@ -316,9 +354,14 @@ namespace BaseGames.Editor EditorGUILayout.Space(4); using (new EditorGUILayout.HorizontalScope(EditorStyles.toolbar)) { - EditorGUILayout.LabelField("资产路径", _boldStyle, GUILayout.ExpandWidth(true)); - EditorGUILayout.LabelField("注册地址", _boldStyle, GUILayout.Width(200)); - EditorGUILayout.LabelField("状态", _boldStyle, GUILayout.Width(80)); + EditorGUILayout.LabelField("资产路径", _boldStyle, GUILayout.Width(180)); + EditorGUILayout.LabelField("注册地址", _boldStyle, GUILayout.Width(150)); + if (_applyRulesOnRegister) + { + EditorGUILayout.LabelField("分组(规则)", _boldStyle, GUILayout.Width(120)); + EditorGUILayout.LabelField("标签(规则)", _boldStyle, GUILayout.Width(150)); + } + EditorGUILayout.LabelField("状态", _boldStyle, GUILayout.Width(70)); } _selectionScrollPos = EditorGUILayout.BeginScrollView(_selectionScrollPos, GUILayout.ExpandHeight(true)); @@ -326,11 +369,16 @@ namespace BaseGames.Editor { using (new EditorGUILayout.HorizontalScope(GUILayout.Height(18))) { - EditorGUILayout.LabelField(entry.AssetPath, GUILayout.ExpandWidth(true)); - entry.Address = EditorGUILayout.TextField(entry.Address, GUILayout.Width(200)); + EditorGUILayout.LabelField(entry.AssetPath, GUILayout.Width(180)); + entry.Address = EditorGUILayout.TextField(entry.Address, GUILayout.Width(150)); + if (_applyRulesOnRegister) + { + EditorGUILayout.LabelField(entry.PredictedGroup ?? "Default", GUILayout.Width(120)); + EditorGUILayout.LabelField(entry.PredictedLabels ?? "—", GUILayout.Width(150)); + } var label = entry.AlreadyRegistered ? "✅ 已有" : "待注册"; var style = entry.AlreadyRegistered ? _okStyle : EditorStyles.miniLabel; - EditorGUILayout.LabelField(label, style, GUILayout.Width(80)); + EditorGUILayout.LabelField(label, style, GUILayout.Width(70)); } } EditorGUILayout.EndScrollView(); @@ -347,22 +395,32 @@ namespace BaseGames.Editor using (new EditorGUILayout.HorizontalScope()) { EditorGUILayout.LabelField("目标分组", GUILayout.Width(70)); - if (_groupNames != null && _groupNames.Length > 0) + // 自动规则模式下,目标分组由规则决定,手动选择无效 + GUI.enabled = !_applyRulesOnRegister; + if (_applyRulesOnRegister) + { + EditorGUILayout.LabelField("(由 AddressableRules 自动决定)", + EditorStyles.miniLabel, GUILayout.Width(200)); + } + else if (_groupNames != null && _groupNames.Length > 0) { _targetGroupIndex = EditorGUILayout.Popup(_targetGroupIndex, _groupNames, GUILayout.Width(200)); } + GUI.enabled = true; EditorGUILayout.Space(8); - EditorGUILayout.LabelField("标签", GUILayout.Width(30)); + EditorGUILayout.LabelField("附加标签", GUILayout.Width(52)); _newLabel = EditorGUILayout.TextField(_newLabel, GUILayout.Width(120)); - GUILayout.Label("(留空则不添加标签)", EditorStyles.miniLabel); + GUILayout.Label("(可在规则标签基础上追加)", EditorStyles.miniLabel); GUILayout.FlexibleSpace(); } using (new EditorGUILayout.HorizontalScope()) { - _overwriteAddress = GUILayout.Toggle(_overwriteAddress, "已注册的资产也覆盖地址"); + _overwriteAddress = GUILayout.Toggle(_overwriteAddress, "已注册的资产也覆盖地址"); + GUILayout.Space(16); + _applyRulesOnRegister = GUILayout.Toggle(_applyRulesOnRegister, "自动应用分组/标签规则"); GUILayout.FlexibleSpace(); if (GUILayout.Button("新建分组…", GUILayout.Width(100))) ShowCreateGroupDialog(); @@ -469,7 +527,7 @@ namespace BaseGames.Editor _folderEntries = new List(); if (!AssetDatabase.IsValidFolder(_folderPath)) return; - var settings = AddressableAssetSettingsDefaultObject.Settings; + var settings = AddressableAssetSettingsDefaultObject.Settings; var registeredGuids = CollectRegisteredGuids(settings); var filters = BuildSearchFilter(); @@ -479,23 +537,45 @@ namespace BaseGames.Editor string absFolder = Path.GetFullPath(_folderPath); + // 收集所有文件 + var allFiles = new List(); foreach (string filter in filters) + allFiles.AddRange(Directory.GetFiles(absFolder, filter, option)); + + try { - foreach (string absPath in Directory.GetFiles(absFolder, filter, option)) + for (int i = 0; i < allFiles.Count; i++) { + string absPath = allFiles[i]; + + if (i % 20 == 0) + EditorUtility.DisplayProgressBar("扫描文件夹", + Path.GetFileName(absPath), + (float)i / allFiles.Count); + string relPath = "Assets" + absPath.Substring(Application.dataPath.Length).Replace('\\', '/'); - string guid = AssetDatabase.AssetPathToGUID(relPath); + if (!IsAddressableAssetPath(relPath)) continue; + if (ShouldExclude(relPath)) continue; + + string guid = AssetDatabase.AssetPathToGUID(relPath); if (string.IsNullOrEmpty(guid)) continue; + string addr = BuildAddress(relPath); _folderEntries.Add(new FolderEntry { AssetPath = relPath, Guid = guid, - Address = BuildAddress(relPath), + Address = addr, AlreadyRegistered = registeredGuids.Contains(guid), + PredictedGroup = AddressableRules.GetExpectedGroup(addr), + PredictedLabels = FormatLabels(AddressableRules.GetExpectedLabels(addr)), }); } } + finally + { + EditorUtility.ClearProgressBar(); + } // 去重(多个 filter 可能匹配同一文件) _folderEntries = _folderEntries @@ -504,6 +584,34 @@ namespace BaseGames.Editor .ToList(); } + /// + /// 返回 true 表示该文件应从 Addressable 注册中排除。 + /// 规范来源:AddressablesLabelSpec §5.2(禁止注册项)。 + /// + private static bool ShouldExclude(string relPath) + { + string lowerPath = relPath.Replace('\\', '/').ToLowerInvariant(); + string fileName = Path.GetFileNameWithoutExtension(relPath); + + // 测试场景(放在 Scenes/Testings/ 下) + if (lowerPath.Contains("/scenes/testings/")) return true; + + // 事件频道 ScriptableObject(EVT_ 前缀) + if (fileName.StartsWith("EVT_", StringComparison.Ordinal)) return true; + + // Sprite Atlas — 随依赖它的 Prefab 隐式打包,不单独注册 + if (Path.GetExtension(relPath) == ".spriteatlas") return true; + + // Material — 随 Prefab 依赖关系打包,不单独注册 + if (Path.GetExtension(relPath) == ".mat") return true; + + // HitBox / HurtBox 等碰撞盒子 Prefab(子 Prefab,不独立寻址) + if (fileName.StartsWith("HitBox_", StringComparison.OrdinalIgnoreCase)) return true; + if (fileName.StartsWith("HurtBox_", StringComparison.OrdinalIgnoreCase)) return true; + + return false; + } + private void RegisterAllFolderEntries() { int count = 0; @@ -554,12 +662,29 @@ namespace BaseGames.Editor private void AddSelectionEntry(string guid, string path, HashSet registeredGuids) { + if (!IsAddressableAssetPath(path)) return; + if (ShouldExclude(path)) return; + + // 资产类型筛选(与 Tab ③ 筛选 Toggle 联动) + string ext = Path.GetExtension(path).ToLowerInvariant(); + bool isMatch = (_selFilterPrefab && ext == ".prefab") + || (_selFilterScene && ext == ".unity") + || (_selFilterSO && ext == ".asset") + || (_selFilterTexture && (ext == ".png" || ext == ".jpg" || ext == ".tga")) + || (_selFilterAudio && (ext == ".mp3" || ext == ".wav" || ext == ".ogg")); + // 若没有任何类型 Toggle 被勾选,则接受所有类型(兜底行为,避免全部筛空) + bool anyToggled = _selFilterPrefab || _selFilterScene || _selFilterSO || _selFilterTexture || _selFilterAudio; + if (anyToggled && !isMatch) return; + + string addr = BuildAddress(path); _selectionEntries.Add(new SelectionEntry { AssetPath = path, Guid = guid, - Address = BuildAddress(path), + Address = addr, AlreadyRegistered = registeredGuids.Contains(guid), + PredictedGroup = AddressableRules.GetExpectedGroup(addr), + PredictedLabels = FormatLabels(AddressableRules.GetExpectedLabels(addr)), }); } @@ -584,8 +709,24 @@ namespace BaseGames.Editor if (string.IsNullOrEmpty(guid)) return; var settings = AddressableAssetSettingsDefaultObject.Settings; - var group = groupNameOverride != null - ? GetOrCreateGroup(settings, groupNameOverride) + + // 重复地址检查:同一 address 已注册到不同 GUID 时提示确认 + var existingByAddress = FindEntryByAddress(settings, address); + if (existingByAddress != null && existingByAddress.guid != guid) + { + bool proceed = EditorUtility.DisplayDialog( + "⚠ 地址已存在", + $"地址 \"{address}\" 已注册到:\n{existingByAddress.AssetPath}\n\n" + + $"继续会将该地址重新指向当前资产(GUID: {guid})。是否继续?", + "继续", "取消"); + if (!proceed) return; + } + + // Determine target group: explicit override → rules → manual selection + string effectiveGroup = groupNameOverride + ?? (_applyRulesOnRegister ? AddressableRules.GetExpectedGroup(address) : null); + var group = effectiveGroup != null + ? GetOrCreateGroup(settings, effectiveGroup) : GetTargetGroup(settings); if (group == null) return; @@ -601,6 +742,28 @@ namespace BaseGames.Editor if (!string.IsNullOrWhiteSpace(_newLabel)) entry.SetLabel(_newLabel.Trim(), true, true); + + // Apply rules-based labels + if (_applyRulesOnRegister) + { + foreach (var lbl in AddressableRules.GetExpectedLabels(address)) + { + EnsureLabelExists(settings, lbl); + entry.SetLabel(lbl, true, true); + } + } + } + + private static AddressableAssetEntry FindEntryByAddress(AddressableAssetSettings settings, string address) + { + if (settings == null) return null; + foreach (var group in settings.groups) + { + if (group == null) continue; + foreach (var e in group.entries) + if (e != null && e.address == address) return e; + } + return null; } private AddressableAssetGroup GetOrCreateGroup(AddressableAssetSettings settings, string groupName) @@ -636,8 +799,36 @@ namespace BaseGames.Editor AddressableAssetSettings.ModificationEvent.EntryModified, null, true); } + private static void EnsureLabelExists(AddressableAssetSettings settings, string label) + { + if (!settings.GetLabels().Contains(label)) + { + settings.AddLabel(label, true); + Debug.Log($"[AddressableBatch] 已创建标签:{label}"); + } + } + + private static string FormatLabels(string[] labels) + => labels.Length > 0 ? string.Join(", ", labels) : "—"; + // ══ 创建分组 ══════════════════════════════════════════════════════════ + private void QuickScanGameFolder() + { + _folderPath = "Assets/_Game"; + _folderAsset = AssetDatabase.LoadAssetAtPath(_folderPath); + _includeSubfolders = true; + _filterPrefab = true; + _filterScene = true; + _filterSO = true; + _filterAudio = true; + _filterTexture = false; + _addressFormat = AddressFormat.FileName; + _applyRulesOnRegister = true; + _tab = 1; + ScanFolder(); + } + private void ShowCreateGroupDialog() { _newGroupName = EditorInputDialog.Show("新建 Addressable 分组", "请输入分组名称:", _newGroupName); @@ -684,15 +875,29 @@ namespace BaseGames.Editor private string BuildAddress(string assetPath) { - string fileName = Path.GetFileNameWithoutExtension(assetPath); + string fileName = Path.GetFileNameWithoutExtension(assetPath); + string relativePath = MakeRelativePath(assetPath, _folderPath); + return _addressFormat switch { - AddressFormat.FileName => fileName, - AddressFormat.FullAssetPath => assetPath, - AddressFormat.RelativeToFolder => MakeRelativePath(assetPath, _folderPath), - AddressFormat.PrefixPlusFileName => _addressPrefix + fileName, - AddressFormat.PrefixPlusRelativePath=> _addressPrefix + MakeRelativePath(assetPath, _folderPath), - _ => fileName, + AddressFormat.FileName => fileName, + AddressFormat.FullAssetPath => assetPath, + AddressFormat.RelativeToFolder => relativePath, + // 前缀拼接:前缀以 '/' 结尾时直接连接(如 "Config/" + "FootstepCatalog") + // 前缀不以 '/' 结尾时用 '_' 连接(如 "Room_Forest" + "_01") + AddressFormat.PrefixPlusFileName => + string.IsNullOrEmpty(_addressPrefix) + ? fileName + : (_addressPrefix.EndsWith("/") + ? _addressPrefix + fileName + : _addressPrefix + "_" + fileName), + AddressFormat.PrefixPlusRelativePath => + string.IsNullOrEmpty(_addressPrefix) + ? relativePath + : (_addressPrefix.EndsWith("/") + ? _addressPrefix + relativePath + : _addressPrefix + "_" + relativePath), + _ => fileName, }; } @@ -716,6 +921,29 @@ namespace BaseGames.Editor return list; } + /// + /// 判断路径是否为可寻址资产(排除脚本、程序集定义、Shader、Sprite Atlas、Material 等文件)。 + /// + private static bool IsAddressableAssetPath(string path) + { + string ext = Path.GetExtension(path); + if (string.IsNullOrEmpty(ext)) return false; + // 排除代码 / 元数据类文件 + return ext != ".cs" + && ext != ".asmdef" + && ext != ".asmref" + && ext != ".shader" + && ext != ".hlsl" + && ext != ".cginc" + && ext != ".glsl" + && ext != ".json" + && ext != ".xml" + && ext != ".txt" + && ext != ".md" + && ext != ".spriteatlas" // 随依赖它的 Prefab 隐式打包 + && ext != ".mat"; // Material 随 Prefab 依赖打包 + } + /// 从 AddressKey(如 "ENM_GruntWarrior")派生搜索名("GruntWarrior")。 private static string DeriveName(string key) { @@ -740,27 +968,6 @@ namespace BaseGames.Editor return string.Equals(name, searchName, StringComparison.OrdinalIgnoreCase); } - /// - /// 判断路径是否为可寻址资产(排除脚本、程序集定义、Shader 等代码文件)。 - /// - private static bool IsAddressableAssetPath(string path) - { - string ext = Path.GetExtension(path); - if (string.IsNullOrEmpty(ext)) return false; - // 排除代码 / 元数据类文件 - return ext != ".cs" - && ext != ".asmdef" - && ext != ".asmref" - && ext != ".shader" - && ext != ".hlsl" - && ext != ".cginc" - && ext != ".glsl" - && ext != ".json" - && ext != ".xml" - && ext != ".txt" - && ext != ".md"; - } - private void InitStyles() { if (_stylesInitialized) return; @@ -790,6 +997,8 @@ namespace BaseGames.Editor public string Guid; public string Address; public bool AlreadyRegistered; + public string PredictedGroup; + public string PredictedLabels; } private class SelectionEntry @@ -798,6 +1007,8 @@ namespace BaseGames.Editor public string Guid; public string Address; public bool AlreadyRegistered; + public string PredictedGroup; + public string PredictedLabels; } private enum AddressFormat diff --git a/Assets/_Game/Scripts/Editor/Addressables/AddressableRuleSyncWindow.cs b/Assets/_Game/Scripts/Editor/Addressables/AddressableRuleSyncWindow.cs index 0439575..a30b5a7 100644 --- a/Assets/_Game/Scripts/Editor/Addressables/AddressableRuleSyncWindow.cs +++ b/Assets/_Game/Scripts/Editor/Addressables/AddressableRuleSyncWindow.cs @@ -36,11 +36,13 @@ namespace BaseGames.Editor public string ExpectedGroup; // null = 规则未覆盖,维持现状 public string[] CurrentLabels; public string[] ExpectedLabels; - public string[] MissingLabels; // 应有但没有 - public string[] ExtraLabels; // 有但不应有 - public bool GroupOk => ExpectedGroup == null || CurrentGroup == ExpectedGroup; - public bool LabelsOk => MissingLabels.Length == 0 && ExtraLabels.Length == 0; - public bool IsOk => GroupOk && LabelsOk; + public string[] MissingLabels; // 应有但没有(规则要求),红色错误 + public string[] ExtraLabels; // 规则不要求且在 KnownLabels 中(多余规则标签),红色错误 + public string[] UnknownLabels; // 规则不要求且不在 KnownLabels 中(自定义标签),黄色警告,不自动删除 + public bool GroupOk => ExpectedGroup == null || CurrentGroup == ExpectedGroup; + public bool LabelsOk => MissingLabels.Length == 0 && ExtraLabels.Length == 0; + public bool IsOk => GroupOk && LabelsOk; + public bool HasWarnings => UnknownLabels.Length > 0; } // ── 状态 ────────────────────────────────────────────────────────────── @@ -74,7 +76,7 @@ namespace BaseGames.Editor public static void OpenWindow() { var win = GetWindow("Addressable Rule Sync"); - win.minSize = new Vector2(900, 520); + win.minSize = new Vector2(1040, 540); win.Show(); } @@ -108,6 +110,9 @@ namespace BaseGames.Editor if (GUILayout.Button("扫描", EditorStyles.toolbarButton, GUILayout.Width(80))) Scan(); + if (GUILayout.Button("🔄 刷新", EditorStyles.toolbarButton, GUILayout.Width(60))) + Scan(); + GUILayout.Space(8); _showOk = GUILayout.Toggle(_showOk, "显示正常项", EditorStyles.toolbarButton, GUILayout.Width(80)); GUILayout.Space(8); @@ -134,12 +139,14 @@ namespace BaseGames.Editor { if (!_scanned) return; - int total = _reports.Count; - int ok = _reports.Count(r => r.IsOk); - int issues = total - ok; + int total = _reports.Count; + int ok = _reports.Count(r => r.IsOk); + int issues = _reports.Count(r => !r.IsOk); + int warnings = _reports.Count(r => r.IsOk && r.HasWarnings); int wrongGrp = _reports.Count(r => !r.GroupOk); int misLabel = _reports.Count(r => r.MissingLabels.Length > 0); int extLabel = _reports.Count(r => r.ExtraLabels.Length > 0); + int unkLabel = _reports.Count(r => r.UnknownLabels.Length > 0); EditorGUILayout.Space(2); using (new EditorGUILayout.HorizontalScope()) @@ -148,9 +155,11 @@ namespace BaseGames.Editor GUILayout.Space(12); DrawColoredLabel($"✅ 正常 {ok}", ColOk); GUILayout.Space(12); - DrawColoredLabel($"⚠ 问题 {issues}", issues > 0 ? ColWarn : ColOk); + DrawColoredLabel($"❌ 问题 {issues}", issues > 0 ? ColError : ColOk); + GUILayout.Space(8); + DrawColoredLabel($"⚠ 自定义标签 {unkLabel}", unkLabel > 0 ? ColWarn : ColOk); GUILayout.Space(20); - GUILayout.Label($"分组错误 {wrongGrp} | 标签缺失 {misLabel} | 标签多余 {extLabel}", + GUILayout.Label($"分组错误 {wrongGrp} | 标签缺失 {misLabel} | 多余规则标签 {extLabel}", EditorStyles.miniLabel); GUILayout.FlexibleSpace(); } @@ -164,12 +173,13 @@ namespace BaseGames.Editor // 表头 using (new EditorGUILayout.HorizontalScope(EditorStyles.toolbar)) { - GUILayout.Label("Address", _boldStyle, GUILayout.Width(200)); - GUILayout.Label("当前分组", _boldStyle, GUILayout.Width(130)); - GUILayout.Label("期望分组", _boldStyle, GUILayout.Width(130)); - GUILayout.Label("缺失标签", _boldStyle, GUILayout.Width(140)); - GUILayout.Label("多余标签", _boldStyle, GUILayout.Width(120)); - GUILayout.Label("状态", _boldStyle, GUILayout.Width(80)); + GUILayout.Label("Address", _boldStyle, GUILayout.Width(200)); + GUILayout.Label("当前分组", _boldStyle, GUILayout.Width(120)); + GUILayout.Label("期望分组", _boldStyle, GUILayout.Width(120)); + GUILayout.Label("缺失标签", _boldStyle, GUILayout.Width(130)); + GUILayout.Label("多余规则标签", _boldStyle, GUILayout.Width(110)); + GUILayout.Label("自定义标签", _boldStyle, GUILayout.Width(110)); + GUILayout.Label("状态", _boldStyle, GUILayout.Width(80)); } _scrollPos = EditorGUILayout.BeginScrollView(_scrollPos, GUILayout.ExpandHeight(true)); @@ -211,29 +221,35 @@ namespace BaseGames.Editor // 当前分组 var grpColor = r.GroupOk ? ColOk : ColError; - DrawColoredLabel(r.CurrentGroup ?? "—", grpColor, GUILayout.Width(130)); + DrawColoredLabel(r.CurrentGroup ?? "—", grpColor, GUILayout.Width(120)); // 期望分组 - var expGrpText = r.ExpectedGroup ?? "(规则未覆盖)"; + var expGrpText = r.ExpectedGroup ?? "(规则未覆盖)"; var expGrpColor = r.GroupOk ? ColOk : ColWarn; - DrawColoredLabel(expGrpText, expGrpColor, GUILayout.Width(130)); + DrawColoredLabel(expGrpText, expGrpColor, GUILayout.Width(120)); - // 缺失标签 + // 缺失标签(红色,须补齐) var missingText = r.MissingLabels.Length > 0 ? string.Join(", ", r.MissingLabels) : "—"; - DrawColoredLabel(missingText, r.MissingLabels.Length > 0 ? ColError : ColOk, GUILayout.Width(140)); + DrawColoredLabel(missingText, r.MissingLabels.Length > 0 ? ColError : ColOk, GUILayout.Width(130)); - // 多余标签 + // 多余规则标签(红色,将被 FixEntry 移除) var extraText = r.ExtraLabels.Length > 0 ? string.Join(", ", r.ExtraLabels) : "—"; - DrawColoredLabel(extraText, r.ExtraLabels.Length > 0 ? ColWarn : ColOk, GUILayout.Width(120)); + DrawColoredLabel(extraText, r.ExtraLabels.Length > 0 ? ColError : ColOk, GUILayout.Width(110)); + + // 自定义标签(黄色警告,不会被自动删除,建议写入规范) + var unknownText = r.UnknownLabels.Length > 0 ? string.Join(", ", r.UnknownLabels) : "—"; + DrawColoredLabel(unknownText, r.UnknownLabels.Length > 0 ? ColWarn : ColOk, GUILayout.Width(110)); // 状态 + 单条修复按钮 if (r.IsOk) { - DrawColoredLabel("✅ 正常", ColOk, GUILayout.Width(80)); + var statusColor = r.HasWarnings ? ColWarn : ColOk; + var statusText = r.HasWarnings ? "⚠ 自定义标签" : "✅ 正常"; + DrawColoredLabel(statusText, statusColor, GUILayout.Width(80)); } else { - DrawColoredLabel("⚠ 需修复", ColWarn, GUILayout.Width(60)); + DrawColoredLabel("❌ 需修复", ColError, GUILayout.Width(60)); if (GUILayout.Button("修复", EditorStyles.miniButton, GUILayout.Width(40))) FixEntry(r); } @@ -247,7 +263,8 @@ namespace BaseGames.Editor EditorGUILayout.Space(4); EditorGUILayout.HelpBox( "规则来源:Docs/Standards/AddressablesLabelSpec.md §3 分组规则:AssetFolderSpec.md §8.1\n" + - "「修复所有问题」仅修改已注册资产的分组/标签,不注册新资产(请用 Addressable Batch Tool)。", + "「修复所有问题」仅修改已注册资产的分组/标签,不注册新资产,不删除自定义标签(黄色警告项)。\n" + + "新增资产工作流:① Addressable Batch Tool → ⚡ 全量扫描 _Game/ → 注册所有 ② 返回此窗口 → 扫描 → 修复所有问题", MessageType.None); } @@ -259,9 +276,6 @@ namespace BaseGames.Editor var settings = AddressableAssetSettingsDefaultObject.Settings; if (settings == null) return; - // 收集所有全局标签供"多余标签"判断 - var allKnownLabels = new HashSet(settings.GetLabels(), StringComparer.Ordinal); - foreach (var group in settings.groups) { if (group == null) continue; @@ -275,32 +289,42 @@ namespace BaseGames.Editor var currentLbls = entry.labels.ToArray(); var missing = expectedLbls.Except(currentLbls, StringComparer.Ordinal).ToArray(); - var extra = currentLbls.Except(expectedLbls, StringComparer.Ordinal).ToArray(); + + // 区分两类"多余标签": + // extra = 规则已知标签(KnownLabels)中规则不要求的 → 红色,FixEntry 会移除 + // unknown = 不在 KnownLabels 中的自定义标签 → 黄色警告,FixEntry 保留,建议写入规范 + var notExpected = currentLbls.Except(expectedLbls, StringComparer.Ordinal); + var extra = notExpected.Where(l => AddressableRules.KnownLabels.Contains(l)).ToArray(); + var unknown = notExpected.Where(l => !AddressableRules.KnownLabels.Contains(l)).ToArray(); _reports.Add(new EntryReport { - Address = address, - AssetPath = entry.AssetPath, - CurrentGroup = group.name, - ExpectedGroup = expectedGroup, - CurrentLabels = currentLbls, + Address = address, + AssetPath = entry.AssetPath, + CurrentGroup = group.name, + ExpectedGroup = expectedGroup, + CurrentLabels = currentLbls, ExpectedLabels = expectedLbls, - MissingLabels = missing, - ExtraLabels = extra, + MissingLabels = missing, + ExtraLabels = extra, + UnknownLabels = unknown, }); } } - // 问题项排前面,正常项排后面;同类按 Address 字母序 + // 问题项排前面,仅有警告的次之,正常项排最后;同类按 Address 字母序 _reports = _reports - .OrderBy(r => r.IsOk) + .OrderBy(r => r.IsOk ? (r.HasWarnings ? 1 : 2) : 0) .ThenBy(r => r.Address, StringComparer.Ordinal) .ToList(); _scanned = true; Repaint(); + + int issues = _reports.Count(r => !r.IsOk); + int warnings = _reports.Count(r => r.IsOk && r.HasWarnings); Debug.Log($"[AddressableRuleSync] 扫描完成:{_reports.Count} 个条目," + - $"{_reports.Count(r => !r.IsOk)} 个需要修复。"); + $"{issues} 个需要修复,{warnings} 个含自定义标签警告。"); } // ── 修复逻辑 ────────────────────────────────────────────────────────── @@ -310,6 +334,22 @@ namespace BaseGames.Editor var issues = _reports.Where(r => !r.IsOk).ToList(); if (issues.Count == 0) return; + int moveCount = issues.Count(r => !r.GroupOk); + int addCount = issues.Sum(r => r.MissingLabels.Length); + int removeCount = issues.Sum(r => r.ExtraLabels.Length); + + // 干跑预览对话框 + bool confirmed = EditorUtility.DisplayDialog( + "确认修复所有问题", + $"将对 {issues.Count} 个条目执行以下操作:\n\n" + + $" • 移动分组:{moveCount} 个\n" + + $" • 添加标签:{addCount} 个\n" + + $" • 移除多余规则标签:{removeCount} 个\n\n" + + "⚠ 自定义标签(黄色警告项)不会被删除。\n" + + "此操作不可撤销,请确认后继续。", + "确认修复", "取消"); + if (!confirmed) return; + int fixedCount = 0; foreach (var r in issues) { @@ -355,7 +395,8 @@ namespace BaseGames.Editor changed = true; } - // 移除多余标签 + // 移除多余规则标签(ExtraLabels 只包含 KnownLabels 中规则不要求的标签; + // UnknownLabels 是用户自定义标签,刻意保留,不做删除) foreach (var lbl in r.ExtraLabels) { entry.SetLabel(lbl, false, true); diff --git a/Assets/_Game/Scripts/Editor/Addressables/AddressableRules.cs b/Assets/_Game/Scripts/Editor/Addressables/AddressableRules.cs index 98badff..d4255f9 100644 --- a/Assets/_Game/Scripts/Editor/Addressables/AddressableRules.cs +++ b/Assets/_Game/Scripts/Editor/Addressables/AddressableRules.cs @@ -14,23 +14,57 @@ namespace BaseGames.Editor /// public static class AddressableRules { + // ── 已知标签白名单 ──────────────────────────────────────────────────────── + // AddressableRuleSyncWindow 用此白名单区分: + // • 规则要求但缺失 → 红色,必须补齐 + // • 规则不要求但存在且不在白名单 → 黄色警告(自定义标签,建议写进规范) + // • 规则不要求但存在且在白名单 → 白色,合法的人工附加标签 + // 每次向 AddressablesLabelSpec 新增 Label 时,须同步在此处添加。 + public static readonly HashSet KnownLabels = new(StringComparer.Ordinal) + { + AddressKeys.Labels.Preload, + AddressKeys.Labels.Poolable, + AddressKeys.Labels.Enemy, + AddressKeys.Labels.BGM, + AddressKeys.Labels.SFX, + AddressKeys.Labels.Charms, + AddressKeys.Labels.Config, + AddressKeys.Labels.Weapon, + }; + // ── 前缀 → 分组名 ────────────────────────────────────────────────────── // 规则:按 AssetFolderSpec §8.1 Group 划分策略。 // 顺序:更长/更具体的前缀必须排在更短/更泛化的前缀之前,否则短前缀会先匹配。 // 特殊:Room_/Boss_ 地址的分组名在运行时动态计算,见 GetExpectedGroup()。 public static readonly (string Prefix, string Group)[] PrefixGroupMap = { + // ── 场景 ───────────────────────────────────────────────────────── ("Scene_", "Scenes"), + // ── 玩家 & 武器 ────────────────────────────────────────────────── ("PLY_", "Player"), ("WPN_", "Player"), // 武器与玩家 Prefab 同组(AssetFolderSpec §8.1) + // ── 敌人 & 投射物 ──────────────────────────────────────────────── ("ENM_", "Enemies"), ("PROJ_", "Projectiles"), - ("VFX_", "VFX_Common"), // 通用特效组(AssetFolderSpec §8.1) + // ── 特效 & UI ──────────────────────────────────────────────────── + ("VFX_", "VFX_Common"), // 通用特效(AssetFolderSpec §8.1) ("UI_", "UI"), + // ── 收集物 ────────────────────────────────────────────────────── ("COL_", "Collectibles"), - ("CHM_", "Config"), // 护身符 SO 归入 Config 组(AddressablesLabelSpec §3.9) - ("Config/", "Config"), - ("AUD_", "Audio_Music"), + // ── 配置数据(更具体的前缀排在泛化前缀之前)────────────────────── + ("CHM_", "Config"), // 护身符 SO(AddressablesLabelSpec §3.9) + ("SKL_", "Config"), // 技能配置 SO(AssetFolderSpec §4) + ("SPL_", "Config"), // 法术配置 SO + ("ABL_", "Config"), // 能力配置 SO + ("MAP_", "Config"), // 地图数据 SO(AssetFolderSpec §4) + ("Config/", "Config"), // 路径前缀配置(AssetFolderSpec §8.2) + // ── 音频(AUD_BGM_ / AUD_SFX_ 必须在通配 AUD_ 之前)───────────── + ("AUD_BGM_", "Audio_Music"), // BGM 流式音频 + ("AUD_SFX_", "Audio_SFX"), // SFX 音效(独立分组便于包体按需加载) + ("AUD_", "Audio_Music"), // 未细分音频归 BGM 组 + // ── 世界 & 持久化 ──────────────────────────────────────────────── + ("WLD_", "World"), // 可交互世界物件 Prefab + ("SYS_", "Persistent"), // Persistent 场景管理器 Prefab }; // ── 精确地址 → 标签(优先级高于前缀规则)──────────────────────────────── @@ -51,19 +85,33 @@ namespace BaseGames.Editor // 顺序:更具体的前缀(AUD_BGM_)在更泛化的前缀(AUD_)之前。 private static readonly (string Prefix, string[] Labels)[] PrefixLabelMap = { + // ── 音频(更具体的 AUD_BGM_ / AUD_SFX_ 必须排在 AUD_ 之前)────── ("AUD_BGM_", new[] { AddressKeys.Labels.BGM }), ("AUD_SFX_", new[] { AddressKeys.Labels.SFX }), ("AUD_", new[] { AddressKeys.Labels.BGM }), // 未细分音频默认归 BGM - ("Scene_", Array.Empty()), // 除 MainMenu 外场景无 label + // ── 场景(除 MainMenu 外无 label,由 ExactLabelMap 特殊处理)────── + ("Scene_", Array.Empty()), + // ── 玩家 & 武器 ────────────────────────────────────────────────── ("PLY_", new[] { AddressKeys.Labels.Preload }), ("WPN_", new[] { AddressKeys.Labels.Weapon, AddressKeys.Labels.Preload }), + // ── 敌人 & 投射物 ──────────────────────────────────────────────── ("ENM_", new[] { AddressKeys.Labels.Enemy }), ("PROJ_", new[] { AddressKeys.Labels.Poolable, AddressKeys.Labels.Preload }), + // ── 特效 & UI ──────────────────────────────────────────────────── ("VFX_", new[] { AddressKeys.Labels.Poolable, AddressKeys.Labels.Preload }), ("UI_", Array.Empty()), // 除 FloatingDamageText 外 UI 无默认 label + // ── 收集物 ────────────────────────────────────────────────────── ("COL_", new[] { AddressKeys.Labels.Poolable, AddressKeys.Labels.Preload }), + // ── 配置数据 ───────────────────────────────────────────────────── ("CHM_", new[] { AddressKeys.Labels.Charms }), + ("MAP_", new[] { AddressKeys.Labels.Config }), // 地图数据 SO 为动态加载配置 ("Config/", new[] { AddressKeys.Labels.Config }), + // ── 技能 / 法术 / 能力 / 世界物件 / 持久化:无批量加载需求,不加 Label ── + ("SKL_", Array.Empty()), + ("SPL_", Array.Empty()), + ("ABL_", Array.Empty()), + ("WLD_", Array.Empty()), + ("SYS_", Array.Empty()), }; // ── 公开 API ───────────────────────────────────────────────────────────