摄像机区域的架构改动
This commit is contained in:
191
Assets/_Game/Scripts/Editor/Addressables/AddressKeyValidator.cs
Normal file
191
Assets/_Game/Scripts/Editor/Addressables/AddressKeyValidator.cs
Normal file
@@ -0,0 +1,191 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using UnityEditor;
|
||||
using UnityEditor.Build;
|
||||
using UnityEditor.Build.Reporting;
|
||||
using UnityEngine;
|
||||
#if UNITY_EDITOR
|
||||
using UnityEditor.AddressableAssets;
|
||||
using UnityEditor.AddressableAssets.Settings;
|
||||
#endif
|
||||
|
||||
namespace BaseGames.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Editor 工具:验证 <see cref="BaseGames.Core.Assets.AddressKeys"/> 中所有常量
|
||||
/// 是否与 Addressable 分组中实际存在的地址同步(架构 13_AssetPoolModule §10)。
|
||||
///
|
||||
/// 菜单:BaseGames → Addressables → Validate Address Keys
|
||||
/// Build 回调顺序 = 0(在 SOValidationRunner callbackOrder = 1 之前执行)
|
||||
/// </summary>
|
||||
public class AddressKeyValidatorBuildHook : IPreprocessBuildWithReport
|
||||
{
|
||||
public int callbackOrder => 0;
|
||||
|
||||
public void OnPreprocessBuild(BuildReport report)
|
||||
{
|
||||
var results = AddressKeyValidator.RunValidation();
|
||||
int missing = results.Count(r => !r.ExistsInAddressables);
|
||||
if (missing > 0)
|
||||
{
|
||||
var orphans = results
|
||||
.Where(r => !r.ExistsInAddressables)
|
||||
.Select(r => $"AddressKeys.{r.FieldName} = \"{r.Value}\"");
|
||||
throw new BuildFailedException(
|
||||
$"[AddressKeyValidator] {missing} 个孤儿 AddressKey,构建中止:\n"
|
||||
+ string.Join("\n", orphans));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Editor 静态工具类:验证逻辑和 MenuItem 入口。
|
||||
/// </summary>
|
||||
public static class AddressKeyValidator
|
||||
{
|
||||
[MenuItem("BaseGames/Addressables/Validate Address Keys")]
|
||||
public static void ValidateAll()
|
||||
{
|
||||
var results = RunValidation();
|
||||
LogResults(results);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 执行验证,返回每个 key 的验证结果。供 Build Pre-process 或测试调用。
|
||||
/// </summary>
|
||||
public static List<ValidationResult> RunValidation()
|
||||
{
|
||||
var results = new List<ValidationResult>();
|
||||
var registeredAddresses = GetAllAddressableAddresses();
|
||||
|
||||
// 通过反射取出 AddressKeys 中所有 public const string 字段
|
||||
var keyType = typeof(BaseGames.Core.Assets.AddressKeys);
|
||||
var fields = keyType.GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy)
|
||||
.Where(f => f.IsLiteral && !f.IsInitOnly && f.FieldType == typeof(string));
|
||||
|
||||
foreach (var field in fields)
|
||||
{
|
||||
var value = (string)field.GetRawConstantValue();
|
||||
var exists = registeredAddresses.Contains(value);
|
||||
results.Add(new ValidationResult(field.Name, value, exists));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ── Internal ──────────────────────────────────────────────────────────
|
||||
|
||||
private static HashSet<string> GetAllAddressableAddresses()
|
||||
{
|
||||
var addresses = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
#if UNITY_EDITOR
|
||||
var settings = AddressableAssetSettingsDefaultObject.Settings;
|
||||
if (settings == null)
|
||||
{
|
||||
Debug.LogWarning("[AddressKeyValidator] Addressable Settings 未找到,请先初始化 Addressables。");
|
||||
return addresses;
|
||||
}
|
||||
|
||||
foreach (var group in settings.groups)
|
||||
{
|
||||
if (group == null) continue;
|
||||
foreach (var entry in group.entries)
|
||||
{
|
||||
if (entry != null)
|
||||
addresses.Add(entry.address);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
return addresses;
|
||||
}
|
||||
|
||||
private static void LogResults(List<ValidationResult> results)
|
||||
{
|
||||
int missing = 0;
|
||||
foreach (var r in results)
|
||||
{
|
||||
if (!r.ExistsInAddressables)
|
||||
{
|
||||
Debug.LogWarning($"[AddressKeyValidator] ⚠ 孤儿 Key:AddressKeys.{r.FieldName} = \"{r.Value}\" — 未在 Addressable 分组中找到对应地址。");
|
||||
missing++;
|
||||
}
|
||||
}
|
||||
|
||||
if (missing == 0)
|
||||
Debug.Log($"[AddressKeyValidator] ✓ 所有 {results.Count} 个 AddressKeys 常量均在 Addressable 分组中存在。");
|
||||
else
|
||||
Debug.LogWarning($"[AddressKeyValidator] 共 {results.Count} 个常量,发现 {missing} 个孤儿 Key。" +
|
||||
"尚未创建的 Prefab/Scene 资产请在创建后添加至 Addressables 分组。");
|
||||
}
|
||||
|
||||
// ── 结果结构 ──────────────────────────────────────────────────────────
|
||||
|
||||
public readonly struct ValidationResult
|
||||
{
|
||||
public readonly string FieldName;
|
||||
public readonly string Value;
|
||||
public readonly bool ExistsInAddressables;
|
||||
|
||||
public ValidationResult(string fieldName, string value, bool exists)
|
||||
{
|
||||
FieldName = fieldName;
|
||||
Value = value;
|
||||
ExistsInAddressables = exists;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 资产导入后自动触发 AddressKey 验证(架构 13_AssetPoolModule §10)。
|
||||
/// 仅在 Addressable Group 资产发生变更时触发,避免每次导入都验证。
|
||||
/// </summary>
|
||||
public class AddressKeyImportWatcher : AssetPostprocessor
|
||||
{
|
||||
private const string AddressableGroupAssetExt = ".asset";
|
||||
private const string AddressableGroupFolder = "Assets/AddressableAssetsData";
|
||||
|
||||
private static void OnPostprocessAllAssets(
|
||||
string[] importedAssets,
|
||||
string[] deletedAssets,
|
||||
string[] movedAssets,
|
||||
string[] movedFromAssetPaths)
|
||||
{
|
||||
bool addressablesChanged = false;
|
||||
|
||||
foreach (var path in importedAssets)
|
||||
{
|
||||
if (path.StartsWith(AddressableGroupFolder, StringComparison.OrdinalIgnoreCase)
|
||||
&& path.EndsWith(AddressableGroupAssetExt, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
addressablesChanged = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!addressablesChanged)
|
||||
{
|
||||
foreach (var path in deletedAssets)
|
||||
{
|
||||
if (path.StartsWith(AddressableGroupFolder, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
addressablesChanged = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (addressablesChanged)
|
||||
{
|
||||
// 延迟一帧执行,等待 AssetDatabase 完全刷新
|
||||
EditorApplication.delayCall += () =>
|
||||
{
|
||||
Debug.Log("[AddressKeyImportWatcher] 检测到 Addressable 分组变更,自动触发 Key 验证...");
|
||||
AddressKeyValidator.ValidateAll();
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4e0e2a58bdc0d4448833bd1c26caf2af
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,303 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using UnityEditor;
|
||||
using UnityEditor.AddressableAssets;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Addressable Key 引用关系图窗口(架构 13_AssetPoolModule §11)。
|
||||
/// 菜单:BaseGames/Tools/Asset Reference Graph
|
||||
///
|
||||
/// 功能:
|
||||
/// - 扫描所有 .cs 文件中对 AddressKeys.X 的引用
|
||||
/// - 列出每个 Key:声明位置、引用文件列表、是否存在于 Addressables
|
||||
/// - 孤儿 Key(有声明无引用)标红显示
|
||||
/// - 无效 Key(有引用但不存在于 Addressables)标橙显示
|
||||
/// - 一键导出 CSV
|
||||
/// </summary>
|
||||
public class AddressReferenceGraphWindow : EditorWindow
|
||||
{
|
||||
// ── State ──────────────────────────────────────────────────────────
|
||||
private List<KeyEntry> _entries;
|
||||
private Vector2 _scrollPos;
|
||||
private string _searchFilter = "";
|
||||
private bool _showOrphansOnly;
|
||||
private bool _showMissingOnly;
|
||||
|
||||
// ── Colors ─────────────────────────────────────────────────────────
|
||||
private static readonly Color ColOrphan = new Color(0.90f, 0.15f, 0.15f, 0.80f); // 孤儿 Key(无引用)
|
||||
private static readonly Color ColMissing = new Color(0.95f, 0.55f, 0.10f, 0.80f); // 无效 Key(不在 Addressables)
|
||||
private static readonly Color ColOk = new Color(0.20f, 0.75f, 0.30f, 0.80f); // 正常
|
||||
|
||||
[MenuItem("BaseGames/Tools/Asset Reference Graph")]
|
||||
public static void OpenWindow()
|
||||
{
|
||||
var win = GetWindow<AddressReferenceGraphWindow>("Asset Reference Graph");
|
||||
win.minSize = new Vector2(900, 500);
|
||||
win.Show();
|
||||
}
|
||||
|
||||
// ── GUI ────────────────────────────────────────────────────────────
|
||||
|
||||
private void OnGUI()
|
||||
{
|
||||
DrawToolbar();
|
||||
|
||||
if (_entries == null)
|
||||
{
|
||||
EditorGUILayout.HelpBox("点击上方「扫描」按钮分析 AddressKeys 引用关系。", MessageType.Info);
|
||||
return;
|
||||
}
|
||||
|
||||
DrawFilterRow();
|
||||
DrawResults();
|
||||
}
|
||||
|
||||
// ── Toolbar ───────────────────────────────────────────────────────
|
||||
|
||||
private void DrawToolbar()
|
||||
{
|
||||
EditorGUILayout.BeginHorizontal(EditorStyles.toolbar);
|
||||
|
||||
if (GUILayout.Button("扫描", EditorStyles.toolbarButton, GUILayout.Width(60)))
|
||||
RunScan();
|
||||
|
||||
if (_entries != null && GUILayout.Button("导出 CSV", EditorStyles.toolbarButton, GUILayout.Width(70)))
|
||||
ExportCsv();
|
||||
|
||||
GUILayout.FlexibleSpace();
|
||||
|
||||
if (_entries != null)
|
||||
{
|
||||
int orphans = _entries.Count(e => e.ReferenceCount == 0);
|
||||
int missing = _entries.Count(e => !e.ExistsInAddressables);
|
||||
EditorGUILayout.LabelField(
|
||||
$"共 {_entries.Count} 个 Key | 孤儿:{orphans} | 未在 Addressables:{missing}",
|
||||
EditorStyles.toolbarButton);
|
||||
}
|
||||
|
||||
EditorGUILayout.EndHorizontal();
|
||||
}
|
||||
|
||||
// ── 过滤行 ────────────────────────────────────────────────────────
|
||||
|
||||
private void DrawFilterRow()
|
||||
{
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
EditorGUILayout.LabelField("搜索:", GUILayout.Width(40));
|
||||
_searchFilter = EditorGUILayout.TextField(_searchFilter, GUILayout.ExpandWidth(true));
|
||||
_showOrphansOnly = EditorGUILayout.ToggleLeft("仅显示孤儿", _showOrphansOnly, GUILayout.Width(90));
|
||||
_showMissingOnly = EditorGUILayout.ToggleLeft("仅显示缺失", _showMissingOnly, GUILayout.Width(90));
|
||||
EditorGUILayout.EndHorizontal();
|
||||
}
|
||||
|
||||
// ── 结果列表 ──────────────────────────────────────────────────────
|
||||
|
||||
private void DrawResults()
|
||||
{
|
||||
var filtered = _entries.AsEnumerable();
|
||||
|
||||
if (_showOrphansOnly)
|
||||
filtered = filtered.Where(e => e.ReferenceCount == 0);
|
||||
if (_showMissingOnly)
|
||||
filtered = filtered.Where(e => !e.ExistsInAddressables);
|
||||
if (!string.IsNullOrEmpty(_searchFilter))
|
||||
filtered = filtered.Where(e =>
|
||||
e.FieldName.IndexOf(_searchFilter, System.StringComparison.OrdinalIgnoreCase) >= 0);
|
||||
|
||||
var list = filtered.ToList();
|
||||
|
||||
// 表头
|
||||
EditorGUILayout.BeginHorizontal(EditorStyles.helpBox);
|
||||
EditorGUILayout.LabelField("状态", GUILayout.Width(50));
|
||||
EditorGUILayout.LabelField("Key 名称", GUILayout.Width(280));
|
||||
EditorGUILayout.LabelField("地址值", GUILayout.Width(300));
|
||||
EditorGUILayout.LabelField("引用数", GUILayout.Width(60));
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
_scrollPos = EditorGUILayout.BeginScrollView(_scrollPos);
|
||||
|
||||
foreach (var entry in list)
|
||||
{
|
||||
bool isOrphan = entry.ReferenceCount == 0;
|
||||
bool isMissing = !entry.ExistsInAddressables;
|
||||
|
||||
Color statusColor = isOrphan ? ColOrphan : (isMissing ? ColMissing : ColOk);
|
||||
string statusIcon = isOrphan ? "⊘" : (isMissing ? "⚠" : "✓");
|
||||
|
||||
var prevBg = GUI.backgroundColor;
|
||||
GUI.backgroundColor = statusColor * 0.6f;
|
||||
EditorGUILayout.BeginHorizontal("box");
|
||||
GUI.backgroundColor = prevBg;
|
||||
|
||||
EditorGUILayout.LabelField(statusIcon, GUILayout.Width(50));
|
||||
EditorGUILayout.LabelField(entry.FieldName, GUILayout.Width(280));
|
||||
|
||||
// 地址值可点击 → Ping Addressable asset
|
||||
if (GUILayout.Button(entry.Value,
|
||||
isOrphan ? EditorStyles.label : EditorStyles.miniButtonMid,
|
||||
GUILayout.Width(300)))
|
||||
{
|
||||
PingAddressableAsset(entry.Value);
|
||||
}
|
||||
|
||||
EditorGUILayout.LabelField(
|
||||
$"{entry.ReferenceCount}",
|
||||
GUILayout.Width(60));
|
||||
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
// 展开:显示引用文件列表
|
||||
if (entry.ReferenceCount > 0 && entry.ReferencedInFiles != null)
|
||||
{
|
||||
foreach (var file in entry.ReferencedInFiles)
|
||||
{
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
GUILayout.Space(60);
|
||||
EditorGUILayout.LabelField($" ↳ {file}", EditorStyles.miniLabel);
|
||||
EditorGUILayout.EndHorizontal();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
EditorGUILayout.EndScrollView();
|
||||
}
|
||||
|
||||
// ── 扫描逻辑 ──────────────────────────────────────────────────────
|
||||
|
||||
private void RunScan()
|
||||
{
|
||||
_entries = new List<KeyEntry>();
|
||||
|
||||
// 1. 收集所有 AddressKeys 常量
|
||||
var keyFields = typeof(BaseGames.Core.Assets.AddressKeys)
|
||||
.GetFields(System.Reflection.BindingFlags.Public
|
||||
| System.Reflection.BindingFlags.Static
|
||||
| System.Reflection.BindingFlags.FlattenHierarchy)
|
||||
.Where(f => f.IsLiteral && !f.IsInitOnly && f.FieldType == typeof(string));
|
||||
|
||||
var keyDict = new Dictionary<string, KeyEntry>();
|
||||
foreach (var f in keyFields)
|
||||
{
|
||||
var value = (string)f.GetRawConstantValue();
|
||||
keyDict[f.Name] = new KeyEntry
|
||||
{
|
||||
FieldName = f.Name,
|
||||
Value = value,
|
||||
ExistsInAddressables = false,
|
||||
ReferencedInFiles = new List<string>()
|
||||
};
|
||||
}
|
||||
|
||||
// 2. 检查 Addressables(直接内联,不依赖 Verification 程序集)
|
||||
var registeredAddressValues = GetRegisteredAddressableAddresses();
|
||||
|
||||
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);
|
||||
|
||||
foreach (var file in csFiles)
|
||||
{
|
||||
string content;
|
||||
try { content = File.ReadAllText(file); }
|
||||
catch { continue; }
|
||||
|
||||
foreach (var kv in keyDict)
|
||||
{
|
||||
// 匹配 AddressKeys.FieldName(单词边界,避免前缀误匹配)
|
||||
if (Regex.IsMatch(content, $@"\bAddressKeys\.{Regex.Escape(kv.Key)}\b"))
|
||||
{
|
||||
string relativePath = "Assets" + file.Substring(Application.dataPath.Length).Replace('\\', '/');
|
||||
kv.Value.ReferencedInFiles.Add(relativePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var kv in keyDict)
|
||||
_entries.Add(kv.Value);
|
||||
|
||||
_entries.Sort((a, b) =>
|
||||
{
|
||||
// 孤儿排最前,其次缺失,最后正常
|
||||
int aScore = a.ReferenceCount == 0 ? 0 : (!a.ExistsInAddressables ? 1 : 2);
|
||||
int bScore = b.ReferenceCount == 0 ? 0 : (!b.ExistsInAddressables ? 1 : 2);
|
||||
return aScore != bScore ? aScore.CompareTo(bScore) : string.Compare(a.FieldName, b.FieldName);
|
||||
});
|
||||
|
||||
Debug.Log($"[AddressReferenceGraph] 扫描完成:{_entries.Count} 个 Key," +
|
||||
$"{_entries.Count(e => e.ReferenceCount == 0)} 孤儿," +
|
||||
$"{_entries.Count(e => !e.ExistsInAddressables)} 未在 Addressables。");
|
||||
}
|
||||
|
||||
// ── CSV 导出 ──────────────────────────────────────────────────────
|
||||
|
||||
private void ExportCsv()
|
||||
{
|
||||
string path = EditorUtility.SaveFilePanel("导出 CSV", "", "AddressKeyReport", "csv");
|
||||
if (string.IsNullOrEmpty(path)) return;
|
||||
|
||||
using var writer = new StreamWriter(path, false, System.Text.Encoding.UTF8);
|
||||
writer.WriteLine("FieldName,Value,ExistsInAddressables,ReferenceCount,ReferencedFiles");
|
||||
foreach (var e in _entries)
|
||||
{
|
||||
string files = e.ReferencedInFiles != null
|
||||
? string.Join(" | ", e.ReferencedInFiles)
|
||||
: "";
|
||||
writer.WriteLine($"{e.FieldName},{e.Value},{e.ExistsInAddressables},{e.ReferenceCount},{files}");
|
||||
}
|
||||
|
||||
Debug.Log($"[AddressReferenceGraph] CSV 已导出:{path}");
|
||||
}
|
||||
|
||||
// ── Addressables 辅助(独立实现,不依赖 Verification 程序集)─────────
|
||||
|
||||
private static HashSet<string> GetRegisteredAddressableAddresses()
|
||||
{
|
||||
var addresses = new HashSet<string>(StringComparer.Ordinal);
|
||||
var settings = AddressableAssetSettingsDefaultObject.Settings;
|
||||
if (settings == null) return addresses;
|
||||
foreach (var group in settings.groups)
|
||||
{
|
||||
if (group == null) continue;
|
||||
foreach (var entry in group.entries)
|
||||
if (entry != null) addresses.Add(entry.address);
|
||||
}
|
||||
return addresses;
|
||||
}
|
||||
|
||||
// ── Ping Addressable ──────────────────────────────────────────────
|
||||
|
||||
private static void PingAddressableAsset(string address)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
var guids = AssetDatabase.FindAssets($"\"{address}\"");
|
||||
if (guids.Length > 0)
|
||||
{
|
||||
var obj = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(AssetDatabase.GUIDToAssetPath(guids[0]));
|
||||
if (obj != null) EditorGUIUtility.PingObject(obj);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// ── Data ──────────────────────────────────────────────────────────
|
||||
|
||||
private class KeyEntry
|
||||
{
|
||||
public string FieldName;
|
||||
public string Value;
|
||||
public bool ExistsInAddressables;
|
||||
public List<string> ReferencedInFiles;
|
||||
public int ReferenceCount => ReferencedInFiles?.Count ?? 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 884c3c28d25877643afa90b72ba2a650
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
885
Assets/_Game/Scripts/Editor/Addressables/AddressableBatchTool.cs
Normal file
885
Assets/_Game/Scripts/Editor/Addressables/AddressableBatchTool.cs
Normal file
@@ -0,0 +1,885 @@
|
||||
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/Tools/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 ① 自动分组用)
|
||||
private static readonly (string Prefix, string GroupName)[] PrefixGroupMap =
|
||||
{
|
||||
("Scene_", "Scenes"),
|
||||
("PLY_", "Player"),
|
||||
("ENM_", "Enemies"),
|
||||
("PROJ_", "Projectiles"),
|
||||
("VFX_", "VFX"),
|
||||
("UI_", "UI"),
|
||||
("COL_", "Collectibles"),
|
||||
("WPN_", "Weapons"),
|
||||
("Config/", "Config"),
|
||||
};
|
||||
|
||||
// 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 int _targetGroupIndex;
|
||||
private string[] _groupNames;
|
||||
private string _newGroupName = "New Group";
|
||||
private string _newLabel = "";
|
||||
private bool _overwriteAddress;
|
||||
|
||||
// ── 样式(惰性初始化)────────────────────────────────────────────────
|
||||
private GUIStyle _headerStyle;
|
||||
private GUIStyle _okStyle;
|
||||
private GUIStyle _warnStyle;
|
||||
private GUIStyle _boldStyle;
|
||||
private bool _stylesInitialized;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
[MenuItem(MenuPath, priority = 200)]
|
||||
[MenuItem("BaseGames/Verification/Open Addressable Batch Tool", priority = 250)]
|
||||
public static void OpenWindow()
|
||||
{
|
||||
var win = GetWindow<AddressableBatchTool>(Title);
|
||||
win.minSize = new Vector2(600, 460);
|
||||
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(200));
|
||||
EditorGUILayout.LabelField("地址 Key", _boldStyle, GUILayout.Width(180));
|
||||
EditorGUILayout.LabelField("状态", _boldStyle, GUILayout.Width(100));
|
||||
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(200));
|
||||
EditorGUILayout.LabelField(entry.AddressKey, GUILayout.Width(180));
|
||||
|
||||
if (entry.IsRegistered)
|
||||
{
|
||||
EditorGUILayout.LabelField("✅ 已注册", _okStyle, GUILayout.Width(100));
|
||||
EditorGUILayout.LabelField(entry.ExistingAssetPath ?? "—");
|
||||
}
|
||||
else if (entry.FoundAssetPath != null)
|
||||
{
|
||||
EditorGUILayout.LabelField("⚠ 未注册", _warnStyle, GUILayout.Width(100));
|
||||
EditorGUILayout.LabelField(entry.FoundAssetPath, GUILayout.ExpandWidth(true));
|
||||
if (GUILayout.Button("注册", GUILayout.Width(50)))
|
||||
RegisterKeyEntry(entry);
|
||||
}
|
||||
else
|
||||
{
|
||||
EditorGUILayout.LabelField("❌ 未找到", _warnStyle, GUILayout.Width(100));
|
||||
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())
|
||||
{
|
||||
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.ExpandWidth(true));
|
||||
EditorGUILayout.LabelField("预计地址", _boldStyle, GUILayout.Width(200));
|
||||
EditorGUILayout.LabelField("状态", _boldStyle, GUILayout.Width(80));
|
||||
}
|
||||
|
||||
_folderScrollPos = EditorGUILayout.BeginScrollView(_folderScrollPos, GUILayout.ExpandHeight(true));
|
||||
foreach (var entry in _folderEntries)
|
||||
{
|
||||
using (new EditorGUILayout.HorizontalScope(GUILayout.Height(18)))
|
||||
{
|
||||
EditorGUILayout.LabelField(entry.AssetPath, GUILayout.ExpandWidth(true));
|
||||
entry.Address = EditorGUILayout.TextField(entry.Address, GUILayout.Width(200));
|
||||
var label = entry.AlreadyRegistered ? "✅ 已有" : "待注册";
|
||||
var style = entry.AlreadyRegistered ? _okStyle : EditorStyles.miniLabel;
|
||||
EditorGUILayout.LabelField(label, style, GUILayout.Width(80));
|
||||
}
|
||||
}
|
||||
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);
|
||||
|
||||
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.ExpandWidth(true));
|
||||
EditorGUILayout.LabelField("注册地址", _boldStyle, GUILayout.Width(200));
|
||||
EditorGUILayout.LabelField("状态", _boldStyle, GUILayout.Width(80));
|
||||
}
|
||||
|
||||
_selectionScrollPos = EditorGUILayout.BeginScrollView(_selectionScrollPos, GUILayout.ExpandHeight(true));
|
||||
foreach (var entry in _selectionEntries)
|
||||
{
|
||||
using (new EditorGUILayout.HorizontalScope(GUILayout.Height(18)))
|
||||
{
|
||||
EditorGUILayout.LabelField(entry.AssetPath, GUILayout.ExpandWidth(true));
|
||||
entry.Address = EditorGUILayout.TextField(entry.Address, GUILayout.Width(200));
|
||||
var label = entry.AlreadyRegistered ? "✅ 已有" : "待注册";
|
||||
var style = entry.AlreadyRegistered ? _okStyle : EditorStyles.miniLabel;
|
||||
EditorGUILayout.LabelField(label, style, GUILayout.Width(80));
|
||||
}
|
||||
}
|
||||
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));
|
||||
if (_groupNames != null && _groupNames.Length > 0)
|
||||
{
|
||||
_targetGroupIndex = EditorGUILayout.Popup(_targetGroupIndex,
|
||||
_groupNames, GUILayout.Width(200));
|
||||
}
|
||||
|
||||
EditorGUILayout.Space(8);
|
||||
EditorGUILayout.LabelField("标签", GUILayout.Width(30));
|
||||
_newLabel = EditorGUILayout.TextField(_newLabel, GUILayout.Width(120));
|
||||
GUILayout.Label("(留空则不添加标签)", EditorStyles.miniLabel);
|
||||
GUILayout.FlexibleSpace();
|
||||
}
|
||||
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
_overwriteAddress = GUILayout.Toggle(_overwriteAddress, "已注册的资产也覆盖地址");
|
||||
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);
|
||||
|
||||
foreach (string filter in filters)
|
||||
{
|
||||
foreach (string absPath in Directory.GetFiles(absFolder, filter, option))
|
||||
{
|
||||
string relPath = "Assets" + absPath.Substring(Application.dataPath.Length).Replace('\\', '/');
|
||||
string guid = AssetDatabase.AssetPathToGUID(relPath);
|
||||
if (string.IsNullOrEmpty(guid)) continue;
|
||||
|
||||
_folderEntries.Add(new FolderEntry
|
||||
{
|
||||
AssetPath = relPath,
|
||||
Guid = guid,
|
||||
Address = BuildAddress(relPath),
|
||||
AlreadyRegistered = registeredGuids.Contains(guid),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 去重(多个 filter 可能匹配同一文件)
|
||||
_folderEntries = _folderEntries
|
||||
.GroupBy(e => e.Guid)
|
||||
.Select(g => g.First())
|
||||
.ToList();
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
_selectionEntries.Add(new SelectionEntry
|
||||
{
|
||||
AssetPath = path,
|
||||
Guid = guid,
|
||||
Address = BuildAddress(path),
|
||||
AlreadyRegistered = registeredGuids.Contains(guid),
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
var group = groupNameOverride != null
|
||||
? GetOrCreateGroup(settings, groupNameOverride)
|
||||
: 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);
|
||||
}
|
||||
|
||||
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 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);
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
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>从 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)
|
||||
{
|
||||
foreach (var (prefix, groupName) in PrefixGroupMap)
|
||||
if (key.StartsWith(prefix, StringComparison.Ordinal))
|
||||
return groupName;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool ExactNameMatch(string assetPath, string searchName)
|
||||
{
|
||||
string name = Path.GetFileNameWithoutExtension(assetPath);
|
||||
return string.Equals(name, searchName, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 判断路径是否为可寻址资产(排除脚本、程序集定义、Shader 等代码文件)。
|
||||
/// </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";
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
private class SelectionEntry
|
||||
{
|
||||
public string AssetPath;
|
||||
public string Guid;
|
||||
public string Address;
|
||||
public bool AlreadyRegistered;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 501e237201ba03f4295b4d12a1d0cad7
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user