摄像机区域的架构改动
This commit is contained in:
@@ -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] ⚠ 孤儿 Key:AddressKeys.{r.FieldName} = \"{r.Value}\" — 未在 Addressable 分组中找到对应地址。");
|
||||
missing++;
|
||||
}
|
||||
}
|
||||
|
||||
if (missing == 0)
|
||||
Debug.Log($"[AddressKeyValidator] ✓ 所有 {results.Count} 个 AddressKeys 常量均在 Addressable 分组中存在。");
|
||||
else
|
||||
Debug.LogWarning($"[AddressKeyValidator] 共 {results.Count} 个常量,发现 {missing} 个孤儿 Key。" +
|
||||
"尚未创建的 Prefab/Scene 资产请在创建后添加至 Addressables 分组。");
|
||||
}
|
||||
|
||||
// ── 结果结构 ──────────────────────────────────────────────────────────
|
||||
|
||||
public readonly struct ValidationResult
|
||||
{
|
||||
public readonly string FieldName;
|
||||
public readonly string Value;
|
||||
public readonly bool ExistsInAddressables;
|
||||
|
||||
public ValidationResult(string fieldName, string value, bool exists)
|
||||
{
|
||||
FieldName = fieldName;
|
||||
Value = value;
|
||||
ExistsInAddressables = exists;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 资产导入后自动触发 AddressKey 验证(架构 13_AssetPoolModule §10)。
|
||||
/// 仅在 Addressable Group 资产发生变更时触发,避免每次导入都验证。
|
||||
/// </summary>
|
||||
public class AddressKeyImportWatcher : AssetPostprocessor
|
||||
{
|
||||
private const string AddressableGroupAssetExt = ".asset";
|
||||
private const string AddressableGroupFolder = "Assets/AddressableAssetsData";
|
||||
|
||||
private static void OnPostprocessAllAssets(
|
||||
string[] importedAssets,
|
||||
string[] deletedAssets,
|
||||
string[] movedAssets,
|
||||
string[] movedFromAssetPaths)
|
||||
{
|
||||
bool addressablesChanged = false;
|
||||
|
||||
foreach (var path in importedAssets)
|
||||
{
|
||||
if (path.StartsWith(AddressableGroupFolder, StringComparison.OrdinalIgnoreCase)
|
||||
&& path.EndsWith(AddressableGroupAssetExt, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
addressablesChanged = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!addressablesChanged)
|
||||
{
|
||||
foreach (var path in deletedAssets)
|
||||
{
|
||||
if (path.StartsWith(AddressableGroupFolder, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
addressablesChanged = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (addressablesChanged)
|
||||
{
|
||||
// 延迟一帧执行,等待 AssetDatabase 完全刷新
|
||||
EditorApplication.delayCall += () =>
|
||||
{
|
||||
Debug.Log("[AddressKeyImportWatcher] 检测到 Addressable 分组变更,自动触发 Key 验证...");
|
||||
AddressKeyValidator.ValidateAll();
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4e0e2a58bdc0d4448833bd1c26caf2af
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,303 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using UnityEditor;
|
||||
using UnityEditor.AddressableAssets;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Addressable Key 引用关系图窗口(架构 13_AssetPoolModule §11)。
|
||||
/// 菜单:BaseGames/Tools/Asset Reference Graph
|
||||
///
|
||||
/// 功能:
|
||||
/// - 扫描所有 .cs 文件中对 AddressKeys.X 的引用
|
||||
/// - 列出每个 Key:声明位置、引用文件列表、是否存在于 Addressables
|
||||
/// - 孤儿 Key(有声明无引用)标红显示
|
||||
/// - 无效 Key(有引用但不存在于 Addressables)标橙显示
|
||||
/// - 一键导出 CSV
|
||||
/// </summary>
|
||||
public class AddressReferenceGraphWindow : EditorWindow
|
||||
{
|
||||
// ── State ──────────────────────────────────────────────────────────
|
||||
private List<KeyEntry> _entries;
|
||||
private Vector2 _scrollPos;
|
||||
private string _searchFilter = "";
|
||||
private bool _showOrphansOnly;
|
||||
private bool _showMissingOnly;
|
||||
|
||||
// ── Colors ─────────────────────────────────────────────────────────
|
||||
private static readonly Color ColOrphan = new Color(0.90f, 0.15f, 0.15f, 0.80f); // 孤儿 Key(无引用)
|
||||
private static readonly Color ColMissing = new Color(0.95f, 0.55f, 0.10f, 0.80f); // 无效 Key(不在 Addressables)
|
||||
private static readonly Color ColOk = new Color(0.20f, 0.75f, 0.30f, 0.80f); // 正常
|
||||
|
||||
[MenuItem("BaseGames/Tools/Asset Reference Graph")]
|
||||
public static void OpenWindow()
|
||||
{
|
||||
var win = GetWindow<AddressReferenceGraphWindow>("Asset Reference Graph");
|
||||
win.minSize = new Vector2(900, 500);
|
||||
win.Show();
|
||||
}
|
||||
|
||||
// ── GUI ────────────────────────────────────────────────────────────
|
||||
|
||||
private void OnGUI()
|
||||
{
|
||||
DrawToolbar();
|
||||
|
||||
if (_entries == null)
|
||||
{
|
||||
EditorGUILayout.HelpBox("点击上方「扫描」按钮分析 AddressKeys 引用关系。", MessageType.Info);
|
||||
return;
|
||||
}
|
||||
|
||||
DrawFilterRow();
|
||||
DrawResults();
|
||||
}
|
||||
|
||||
// ── Toolbar ───────────────────────────────────────────────────────
|
||||
|
||||
private void DrawToolbar()
|
||||
{
|
||||
EditorGUILayout.BeginHorizontal(EditorStyles.toolbar);
|
||||
|
||||
if (GUILayout.Button("扫描", EditorStyles.toolbarButton, GUILayout.Width(60)))
|
||||
RunScan();
|
||||
|
||||
if (_entries != null && GUILayout.Button("导出 CSV", EditorStyles.toolbarButton, GUILayout.Width(70)))
|
||||
ExportCsv();
|
||||
|
||||
GUILayout.FlexibleSpace();
|
||||
|
||||
if (_entries != null)
|
||||
{
|
||||
int orphans = _entries.Count(e => e.ReferenceCount == 0);
|
||||
int missing = _entries.Count(e => !e.ExistsInAddressables);
|
||||
EditorGUILayout.LabelField(
|
||||
$"共 {_entries.Count} 个 Key | 孤儿:{orphans} | 未在 Addressables:{missing}",
|
||||
EditorStyles.toolbarButton);
|
||||
}
|
||||
|
||||
EditorGUILayout.EndHorizontal();
|
||||
}
|
||||
|
||||
// ── 过滤行 ────────────────────────────────────────────────────────
|
||||
|
||||
private void DrawFilterRow()
|
||||
{
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
EditorGUILayout.LabelField("搜索:", GUILayout.Width(40));
|
||||
_searchFilter = EditorGUILayout.TextField(_searchFilter, GUILayout.ExpandWidth(true));
|
||||
_showOrphansOnly = EditorGUILayout.ToggleLeft("仅显示孤儿", _showOrphansOnly, GUILayout.Width(90));
|
||||
_showMissingOnly = EditorGUILayout.ToggleLeft("仅显示缺失", _showMissingOnly, GUILayout.Width(90));
|
||||
EditorGUILayout.EndHorizontal();
|
||||
}
|
||||
|
||||
// ── 结果列表 ──────────────────────────────────────────────────────
|
||||
|
||||
private void DrawResults()
|
||||
{
|
||||
var filtered = _entries.AsEnumerable();
|
||||
|
||||
if (_showOrphansOnly)
|
||||
filtered = filtered.Where(e => e.ReferenceCount == 0);
|
||||
if (_showMissingOnly)
|
||||
filtered = filtered.Where(e => !e.ExistsInAddressables);
|
||||
if (!string.IsNullOrEmpty(_searchFilter))
|
||||
filtered = filtered.Where(e =>
|
||||
e.FieldName.IndexOf(_searchFilter, System.StringComparison.OrdinalIgnoreCase) >= 0);
|
||||
|
||||
var list = filtered.ToList();
|
||||
|
||||
// 表头
|
||||
EditorGUILayout.BeginHorizontal(EditorStyles.helpBox);
|
||||
EditorGUILayout.LabelField("状态", GUILayout.Width(50));
|
||||
EditorGUILayout.LabelField("Key 名称", GUILayout.Width(280));
|
||||
EditorGUILayout.LabelField("地址值", GUILayout.Width(300));
|
||||
EditorGUILayout.LabelField("引用数", GUILayout.Width(60));
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
_scrollPos = EditorGUILayout.BeginScrollView(_scrollPos);
|
||||
|
||||
foreach (var entry in list)
|
||||
{
|
||||
bool isOrphan = entry.ReferenceCount == 0;
|
||||
bool isMissing = !entry.ExistsInAddressables;
|
||||
|
||||
Color statusColor = isOrphan ? ColOrphan : (isMissing ? ColMissing : ColOk);
|
||||
string statusIcon = isOrphan ? "⊘" : (isMissing ? "⚠" : "✓");
|
||||
|
||||
var prevBg = GUI.backgroundColor;
|
||||
GUI.backgroundColor = statusColor * 0.6f;
|
||||
EditorGUILayout.BeginHorizontal("box");
|
||||
GUI.backgroundColor = prevBg;
|
||||
|
||||
EditorGUILayout.LabelField(statusIcon, GUILayout.Width(50));
|
||||
EditorGUILayout.LabelField(entry.FieldName, GUILayout.Width(280));
|
||||
|
||||
// 地址值可点击 → Ping Addressable asset
|
||||
if (GUILayout.Button(entry.Value,
|
||||
isOrphan ? EditorStyles.label : EditorStyles.miniButtonMid,
|
||||
GUILayout.Width(300)))
|
||||
{
|
||||
PingAddressableAsset(entry.Value);
|
||||
}
|
||||
|
||||
EditorGUILayout.LabelField(
|
||||
$"{entry.ReferenceCount}",
|
||||
GUILayout.Width(60));
|
||||
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
// 展开:显示引用文件列表
|
||||
if (entry.ReferenceCount > 0 && entry.ReferencedInFiles != null)
|
||||
{
|
||||
foreach (var file in entry.ReferencedInFiles)
|
||||
{
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
GUILayout.Space(60);
|
||||
EditorGUILayout.LabelField($" ↳ {file}", EditorStyles.miniLabel);
|
||||
EditorGUILayout.EndHorizontal();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
EditorGUILayout.EndScrollView();
|
||||
}
|
||||
|
||||
// ── 扫描逻辑 ──────────────────────────────────────────────────────
|
||||
|
||||
private void RunScan()
|
||||
{
|
||||
_entries = new List<KeyEntry>();
|
||||
|
||||
// 1. 收集所有 AddressKeys 常量
|
||||
var keyFields = typeof(BaseGames.Core.Assets.AddressKeys)
|
||||
.GetFields(System.Reflection.BindingFlags.Public
|
||||
| System.Reflection.BindingFlags.Static
|
||||
| System.Reflection.BindingFlags.FlattenHierarchy)
|
||||
.Where(f => f.IsLiteral && !f.IsInitOnly && f.FieldType == typeof(string));
|
||||
|
||||
var keyDict = new Dictionary<string, KeyEntry>();
|
||||
foreach (var f in keyFields)
|
||||
{
|
||||
var value = (string)f.GetRawConstantValue();
|
||||
keyDict[f.Name] = new KeyEntry
|
||||
{
|
||||
FieldName = f.Name,
|
||||
Value = value,
|
||||
ExistsInAddressables = false,
|
||||
ReferencedInFiles = new List<string>()
|
||||
};
|
||||
}
|
||||
|
||||
// 2. 检查 Addressables(直接内联,不依赖 Verification 程序集)
|
||||
var registeredAddressValues = GetRegisteredAddressableAddresses();
|
||||
|
||||
foreach (var kv in keyDict)
|
||||
kv.Value.ExistsInAddressables = registeredAddressValues.Contains(kv.Value.Value);
|
||||
|
||||
// 3. 扫描 .cs 文件引用
|
||||
var csFiles = Directory.GetFiles(
|
||||
Path.Combine(Application.dataPath, "Scripts"),
|
||||
"*.cs",
|
||||
SearchOption.AllDirectories);
|
||||
|
||||
foreach (var file in csFiles)
|
||||
{
|
||||
string content;
|
||||
try { content = File.ReadAllText(file); }
|
||||
catch { continue; }
|
||||
|
||||
foreach (var kv in keyDict)
|
||||
{
|
||||
// 匹配 AddressKeys.FieldName(单词边界,避免前缀误匹配)
|
||||
if (Regex.IsMatch(content, $@"\bAddressKeys\.{Regex.Escape(kv.Key)}\b"))
|
||||
{
|
||||
string relativePath = "Assets" + file.Substring(Application.dataPath.Length).Replace('\\', '/');
|
||||
kv.Value.ReferencedInFiles.Add(relativePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var kv in keyDict)
|
||||
_entries.Add(kv.Value);
|
||||
|
||||
_entries.Sort((a, b) =>
|
||||
{
|
||||
// 孤儿排最前,其次缺失,最后正常
|
||||
int aScore = a.ReferenceCount == 0 ? 0 : (!a.ExistsInAddressables ? 1 : 2);
|
||||
int bScore = b.ReferenceCount == 0 ? 0 : (!b.ExistsInAddressables ? 1 : 2);
|
||||
return aScore != bScore ? aScore.CompareTo(bScore) : string.Compare(a.FieldName, b.FieldName);
|
||||
});
|
||||
|
||||
Debug.Log($"[AddressReferenceGraph] 扫描完成:{_entries.Count} 个 Key," +
|
||||
$"{_entries.Count(e => e.ReferenceCount == 0)} 孤儿," +
|
||||
$"{_entries.Count(e => !e.ExistsInAddressables)} 未在 Addressables。");
|
||||
}
|
||||
|
||||
// ── CSV 导出 ──────────────────────────────────────────────────────
|
||||
|
||||
private void ExportCsv()
|
||||
{
|
||||
string path = EditorUtility.SaveFilePanel("导出 CSV", "", "AddressKeyReport", "csv");
|
||||
if (string.IsNullOrEmpty(path)) return;
|
||||
|
||||
using var writer = new StreamWriter(path, false, System.Text.Encoding.UTF8);
|
||||
writer.WriteLine("FieldName,Value,ExistsInAddressables,ReferenceCount,ReferencedFiles");
|
||||
foreach (var e in _entries)
|
||||
{
|
||||
string files = e.ReferencedInFiles != null
|
||||
? string.Join(" | ", e.ReferencedInFiles)
|
||||
: "";
|
||||
writer.WriteLine($"{e.FieldName},{e.Value},{e.ExistsInAddressables},{e.ReferenceCount},{files}");
|
||||
}
|
||||
|
||||
Debug.Log($"[AddressReferenceGraph] CSV 已导出:{path}");
|
||||
}
|
||||
|
||||
// ── Addressables 辅助(独立实现,不依赖 Verification 程序集)─────────
|
||||
|
||||
private static HashSet<string> GetRegisteredAddressableAddresses()
|
||||
{
|
||||
var addresses = new HashSet<string>(StringComparer.Ordinal);
|
||||
var settings = AddressableAssetSettingsDefaultObject.Settings;
|
||||
if (settings == null) return addresses;
|
||||
foreach (var group in settings.groups)
|
||||
{
|
||||
if (group == null) continue;
|
||||
foreach (var entry in group.entries)
|
||||
if (entry != null) addresses.Add(entry.address);
|
||||
}
|
||||
return addresses;
|
||||
}
|
||||
|
||||
// ── Ping Addressable ──────────────────────────────────────────────
|
||||
|
||||
private static void PingAddressableAsset(string address)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
var guids = AssetDatabase.FindAssets($"\"{address}\"");
|
||||
if (guids.Length > 0)
|
||||
{
|
||||
var obj = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(AssetDatabase.GUIDToAssetPath(guids[0]));
|
||||
if (obj != null) EditorGUIUtility.PingObject(obj);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// ── Data ──────────────────────────────────────────────────────────
|
||||
|
||||
private class KeyEntry
|
||||
{
|
||||
public string FieldName;
|
||||
public string Value;
|
||||
public bool ExistsInAddressables;
|
||||
public List<string> ReferencedInFiles;
|
||||
public int ReferenceCount => ReferencedInFiles?.Count ?? 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 884c3c28d25877643afa90b72ba2a650
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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:
|
||||
@@ -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._vcamA(Persistent 场景)
|
||||
/// → 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("CameraStateController(Persistent 场景)");
|
||||
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 的对象。" +
|
||||
"请先放置 Player(BaseGames → 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:
|
||||
@@ -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 增强 Inspector(W-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 Prefab(W-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:
|
||||
@@ -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 < 0.1 时阶段条变红警告
|
||||
/// - 点击阶段条高亮对应 AttackPatternSO(EditorGUIUtility.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.effects(List<ICharmEffect>)提供友好的 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:
|
||||
@@ -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<T> 子类提供 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:
|
||||
@@ -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 NavSurfaces(Ctrl+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:
|
||||
@@ -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("未找到 EnemyStatsSO,EnemyBase._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("未找到 DamageSourceSO,HitBox_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 Source,AudioMixer 仍需手工指定。");
|
||||
}
|
||||
|
||||
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:
|
||||
@@ -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:
|
||||
@@ -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 Prefab(W-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:
|
||||
@@ -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
|
||||
@@ -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(
|
||||
"Gizmo:Scene 视图中橙红色边框 = 可破坏物。\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:
|
||||
Reference in New Issue
Block a user