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