Files
zeling_v2/Assets/_Game/Scripts/Editor/Addressables/AddressableBatchTool.cs
Joywayer f1c0b65737 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.
2026-05-22 13:34:47 +08:00

1081 lines
50 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 UnityEngine;
using BaseGames.Core.Assets;
namespace BaseGames.Editor
{
/// <summary>
/// Addressable 批量注册工具
/// 菜单BaseGames → Tools → Addressable Batch Tool (Alt+Shift+A)
///
/// 三种工作流:
/// ① 同步 AddressKeys — 读取 AddressKeys.cs 中的常量,按名称在 Project 中搜索对应资产并自动注册
/// ② 文件夹批量注册 — 拖入文件夹,将其下所有指定类型的资产注册到选定分组,地址格式可配置
/// ③ 选中资产注册 — 在 Project 窗口选中资产后,一键批量注册到选定分组
/// </summary>
public class AddressableBatchTool : EditorWindow
{
// ── 常量 ─────────────────────────────────────────────────────────────
private const string Title = "Addressable 批量工具";
private const string MenuPath = "BaseGames/Addressables/Addressable Batch Tool";
private const string PrefsKey = "AddressableBatch.";
// ── 状态 ─────────────────────────────────────────────────────────────
private int _tab;
private string[] _tabNames = { "① 同步 AddressKeys", "② 文件夹批量注册", "③ 选中资产注册" };
// Tab ①
private List<KeySyncEntry> _keyEntries;
private Vector2 _keyScrollPos;
private bool _onlyShowMissing = true;
private bool _autoSearch = true; // 自动按名称搜索资产
private bool _autoGroupByPrefix = true; // 按 Key 前缀自动选/建分组
// Key 前缀 → 分组名称映射Tab ① 自动分组用)
// 规范数据统一来自 AddressableRules此处不再声明本地副本。
// Tab ②
private DefaultAsset _folderAsset;
private string _folderPath;
private bool _includeSubfolders = true;
private AddressFormat _addressFormat = AddressFormat.FileName;
private string _addressPrefix = "";
private string[] _assetTypeFilters = { "*.prefab", "*.unity", "*.asset" };
private bool _filterPrefab = true;
private bool _filterScene = true;
private bool _filterSO = true;
private bool _filterTexture;
private bool _filterAudio;
private List<FolderEntry> _folderEntries;
private Vector2 _folderScrollPos;
// Tab ③
private List<SelectionEntry> _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;
private string[] _groupNames;
private string _newGroupName = "New Group";
private string _newLabel = "";
private bool _overwriteAddress;
private bool _applyRulesOnRegister = true;
// ── 样式(惰性初始化)────────────────────────────────────────────────
private GUIStyle _headerStyle;
private GUIStyle _okStyle;
private GUIStyle _warnStyle;
private GUIStyle _boldStyle;
private bool _stylesInitialized;
// ─────────────────────────────────────────────────────────────────────
[MenuItem(MenuPath, priority = 200)]
[MenuItem("BaseGames/Addressables/Open Addressable Batch Tool", priority = 250)]
public static void OpenWindow()
{
var win = GetWindow<AddressableBatchTool>(Title);
win.minSize = new Vector2(920, 520);
win.Show();
}
// ══ GUI ══════════════════════════════════════════════════════════════
private void OnGUI()
{
InitStyles();
if (AddressableAssetSettingsDefaultObject.Settings == null)
{
EditorGUILayout.HelpBox(
"Addressable Settings 未初始化。\n" +
"请先执行 Window → Asset Management → Addressables → Groups → Create Addressables Settings。",
MessageType.Error);
return;
}
RefreshGroupNames();
EditorGUILayout.Space(4);
_tab = GUILayout.Toolbar(_tab, _tabNames, GUILayout.Height(28));
EditorGUILayout.Space(4);
switch (_tab)
{
case 0: DrawSyncTab(); break;
case 1: DrawFolderTab(); break;
case 2: DrawSelectionTab(); break;
}
EditorGUILayout.Space(4);
DrawSharedOptions();
}
// ══ Tab ① 同步 AddressKeys ═══════════════════════════════════════════
private void DrawSyncTab()
{
EditorGUILayout.LabelField("根据 AddressKeys.cs 中的常量,自动搜索匹配资产并注册到 Addressables。", EditorStyles.wordWrappedMiniLabel);
EditorGUILayout.Space(4);
using (new EditorGUILayout.HorizontalScope())
{
_onlyShowMissing = GUILayout.Toggle(_onlyShowMissing, "仅显示未注册项", GUILayout.Width(140));
_autoSearch = GUILayout.Toggle(_autoSearch, "自动按文件名搜索", GUILayout.Width(140));
_autoGroupByPrefix = GUILayout.Toggle(_autoGroupByPrefix, "按前缀自动分组", GUILayout.Width(120));
GUILayout.FlexibleSpace();
if (GUILayout.Button("刷新列表", GUILayout.Width(80)))
RefreshKeyEntries();
if (GUILayout.Button("注册所有已匹配项", GUILayout.Width(120)))
RegisterAllMatchedKeys();
}
if (_keyEntries == null)
RefreshKeyEntries();
EditorGUILayout.Space(4);
// 列表表头
using (new EditorGUILayout.HorizontalScope(EditorStyles.toolbar))
{
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);
}
var displayList = _onlyShowMissing
? _keyEntries.Where(e => !e.IsRegistered).ToList()
: _keyEntries;
_keyScrollPos = EditorGUILayout.BeginScrollView(_keyScrollPos, GUILayout.ExpandHeight(true));
foreach (var entry in displayList)
{
using (new EditorGUILayout.HorizontalScope(GUILayout.Height(20)))
{
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(80));
EditorGUILayout.LabelField(entry.ExistingAssetPath ?? "—");
}
else if (entry.FoundAssetPath != null)
{
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(80));
entry.ManualAsset = (UnityEngine.Object)EditorGUILayout.ObjectField(
entry.ManualAsset, typeof(UnityEngine.Object), false);
if (entry.ManualAsset != null)
{
if (GUILayout.Button("注册", GUILayout.Width(50)))
RegisterKeyEntryManual(entry);
}
}
}
}
EditorGUILayout.EndScrollView();
EditorGUILayout.Space(4);
var total = _keyEntries.Count;
var registered = _keyEntries.Count(e => e.IsRegistered);
var matched = _keyEntries.Count(e => !e.IsRegistered && e.FoundAssetPath != null);
EditorGUILayout.LabelField(
$"总计 {total} 个 Key | 已注册 {registered} | 已搜索到但未注册 {matched} | 未找到 {total - registered - matched}",
EditorStyles.miniLabel);
}
// ══ Tab ② 文件夹批量注册 ═════════════════════════════════════════════
private void DrawFolderTab()
{
EditorGUILayout.LabelField("将指定文件夹中所有符合条件的资产批量注册到 Addressables。", EditorStyles.wordWrappedMiniLabel);
EditorGUILayout.Space(4);
// 文件夹选择
using (new EditorGUILayout.HorizontalScope())
{
_folderAsset = (DefaultAsset)EditorGUILayout.ObjectField(
"目标文件夹", _folderAsset, typeof(DefaultAsset), false);
if (_folderAsset != null)
_folderPath = AssetDatabase.GetAssetPath(_folderAsset);
}
if (!string.IsNullOrEmpty(_folderPath) && !AssetDatabase.IsValidFolder(_folderPath))
{
EditorGUILayout.HelpBox("请拖入一个文件夹(蓝色图标),不是文件。", MessageType.Warning);
_folderPath = null;
}
_includeSubfolders = EditorGUILayout.Toggle("包含子文件夹", _includeSubfolders);
// 资产类型筛选
EditorGUILayout.LabelField("资产类型筛选", EditorStyles.boldLabel);
using (new EditorGUILayout.HorizontalScope())
{
_filterPrefab = GUILayout.Toggle(_filterPrefab, "Prefab", GUILayout.Width(70));
_filterScene = GUILayout.Toggle(_filterScene, "Scene", GUILayout.Width(70));
_filterSO = GUILayout.Toggle(_filterSO, "SO/Asset", GUILayout.Width(80));
_filterTexture = GUILayout.Toggle(_filterTexture, "Texture", GUILayout.Width(70));
_filterAudio = GUILayout.Toggle(_filterAudio, "Audio", GUILayout.Width(70));
}
// 地址格式
_addressFormat = (AddressFormat)EditorGUILayout.EnumPopup("地址格式", _addressFormat);
if (_addressFormat == AddressFormat.PrefixPlusFileName ||
_addressFormat == AddressFormat.PrefixPlusRelativePath)
{
_addressPrefix = EditorGUILayout.TextField("地址前缀", _addressPrefix);
}
EditorGUILayout.Space(4);
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)))
ScanFolder();
GUI.enabled = _folderEntries != null && _folderEntries.Count > 0;
if (GUILayout.Button("注册所有", GUILayout.Width(100)))
RegisterAllFolderEntries();
GUI.enabled = true;
}
if (_folderEntries == null || _folderEntries.Count == 0)
{
EditorGUILayout.HelpBox("拖入文件夹后点击「扫描文件夹」。", MessageType.Info);
return;
}
EditorGUILayout.Space(4);
using (new EditorGUILayout.HorizontalScope(EditorStyles.toolbar))
{
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));
foreach (var entry in _folderEntries)
{
using (new EditorGUILayout.HorizontalScope(GUILayout.Height(18)))
{
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(70));
}
}
EditorGUILayout.EndScrollView();
int newCount = _folderEntries.Count(e => !e.AlreadyRegistered);
EditorGUILayout.LabelField($"共 {_folderEntries.Count} 个资产,{newCount} 个待注册", EditorStyles.miniLabel);
}
// ══ Tab ③ 选中资产注册 ════════════════════════════════════════════════
private void DrawSelectionTab()
{
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();
if (GUILayout.Button("读取选中项", GUILayout.Width(110)))
LoadSelection();
GUI.enabled = _selectionEntries != null && _selectionEntries.Count > 0;
if (GUILayout.Button("注册所有", GUILayout.Width(100)))
RegisterAllSelectionEntries();
GUI.enabled = true;
}
if (_selectionEntries == null || _selectionEntries.Count == 0)
{
EditorGUILayout.HelpBox("在 Project 窗口选中资产后点击「读取选中项」。", MessageType.Info);
return;
}
EditorGUILayout.Space(4);
using (new EditorGUILayout.HorizontalScope(EditorStyles.toolbar))
{
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));
foreach (var entry in _selectionEntries)
{
using (new EditorGUILayout.HorizontalScope(GUILayout.Height(18)))
{
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(70));
}
}
EditorGUILayout.EndScrollView();
int newCount = _selectionEntries.Count(e => !e.AlreadyRegistered);
EditorGUILayout.LabelField($"共 {_selectionEntries.Count} 项,{newCount} 待注册", EditorStyles.miniLabel);
}
// ══ 共用选项区 ════════════════════════════════════════════════════════
private void DrawSharedOptions()
{
EditorGUILayout.LabelField("── 注册选项 ──", EditorStyles.boldLabel);
using (new EditorGUILayout.HorizontalScope())
{
EditorGUILayout.LabelField("目标分组", GUILayout.Width(70));
// 自动规则模式下,目标分组由规则决定,手动选择无效
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(52));
_newLabel = EditorGUILayout.TextField(_newLabel, GUILayout.Width(120));
GUILayout.Label("(可在规则标签基础上追加)", EditorStyles.miniLabel);
GUILayout.FlexibleSpace();
}
using (new EditorGUILayout.HorizontalScope())
{
_overwriteAddress = GUILayout.Toggle(_overwriteAddress, "已注册的资产也覆盖地址");
GUILayout.Space(16);
_applyRulesOnRegister = GUILayout.Toggle(_applyRulesOnRegister, "自动应用分组/标签规则");
GUILayout.FlexibleSpace();
if (GUILayout.Button("新建分组…", GUILayout.Width(100)))
ShowCreateGroupDialog();
}
}
// ══ 逻辑Tab ① ══════════════════════════════════════════════════════
private void RefreshKeyEntries()
{
_keyEntries = new List<KeySyncEntry>();
var settings = AddressableAssetSettingsDefaultObject.Settings;
if (settings == null) return;
// 收集所有已注册地址 → 地址字符串 → 资产路径
var registeredMap = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var group in settings.groups)
{
if (group == null) continue;
foreach (var e in group.entries)
if (e != null) registeredMap[e.address] = e.AssetPath;
}
// 遍历 AddressKeys 常量
var fields = typeof(AddressKeys)
.GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy)
.Where(f => f.IsLiteral && !f.IsInitOnly && f.FieldType == typeof(string));
foreach (var field in fields)
{
var key = (string)field.GetRawConstantValue();
var entry = new KeySyncEntry { FieldName = field.Name, AddressKey = key };
if (registeredMap.TryGetValue(key, out string existingPath))
{
entry.IsRegistered = true;
entry.ExistingAssetPath = existingPath;
}
else if (_autoSearch)
{
// 从地址 key 派生搜索名取最后一段去掉前缀ENM_、VFX_ 等)
string searchName = DeriveName(key);
string[] guids = AssetDatabase.FindAssets(searchName);
if (guids.Length > 0)
{
// 优先取名称完全匹配的;排除文件夹、脚本及程序集定义文件
string best = guids
.Select(AssetDatabase.GUIDToAssetPath)
.Where(p => !AssetDatabase.IsValidFolder(p) && IsAddressableAssetPath(p))
.OrderBy(p => ExactNameMatch(p, searchName) ? 0 : 1)
.FirstOrDefault();
entry.FoundAssetPath = best;
entry.FoundGuid = best != null ? AssetDatabase.AssetPathToGUID(best) : null;
}
}
_keyEntries.Add(entry);
}
}
private void RegisterKeyEntry(KeySyncEntry entry)
{
if (entry.FoundAssetPath == null) return;
string groupOverride = _autoGroupByPrefix ? DeriveGroupName(entry.AddressKey) : null;
Register(entry.FoundGuid, entry.AddressKey, groupOverride);
entry.IsRegistered = true;
entry.ExistingAssetPath = entry.FoundAssetPath;
entry.FoundAssetPath = null;
SaveSettings();
}
private void RegisterKeyEntryManual(KeySyncEntry entry)
{
string path = AssetDatabase.GetAssetPath(entry.ManualAsset);
string guid = AssetDatabase.AssetPathToGUID(path);
string groupOverride = _autoGroupByPrefix ? DeriveGroupName(entry.AddressKey) : null;
Register(guid, entry.AddressKey, groupOverride);
entry.IsRegistered = true;
entry.ExistingAssetPath = path;
entry.ManualAsset = null;
SaveSettings();
}
private void RegisterAllMatchedKeys()
{
int count = 0;
foreach (var entry in _keyEntries.Where(e => !e.IsRegistered && e.FoundAssetPath != null))
{
string groupOverride = _autoGroupByPrefix ? DeriveGroupName(entry.AddressKey) : null;
Register(entry.FoundGuid, entry.AddressKey, groupOverride);
entry.IsRegistered = true;
entry.ExistingAssetPath = entry.FoundAssetPath;
entry.FoundAssetPath = null;
count++;
}
Debug.Log($"[AddressableBatch] 已注册 {count} 个 AddressKeys 条目。");
SaveSettings();
}
// ══ 逻辑Tab ② ══════════════════════════════════════════════════════
private void ScanFolder()
{
_folderEntries = new List<FolderEntry>();
if (!AssetDatabase.IsValidFolder(_folderPath)) return;
var settings = AddressableAssetSettingsDefaultObject.Settings;
var registeredGuids = CollectRegisteredGuids(settings);
var filters = BuildSearchFilter();
var option = _includeSubfolders
? SearchOption.AllDirectories
: SearchOption.TopDirectoryOnly;
string absFolder = Path.GetFullPath(_folderPath);
// 收集所有文件
var allFiles = new List<string>();
foreach (string filter in filters)
allFiles.AddRange(Directory.GetFiles(absFolder, filter, option));
try
{
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('\\', '/');
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 = addr,
AlreadyRegistered = registeredGuids.Contains(guid),
PredictedGroup = AddressableRules.GetExpectedGroup(addr),
PredictedLabels = FormatLabels(AddressableRules.GetExpectedLabels(addr)),
});
}
}
finally
{
EditorUtility.ClearProgressBar();
}
// 去重(多个 filter 可能匹配同一文件)
_folderEntries = _folderEntries
.GroupBy(e => e.Guid)
.Select(g => g.First())
.ToList();
}
/// <summary>
/// 返回 true 表示该文件应从 Addressable 注册中排除。
/// 规范来源AddressablesLabelSpec §5.2(禁止注册项)。
/// </summary>
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;
// 事件频道 ScriptableObjectEVT_ 前缀)
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;
foreach (var entry in _folderEntries)
{
if (entry.AlreadyRegistered && !_overwriteAddress) continue;
Register(entry.Guid, entry.Address);
entry.AlreadyRegistered = true;
count++;
}
Debug.Log($"[AddressableBatch] 文件夹批量注册完成,共注册 {count} 个资产。");
SaveSettings();
}
// ══ 逻辑Tab ③ ══════════════════════════════════════════════════════
private void LoadSelection()
{
_selectionEntries = new List<SelectionEntry>();
var settings = AddressableAssetSettingsDefaultObject.Settings;
var registeredGuids = CollectRegisteredGuids(settings);
foreach (string guid in Selection.assetGUIDs)
{
string path = AssetDatabase.GUIDToAssetPath(guid);
if (AssetDatabase.IsValidFolder(path))
{
// 展开文件夹
foreach (string sub in AssetDatabase.FindAssets("", new[] { path }))
{
string subPath = AssetDatabase.GUIDToAssetPath(sub);
if (!AssetDatabase.IsValidFolder(subPath))
AddSelectionEntry(sub, subPath, registeredGuids);
}
}
else
{
AddSelectionEntry(guid, path, registeredGuids);
}
}
_selectionEntries = _selectionEntries
.GroupBy(e => e.Guid)
.Select(g => g.First())
.ToList();
}
private void AddSelectionEntry(string guid, string path, HashSet<string> 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 = addr,
AlreadyRegistered = registeredGuids.Contains(guid),
PredictedGroup = AddressableRules.GetExpectedGroup(addr),
PredictedLabels = FormatLabels(AddressableRules.GetExpectedLabels(addr)),
});
}
private void RegisterAllSelectionEntries()
{
int count = 0;
foreach (var entry in _selectionEntries)
{
if (entry.AlreadyRegistered && !_overwriteAddress) continue;
Register(entry.Guid, entry.Address);
entry.AlreadyRegistered = true;
count++;
}
Debug.Log($"[AddressableBatch] 选中资产注册完成,共注册 {count} 个资产。");
SaveSettings();
}
// ══ 核心注册 API ═════════════════════════════════════════════════════
private void Register(string guid, string address, string groupNameOverride = null)
{
if (string.IsNullOrEmpty(guid)) return;
var settings = AddressableAssetSettingsDefaultObject.Settings;
// 重复地址检查:同一 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;
AddressableAssetEntry entry = settings.FindAssetEntry(guid) ??
settings.CreateOrMoveEntry(guid, group, false, false);
if (entry == null) return;
if (_overwriteAddress || entry.address != address)
entry.address = address;
settings.MoveEntry(entry, group, false, false);
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)
{
var existing = settings.groups.FirstOrDefault(g => g != null && g.name == groupName);
if (existing != null) return existing;
var template = settings.GroupTemplateObjects.FirstOrDefault() as AddressableAssetGroupTemplate;
var newGroup = settings.CreateGroup(groupName, false, false, true,
template != null ? new List<AddressableAssetGroupSchema>(template.SchemaObjects) : null);
if (newGroup != null)
{
RefreshGroupNames();
Debug.Log($"[AddressableBatch] 已自动创建分组:{groupName}");
}
return newGroup ?? settings.DefaultGroup;
}
private AddressableAssetGroup GetTargetGroup(AddressableAssetSettings settings)
{
RefreshGroupNames();
if (_groupNames == null || _groupNames.Length == 0) return settings.DefaultGroup;
string name = _groupNames[Mathf.Clamp(_targetGroupIndex, 0, _groupNames.Length - 1)];
return settings.groups.FirstOrDefault(g => g != null && g.name == name)
?? settings.DefaultGroup;
}
private static void SaveSettings()
{
AssetDatabase.SaveAssets();
AddressableAssetSettingsDefaultObject.Settings?.SetDirty(
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<DefaultAsset>(_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);
if (string.IsNullOrWhiteSpace(_newGroupName)) return;
var settings = AddressableAssetSettingsDefaultObject.Settings;
var template = settings.GroupTemplateObjects.FirstOrDefault() as AddressableAssetGroupTemplate;
var newGroup = settings.CreateGroup(_newGroupName.Trim(), false, false, true,
template != null ? new List<AddressableAssetGroupSchema>(template.SchemaObjects) : null);
if (newGroup != null)
{
Debug.Log($"[AddressableBatch] 已创建分组:{newGroup.name}");
RefreshGroupNames();
_targetGroupIndex = Array.IndexOf(_groupNames, newGroup.name);
}
}
// ══ 辅助 ══════════════════════════════════════════════════════════════
private void RefreshGroupNames()
{
var settings = AddressableAssetSettingsDefaultObject.Settings;
if (settings == null) { _groupNames = Array.Empty<string>(); return; }
_groupNames = settings.groups
.Where(g => g != null)
.Select(g => g.name)
.ToArray();
_targetGroupIndex = Mathf.Clamp(_targetGroupIndex, 0, Mathf.Max(0, _groupNames.Length - 1));
}
private static HashSet<string> CollectRegisteredGuids(AddressableAssetSettings settings)
{
var set = new HashSet<string>(StringComparer.Ordinal);
if (settings == null) return set;
foreach (var group in settings.groups)
{
if (group == null) continue;
foreach (var e in group.entries)
if (e != null) set.Add(e.guid);
}
return set;
}
private string BuildAddress(string assetPath)
{
string fileName = Path.GetFileNameWithoutExtension(assetPath);
string relativePath = MakeRelativePath(assetPath, _folderPath);
return _addressFormat switch
{
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,
};
}
private static string MakeRelativePath(string assetPath, string baseFolderPath)
{
if (string.IsNullOrEmpty(baseFolderPath)) return assetPath;
return assetPath.StartsWith(baseFolderPath)
? assetPath.Substring(baseFolderPath.Length).TrimStart('/')
: assetPath;
}
private List<string> BuildSearchFilter()
{
var list = new List<string>();
if (_filterPrefab) list.Add("*.prefab");
if (_filterScene) list.Add("*.unity");
if (_filterSO) list.Add("*.asset");
if (_filterTexture) { list.Add("*.png"); list.Add("*.jpg"); list.Add("*.tga"); }
if (_filterAudio) { list.Add("*.mp3"); list.Add("*.wav"); list.Add("*.ogg"); }
if (list.Count == 0) list.Add("*.*");
return list;
}
/// <summary>
/// 判断路径是否为可寻址资产排除脚本、程序集定义、Shader、Sprite Atlas、Material 等文件)。
/// </summary>
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 依赖打包
}
/// <summary>从 AddressKey如 "ENM_GruntWarrior")派生搜索名("GruntWarrior")。</summary>
private static string DeriveName(string key)
{
// 取最后一个 '/' 之后的部分Config/FootstepCatalog → FootstepCatalog
int slash = key.LastIndexOf('/');
string last = slash >= 0 ? key.Substring(slash + 1) : key;
// 去掉前缀ENM_, VFX_, PROJ_ 等):找第一个 '_' 并截断前缀
int underscore = last.IndexOf('_');
return underscore >= 0 && underscore < last.Length - 1
? last.Substring(underscore + 1)
: last;
}
/// <summary>根据 AddressKey 前缀返回建议分组名,未匹配时返回 null回退到手动选定分组。</summary>
private static string DeriveGroupName(string key)
=> AddressableRules.GetExpectedGroup(key);
private static bool ExactNameMatch(string assetPath, string searchName)
{
string name = Path.GetFileNameWithoutExtension(assetPath);
return string.Equals(name, searchName, StringComparison.OrdinalIgnoreCase);
}
private void InitStyles()
{
if (_stylesInitialized) return;
_headerStyle = new GUIStyle(EditorStyles.boldLabel) { fontSize = 14 };
_okStyle = new GUIStyle(EditorStyles.miniLabel) { normal = { textColor = new Color(0.2f, 0.8f, 0.2f) } };
_warnStyle = new GUIStyle(EditorStyles.miniLabel) { normal = { textColor = new Color(1f, 0.6f, 0.1f) } };
_boldStyle = new GUIStyle(EditorStyles.miniLabel) { fontStyle = FontStyle.Bold };
_stylesInitialized = true;
}
// ══ 数据结构 ══════════════════════════════════════════════════════════
private class KeySyncEntry
{
public string FieldName;
public string AddressKey;
public bool IsRegistered;
public string ExistingAssetPath;
public string FoundAssetPath;
public string FoundGuid;
public UnityEngine.Object ManualAsset;
}
private class FolderEntry
{
public string AssetPath;
public string Guid;
public string Address;
public bool AlreadyRegistered;
public string PredictedGroup;
public string PredictedLabels;
}
private class SelectionEntry
{
public string AssetPath;
public string Guid;
public string Address;
public bool AlreadyRegistered;
public string PredictedGroup;
public string PredictedLabels;
}
private enum AddressFormat
{
[InspectorName("文件名(推荐)")] FileName,
[InspectorName("完整 Asset 路径")] FullAssetPath,
[InspectorName("相对于选定文件夹")] RelativeToFolder,
[InspectorName("前缀 + 文件名")] PrefixPlusFileName,
[InspectorName("前缀 + 相对路径")] PrefixPlusRelativePath,
}
}
// ── 轻量输入对话框(避免依赖 EditorInputDialog 插件)─────────────────────
internal static class EditorInputDialog
{
/// <summary>弹出单行文本输入对话框。返回用户输入,取消则返回原始默认值。</summary>
public static string Show(string title, string message, string defaultValue = "")
{
string result = defaultValue;
// 通过简单的 EditorWindow 实现
var win = ScriptableObject.CreateInstance<InputDialogWindow>();
win.Init(title, message, defaultValue, v => { result = v; });
win.ShowModal();
return result;
}
}
internal class InputDialogWindow : EditorWindow
{
private string _title;
private string _message;
private string _value;
private Action<string> _onConfirm;
public void Init(string title, string message, string defaultValue, Action<string> onConfirm)
{
titleContent = new GUIContent(title);
_title = title;
_message = message;
_value = defaultValue;
_onConfirm = onConfirm;
minSize = maxSize = new Vector2(340, 110);
}
private void OnGUI()
{
EditorGUILayout.Space(8);
EditorGUILayout.LabelField(_message);
GUI.SetNextControlName("input");
_value = EditorGUILayout.TextField(_value);
EditorGUI.FocusTextInControl("input");
EditorGUILayout.Space(8);
using (new EditorGUILayout.HorizontalScope())
{
GUILayout.FlexibleSpace();
if (GUILayout.Button("取消", GUILayout.Width(70)))
Close();
if (GUILayout.Button("确认", GUILayout.Width(70)))
{
_onConfirm?.Invoke(_value);
Close();
}
}
}
}
}