摄像机区域的架构改动

This commit is contained in:
2026-05-15 14:47:24 +08:00
parent 1b37297585
commit f264329751
3591 changed files with 1687228 additions and 446503 deletions
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 07ed02361aa3739468cbd36457aecda6
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,112 @@
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;
using BaseGames.Progression;
namespace BaseGames.Editor.Achievements
{
/// <summary>
/// AchievementSO 自定义 Inspector(架构 16_SupportingModules §2.4)。
/// 在 conditions 数组中内联展示各 AchievementCondition SO 的关键字段,
/// 并在头部显示条件类型的中文名,提供 Ping 和删除按钮。
/// </summary>
[CustomEditor(typeof(AchievementSO))]
public class AchievementSOEditor : UnityEditor.Editor
{
private static readonly Dictionary<string, string> _conditionLabels = new()
{
{ "DefeatedBossCondition", "击败 Boss" },
{ "DefeatedAllBossesCondition", "击败全部 Boss" },
{ "EnteredRegionCondition", "到达区域" },
{ "MapExplorationCondition", "地图探索 %" },
{ "CollectedItemCondition", "收集物品" },
{ "CollectedAllCharmsCondition", "集满全部 Charm" },
{ "UnlockedAllAbilitiesCondition", "解锁全部能力" },
{ "NoHealRunCondition", "无治疗通关" },
{ "TimedBossKillCondition", "限时击败 Boss" },
{ "ParryCountCondition", "弹反 N 次" },
{ "NailClashCountCondition", "拼刀 N 次" },
{ "EventTriggeredCondition", "监听事件" },
};
private SerializedProperty _conditionsProp;
private void OnEnable()
{
_conditionsProp = serializedObject.FindProperty("conditions");
}
public override void OnInspectorGUI()
{
serializedObject.Update();
// 绘制除 conditions 之外的所有默认字段
DrawPropertiesExcluding(serializedObject, "conditions");
EditorGUILayout.Space(8);
EditorGUILayout.LabelField("解锁条件(AND 全部满足)", EditorStyles.boldLabel);
for (int i = 0; i < _conditionsProp.arraySize; i++)
{
var elemProp = _conditionsProp.GetArrayElementAtIndex(i);
var condSO = elemProp.objectReferenceValue as AchievementCondition;
string typeName = condSO?.GetType().Name ?? "";
string label = condSO != null && _conditionLabels.TryGetValue(typeName, out var n)
? $"{n} [{condSO.name}]"
: (condSO != null ? $"{typeName} [{condSO.name}]" : "(未指定条件 SO");
EditorGUILayout.BeginVertical(EditorStyles.helpBox);
// 标题行
EditorGUILayout.BeginHorizontal();
EditorGUILayout.LabelField(label, EditorStyles.boldLabel);
if (condSO != null && GUILayout.Button("↗", GUILayout.Width(24)))
EditorGUIUtility.PingObject(condSO);
var prevColor = GUI.color;
GUI.color = Color.red * 0.9f;
if (GUILayout.Button("✕", GUILayout.Width(24)))
{
GUI.color = prevColor;
// 先将引用置空再删除,避免删除保留引用的 Unity 行为
_conditionsProp.GetArrayElementAtIndex(i).objectReferenceValue = null;
_conditionsProp.DeleteArrayElementAtIndex(i);
serializedObject.ApplyModifiedProperties();
break;
}
GUI.color = prevColor;
EditorGUILayout.EndHorizontal();
// 内联展开 SO 字段(可编辑)
if (condSO != null)
{
var innerSO = new SerializedObject(condSO);
innerSO.Update();
var prop = innerSO.GetIterator();
prop.NextVisible(true); // 跳过 m_Script
while (prop.NextVisible(false))
EditorGUILayout.PropertyField(prop, true);
if (innerSO.ApplyModifiedProperties())
EditorUtility.SetDirty(condSO);
}
else
{
EditorGUILayout.PropertyField(elemProp, GUIContent.none);
}
EditorGUILayout.EndVertical();
EditorGUILayout.Space(2);
}
// 添加按钮
EditorGUILayout.BeginHorizontal();
if (GUILayout.Button(" 添加条件 SO 引用"))
_conditionsProp.arraySize++;
EditorGUILayout.EndHorizontal();
serializedObject.ApplyModifiedProperties();
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 95b58f8c5a3285c4abbf929f7bf36946
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 5de106d4bd1d78a4795365c301707920
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -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] ⚠ 孤儿 KeyAddressKeys.{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:
@@ -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:
@@ -0,0 +1,46 @@
{
"name": "BaseGames.Editor",
"rootNamespace": "BaseGames.Editor",
"references": [
"BaseGames.Core",
"BaseGames.Core.Events",
"Unity.Addressables",
"Unity.Addressables.Editor",
"BaseGames.Core.Save",
"BaseGames.Input",
"BaseGames.Combat",
"BaseGames.Combat.StatusEffects",
"BaseGames.Quest",
"BaseGames.World.Shop",
"BaseGames.Player",
"BaseGames.Player.States",
"BaseGames.Enemies",
"BaseGames.Camera",
"BaseGames.World",
"BaseGames.UI",
"BaseGames.Audio",
"BaseGames.Feedback",
"BaseGames.Dialogue",
"BaseGames.Progression",
"PathBerserker2d",
"Unity.Cinemachine",
"Kybernetik.Animancer",
"BaseGames.Animation",
"BaseGames.Equipment",
"BaseGames.Skills",
"BaseGames.World.Map",
"BaseGames.EventChain",
"BaseGames.VFX"
],
"includePlatforms": [
"Editor"
],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": false,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 0cb7f1698076b424bbbf87d8789ca0ed
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 2b1294877189cc0468771b03fe04c4d4
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,277 @@
using System.Reflection;
using UnityEditor;
using UnityEngine;
using Unity.Cinemachine;
using BaseGames.Camera;
namespace BaseGames.Editor
{
/// <summary>
/// CameraArea 自定义 Inspector + Scene GUI。
///
/// 功能与 <see cref="RoomCameraEditor"/> 一致:
/// 1. Scene 视图中直接拖拽黄色矩形四条边,编辑「可视区域」(_visibleBounds)。
/// 2. Inspector 按钮「从可视区域更新限位区域(透视)」:
/// 根据 FOV 和相机深度计算 PolygonCollider2D 限位范围并写入。
///
/// FOV 优先级(降序):
/// 专有 DedicatedCamera.Lens.FieldOfView
/// → CameraStateController._vcamAPersistent 场景)
/// → Camera.main.fieldOfView
/// → 60f(默认)
/// </summary>
[CustomEditor(typeof(CameraArea))]
public class CameraAreaEditor : UnityEditor.Editor
{
// ── 颜色常量 ──────────────────────────────────────────────────────────
private static readonly Color kVisibleFill = new Color(1f, 0.85f, 0.15f, 0.08f);
private static readonly Color kVisibleOutline = new Color(1f, 0.85f, 0.15f, 0.90f);
private static readonly Color kConfinerColor = new Color(0.2f, 0.8f, 1.0f, 0.80f);
// ══ Inspector ═════════════════════════════════════════════════════════
public override void OnInspectorGUI()
{
DrawDefaultInspector();
EditorGUILayout.Space(8f);
EditorGUILayout.LabelField("── 可视区域工具 ──", EditorStyles.boldLabel);
CameraArea area = (CameraArea)target;
float vFOV = GetFOV(area);
float aspect = GetAspect();
float depth = area.CameraDepth;
float halfH = depth * Mathf.Tan(vFOV * 0.5f * Mathf.Deg2Rad);
float halfW = halfH * aspect;
using (new EditorGUI.DisabledScope(true))
{
EditorGUILayout.FloatField("垂直 FOV(来源见工具提示)", vFOV);
EditorGUILayout.FloatField("有效深度", depth);
EditorGUILayout.FloatField("视口半高(世界单位)", halfH);
EditorGUILayout.FloatField("视口半宽(世界单位)", halfW);
}
bool canSync = area.ConfinerCollider != null;
if (!canSync)
EditorGUILayout.HelpBox("ConfinerCollider 未绑定,无法同步限位区域。", MessageType.Warning);
using (new EditorGUI.DisabledScope(!canSync))
{
if (GUILayout.Button("从可视区域更新限位区域(透视)", GUILayout.Height(28f)))
SyncConfinerFromVisibleBounds(area, vFOV, aspect);
}
// ── 图例说明 ─────────────────────────────────────────────────────
EditorGUILayout.Space(4f);
DrawLegend("■ 黄色矩形(Scene 视图)", kVisibleOutline, "可视区域 — 摄像机视口永不超出此范围");
DrawLegend("■ 蓝色多边形(Scene 视图)", kConfinerColor, "限位区域 — CinemachineConfiner2D 的运动边界");
}
// ══ Scene GUI ════════════════════════════════════════════════════════
private void OnSceneGUI()
{
CameraArea area = (CameraArea)target;
serializedObject.Update();
SerializedProperty boundsP = serializedObject.FindProperty("_visibleBounds");
Rect r = boundsP.rectValue;
// ── 绘制限位多边形(蓝色,参考用) ──────────────────────────────
DrawConfinerGizmo(area);
// ── 绘制可视区域填充 + 边框 ──────────────────────────────────────
DrawVisibleRect(r);
// ── 四条边的拖拽 Handle ──────────────────────────────────────────
EditorGUI.BeginChangeCheck();
EditRectEdges(ref r);
if (EditorGUI.EndChangeCheck())
{
Undo.RecordObject(area, "Edit Visible Bounds");
boundsP.rectValue = r;
serializedObject.ApplyModifiedProperties();
}
}
// ══ 绘制辅助 ═════════════════════════════════════════════════════════
private static void DrawVisibleRect(Rect r)
{
Vector3[] corners =
{
new Vector3(r.xMin, r.yMin, 0f),
new Vector3(r.xMin, r.yMax, 0f),
new Vector3(r.xMax, r.yMax, 0f),
new Vector3(r.xMax, r.yMin, 0f),
};
Handles.DrawSolidRectangleWithOutline(corners, kVisibleFill, kVisibleOutline);
Handles.color = kVisibleOutline;
Handles.Label(
new Vector3(r.xMin + 0.15f, r.yMax - 0.15f, 0f),
"Visible Area",
EditorStyles.miniLabel);
}
private static void DrawConfinerGizmo(CameraArea area)
{
var poly = area.ConfinerCollider;
if (poly == null || poly.pathCount == 0) return;
int ptCount = poly.GetTotalPointCount();
if (ptCount < 2) return;
var pts2 = new System.Collections.Generic.List<Vector2>(ptCount);
poly.GetPath(0, pts2);
var pts3 = new Vector3[ptCount + 1];
for (int i = 0; i < ptCount; i++)
pts3[i] = poly.transform.TransformPoint(pts2[i]);
pts3[ptCount] = pts3[0];
Handles.color = kConfinerColor;
Handles.DrawPolyLine(pts3);
Handles.Label(
(Vector3)poly.transform.TransformPoint(pts2[0]) + new Vector3(0.1f, 0.1f),
"Confiner",
EditorStyles.miniLabel);
}
/// <summary>绘制四条边的滑动 Handle,允许用户直接拖拽修改可视区域。</summary>
private static void EditRectEdges(ref Rect r)
{
float hs = HandleUtility.GetHandleSize(r.center) * 0.10f;
Handles.color = kVisibleOutline;
// 左边 —— 沿 X 轴滑动
EditorGUI.BeginChangeCheck();
Vector3 lp = Handles.Slider(
new Vector3(r.xMin, r.center.y, 0f),
Vector3.right, hs, Handles.RectangleHandleCap, EditorSnapSettings.move.x);
if (EditorGUI.EndChangeCheck())
r.xMin = Mathf.Min(lp.x, r.xMax - 0.1f);
// 右边 —— 沿 X 轴滑动
EditorGUI.BeginChangeCheck();
Vector3 rp = Handles.Slider(
new Vector3(r.xMax, r.center.y, 0f),
Vector3.right, hs, Handles.RectangleHandleCap, EditorSnapSettings.move.x);
if (EditorGUI.EndChangeCheck())
r.xMax = Mathf.Max(rp.x, r.xMin + 0.1f);
// 下边 —— 沿 Y 轴滑动
EditorGUI.BeginChangeCheck();
Vector3 bp = Handles.Slider(
new Vector3(r.center.x, r.yMin, 0f),
Vector3.up, hs, Handles.RectangleHandleCap, EditorSnapSettings.move.y);
if (EditorGUI.EndChangeCheck())
r.yMin = Mathf.Min(bp.y, r.yMax - 0.1f);
// 上边 —— 沿 Y 轴滑动
EditorGUI.BeginChangeCheck();
Vector3 tp = Handles.Slider(
new Vector3(r.center.x, r.yMax, 0f),
Vector3.up, hs, Handles.RectangleHandleCap, EditorSnapSettings.move.y);
if (EditorGUI.EndChangeCheck())
r.yMax = Mathf.Max(tp.y, r.yMin + 0.1f);
}
// ══ 透视同步逻辑 ══════════════════════════════════════════════════════
private static void SyncConfinerFromVisibleBounds(CameraArea area, float vFOV, float aspect)
{
var poly = area.ConfinerCollider;
if (poly == null)
{
Debug.LogWarning($"[CameraAreaEditor] {area.name}ConfinerCollider 未绑定,无法同步。");
return;
}
Rect visible = area.VisibleBounds;
float depth = area.CameraDepth;
float halfH = depth * Mathf.Tan(vFOV * 0.5f * Mathf.Deg2Rad);
float halfW = halfH * aspect;
float xMin = visible.xMin + halfW;
float xMax = visible.xMax - halfW;
float yMin = visible.yMin + halfH;
float yMax = visible.yMax - halfH;
// 房间小于单屏 → 相机锁定在可视区域中心
if (xMin > xMax) { float cx = visible.center.x; xMin = xMax = cx; }
if (yMin > yMax) { float cy = visible.center.y; yMin = yMax = cy; }
Transform polyT = poly.transform;
Vector2 Local(Vector3 w) => polyT.InverseTransformPoint(w);
Undo.RecordObject(poly, "Sync Confiner from Visible Bounds");
poly.SetPath(0, new[]
{
Local(new Vector3(xMin, yMin, 0f)),
Local(new Vector3(xMin, yMax, 0f)),
Local(new Vector3(xMax, yMax, 0f)),
Local(new Vector3(xMax, yMin, 0f)),
});
EditorUtility.SetDirty(poly);
Debug.Log(
$"[CameraAreaEditor] {area.name}:限位区域已同步。\n" +
$" 可视区域:{visible}\n" +
$" FOV={vFOV:F1}° Depth={depth:F1} HalfView=({halfW:F2}, {halfH:F2})\n" +
$" 限位区域:({xMin:F2}, {yMin:F2}) ~ ({xMax:F2}, {yMax:F2})");
}
// ══ 工具方法 ══════════════════════════════════════════════════════════
/// <summary>
/// 获取用于透视计算的 FOV(优先级:专有 VCam → 全局 VCamA → Camera.main → 60f)。
/// </summary>
private static float GetFOV(CameraArea area)
{
// 1. 区域专有 VCam
if (area.DedicatedCamera != null)
return area.DedicatedCamera.Lens.FieldOfView;
// 2. Persistent 场景中的 CameraStateController._vcamA(通过反射读取私有字段)
#pragma warning disable UNT0023 // FindObjectOfType 在编辑器工具中可接受
var ctrl = Object.FindObjectOfType<CameraStateController>();
#pragma warning restore UNT0023
if (ctrl != null)
{
var fi = typeof(CameraStateController).GetField(
"_vcamA", BindingFlags.Instance | BindingFlags.NonPublic);
if (fi != null && fi.GetValue(ctrl) is CinemachineCamera vcamA && vcamA != null)
return vcamA.Lens.FieldOfView;
}
// 3. Camera.main
if (UnityEngine.Camera.main != null)
return UnityEngine.Camera.main.fieldOfView;
// 4. 默认
return 60f;
}
private static float GetAspect()
{
if (UnityEngine.Camera.main != null) return UnityEngine.Camera.main.aspect;
return 16f / 9f;
}
private static void DrawLegend(string text, Color color, string tooltip)
{
using (new EditorGUILayout.HorizontalScope())
{
Color prev = GUI.color;
GUI.color = color;
GUILayout.Label("■", GUILayout.Width(14f));
GUI.color = prev;
EditorGUILayout.LabelField(new GUIContent(text, tooltip));
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f3f7c4193e749e54b85c7ca1b31f0783
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,268 @@
using UnityEditor;
using UnityEngine;
using Unity.Cinemachine;
using BaseGames.Camera;
namespace BaseGames.Editor
{
/// <summary>
/// RoomCamera 自定义 Inspector + Scene GUI。
///
/// 功能:
/// 1. Scene 视图中直接拖拽黄色矩形的四条边,编辑「可视区域」(_visibleBounds)。
/// 2. Inspector 按钮「从可视区域更新限位区域(透视)」:
/// 根据 CinemachineCamera.Lens.FieldOfView 和摄像机深度,计算出
/// CinemachineConfiner2D 所需的限位多边形并写入子节点 PolygonCollider2D。
///
/// 透视相机限位公式:
/// halfH = depth × tan(vFOV / 2)
/// halfW = halfH × aspectRatio
/// confiner = visibleBounds inset by (halfW, halfH)
/// → 相机视口边缘恰好与可视区域边框对齐。
/// → 若房间小于单屏,限位收缩为中心点(相机固定居中)。
/// </summary>
[CustomEditor(typeof(RoomCamera))]
public class RoomCameraEditor : UnityEditor.Editor
{
// ── 颜色常量 ──────────────────────────────────────────────────────────
private static readonly Color kVisibleFill = new Color(1f, 0.85f, 0.15f, 0.08f);
private static readonly Color kVisibleOutline = new Color(1f, 0.85f, 0.15f, 0.90f);
private static readonly Color kConfinerColor = new Color(0.2f, 0.8f, 1.0f, 0.80f);
// ══ Inspector ═════════════════════════════════════════════════════════
public override void OnInspectorGUI()
{
DrawDefaultInspector();
EditorGUILayout.Space(8f);
EditorGUILayout.LabelField("── 可视区域工具 ──", EditorStyles.boldLabel);
RoomCamera rc = (RoomCamera)target;
var vcam = rc.GetComponent<CinemachineCamera>();
var confiner = rc.GetComponent<CinemachineConfiner2D>();
// ── 透视参数预览 ─────────────────────────────────────────────────
float vFOV = vcam != null ? vcam.Lens.FieldOfView : 60f;
float aspect = GetAspect();
float depth = rc.CameraDepth;
float halfH = depth * Mathf.Tan(vFOV * 0.5f * Mathf.Deg2Rad);
float halfW = halfH * aspect;
using (new EditorGUI.DisabledScope(true))
{
EditorGUILayout.FloatField("垂直 FOV(来自 Lens", vFOV);
EditorGUILayout.FloatField("有效深度", depth);
EditorGUILayout.FloatField("视口半高(世界单位)", halfH);
EditorGUILayout.FloatField("视口半宽(世界单位)", halfW);
}
bool canSync = rc.ConfinerCollider != null;
if (!canSync)
EditorGUILayout.HelpBox("ConfinerCollider 未绑定(_visibleArea 为空),无法同步限位区域。", MessageType.Warning);
using (new EditorGUI.DisabledScope(!canSync))
{
if (GUILayout.Button("从可视区域更新限位区域(透视)", GUILayout.Height(28f)))
SyncConfinerFromVisibleBounds(rc, vFOV, aspect);
}
// ── 图例说明 ─────────────────────────────────────────────────────
EditorGUILayout.Space(4f);
DrawLegend("■ 黄色矩形(Scene 视图)", kVisibleOutline, "可视区域 — 摄像机视口永不超出此范围");
DrawLegend("■ 蓝色多边形(Scene 视图)", kConfinerColor, "限位区域 — CinemachineConfiner2D 的运动边界");
}
// ══ Scene GUI ════════════════════════════════════════════════════════
private void OnSceneGUI()
{
RoomCamera rc = (RoomCamera)target;
serializedObject.Update();
SerializedProperty boundsP = serializedObject.FindProperty("_visibleBounds");
Rect r = boundsP.rectValue;
// ── 绘制限位多边形(蓝色,参考用) ──────────────────────────────
DrawConfinerGizmo(rc);
// ── 绘制可视区域填充 + 边框 ──────────────────────────────────────
DrawVisibleRect(r);
// ── 四条边的拖拽 Handle ──────────────────────────────────────────
EditorGUI.BeginChangeCheck();
EditRectEdges(ref r);
if (EditorGUI.EndChangeCheck())
{
Undo.RecordObject(rc, "Edit Visible Bounds");
boundsP.rectValue = r;
serializedObject.ApplyModifiedProperties();
}
}
// ══ 绘制辅助 ═════════════════════════════════════════════════════════
private static void DrawVisibleRect(Rect r)
{
Vector3[] corners =
{
new Vector3(r.xMin, r.yMin, 0f),
new Vector3(r.xMin, r.yMax, 0f),
new Vector3(r.xMax, r.yMax, 0f),
new Vector3(r.xMax, r.yMin, 0f),
};
Handles.DrawSolidRectangleWithOutline(corners, kVisibleFill, kVisibleOutline);
// 标签
Handles.color = kVisibleOutline;
Handles.Label(
new Vector3(r.xMin + 0.15f, r.yMax - 0.15f, 0f),
"Visible Area",
EditorStyles.miniLabel);
}
private static void DrawConfinerGizmo(RoomCamera rc)
{
var poly = rc.ConfinerCollider;
if (poly == null || poly.pathCount == 0) return;
int ptCount = poly.GetTotalPointCount();
if (ptCount < 2) return;
var pts2 = new System.Collections.Generic.List<Vector2>(ptCount);
poly.GetPath(0, pts2);
var pts3 = new Vector3[ptCount + 1];
for (int i = 0; i < ptCount; i++)
pts3[i] = poly.transform.TransformPoint(pts2[i]);
pts3[ptCount] = pts3[0];
Handles.color = kConfinerColor;
Handles.DrawPolyLine(pts3);
Handles.Label(
(Vector3)poly.transform.TransformPoint(pts2[0]) + new Vector3(0.1f, 0.1f),
"Confiner",
EditorStyles.miniLabel);
}
/// <summary>绘制四条边的滑动 Handle,允许用户直接拖拽修改可视区域。</summary>
private static void EditRectEdges(ref Rect r)
{
float hs = HandleUtility.GetHandleSize(r.center) * 0.10f;
Handles.color = kVisibleOutline;
// 左边 —— 沿 X 轴滑动
EditorGUI.BeginChangeCheck();
Vector3 lp = Handles.Slider(
new Vector3(r.xMin, r.center.y, 0f),
Vector3.right, hs, Handles.RectangleHandleCap, EditorSnapSettings.move.x);
if (EditorGUI.EndChangeCheck())
{
// 保持 xMax 不变,xMin 向右最多到 xMax-0.1
r.xMin = Mathf.Min(lp.x, r.xMax - 0.1f);
}
// 右边 —— 沿 X 轴滑动
EditorGUI.BeginChangeCheck();
Vector3 rp = Handles.Slider(
new Vector3(r.xMax, r.center.y, 0f),
Vector3.right, hs, Handles.RectangleHandleCap, EditorSnapSettings.move.x);
if (EditorGUI.EndChangeCheck())
{
r.xMax = Mathf.Max(rp.x, r.xMin + 0.1f);
}
// 下边 —— 沿 Y 轴滑动
EditorGUI.BeginChangeCheck();
Vector3 bp = Handles.Slider(
new Vector3(r.center.x, r.yMin, 0f),
Vector3.up, hs, Handles.RectangleHandleCap, EditorSnapSettings.move.y);
if (EditorGUI.EndChangeCheck())
{
r.yMin = Mathf.Min(bp.y, r.yMax - 0.1f);
}
// 上边 —— 沿 Y 轴滑动
EditorGUI.BeginChangeCheck();
Vector3 tp = Handles.Slider(
new Vector3(r.center.x, r.yMax, 0f),
Vector3.up, hs, Handles.RectangleHandleCap, EditorSnapSettings.move.y);
if (EditorGUI.EndChangeCheck())
{
r.yMax = Mathf.Max(tp.y, r.yMin + 0.1f);
}
}
// ══ 透视同步逻辑 ══════════════════════════════════════════════════════
/// <summary>
/// 根据可视区域矩形与透视参数计算限位多边形,写入 PolygonCollider2D。
/// </summary>
private static void SyncConfinerFromVisibleBounds(RoomCamera rc, float vFOV, float aspect)
{
var poly = rc.ConfinerCollider;
if (poly == null)
{
Debug.LogWarning($"[RoomCameraEditor] {rc.name}ConfinerCollider 未绑定,无法同步。");
return;
}
Rect visible = rc.VisibleBounds;
float depth = rc.CameraDepth;
float halfH = depth * Mathf.Tan(vFOV * 0.5f * Mathf.Deg2Rad);
float halfW = halfH * aspect;
float xMin = visible.xMin + halfW;
float xMax = visible.xMax - halfW;
float yMin = visible.yMin + halfH;
float yMax = visible.yMax - halfH;
// 房间小于单屏 → 相机锁定在可视区域中心
if (xMin > xMax) { float cx = visible.center.x; xMin = xMax = cx; }
if (yMin > yMax) { float cy = visible.center.y; yMin = yMax = cy; }
Transform polyT = poly.transform;
Vector2 Local(Vector3 w) => polyT.InverseTransformPoint(w);
Undo.RecordObject(poly, "Sync Confiner from Visible Bounds");
poly.SetPath(0, new[]
{
Local(new Vector3(xMin, yMin, 0f)),
Local(new Vector3(xMin, yMax, 0f)),
Local(new Vector3(xMax, yMax, 0f)),
Local(new Vector3(xMax, yMin, 0f)),
});
EditorUtility.SetDirty(poly);
Debug.Log(
$"[RoomCameraEditor] {rc.name}:限位区域已同步。\n" +
$" 可视区域:{visible}\n" +
$" FOV={vFOV:F1}° Depth={depth:F1} HalfView=({halfW:F2}, {halfH:F2})\n" +
$" 限位区域:({xMin:F2}, {yMin:F2}) ~ ({xMax:F2}, {yMax:F2})");
}
// ══ 工具方法 ══════════════════════════════════════════════════════════
/// <summary>
/// 获取 Game 视图宽高比。编辑器中优先用 Camera.main,否则回退到 16:9。
/// </summary>
private static float GetAspect()
{
if (UnityEngine.Camera.main != null) return UnityEngine.Camera.main.aspect;
return 16f / 9f;
}
private static void DrawLegend(string text, Color color, string tooltip)
{
using (new EditorGUILayout.HorizontalScope())
{
Color prev = GUI.color;
GUI.color = color;
GUILayout.Label("■", GUILayout.Width(14f));
GUI.color = prev;
EditorGUILayout.LabelField(new GUIContent(text, tooltip));
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e733a7cb718909842b12f5994eb841c4
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,866 @@
using System.Collections.Generic;
using BaseGames.Camera;
using Unity.Cinemachine;
using UnityEditor;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.Tilemaps;
namespace BaseGames.Editor
{
/// <summary>
/// 相机区域配置工具窗口。
/// 扫描当前已加载场景中的所有 CameraArea / CameraTriggerZone / CameraStateController
/// 显示各组件的绑定状态,并提供一键修复快捷操作。
///
/// 菜单:BaseGames → Camera → Camera Area Setup
/// </summary>
public class CameraAreaSetupTool : EditorWindow
{
// ── 状态 ──────────────────────────────────────────────────────────────
private Vector2 _scroll;
private List<CameraArea> _cameraAreas = new List<CameraArea>();
private List<CameraTriggerZone> _triggerZones = new List<CameraTriggerZone>();
private CameraStateController _controller;
// ── GUI 样式缓存 ──────────────────────────────────────────────────────
private GUIStyle _boxStyle;
// ══ 菜单入口 ══════════════════════════════════════════════════════════
[MenuItem("BaseGames/Camera/Camera Area Setup", priority = 100)]
public static void ShowWindow()
{
var win = GetWindow<CameraAreaSetupTool>("Camera Area Setup");
win.minSize = new Vector2(420f, 300f);
win.Show();
}
// ══ EditorWindow 生命周期 ═════════════════════════════════════════════
private void OnEnable() => RescanScene();
private void OnHierarchyChange() => RescanScene();
private void OnFocus() => RescanScene();
// ══ 场景扫描 ══════════════════════════════════════════════════════════
private void RescanScene()
{
_cameraAreas.Clear();
_triggerZones.Clear();
_controller = null;
for (int i = 0; i < SceneManager.sceneCount; i++)
{
Scene scene = SceneManager.GetSceneAt(i);
if (!scene.isLoaded) continue;
foreach (GameObject root in scene.GetRootGameObjects())
{
_cameraAreas.AddRange(root.GetComponentsInChildren<CameraArea>(true));
_triggerZones.AddRange(root.GetComponentsInChildren<CameraTriggerZone>(true));
if (_controller == null)
_controller = root.GetComponentInChildren<CameraStateController>(true);
}
}
Repaint();
}
// ══ GUI ═══════════════════════════════════════════════════════════════
private void OnGUI()
{
EnsureStyles();
// ── 工具栏 ─────────────────────────────────────────────────────
using (new EditorGUILayout.HorizontalScope(EditorStyles.toolbar))
{
if (GUILayout.Button("↻ 刷新", EditorStyles.toolbarButton, GUILayout.Width(56)))
RescanScene();
GUILayout.FlexibleSpace();
if (GUILayout.Button("Place Camera Area", EditorStyles.toolbarButton))
EditorApplication.ExecuteMenuItem("BaseGames/Scene/Place/Camera Area");
if (GUILayout.Button("Place Trigger Zone", EditorStyles.toolbarButton))
EditorApplication.ExecuteMenuItem("BaseGames/Scene/Place/Camera Trigger Zone");
}
_scroll = EditorGUILayout.BeginScrollView(_scroll);
// ── CameraStateController ───────────────────────────────────────
DrawSectionHeader("CameraStateControllerPersistent 场景)");
DrawControllerSection();
EditorGUILayout.Space(8f);
// ── CameraArea 列表 ─────────────────────────────────────────────
DrawSectionHeader($"Camera Areas [{_cameraAreas.Count}]");
DrawCameraAreasSection();
EditorGUILayout.Space(8f);
// ── CameraTriggerZone 列表 ──────────────────────────────────────
DrawSectionHeader($"Camera Trigger Zones [{_triggerZones.Count}]");
DrawTriggerZonesSection();
EditorGUILayout.EndScrollView();
}
// ── CameraStateController ──────────────────────────────────────────
private void DrawControllerSection()
{
if (_controller == null)
{
EditorGUILayout.HelpBox(
"当前已加载场景中未找到 CameraStateController(正常)。\n" +
"该组件位于 Persistent 场景,单独编辑房间场景时不会加载。\n" +
"进入 Play Mode 前请确保 Persistent 场景已一同加载。",
MessageType.Info);
return;
}
using (new EditorGUILayout.VerticalScope(_boxStyle))
{
using (new EditorGUILayout.HorizontalScope())
{
EditorGUILayout.LabelField("GameObject", GUILayout.Width(120f));
EditorGUILayout.ObjectField(_controller.gameObject, typeof(GameObject), true);
}
SerializedObject so = new SerializedObject(_controller);
DrawFieldCheck(so, "_vcamA", "全局 VCam A (CinemachineCamera)");
DrawFieldCheck(so, "_vcamB", "全局 VCam B (CinemachineCamera)");
DrawFieldCheck(so, "_brain", "CinemachineBrain");
DrawFieldCheck(so, "_impulseSource", "CinemachineImpulseSource", optional: true);
DrawFieldCheck(so, "_defaultBlendProfile","默认混合配置 (CameraBlendProfileSO)", optional: true);
EditorGUILayout.Space(4f);
if (GUILayout.Button("为全局 VCam 赋值 Follow 目标(Player/CameraFollowTarget", GUILayout.Height(24f)))
AssignFollowToGlobalVCams(so);
}
}
// ── CameraArea 列表 ────────────────────────────────────────────────
private void DrawCameraAreasSection()
{
if (_cameraAreas.Count == 0)
{
EditorGUILayout.HelpBox(
"场景中未找到 CameraArea 组件。\n使用工具栏 \"Place Camera Area\" 快速生成。",
MessageType.Info);
return;
}
foreach (var area in _cameraAreas)
{
if (area == null) continue;
DrawCameraAreaEntry(area);
EditorGUILayout.Space(2f);
}
}
private void DrawCameraAreaEntry(CameraArea area)
{
SerializedObject so = new SerializedObject(area);
bool confinerOk = so.FindProperty("_confinerCollider").objectReferenceValue != null;
bool dedicatedSet = so.FindProperty("_dedicatedCamera").objectReferenceValue != null;
bool allOk = confinerOk;
using (new EditorGUILayout.VerticalScope(_boxStyle))
{
// 标题行
using (new EditorGUILayout.HorizontalScope())
{
GUILayout.Label(allOk ? "✅" : "⚠", GUILayout.Width(20f));
if (GUILayout.Button(area.gameObject.name, EditorStyles.boldLabel, GUILayout.ExpandWidth(true)))
Selection.activeGameObject = area.gameObject;
if (GUILayout.Button("选中", GUILayout.Width(40f)))
Selection.activeGameObject = area.gameObject;
}
EditorGUILayout.Space(2f);
DrawCheckRow("_confinerCollider (PolygonCollider2D)", confinerOk);
DrawCheckRow("_dedicatedCamera(专有 VCam,可选)", dedicatedSet, optional: true);
DrawCheckRow("_blendProfile(可选,未设则用全局默认)",
so.FindProperty("_blendProfile").objectReferenceValue != null, optional: true);
if (!confinerOk)
{
EditorGUILayout.Space(2f);
if (GUILayout.Button("修复:绑定子节点 PolygonCollider2D", GUILayout.Height(20f)))
FixConfinerBinding(area);
}
}
}
// ── CameraTriggerZone 列表 ─────────────────────────────────────────
private void DrawTriggerZonesSection()
{
if (_triggerZones.Count == 0)
{
EditorGUILayout.HelpBox(
"场景中未找到 CameraTriggerZone。\n" +
"至少需要一个触发器来在运行时激活 CameraArea。\n" +
"使用工具栏 \"Place Trigger Zone\" 快速生成。",
MessageType.Info);
return;
}
foreach (var zone in _triggerZones)
{
if (zone == null) continue;
DrawTriggerZoneEntry(zone);
}
}
private void DrawTriggerZoneEntry(CameraTriggerZone zone)
{
SerializedObject so = new SerializedObject(zone);
bool hasTarget = so.FindProperty("_targetArea").objectReferenceValue != null;
using (new EditorGUILayout.HorizontalScope(_boxStyle))
{
GUILayout.Label(hasTarget ? "✅" : "❌", GUILayout.Width(20f));
if (GUILayout.Button(zone.gameObject.name, EditorStyles.label, GUILayout.ExpandWidth(true)))
Selection.activeGameObject = zone.gameObject;
if (!hasTarget)
EditorGUILayout.LabelField("⚠ _targetArea 未绑定!", GUILayout.Width(160f));
if (GUILayout.Button("选中", GUILayout.Width(40f)))
Selection.activeGameObject = zone.gameObject;
}
}
// ══ 自动修复操作 ═══════════════════════════════════════════════════════
/// <summary>
/// 查找场景中 tag=Player 的 Player/CameraFollowTarget
/// 写入 CameraStateController._vcamA 和 _vcamB 的 Follow 字段。
/// </summary>
private static void AssignFollowToGlobalVCams(SerializedObject controllerSO)
{
GameObject player = GameObject.FindWithTag("Player");
if (player == null)
{
Debug.LogWarning("[CameraAreaSetupTool] 场景中未找到 tag=Player 的对象。" +
"请先放置 PlayerBaseGames → Scene → Place → Player)。");
return;
}
const string followNodeName = "CameraFollowTarget";
Transform followTarget = player.transform.Find(followNodeName);
if (followTarget == null)
{
var go = new GameObject(followNodeName);
Undo.RegisterCreatedObjectUndo(go, "Create CameraFollowTarget");
Undo.SetTransformParent(go.transform, player.transform, "Parent CameraFollowTarget");
go.transform.localPosition = Vector3.zero;
go.transform.localRotation = Quaternion.identity;
go.transform.localScale = Vector3.one;
followTarget = go.transform;
Debug.Log($"[CameraAreaSetupTool] 已在 Player 下自动创建 {followNodeName} 子节点。");
}
int count = 0;
foreach (string fieldName in new[] { "_vcamA", "_vcamB" })
{
var vcamProp = controllerSO.FindProperty(fieldName);
if (vcamProp?.objectReferenceValue is CinemachineCamera vcam)
{
Undo.RecordObject(vcam, "Assign Camera Follow Target");
vcam.Follow = followTarget;
EditorUtility.SetDirty(vcam);
count++;
}
}
if (count > 0)
Debug.Log($"[CameraAreaSetupTool] 已为 {count} 台全局 VCam 赋值 Follow → {followTarget.name}。");
else
Debug.LogWarning("[CameraAreaSetupTool] _vcamA/_vcamB 均未绑定,无法赋值 Follow。请先在 Inspector 中绑定。");
}
/// <summary>将子节点中找到的第一个 PolygonCollider2D 绑定到 CameraArea._confinerCollider。</summary>
private static void FixConfinerBinding(CameraArea area)
{
PolygonCollider2D poly = area.GetComponentInChildren<PolygonCollider2D>(true);
if (poly == null)
{
Debug.LogWarning($"[CameraAreaSetupTool] {area.name}:子节点中未找到 PolygonCollider2D。");
return;
}
SerializedObject so = new SerializedObject(area);
so.FindProperty("_confinerCollider").objectReferenceValue = poly;
so.ApplyModifiedProperties();
Debug.Log($"[CameraAreaSetupTool] {area.name}_confinerCollider → {poly.gameObject.name}");
}
// ══ GUI 辅助 ═══════════════════════════════════════════════════════════
private void EnsureStyles()
{
if (_boxStyle == null)
{
_boxStyle = new GUIStyle(GUI.skin.box)
{
padding = new RectOffset(6, 6, 4, 4),
};
}
}
private static void DrawSectionHeader(string title)
{
EditorGUILayout.Space(4f);
using (new EditorGUILayout.HorizontalScope())
{
GUILayout.Label(title, EditorStyles.boldLabel);
}
Rect r = EditorGUILayout.GetControlRect(false, 1f);
EditorGUI.DrawRect(r, new Color(0.4f, 0.4f, 0.4f, 1f));
EditorGUILayout.Space(2f);
}
private static void DrawFieldCheck(SerializedObject so, string propName, string displayName, bool optional = false)
{
var prop = so.FindProperty(propName);
bool ok = prop != null && prop.objectReferenceValue != null;
DrawCheckRow(displayName, ok, optional);
}
private static void DrawCheckRow(string label, bool ok, bool optional = false)
{
using (new EditorGUILayout.HorizontalScope())
{
Color prev = GUI.color;
GUI.color = ok
? new Color(0.4f, 1f, 0.4f)
: (optional ? new Color(0.8f, 0.8f, 0.4f) : new Color(1f, 0.4f, 0.4f));
GUILayout.Label(ok ? "●" : (optional ? "◌" : "✗"), GUILayout.Width(16f));
GUI.color = prev;
EditorGUILayout.LabelField(label);
}
}
}
}
namespace BaseGames.Editor
{
/// <summary>
/// 区域相机配置工具窗口。
/// 扫描当前已加载场景中的所有 RoomCamera / CameraTriggerZone / CameraStateController
/// 显示各组件的绑定状态,并提供一键修复快捷操作。
///
/// 菜单:BaseGames → Camera → Room Camera Setup
/// </summary>
public class RoomCameraSetupTool : EditorWindow
{
// ── 状态 ──────────────────────────────────────────────────────────────
private Vector2 _scroll;
private List<RoomCamera> _roomCameras = new List<RoomCamera>();
private List<CameraTriggerZone> _triggerZones = new List<CameraTriggerZone>();
private CameraStateController _controller;
// ── GUI 样式缓存 ──────────────────────────────────────────────────────
private GUIStyle _boxStyle;
// ══ 菜单入口 ══════════════════════════════════════════════════════════
[MenuItem("BaseGames/Camera/Room Camera Setup", priority = 100)]
public static void ShowWindow()
{
var win = GetWindow<RoomCameraSetupTool>("Room Camera Setup");
win.minSize = new Vector2(420f, 300f);
win.Show();
}
// ══ EditorWindow 生命周期 ═════════════════════════════════════════════
private void OnEnable() => RescanScene();
private void OnHierarchyChange() => RescanScene();
private void OnFocus() => RescanScene();
// ══ 场景扫描 ══════════════════════════════════════════════════════════
private void RescanScene()
{
_roomCameras.Clear();
_triggerZones.Clear();
_controller = null;
for (int i = 0; i < SceneManager.sceneCount; i++)
{
Scene scene = SceneManager.GetSceneAt(i);
if (!scene.isLoaded) continue;
foreach (GameObject root in scene.GetRootGameObjects())
{
_roomCameras.AddRange(root.GetComponentsInChildren<RoomCamera>(true));
_triggerZones.AddRange(root.GetComponentsInChildren<CameraTriggerZone>(true));
if (_controller == null)
_controller = root.GetComponentInChildren<CameraStateController>(true);
}
}
Repaint();
}
// ══ GUI ═══════════════════════════════════════════════════════════════
private void OnGUI()
{
EnsureStyles();
// ── 工具栏 ─────────────────────────────────────────────────────
using (new EditorGUILayout.HorizontalScope(EditorStyles.toolbar))
{
if (GUILayout.Button("↻ 刷新", EditorStyles.toolbarButton, GUILayout.Width(56)))
RescanScene();
GUILayout.FlexibleSpace();
if (GUILayout.Button("Place Room Camera", EditorStyles.toolbarButton))
EditorApplication.ExecuteMenuItem("BaseGames/Scene/Place/Room Camera");
if (GUILayout.Button("Place Trigger Zone", EditorStyles.toolbarButton))
EditorApplication.ExecuteMenuItem("BaseGames/Scene/Place/Camera Trigger Zone");
}
_scroll = EditorGUILayout.BeginScrollView(_scroll);
// ── CameraStateController ───────────────────────────────────────
DrawSectionHeader("CameraStateController(持久场景)");
DrawControllerSection();
EditorGUILayout.Space(8f);
// ── RoomCamera 列表 ─────────────────────────────────────────────
DrawSectionHeader($"Room Cameras [{_roomCameras.Count}]");
DrawRoomCamerasSection();
EditorGUILayout.Space(8f);
// ── CameraTriggerZone 列表 ──────────────────────────────────────
DrawSectionHeader($"Camera Trigger Zones [{_triggerZones.Count}]");
DrawTriggerZonesSection();
EditorGUILayout.EndScrollView();
}
// ── CameraStateController ──────────────────────────────────────────
private void DrawControllerSection()
{
if (_controller == null)
{
EditorGUILayout.HelpBox(
"当前已加载场景中未找到 CameraStateController(正常)。\n" +
"该组件位于 Persistent 场景,单独编辑房间场景时不会加载。\n" +
"进入 Play Mode 前请确保 Persistent 场景已一同加载。",
MessageType.Info);
return;
}
using (new EditorGUILayout.VerticalScope(_boxStyle))
{
using (new EditorGUILayout.HorizontalScope())
{
EditorGUILayout.LabelField("GameObject", GUILayout.Width(120f));
EditorGUILayout.ObjectField(_controller.gameObject, typeof(GameObject), true);
}
SerializedObject so = new SerializedObject(_controller);
DrawFieldCheck(so, "_brain", "CinemachineBrain");
DrawFieldCheck(so, "_impulseSource", "CinemachineImpulseSource");
DrawFieldCheck(so, "_defaultBlendProfile","默认混合配置 (CameraBlendProfileSO)", optional: true);
}
}
// ── RoomCamera 列表 ────────────────────────────────────────────────
private void DrawRoomCamerasSection()
{
if (_roomCameras.Count == 0)
{
EditorGUILayout.HelpBox(
"场景中未找到 RoomCamera 组件。\n使用工具栏 \"Place Room Camera\" 快速生成。",
MessageType.Info);
return;
}
// 批量操作
using (new EditorGUILayout.HorizontalScope())
{
if (GUILayout.Button("批量赋值 Follow (Player)", GUILayout.Height(22f)))
BatchAssignFollowTarget();
if (GUILayout.Button("批量修复 Confiner 绑定", GUILayout.Height(22f)))
BatchFixConfinerBinding();
}
EditorGUILayout.Space(4f);
foreach (var cam in _roomCameras)
{
if (cam == null) continue;
DrawRoomCameraEntry(cam);
EditorGUILayout.Space(2f);
}
}
private void DrawRoomCameraEntry(RoomCamera cam)
{
if (cam == null) return;
SerializedObject camSO = new SerializedObject(cam);
CinemachineCamera vcam = cam.GetComponent<CinemachineCamera>();
CinemachineConfiner2D confiner = cam.GetComponent<CinemachineConfiner2D>();
bool visibleAreaOk = camSO.FindProperty("_visibleArea").objectReferenceValue != null;
bool confinerCompOk = confiner != null;
bool confinerBoundOk = confiner != null &&
new SerializedObject(confiner).FindProperty("m_BoundingShape2D").objectReferenceValue != null;
bool followOk = vcam != null && vcam.Follow != null;
bool blendOk = camSO.FindProperty("_blendProfile").objectReferenceValue != null;
bool allOk = visibleAreaOk && confinerCompOk && confinerBoundOk && followOk;
using (new EditorGUILayout.VerticalScope(_boxStyle))
{
// 标题行
using (new EditorGUILayout.HorizontalScope())
{
GUILayout.Label(allOk ? "✅" : "⚠", GUILayout.Width(20f));
if (GUILayout.Button(cam.gameObject.name, EditorStyles.boldLabel, GUILayout.ExpandWidth(true)))
Selection.activeGameObject = cam.gameObject;
if (GUILayout.Button("选中", GUILayout.Width(40f)))
Selection.activeGameObject = cam.gameObject;
}
EditorGUILayout.Space(2f);
// 状态行
DrawCheckRow("_visibleArea (RoomVisibleArea)", visibleAreaOk);
DrawCheckRow("CinemachineConfiner2D 组件", confinerCompOk);
DrawCheckRow("Confiner2D.m_BoundingShape2D 已绑定", confinerBoundOk);
DrawCheckRow("CinemachineCamera.Follow (Player/CameraFollowTarget)", followOk);
DrawCheckRow("_blendProfile (未设则用全局默认,可选)", blendOk, optional: true);
// 修复按钮区
bool needFix = !followOk || !confinerBoundOk || !visibleAreaOk;
if (needFix)
{
EditorGUILayout.Space(2f);
using (new EditorGUILayout.HorizontalScope())
{
if (!followOk)
if (GUILayout.Button("赋值 Follow", GUILayout.Height(20f)))
AssignFollowTarget(cam);
if (!confinerBoundOk && confiner != null)
if (GUILayout.Button("修复 Confiner 绑定", GUILayout.Height(20f)))
FixConfinerBinding(cam, confiner);
if (!visibleAreaOk)
if (GUILayout.Button("修复 VisibleArea", GUILayout.Height(20f)))
FixVisibleArea(cam);
}
}
// Tilemap 适配按钮(始终可见,因为有时需要重新适配)
EditorGUILayout.Space(2f);
using (new EditorGUILayout.HorizontalScope())
{
if (GUILayout.Button("以 Ground Tilemap 范围调整边界", GUILayout.Height(20f)))
FitConfinerToGroundTilemaps(cam);
if (!blendOk)
{
EditorGUILayout.HelpBox(
"_blendProfile 未设置,切换时使用控制器全局默认混合配置。",
MessageType.None);
}
}
}
}
// ── CameraTriggerZone 列表 ─────────────────────────────────────────
private void DrawTriggerZonesSection()
{
if (_triggerZones.Count == 0)
{
EditorGUILayout.HelpBox(
"场景中未找到 CameraTriggerZone。\n" +
"至少需要一个触发器来在运行时激活 RoomCamera。\n" +
"使用工具栏 \"Place Trigger Zone\" 快速生成。",
MessageType.Info);
return;
}
foreach (var zone in _triggerZones)
{
if (zone == null) continue;
DrawTriggerZoneEntry(zone);
}
}
private void DrawTriggerZoneEntry(CameraTriggerZone zone)
{
SerializedObject so = new SerializedObject(zone);
bool hasTarget = so.FindProperty("_targetCamera").objectReferenceValue != null;
using (new EditorGUILayout.HorizontalScope(_boxStyle))
{
GUILayout.Label(hasTarget ? "✅" : "❌", GUILayout.Width(20f));
if (GUILayout.Button(zone.gameObject.name, EditorStyles.label, GUILayout.ExpandWidth(true)))
Selection.activeGameObject = zone.gameObject;
if (!hasTarget)
EditorGUILayout.LabelField("⚠ _targetCamera 未绑定!", GUILayout.Width(160f));
if (GUILayout.Button("选中", GUILayout.Width(40f)))
Selection.activeGameObject = zone.gameObject;
}
}
// ══ 自动修复操作 ═══════════════════════════════════════════════════════
/// <summary>为所有未设置 Follow 的 RoomCamera 自动绑定场景中 tag=Player 的 Transform。</summary>
private void BatchAssignFollowTarget()
{
int count = 0;
foreach (var cam in _roomCameras)
{
if (cam == null) continue;
if (AssignFollowTarget(cam)) count++;
}
if (count > 0)
Debug.Log($"[RoomCameraSetupTool] 批量赋值:已为 {count} 台 RoomCamera 赋值 Follow 目标。");
else
Debug.Log("[RoomCameraSetupTool] 批量赋值:所有 RoomCamera 均已设置 Follow,无需修改。");
}
/// <summary>为所有 Confiner2D.m_BoundingShape2D 未绑定的相机自动绑定子节点 PolygonCollider2D。</summary>
private void BatchFixConfinerBinding()
{
int count = 0;
foreach (var cam in _roomCameras)
{
if (cam == null) continue;
var confiner = cam.GetComponent<CinemachineConfiner2D>();
if (confiner == null) continue;
var so = new SerializedObject(confiner);
if (so.FindProperty("m_BoundingShape2D").objectReferenceValue == null)
{
FixConfinerBinding(cam, confiner);
count++;
}
}
Debug.Log($"[RoomCameraSetupTool] 批量修复:已修复 {count} 台 RoomCamera 的 Confiner 绑定。");
}
/// <summary>
/// 在场景中查找 tag=Player 的 GameObject
/// 再在其下寻找名为 "CameraFollowTarget" 的子节点并赋给 CinemachineCamera.Follow。
/// 子节点不存在时会自动创建。
/// </summary>
private bool AssignFollowTarget(RoomCamera cam)
{
CinemachineCamera vcam = cam.GetComponent<CinemachineCamera>();
if (vcam == null || vcam.Follow != null) return false;
GameObject player = GameObject.FindWithTag("Player");
if (player == null)
{
Debug.LogWarning("[RoomCameraSetupTool] 场景中未找到 tag=Player 的对象,无法自动赋值 Follow。" +
"请先放置 Player 对象(BaseGames → Scene → Place → Player)。");
return false;
}
const string followNodeName = "CameraFollowTarget";
Transform followTarget = player.transform.Find(followNodeName);
if (followTarget == null)
{
// 子节点不存在则自动创建,位置归零
var go = new GameObject(followNodeName);
Undo.RegisterCreatedObjectUndo(go, "Create CameraFollowTarget");
Undo.SetTransformParent(go.transform, player.transform, "Parent CameraFollowTarget");
go.transform.localPosition = Vector3.zero;
go.transform.localRotation = Quaternion.identity;
go.transform.localScale = Vector3.one;
followTarget = go.transform;
Debug.Log($"[RoomCameraSetupTool] 已在 Player 下自动创建 {followNodeName} 子节点。");
}
Undo.RecordObject(vcam, "Assign Camera Follow Target");
vcam.Follow = followTarget;
EditorUtility.SetDirty(vcam);
return true;
}
/// <summary>将子节点中找到的第一个 PolygonCollider2D 绑定到 CinemachineConfiner2D。</summary>
private void FixConfinerBinding(RoomCamera cam, CinemachineConfiner2D confiner)
{
PolygonCollider2D poly = cam.GetComponentInChildren<PolygonCollider2D>(true);
if (poly == null)
{
Debug.LogWarning($"[RoomCameraSetupTool] {cam.name}:子节点中未找到 PolygonCollider2D。" +
"请确保 RoomBoundary 子对象存在(使用 Place Room Camera 创建)。");
return;
}
SerializedObject so = new SerializedObject(confiner);
so.FindProperty("m_BoundingShape2D").objectReferenceValue = poly;
so.ApplyModifiedProperties();
Debug.Log($"[RoomCameraSetupTool] {cam.name}Confiner2D.m_BoundingShape2D → {poly.gameObject.name}");
}
/// <summary>将子节点中找到的 RoomVisibleArea 绑定到 RoomCamera._visibleArea。</summary>
private void FixVisibleArea(RoomCamera cam)
{
RoomVisibleArea existing = cam.GetComponentInChildren<RoomVisibleArea>(true);
if (existing == null)
{
Debug.LogWarning($"[RoomCameraSetupTool] {cam.name}:子节点中未找到 RoomVisibleArea。" +
"请确保 RoomBoundary 子对象存在(使用 Place Room Camera 创建)。");
return;
}
SerializedObject so = new SerializedObject(cam);
so.FindProperty("_visibleArea").objectReferenceValue = existing;
so.ApplyModifiedProperties();
Debug.Log($"[RoomCameraSetupTool] {cam.name}_visibleArea → {existing.gameObject.name}");
}
/// <summary>
/// 以场景中所有 Ground 层 Tilemap 的世界空间包围盒(合并后)来调整
/// RoomCamera 子节点 RoomBoundary 的 PolygonCollider2D 顶点,实现一键适配房间边界。
/// </summary>
private void FitConfinerToGroundTilemaps(RoomCamera cam)
{
PolygonCollider2D poly = cam.GetComponentInChildren<PolygonCollider2D>(true);
if (poly == null)
{
Debug.LogWarning($"[RoomCameraSetupTool] {cam.name}:子节点中未找到 PolygonCollider2D,无法适配。");
return;
}
int groundLayer = LayerMask.NameToLayer("Ground");
var tilemaps = FindObjectsOfType<Tilemap>();
Bounds? combined = null;
foreach (var tm in tilemaps)
{
if (tm.gameObject.layer != groundLayer) continue;
tm.CompressBounds();
Bounds worldBounds = TransformBounds(tm.transform, tm.localBounds);
combined = combined.HasValue ? Combine(combined.Value, worldBounds) : worldBounds;
}
if (!combined.HasValue)
{
Debug.LogWarning("[RoomCameraSetupTool] 场景中未找到 Ground 层 Tilemap,无法自动适配。");
return;
}
Bounds b = combined.Value;
// Convert to local space of PolygonCollider2D's transform
Transform polyT = poly.transform;
Vector2 LocalPt(Vector3 world) => polyT.InverseTransformPoint(world);
Undo.RecordObject(poly, "Fit Confiner to Tilemap Bounds");
poly.SetPath(0, new Vector2[]
{
LocalPt(new Vector3(b.min.x, b.min.y)),
LocalPt(new Vector3(b.min.x, b.max.y)),
LocalPt(new Vector3(b.max.x, b.max.y)),
LocalPt(new Vector3(b.max.x, b.min.y)),
});
EditorUtility.SetDirty(poly);
Debug.Log($"[RoomCameraSetupTool] {cam.name}RoomBoundary 已适配至 Ground Tilemap 合并范围 " +
$"({b.min.x:F1},{b.min.y:F1}) ~ ({b.max.x:F1},{b.max.y:F1})。");
}
// ══ 工具方法 ═══════════════════════════════════════════════════════════
private static Bounds TransformBounds(Transform t, Bounds localBounds)
{
Bounds world = new Bounds(t.TransformPoint(localBounds.center), Vector3.zero);
// 变换 8 个角点取包围
foreach (Vector3 corner in new[]
{
localBounds.min,
localBounds.max,
new Vector3(localBounds.min.x, localBounds.max.y, 0f),
new Vector3(localBounds.max.x, localBounds.min.y, 0f),
})
world.Encapsulate(t.TransformPoint(corner));
return world;
}
private static Bounds Combine(Bounds a, Bounds b)
{
a.Encapsulate(b.min);
a.Encapsulate(b.max);
return a;
}
// ══ GUI 辅助 ═══════════════════════════════════════════════════════════
private void EnsureStyles()
{
if (_boxStyle == null)
{
_boxStyle = new GUIStyle(GUI.skin.box)
{
padding = new RectOffset(6, 6, 4, 4),
};
}
}
private static void DrawSectionHeader(string title)
{
EditorGUILayout.Space(4f);
using (new EditorGUILayout.HorizontalScope())
{
GUILayout.Label(title, EditorStyles.boldLabel);
}
Rect r = EditorGUILayout.GetControlRect(false, 1f);
EditorGUI.DrawRect(r, new Color(0.4f, 0.4f, 0.4f, 1f));
EditorGUILayout.Space(2f);
}
private static void DrawFieldCheck(SerializedObject so, string propName, string displayName, bool optional = false)
{
var prop = so.FindProperty(propName);
bool ok = prop != null && prop.objectReferenceValue != null;
DrawCheckRow(displayName, ok, optional);
}
private static void DrawCheckRow(string label, bool ok, bool optional = false)
{
using (new EditorGUILayout.HorizontalScope())
{
Color prev = GUI.color;
GUI.color = ok ? new Color(0.4f, 1f, 0.4f) : (optional ? new Color(0.8f, 0.8f, 0.4f) : new Color(1f, 0.4f, 0.4f));
GUILayout.Label(ok ? "●" : (optional ? "◌" : "✗"), GUILayout.Width(16f));
GUI.color = prev;
EditorGUILayout.LabelField(label);
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: fd2d2ca2985a57d4ea78c6c509b0dec1
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: a884190f06d571d47b05f7693fab90e2
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,157 @@
using System;
using UnityEditor;
using UnityEngine;
using BaseGames.Combat;
namespace BaseGames.Editor.Combat
{
/// <summary>
/// DamageSourceSO 增强 InspectorW-09)。
/// 在默认字段下方追加:
/// ① 伤害预览(BaseDamage × DamageMultiplier
/// ② BreakLevel 颜色标签
/// ③ DamageFlags / DamageTags 逐位 Toggle 组(可编辑,替代默认位掩码数字)
/// </summary>
[CustomEditor(typeof(DamageSourceSO))]
public class DamageSourceInspector : UnityEditor.Editor
{
private static readonly Color[] _breakColors =
{
new Color(0.55f, 0.55f, 0.55f), // None — 灰
new Color(0.25f, 0.80f, 0.25f), // Light — 绿
new Color(0.25f, 0.55f, 0.95f), // Medium — 蓝
new Color(1.00f, 0.60f, 0.10f), // Heavy — 橙
new Color(0.90f, 0.15f, 0.15f), // Breaker — 红
};
private static readonly string[] _breakLabels = { "None", "Light", "Medium", "Heavy", "Breaker" };
// ── DamageFlags 枚举值(排除 None=0
private static readonly DamageFlags[] _allFlags =
(DamageFlags[])Enum.GetValues(typeof(DamageFlags));
// ── DamageTags 枚举值(排除 None=0
private static readonly DamageTags[] _allTags =
(DamageTags[])Enum.GetValues(typeof(DamageTags));
public override void OnInspectorGUI()
{
DrawDefaultInspector();
var src = (DamageSourceSO)target;
EditorGUILayout.Space(10);
// ── ① 伤害预览 ────────────────────────────────────────────────────
EditorGUILayout.LabelField("── 伤害预览", EditorStyles.boldLabel);
using (new EditorGUI.DisabledGroupScope(true))
{
int calculated = Mathf.RoundToInt(src.BaseDamage * src.DamageMultiplier);
EditorGUILayout.IntField(
new GUIContent("计算伤害 (Base × Multi)", "= BaseDamage × DamageMultiplier,四舍五入"),
calculated);
}
// ── ② BreakLevel 颜色标签 ─────────────────────────────────────────
int idx = Mathf.Clamp((int)src.BreakLevel, 0, _breakColors.Length - 1);
EditorGUILayout.BeginHorizontal();
EditorGUILayout.PrefixLabel(new GUIContent("破霸体等级(颜色预览)"));
var colorStyle = new GUIStyle(EditorStyles.helpBox)
{
alignment = TextAnchor.MiddleCenter,
fontStyle = FontStyle.Bold,
normal = { textColor = Color.white },
};
Color prev = GUI.backgroundColor;
GUI.backgroundColor = _breakColors[idx];
GUILayout.Box(_breakLabels[idx], colorStyle, GUILayout.Width(72), GUILayout.Height(18));
GUI.backgroundColor = prev;
EditorGUILayout.EndHorizontal();
EditorGUILayout.Space(8);
// ── ③ DamageFlags Toggle 组 ───────────────────────────────────────
EditorGUILayout.LabelField("── DamageFlags(位标志,可直接勾选)", EditorStyles.boldLabel);
var flagsProp = serializedObject.FindProperty("Flags");
serializedObject.Update();
int flagsVal = flagsProp.intValue;
bool flagsChanged = false;
var validFlags = System.Array.FindAll(_allFlags, f => f != DamageFlags.None);
EditorGUILayout.BeginHorizontal();
int col = 0;
for (int fi = 0; fi < validFlags.Length; fi++)
{
var flag = validFlags[fi];
bool wasSet = (flagsVal & (int)flag) != 0;
bool nowSet = GUILayout.Toggle(wasSet, flag.ToString(),
GUI.skin.button, GUILayout.Height(18), GUILayout.MinWidth(90));
if (nowSet != wasSet)
{
flagsVal = nowSet ? flagsVal | (int)flag : flagsVal & ~(int)flag;
flagsChanged = true;
}
col++;
if (col % 4 == 0 && fi < validFlags.Length - 1)
{
EditorGUILayout.EndHorizontal();
EditorGUILayout.BeginHorizontal();
}
}
EditorGUILayout.EndHorizontal();
if (flagsChanged)
{
flagsProp.intValue = flagsVal;
serializedObject.ApplyModifiedProperties();
}
// 不安全 Flags 警告
if ((src.Flags & DamageFlags.Unblockable) != 0)
EditorGUILayout.HelpBox("Unblockable:玩家无法格挡此伤害,请确认设计意图。", MessageType.Warning);
EditorGUILayout.Space(6);
// ── ④ DamageTags Toggle 组 ────────────────────────────────────────
EditorGUILayout.LabelField("── DamageTags(交互标签,可直接勾选)", EditorStyles.boldLabel);
var tagsProp = serializedObject.FindProperty("Tags");
serializedObject.Update();
uint tagsVal = (uint)tagsProp.longValue;
bool tagsChanged = false;
var validTags = System.Array.FindAll(_allTags, t => t != DamageTags.None);
EditorGUILayout.BeginHorizontal();
col = 0;
for (int ti = 0; ti < validTags.Length; ti++)
{
var tag = validTags[ti];
bool wasSet = (tagsVal & (uint)tag) != 0;
bool nowSet = GUILayout.Toggle(wasSet, tag.ToString(),
GUI.skin.button, GUILayout.Height(18), GUILayout.MinWidth(90));
if (nowSet != wasSet)
{
tagsVal = nowSet ? tagsVal | (uint)tag : tagsVal & ~(uint)tag;
tagsChanged = true;
}
col++;
if (col % 4 == 0 && ti < validTags.Length - 1)
{
EditorGUILayout.EndHorizontal();
EditorGUILayout.BeginHorizontal();
}
}
EditorGUILayout.EndHorizontal();
if (tagsChanged)
{
tagsProp.longValue = tagsVal;
serializedObject.ApplyModifiedProperties();
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 2562d64f9a5ca764981b69d3c62d1c17
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,64 @@
#if UNITY_EDITOR
using UnityEditor;
using UnityEngine;
using BaseGames.Combat;
namespace BaseGames.Editor
{
/// <summary>
/// HurtBox 运行时注入状态可视化面板。
/// 通过 HurtBox 上的 Editor* 属性读取注入状态,以颜色区分是否注入成功。
/// 绿色 = 注入完成;橙色 = 未注入(该能力静默不生效);灰色 = 非 PlayMode。
/// </summary>
[CustomEditor(typeof(HurtBox))]
public class HurtBoxEditor : UnityEditor.Editor
{
// (属性访问器, 标签, 缺席说明)
private static readonly (System.Func<HurtBox, object> getter, string label, string absentNote)[] _fields =
{
(hb => hb.EditorOwner, "Owner (IDamageable)", "— 注入失败,ReceiveDamage 将无效"),
(hb => hb.EditorShieldable, "Shieldable", "— 未注入(玩家专属,敌人无需)"),
(hb => hb.EditorParrySystem, "ParrySystem", "— 未注入(弹反静默不生效)"),
(hb => hb.EditorPoiseSource, "PoiseSource", "— 未注入(霸体静默不生效)"),
(hb => hb.EditorStatusEffectable, "StatusEffectable", "— 未注入(状态效果静默不生效)"),
};
public override void OnInspectorGUI()
{
DrawDefaultInspector();
EditorGUILayout.Space(4);
EditorGUILayout.LabelField("── 运行时注入状态 ──", EditorStyles.boldLabel);
if (!Application.isPlaying)
{
EditorGUILayout.HelpBox("进入 PlayMode 后查看注入状态。", MessageType.Info);
return;
}
var hurtBox = (HurtBox)target;
foreach (var (getter, label, absentNote) in _fields)
{
var value = getter(hurtBox);
bool present = value != null;
var savedColor = GUI.contentColor;
GUI.contentColor = present
? new Color(0.3f, 0.9f, 0.4f) // 绿
: new Color(1.0f, 0.6f, 0.1f); // 橙
string displayValue = present
? $"✓ {value.GetType().Name}"
: $"✗ null {absentNote}";
EditorGUILayout.LabelField(label, displayValue);
GUI.contentColor = savedColor;
}
// 持续刷新(避免只显示初始状态)
if (Application.isPlaying) Repaint();
}
}
}
#endif
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8650ccc7960fe304a95be1c629ef7b1e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,339 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Animancer;
using UnityEditor;
using UnityEditor.UIElements;
using UnityEngine;
using UnityEngine.UIElements;
using BaseGames.Combat;
using BaseGames.Player;
namespace BaseGames.Editor.Combat
{
/// <summary>
/// 武器数据管理窗口(W-02)。
/// 技术:UI Toolkit TwoPaneSplitView。
/// 菜单:BaseGames / Data / Weapon Editor
///
/// 左栏:可搜索的 WeaponSO 列表 + [新建] 按钮。
/// 右栏:选中武器的完整属性编辑 + HitBox Prefab 结构校验 + 快速操作。
/// </summary>
public class WeaponEditorWindow : EditorWindow
{
private static readonly StyleSheet _sharedUSS;
static WeaponEditorWindow()
{
_sharedUSS = AssetDatabase.LoadAssetAtPath<StyleSheet>(
"Assets/_Game/Scripts/Editor/UIToolkit/Editor.uss");
}
[MenuItem("BaseGames/Data/Weapon Editor", priority = 100)]
public static void Open()
{
var wnd = GetWindow<WeaponEditorWindow>();
wnd.titleContent = new GUIContent("Weapon Editor");
wnd.minSize = new Vector2(680, 400);
}
// ── 状态 ─────────────────────────────────────────────────────────────
private List<WeaponSO> _weapons = new();
private List<WeaponSO> _filtered = new();
private ListView _listView;
private VisualElement _detailRoot;
private string _searchText = "";
private InspectorElement _currentInspector;
// ── 生命周期 ──────────────────────────────────────────────────────────
public void CreateGUI()
{
if (_sharedUSS != null)
rootVisualElement.styleSheets.Add(_sharedUSS);
// Toolbar
var toolbar = new Toolbar();
var searchField = new ToolbarSearchField { style = { flexGrow = 1 } };
searchField.RegisterValueChangedCallback(e =>
{
_searchText = e.newValue;
RefreshFilter();
});
toolbar.Add(searchField);
var btnCreate = new ToolbarButton(CreateNewWeapon) { text = "+ 新建武器" };
toolbar.Add(btnCreate);
var btnRefresh = new ToolbarButton(RefreshAll) { text = "↺" };
btnRefresh.tooltip = "重新扫描 Project 中的 WeaponSO 资产";
toolbar.Add(btnRefresh);
rootVisualElement.Add(toolbar);
// Split view
var split = new TwoPaneSplitView(0, 220, TwoPaneSplitViewOrientation.Horizontal);
// ── 左栏 ──────────────────────────────────────────────────────
var leftPane = new VisualElement { style = { minWidth = 140 } };
_listView = new ListView
{
selectionType = SelectionType.Single,
fixedItemHeight = 22,
makeItem = MakeListItem,
bindItem = BindListItem,
style = { flexGrow = 1 },
};
_listView.selectionChanged += OnSelectionChanged;
leftPane.Add(_listView);
split.Add(leftPane);
// ── 右栏 ──────────────────────────────────────────────────────
_detailRoot = new ScrollView { style = { flexGrow = 1 } };
_detailRoot.AddToClassList("detail-panel");
split.Add(_detailRoot);
rootVisualElement.Add(split);
RefreshAll();
}
private void OnFocus() => RefreshAll();
// ── 列表构建 ──────────────────────────────────────────────────────────
private void RefreshAll()
{
_weapons = EditorScaffoldUtils.FindAllAssetsOfType<WeaponSO>();
_weapons.Sort((a, b) => string.Compare(
a.weaponId, b.weaponId, StringComparison.OrdinalIgnoreCase));
RefreshFilter();
}
private void RefreshFilter()
{
if (string.IsNullOrEmpty(_searchText))
{
_filtered = new List<WeaponSO>(_weapons);
}
else
{
string s = _searchText;
_filtered = _weapons.Where(w => w != null &&
(w.weaponId?.Contains(s, StringComparison.OrdinalIgnoreCase) == true ||
w.displayName?.Contains(s, StringComparison.OrdinalIgnoreCase) == true)).ToList();
}
_listView.itemsSource = _filtered;
_listView.Rebuild();
}
private static VisualElement MakeListItem()
{
var label = new Label();
label.AddToClassList("list-item");
return label;
}
private void BindListItem(VisualElement element, int index)
{
var label = (Label)element;
var weapon = _filtered.Count > index ? _filtered[index] : null;
if (weapon == null) { label.text = "(null)"; return; }
label.text = string.IsNullOrEmpty(weapon.displayName)
? weapon.weaponId
: $"{weapon.weaponId} <color=#888>({weapon.displayName})</color>";
}
// ── 详情面板 ──────────────────────────────────────────────────────────
private void OnSelectionChanged(IEnumerable<object> items)
{
_detailRoot.Clear();
_currentInspector = null;
var weapon = items.FirstOrDefault() as WeaponSO;
if (weapon == null) return;
// 标题
var title = new Label(
string.IsNullOrEmpty(weapon.displayName) ? weapon.weaponId : $"{weapon.weaponId} · {weapon.displayName}")
{
style =
{
fontSize = 14,
unityFontStyleAndWeight = FontStyle.Bold,
marginBottom = 6,
}
};
_detailRoot.Add(title);
// HitBox Prefab 状态
BuildHitBoxStatus(weapon);
// 连击链预览
BuildComboPreview(weapon);
// Inspector 完整属性编辑
_currentInspector = new InspectorElement(weapon);
_detailRoot.Add(_currentInspector);
// 操作按钮
var btnRow = new VisualElement();
btnRow.AddToClassList("action-buttons");
var btnSelect = new Button(() => EditorScaffoldUtils.PingAndSelect(weapon))
{ text = "在 Project 中定位" };
var btnInspector = new Button(() => Selection.activeObject = weapon)
{ text = "在 Inspector 中打开" };
var btnWizard = new Button(WeaponHitBoxWizard.Open)
{ text = "HitBox Prefab 向导…" };
btnRow.Add(btnSelect);
btnRow.Add(btnInspector);
btnRow.Add(btnWizard);
_detailRoot.Add(btnRow);
}
/// <summary>attack1 → attack2 → attack3 连击链数值横排预览。</summary>
private void BuildComboPreview(WeaponSO weapon)
{
// 只在有至少一个连击数据时显示
if (weapon.attack1Source == null && weapon.attack2Source == null && weapon.attack3Source == null)
return;
var section = new Label("连击链预览") { style = { unityFontStyleAndWeight = FontStyle.Bold, marginBottom = 4 } };
_detailRoot.Add(section);
var chain = new VisualElement();
chain.AddToClassList("stats-preview");
void AddSegment(string label, ClipTransition clip, DamageSourceSO src, bool addArrow)
{
var cell = new VisualElement
{
style =
{
alignItems = Align.Center,
marginRight = 4,
paddingLeft = 6,
paddingRight = 6,
paddingTop = 3,
paddingBottom = 3,
backgroundColor = new Color(0.25f, 0.25f, 0.28f, 1f),
borderTopLeftRadius = 3,
borderTopRightRadius = 3,
borderBottomLeftRadius = 3,
borderBottomRightRadius = 3,
}
};
// 段名
cell.Add(new Label(label)
{
style = { fontSize = 10, color = new Color(0.65f, 0.65f, 0.65f) }
});
// Clip 名称
string clipName = clip?.Clip != null ? clip.Clip.name : "<无动画>";
cell.Add(new Label(clipName)
{
style = { fontSize = 11, unityFontStyleAndWeight = FontStyle.Bold }
});
// 伤害数值
if (src != null)
{
int dmg = Mathf.RoundToInt(src.BaseDamage * src.DamageMultiplier);
cell.Add(new Label($"伤害 {dmg} [{src.BreakLevel}]")
{
style = { fontSize = 10, color = new Color(1f, 0.7f, 0.3f) }
});
}
else
{
cell.Add(new Label("(无 DamageSource)")
{
style = { fontSize = 10, color = new Color(0.8f, 0.3f, 0.3f) }
});
}
chain.Add(cell);
if (addArrow)
chain.Add(new Label("→") { style = { alignSelf = Align.Center, marginLeft = 2, marginRight = 2 } });
}
AddSegment("攻击1", weapon.attack1Clip, weapon.attack1Source, true);
AddSegment("攻击2", weapon.attack2Clip, weapon.attack2Source, true);
AddSegment("攻击3", weapon.attack3Clip, weapon.attack3Source, false);
_detailRoot.Add(chain);
// 追加空中/上/下攻击的简要行
var extraRow = new VisualElement
{
style = { flexDirection = FlexDirection.Row, flexWrap = Wrap.Wrap, marginBottom = 6, paddingLeft = 6 }
};
void ExtraStat(string label, DamageSourceSO src)
{
if (src == null) return;
int dmg = Mathf.RoundToInt(src.BaseDamage * src.DamageMultiplier);
extraRow.Add(new Label($"{label}{dmg} [{src.BreakLevel}]")
{
style = { marginRight = 14, fontSize = 11, color = new Color(0.7f, 0.7f, 0.7f) }
});
}
ExtraStat("空中", weapon.airAttackSource);
ExtraStat("上挑", weapon.upAttackSource);
ExtraStat("下砸", weapon.downAttackSource);
if (extraRow.childCount > 0)
_detailRoot.Add(extraRow);
}
private void BuildHitBoxStatus(WeaponSO weapon)
{
HelpBoxMessageType msgType;
string msg;
if (weapon.hitBoxPrefab == null)
{
msgType = HelpBoxMessageType.Warning;
msg = "hitBoxPrefab 未赋值!请创建并关联武器 HitBox Prefab。";
}
else if (weapon.hitBoxPrefab.GetComponent<WeaponHitBoxInstance>() == null)
{
msgType = HelpBoxMessageType.Error;
msg = $"hitBoxPrefab「{weapon.hitBoxPrefab.name}」缺少 WeaponHitBoxInstance 组件!";
}
else
{
msgType = HelpBoxMessageType.Info;
msg = $"HitBox Prefab 结构正常:{weapon.hitBoxPrefab.name}";
}
_detailRoot.Add(new HelpBox(msg, msgType) { style = { marginBottom = 6 } });
}
// ── 新建武器 ──────────────────────────────────────────────────────────
private void CreateNewWeapon()
{
var asset = EditorScaffoldUtils.CreateSOAsset<WeaponSO>(
"Assets/_Game/Data/Player/Weapons", "WeaponSO_New");
if (asset != null)
{
RefreshAll();
int idx = _filtered.IndexOf(asset);
if (idx >= 0)
_listView.SetSelection(idx);
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7cc9d2828e2d3f9458e74befbb0e2b4e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,181 @@
using System.IO;
using UnityEditor;
using UnityEngine;
using BaseGames.Combat;
using BaseGames.Player;
namespace BaseGames.Editor.Combat
{
/// <summary>
/// 向导:一键生成武器 HitBox PrefabW-10)。
/// 菜单:BaseGames / Create / Weapon HitBox Prefab
///
/// 生成路径规范:Assets/_Game/Prefabs/Weapons/WPN_{weaponId}_HitBox.prefab
/// Prefab 结构:
/// [WPN_{weaponId}_HitBox] ← WeaponHitBoxInstance
/// ├── [HitBox_Ground] ← Collider2D(IsTrigger, 形状可选) + HitBox, Layer=PlayerHitBox
/// ├── [HitBox_Up]
/// ├── [HitBox_Down]
/// └── [HitBox_Air]
/// </summary>
public class WeaponHitBoxWizard : ScriptableWizard
{
private const string OutputFolder = "Assets/_Game/Prefabs/Weapons";
/// <summary>每个方向可选的 Collider 形状。</summary>
public enum ColliderShape
{
[Tooltip("BoxCollider2D — 矩形,适合水平/垂直扫击")]
Box,
[Tooltip("CapsuleCollider2D — 胶囊体,适合刺击或弧形")]
Capsule,
[Tooltip("PolygonCollider2D(菱形默认点)— 适合不规则斩击")]
Polygon,
}
[MenuItem("BaseGames/Create/Weapon HitBox Prefab", priority = 200)]
public static void Open() =>
DisplayWizard<WeaponHitBoxWizard>("Weapon HitBox Prefab 向导", "创建");
[Tooltip("武器唯一 ID,如 SkyBlade。Prefab 将命名为 WPN_{weaponId}_HitBox")]
public string weaponId = "";
[Header("包含哪些攻击方向")]
public bool includeGround = true;
public bool includeUp = true;
public bool includeDown = true;
public bool includeAir = true;
[Header("每个方向的 Collider 形状")]
[Tooltip("Ground / 落地攻击的碰撞体形状")]
public ColliderShape groundShape = ColliderShape.Box;
[Tooltip("Up / 上挑攻击的碰撞体形状")]
public ColliderShape upShape = ColliderShape.Capsule;
[Tooltip("Down / 下砸攻击的碰撞体形状")]
public ColliderShape downShape = ColliderShape.Box;
[Tooltip("Air / 空中攻击的碰撞体形状")]
public ColliderShape airShape = ColliderShape.Capsule;
// ── 向导回调 ──────────────────────────────────────────────────────────
private void OnWizardUpdate()
{
isValid = !string.IsNullOrWhiteSpace(weaponId);
helpString = isValid
? $"将创建:{OutputFolder}/WPN_{weaponId}_HitBox.prefab"
: "请输入 weaponId(武器唯一 ID,如 SkyBlade)。";
}
private void OnWizardCreate()
{
if (string.IsNullOrWhiteSpace(weaponId))
{
EditorUtility.DisplayDialog("错误", "weaponId 不能为空。", "确认");
return;
}
string prefabName = $"WPN_{weaponId}_HitBox";
string assetPath = $"{OutputFolder}/{prefabName}.prefab";
string fullPath = Path.Combine(
Path.GetDirectoryName(Application.dataPath)!,
assetPath.Replace('/', Path.DirectorySeparatorChar));
if (File.Exists(fullPath))
{
if (!EditorUtility.DisplayDialog("已存在",
$"{assetPath}\n\n该 Prefab 已存在,是否覆盖?",
"覆盖", "取消"))
return;
}
EditorScaffoldUtils.EnsureFolder(OutputFolder);
int hitBoxLayer = LayerMask.NameToLayer("PlayerHitBox");
if (hitBoxLayer < 0)
{
Debug.LogWarning("[WeaponHitBoxWizard] 未找到 Physics Layer 'PlayerHitBox',子节点 Layer 将设为 Default。");
hitBoxLayer = 0;
}
// ── 构建 Prefab ────────────────────────────────────────────────
var root = new GameObject(prefabName);
var instance = root.AddComponent<WeaponHitBoxInstance>();
var so = new SerializedObject(instance);
void AddDirection(bool enabled, string nodeName, string fieldName, ColliderShape shape)
{
if (!enabled) return;
var child = new GameObject(nodeName);
child.transform.SetParent(root.transform, false);
child.layer = hitBoxLayer;
AddCollider(child, shape);
var hb = child.AddComponent<HitBox>();
var prop = so.FindProperty(fieldName);
if (prop != null)
prop.objectReferenceValue = hb;
}
AddDirection(includeGround, "HitBox_Ground", "_hitBoxGround", groundShape);
AddDirection(includeUp, "HitBox_Up", "_hitBoxUp", upShape);
AddDirection(includeDown, "HitBox_Down", "_hitBoxDown", downShape);
AddDirection(includeAir, "HitBox_Air", "_hitBoxAir", airShape);
so.ApplyModifiedPropertiesWithoutUndo();
var prefab = PrefabUtility.SaveAsPrefabAsset(root, assetPath);
Object.DestroyImmediate(root);
AssetDatabase.Refresh();
if (prefab != null)
{
EditorScaffoldUtils.PingAndSelect(prefab);
Debug.Log($"[WeaponHitBoxWizard] 已创建:{assetPath}");
}
else
{
Debug.LogError($"[WeaponHitBoxWizard] Prefab 保存失败:{assetPath}");
}
}
// ── 辅助:按形状添加 2D 碰撞体 ────────────────────────────────────────
private static void AddCollider(GameObject go, ColliderShape shape)
{
switch (shape)
{
case ColliderShape.Box:
{
var c = go.AddComponent<BoxCollider2D>();
c.isTrigger = true;
c.size = new Vector2(1f, 0.5f);
break;
}
case ColliderShape.Capsule:
{
var c = go.AddComponent<CapsuleCollider2D>();
c.isTrigger = true;
c.size = new Vector2(0.5f, 1f);
break;
}
case ColliderShape.Polygon:
{
var c = go.AddComponent<PolygonCollider2D>();
c.isTrigger = true;
// 默认菱形点(0.5 × 0.5 单位)
c.SetPath(0, new Vector2[]
{
new( 0f, 0.3f),
new( 0.5f, 0f ),
new( 0f, -0.3f),
new(-0.5f, 0f ),
});
break;
}
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 85778ca4e33d8d441abe05d60450d1ab
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 15e3832e24dc8f342b021cd24fa4b06c
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,306 @@
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;
using BaseGames.Boss;
namespace BaseGames.Editor
{
/// <summary>
/// Boss 技能序列甘特图可视化窗口(架构 23_BossSkillModule §12)。
/// 菜单:BaseGames/Tools/Boss Skill Sequence Viewer
///
/// 功能:
/// - 拖放 BossSkillSO 或 SkillSequenceSO 资产加载
/// - 甘特图:Windup(黄色)→ Active(红色)→ Recovery(灰色)各阶段时序条
/// - VulnerabilityWindow 绿色覆盖层(TriggerDelay 偏移 + Duration 宽度)
/// - DurationNormalized &lt; 0.1 时阶段条变红警告
/// - 点击阶段条高亮对应 AttackPatternSOEditorGUIUtility.PingObject
/// </summary>
public class BossSkillSequenceWindow : EditorWindow
{
// ── State ──────────────────────────────────────────────────────────
private BossSkillSO _loadedSkill;
private SkillSequenceSO _loadedSequence;
private Vector2 _scrollPos;
// ── Layout ─────────────────────────────────────────────────────────
private const float HeaderH = 24f;
private const float RowH = 28f;
private const float LabelW = 180f;
private const float MinBarWidth = 6f;
// 时间轴宽度随窗口宽度动态调整,最小 300px
private float TimelineW => Mathf.Max(300f, position.width - LabelW - 30f);
// ── Colors ─────────────────────────────────────────────────────────
private static readonly Color ColWindup = new Color(0.95f, 0.80f, 0.10f, 0.85f);
private static readonly Color ColActive = new Color(0.90f, 0.20f, 0.15f, 0.85f);
private static readonly Color ColRecovery = new Color(0.50f, 0.50f, 0.55f, 0.70f);
private static readonly Color ColVuln = new Color(0.10f, 0.90f, 0.30f, 0.45f);
private static readonly Color ColDelay = new Color(0.25f, 0.25f, 0.30f, 0.50f);
private static readonly Color ColWarn = new Color(0.95f, 0.10f, 0.10f, 0.85f);
[MenuItem("BaseGames/Tools/Boss Skill Sequence Viewer")]
public static void OpenWindow()
{
var win = GetWindow<BossSkillSequenceWindow>("Boss Skill Sequence");
win.minSize = new Vector2(900, 400);
win.Show();
}
// ── GUI ────────────────────────────────────────────────────────────
private void OnGUI()
{
DrawToolbar();
if (_loadedSkill == null && _loadedSequence == null)
{
EditorGUILayout.HelpBox(
"将 BossSkillSO 或 SkillSequenceSO 资产拖放到此处,或使用上方字段加载。",
MessageType.Info);
HandleDragDrop();
return;
}
_scrollPos = EditorGUILayout.BeginScrollView(_scrollPos);
if (_loadedSkill != null)
DrawSkillTimeline(_loadedSkill);
else if (_loadedSequence != null)
DrawSequenceTimeline(_loadedSequence);
EditorGUILayout.EndScrollView();
}
// ── Toolbar ───────────────────────────────────────────────────────
private void DrawToolbar()
{
EditorGUILayout.BeginHorizontal(EditorStyles.toolbar);
EditorGUILayout.LabelField("技能:", GUILayout.Width(36));
var newSkill = (BossSkillSO)EditorGUILayout.ObjectField(
_loadedSkill, typeof(BossSkillSO), false, GUILayout.Width(200));
if (newSkill != _loadedSkill)
{
_loadedSkill = newSkill;
_loadedSequence = null;
}
GUILayout.Space(12);
EditorGUILayout.LabelField("序列:", GUILayout.Width(36));
var newSeq = (SkillSequenceSO)EditorGUILayout.ObjectField(
_loadedSequence, typeof(SkillSequenceSO), false, GUILayout.Width(200));
if (newSeq != _loadedSequence)
{
_loadedSequence = newSeq;
_loadedSkill = null;
}
GUILayout.FlexibleSpace();
if (GUILayout.Button("清除", EditorStyles.toolbarButton, GUILayout.Width(50)))
{
_loadedSkill = null;
_loadedSequence = null;
}
EditorGUILayout.EndHorizontal();
}
// ── BossSkillSO 时间轴 ────────────────────────────────────────────
private void DrawSkillTimeline(BossSkillSO skill)
{
EditorGUILayout.LabelField($"技能:{skill.displayName} [{skill.skillId}]",
EditorStyles.boldLabel);
EditorGUILayout.Space(4);
if (skill.attackPatterns == null || skill.attackPatterns.Length == 0)
{
EditorGUILayout.HelpBox("此技能没有 AttackPattern。", MessageType.Warning);
return;
}
// 计算总时长
float totalDuration = 0f;
foreach (var p in skill.attackPatterns)
if (p != null) totalDuration += p.WindupDuration + p.ActiveDuration + p.RecoveryDuration;
if (totalDuration <= 0f) totalDuration = 1f;
DrawTimelineHeader(totalDuration);
float cursor = 0f;
for (int i = 0; i < skill.attackPatterns.Length; i++)
{
var pattern = skill.attackPatterns[i];
if (pattern == null) continue;
DrawPatternRow($"[{i}] {pattern.name}", pattern, ref cursor, totalDuration);
}
// 绘制 VulnerabilityWindows
if (skill.vulnerabilityWindows != null && skill.vulnerabilityWindows.Length > 0)
{
EditorGUILayout.Space(4);
EditorGUILayout.LabelField("弱点窗口(Vulnerability Windows", EditorStyles.miniBoldLabel);
foreach (var vw in skill.vulnerabilityWindows)
DrawVulnWindowRow(vw, totalDuration);
}
}
// ── SkillSequenceSO 时间轴 ────────────────────────────────────────
private void DrawSequenceTimeline(SkillSequenceSO sequence)
{
EditorGUILayout.LabelField($"序列:{sequence.name}", EditorStyles.boldLabel);
EditorGUILayout.Space(4);
if (sequence.steps == null || sequence.steps.Length == 0)
{
EditorGUILayout.HelpBox("此序列没有步骤。", MessageType.Warning);
return;
}
// 计算总时长
float totalDuration = 0f;
foreach (var step in sequence.steps)
{
totalDuration += step.delayBeforeStep;
if (step.pattern != null)
totalDuration += step.pattern.WindupDuration + step.pattern.ActiveDuration + step.pattern.RecoveryDuration;
}
if (totalDuration <= 0f) totalDuration = 1f;
DrawTimelineHeader(totalDuration);
float cursor = 0f;
for (int i = 0; i < sequence.steps.Length; i++)
{
var step = sequence.steps[i];
// 延迟条
if (step.delayBeforeStep > 0f)
{
DrawBar($"延迟 {step.delayBeforeStep:F2}s", cursor, step.delayBeforeStep,
totalDuration, ColDelay, null);
cursor += step.delayBeforeStep;
}
if (step.pattern != null)
DrawPatternRow($"[{i}] {step.pattern.name}", step.pattern, ref cursor, totalDuration);
}
}
// ── 共用绘制方法 ──────────────────────────────────────────────────
private void DrawTimelineHeader(float totalDuration)
{
Rect headerRect = EditorGUILayout.GetControlRect(false, HeaderH);
headerRect.x += LabelW;
headerRect.width -= LabelW;
EditorGUI.DrawRect(headerRect, new Color(0.18f, 0.18f, 0.20f));
// 刻度线(每 0.5s 一条)
float step = 0.5f;
for (float t = 0; t <= totalDuration + 0.001f; t += step)
{
float x = headerRect.x + (t / totalDuration) * headerRect.width;
EditorGUI.DrawRect(new Rect(x, headerRect.y, 1f, HeaderH * 0.6f), Color.gray);
EditorGUI.LabelField(new Rect(x + 2f, headerRect.y, 40f, HeaderH),
$"{t:F1}s", new GUIStyle(EditorStyles.miniLabel) { normal = { textColor = Color.gray } });
}
}
private void DrawPatternRow(string label, AttackPatternSO pattern, ref float cursor, float totalDuration)
{
float windupDur = pattern.WindupDuration;
float activeDur = pattern.ActiveDuration;
float recoveryDur = pattern.RecoveryDuration;
float rowStart = cursor;
EditorGUILayout.BeginHorizontal(GUILayout.Height(RowH));
// 标签 + Ping
if (GUILayout.Button(label, EditorStyles.miniLabel, GUILayout.Width(LabelW), GUILayout.Height(RowH)))
EditorGUIUtility.PingObject(pattern);
Rect timelineRect = EditorGUILayout.GetControlRect(false, RowH,
GUILayout.Width(TimelineW));
// Windup
if (windupDur > 0f)
DrawBarInRect(timelineRect, cursor, windupDur, totalDuration,
windupDur / (windupDur + activeDur + recoveryDur) < 0.1f ? ColWarn : ColWindup);
cursor += windupDur;
// Active
if (activeDur > 0f)
DrawBarInRect(timelineRect, cursor, activeDur, totalDuration,
activeDur / (windupDur + activeDur + recoveryDur) < 0.1f ? ColWarn : ColActive);
cursor += activeDur;
// Recovery
if (recoveryDur > 0f)
DrawBarInRect(timelineRect, cursor, recoveryDur, totalDuration,
recoveryDur / (windupDur + activeDur + recoveryDur) < 0.1f ? ColWarn : ColRecovery);
cursor += recoveryDur;
_ = rowStart; // suppress unused warning
EditorGUILayout.EndHorizontal();
}
private void DrawVulnWindowRow(VulnerabilityWindow vw, float totalDuration)
{
string label = $"弱点:{vw.TriggerType} +{vw.TriggerDelay:F2}s / {vw.Duration:F2}s";
DrawBar(label, vw.TriggerDelay, vw.Duration, totalDuration, ColVuln, null);
}
private void DrawBar(string label, float start, float duration, float totalDuration,
Color color, AttackPatternSO pingTarget)
{
EditorGUILayout.BeginHorizontal(GUILayout.Height(RowH));
if (GUILayout.Button(label, EditorStyles.miniLabel, GUILayout.Width(LabelW), GUILayout.Height(RowH)))
{
if (pingTarget != null) EditorGUIUtility.PingObject(pingTarget);
}
Rect timelineRect = EditorGUILayout.GetControlRect(false, RowH, GUILayout.Width(TimelineW));
DrawBarInRect(timelineRect, start, duration, totalDuration, color);
EditorGUILayout.EndHorizontal();
}
private static void DrawBarInRect(Rect timeline, float start, float duration,
float totalDuration, Color color)
{
float x = timeline.x + (start / totalDuration) * timeline.width;
float w = Mathf.Max(MinBarWidth, (duration / totalDuration) * timeline.width);
EditorGUI.DrawRect(new Rect(x, timeline.y + 2f, w, timeline.height - 4f), color);
}
// ── Drag & Drop ───────────────────────────────────────────────────
private void HandleDragDrop()
{
var evt = Event.current;
if (evt.type != EventType.DragUpdated && evt.type != EventType.DragPerform) return;
DragAndDrop.visualMode = DragAndDropVisualMode.Copy;
if (evt.type == EventType.DragPerform)
{
DragAndDrop.AcceptDrag();
foreach (var obj in DragAndDrop.objectReferences)
{
if (obj is BossSkillSO skill) { _loadedSkill = skill; _loadedSequence = null; break; }
if (obj is SkillSequenceSO seq) { _loadedSequence = seq; _loadedSkill = null; break; }
}
Repaint();
}
evt.Use();
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d47145d394333184eb3ff822e3c4aa4d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,347 @@
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEditor;
using UnityEditor.UIElements;
using UnityEngine;
using UnityEngine.UIElements;
using BaseGames.Enemies;
namespace BaseGames.Editor.Enemies
{
/// <summary>
/// 敌人数据管理窗口(W-05)。
/// 技术:UI Toolkit TwoPaneSplitView + 手动标签页。
/// 菜单:BaseGames / Data / Enemy Data Manager
///
/// 左栏:可搜索的 EnemyStatsSO 列表 + [新建] 按钮。
/// 右栏两个标签页:
/// Stats — EnemyStatsSO 完整属性编辑
/// Loot — LootTableSO 浏览与编辑
/// </summary>
public class EnemyDataWindow : EditorWindow
{
private static readonly StyleSheet _sharedUSS;
static EnemyDataWindow()
{
_sharedUSS = AssetDatabase.LoadAssetAtPath<StyleSheet>(
"Assets/_Game/Scripts/Editor/UIToolkit/Editor.uss");
}
[MenuItem("BaseGames/Data/Enemy Data Manager", priority = 102)]
public static void Open()
{
var wnd = GetWindow<EnemyDataWindow>();
wnd.titleContent = new GUIContent("Enemy Data Manager");
wnd.minSize = new Vector2(720, 420);
}
// ── 状态 ─────────────────────────────────────────────────────────────
private List<EnemyStatsSO> _enemies = new();
private List<EnemyStatsSO> _filtered = new();
private List<LootTableSO> _lootTables = new();
private List<LootTableSO> _lootFiltered = new();
private ListView _enemyList;
private ListView _lootList;
private VisualElement _detailRoot; // Stats 标签页 Loot 详情区
private ScrollView _lootDetailRoot; // Loot 标签页 LootTable 详情区
private VisualElement _tabStats;
private VisualElement _tabLoot;
private Button _btnStats;
private Button _btnLoot;
private string _searchText = "";
private string _lootSearchText = "";
private int _activeTab = 0; // 0=Stats, 1=Loot
private InspectorElement _statsInspector;
private InspectorElement _lootInspector;
// ── 生命周期 ──────────────────────────────────────────────────────────
public void CreateGUI()
{
if (_sharedUSS != null)
rootVisualElement.styleSheets.Add(_sharedUSS);
// Toolbar
var toolbar = new Toolbar();
var searchField = new ToolbarSearchField { style = { flexGrow = 1 } };
searchField.RegisterValueChangedCallback(e => { _searchText = e.newValue; RefreshEnemyFilter(); });
searchField.tooltip = "按名称 / ID 过滤 EnemyStatsSO 列表";
toolbar.Add(searchField);
var btnCreate = new ToolbarButton(CreateNewEnemyStats) { text = "+ 新建敌人" };
var btnRefresh = new ToolbarButton(RefreshAll) { text = "↺" };
btnRefresh.tooltip = "重新扫描 Project 中的资产";
toolbar.Add(btnCreate);
toolbar.Add(btnRefresh);
rootVisualElement.Add(toolbar);
// Split view
var split = new TwoPaneSplitView(0, 230, TwoPaneSplitViewOrientation.Horizontal);
// ── 左栏:敌人列表 ────────────────────────────────────────────
var leftPane = new VisualElement { style = { minWidth = 150 } };
_enemyList = new ListView
{
selectionType = SelectionType.Single,
fixedItemHeight = 22,
makeItem = MakeEnemyItem,
bindItem = BindEnemyItem,
style = { flexGrow = 1 },
};
_enemyList.selectionChanged += OnEnemySelected;
leftPane.Add(_enemyList);
split.Add(leftPane);
// ── 右栏:标签页 + 内容 ───────────────────────────────────────
var rightPane = new VisualElement { style = { flexGrow = 1 } };
// 标签页按钮栏
var tabBar = new VisualElement();
tabBar.AddToClassList("tab-bar");
_btnStats = new Button(() => ActivateTab(0)) { text = "Stats" };
_btnLoot = new Button(() => ActivateTab(1)) { text = "Loot Table" };
_btnStats.AddToClassList("tab-button");
_btnLoot.AddToClassList("tab-button");
tabBar.Add(_btnStats);
tabBar.Add(_btnLoot);
rightPane.Add(tabBar);
// Stats 面板
_tabStats = new ScrollView { style = { flexGrow = 1 } };
_tabStats.AddToClassList("detail-panel");
rightPane.Add(_tabStats);
// Loot 面板(初始隐藏)
_tabLoot = BuildLootPanel();
_tabLoot.style.display = DisplayStyle.None;
rightPane.Add(_tabLoot);
split.Add(rightPane);
rootVisualElement.Add(split);
ActivateTab(0);
RefreshAll();
}
private void OnFocus() => RefreshAll();
// ── 标签页切换 ────────────────────────────────────────────────────────
private void ActivateTab(int tab)
{
_activeTab = tab;
_tabStats.style.display = tab == 0 ? DisplayStyle.Flex : DisplayStyle.None;
_tabLoot.style.display = tab == 1 ? DisplayStyle.Flex : DisplayStyle.None;
_btnStats.EnableInClassList("tab-button--active", tab == 0);
_btnLoot.EnableInClassList("tab-button--active", tab == 1);
}
// ── 敌人列表 ──────────────────────────────────────────────────────────
private void RefreshAll()
{
_enemies = EditorScaffoldUtils.FindAllAssetsOfType<EnemyStatsSO>();
_enemies.Sort((a, b) => string.Compare(a.name, b.name, StringComparison.OrdinalIgnoreCase));
_lootTables = EditorScaffoldUtils.FindAllAssetsOfType<LootTableSO>();
_lootTables.Sort((a, b) => string.Compare(a.name, b.name, StringComparison.OrdinalIgnoreCase));
RefreshEnemyFilter();
RefreshLootFilter();
}
private void RefreshEnemyFilter()
{
_filtered = string.IsNullOrEmpty(_searchText)
? new List<EnemyStatsSO>(_enemies)
: _enemies.Where(e => e != null &&
e.name.Contains(_searchText, StringComparison.OrdinalIgnoreCase)).ToList();
_enemyList.itemsSource = _filtered;
_enemyList.Rebuild();
}
private static VisualElement MakeEnemyItem()
{
var label = new Label();
label.AddToClassList("list-item");
return label;
}
private void BindEnemyItem(VisualElement element, int index)
{
var label = (Label)element;
var enemy = _filtered.Count > index ? _filtered[index] : null;
label.text = enemy != null ? enemy.name : "(null)";
}
private void OnEnemySelected(IEnumerable<object> items)
{
_tabStats.Clear();
_statsInspector = null;
var enemy = items.FirstOrDefault() as EnemyStatsSO;
if (enemy == null) return;
// 数值快览条
BuildStatsPreview(enemy);
// 完整属性编辑
_statsInspector = new InspectorElement(enemy);
_tabStats.Add(_statsInspector);
// 操作按钮
var btnRow = new VisualElement();
btnRow.AddToClassList("action-buttons");
btnRow.Add(new Button(() => EditorScaffoldUtils.PingAndSelect(enemy)) { text = "在 Project 中定位" });
btnRow.Add(new Button(() => Selection.activeObject = enemy) { text = "在 Inspector 中打开" });
btnRow.Add(new Button(() => CloneEnemy(enemy)) { text = "克隆为变体…" });
_tabStats.Add(btnRow);
}
private void BuildStatsPreview(EnemyStatsSO e)
{
var row = new VisualElement();
row.AddToClassList("stats-preview");
void Stat(string label, string val)
{
row.Add(new Label(label) { style = { color = new Color(0.65f, 0.65f, 0.65f), marginRight = 3 } });
row.Add(new Label(val) { style = { marginRight = 14, unityFontStyleAndWeight = FontStyle.Bold } });
}
Stat("HP", $"{e.MaxHP}");
Stat("DEF", $"{e.Defense}");
Stat("ATK", $"{e.AttackDamage}");
Stat("SPD", $"{e.WalkSpeed}/{e.RunSpeed}");
Stat("范围:", $"{e.AttackRange:F1}");
Stat("视野:", $"{e.DetectRange:F1}");
_tabStats.Add(row);
}
private void CloneEnemy(EnemyStatsSO source)
{
string name = source.name;
string clone = EditorUtility.SaveFilePanelInProject(
"克隆敌人配置", $"{name}_Clone", "asset",
"选择克隆 EnemyStatsSO 的保存路径");
if (string.IsNullOrEmpty(clone)) return;
var asset = Instantiate(source);
AssetDatabase.CreateAsset(asset, clone);
AssetDatabase.SaveAssets();
EditorScaffoldUtils.PingAndSelect(asset);
RefreshAll();
}
// ── Loot Table 面板 ───────────────────────────────────────────────────
private VisualElement BuildLootPanel()
{
var container = new VisualElement { style = { flexGrow = 1 } };
// Loot 搜索栏
var lootToolbar = new Toolbar();
var lootSearch = new ToolbarSearchField { style = { flexGrow = 1 } };
lootSearch.RegisterValueChangedCallback(e => { _lootSearchText = e.newValue; RefreshLootFilter(); });
lootSearch.tooltip = "过滤 LootTableSO 列表";
lootToolbar.Add(lootSearch);
var btnCreateLoot = new ToolbarButton(CreateNewLootTable) { text = "+ 新建 LootTable" };
lootToolbar.Add(btnCreateLoot);
container.Add(lootToolbar);
// 左右分割:Loot 列表 + Loot 详情
var lootSplit = new TwoPaneSplitView(0, 200, TwoPaneSplitViewOrientation.Horizontal);
var lootLeft = new VisualElement { style = { minWidth = 120 } };
_lootList = new ListView
{
selectionType = SelectionType.Single,
fixedItemHeight = 22,
makeItem = () => { var l = new Label(); l.AddToClassList("list-item"); return l; },
bindItem = (el, idx) =>
{
var lbl = (Label)el;
var loot = _lootFiltered.Count > idx ? _lootFiltered[idx] : null;
lbl.text = loot?.name ?? "(null)";
},
style = { flexGrow = 1 },
};
_lootList.selectionChanged += OnLootSelected;
lootLeft.Add(_lootList);
lootSplit.Add(lootLeft);
_lootDetailRoot = new ScrollView { style = { flexGrow = 1 } };
_lootDetailRoot.AddToClassList("detail-panel");
lootSplit.Add(_lootDetailRoot);
container.Add(lootSplit);
return container;
}
private void RefreshLootFilter()
{
_lootFiltered = string.IsNullOrEmpty(_lootSearchText)
? new List<LootTableSO>(_lootTables)
: _lootTables.Where(l => l != null &&
l.name.Contains(_lootSearchText, StringComparison.OrdinalIgnoreCase)).ToList();
_lootList.itemsSource = _lootFiltered;
_lootList.Rebuild();
}
private void OnLootSelected(IEnumerable<object> items)
{
_lootDetailRoot.Clear();
_lootInspector = null;
var loot = items.FirstOrDefault() as LootTableSO;
if (loot == null) return;
var title = new Label($"Loot{loot.name}")
{
style = { fontSize = 13, unityFontStyleAndWeight = FontStyle.Bold, marginBottom = 6 }
};
_lootDetailRoot.Add(title);
// 简要统计
int entryCount = loot.Entries?.Length ?? 0;
_lootDetailRoot.Add(new Label($"条目数:{entryCount} 保底 LingZhu{loot.GuaranteedLingZhuMin}{loot.GuaranteedLingZhuMax}")
{
style = { color = new Color(0.7f, 0.7f, 0.7f), marginBottom = 4 }
});
_lootInspector = new InspectorElement(loot);
_lootDetailRoot.Add(_lootInspector);
var btnRow = new VisualElement();
btnRow.AddToClassList("action-buttons");
btnRow.Add(new Button(() => EditorScaffoldUtils.PingAndSelect(loot)) { text = "在 Project 中定位" });
btnRow.Add(new Button(() => Selection.activeObject = loot) { text = "在 Inspector 中打开" });
_lootDetailRoot.Add(btnRow);
}
// ── 新建资产 ──────────────────────────────────────────────────────────
private void CreateNewEnemyStats()
{
var asset = EditorScaffoldUtils.CreateSOAsset<EnemyStatsSO>(
"Assets/_Game/Data/Enemies", "EnemyStatsSO_New");
if (asset != null) RefreshAll();
}
private void CreateNewLootTable()
{
var asset = EditorScaffoldUtils.CreateSOAsset<LootTableSO>(
"Assets/_Game/Data/Enemies", "LootTableSO_New");
if (asset != null) RefreshAll();
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 3a95bf3e8be76e44881b0efa6a42f753
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 53f701b15574fcc49bc11d1e8798ba52
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,118 @@
#if UNITY_EDITOR
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEditor;
using UnityEngine;
using BaseGames.Equipment;
namespace BaseGames.Editor.Equipment
{
/// <summary>
/// 为 CharmSO.effectsList&lt;ICharmEffect&gt;)提供友好的 Inspector 体验(架构 09_ProgressionModule §4.1)。
/// - 下拉菜单选类型(显示中文名而非 C# 全称)
/// - 每条效果展开显示字段 + GetEffectDescription() 预览文字
/// - 支持单条删除
/// </summary>
[CustomEditor(typeof(CharmSO))]
public class CharmSOEditor : UnityEditor.Editor
{
// 已注册的所有 ICharmEffect 实现类型(反射收集)
private static readonly Type[] _effectTypes = CollectEffectTypes();
// 策划友好名称映射
private static readonly Dictionary<Type, string> _typeLabels = new()
{
{ typeof(StatModifierEffect), "属性加成" },
{ typeof(AttackSpeedEffect), "攻击速度" },
{ typeof(OnHitEffect), "命中触发" },
{ typeof(SoulSpellEffect), "灵魂法术" },
{ typeof(SkillNumericModifierEffect), "技能数值修改" },
{ typeof(SkillSlotOverrideEffect), "技能插槽替换" },
{ typeof(WeaponOverrideEffect), "武器替换" },
};
private SerializedProperty _effectsProp;
private void OnEnable()
=> _effectsProp = serializedObject.FindProperty("effects");
public override void OnInspectorGUI()
{
serializedObject.Update();
DrawPropertiesExcluding(serializedObject, "effects");
EditorGUILayout.Space(8);
EditorGUILayout.LabelField("Effects", EditorStyles.boldLabel);
if (_effectsProp != null)
{
for (int i = 0; i < _effectsProp.arraySize; i++)
{
var elemProp = _effectsProp.GetArrayElementAtIndex(i);
var effect = elemProp.managedReferenceValue as ICharmEffect;
string label = effect != null && _typeLabels.TryGetValue(effect.GetType(), out var n)
? n : (effect?.GetType().Name ?? "null");
EditorGUILayout.BeginVertical(EditorStyles.helpBox);
EditorGUILayout.BeginHorizontal();
EditorGUILayout.LabelField(label, EditorStyles.boldLabel);
if (GUILayout.Button("✕", GUILayout.Width(24)))
{
_effectsProp.DeleteArrayElementAtIndex(i);
serializedObject.ApplyModifiedProperties();
break;
}
EditorGUILayout.EndHorizontal();
EditorGUILayout.PropertyField(elemProp, GUIContent.none, true);
if (effect != null)
EditorGUILayout.LabelField(effect.GetEffectDescription(),
EditorStyles.miniLabel);
EditorGUILayout.EndVertical();
EditorGUILayout.Space(2);
}
}
// 添加效果按钮(下拉菜单)
if (GUILayout.Button(" 添加效果"))
{
var menu = new GenericMenu();
foreach (var t in _effectTypes)
{
var captured = t;
string menuLabel = _typeLabels.GetValueOrDefault(t, t.Name);
menu.AddItem(new GUIContent(menuLabel), false, () =>
{
if (_effectsProp == null) return;
_effectsProp.arraySize++;
_effectsProp
.GetArrayElementAtIndex(_effectsProp.arraySize - 1)
.managedReferenceValue = Activator.CreateInstance(captured);
serializedObject.ApplyModifiedProperties();
});
}
menu.ShowAsContext();
}
serializedObject.ApplyModifiedProperties();
}
private static Type[] CollectEffectTypes()
{
var baseType = typeof(ICharmEffect);
return AppDomain.CurrentDomain.GetAssemblies()
.SelectMany(a =>
{
try { return a.GetTypes(); }
catch { return Array.Empty<Type>(); }
})
.Where(t => t.IsClass && !t.IsAbstract && baseType.IsAssignableFrom(t))
.ToArray();
}
}
}
#endif
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 38fb3e35ebefbc8418ba2ea0b5781f92
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 65f0a61a23616ec418d544b3284d47f0
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,312 @@
using System.Collections.Generic;
using System.Linq;
using UnityEditor;
using UnityEngine;
using BaseGames.EventChain;
namespace BaseGames.Editor
{
/// <summary>
/// 事件链可视化编辑器窗口(架构 14_NarrativeModule §13)。
/// 菜单:BaseGames/Tools/Event Chain Viewer
///
/// 功能:
/// - 左侧:chainId 分组总览(按完成状态着色)
/// - 右侧:选中链的 Conditions 和 Actions 表格
/// - Play Mode:运行时状态着色(已完成=绿 / 条件满足=橙 / 未满足=白)
/// - ChainCompletedCondition 依赖链箭头指示
/// - 执行日志(最近 20 条)
/// - 双击 → EditorGUIUtility.PingObject
/// </summary>
public class EventChainEditorWindow : EditorWindow
{
// ── State ──────────────────────────────────────────────────────────
private EventChainSO[] _allChains;
private EventChainSO _selectedChain;
private Vector2 _leftScroll;
private Vector2 _rightScroll;
private Vector2 _logScroll;
private static readonly List<string> _log = new();
private const int MaxLogEntries = 20;
// ── Colors ─────────────────────────────────────────────────────────
private static readonly Color ColCompleted = new Color(0.15f, 0.75f, 0.25f, 0.80f);
private static readonly Color ColActive = new Color(0.95f, 0.60f, 0.10f, 0.80f);
private static readonly Color ColPending = new Color(0.70f, 0.70f, 0.75f, 0.80f);
[MenuItem("BaseGames/Tools/Event Chain Viewer")]
public static void OpenWindow()
{
var win = GetWindow<EventChainEditorWindow>("Event Chain Viewer");
win.minSize = new Vector2(800, 500);
win.Show();
}
/// <summary>外部调用:向执行日志追加一条记录(可在运行时由 EventChainManager 调用)。</summary>
public static void LogExecution(string chainId, string message)
{
_log.Add($"[{System.DateTime.Now:HH:mm:ss}] [{chainId}] {message}");
if (_log.Count > MaxLogEntries)
_log.RemoveAt(0);
}
// ── Lifecycle ─────────────────────────────────────────────────────
private void OnEnable()
{
RefreshChainList();
EditorApplication.playModeStateChanged += OnPlayModeChanged;
EventChainManager.OnChainExecutedInEditor += LogExecution;
}
private void OnDisable()
{
EditorApplication.playModeStateChanged -= OnPlayModeChanged;
EventChainManager.OnChainExecutedInEditor -= LogExecution;
}
private void OnPlayModeChanged(PlayModeStateChange state)
{
if (state == PlayModeStateChange.EnteredPlayMode
|| state == PlayModeStateChange.ExitingPlayMode)
{
RefreshChainList();
Repaint();
}
}
private void RefreshChainList()
{
var guids = AssetDatabase.FindAssets("t:EventChainSO");
var chains = new List<EventChainSO>(guids.Length);
foreach (var g in guids)
{
var path = AssetDatabase.GUIDToAssetPath(g);
var chain = AssetDatabase.LoadAssetAtPath<EventChainSO>(path);
if (chain != null) chains.Add(chain);
}
_allChains = chains.OrderBy(c => c.chainId).ToArray();
}
// ── GUI ────────────────────────────────────────────────────────────
private void OnGUI()
{
DrawToolbar();
EditorGUILayout.BeginHorizontal();
// 左:链列表
EditorGUILayout.BeginVertical(GUILayout.Width(240));
DrawChainList();
EditorGUILayout.EndVertical();
// 分割线
EditorGUILayout.BeginVertical(GUILayout.Width(2));
EditorGUI.DrawRect(GUILayoutUtility.GetRect(2, position.height), new Color(0.1f, 0.1f, 0.1f));
EditorGUILayout.EndVertical();
// 右:选中链详情
EditorGUILayout.BeginVertical();
if (_selectedChain != null)
DrawChainDetail(_selectedChain);
else
EditorGUILayout.HelpBox("从左侧选择一条事件链查看详情。", MessageType.None);
EditorGUILayout.EndVertical();
EditorGUILayout.EndHorizontal();
}
// ── Toolbar ───────────────────────────────────────────────────────
private void DrawToolbar()
{
EditorGUILayout.BeginHorizontal(EditorStyles.toolbar);
if (GUILayout.Button("刷新", EditorStyles.toolbarButton, GUILayout.Width(50)))
RefreshChainList();
GUILayout.FlexibleSpace();
EditorGUILayout.LabelField(
$"共 {_allChains?.Length ?? 0} 条事件链",
EditorStyles.toolbarButton);
EditorGUILayout.EndHorizontal();
}
// ── 左侧链列表 ────────────────────────────────────────────────────
private void DrawChainList()
{
EditorGUILayout.LabelField("事件链列表", EditorStyles.boldLabel);
_leftScroll = EditorGUILayout.BeginScrollView(_leftScroll);
if (_allChains == null || _allChains.Length == 0)
{
EditorGUILayout.HelpBox("未找到 EventChainSO 资产。", MessageType.Info);
EditorGUILayout.EndScrollView();
return;
}
foreach (var chain in _allChains)
{
if (chain == null) continue;
bool isSelected = _selectedChain == chain;
bool isCompleted = IsChainCompleted(chain);
bool isActive = Application.isPlaying && IsChainActive(chain);
Color bgColor = isCompleted ? ColCompleted : isActive ? ColActive : ColPending;
var prevBg = GUI.backgroundColor;
GUI.backgroundColor = isSelected ? bgColor * 1.4f : bgColor * 0.7f;
EditorGUILayout.BeginHorizontal("box");
// 状态图标
string icon = isCompleted ? "✓" : isActive ? "▶" : "○";
EditorGUILayout.LabelField(icon, GUILayout.Width(16));
if (GUILayout.Button(chain.chainId, isSelected ? EditorStyles.boldLabel : EditorStyles.label))
_selectedChain = chain;
// 双击 Ping
if (Event.current.type == EventType.MouseDown && Event.current.clickCount == 2
&& GUILayoutUtility.GetLastRect().Contains(Event.current.mousePosition))
{
EditorGUIUtility.PingObject(chain);
Event.current.Use();
}
EditorGUILayout.EndHorizontal();
GUI.backgroundColor = prevBg;
}
EditorGUILayout.EndScrollView();
}
// ── 右侧详情 ──────────────────────────────────────────────────────
private void DrawChainDetail(EventChainSO chain)
{
_rightScroll = EditorGUILayout.BeginScrollView(_rightScroll);
// 标题行
EditorGUILayout.BeginHorizontal();
EditorGUILayout.LabelField(
$"事件链:{chain.chainId}",
EditorStyles.boldLabel);
if (GUILayout.Button("↗ Ping", GUILayout.Width(60)))
EditorGUIUtility.PingObject(chain);
EditorGUILayout.EndHorizontal();
EditorGUILayout.LabelField(
$"可重复:{(chain.repeatable ? "" : "")} | " +
$"动作间隔:{chain.actionDelay:F2}s",
EditorStyles.miniLabel);
EditorGUILayout.Space(6);
// Conditions 表格
EditorGUILayout.LabelField("触发条件(全部满足才触发)", EditorStyles.boldLabel);
if (chain.conditions != null && chain.conditions.Length > 0)
{
foreach (var cond in chain.conditions)
{
if (cond == null) continue;
bool met = Application.isPlaying && cond.IsMet();
var prevBg = GUI.backgroundColor;
GUI.backgroundColor = met ? ColCompleted * 0.8f : new Color(0.9f, 0.9f, 0.9f, 0.3f);
EditorGUILayout.BeginHorizontal("box");
string status = Application.isPlaying ? (met ? "✓" : "✗") : "—";
EditorGUILayout.LabelField(status, GUILayout.Width(20));
EditorGUILayout.LabelField(cond.GetType().Name, GUILayout.Width(220));
// 依赖箭头:ChainCompletedCondition
if (cond is ChainCompletedCondition depCond)
{
EditorGUILayout.LabelField($"→ 依赖链:{depCond.chainId}",
EditorStyles.miniLabel);
}
if (GUILayout.Button("↗", GUILayout.Width(24)))
EditorGUIUtility.PingObject(cond);
EditorGUILayout.EndHorizontal();
GUI.backgroundColor = prevBg;
}
}
else
{
EditorGUILayout.LabelField("(无条件,立即触发)", EditorStyles.miniLabel);
}
EditorGUILayout.Space(6);
// Actions 表格
EditorGUILayout.LabelField("执行动作(顺序执行)", EditorStyles.boldLabel);
if (chain.actions != null && chain.actions.Length > 0)
{
for (int i = 0; i < chain.actions.Length; i++)
{
var action = chain.actions[i];
if (action == null) continue;
EditorGUILayout.BeginHorizontal("box");
EditorGUILayout.LabelField($"[{i}]", GUILayout.Width(30));
EditorGUILayout.LabelField(action.GetType().Name, GUILayout.Width(200));
EditorGUILayout.LabelField(action.name, EditorStyles.miniLabel);
if (GUILayout.Button("↗", GUILayout.Width(24)))
EditorGUIUtility.PingObject(action);
EditorGUILayout.EndHorizontal();
}
}
else
{
EditorGUILayout.LabelField("(无动作)", EditorStyles.miniLabel);
}
// 执行日志
EditorGUILayout.Space(6);
EditorGUILayout.LabelField($"执行日志(最近 {MaxLogEntries} 条)", EditorStyles.boldLabel);
_logScroll = EditorGUILayout.BeginScrollView(_logScroll, GUILayout.Height(120));
var relevantLogs = _log.Where(l => l.Contains(chain.chainId)).ToList();
if (relevantLogs.Count == 0)
EditorGUILayout.LabelField("—(无日志)", EditorStyles.miniLabel);
else
foreach (var entry in relevantLogs)
EditorGUILayout.LabelField(entry, EditorStyles.miniLabel);
EditorGUILayout.EndScrollView();
EditorGUILayout.EndScrollView();
}
// ── 运行时状态查询 ────────────────────────────────────────────────
private static bool IsChainCompleted(EventChainSO chain)
{
if (!Application.isPlaying) return false;
var manager = FindFirstObjectByType<EventChainManager>();
if (manager == null) return false;
// 通过反射读取 _completedChains
var field = typeof(EventChainManager).GetField(
"_completedChains",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
if (field?.GetValue(manager) is HashSet<string> completed)
return completed.Contains(chain.chainId);
return false;
}
private static bool IsChainActive(EventChainSO chain)
{
// 链"激活中"= 有任意条件已满足但链未完成
if (chain.conditions == null) return false;
return chain.conditions.Any(c => c != null && c.IsMet());
}
private void Update()
{
// Play Mode 下每秒刷新一次以更新状态颜色
if (Application.isPlaying)
Repaint();
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e8cb0e5db63d15d418e73c29e6ff6f1f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: d986178e190294a49abcfcea63b4e698
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,161 @@
using UnityEngine;
using UnityEditor;
using BaseGames.Core.Events;
using BaseGames.Combat;
using BaseGames.Player;
using BaseGames.Dialogue;
using BaseGames.Progression;
using System.IO;
namespace BaseGames.Editor
{
/// <summary>
/// Editor 工具:一键在 Assets/_Game/Data/Events/ 下生成所有全局事件频道 .asset 资产。
/// 菜单:BaseGames → Tools → Create Event Channel Assets
/// 已存在的资产会自动跳过(幂等)。
/// </summary>
public static class CreateEventChannelAssets
{
private const string RootPath = "Assets/_Game/Data/Events";
[MenuItem("BaseGames/Tools/Create Event Channel Assets")]
public static void CreateAll()
{
// ── Core 原始类型频道 ──────────────────────────────────────────────
CreateAsset<VoidEventChannelSO> ("Core", "EVT_Void");
CreateAsset<BoolEventChannelSO> ("Core", "EVT_Bool");
CreateAsset<IntEventChannelSO> ("Core", "EVT_Int");
CreateAsset<FloatEventChannelSO> ("Core", "EVT_Float");
CreateAsset<StringEventChannelSO> ("Core", "EVT_String");
CreateAsset<Vector2EventChannelSO> ("Core", "EVT_Vector2");
CreateAsset<TransformEventChannelSO> ("Core", "EVT_Transform");
CreateAsset<GameStateEventChannelSO> ("Core", "EVT_GameState");
CreateAsset<GameStateEventChannelSO> ("Core", "EVT_GameStateChanged");
CreateAsset<SceneLoadRequestEventChannelSO>("Core", "EVT_SceneLoadRequest");
CreateAsset<StringEventChannelSO> ("Core", "EVT_SceneLoaded");
CreateAsset<VoidEventChannelSO> ("Core", "EVT_FadeInRequest");
CreateAsset<VoidEventChannelSO> ("Core", "EVT_FadeOutRequest");
// ── 难度 ──────────────────────────────────────────────────────────
CreateAsset<DifficultyChangedEventChannel>("Difficulty", "EVT_DifficultyChanged");
// ── 战斗 ────────────────────────────────────────────────────────── CreateAsset<DamageInfoEventChannelSO> ("Combat", "EVT_DamageDealt"); CreateAsset<HitConfirmedEventChannelSO> ("Combat", "EVT_HitConfirmed");
CreateAsset<VoidEventChannelSO> ("Combat", "EVT_PlayerDied");
CreateAsset<VoidEventChannelSO> ("Combat", "EVT_DeathScreenConfirmed");
CreateAsset<VoidEventChannelSO> ("Combat", "EVT_EnemyDied");
CreateAsset<VoidEventChannelSO> ("Combat", "EVT_ParrySuccess");
CreateAsset<VoidEventChannelSO> ("Combat", "EVT_PlayerRespawn");
CreateAsset<VoidEventChannelSO> ("Combat", "EVT_PlayerRespawned");
CreateAsset<VoidEventChannelSO> ("Combat", "EVT_RespawnStarted");
CreateAsset<VoidEventChannelSO> ("Combat", "EVT_RespawnCompleted");
// ── Boss ──────────────────────────────────────────────────────────
CreateAsset<BossSkillEventChannelSO> ("Boss", "EVT_BossSkill");
CreateAsset<BossPhaseEventChannelSO> ("Boss", "EVT_BossPhase");
CreateAsset<StatusEffectEventChannelSO> ("Boss", "EVT_StatusEffect");
CreateAsset<StringEventChannelSO> ("Boss", "EVT_BossFightStarted");
CreateAsset<BoolEventChannelSO> ("Boss", "EVT_BossFightEnded");
// ── 任务 ──────────────────────────────────────────────────────────
CreateAsset<QuestStateChangedEventChannel>("Quest", "EVT_QuestStateChanged");
CreateAsset<QuestObjectiveEventChannelSO> ("Quest", "EVT_QuestObjective");
// ── UI ────────────────────────────────────────────────────────────
CreateAsset<VoidEventChannelSO> ("UI", "EVT_PauseRequested");
CreateAsset<VoidEventChannelSO> ("UI", "EVT_PauseResumed");
CreateAsset<VoidEventChannelSO> ("UI", "EVT_FastTravelOpen");
CreateAsset<StringEventChannelSO> ("UI", "EVT_ShopOpen");
CreateAsset<VoidEventChannelSO> ("UI", "EVT_MapOpen");
CreateAsset<ColorblindModeEventChannelSO> ("UI", "EVT_ColorblindMode");
// ── World ─────────────────────────────────────────────────────────
CreateAsset<StringEventChannelSO> ("World", "EVT_SavePointActivated");
// ── 对话/商店 ─────────────────────────────────────────────────────
CreateAsset<ShopPurchaseEventChannelSO> ("Dialogue", "EVT_ShopPurchase");
CreateAsset<DialogueEventChannelSO> ("Dialogue", "EVT_DialogueStartRequest");
CreateAsset<VoidEventChannelSO> ("Dialogue", "EVT_DialogueEnded");
// ── 玩家能力 ──────────────────────────────────────────────────────
CreateAsset<TransformEventChannelSO> ("Player", "EVT_PlayerSpawned"); CreateAsset<IntEventChannelSO> ("Player", "EVT_HPChanged");
CreateAsset<IntEventChannelSO> ("Player", "EVT_MaxHPChanged");
CreateAsset<IntEventChannelSO> ("Player", "EVT_SoulPowerChanged");
CreateAsset<IntEventChannelSO> ("Player", "EVT_SpiritPowerChanged");
CreateAsset<IntEventChannelSO> ("Player", "EVT_SpringChargesChanged");
CreateAsset<IntEventChannelSO> ("Player", "EVT_LingZhuChanged"); CreateAsset<AbilityTypeEventChannelSO> ("Player", "EVT_AbilityUnlocked");
CreateAsset<StringEventChannelSO> ("Player", "EVT_AbilityUnlockedStr");
// ── 音频 ──────────────────────────────────────────────────────────
CreateAsset<StringEventChannelSO> ("Audio", "EVT_BGMRequest");
CreateAsset<VoidEventChannelSO> ("Audio", "EVT_BGMStop");
// ── 进度/成就 ─────────────────────────────────────────────────────
CreateAsset<ToolUsedEventChannelSO> ("Progression", "EVT_ToolUsed");
CreateAsset<AchievementEventChannelSO> ("Progression", "EVT_AchievementUnlocked");
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
Debug.Log("[CreateEventChannelAssets] 所有事件频道资产生成完毕。");
}
[MenuItem("BaseGames/Tools/Reimport Event Channel Assets")]
public static void ReimportAllEventAssets()
{
if (!AssetDatabase.IsValidFolder(RootPath))
{
Debug.LogWarning($"[CreateEventChannelAssets] 未找到目录: {RootPath}");
return;
}
string absoluteRoot = Path.Combine(Directory.GetCurrentDirectory(), RootPath);
string[] files = Directory.GetFiles(absoluteRoot, "*.asset", SearchOption.AllDirectories);
int count = 0;
foreach (string file in files)
{
string relativePath = "Assets" + file.Replace('\\', '/').Substring(Directory.GetCurrentDirectory().Length);
AssetDatabase.ImportAsset(relativePath, ImportAssetOptions.ForceUpdate);
count++;
}
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
Debug.Log($"[CreateEventChannelAssets] 已重导入 {count} 个事件资产。");
}
private static void CreateAsset<T>(string subfolder, string assetName) where T : ScriptableObject
{
string folderPath = $"{RootPath}/{subfolder}";
EnsureDirectory(folderPath);
string fullPath = $"{folderPath}/{assetName}.asset";
if (AssetDatabase.LoadAssetAtPath<T>(fullPath) != null)
{
Debug.Log($"[CreateEventChannelAssets] 已跳过(已存在): {fullPath}");
return;
}
T asset = ScriptableObject.CreateInstance<T>();
AssetDatabase.CreateAsset(asset, fullPath);
Debug.Log($"[CreateEventChannelAssets] 已创建: {fullPath}");
}
/// <summary>递归创建所有缺失的中间文件夹(使用 AssetDatabase API)。</summary>
private static void EnsureDirectory(string path)
{
if (AssetDatabase.IsValidFolder(path))
return;
string[] parts = path.Split('/');
string current = parts[0];
for (int i = 1; i < parts.Length; i++)
{
string next = $"{current}/{parts[i]}";
if (!AssetDatabase.IsValidFolder(next))
AssetDatabase.CreateFolder(current, parts[i]);
current = next;
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8c6f33a6c3ce1f6469e1a2ac17c95b6b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,124 @@
using System;
using System.Linq;
using BaseGames.Core.Events;
using UnityEditor;
using UnityEngine;
namespace BaseGames.Editor
{
public sealed class EventBusMonitorWindow : EditorWindow
{
private string _filter = string.Empty;
private bool _pauseCapture;
private bool _autoScroll = true;
private Vector2 _scroll;
[MenuItem("BaseGames/Tools/Event Bus Monitor %#e")]
public static void OpenWindow()
{
EventBusMonitorWindow window = GetWindow<EventBusMonitorWindow>("Event Bus Monitor");
window.minSize = new Vector2(760f, 320f);
}
private void OnEnable()
{
EditorApplication.update += RepaintWhilePlaying;
}
private void OnDisable()
{
EditorApplication.update -= RepaintWhilePlaying;
}
private void OnGUI()
{
DrawToolbar();
DrawHeader();
DrawRows();
}
private void DrawToolbar()
{
using (new EditorGUILayout.HorizontalScope(EditorStyles.toolbar))
{
_filter = EditorGUILayout.TextField(_filter, EditorStyles.toolbarSearchField,
GUILayout.MinWidth(180f), GUILayout.ExpandWidth(true));
if (!string.IsNullOrEmpty(_filter)
&& GUILayout.Button(GUIContent.none, "ToolbarSeachCancelButton"))
{
_filter = "";
GUI.FocusControl(null);
}
GUILayout.Space(8f);
_pauseCapture = GUILayout.Toggle(_pauseCapture, "Pause", EditorStyles.toolbarButton, GUILayout.Width(56f));
_autoScroll = GUILayout.Toggle(_autoScroll, "Auto Scroll", EditorStyles.toolbarButton, GUILayout.Width(82f));
if (GUILayout.Button("Clear", EditorStyles.toolbarButton, GUILayout.Width(48f)))
EventBusMonitor.Clear();
GUILayout.FlexibleSpace();
GUILayout.Label(EditorApplication.isPlaying ? "Play Mode" : "Edit Mode", EditorStyles.miniLabel);
}
}
private void DrawHeader()
{
using (new EditorGUILayout.HorizontalScope())
{
GUILayout.Label("Time", EditorStyles.boldLabel, GUILayout.Width(80f));
GUILayout.Label("Frame", EditorStyles.boldLabel, GUILayout.Width(60f));
GUILayout.Label("Channel", EditorStyles.boldLabel, GUILayout.Width(220f));
GUILayout.Label("Payload", EditorStyles.boldLabel, GUILayout.ExpandWidth(true));
GUILayout.Label("Subs", EditorStyles.boldLabel, GUILayout.Width(48f));
}
EditorGUILayout.LabelField(GUIContent.none, GUI.skin.horizontalSlider);
}
private void DrawRows()
{
var records = EventBusMonitor.Records;
if (!string.IsNullOrWhiteSpace(_filter))
{
records = records.Where(record =>
record.ChannelName.IndexOf(_filter, StringComparison.OrdinalIgnoreCase) >= 0 ||
record.Payload.IndexOf(_filter, StringComparison.OrdinalIgnoreCase) >= 0);
}
var displayRecords = records.ToArray();
_scroll = EditorGUILayout.BeginScrollView(_scroll);
foreach (var record in displayRecords)
DrawRow(record);
EditorGUILayout.EndScrollView();
if (_autoScroll && Event.current.type == EventType.Repaint)
_scroll.y = float.MaxValue;
}
private void DrawRow(EventBusMonitor.EventRecord record)
{
Color oldColor = GUI.color;
if (record.ListenerCount == 0)
GUI.color = new Color(1f, 0.65f, 0.65f);
using (new EditorGUILayout.HorizontalScope())
{
GUILayout.Label(record.Timestamp.ToString("HH:mm:ss.fff"), GUILayout.Width(80f));
GUILayout.Label($"#{record.FrameCount}", GUILayout.Width(60f));
GUILayout.Label(record.ChannelName, GUILayout.Width(220f));
GUILayout.Label(record.Payload, GUILayout.ExpandWidth(true));
GUILayout.Label(record.ListenerCount.ToString(), GUILayout.Width(48f));
}
GUI.color = oldColor;
}
private void RepaintWhilePlaying()
{
if (!_pauseCapture)
Repaint();
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 861ce74d8a5c0ce4f957719423a0be7b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,92 @@
using System;
using UnityEditor;
using UnityEngine;
using BaseGames.Core.Events;
namespace BaseGames.Editor
{
/// <summary>
/// 为 VoidEventChannelSO 提供 Inspector 内的"Raise(测试触发)"按钮。
/// 仅在 Play Mode 下可用,防止在编辑状态误触发副作用。
/// </summary>
[CustomEditor(typeof(VoidBaseEventChannelSO), true)]
public class VoidEventChannelSOEditor : UnityEditor.Editor
{
public override void OnInspectorGUI()
{
DrawDefaultInspector();
EditorGUILayout.Space(6);
EditorGUI.BeginDisabledGroup(!Application.isPlaying);
if (GUILayout.Button("▶ Raise(测试触发)", GUILayout.Height(28)))
{
var channel = (VoidBaseEventChannelSO)target;
channel.Raise();
Debug.Log($"[EventChannelEditor] Raised: {target.name}");
}
EditorGUI.EndDisabledGroup();
if (!Application.isPlaying)
{
EditorGUILayout.HelpBox("进入 Play Mode 后可点击 Raise 触发此事件。", MessageType.Info);
}
}
}
/// <summary>
/// 为所有 BaseEventChannelSO&lt;T&gt; 子类提供 Inspector 内的订阅者数量显示和说明标签。
/// 因泛型限制,Raise 按钮由具体类型的派生 Editor 提供(见下方注册器)。
/// </summary>
[CustomEditor(typeof(ScriptableObject), true)]
public class GenericEventChannelSOEditor : UnityEditor.Editor
{
// 仅对 BaseEventChannelSO<T> 子类生效
private bool _isEventChannel;
private void OnEnable()
{
var t = target.GetType();
while (t != null && t != typeof(object))
{
if (t.IsGenericType && t.GetGenericTypeDefinition() == typeof(BaseEventChannelSO<>))
{
_isEventChannel = true;
break;
}
t = t.BaseType;
}
}
public override void OnInspectorGUI()
{
if (!_isEventChannel)
{
DrawDefaultInspector();
return;
}
DrawDefaultInspector();
EditorGUILayout.Space(6);
EditorGUI.BeginDisabledGroup(true);
if (Application.isPlaying)
{
// 反射获取订阅者数量
var field = target.GetType().GetField("OnEventRaised",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
if (field != null)
{
var del = field.GetValue(target) as Delegate;
int count = del?.GetInvocationList().Length ?? 0;
EditorGUILayout.LabelField("当前订阅者数量", count.ToString());
}
}
EditorGUI.EndDisabledGroup();
if (!Application.isPlaying)
{
EditorGUILayout.HelpBox("进入 Play Mode 可查看实时订阅者数量。", MessageType.Info);
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 39fd6fe0ebb5ceb4db85f82919217956
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,173 @@
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;
using BaseGames.Animation;
namespace BaseGames.Editor
{
/// <summary>
/// AnimationEventConfigSO 自定义 Inspector(架构 §AnimationModule)。
/// 功能:
/// - 以时间线色块可视化事件分布
/// - 自动检测 Clip 长度漂移(超过 5 帧则显示警告)
/// - 验证归一化时间范围 [0, 1]
/// - 一键对事件按归一化时间排序
/// </summary>
[CustomEditor(typeof(AnimationEventConfigSO))]
public class EventConfigEditor : UnityEditor.Editor
{
// ── 事件类型 → 色块颜色映射 ────────────────────────────────────────
private static readonly Dictionary<AnimationEventType, Color> _colorMap = new()
{
{ AnimationEventType.EnableHitBox, new Color(0.9f, 0.2f, 0.2f, 0.8f) }, // 红
{ AnimationEventType.DisableHitBox, new Color(0.9f, 0.2f, 0.2f, 0.8f) },
{ AnimationEventType.AttackImpact, new Color(0.9f, 0.2f, 0.2f, 0.8f) },
{ AnimationEventType.EnableIFrame, new Color(0.2f, 0.8f, 0.2f, 0.8f) }, // 绿
{ AnimationEventType.DisableIFrame, new Color(0.2f, 0.8f, 0.2f, 0.8f) },
{ AnimationEventType.Footstep, new Color(0.2f, 0.4f, 0.9f, 0.8f) }, // 蓝
{ AnimationEventType.PlaySFX, new Color(0.2f, 0.4f, 0.9f, 0.8f) },
{ AnimationEventType.LandImpact, new Color(0.2f, 0.4f, 0.9f, 0.8f) },
{ AnimationEventType.JumpLaunch, new Color(0.2f, 0.4f, 0.9f, 0.8f) },
{ AnimationEventType.EnableParryWindow, new Color(0.9f, 0.8f, 0.1f, 0.8f) }, // 黄
{ AnimationEventType.DisableParryWindow,new Color(0.9f, 0.8f, 0.1f, 0.8f) },
{ AnimationEventType.TriggerFeedback, new Color(0.6f, 0.2f, 0.9f, 0.8f) }, // 紫
{ AnimationEventType.CancelWindowOpen, new Color(0.9f, 0.5f, 0.1f, 0.8f) }, // 橙
{ AnimationEventType.CancelWindowClose, new Color(0.9f, 0.5f, 0.1f, 0.8f) },
{ AnimationEventType.SpawnProjectile, new Color(0.9f, 0.9f, 0.9f, 0.8f) }, // 白
{ AnimationEventType.RoarStart, new Color(0.9f, 0.9f, 0.9f, 0.8f) },
{ AnimationEventType.RoarEnd, new Color(0.9f, 0.9f, 0.9f, 0.8f) },
{ AnimationEventType.PhaseTwoStart, new Color(0.9f, 0.9f, 0.9f, 0.8f) },
};
private const float TimelineHeight = 24f;
private const float MarkerWidth = 3f;
private const float DriftThresholdFrames = 5f;
public override void OnInspectorGUI()
{
var config = (AnimationEventConfigSO)target;
serializedObject.Update();
// ── 时间线预览 ───────────────────────────────────────────────
EditorGUILayout.LabelField("事件时间线预览", EditorStyles.boldLabel);
DrawTimeline(config);
EditorGUILayout.Space(4f);
// ── 标准字段 ─────────────────────────────────────────────────
DrawDefaultInspector();
EditorGUILayout.Space(4f);
// ── 验证警告 ─────────────────────────────────────────────────
ValidateEntries(config);
// ── Clip 长度漂移检测 ─────────────────────────────────────────
if (config.targetClip != null && config.ExpectedClipLength > 0f)
{
float fps = config.targetClip.frameRate;
float actualLen = config.targetClip.length;
float drift = Mathf.Abs(actualLen - config.ExpectedClipLength) * fps;
if (drift > DriftThresholdFrames)
{
EditorGUILayout.HelpBox(
$"⚠ Clip 长度已变化 {drift:F1} 帧(期望 {config.ExpectedClipLength:F3}s" +
$"实际 {actualLen:F3}s)。\n请检查事件时机是否需要更新。",
MessageType.Warning);
}
}
EditorGUILayout.Space(4f);
// ── 操作按钮 ─────────────────────────────────────────────────
EditorGUILayout.BeginHorizontal();
if (GUILayout.Button("按时间排序"))
{
SortEvents(config);
}
if (config.targetClip != null && GUILayout.Button("记录当前 Clip 长度"))
{
Undo.RecordObject(config, "记录 Clip 长度");
config.ExpectedClipLength = config.targetClip.length;
EditorUtility.SetDirty(config);
}
EditorGUILayout.EndHorizontal();
serializedObject.ApplyModifiedProperties();
}
// ── 时间线绘制 ────────────────────────────────────────────────────
private static void DrawTimeline(AnimationEventConfigSO config)
{
Rect rect = GUILayoutUtility.GetRect(GUIContent.none, GUIStyle.none,
GUILayout.Height(TimelineHeight), GUILayout.ExpandWidth(true));
// 背景轨道
EditorGUI.DrawRect(rect, new Color(0.15f, 0.15f, 0.15f, 1f));
// 标尺刻度(每 10% 一条)
for (int i = 0; i <= 10; i++)
{
float x = rect.x + rect.width * i / 10f;
float h = (i % 5 == 0) ? rect.height * 0.6f : rect.height * 0.3f;
var tick = new Rect(x, rect.y + rect.height - h, 1f, h);
EditorGUI.DrawRect(tick, new Color(0.5f, 0.5f, 0.5f, 0.8f));
}
if (config.events == null) return;
foreach (var entry in config.events)
{
float nx = Mathf.Clamp01(entry.normalizedTime);
float xPos = rect.x + rect.width * nx - MarkerWidth * 0.5f;
var markerRect = new Rect(xPos, rect.y + 2f, MarkerWidth, rect.height - 4f);
Color color = _colorMap.TryGetValue(entry.eventType, out var c)
? c
: new Color(0.8f, 0.8f, 0.8f, 0.8f);
EditorGUI.DrawRect(markerRect, color);
}
}
// ── 验证 ──────────────────────────────────────────────────────────
private static void ValidateEntries(AnimationEventConfigSO config)
{
if (config.events == null) return;
for (int i = 0; i < config.events.Length; i++)
{
float t = config.events[i].normalizedTime;
if (t < 0f || t > 1f)
{
EditorGUILayout.HelpBox(
$"事件 [{i}] {config.events[i].eventType}" +
$"normalizedTime = {t:F3} 超出 [0, 1] 范围。",
MessageType.Error);
}
}
}
// ── 排序 ──────────────────────────────────────────────────────────
private static void SortEvents(AnimationEventConfigSO config)
{
if (config.events == null || config.events.Length < 2) return;
Undo.RecordObject(config, "排序动画事件");
System.Array.Sort(config.events, (a, b) =>
a.normalizedTime.CompareTo(b.normalizedTime));
EditorUtility.SetDirty(config);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c992100309cc05a40bb06a3e23076c5b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 08a52815a08c8c3428ccb6a530171ddd
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,140 @@
#if UNITY_EDITOR
using UnityEditor;
using UnityEngine;
using BaseGames.World.Map;
namespace BaseGames.Editor.Map
{
/// <summary>
/// MapRoomDataSO 自定义编辑器(架构 15_MapShopModule §5)。
/// 在 Scene View 中直接拖拽调整房间格子位置/大小;提供一键居中 SceneView 快捷按钮。
/// 拖动自动吸附到整格精度;左下/右上角可独立拖动(含反转保护);支持 Undo。
/// </summary>
[CustomEditor(typeof(MapRoomDataSO))]
public class MapRoomDataEditor : UnityEditor.Editor
{
private const float CELL_SIZE = 1f; // 每格在 Scene 中的世界单位尺寸
private static readonly Color FillColor = new Color(0.2f, 0.6f, 1f, 0.15f);
private static readonly Color OutlineColor = new Color(0.2f, 0.6f, 1f, 0.9f);
private static readonly Color HandleColor = new Color(1f, 0.85f, 0.2f, 1f);
private static readonly GUIStyle LabelStyle = new GUIStyle
{
alignment = TextAnchor.MiddleCenter,
fontStyle = FontStyle.Bold,
normal = { textColor = Color.white },
};
private MapRoomDataSO _target;
private void OnEnable() => _target = (MapRoomDataSO)target;
// ── Inspector ─────────────────────────────────────────────────────────
public override void OnInspectorGUI()
{
DrawDefaultInspector();
EditorGUILayout.Space(8);
EditorGUILayout.HelpBox(
"在 Scene View 中可直接拖拽房间角点调整 GridPosition / GridSize。\n" +
"拖动自动吸附到 1 格精度,支持 Undo。",
MessageType.Info);
if (GUILayout.Button("居中 Scene View 到此房间", GUILayout.Height(28)))
CenterSceneViewOnRoom(_target);
}
// ── Scene GUI ─────────────────────────────────────────────────────────
private void OnSceneGUI()
{
if (_target == null) return;
Vector3 origin = new Vector3(
_target.GridPosition.x * CELL_SIZE,
_target.GridPosition.y * CELL_SIZE, 0f);
Vector3 size = new Vector3(
_target.GridSize.x * CELL_SIZE,
_target.GridSize.y * CELL_SIZE, 0f);
// 绘制半透明矩形(Vector3[] 重载正确)
Handles.DrawSolidRectangleWithOutline(GetRectCorners(origin, size), FillColor, OutlineColor);
// 房间 ID 标签(居中、加粗、白色)
Handles.Label(origin + size * 0.5f,
string.IsNullOrEmpty(_target.RoomId) ? "(No RoomId)" : _target.RoomId,
LabelStyle);
// ── 双角控制点(左下 = BL,右上 = TR)────────────────────────────
EditorGUI.BeginChangeCheck();
Vector3 newBL = DragHandle(origin, "BL");
Vector3 newTR = DragHandle(origin + size, "TR");
if (EditorGUI.EndChangeCheck())
{
Undo.RecordObject(_target, "Resize MapRoom");
// 防反转:确保 BL ≤ TR
float minX = Mathf.Min(newBL.x, newTR.x);
float minY = Mathf.Min(newBL.y, newTR.y);
float maxX = Mathf.Max(newBL.x, newTR.x);
float maxY = Mathf.Max(newBL.y, newTR.y);
_target.GridPosition = ToGrid(new Vector2(minX, minY));
var newSize = ToGrid(new Vector2(maxX, maxY)) - _target.GridPosition;
_target.GridSize = new Vector2Int(Mathf.Max(1, newSize.x), Mathf.Max(1, newSize.y));
EditorUtility.SetDirty(_target);
}
}
// ── 帮助方法 ──────────────────────────────────────────────────────────
private static Vector3 DragHandle(Vector3 pos, string label)
{
float sz = HandleUtility.GetHandleSize(pos) * 0.12f;
Color prev = Handles.color;
Handles.color = HandleColor;
var result = Handles.FreeMoveHandle(pos, sz, Vector3.zero, Handles.DotHandleCap);
Handles.color = prev;
return SnapToGrid(result);
}
/// <summary>将世界坐标吸附到最近格点。</summary>
private static Vector3 SnapToGrid(Vector3 world)
=> new(Mathf.Round(world.x / CELL_SIZE) * CELL_SIZE,
Mathf.Round(world.y / CELL_SIZE) * CELL_SIZE,
0f);
private static Vector2Int ToGrid(Vector2 world)
=> new(Mathf.RoundToInt(world.x / CELL_SIZE),
Mathf.RoundToInt(world.y / CELL_SIZE));
private static void CenterSceneViewOnRoom(MapRoomDataSO room)
{
if (room == null) return;
var sv = SceneView.lastActiveSceneView;
if (sv == null) return;
Vector3 center = new Vector3(
(room.GridPosition.x + room.GridSize.x * 0.5f) * CELL_SIZE,
(room.GridPosition.y + room.GridSize.y * 0.5f) * CELL_SIZE, 0f);
sv.Frame(new Bounds(center, new Vector3(
room.GridSize.x * CELL_SIZE * 2f,
room.GridSize.y * CELL_SIZE * 2f, 1f)), false);
}
private static Vector3[] GetRectCorners(Vector3 origin, Vector3 size)
=> new[]
{
origin,
origin + new Vector3(size.x, 0f, 0f),
origin + size,
origin + new Vector3(0f, size.y, 0f),
};
}
}
#endif
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 168d8a104fffcaf4db9849cd8b2140f9
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 763f62796f2f4ed43a21dfe3befb70a1
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,86 @@
using System.Reflection;
using UnityEditor;
using UnityEngine;
using PathBerserker2d;
namespace BaseGames.Editor
{
/// <summary>
/// 快捷键:BaseGames → Tools → Bake All NavSurfacesCtrl+Shift+B
/// 烘焙当前场景中所有 PathBerserker2d NavSurface 的导航网格。
/// 等效于在每个 NavSurface Inspector 中逐一点击 "Bake"。
/// </summary>
public static class NavSurfaceBakeShortcut
{
// NavSurface.StartBakeJob() 和 NavSurface.BakeJob 均为 internal,通过反射访问。
private static readonly MethodInfo s_startBakeJobMethod =
typeof(NavSurface).GetMethod("StartBakeJob", BindingFlags.NonPublic | BindingFlags.Instance);
private static readonly PropertyInfo s_bakeJobProp =
typeof(NavSurface).GetProperty("BakeJob", BindingFlags.NonPublic | BindingFlags.Instance);
[MenuItem("BaseGames/Tools/Bake All NavSurfaces %#b", priority = 100)]
public static void BakeAll()
{
var surfaces = Object.FindObjectsByType<NavSurface>(FindObjectsSortMode.None);
if (surfaces.Length == 0)
{
Debug.Log("[NavSurfaceBake] 当前场景没有找到 NavSurface 组件。");
return;
}
int count = 0;
foreach (var surface in surfaces)
{
if (surface == null) continue;
s_startBakeJobMethod?.Invoke(surface, null);
EditorApplication.update -= MakeWatcher(surface);
EditorApplication.update += MakeWatcher(surface);
count++;
}
Debug.Log($"[NavSurfaceBake] 开始烘焙 {count} 个 NavSurface……");
}
[MenuItem("BaseGames/Tools/Bake All NavSurfaces %#b", validate = true)]
private static bool BakeAllValidate()
{
// 仅在非 Play Mode 时可用(NavSurface.Bake 仅支持编辑器模式)
return !Application.isPlaying;
}
// ── 每个 NavSurface 独立监听烘焙完成 ──────────────────────────────
private static EditorApplication.CallbackFunction MakeWatcher(NavSurface surface)
{
EditorApplication.CallbackFunction watcher = null;
watcher = () =>
{
if (surface == null)
{
EditorApplication.update -= watcher;
return;
}
var bakeJob = s_bakeJobProp?.GetValue(surface);
if (bakeJob == null)
{
EditorApplication.update -= watcher;
return;
}
bool isFinished = (bool)bakeJob.GetType()
.GetProperty("IsFinished")!.GetValue(bakeJob);
if (isFinished)
{
EditorApplication.update -= watcher;
EditorUtility.SetDirty(surface);
float totalTime = (float)bakeJob.GetType()
.GetProperty("TotalBakeTime")!.GetValue(bakeJob);
Debug.Log($"[NavSurfaceBake] ✓ {surface.name} 烘焙完成({totalTime} ms");
}
};
return watcher;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 291f3b33fb176b8469ebaaa8afa317ba
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 9608b6106f68dfe499d2d776ad005296
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,788 @@
using System.Collections.Generic;
using System.Reflection;
using BaseGames.Camera;
using BaseGames.Combat;
using BaseGames.Dialogue;
using BaseGames.Enemies;
using BaseGames.Player;
using BaseGames.Player.States;
using BaseGames.World;
using PathBerserker2d;
using Unity.Cinemachine;
using UnityEditor;
using UnityEngine;
using UnityEngine.Tilemaps;
namespace BaseGames.Editor
{
/// <summary>
/// 场景对象快速放置工具。
/// 在当前活动场景中生成常用游戏对象(玩家、敌人、机关、存档点、相机等),
/// 并自动挂载基础组件、设置正确的物理层、绑定已有的事件频道资产。
///
/// 菜单:BaseGames → Scene → Place → …
///
/// 所有操作支持 Undo(Ctrl+Z)。生成后选中对象便于立即调整位置。
/// </summary>
public static class SceneObjectPlacerTool
{
// ══ 菜单入口 ══════════════════════════════════════════════════════════
[MenuItem("BaseGames/Scene/Place/Player", priority = 100)]
public static void PlacePlayer()
{
var report = new List<string>();
GameObject go = new GameObject("Player");
Undo.RegisterCreatedObjectUndo(go, "Place Player");
go.transform.position = GetDropPosition();
go.tag = "Player";
SetLayer(go, "Player", report);
Rigidbody2D rb = GetOrAddComponent<Rigidbody2D>(go);
rb.bodyType = RigidbodyType2D.Dynamic;
rb.gravityScale = 2f;
rb.constraints = RigidbodyConstraints2D.FreezeRotation;
rb.collisionDetectionMode = CollisionDetectionMode2D.Continuous;
GetOrAddComponent<CapsuleCollider2D>(go);
GetOrAddComponent<Animator>(go);
SetupSpriteRenderer(go);
PlayerStats playerStats = GetOrAddComponent<PlayerStats>(go);
PlayerMovement playerMovement = GetOrAddComponent<PlayerMovement>(go);
PlayerController playerController = GetOrAddComponent<PlayerController>(go);
PlayerCombat playerCombat = GetOrAddComponent<PlayerCombat>(go);
// Ground check pivot
Transform groundCheckGo = GetOrCreateChild(go.transform, "GroundCheck");
groundCheckGo.localPosition = new Vector3(0f, -0.75f, 0f);
AssignReference(playerMovement, "_groundCheck", groundCheckGo, report);
AssignLayerMask(playerMovement, "_groundLayer", "Ground", report);
// Weapon socket (WeaponManager instantiates weapons here at runtime)
GetOrCreateChild(go.transform, "WeaponSocket");
// Camera follow target — CinemachineCamera.Follow 使用此子节点而非 Player 根节点
GetOrCreateChild(go.transform, "CameraFollowTarget");
// HurtBox child
Transform hurtBoxT = GetOrCreateChild(go.transform, "HurtBox");
SetLayer(hurtBoxT.gameObject, "PlayerHurtBox", report);
CapsuleCollider2D hurtCollider = GetOrAddComponent<CapsuleCollider2D>(hurtBoxT.gameObject);
hurtCollider.isTrigger = true;
HurtBox hurtBox = GetOrAddComponent<HurtBox>(hurtBoxT.gameObject);
// Assign controller references
AssignReference(playerController, "_stats", playerStats, report);
AssignReference(playerController, "_hurtBox", hurtBox, report);
AssignReference(playerController, "_movement", playerMovement, report);
AssignReference(playerController, "_combat", playerCombat, report);
// Event channels (all optional — will be skipped silently if assets missing)
AssignAsset(playerStats, "_onHPChanged", report, false, "EVT_HPChanged");
AssignAsset(playerStats, "_onMaxHPChanged", report, false, "EVT_MaxHPChanged");
AssignAsset(playerStats, "_onSoulPowerChanged", report, false, "EVT_SoulPowerChanged");
AssignAsset(playerStats, "_onSpiritPowerChanged", report, false, "EVT_SpiritPowerChanged");
AssignAsset(playerStats, "_onSpringChargesChanged", report, false, "EVT_SpringChargesChanged");
AssignAsset(playerStats, "_onLingZhuChanged", report, false, "EVT_LingZhuChanged");
AssignAsset(playerStats, "_onAbilityUnlocked", report, false, "EVT_AbilityUnlocked");
AssignAsset(playerStats, "_onDifficultyChanged", report, false, "EVT_DifficultyChanged");
AssignAsset(playerController, "_onPlayerDied", report, false, "EVT_PlayerDied");
AssignAsset(playerController, "_onPlayerSpawned", report, false, "EVT_PlayerSpawned");
AssignAsset(hurtBox, "_onDamageDealt", report, false, "EVT_DamageDealt");
AssignAsset(hurtBox, "_onHitConfirmed", report, false, "EVT_HitConfirmed");
// Config ScriptableObjects (optional — link manually after placing)
Object statsConfig = FindFirstAsset("PLY_PlayerStats", "PlayerStats");
Object movConfig = FindFirstAsset("PLY_PlayerMovementConfig", "PlayerMovementConfig");
if (movConfig != null) AssignReference(playerController, "_movementConfig", movConfig, report);
if (statsConfig != null) AssignReference(playerStats, "_config", statsConfig, report);
if (movConfig != null) AssignReference(playerMovement, "_config", movConfig, report);
report.Add("PlayerMovement._config、PlayerController._animConfig、_inputReader 等需后续手动绑定。");
Selection.activeGameObject = go;
MarkDirtyAndLog("Player", go, report);
}
[MenuItem("BaseGames/Scene/Place/Player Spawn Point", priority = 105)]
public static void PlacePlayerSpawnPoint()
{
var report = new List<string>();
GameObject go = new GameObject("SpawnPoint");
Undo.RegisterCreatedObjectUndo(go, "Place Player Spawn Point");
go.transform.position = GetDropPosition();
PlayerSpawnPoint spawnPoint = GetOrAddComponent<PlayerSpawnPoint>(go);
AssignString(spawnPoint, "_transitionId", "default", report);
AssignInt(spawnPoint, "_facingDirection", 1);
report.Add("修改 _transitionId,使其与对应 RoomTransition._targetTransitionId 匹配。");
report.Add("+1 = 朝右出生,-1 = 朝左出生(_facingDirection)。");
Selection.activeGameObject = go;
MarkDirtyAndLog("Player Spawn Point", go, report);
}
[MenuItem("BaseGames/Scene/Place/Enemy (Basic)", priority = 110)]
public static void PlaceEnemy()
{
var report = new List<string>();
GameObject go = new GameObject("BasicEnemy");
Undo.RegisterCreatedObjectUndo(go, "Place Enemy");
go.transform.position = GetDropPosition();
SetLayer(go, "Enemy", report);
Rigidbody2D rb = GetOrAddComponent<Rigidbody2D>(go);
rb.bodyType = RigidbodyType2D.Dynamic;
rb.gravityScale = 2f;
rb.constraints = RigidbodyConstraints2D.FreezeRotation;
GetOrAddComponent<CapsuleCollider2D>(go);
GetOrAddComponent<Animator>(go);
SetupSpriteRenderer(go);
EnemyBase enemyBase = GetOrAddComponent<EnemyBase>(go);
EnemyStats enemyStats = GetOrAddComponent<EnemyStats>(go);
// HurtBox child
Transform hurtBoxT = GetOrCreateChild(go.transform, "HurtBox");
SetLayer(hurtBoxT.gameObject, "EnemyHurtBox", report);
CapsuleCollider2D hurtCollider = GetOrAddComponent<CapsuleCollider2D>(hurtBoxT.gameObject);
hurtCollider.isTrigger = true;
HurtBox hurtBox = GetOrAddComponent<HurtBox>(hurtBoxT.gameObject);
// Contact-damage HitBox child
Transform hitBodyT = GetOrCreateChild(go.transform, "HitBox_Body");
SetLayer(hitBodyT.gameObject, "EnemyHitBox", report);
CircleCollider2D hitCollider = GetOrAddComponent<CircleCollider2D>(hitBodyT.gameObject);
hitCollider.isTrigger = true;
hitCollider.radius = 0.55f;
HitBox hitBox = GetOrAddComponent<HitBox>(hitBodyT.gameObject);
GetOrAddComponent<BodyContactDamage>(hitBodyT.gameObject);
// References
AssignReference(enemyBase, "_stats", enemyStats, report);
// DamageSourceSO for body contact (optional — create manually if missing)
Object dmgSrc = FindFirstAsset("DS_EnemyBody", "DS_TestEnemyBody");
if (dmgSrc != null)
AssignReference(hitBox, "_defaultSource", dmgSrc, report);
else
report.Add("未找到 DamageSourceSO (DS_EnemyBody)HitBox_Body._defaultSource 未绑定。请创建后手动指定。");
// Event channels
AssignAsset(enemyBase, "_onEnemyDied", report, false, "EVT_EnemyDied");
AssignAsset(enemyBase, "_onPlayerSpawned", report, false, "EVT_PlayerSpawned");
AssignAsset(enemyStats, "_onDifficultyChanged", report, false, "EVT_DifficultyChanged");
AssignAsset(hurtBox, "_onDamageDealt", report, false, "EVT_DamageDealt");
AssignAsset(hurtBox, "_onHitConfirmed", report, false, "EVT_HitConfirmed");
// EnemyStatsSO (optional)
Object enemyStatsSO = FindFirstAsset("BasicEnemyStats", "EnemyStatsSO");
if (enemyStatsSO != null)
AssignReference(enemyBase, "_statsSO", enemyStatsSO, report);
else
report.Add("未找到 EnemyStatsSOEnemyBase._statsSO 未绑定。请在 Data/Enemies/ 创建后手动指定。");
report.Add("行为树、导航参数(NavAgent)、动画片段需后续手工挂载。");
Selection.activeGameObject = go;
MarkDirtyAndLog("Enemy (Basic)", go, report);
}
[MenuItem("BaseGames/Scene/Place/Boss Enemy", priority = 115)]
public static void PlaceBossEnemy()
{
var report = new List<string>();
GameObject go = new GameObject("BossEnemy");
Undo.RegisterCreatedObjectUndo(go, "Place Boss Enemy");
go.transform.position = GetDropPosition();
SetLayer(go, "Enemy", report);
Rigidbody2D rb = GetOrAddComponent<Rigidbody2D>(go);
rb.bodyType = RigidbodyType2D.Dynamic;
rb.gravityScale = 2f;
rb.constraints = RigidbodyConstraints2D.FreezeRotation;
rb.collisionDetectionMode = CollisionDetectionMode2D.Continuous;
rb.interpolation = RigidbodyInterpolation2D.Interpolate;
GetOrAddComponent<CapsuleCollider2D>(go);
GetOrAddComponent<Animator>(go);
SetupSpriteRenderer(go);
BossBase bossBase = GetOrAddComponent<BossBase>(go);
EnemyStats bossStats = GetOrAddComponent<EnemyStats>(go);
// HurtBox child
Transform hurtBoxT = GetOrCreateChild(go.transform, "HurtBox");
SetLayer(hurtBoxT.gameObject, "EnemyHurtBox", report);
CapsuleCollider2D hurtCollider = GetOrAddComponent<CapsuleCollider2D>(hurtBoxT.gameObject);
hurtCollider.isTrigger = true;
hurtCollider.size = new Vector2(1.5f, 2.5f);
HurtBox hurtBox = GetOrAddComponent<HurtBox>(hurtBoxT.gameObject);
// Contact-damage HitBox child
Transform hitBodyT = GetOrCreateChild(go.transform, "HitBox_Body");
SetLayer(hitBodyT.gameObject, "EnemyHitBox", report);
CircleCollider2D hitCollider = GetOrAddComponent<CircleCollider2D>(hitBodyT.gameObject);
hitCollider.isTrigger = true;
hitCollider.radius = 0.9f;
HitBox hitBox = GetOrAddComponent<HitBox>(hitBodyT.gameObject);
GetOrAddComponent<BodyContactDamage>(hitBodyT.gameObject);
// References
AssignReference(bossBase, "_stats", bossStats, report);
// DamageSourceSO
Object dmgSrc = FindFirstAsset("DS_BossBody", "DS_EnemyBody");
if (dmgSrc != null)
AssignReference(hitBox, "_defaultSource", dmgSrc, report);
else
report.Add("未找到 DamageSourceSOHitBox_Body._defaultSource 未绑定。");
// Event channels
AssignAsset(bossBase, "_onEnemyDied", report, false, "EVT_EnemyDied");
AssignAsset(bossBase, "_onPlayerSpawned", report, false, "EVT_PlayerSpawned");
AssignAsset(bossBase, "_onBossFightEnded", report, false, "EVT_BossFightEnded");
AssignAsset(bossBase, "_onBossPhaseChanged", report, false, "EVT_BossPhaseChanged");
AssignAsset(bossStats, "_onDifficultyChanged", report, false, "EVT_DifficultyChanged");
AssignAsset(hurtBox, "_onDamageDealt", report, false, "EVT_DamageDealt");
AssignAsset(hurtBox, "_onHitConfirmed", report, false, "EVT_HitConfirmed");
report.Add("填写 _bossId。");
report.Add("挂载 BossSkillSequencer 组件并指定技能序列 SO;行为树、NavAgent 需手工添加。");
report.Add("多阶段 Boss 可在此 GameObject 上继续 AddComponent 阶段切换控制器。");
Selection.activeGameObject = go;
MarkDirtyAndLog("Boss Enemy", go, report);
}
[MenuItem("BaseGames/Scene/Place/Hazard (LethalTrap)", priority = 120)]
public static void PlaceLethalTrap()
{
var report = new List<string>();
GameObject go = new GameObject("LethalTrap");
Undo.RegisterCreatedObjectUndo(go, "Place LethalTrap");
go.transform.position = GetDropPosition();
SetLayer(go, "EnemyHitBox", report);
BoxCollider2D col = GetOrAddComponent<BoxCollider2D>(go);
col.isTrigger = true;
col.size = new Vector2(2f, 0.5f);
SetupSpriteRenderer(go);
LethalTrap trap = GetOrAddComponent<LethalTrap>(go);
AssignLayerMask(trap, "_playerLayers", "PlayerHurtBox", report);
AssignInt(trap, "_damage", 1);
AssignBool(trap, "_canPogo", true);
// Child HurtBox (EnemyHurtBox layer) to allow pogo when _canPogo = true
Transform hurtBoxT = GetOrCreateChild(go.transform, "HurtBox");
SetLayer(hurtBoxT.gameObject, "EnemyHurtBox", report);
BoxCollider2D hurtCol = GetOrAddComponent<BoxCollider2D>(hurtBoxT.gameObject);
hurtCol.isTrigger = true;
hurtCol.size = new Vector2(2f, 0.3f);
GetOrAddComponent<HurtBox>(hurtBoxT.gameObject);
AssignAsset(trap, "_onPlayerDied", report, false, "EVT_PlayerDied");
report.Add("_canPogo=true:子 HurtBox 供玩家下劈弹起;设为 false 可改为纯死亡区(无需子 HurtBox)。");
Selection.activeGameObject = go;
MarkDirtyAndLog("Hazard (LethalTrap)", go, report);
}
[MenuItem("BaseGames/Scene/Place/Collectible (LingZhu)", priority = 125)]
public static void PlaceCollectible()
{
var report = new List<string>();
GameObject go = new GameObject("Collectible_LingZhu");
Undo.RegisterCreatedObjectUndo(go, "Place Collectible");
go.transform.position = GetDropPosition();
Rigidbody2D rb = GetOrAddComponent<Rigidbody2D>(go);
rb.gravityScale = 1f;
rb.freezeRotation = true;
rb.interpolation = RigidbodyInterpolation2D.Interpolate;
CircleCollider2D col = GetOrAddComponent<CircleCollider2D>(go);
col.isTrigger = true;
col.radius = 0.3f;
SetupSpriteRenderer(go);
Collectible collectible = GetOrAddComponent<Collectible>(go);
// CollectibleType.LingZhu = 0
AssignInt(collectible, "_type", 0);
AssignInt(collectible, "_lingZhuAmount", 1);
AssignBool(collectible, "_isPersistent", false);
AssignAsset(collectible, "_onCollectiblePickup", report, false, "EVT_ItemPickup", "EVT_CollectiblePickup");
AssignAsset(collectible, "_onCollectibleSaved", report, false, "EVT_CollectibleSaved");
report.Add("若为场景固定摆放道具,设 _isPersistent = true 并填写唯一 _collectibleId。");
Selection.activeGameObject = go;
MarkDirtyAndLog("Collectible (LingZhu)", go, report);
}
[MenuItem("BaseGames/Scene/Place/Save Point", priority = 130)]
public static void PlaceSavePoint()
{
var report = new List<string>();
GameObject go = new GameObject("SavePoint");
Undo.RegisterCreatedObjectUndo(go, "Place Save Point");
go.transform.position = GetDropPosition();
SetLayer(go, "TriggerZone", report);
BoxCollider2D col = GetOrAddComponent<BoxCollider2D>(go);
col.isTrigger = true;
col.size = new Vector2(1f, 1.5f);
SetupSpriteRenderer(go);
SavePoint savePoint = GetOrAddComponent<SavePoint>(go);
AssignAsset(savePoint, "_onSavePointActivated", report, false, "EVT_SavePointActivated");
AssignAsset(savePoint, "_onFastTravelOpen", report, false, "EVT_FastTravelOpen");
Selection.activeGameObject = go;
MarkDirtyAndLog("Save Point", go, report);
}
[MenuItem("BaseGames/Scene/Place/Room Transition", priority = 135)]
public static void PlaceRoomTransition()
{
var report = new List<string>();
GameObject go = new GameObject("RoomTransition");
Undo.RegisterCreatedObjectUndo(go, "Place Room Transition");
go.transform.position = GetDropPosition();
SetLayer(go, "TriggerZone", report);
BoxCollider2D col = GetOrAddComponent<BoxCollider2D>(go);
col.isTrigger = true;
col.size = new Vector2(1f, 2.5f);
RoomTransition transition = GetOrAddComponent<RoomTransition>(go);
AssignString(transition, "_transitionId", "exit_default", report);
AssignBool(transition, "_autoTrigger", true);
AssignBool(transition, "_requiresKeyItem", false);
AssignAsset(transition, "_onSceneLoadRequest", report, false, "EVT_SceneLoadRequest");
report.Add("填写 _transitionId(本出口唯一 ID)、_targetSceneAddress(目标场景 Addressable Key)、_targetTransitionId(目标出生点 ID)。");
report.Add("若需锁门,设 _requiresKeyItem = true 并填写 _requiredItemId。");
report.Add("_worldState 字段需拖入 WorldStateRegistry SO(可选)。");
Selection.activeGameObject = go;
MarkDirtyAndLog("Room Transition", go, report);
}
[MenuItem("BaseGames/Scene/Place/Room Camera", priority = 140)]
public static void PlaceRoomCamera()
{
var report = new List<string>();
GameObject go = new GameObject("RoomCamera");
Undo.RegisterCreatedObjectUndo(go, "Place Room Camera");
go.transform.position = GetDropPosition();
CinemachineCamera cinemachine = GetOrAddComponent<CinemachineCamera>(go);
RoomCamera roomCamera = GetOrAddComponent<RoomCamera>(go);
CinemachineConfiner2D confiner = GetOrAddComponent<CinemachineConfiner2D>(go);
// RoomBoundary child — defines the camera confinement area
Transform boundaryT = GetOrCreateChild(go.transform, "RoomBoundary");
PolygonCollider2D boundaryCollider = GetOrAddComponent<PolygonCollider2D>(boundaryT.gameObject);
boundaryCollider.pathCount = 1;
boundaryCollider.SetPath(0, new Vector2[]
{
new Vector2(-12f, -6f),
new Vector2(-12f, 6f),
new Vector2( 12f, 6f),
new Vector2( 12f, -6f),
});
RoomVisibleArea visibleArea = GetOrAddComponent<RoomVisibleArea>(boundaryT.gameObject);
AssignReference(roomCamera, "_visibleArea", visibleArea, report);
AssignReference(confiner, "m_BoundingShape2D", boundaryCollider, report);
// Disable any Camera and AudioListener added by Cinemachine
UnityEngine.Camera cam = go.GetComponent<UnityEngine.Camera>();
if (cam != null) cam.enabled = false;
AudioListener al = go.GetComponent<AudioListener>();
if (al != null) { Undo.DestroyObjectImmediate(al); }
report.Add("将 Player/CameraFollowTarget Transform 拖入 CinemachineCamera.Follow 字段以跟随玩家(或使用 Room Camera Setup 工具批量赋值)。");
report.Add("调整 RoomBoundary PolygonCollider2D 顶点以匹配房间边界。");
Selection.activeGameObject = go;
MarkDirtyAndLog("Room Camera", go, report);
}
[MenuItem("BaseGames/Scene/Place/Ground Platform", priority = 150)]
public static void PlaceGroundPlatform()
{
var report = new List<string>();
GameObject go = new GameObject("GroundPlatform");
Undo.RegisterCreatedObjectUndo(go, "Place Ground Platform");
go.transform.position = GetDropPosition();
SetLayer(go, "Ground", report);
// 2D Sprite:用 localScale 设定尺寸,让 SpriteRenderer 和 BoxCollider2D 同步缩放
go.transform.localScale = new Vector3(8f, 0.5f, 1f);
GetOrAddComponent<BoxCollider2D>(go);
SetupSpriteRenderer(go);
Rigidbody2D rb = GetOrAddComponent<Rigidbody2D>(go);
rb.bodyType = RigidbodyType2D.Static;
Selection.activeGameObject = go;
MarkDirtyAndLog("Ground Platform", go, report);
}
[MenuItem("BaseGames/Scene/Place/Moving Platform", priority = 155)]
public static void PlaceMovingPlatform()
{
var report = new List<string>();
GameObject go = new GameObject("MovingPlatform");
Undo.RegisterCreatedObjectUndo(go, "Place Moving Platform");
go.transform.position = GetDropPosition();
SetLayer(go, "Ground", report);
Rigidbody2D rb = GetOrAddComponent<Rigidbody2D>(go);
rb.bodyType = RigidbodyType2D.Kinematic;
rb.interpolation = RigidbodyInterpolation2D.Interpolate;
rb.freezeRotation = true;
BoxCollider2D col = GetOrAddComponent<BoxCollider2D>(go);
col.size = new Vector2(4f, 0.4f);
SetupSpriteRenderer(go);
// Passenger sensor — trigger collider just above the platform surface
Transform sensorT = GetOrCreateChild(go.transform, "PassengerSensor");
BoxCollider2D sensorCol = GetOrAddComponent<BoxCollider2D>(sensorT.gameObject);
sensorCol.isTrigger = true;
sensorCol.size = new Vector2(3.8f, 0.25f);
sensorCol.offset = new Vector2(0f, 0.33f);
// Waypoint markers (LinearAB mode end points)
Transform wpA = GetOrCreateChild(go.transform, "WaypointA");
Transform wpB = GetOrCreateChild(go.transform, "WaypointB");
wpA.localPosition = new Vector3(-3f, 0f, 0f);
wpB.localPosition = new Vector3(3f, 0f, 0f);
MovingPlatform platform = GetOrAddComponent<MovingPlatform>(go);
AssignReference(platform, "_passengerSensor", sensorCol, report);
AssignObjectArray(platform, "_wayPoints", new Object[] { wpA, wpB }, report);
report.Add("WaypointA / WaypointB 为移动端点,可将其拖出平台并在场景中调整位置。");
report.Add("如需触发激活,改 _moveType = TriggeredLinear 并将 VoidEventChannelSO 拖入 _activationChannel。");
Selection.activeGameObject = go;
MarkDirtyAndLog("Moving Platform", go, report);
}
[MenuItem("BaseGames/Scene/Place/Tilemap Ground", priority = 160)]
public static void PlaceTilemapGround()
{
var report = new List<string>();
GameObject gridGo = new GameObject("GroundGrid");
Undo.RegisterCreatedObjectUndo(gridGo, "Place Tilemap Ground");
gridGo.transform.position = GetDropPosition();
GetOrAddComponent<Grid>(gridGo);
GameObject groundGo = GetOrCreateChild(gridGo.transform, "Ground").gameObject;
SetLayer(groundGo, "Ground", report);
GetOrAddComponent<Tilemap>(groundGo);
GetOrAddComponent<TilemapRenderer>(groundGo);
TilemapCollider2D tilemapCollider = GetOrAddComponent<TilemapCollider2D>(groundGo);
tilemapCollider.usedByComposite = true;
Rigidbody2D rb = GetOrAddComponent<Rigidbody2D>(groundGo);
rb.bodyType = RigidbodyType2D.Static;
GetOrAddComponent<CompositeCollider2D>(groundGo);
report.Add("在 Tilemap 组件中使用 Tile Palette 绘制地形。");
Selection.activeGameObject = gridGo;
MarkDirtyAndLog("Tilemap Ground", gridGo, report);
}
[MenuItem("BaseGames/Scene/Place/Nav Surface", priority = 170)]
public static void PlaceNavSurface()
{
var report = new List<string>();
GameObject go = new GameObject("NavSurface");
Undo.RegisterCreatedObjectUndo(go, "Place Nav Surface");
go.transform.position = GetDropPosition();
GetOrAddComponent<NavSurface>(go);
report.Add("NavSurface 已添加。在 Inspector 中点击 Bake 生成导航网格。");
Selection.activeGameObject = go;
MarkDirtyAndLog("Nav Surface", go, report);
}
[MenuItem("BaseGames/Scene/Place/Camera Trigger Zone", priority = 180)]
public static void PlaceCameraTriggerZone()
{
var report = new List<string>();
GameObject go = new GameObject("CameraTriggerZone");
Undo.RegisterCreatedObjectUndo(go, "Place Camera Trigger Zone");
go.transform.position = GetDropPosition();
SetLayer(go, "TriggerZone", report);
BoxCollider2D col = GetOrAddComponent<BoxCollider2D>(go);
col.isTrigger = true;
col.size = new Vector2(2f, 2f);
GetOrAddComponent<CameraTriggerZone>(go);
report.Add("将目标 RoomCamera 拖入 CameraTriggerZone._targetCamera 字段。");
Selection.activeGameObject = go;
MarkDirtyAndLog("Camera Trigger Zone", go, report);
}
[MenuItem("BaseGames/Scene/Place/Obstacle (Static)", priority = 190)]
public static void PlaceObstacle()
{
var report = new List<string>();
GameObject go = new GameObject("Obstacle");
Undo.RegisterCreatedObjectUndo(go, "Place Obstacle");
go.transform.position = GetDropPosition();
SetLayer(go, "Ground", report);
// 2D Sprite:用 localScale 设定尺寸,让 SpriteRenderer 和 BoxCollider2D 同步缩放
go.transform.localScale = new Vector3(1f, 1f, 1f);
GetOrAddComponent<BoxCollider2D>(go);
SetupSpriteRenderer(go);
Rigidbody2D rb = GetOrAddComponent<Rigidbody2D>(go);
rb.bodyType = RigidbodyType2D.Static;
Selection.activeGameObject = go;
MarkDirtyAndLog("Obstacle (Static)", go, report);
}
[MenuItem("BaseGames/Scene/Place/Interactable NPC", priority = 195)]
public static void PlaceInteractableNPC()
{
var report = new List<string>();
GameObject go = new GameObject("NPC");
Undo.RegisterCreatedObjectUndo(go, "Place Interactable NPC");
go.transform.position = GetDropPosition();
// Interaction range trigger (matches InteractableNPC._interactRadius default)
CircleCollider2D rangeTrigger = GetOrAddComponent<CircleCollider2D>(go);
rangeTrigger.isTrigger = true;
rangeTrigger.radius = 1.5f;
GetOrAddComponent<InteractableNPC>(go);
GetOrAddComponent<Animator>(go);
SetupSpriteRenderer(go);
report.Add("填写 _npcId(全局唯一)。");
report.Add("将 DialogueSequenceSO 拖入 _defaultDialogue 字段。");
report.Add("若为任务 NPC,将 InteractableNPC 替换为 QuestGiver 组件。");
report.Add("NPC 动画控制器需手工指定。");
Selection.activeGameObject = go;
MarkDirtyAndLog("Interactable NPC", go, report);
}
// ══ 私有辅助方法 ══════════════════════════════════════════════════════
/// <summary>
/// 返回用于放置新对象的世界坐标:优先使用 SceneView 视口中心,否则原点。
/// </summary>
private static Vector3 GetDropPosition()
{
SceneView sv = SceneView.lastActiveSceneView;
if (sv != null)
{
Vector3 pos = sv.pivot;
pos.z = 0f; // 2D 游戏固定 z=0
return pos;
}
return Vector3.zero;
}
private static T GetOrAddComponent<T>(GameObject go) where T : Component
{
T comp = go.GetComponent<T>();
return comp != null ? comp : Undo.AddComponent<T>(go);
}
/// <summary>
/// SpriteRenderer 添加并赋值 Unity 内置默认 Sprite(白色圆角方块)。
/// 若已有 Sprite 则不覆盖(防止覆盖手动赋値)。
/// </summary>
private static SpriteRenderer SetupSpriteRenderer(GameObject go)
{
var sr = GetOrAddComponent<SpriteRenderer>(go);
if (sr.sprite == null)
sr.sprite = AssetDatabase.LoadAssetAtPath<Sprite>(
"Packages/com.unity.2d.sprite/Editor/ObjectMenuCreation/DefaultAssets/Textures/v2/Square.png");
return sr;
}
private static Transform GetOrCreateChild(Transform parent, string name)
{
Transform child = parent.Find(name);
if (child != null)
return child;
GameObject go = new GameObject(name);
Undo.RegisterCreatedObjectUndo(go, $"Create {name}");
go.transform.SetParent(parent, false);
return go.transform;
}
private static void SetLayer(GameObject go, string layerName, List<string> report)
{
int layer = LayerMask.NameToLayer(layerName);
if (layer == -1)
report.Add($"Layer '{layerName}' 不存在,请在 Tags and Layers 中创建。");
else
go.layer = layer;
}
private static void AssignReference(Object target, string propName, Object value, List<string> report = null)
{
var so = new SerializedObject(target);
var sp = so.FindProperty(propName);
if (sp == null)
{
report?.Add($"{target.GetType().Name}.{propName} 字段不存在,跳过引用赋值。");
return;
}
sp.objectReferenceValue = value;
so.ApplyModifiedPropertiesWithoutUndo();
}
private static void AssignAsset(Object target, string propName, List<string> report, bool required, params string[] candidates)
{
Object asset = FindFirstAsset(candidates);
if (asset == null && required)
report.Add($"未找到 {target.GetType().Name}.{propName} 需要的资产: {string.Join(" / ", candidates)}");
if (asset != null)
AssignReference(target, propName, asset, report);
}
private static void AssignLayerMask(Object target, string propName, string layerName, List<string> report)
{
int layer = LayerMask.NameToLayer(layerName);
if (layer == -1)
{
report.Add($"Layer '{layerName}' 不存在,{target.GetType().Name}.{propName} 未能赋值 LayerMask。");
return;
}
var so = new SerializedObject(target);
var sp = so.FindProperty(propName);
if (sp == null)
{
report.Add($"{target.GetType().Name}.{propName} 字段不存在,跳过 LayerMask 赋值。");
return;
}
sp.intValue = 1 << layer;
so.ApplyModifiedPropertiesWithoutUndo();
}
private static void AssignInt(Object target, string propName, int value)
{
var so = new SerializedObject(target);
var sp = so.FindProperty(propName);
if (sp != null)
{
sp.intValue = value;
so.ApplyModifiedPropertiesWithoutUndo();
}
}
private static void AssignBool(Object target, string propName, bool value)
{
var so = new SerializedObject(target);
var sp = so.FindProperty(propName);
if (sp != null)
{
sp.boolValue = value;
so.ApplyModifiedPropertiesWithoutUndo();
}
}
private static void AssignString(Object target, string propName, string value, List<string> report = null)
{
var so = new SerializedObject(target);
var sp = so.FindProperty(propName);
if (sp == null)
{
report?.Add($"{target.GetType().Name}.{propName} 字段不存在,跳过字符串赋值。");
return;
}
sp.stringValue = value;
so.ApplyModifiedPropertiesWithoutUndo();
}
private static void AssignObjectArray(Object target, string propName, Object[] values, List<string> report = null)
{
var so = new SerializedObject(target);
var sp = so.FindProperty(propName);
if (sp == null || !sp.isArray)
{
report?.Add($"{target.GetType().Name}.{propName} 不是可写数组字段,跳过数组赋值。");
return;
}
sp.arraySize = values.Length;
for (int i = 0; i < values.Length; i++)
sp.GetArrayElementAtIndex(i).objectReferenceValue = values[i];
so.ApplyModifiedPropertiesWithoutUndo();
}
private static Object FindFirstAsset(params string[] candidates)
{
foreach (string candidate in candidates)
{
if (string.IsNullOrWhiteSpace(candidate))
continue;
string[] guids = AssetDatabase.FindAssets(candidate);
foreach (string guid in guids)
{
string path = AssetDatabase.GUIDToAssetPath(guid);
Object asset = AssetDatabase.LoadMainAssetAtPath(path);
if (asset != null && asset.name == candidate)
return asset;
}
}
return null;
}
private static void MarkDirtyAndLog(string label, GameObject root, List<string> report)
{
EditorUtility.SetDirty(root);
UnityEditor.SceneManagement.EditorSceneManager.MarkSceneDirty(root.scene);
if (report != null && report.Count > 0)
Debug.Log($"[SceneObjectPlacer] {label} 已放置。\n " + string.Join("\n ", report));
else
Debug.Log($"[SceneObjectPlacer] {label} 已放置。");
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f3e7994893f6c2942acb4d724e879460
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,566 @@
using System.Collections.Generic;
using BaseGames.Audio;
using BaseGames.Camera;
using BaseGames.Core;
using BaseGames.Core.Events;
using BaseGames.Core.Save;
using BaseGames.Core.Pool;
using BaseGames.Input;
using BaseGames.UI;
using BaseGames.UI.HUD;
using BaseGames.UI.Menus;
using BaseGames.World;
using PathBerserker2d;
using Unity.Cinemachine;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.Tilemaps;
using UnityEngine.UI;
namespace BaseGames.Editor
{
public static class SceneScaffoldTools
{
[MenuItem("BaseGames/Tools/Scaffold Persistent Scene")]
public static void ScaffoldPersistentScene()
{
var report = new List<string>();
EnsureEventChannelAssets(report);
GameObject root = GetOrCreateRoot("[Persistent]");
Transform services = GetOrCreateChild(root.transform, "[Services]");
Transform input = GetOrCreateChild(root.transform, "[Input]");
Transform camera = GetOrCreateChild(root.transform, "[Camera]");
Transform ui = GetOrCreateChild(root.transform, "[UI]");
GameObject registrarGo = GetOrCreateChild(services, "GameServiceRegistrar").gameObject;
GameObject deathRespawnGo = GetOrCreateChild(services, "DeathRespawnService").gameObject;
GameObject sceneServiceGo = GetOrCreateChild(services, "SceneService").gameObject;
GameObject sceneLoaderGo = GetOrCreateChild(services, "SceneLoader").gameObject;
GameObject registryGo = GetOrCreateChild(services, "EventChannelRegistry").gameObject;
GameObject settingsGo = GetOrCreateChild(services, "SettingsManager").gameObject;
GameObject poolGo = GetOrCreateChild(services, "GlobalObjectPool").gameObject;
GameObject gameManagerGo = GetOrCreateChild(services, "GameManager").gameObject;
GameObject audioManagerGo = GetOrCreateChild(services, "AudioManager").gameObject;
GameObject saveManagerGo = GetOrCreateChild(services, "GameSaveManager").gameObject;
GameServiceRegistrar registrar = GetOrAddComponent<GameServiceRegistrar>(registrarGo);
DeathRespawnService deathRespawnService = GetOrAddComponent<DeathRespawnService>(deathRespawnGo);
SceneService sceneService = GetOrAddComponent<SceneService>(sceneServiceGo);
SceneLoader sceneLoader = GetOrAddComponent<SceneLoader>(sceneLoaderGo);
EventChannelRegistry registry = GetOrAddComponent<EventChannelRegistry>(registryGo);
SettingsManager settingsManager = GetOrAddComponent<SettingsManager>(settingsGo);
GetOrAddComponent<GlobalObjectPool>(poolGo);
GameManager gameManager = GetOrAddComponent<GameManager>(gameManagerGo);
AudioManager audioManager = GetOrAddComponent<AudioManager>(audioManagerGo);
GameSaveManager gameSaveManager = GetOrAddComponent<GameSaveManager>(saveManagerGo);
GameObject inputHolderGo = GetOrCreateChild(input, "InputReaderHolder").gameObject;
Object inputReaderAsset = FindFirstAssetByType<InputReaderSO>("InputReader", "InputReaderSO");
if (inputReaderAsset == null)
inputReaderAsset = EnsureInputReaderAsset(report);
InputReaderBootstrap inputBootstrap = GetOrAddComponent<InputReaderBootstrap>(inputHolderGo);
AssignReference(inputBootstrap, "_inputReader", inputReaderAsset, report);
if (inputReaderAsset != null)
{
AssignReference(inputReaderAsset, "_onPauseRequested", FindFirstAssetByType<VoidEventChannelSO>("EVT_PauseRequested"), report);
AssignReference(inputReaderAsset, "_inputActions", FindFirstAssetWithExtension(".inputactions", "PlayerInputActions", "InputActions"), report);
}
if (inputReaderAsset == null)
report.Add("未找到 InputReaderSO 资产,InputReaderBootstrap 将保持空引用。请补齐 Assets/_Game/Data/Player/Input/InputReader.asset。");
GameObject mainCameraGo = GetOrCreateChild(camera, "Main Camera").gameObject;
UnityEngine.Camera mainCamera = GetOrAddComponent<UnityEngine.Camera>(mainCameraGo);
mainCamera.orthographic = false;
mainCamera.fieldOfView = 60f;
mainCameraGo.tag = "MainCamera";
GetOrAddComponent<AudioListener>(mainCameraGo);
CinemachineBrain brain = GetOrAddComponent<CinemachineBrain>(mainCameraGo);
GameObject cameraStateGo = GetOrCreateChild(camera, "CameraStateController").gameObject;
CameraStateController cameraStateController = GetOrAddComponent<CameraStateController>(cameraStateGo);
CinemachineImpulseSource impulseSource = GetOrAddComponent<CinemachineImpulseSource>(cameraStateGo);
GameObject uiRootGo = GetOrCreateChild(ui, "UIRoot").gameObject;
UIManager uiManager = GetOrAddComponent<UIManager>(uiRootGo);
GameObject hudCanvasGo = GetOrCreateCanvas(uiRootGo.transform, "HUD Canvas", 0);
GameObject hudRootGo = GetOrCreateChild(hudCanvasGo.transform, "HUDRoot").gameObject;
HUDController hudController = GetOrAddComponent<HUDController>(hudRootGo);
GameObject pauseRootGo = GetOrCreateChild(uiRootGo.transform, "PauseMenuRoot").gameObject;
GameObject settingsRootGo = GetOrCreateChild(uiRootGo.transform, "SettingsRoot").gameObject;
GameObject mapRootGo = GetOrCreateChild(uiRootGo.transform, "MapRoot").gameObject;
GameObject shopRootGo = GetOrCreateChild(uiRootGo.transform, "ShopRoot").gameObject;
pauseRootGo.SetActive(false);
settingsRootGo.SetActive(false);
mapRootGo.SetActive(false);
shopRootGo.SetActive(false);
GameObject deathCanvasGo = GetOrCreateCanvas(uiRootGo.transform, "DeathScreen Canvas", 10);
GameObject deathRootGo = GetOrCreateChild(deathCanvasGo.transform, "DeathScreenRoot").gameObject;
DeathScreenController deathScreenController = GetOrAddComponent<DeathScreenController>(deathRootGo);
deathRootGo.SetActive(false);
GameObject respawnButtonGo = GetOrCreateChild(deathRootGo.transform, "RespawnButton").gameObject;
GetOrAddComponent<Image>(respawnButtonGo);
Button respawnButton = GetOrAddComponent<Button>(respawnButtonGo);
EnsureAudioSources(audioManagerGo, audioManager, report);
AssignReference(registrar, "_deathRespawnService", deathRespawnService);
AssignReference(registrar, "_sceneService", sceneService);
AssignReference(registrar, "_eventChannelRegistry", registry);
AssignReference(registrar, "_saveManager", gameSaveManager);
AssignReference(gameManager, "_settingsManager", settingsManager);
AssignReference(gameManager, "_deathRespawnService", deathRespawnService);
AssignReference(gameManager, "_sceneService", sceneService);
AssignAsset(gameManager, "_onPlayerDied", report, true, "EVT_PlayerDied");
AssignAsset(gameManager, "_onPauseRequested", report, false, "EVT_PauseRequested");
AssignAsset(gameManager, "_onResumeRequested", report, false, "EVT_ResumeRequested", "EVT_PauseResumed");
AssignAsset(gameManager, "_onBossFightStarted", report, false, "EVT_BossFightStarted", "EVT_BossFight");
AssignAsset(gameManager, "_onBossFightEnded", report, false, "EVT_BossFightEnded");
AssignAsset(gameManager, "_onDeathScreenConfirmed", report, true, "EVT_DeathScreenConfirmed");
AssignAsset(gameManager, "_onGameStateChanged", report, true, "EVT_GameStateChanged", "EVT_GameState");
AssignAsset(gameManager, "_onPlayerRespawned", report, false, "EVT_PlayerRespawned", "EVT_PlayerRespawn");
AssignAsset(sceneService, "_onSceneLoadRequest", report, false, "EVT_SceneLoadRequest");
AssignAsset(sceneService, "_onFadeInRequest", report, false, "EVT_FadeInRequest");
AssignAsset(sceneService, "_onFadeOutRequest", report, false, "EVT_FadeOutRequest");
AssignReference(sceneService, "_sceneLoader", sceneLoader);
AssignAsset(sceneLoader, "_onSceneLoaded", report, false, "EVT_SceneLoaded");
AssignAsset(deathRespawnService, "_onRespawnStarted", report, false, "EVT_RespawnStarted");
AssignAsset(deathRespawnService, "_onRespawnCompleted", report, false, "EVT_RespawnCompleted");
AssignAsset(deathRespawnService, "_onDeathScreenConfirmed", report, true, "EVT_DeathScreenConfirmed");
AssignAsset(settingsManager, "_defaultSettings", report, false, "SET_GlobalSettings");
AssignAsset(audioManager, "_onPlayerDied", report, false, "EVT_PlayerDied");
AssignReference(cameraStateController, "_brain", brain);
AssignReference(cameraStateController, "_impulseSource", impulseSource);
AssignReference(uiManager, "_hudRoot", hudRootGo);
AssignReference(uiManager, "_pauseMenuRoot", pauseRootGo);
AssignReference(uiManager, "_deathScreenRoot", deathRootGo);
AssignReference(uiManager, "_settingsRoot", settingsRootGo);
AssignReference(uiManager, "_mapRoot", mapRootGo);
AssignReference(uiManager, "_shopRoot", shopRootGo);
AssignAsset(uiManager, "_onGameStateChanged", report, true, "EVT_GameStateChanged", "EVT_GameState");
AssignAsset(uiManager, "_onPauseRequested", report, false, "EVT_PauseRequested");
AssignAsset(uiManager, "_onFastTravelOpen", report, false, "EVT_FastTravelOpen");
AssignAsset(uiManager, "_onShopOpen", report, false, "EVT_ShopOpen");
AssignAsset(uiManager, "_onMapOpen", report, false, "EVT_MapOpen");
AssignReference(deathScreenController, "_btnRespawn", respawnButton);
AssignAsset(deathScreenController, "_onPlayerDied", report, true, "EVT_PlayerDied");
AssignAsset(deathScreenController, "_onDeathScreenConfirmed", report, true, "EVT_DeathScreenConfirmed");
AddScaffoldNote(hudRootGo, "HUDController 已挂载。其内部图片/文本/图标 Prefab 依赖较多,需后续手工补 UI 资源与事件频道。", report);
MarkDirtyAndLog("Persistent 场景脚手架", root, report);
}
// ─────────────────────────────────────────────────────────────────────
// Scaffold Game Room
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// 在当前活动场景中生成标准游戏关卡房间的完整层级结构:
/// [RoomRoot] → [Camera] / [SpawnPoints] / [Environment] / [Transitions]
/// 可配合 SceneObjectPlacerTool 在层级内快速追加更多对象。
/// </summary>
[MenuItem("BaseGames/Tools/Scaffold Game Room", priority = 201)]
public static void ScaffoldGameRoom()
{
var report = new List<string>();
// ── [RoomRoot] ─────────────────────────────────────────────────
GameObject root = GetOrCreateRoot("[RoomRoot]");
RoomController roomController = GetOrAddComponent<RoomController>(root);
// ── [Camera] ───────────────────────────────────────────────────
Transform cameraGroup = GetOrCreateChild(root.transform, "[Camera]");
GameObject roomCameraGo = GetOrCreateChild(cameraGroup, "RoomCamera").gameObject;
CinemachineCamera cinemachineCamera = GetOrAddComponent<CinemachineCamera>(roomCameraGo);
RoomCamera roomCamera = GetOrAddComponent<RoomCamera>(roomCameraGo);
CinemachineConfiner2D confiner = GetOrAddComponent<CinemachineConfiner2D>(roomCameraGo);
// RoomBoundary — defines visible area and confiner polygon
Transform boundaryT = GetOrCreateChild(roomCameraGo.transform, "RoomBoundary");
PolygonCollider2D boundaryCollider = GetOrAddComponent<PolygonCollider2D>(boundaryT.gameObject);
boundaryCollider.pathCount = 1;
boundaryCollider.SetPath(0, new Vector2[]
{
new Vector2(-12f, -6f), new Vector2(-12f, 6f),
new Vector2( 12f, 6f), new Vector2( 12f, -6f),
});
RoomVisibleArea visibleArea = GetOrAddComponent<RoomVisibleArea>(boundaryT.gameObject);
AssignReference(roomCamera, "_visibleArea", visibleArea);
AssignReference(confiner, "m_BoundingShape2D", boundaryCollider);
// Disable stray Camera / AudioListener components sometimes added by Cinemachine
UnityEngine.Camera staleCam = roomCameraGo.GetComponent<UnityEngine.Camera>();
if (staleCam != null) staleCam.enabled = false;
AudioListener staleAl = roomCameraGo.GetComponent<AudioListener>();
if (staleAl != null) { Undo.DestroyObjectImmediate(staleAl); }
// ── [SpawnPoints] ──────────────────────────────────────────────
Transform spawnGroup = GetOrCreateChild(root.transform, "[SpawnPoints]");
GameObject defaultSpawnGo = GetOrCreateChild(spawnGroup, "SpawnPoint_Default").gameObject;
PlayerSpawnPoint defaultSpawn = GetOrAddComponent<PlayerSpawnPoint>(defaultSpawnGo);
AssignString(defaultSpawn, "_transitionId", "default");
AssignInt(defaultSpawn, "_facingDirection", 1);
// ── [Environment] ──────────────────────────────────────────────
Transform envGroup = GetOrCreateChild(root.transform, "[Environment]");
// Ground Tilemap
GameObject gridGo = GetOrCreateChild(envGroup, "GroundGrid").gameObject;
GetOrAddComponent<Grid>(gridGo);
GameObject groundTileGo = GetOrCreateChild(gridGo.transform, "Ground").gameObject;
int groundLayer = LayerMask.NameToLayer("Ground");
if (groundLayer >= 0) groundTileGo.layer = groundLayer;
else report.Add("Layer 'Ground' 不存在,请在 Tags and Layers 中创建。");
GetOrAddComponent<Tilemap>(groundTileGo);
GetOrAddComponent<TilemapRenderer>(groundTileGo);
TilemapCollider2D tilemapCol = GetOrAddComponent<TilemapCollider2D>(groundTileGo);
tilemapCol.usedByComposite = true;
Rigidbody2D groundRb = GetOrAddComponent<Rigidbody2D>(groundTileGo);
groundRb.bodyType = RigidbodyType2D.Static;
GetOrAddComponent<CompositeCollider2D>(groundTileGo);
// NavSurface for PathBerserker2d
GameObject navGo = GetOrCreateChild(envGroup, "NavSurface").gameObject;
GetOrAddComponent<NavSurface>(navGo);
// ── [Transitions] ──────────────────────────────────────────────
GetOrCreateChild(root.transform, "[Transitions]");
// ── Wire RoomController ────────────────────────────────────────
AssignReference(roomController, "_roomCamera", roomCamera);
SerializedObject roomSO = new SerializedObject(roomController);
SerializedProperty spawnArrayProp = roomSO.FindProperty("_spawnPoints");
if (spawnArrayProp != null && spawnArrayProp.isArray)
{
spawnArrayProp.arraySize = 1;
spawnArrayProp.GetArrayElementAtIndex(0).objectReferenceValue = defaultSpawn;
roomSO.ApplyModifiedPropertiesWithoutUndo();
}
// ── Report ─────────────────────────────────────────────────────
report.Add("在 RoomController._roomId 填写唯一房间 ID(如 \"Room_Forest_01\")。");
report.Add("将 Player/CameraFollowTarget Transform 拖入 CinemachineCamera.Follow 字段以跟随玩家(或使用 BaseGames → Camera → Room Camera Setup 工具批量赋值)。");
report.Add("调整 RoomBoundary PolygonCollider2D 顶点以匹配实际房间大小。");
report.Add("使用 Tile Palette 在 Ground Tilemap 上绘制地形,然后在 NavSurface Inspector 中点击 Bake。");
report.Add("[Transitions] 子节点下使用 BaseGames/Scene/Place/Room Transition 添加过渡点。");
MarkDirtyAndLog("Game Room 脚手架", root, report);
}
private static void AssignString(Object target, string propertyName, string value, List<string> report = null)
{
SerializedObject serializedObject = new SerializedObject(target);
SerializedProperty property = serializedObject.FindProperty(propertyName);
if (property == null)
{
report?.Add($"{target.GetType().Name}.{propertyName} 字段不存在,未写入字符串值。");
return;
}
property.stringValue = value;
serializedObject.ApplyModifiedPropertiesWithoutUndo();
}
private static void AssignInt(Object target, string propertyName, int value, List<string> report = null)
{
SerializedObject serializedObject = new SerializedObject(target);
SerializedProperty property = serializedObject.FindProperty(propertyName);
if (property == null)
{
report?.Add($"{target.GetType().Name}.{propertyName} 字段不存在,未写入整型值。");
return;
}
property.intValue = value;
serializedObject.ApplyModifiedPropertiesWithoutUndo();
}
private static void EnsureEventChannelAssets(List<string> report)
{
bool hasCoreSet =
FindFirstAsset("EVT_PlayerDied") != null &&
FindFirstAsset("EVT_DeathScreenConfirmed") != null &&
(FindFirstAsset("EVT_GameStateChanged") != null || FindFirstAsset("EVT_GameState") != null) &&
FindFirstAsset("EVT_PauseRequested") != null &&
FindFirstAsset("EVT_SceneLoadRequest") != null;
if (hasCoreSet)
return;
CreateEventChannelAssets.CreateAll();
report?.Add("检测到关键事件频道缺失,已自动执行 Create Event Channel Assets。");
}
private static void EnsureAudioSources(GameObject audioManagerGo, AudioManager audioManager, List<string> report)
{
GameObject bgmAGo = GetOrCreateChild(audioManagerGo.transform, "BGM Source A").gameObject;
GameObject bgmBGo = GetOrCreateChild(audioManagerGo.transform, "BGM Source B").gameObject;
GameObject sfxRootGo = GetOrCreateChild(audioManagerGo.transform, "SFX Sources").gameObject;
AudioSource bgmA = GetOrAddComponent<AudioSource>(bgmAGo);
AudioSource bgmB = GetOrAddComponent<AudioSource>(bgmBGo);
bgmA.playOnAwake = false;
bgmB.playOnAwake = false;
bgmA.loop = true;
bgmB.loop = true;
var sfxSources = new AudioSource[6];
for (int i = 0; i < sfxSources.Length; i++)
{
GameObject sfxGo = GetOrCreateChild(sfxRootGo.transform, $"SFX Source {i + 1}").gameObject;
AudioSource sfxSource = GetOrAddComponent<AudioSource>(sfxGo);
sfxSource.playOnAwake = false;
sfxSources[i] = sfxSource;
}
AssignReference(audioManager, "_bgmSourceA", bgmA);
AssignReference(audioManager, "_bgmSourceB", bgmB);
AssignArrayReferences(audioManager, "_sfxSources", sfxSources, report);
report.Add("AudioManager 已生成 2 个 BGM Source 和 6 个 SFX SourceAudioMixer 仍需手工指定。");
}
private static GameObject GetOrCreateRoot(string name)
{
Scene scene = SceneManager.GetActiveScene();
foreach (GameObject rootObject in scene.GetRootGameObjects())
{
if (rootObject.name == name)
return rootObject;
}
GameObject root = new GameObject(name);
Undo.RegisterCreatedObjectUndo(root, $"Create {name}");
return root;
}
private static Transform GetOrCreateChild(Transform parent, string name)
{
Transform child = parent.Find(name);
if (child != null)
return child;
GameObject go = new GameObject(name);
Undo.RegisterCreatedObjectUndo(go, $"Create {name}");
go.transform.SetParent(parent, false);
return go.transform;
}
private static T GetOrAddComponent<T>(GameObject go) where T : Component
{
T component = go.GetComponent<T>();
if (component != null)
return component;
return Undo.AddComponent<T>(go);
}
private static GameObject GetOrCreateCanvas(Transform parent, string name, int sortOrder)
{
GameObject canvasGo = GetOrCreateChild(parent, name).gameObject;
Canvas canvas = GetOrAddComponent<Canvas>(canvasGo);
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
canvas.sortingOrder = sortOrder;
GetOrAddComponent<CanvasScaler>(canvasGo);
GetOrAddComponent<GraphicRaycaster>(canvasGo);
return canvasGo;
}
private static void AssignReference(Object target, string propertyName, Object value)
{
AssignReference(target, propertyName, value, null);
}
private static void AssignReference(Object target, string propertyName, Object value, List<string> report)
{
SerializedObject serializedObject = new SerializedObject(target);
SerializedProperty property = serializedObject.FindProperty(propertyName);
if (property == null)
{
report?.Add($"{target.GetType().Name}.{propertyName} 字段不存在,未写入引用。");
return;
}
property.objectReferenceValue = value;
serializedObject.ApplyModifiedPropertiesWithoutUndo();
}
private static void AssignArrayReferences(Object target, string propertyName, IReadOnlyList<Object> values, List<string> report)
{
SerializedObject serializedObject = new SerializedObject(target);
SerializedProperty property = serializedObject.FindProperty(propertyName);
if (property == null || !property.isArray)
{
report.Add($"{target.GetType().Name}.{propertyName} 不是可写数组字段。");
return;
}
property.arraySize = values.Count;
for (int i = 0; i < values.Count; i++)
property.GetArrayElementAtIndex(i).objectReferenceValue = values[i];
serializedObject.ApplyModifiedPropertiesWithoutUndo();
}
private static void AssignAsset(Object target, string propertyName, List<string> report, bool required, params string[] candidates)
{
Object asset = FindFirstAsset(candidates);
if (asset == null && required)
report.Add($"未找到 {target.GetType().Name}.{propertyName} 需要的资产: {string.Join(" / ", candidates)}");
AssignReference(target, propertyName, asset, report);
}
private static Object FindFirstAsset(params string[] candidates)
{
foreach (string candidate in candidates)
{
if (string.IsNullOrWhiteSpace(candidate))
continue;
string[] guids = AssetDatabase.FindAssets(candidate);
foreach (string guid in guids)
{
string path = AssetDatabase.GUIDToAssetPath(guid);
Object asset = AssetDatabase.LoadMainAssetAtPath(path);
if (asset != null && asset.name == candidate)
return asset;
}
}
return null;
}
private static Object FindFirstAssetByType<T>(params string[] candidates) where T : Object
{
foreach (string candidate in candidates)
{
if (string.IsNullOrWhiteSpace(candidate))
continue;
string[] guids = AssetDatabase.FindAssets(candidate);
foreach (string guid in guids)
{
string path = AssetDatabase.GUIDToAssetPath(guid);
T asset = AssetDatabase.LoadAssetAtPath<T>(path);
if (asset != null && asset.name == candidate)
return asset;
}
}
return null;
}
private static Object FindFirstAssetWithExtension(string extension, params string[] candidates)
{
foreach (string candidate in candidates)
{
if (string.IsNullOrWhiteSpace(candidate))
continue;
string[] guids = AssetDatabase.FindAssets(candidate);
foreach (string guid in guids)
{
string path = AssetDatabase.GUIDToAssetPath(guid);
if (string.IsNullOrEmpty(path) || !path.EndsWith(extension, System.StringComparison.OrdinalIgnoreCase))
continue;
Object asset = AssetDatabase.LoadMainAssetAtPath(path);
if (asset != null && asset.name == candidate)
return asset;
}
}
return null;
}
private static Object EnsureInputReaderAsset(List<string> report)
{
string[] existing = AssetDatabase.FindAssets("t:InputReaderSO");
if (existing != null && existing.Length > 0)
{
string firstPath = AssetDatabase.GUIDToAssetPath(existing[0]);
Object found = AssetDatabase.LoadMainAssetAtPath(firstPath);
if (found != null)
return found;
}
const string inputFolder = "Assets/_Game/Data/Player/Input";
EnsureFolder(inputFolder);
const string assetPath = "Assets/_Game/Data/Player/Input/InputReader.asset";
InputReaderSO created = ScriptableObject.CreateInstance<InputReaderSO>();
AssetDatabase.CreateAsset(created, assetPath);
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
report?.Add("未找到 InputReaderSO,已自动创建 Assets/_Game/Data/Player/Input/InputReader.asset。");
return created;
}
private static void EnsureFolder(string fullPath)
{
string[] parts = fullPath.Split('/');
if (parts.Length == 0 || parts[0] != "Assets")
return;
string current = "Assets";
for (int i = 1; i < parts.Length; i++)
{
string next = current + "/" + parts[i];
if (!AssetDatabase.IsValidFolder(next))
AssetDatabase.CreateFolder(current, parts[i]);
current = next;
}
}
private static void AddScaffoldNote(GameObject go, string message)
{
AddScaffoldNote(go, message, null);
}
private static void AddScaffoldNote(GameObject go, string message, List<string> report)
{
// 注意:不再添加 MonoBehaviour 组件,避免 Editor 程序集组件在 Play 模式下出现 Missing Script
report?.Add($"{go.name}: {message}");
Debug.Log($"[SceneScaffold] {go.name}: {message}");
}
private static void MarkDirtyAndLog(string scaffoldName, GameObject root, List<string> report)
{
EditorSceneManager.MarkSceneDirty(SceneManager.GetActiveScene());
Selection.activeGameObject = root;
if (report.Count == 0)
{
Debug.Log($"[SceneScaffoldTools] {scaffoldName} 完成。所有可自动补齐的对象与引用均已生成。", root);
return;
}
Debug.LogWarning($"[SceneScaffoldTools] {scaffoldName} 完成,但仍有 {report.Count} 项需要手工确认:\n- {string.Join("\n- ", report)}", root);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: eb2b7f90961ee3344a5f39c68931a26d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: e79a8beb7b402ef4d884b39587424d02
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,82 @@
using System;
using System.Collections.Generic;
using System.IO;
using UnityEditor;
using UnityEngine;
namespace BaseGames.Editor
{
/// <summary>
/// 编辑器工具通用静态方法,供各窗口、向导共享调用。
/// </summary>
public static class EditorScaffoldUtils
{
// ── SO 资产创建 ───────────────────────────────────────────────────────
/// <summary>
/// 在指定文件夹(Assets 相对路径)创建 SO 资产。
/// 若资产已存在则返回 null(不覆盖);成功后自动 Ping 并选中。
/// </summary>
public static T CreateSOAsset<T>(string folder, string assetName) where T : ScriptableObject
{
string dir = folder.TrimEnd('/');
string path = $"{dir}/{assetName}.asset";
if (AssetDatabase.LoadAssetAtPath<T>(path) != null)
{
Debug.LogWarning($"[EditorScaffoldUtils] 资产已存在,跳过创建:{path}");
return null;
}
EnsureFolder(dir);
var asset = ScriptableObject.CreateInstance<T>();
AssetDatabase.CreateAsset(asset, path);
AssetDatabase.SaveAssets();
PingAndSelect(asset);
return asset;
}
// ── 目录工具 ──────────────────────────────────────────────────────────
/// <summary>确保 Assets 相对路径目录存在(不存在则递归创建)。</summary>
public static void EnsureFolder(string assetPath)
{
string projectRoot = Path.GetDirectoryName(Application.dataPath)!;
string fullPath = Path.Combine(projectRoot, assetPath.Replace('/', Path.DirectorySeparatorChar));
if (!Directory.Exists(fullPath))
Directory.CreateDirectory(fullPath);
}
// ── 资产查找 ──────────────────────────────────────────────────────────
/// <summary>
/// 查找 Project 中所有指定 ScriptableObject 类型的资产(不含 Packages)。
/// </summary>
public static List<T> FindAllAssetsOfType<T>() where T : ScriptableObject
{
var result = new List<T>();
string[] guids = AssetDatabase.FindAssets($"t:{typeof(T).Name}");
foreach (string guid in guids)
{
string path = AssetDatabase.GUIDToAssetPath(guid);
// 排除 Packages 目录
if (path.StartsWith("Packages/", StringComparison.OrdinalIgnoreCase))
continue;
var asset = AssetDatabase.LoadAssetAtPath<T>(path);
if (asset != null)
result.Add(asset);
}
return result;
}
// ── 选中 / Ping ───────────────────────────────────────────────────────
/// <summary>Ping 并选中 Project 中的资产。</summary>
public static void PingAndSelect(UnityEngine.Object asset)
{
EditorGUIUtility.PingObject(asset);
Selection.activeObject = asset;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: cf604376607014a429f588969c75dc57
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 7ff617758cc6e5c48bb1a90b185e26bf
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,276 @@
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEditor;
using UnityEditor.UIElements;
using UnityEngine;
using UnityEngine.UIElements;
using BaseGames.Combat;
using BaseGames.Skills;
namespace BaseGames.Editor.Skills
{
/// <summary>
/// 技能数据管理窗口(W-03)。
/// 技术:UI Toolkit TwoPaneSplitView。
/// 菜单:BaseGames / Data / Skill Editor
///
/// 左栏:可搜索的 FormSkillSO 列表,按 SkillEffectType 分组过滤。
/// 右栏:选中技能的完整属性编辑 + HitBox Prefab 结构校验 + 底部资源消耗预览。
/// </summary>
public class SkillEditorWindow : EditorWindow
{
private static readonly StyleSheet _sharedUSS;
private static readonly string[] _effectTypeOptions;
static SkillEditorWindow()
{
_sharedUSS = AssetDatabase.LoadAssetAtPath<StyleSheet>(
"Assets/_Game/Scripts/Editor/UIToolkit/Editor.uss");
var names = Enum.GetNames(typeof(SkillEffectType));
_effectTypeOptions = new string[names.Length + 1];
_effectTypeOptions[0] = "全部";
Array.Copy(names, 0, _effectTypeOptions, 1, names.Length);
}
[MenuItem("BaseGames/Data/Skill Editor", priority = 101)]
public static void Open()
{
var wnd = GetWindow<SkillEditorWindow>();
wnd.titleContent = new GUIContent("Skill Editor");
wnd.minSize = new Vector2(700, 400);
}
// ── 状态 ─────────────────────────────────────────────────────────────
private List<FormSkillSO> _skills = new();
private List<FormSkillSO> _filtered = new();
private ListView _listView;
private VisualElement _detailRoot;
private string _searchText = "";
private string _filterType = "全部";
private InspectorElement _currentInspector;
// ── 生命周期 ──────────────────────────────────────────────────────────
public void CreateGUI()
{
if (_sharedUSS != null)
rootVisualElement.styleSheets.Add(_sharedUSS);
// Toolbar
var toolbar = new Toolbar();
var searchField = new ToolbarSearchField { style = { flexGrow = 1 } };
searchField.RegisterValueChangedCallback(e =>
{
_searchText = e.newValue;
RefreshFilter();
});
toolbar.Add(searchField);
// SkillEffectType 过滤下拉框
var typeFilter = new ToolbarMenu { text = "类型:全部", style = { minWidth = 100 } };
foreach (var opt in _effectTypeOptions)
{
string captured = opt;
typeFilter.menu.AppendAction(opt, _ =>
{
_filterType = captured;
typeFilter.text = $"类型:{captured}";
RefreshFilter();
});
}
toolbar.Add(typeFilter);
var btnCreate = new ToolbarButton(CreateNewSkill) { text = "+ 新建技能" };
var btnRefresh = new ToolbarButton(RefreshAll) { text = "↺" };
btnRefresh.tooltip = "重新扫描 Project 中的 FormSkillSO 资产";
toolbar.Add(btnCreate);
toolbar.Add(btnRefresh);
rootVisualElement.Add(toolbar);
// Split view
var split = new TwoPaneSplitView(0, 220, TwoPaneSplitViewOrientation.Horizontal);
// ── 左栏 ──────────────────────────────────────────────────────
var leftPane = new VisualElement { style = { minWidth = 140 } };
_listView = new ListView
{
selectionType = SelectionType.Single,
fixedItemHeight = 22,
makeItem = MakeListItem,
bindItem = BindListItem,
style = { flexGrow = 1 },
};
_listView.selectionChanged += OnSelectionChanged;
leftPane.Add(_listView);
split.Add(leftPane);
// ── 右栏 ──────────────────────────────────────────────────────
_detailRoot = new ScrollView { style = { flexGrow = 1 } };
_detailRoot.AddToClassList("detail-panel");
split.Add(_detailRoot);
rootVisualElement.Add(split);
RefreshAll();
}
private void OnFocus() => RefreshAll();
// ── 列表构建 ──────────────────────────────────────────────────────────
private void RefreshAll()
{
_skills = EditorScaffoldUtils.FindAllAssetsOfType<FormSkillSO>();
_skills.Sort((a, b) => string.Compare(
a.skillId, b.skillId, StringComparison.OrdinalIgnoreCase));
RefreshFilter();
}
private void RefreshFilter()
{
IEnumerable<FormSkillSO> query = _skills;
if (_filterType != "全部" && Enum.TryParse(_filterType, out SkillEffectType filterEnum))
query = query.Where(s => s.effectType == filterEnum);
if (!string.IsNullOrEmpty(_searchText))
{
string s = _searchText;
query = query.Where(sk => sk != null &&
(sk.skillId?.Contains(s, StringComparison.OrdinalIgnoreCase) == true ||
sk.displayNameKey?.Contains(s, StringComparison.OrdinalIgnoreCase) == true));
}
_filtered = query.ToList();
_listView.itemsSource = _filtered;
_listView.Rebuild();
}
private static VisualElement MakeListItem()
{
var label = new Label();
label.AddToClassList("list-item");
return label;
}
private void BindListItem(VisualElement element, int index)
{
var label = (Label)element;
var skill = _filtered.Count > index ? _filtered[index] : null;
if (skill == null) { label.text = "(null)"; return; }
label.text = string.IsNullOrEmpty(skill.displayNameKey)
? skill.skillId
: $"{skill.skillId} <color=#888>[{skill.effectType}]</color>";
}
// ── 详情面板 ──────────────────────────────────────────────────────────
private void OnSelectionChanged(IEnumerable<object> items)
{
_detailRoot.Clear();
_currentInspector = null;
var skill = items.FirstOrDefault() as FormSkillSO;
if (skill == null) return;
// 标题
var title = new Label($"{skill.skillId} [{skill.effectType}]")
{
style =
{
fontSize = 14,
unityFontStyleAndWeight = FontStyle.Bold,
marginBottom = 6,
}
};
_detailRoot.Add(title);
// 资源消耗快览
BuildCostPreview(skill);
// HitBox Prefab 状态
BuildHitBoxStatus(skill);
// 完整属性编辑
_currentInspector = new InspectorElement(skill);
_detailRoot.Add(_currentInspector);
// 操作按钮
var btnRow = new VisualElement();
btnRow.AddToClassList("action-buttons");
btnRow.Add(new Button(() => EditorScaffoldUtils.PingAndSelect(skill))
{ text = "在 Project 中定位" });
btnRow.Add(new Button(() => Selection.activeObject = skill)
{ text = "在 Inspector 中打开" });
btnRow.Add(new Button(SkillHitBoxWizard.Open)
{ text = "HitBox Prefab 向导…" });
_detailRoot.Add(btnRow);
}
private void BuildCostPreview(FormSkillSO skill)
{
var box = new VisualElement();
box.AddToClassList("stats-preview");
void AddStat(string label, string value)
{
box.Add(new Label(label) { style = { color = new Color(0.7f, 0.7f, 0.7f), marginRight = 4 } });
box.Add(new Label(value) { style = { marginRight = 16, unityFontStyleAndWeight = FontStyle.Bold } });
}
AddStat("消耗:", $"{skill.baseCost} {skill.resourceType}");
AddStat("冷却:", $"{skill.cooldown:F1}s");
AddStat("施放锁:", $"{skill.castLockDuration:F2}s");
_detailRoot.Add(box);
}
private void BuildHitBoxStatus(FormSkillSO skill)
{
// 投射物技能不需要近战 HitBox Prefab
if (skill.effectType == SkillEffectType.Projectile) return;
HelpBoxMessageType msgType;
string msg;
if (skill.SkillHitBoxPrefab == null)
{
msgType = HelpBoxMessageType.Warning;
msg = "SkillHitBoxPrefab 未赋值!近战/爆炸技能需要关联 HitBox Prefab。";
}
else if (skill.SkillHitBoxPrefab.GetComponent<SkillHitBoxInstance>() == null)
{
msgType = HelpBoxMessageType.Error;
msg = $"SkillHitBoxPrefab「{skill.SkillHitBoxPrefab.name}」缺少 SkillHitBoxInstance 组件!";
}
else
{
msgType = HelpBoxMessageType.Info;
msg = $"HitBox Prefab 结构正常:{skill.SkillHitBoxPrefab.name}";
}
_detailRoot.Add(new HelpBox(msg, msgType) { style = { marginBottom = 6 } });
}
// ── 新建技能 ──────────────────────────────────────────────────────────
private void CreateNewSkill()
{
var asset = EditorScaffoldUtils.CreateSOAsset<FormSkillSO>(
"Assets/_Game/Data/Skills", "FormSkillSO_New");
if (asset != null)
{
RefreshAll();
int idx = _filtered.IndexOf(asset);
if (idx >= 0)
_listView.SetSelection(idx);
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 22de97a32c867fd429c1814853d61ec6
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,127 @@
using System.IO;
using UnityEditor;
using UnityEngine;
using BaseGames.Combat;
namespace BaseGames.Editor.Skills
{
/// <summary>
/// 向导:一键生成技能 HitBox PrefabW-11)。
/// 菜单:BaseGames / Create / Skill HitBox Prefab
///
/// 生成路径规范:Assets/_Game/Prefabs/Skills/SKL_{skillId}_HitBox.prefab
/// Prefab 结构(以 hitBoxCount=1 为例):
/// [SKL_{skillId}_HitBox] ← SkillHitBoxInstance (_hitBoxes 自动赋值)
/// └── [HitBox_0] ← PolygonCollider2D(IsTrigger) + HitBox, Layer=PlayerHitBox
/// </summary>
public class SkillHitBoxWizard : ScriptableWizard
{
private const string OutputFolder = "Assets/_Game/Prefabs/Skills";
[MenuItem("BaseGames/Create/Skill HitBox Prefab", priority = 201)]
public static void Open() =>
DisplayWizard<SkillHitBoxWizard>("Skill HitBox Prefab 向导", "创建");
[Tooltip("技能唯一 ID,如 SkySlash。Prefab 将命名为 SKL_{skillId}_HitBox")]
public string skillId = "";
[Tooltip("多段伤害时可设置 >1(每段一个 HitBox 子节点)")]
[Range(1, 4)]
public int hitBoxCount = 1;
// ── 向导回调 ──────────────────────────────────────────────────────────
private void OnWizardUpdate()
{
isValid = !string.IsNullOrWhiteSpace(skillId);
helpString = isValid
? $"将创建:{OutputFolder}/SKL_{skillId}_HitBox.prefab{hitBoxCount} 个 HitBox"
: "请输入 skillId(技能唯一 ID,如 SkySlash)。";
}
private void OnWizardCreate()
{
if (string.IsNullOrWhiteSpace(skillId))
{
EditorUtility.DisplayDialog("错误", "skillId 不能为空。", "确认");
return;
}
string prefabName = $"SKL_{skillId}_HitBox";
string assetPath = $"{OutputFolder}/{prefabName}.prefab";
string fullPath = Path.Combine(
Path.GetDirectoryName(Application.dataPath)!,
assetPath.Replace('/', Path.DirectorySeparatorChar));
if (File.Exists(fullPath))
{
if (!EditorUtility.DisplayDialog("已存在",
$"{assetPath}\n\n该 Prefab 已存在,是否覆盖?",
"覆盖", "取消"))
return;
}
EditorScaffoldUtils.EnsureFolder(OutputFolder);
int hitBoxLayer = LayerMask.NameToLayer("PlayerHitBox");
if (hitBoxLayer < 0)
{
Debug.LogWarning("[SkillHitBoxWizard] 未找到 Physics Layer 'PlayerHitBox',子节点 Layer 将设为 Default。");
hitBoxLayer = 0;
}
// ── 构建 Prefab ────────────────────────────────────────────────
var root = new GameObject(prefabName);
var instance = root.AddComponent<SkillHitBoxInstance>();
var so = new SerializedObject(instance);
var hbRefs = new HitBox[hitBoxCount];
for (int i = 0; i < hitBoxCount; i++)
{
var child = new GameObject($"HitBox_{i}");
child.transform.SetParent(root.transform, false);
child.layer = hitBoxLayer;
// PolygonCollider2D 默认菱形(1×0.5),策划在 Scene 中调整具体形状
var poly = child.AddComponent<PolygonCollider2D>();
poly.isTrigger = true;
poly.SetPath(0, new Vector2[]
{
new(-0.5f, -0.25f), new(0.5f, -0.25f),
new(0.5f, 0.25f), new(-0.5f, 0.25f),
});
hbRefs[i] = child.AddComponent<HitBox>();
}
// 将 HitBox[] 赋值给 _hitBoxes
var arrayProp = so.FindProperty("_hitBoxes");
if (arrayProp != null && arrayProp.isArray)
{
arrayProp.arraySize = hitBoxCount;
for (int i = 0; i < hitBoxCount; i++)
arrayProp.GetArrayElementAtIndex(i).objectReferenceValue = hbRefs[i];
so.ApplyModifiedPropertiesWithoutUndo();
}
else
{
Debug.LogWarning("[SkillHitBoxWizard] 未找到 SkillHitBoxInstance._hitBoxes 字段,请手动赋值。");
}
var prefab = PrefabUtility.SaveAsPrefabAsset(root, assetPath);
Object.DestroyImmediate(root);
AssetDatabase.Refresh();
if (prefab != null)
{
EditorScaffoldUtils.PingAndSelect(prefab);
Debug.Log($"[SkillHitBoxWizard] 已创建:{assetPath}");
}
else
{
Debug.LogError($"[SkillHitBoxWizard] Prefab 保存失败:{assetPath}");
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 20fe05f2dec972d4093ff334f845dc0f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: f743e71d4001f804ab24a566da3eb570
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,230 @@
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;
namespace BaseGames.Editor
{
/// <summary>
/// 扫描当前场景及 Project 中所有 Prefab,移除丢失(Missing)脚本引用。
///
/// 菜单:BaseGames/Tools/Missing Scripts/
/// </summary>
public static class MissingScriptCleaner
{
// ──────────────────────────────────────────────
// 场景
// ──────────────────────────────────────────────
[MenuItem("BaseGames/Tools/Missing Scripts/Clear In Scene")]
public static void ClearMissingScriptsInScene()
{
int totalRemoved = 0;
var affected = new List<GameObject>();
foreach (var go in GetAllSceneObjects())
{
int removed = GameObjectUtility.RemoveMonoBehavioursWithMissingScript(go);
if (removed > 0)
{
affected.Add(go);
totalRemoved += removed;
EditorUtility.SetDirty(go);
Debug.Log($"[MissingScriptCleaner] 场景已清理:{GetFullPath(go)}", go);
}
}
if (totalRemoved == 0)
Debug.Log("[MissingScriptCleaner] 场景中未发现丢失脚本。");
else
Debug.Log($"[MissingScriptCleaner] 场景完成。共移除 {totalRemoved} 个丢失脚本,影响 {affected.Count} 个 GameObject。");
}
[MenuItem("BaseGames/Tools/Missing Scripts/Find In Scene")]
public static void FindMissingScriptsInScene()
{
int totalFound = 0;
foreach (var go in GetAllSceneObjects())
{
foreach (var component in go.GetComponents<Component>())
{
if (component == null)
{
Debug.LogWarning($"[MissingScriptCleaner] 场景丢失脚本:{GetFullPath(go)}", go);
totalFound++;
break;
}
}
}
if (totalFound == 0)
Debug.Log("[MissingScriptCleaner] 场景中未发现丢失脚本。");
else
Debug.LogWarning($"[MissingScriptCleaner] 场景共发现 {totalFound} 个含丢失脚本的 GameObject。");
}
// ──────────────────────────────────────────────
// Prefab 资产
// ──────────────────────────────────────────────
[MenuItem("BaseGames/Tools/Missing Scripts/Clear In All Prefabs")]
public static void ClearMissingScriptsInPrefabs()
{
int totalRemoved = 0;
int affectedPrefabs = 0;
string[] guids = AssetDatabase.FindAssets("t:Prefab");
try
{
for (int i = 0; i < guids.Length; i++)
{
string path = AssetDatabase.GUIDToAssetPath(guids[i]);
if (!path.StartsWith("Assets/")) continue; // 跳过 Packages 等只读路径
if (EditorUtility.DisplayCancelableProgressBar(
"清理 Prefab 丢失脚本",
path,
(float)i / guids.Length))
break;
int removed = CleanPrefabAtPath(path);
if (removed > 0)
{
totalRemoved += removed;
affectedPrefabs++;
}
}
}
finally
{
EditorUtility.ClearProgressBar();
AssetDatabase.SaveAssets();
}
if (totalRemoved == 0)
Debug.Log("[MissingScriptCleaner] 所有 Prefab 中未发现丢失脚本。");
else
Debug.Log($"[MissingScriptCleaner] Prefab 完成。共移除 {totalRemoved} 个丢失脚本,影响 {affectedPrefabs} 个 Prefab。");
}
[MenuItem("BaseGames/Tools/Missing Scripts/Find In All Prefabs")]
public static void FindMissingScriptsInPrefabs()
{
int totalFound = 0;
string[] guids = AssetDatabase.FindAssets("t:Prefab");
try
{
for (int i = 0; i < guids.Length; i++)
{
string path = AssetDatabase.GUIDToAssetPath(guids[i]);
if (!path.StartsWith("Assets/")) continue; // 跳过 Packages 等只读路径
if (EditorUtility.DisplayCancelableProgressBar(
"查找 Prefab 丢失脚本",
path,
(float)i / guids.Length))
break;
var contentsRoot = PrefabUtility.LoadPrefabContents(path);
if (contentsRoot == null) continue;
try
{
var tempScene = contentsRoot.scene;
foreach (var go in Resources.FindObjectsOfTypeAll<GameObject>())
{
if (go.scene != tempScene) continue;
foreach (var component in go.GetComponents<Component>())
{
if (component == null)
{
Debug.LogWarning($"[MissingScriptCleaner] Prefab 丢失脚本:{path} → {go.name}");
totalFound++;
break;
}
}
}
}
finally
{
PrefabUtility.UnloadPrefabContents(contentsRoot);
}
}
}
finally
{
EditorUtility.ClearProgressBar();
}
if (totalFound == 0)
Debug.Log("[MissingScriptCleaner] 所有 Prefab 中未发现丢失脚本。");
else
Debug.LogWarning($"[MissingScriptCleaner] Prefab 共发现 {totalFound} 个含丢失脚本的 GameObject。");
}
// ──────────────────────────────────────────────
// 内部辅助
// ──────────────────────────────────────────────
static int CleanPrefabAtPath(string path)
{
// LoadPrefabContents 在临时预览场景中打开 Prefab,
// 此时 Resources.FindObjectsOfTypeAll 可枚举到包括 HideInHierarchy 在内的全部对象。
// 修改完毕后必须用 SaveAsPrefabAsset 写回,否则隐藏对象的改动不会持久化。
var contentsRoot = PrefabUtility.LoadPrefabContents(path);
if (contentsRoot == null) return 0;
int totalRemoved = 0;
try
{
var tempScene = contentsRoot.scene;
foreach (var go in Resources.FindObjectsOfTypeAll<GameObject>())
{
if (go.scene != tempScene) continue;
int removed = GameObjectUtility.RemoveMonoBehavioursWithMissingScript(go);
if (removed > 0)
{
totalRemoved += removed;
Debug.Log($"[MissingScriptCleaner] Prefab 已清理:{path} → {go.name}");
}
}
if (totalRemoved > 0)
PrefabUtility.SaveAsPrefabAsset(contentsRoot, path);
}
finally
{
PrefabUtility.UnloadPrefabContents(contentsRoot);
}
return totalRemoved;
}
static GameObject[] GetAllSceneObjects()
{
// FindObjectsByType 不返回 HideFlags.HideInHierarchy 的对象,
// 使用 Resources.FindObjectsOfTypeAll 获取全部对象,再过滤出已加载场景内的实例。
var all = Resources.FindObjectsOfTypeAll<GameObject>();
var result = new List<GameObject>();
foreach (var go in all)
{
if (go.scene.IsValid() && go.scene.isLoaded)
result.Add(go);
}
return result.ToArray();
}
static string GetFullPath(GameObject go)
{
var path = go.name;
var parent = go.transform.parent;
while (parent != null)
{
path = parent.name + "/" + path;
parent = parent.parent;
}
return path;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d13fb92923fd33e4c812025f203a9928
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,215 @@
using System.Collections.Generic;
using System.Text;
using UnityEditor;
using UnityEngine;
namespace BaseGames.Editor
{
/// <summary>
/// Physics2D 层碰撞矩阵检查与修复工具。
///
/// 菜单:BaseGames → Tools → Physics2D Layer Matrix
///
/// 检查规则:
/// · PlayerHitBox ↔ EnemyHurtBox → 应碰撞(玩家攻击伤害敌人)
/// · EnemyHitBox ↔ PlayerHurtBox → 应碰撞(敌人攻击伤害玩家)
/// · EnemyHitBox ↔ EnemyHurtBox → 应碰撞(敌人可互相伤害,HitBox 运行时排除自身根节点)
/// · Player ↔ Ground → 应碰撞(玩家站在地面上)
/// · Enemy ↔ Ground → 应碰撞(敌人站在地面上)
/// · PlayerProjectile ↔ EnemyHurtBox → 应碰撞(玩家投射物伤害敌人)
/// · PlayerProjectile ↔ PlayerHurtBox → 应忽略(玩家投射物不自伤)
/// · PlayerProjectile ↔ Ground → 应碰撞(玩家投射物命中地形)
/// · EnemyProjectile ↔ PlayerHurtBox → 应碰撞(敌人投射物伤害玩家)
/// · EnemyProjectile ↔ EnemyHurtBox → 应忽略(敌人投射物不自伤)
/// · EnemyProjectile ↔ Ground → 应碰撞(敌人投射物命中地形)
/// · PlayerHitBox ↔ PlayerHurtBox → 应忽略(玩家不自伤)
/// · PlayerProjectile ↔ EnemyProjectile → 应忽略(子弹不互相碰撞,Clash 系统单独处理)
/// </summary>
public static class Physics2DLayerReport
{
// ── 期望配置表 ────────────────────────────────────────────────────────
private static readonly ExpectedPair[] ExpectedPairs =
{
new("PlayerHitBox", "EnemyHurtBox", true, "玩家攻击伤害敌人"),
new("EnemyHitBox", "PlayerHurtBox", true, "敌人攻击伤害玩家"),
new("EnemyHitBox", "EnemyHurtBox", true, "敌人可互相伤害(HitBox 运行时排除自身根节点)"),
new("Player", "Ground", true, "玩家站在地面上"),
new("Enemy", "Ground", true, "敌人站在地面上"),
new("PlayerProjectile", "EnemyHurtBox", true, "玩家投射物伤害敌人"),
new("PlayerProjectile", "PlayerHurtBox", false, "玩家投射物不自伤"),
new("PlayerProjectile", "Ground", true, "玩家投射物命中地形"),
new("EnemyProjectile", "PlayerHurtBox", true, "敌人投射物伤害玩家"),
new("EnemyProjectile", "EnemyHurtBox", false, "敌人投射物不自伤"),
new("EnemyProjectile", "Ground", true, "敌人投射物命中地形"),
new("PlayerHitBox", "PlayerHurtBox", false, "玩家不自伤"),
new("PlayerProjectile", "EnemyProjectile", false, "子弹不互相碰撞(Clash 系统单独处理)"),
};
// ─────────────────────────────────────────────────────────────────────
[MenuItem("BaseGames/Tools/Physics2D Layer Matrix/Check", priority = 210)]
public static void CheckAndPrintReport()
{
var results = Check();
PrintToConsole(results);
}
[MenuItem("BaseGames/Tools/Physics2D Layer Matrix/Auto Fix", priority = 211)]
public static void FixAndReport()
{
var results = Check();
int fixed_ = ApplyFixes(results);
Debug.Log($"[Physics2DLayerReport] 修复完成,共修正 {fixed_} 项。");
}
// ─────────────────────────────────────────────────────────────────────
/// <summary>返回当前所有期望配置的检查结果列表。</summary>
public static List<LayerPairResult> Check()
{
var results = new List<LayerPairResult>(ExpectedPairs.Length);
foreach (var pair in ExpectedPairs)
{
int layerA = LayerMask.NameToLayer(pair.LayerA);
int layerB = LayerMask.NameToLayer(pair.LayerB);
bool layerMissing = layerA == -1 || layerB == -1;
bool actualCollide = false;
if (!layerMissing)
{
// GetIgnoreLayerCollision 返回 true = 忽略碰撞(不碰)
bool ignored = Physics2D.GetIgnoreLayerCollision(layerA, layerB);
actualCollide = !ignored;
}
results.Add(new LayerPairResult
{
LayerA = pair.LayerA,
LayerB = pair.LayerB,
ShouldCollide = pair.ShouldCollide,
ActualCollide = actualCollide,
LayerMissing = layerMissing,
Description = pair.Description,
});
}
return results;
}
/// <summary>
/// 对检查结果中所有不正确项应用修复。
/// 返回修复数量。
/// </summary>
public static int ApplyFixes(List<LayerPairResult> results)
{
int count = 0;
foreach (var r in results)
{
if (r.IsOk) continue;
if (r.LayerMissing)
{
Debug.LogWarning($"[Physics2DLayerReport] Layer '{r.LayerA}' 或 '{r.LayerB}' 不存在," +
"请先在 Tags and Layers 中创建。");
continue;
}
int layerA = LayerMask.NameToLayer(r.LayerA);
int layerB = LayerMask.NameToLayer(r.LayerB);
// IgnoreLayerCollision(a, b, ignore=true) = 不碰;ignore=false = 碰
Physics2D.IgnoreLayerCollision(layerA, layerB, !r.ShouldCollide);
count++;
string action = r.ShouldCollide ? "已启用碰撞" : "已禁用碰撞";
Debug.Log($"[Physics2DLayerReport] {action}{r.LayerA} ↔ {r.LayerB}{r.Description}");
}
// 修改 ProjectSettings 使改动持久化
if (count > 0)
SavePhysicsSettings();
return count;
}
// ── 私有辅助 ─────────────────────────────────────────────────────────
private static void PrintToConsole(List<LayerPairResult> results)
{
var sb = new StringBuilder();
sb.AppendLine("[Physics2DLayerReport] ── Physics2D 层碰撞矩阵检查报告 ──────────────────");
int okCount = 0;
int errCount = 0;
int missCount = 0;
foreach (var r in results)
{
if (r.LayerMissing)
{
sb.AppendLine($" ⚠ {r.LayerA} ↔ {r.LayerB} [Layer 不存在] {r.Description}");
missCount++;
}
else if (r.IsOk)
{
sb.AppendLine($" ✅ {r.LayerA} ↔ {r.LayerB} [正常] {r.Description}");
okCount++;
}
else
{
string current = r.ActualCollide ? "碰撞" : "忽略";
string expected = r.ShouldCollide ? "碰撞" : "忽略";
sb.AppendLine($" ❌ {r.LayerA} ↔ {r.LayerB} [当前:{current} 期望:{expected}] {r.Description}");
errCount++;
}
}
sb.AppendLine($"──────────────── 正常:{okCount} 错误:{errCount} Layer缺失:{missCount} ────────────────");
if (errCount == 0 && missCount == 0)
Debug.Log(sb.ToString());
else
Debug.LogWarning(sb.ToString());
}
/// <summary>
/// 通过 SerializedObject 修改 ProjectSettings/DynamicsManager.asset 以持久化 Physics2D 层矩阵。
/// Unity 在退出时也会自动保存,但显式调用可立即落盘。
/// </summary>
private static void SavePhysicsSettings()
{
// Unity 内部会在下一次 AssetDatabase 刷新时持久化 Physics2D 设置
// 使用 Physics2DLayerMatrix 写入后刷新即可
AssetDatabase.SaveAssets();
Physics2D.defaultContactOffset = Physics2D.defaultContactOffset; // 强制标记 dirty
}
// ── 内部数据结构 ─────────────────────────────────────────────────────
private readonly struct ExpectedPair
{
public readonly string LayerA;
public readonly string LayerB;
public readonly bool ShouldCollide;
public readonly string Description;
public ExpectedPair(string a, string b, bool shouldCollide, string desc)
{
LayerA = a;
LayerB = b;
ShouldCollide = shouldCollide;
Description = desc;
}
}
}
// ══ 层碰撞结果结构体 ══════════════════════════════════════════════════════
public struct LayerPairResult
{
public string LayerA;
public string LayerB;
public bool ShouldCollide;
public bool ActualCollide;
public bool LayerMissing;
public string Description;
public bool IsOk => !LayerMissing && (ActualCollide == ShouldCollide);
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 97f163d1f0e4f904cbb6c89abe5a546e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,78 @@
using System.Collections.Generic;
using System.Linq;
using UnityEditor;
using UnityEditor.Build;
using UnityEditor.Build.Reporting;
using UnityEngine;
namespace BaseGames.Editor
{
/// <summary>
/// 扫描项目中所有实现 <see cref="BaseGames.Core.IValidatable"/> 接口的 ScriptableObject
/// 调用 Validate() 并在 Console 报告验证结果。同时作为构建前处理器,发现错误时中止构建。
///
/// 菜单:BaseGames/Tools/Validate All ScriptableObjects
/// Build 回调顺序 = 1(在 AddressKeyValidator callbackOrder = 0 之后执行)
/// </summary>
public class SOValidationRunner : IPreprocessBuildWithReport
{
public int callbackOrder => 1;
public void OnPreprocessBuild(BuildReport report)
{
var (errors, warnings) = RunAll();
foreach (var w in warnings)
Debug.LogWarning(w);
if (errors.Count > 0)
throw new BuildFailedException(
$"[SOValidationRunner] {errors.Count} 处 SO 数据错误,构建中止:\n"
+ string.Join("\n", errors));
}
[MenuItem("BaseGames/Tools/Validate All ScriptableObjects")]
public static void ValidateMenu()
{
var (errors, warnings) = RunAll();
if (errors.Count == 0 && warnings.Count == 0)
{
Debug.Log("[SOValidationRunner] ✅ 所有 SO 数据均合法。");
return;
}
foreach (var w in warnings) Debug.LogWarning(w);
foreach (var e in errors) Debug.LogError(e);
Debug.Log($"[SOValidationRunner] 校验完成:{errors.Count} 错误,{warnings.Count} 警告。");
}
// ── Internal ──────────────────────────────────────────────────────
private static (List<string> errors, List<string> warnings) RunAll()
{
var errors = new List<string>();
var warnings = new List<string>();
var guids = AssetDatabase.FindAssets("t:ScriptableObject");
foreach (var guid in guids)
{
var path = AssetDatabase.GUIDToAssetPath(guid);
var so = AssetDatabase.LoadAssetAtPath<ScriptableObject>(path);
if (so is BaseGames.Core.IValidatable validatable)
{
foreach (var result in validatable.Validate())
{
if (result.Severity == BaseGames.Core.ValidationSeverity.Error)
errors.Add($"❌ {result.Message} ({path})");
else
warnings.Add($"⚠️ {result.Message} ({path})");
}
}
}
return (errors, warnings);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9c46a1c015cf48e4c871252c189bd01a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,159 @@
using System;
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;
namespace BaseGames.Editor
{
/// <summary>
/// 一键应用/校验项目推荐的 Script Execution Order。
/// </summary>
public static class ScriptExecutionOrderTools
{
private readonly struct OrderRule
{
public readonly string ClassName;
public readonly int Order;
public OrderRule(string className, int order)
{
ClassName = className;
Order = order;
}
}
private static readonly OrderRule[] Rules =
{
new OrderRule("GameServiceRegistrar", -2000),
new OrderRule("GameManager", -1000),
new OrderRule("SceneService", -900),
new OrderRule("GameSaveManager", -900),
new OrderRule("AudioManager", -500),
new OrderRule("PlayerController", -100),
};
[MenuItem("BaseGames/Tools/Apply Script Execution Order Preset")]
public static void ApplyPreset()
{
int updated = 0;
int skipped = 0;
var issues = new List<string>();
foreach (var rule in Rules)
{
if (!TryFindMonoScript(rule.ClassName, out MonoScript script, out string issue))
{
skipped++;
issues.Add(issue);
continue;
}
int current = MonoImporter.GetExecutionOrder(script);
if (current == rule.Order)
continue;
MonoImporter.SetExecutionOrder(script, rule.Order);
updated++;
}
AssetDatabase.SaveAssets();
if (issues.Count > 0)
{
Debug.LogWarning(
"[ScriptExecutionOrderTools] 已应用执行顺序预设(部分脚本未处理)。\n" +
$"更新: {updated}, 跳过: {skipped}\n- {string.Join("\n- ", issues)}");
return;
}
Debug.Log($"[ScriptExecutionOrderTools] 执行顺序预设应用完成。更新数量: {updated}。");
}
[MenuItem("BaseGames/Tools/Validate Script Execution Order Preset")]
public static void ValidatePreset()
{
var mismatches = new List<string>();
var issues = new List<string>();
foreach (var rule in Rules)
{
if (!TryFindMonoScript(rule.ClassName, out MonoScript script, out string issue))
{
issues.Add(issue);
continue;
}
int current = MonoImporter.GetExecutionOrder(script);
if (current != rule.Order)
mismatches.Add($"{rule.ClassName}: 当前 {current}, 期望 {rule.Order}");
}
if (mismatches.Count == 0 && issues.Count == 0)
{
Debug.Log("[ScriptExecutionOrderTools] 执行顺序校验通过,所有脚本均符合预设。");
return;
}
string message = "[ScriptExecutionOrderTools] 执行顺序校验发现问题。";
if (mismatches.Count > 0)
message += "\n顺序不一致:\n- " + string.Join("\n- ", mismatches);
if (issues.Count > 0)
message += "\n脚本解析问题:\n- " + string.Join("\n- ", issues);
Debug.LogWarning(message);
}
/// <summary>
/// 根据 <paramref name="className"/> 查找 MonoScript。
/// <para>当 className 包含 '.'(全限定名)时,用 <c>type.FullName</c> 精确匹配;
/// 否则用 <c>type.Name</c> 匹配(向后兼容简单类名)。</para>
/// </summary>
private static bool TryFindMonoScript(string className, out MonoScript script, out string issue)
{
script = null;
issue = null;
// 全限定名时,FindAssets 只取最后一段(简单类名)作为搜索词
bool useFullName = className.Contains('.');
string searchName = useFullName
? className.Substring(className.LastIndexOf('.') + 1)
: className;
string[] guids = AssetDatabase.FindAssets($"{searchName} t:MonoScript");
var matches = new List<MonoScript>();
foreach (string guid in guids)
{
string path = AssetDatabase.GUIDToAssetPath(guid);
var candidate = AssetDatabase.LoadAssetAtPath<MonoScript>(path);
if (candidate == null)
continue;
Type type = candidate.GetClass();
if (type == null) continue;
bool nameMatch = useFullName
? type.FullName == className
: type.Name == className;
if (nameMatch)
matches.Add(candidate);
}
if (matches.Count == 0)
{
issue = $"未找到脚本: {className}";
return false;
}
if (matches.Count > 1)
{
issue = $"存在多个同名脚本: {className}(请消歧后重试)";
return false;
}
script = matches[0];
return true;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1d2bcc35606ec6a47b82e00462955dbe
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: a7e509556039bfe42bb4fd40a9196b21
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,78 @@
/* BaseGames Editor 统一样式表
路径: Assets/_Game/Scripts/Editor/UIToolkit/Editor.uss */
/* ── 分区标题 ──────────────────────────────────────────── */
.section-header {
font-size: 11px;
-unity-font-style: bold;
margin-top: 8px;
margin-bottom: 2px;
color: rgb(180, 180, 180);
}
/* ── 列表项 ─────────────────────────────────────────────── */
.list-item {
padding: 3px 6px;
}
.list-item:hover {
background-color: rgba(255, 255, 255, 0.05);
}
/* ── 状态行 ─────────────────────────────────────────────── */
.row-warning {
background-color: rgba(220, 150, 0, 0.15);
}
.row-error {
background-color: rgba(220, 50, 50, 0.15);
}
.row-ok {
background-color: rgba(50, 180, 50, 0.10);
}
/* ── 详情面板内边距 ──────────────────────────────────────── */
.detail-panel {
padding: 8px;
}
/* ── 操作按钮组 ──────────────────────────────────────────── */
.action-buttons {
flex-direction: row;
flex-wrap: wrap;
margin-top: 8px;
}
.action-buttons Button {
margin-right: 4px;
margin-bottom: 4px;
}
/* ── 数值快览行(Weapon / Skill / Enemy 数据窗口顶部横排预览)── */
.stats-preview {
flex-direction: row;
flex-wrap: wrap;
margin-bottom: 6px;
padding: 4px 6px;
background-color: rgba(40, 40, 40, 0.75);
}
/* ── 标签页按钮 ──────────────────────────────────────────── */
.tab-bar {
flex-direction: row;
border-bottom-width: 1px;
border-bottom-color: rgb(60, 60, 60);
margin-bottom: 6px;
}
.tab-button {
padding: 4px 10px;
border-radius: 0;
border-width: 0;
background-color: rgba(0, 0, 0, 0);
color: rgb(160, 160, 160);
}
.tab-button:hover {
background-color: rgba(255, 255, 255, 0.08);
}
.tab-button--active {
color: rgb(255, 255, 255);
border-bottom-width: 2px;
border-bottom-color: rgb(100, 160, 255);
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6dabfd174f875134b81d3ffc47fb1ff7
ScriptedImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 2
userData:
assetBundleName:
assetBundleVariant:
script: {fileID: 12385, guid: 0000000000000000e000000000000000, type: 0}
disableValidation: 0
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: f755c6c204ed63b4cab86b617889f465
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,52 @@
using UnityEditor;
using UnityEngine;
using BaseGames.World;
namespace BaseGames.Editor
{
/// <summary>
/// 为 DestructibleTile 和 DirectionalDestructible 在 Scene 视图中绘制 Gizmo。
/// 红色实心矩形 = 可破坏状态;灰色叉号 = 已破坏(编辑时无法判断,故始终显示可破坏状态)。
/// </summary>
[CustomEditor(typeof(DestructibleTile), true)]
public class DestructibleTileEditor : UnityEditor.Editor
{
[DrawGizmo(GizmoType.NotInSelectionHierarchy | GizmoType.InSelectionHierarchy)]
private static void DrawGizmo(DestructibleTile tile, GizmoType gizmoType)
{
if (tile == null) return;
var col = tile.GetComponent<Collider2D>();
Bounds bounds = col != null ? col.bounds : new Bounds(tile.transform.position, Vector3.one * 0.5f);
bool isSelected = (gizmoType & GizmoType.InSelectionHierarchy) != 0;
// 可破坏物:橙红色边框;选中时更亮
Gizmos.color = isSelected
? new Color(1f, 0.35f, 0.1f, 0.85f)
: new Color(1f, 0.35f, 0.1f, 0.45f);
Gizmos.DrawWireCube(bounds.center, bounds.size);
// 内部半透明填充
Gizmos.color = new Color(1f, 0.35f, 0.1f, 0.08f);
Gizmos.DrawCube(bounds.center, bounds.size);
// 中心锤子符号(用 GUI label 显示)
Handles.Label(
bounds.center + Vector3.up * (bounds.extents.y + 0.15f),
"💥",
new GUIStyle(GUI.skin.label) { fontSize = 10, alignment = TextAnchor.MiddleCenter });
}
public override void OnInspectorGUI()
{
DrawDefaultInspector();
EditorGUILayout.Space(4);
EditorGUILayout.HelpBox(
"GizmoScene 视图中橙红色边框 = 可破坏物。\n" +
"子类 DirectionalDestructible 会额外显示攻击方向箭头。",
MessageType.None);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9ccbc749bcda1104ba82ec725c43e7cf
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: