多轮审查和修复
This commit is contained in:
8
Assets/Scripts/Editor/Achievements.meta
Normal file
8
Assets/Scripts/Editor/Achievements.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 07ed02361aa3739468cbd36457aecda6
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
112
Assets/Scripts/Editor/Achievements/AchievementSOEditor.cs
Normal file
112
Assets/Scripts/Editor/Achievements/AchievementSOEditor.cs
Normal file
@@ -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:
|
||||
@@ -3,6 +3,8 @@ 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;
|
||||
@@ -16,6 +18,30 @@ namespace BaseGames.Editor
|
||||
/// 是否与 Addressable 分组中实际存在的地址同步(架构 13_AssetPoolModule §10)。
|
||||
///
|
||||
/// 菜单:BaseGames → Tools → 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
|
||||
{
|
||||
@@ -91,7 +117,8 @@ namespace BaseGames.Editor
|
||||
if (missing == 0)
|
||||
Debug.Log($"[AddressKeyValidator] ✓ 所有 {results.Count} 个 AddressKeys 常量均在 Addressable 分组中存在。");
|
||||
else
|
||||
Debug.LogWarning($"[AddressKeyValidator] 共 {results.Count} 个常量,发现 {missing} 个孤儿 Key,请检查 Addressable 分组配置。");
|
||||
Debug.LogWarning($"[AddressKeyValidator] 共 {results.Count} 个常量,发现 {missing} 个孤儿 Key。" +
|
||||
$"尚未创建的 Prefab/Scene 资产请在创建后添加至 Addressables 分组。");
|
||||
}
|
||||
|
||||
// ── 结果结构 ──────────────────────────────────────────────────────────
|
||||
|
||||
288
Assets/Scripts/Editor/AddressReferenceGraphWindow.cs
Normal file
288
Assets/Scripts/Editor/AddressReferenceGraphWindow.cs
Normal file
@@ -0,0 +1,288 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using UnityEditor;
|
||||
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
|
||||
var registeredAddresses = AddressKeyValidator.RunValidation()
|
||||
.Where(r => r.ExistsInAddressables)
|
||||
.Select(r => r.FieldName)
|
||||
.ToHashSet();
|
||||
|
||||
foreach (var kv in keyDict)
|
||||
kv.Value.ExistsInAddressables = registeredAddresses.Contains(kv.Key);
|
||||
|
||||
// 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}");
|
||||
}
|
||||
|
||||
// ── Ping Addressable ──────────────────────────────────────────────
|
||||
|
||||
private static void PingAddressableAsset(string address)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
var guids = AssetDatabase.FindAssets($"\"{address}\"");
|
||||
if (guids.Length > 0)
|
||||
{
|
||||
var obj = AssetDatabase.LoadAssetAtPath<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;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Editor/AddressReferenceGraphWindow.cs.meta
Normal file
11
Assets/Scripts/Editor/AddressReferenceGraphWindow.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 884c3c28d25877643afa90b72ba2a650
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,32 +1,42 @@
|
||||
{
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"precompiledReferences": [],
|
||||
"name": "BaseGames.Editor",
|
||||
"defineConstraints": [],
|
||||
"noEngineReferences": false,
|
||||
"versionDefines": [],
|
||||
"rootNamespace": "BaseGames.Editor",
|
||||
"references": [
|
||||
"BaseGames.Core",
|
||||
"BaseGames.Core.Events",
|
||||
"Unity.Addressables",
|
||||
"Unity.Addressables.Editor",
|
||||
"BaseGames.Core.Save",
|
||||
"BaseGames.Input",
|
||||
"BaseGames.Combat",
|
||||
"BaseGames.Player",
|
||||
"BaseGames.Enemies",
|
||||
"BaseGames.World",
|
||||
"BaseGames.UI",
|
||||
"BaseGames.Audio",
|
||||
"BaseGames.Feedback",
|
||||
"BaseGames.Dialogue",
|
||||
"BaseGames.Progression"
|
||||
],
|
||||
"autoReferenced": false,
|
||||
"overrideReferences": false,
|
||||
"includePlatforms": [
|
||||
"Editor"
|
||||
]
|
||||
}
|
||||
"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.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"
|
||||
],
|
||||
"includePlatforms": [
|
||||
"Editor"
|
||||
],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": false,
|
||||
"precompiledReferences": [],
|
||||
"autoReferenced": false,
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [],
|
||||
"noEngineReferences": false
|
||||
}
|
||||
305
Assets/Scripts/Editor/BossSkillSequenceWindow.cs
Normal file
305
Assets/Scripts/Editor/BossSkillSequenceWindow.cs
Normal file
@@ -0,0 +1,305 @@
|
||||
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 TimelineW = 600f;
|
||||
private const float MinBarWidth = 6f;
|
||||
|
||||
// ── 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Editor/BossSkillSequenceWindow.cs.meta
Normal file
11
Assets/Scripts/Editor/BossSkillSequenceWindow.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d47145d394333184eb3ff822e3c4aa4d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Assets/Scripts/Editor/Combat.meta
Normal file
8
Assets/Scripts/Editor/Combat.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a884190f06d571d47b05f7693fab90e2
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
64
Assets/Scripts/Editor/Combat/HurtBoxEditor.cs
Normal file
64
Assets/Scripts/Editor/Combat/HurtBoxEditor.cs
Normal file
@@ -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
|
||||
11
Assets/Scripts/Editor/Combat/HurtBoxEditor.cs.meta
Normal file
11
Assets/Scripts/Editor/Combat/HurtBoxEditor.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8650ccc7960fe304a95be1c629ef7b1e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -30,7 +30,11 @@ namespace BaseGames.Editor
|
||||
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");
|
||||
@@ -38,14 +42,20 @@ namespace BaseGames.Editor
|
||||
// ── 战斗 ──────────────────────────────────────────────────────────
|
||||
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");
|
||||
@@ -54,8 +64,14 @@ namespace BaseGames.Editor
|
||||
// ── 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");
|
||||
@@ -121,7 +137,7 @@ namespace BaseGames.Editor
|
||||
Debug.Log($"[CreateEventChannelAssets] 已创建: {fullPath}");
|
||||
}
|
||||
|
||||
/// <summary>递归创建所有缺失的中间文件夹(兼容 AssetDatabase)。</summary>
|
||||
/// <summary>递归创建所有缺失的中间文件夹(使用 AssetDatabase API)。</summary>
|
||||
private static void EnsureDirectory(string path)
|
||||
{
|
||||
if (AssetDatabase.IsValidFolder(path))
|
||||
|
||||
8
Assets/Scripts/Editor/Equipment.meta
Normal file
8
Assets/Scripts/Editor/Equipment.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 53f701b15574fcc49bc11d1e8798ba52
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
118
Assets/Scripts/Editor/Equipment/CharmEffectDrawer.cs
Normal file
118
Assets/Scripts/Editor/Equipment/CharmEffectDrawer.cs
Normal file
@@ -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
|
||||
11
Assets/Scripts/Editor/Equipment/CharmEffectDrawer.cs.meta
Normal file
11
Assets/Scripts/Editor/Equipment/CharmEffectDrawer.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 38fb3e35ebefbc8418ba2ea0b5781f92
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
117
Assets/Scripts/Editor/EventBusMonitorWindow.cs
Normal file
117
Assets/Scripts/Editor/EventBusMonitorWindow.cs
Normal file
@@ -0,0 +1,117 @@
|
||||
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))
|
||||
{
|
||||
GUILayout.Label("Filter", GUILayout.Width(34f));
|
||||
_filter = GUILayout.TextField(_filter, EditorStyles.toolbarTextField, GUILayout.MinWidth(180f));
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Editor/EventBusMonitorWindow.cs.meta
Normal file
11
Assets/Scripts/Editor/EventBusMonitorWindow.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 861ce74d8a5c0ce4f957719423a0be7b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
312
Assets/Scripts/Editor/EventChainEditorWindow.cs
Normal file
312
Assets/Scripts/Editor/EventChainEditorWindow.cs
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Editor/EventChainEditorWindow.cs.meta
Normal file
11
Assets/Scripts/Editor/EventChainEditorWindow.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e8cb0e5db63d15d418e73c29e6ff6f1f
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
92
Assets/Scripts/Editor/EventChannelEditor.cs
Normal file
92
Assets/Scripts/Editor/EventChannelEditor.cs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Editor/EventChannelEditor.cs.meta
Normal file
11
Assets/Scripts/Editor/EventChannelEditor.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 39fd6fe0ebb5ceb4db85f82919217956
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
173
Assets/Scripts/Editor/EventConfigEditor.cs
Normal file
173
Assets/Scripts/Editor/EventConfigEditor.cs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Editor/EventConfigEditor.cs.meta
Normal file
11
Assets/Scripts/Editor/EventConfigEditor.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c992100309cc05a40bb06a3e23076c5b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Assets/Scripts/Editor/Map.meta
Normal file
8
Assets/Scripts/Editor/Map.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 08a52815a08c8c3428ccb6a530171ddd
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
140
Assets/Scripts/Editor/Map/MapRoomDataEditor.cs
Normal file
140
Assets/Scripts/Editor/Map/MapRoomDataEditor.cs
Normal file
@@ -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
|
||||
11
Assets/Scripts/Editor/Map/MapRoomDataEditor.cs.meta
Normal file
11
Assets/Scripts/Editor/Map/MapRoomDataEditor.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 168d8a104fffcaf4db9849cd8b2140f9
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
67
Assets/Scripts/Editor/NavSurfaceBakeShortcut.cs
Normal file
67
Assets/Scripts/Editor/NavSurfaceBakeShortcut.cs
Normal file
@@ -0,0 +1,67 @@
|
||||
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
|
||||
{
|
||||
[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;
|
||||
|
||||
surface.StartBakeJob();
|
||||
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 || surface.BakeJob == null)
|
||||
{
|
||||
EditorApplication.update -= watcher;
|
||||
return;
|
||||
}
|
||||
if (surface.BakeJob.IsFinished)
|
||||
{
|
||||
EditorApplication.update -= watcher;
|
||||
EditorUtility.SetDirty(surface);
|
||||
Debug.Log($"[NavSurfaceBake] ✓ {surface.name} 烘焙完成({surface.BakeJob.TotalBakeTime} ms)");
|
||||
}
|
||||
};
|
||||
return watcher;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Editor/NavSurfaceBakeShortcut.cs.meta
Normal file
11
Assets/Scripts/Editor/NavSurfaceBakeShortcut.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 291f3b33fb176b8469ebaaa8afa317ba
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
904
Assets/Scripts/Editor/SceneScaffoldTools.cs
Normal file
904
Assets/Scripts/Editor/SceneScaffoldTools.cs
Normal file
@@ -0,0 +1,904 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using BaseGames.Audio;
|
||||
using BaseGames.Camera;
|
||||
using BaseGames.Combat;
|
||||
using BaseGames.Core;
|
||||
using BaseGames.Core.Events;
|
||||
using BaseGames.Core.Pool;
|
||||
using BaseGames.Enemies;
|
||||
using BaseGames.Input;
|
||||
using BaseGames.Player;
|
||||
using BaseGames.Player.States;
|
||||
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;
|
||||
|
||||
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);
|
||||
|
||||
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/Data/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(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, "_onSceneLoaded", report, false, "EVT_SceneLoaded");
|
||||
AssignAsset(sceneService, "_onFadeInRequest", report, false, "EVT_FadeInRequest");
|
||||
AssignAsset(sceneService, "_onFadeOutRequest", report, false, "EVT_FadeOutRequest");
|
||||
|
||||
AssignAsset(sceneLoader, "_onSceneLoadRequest", report, false, "EVT_SceneLoadRequest");
|
||||
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);
|
||||
}
|
||||
|
||||
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。");
|
||||
}
|
||||
|
||||
|
||||
[MenuItem("BaseGames/Tools/Scaffold Test Room")]
|
||||
public static void ScaffoldTestRoom()
|
||||
{
|
||||
var report = new List<string>();
|
||||
|
||||
EnsureEventChannelAssets(report);
|
||||
|
||||
Object inputReaderAsset = FindFirstAssetByType<InputReaderSO>("InputReader", "InputReaderSO");
|
||||
if (inputReaderAsset == null)
|
||||
inputReaderAsset = EnsureInputReaderAsset(report);
|
||||
|
||||
GameObject root = GetOrCreateRoot("[TestRoom]");
|
||||
Transform environment = GetOrCreateChild(root.transform, "[Environment]");
|
||||
Transform playerRoot = GetOrCreateChild(root.transform, "[Player]");
|
||||
Transform enemyRoot = GetOrCreateChild(root.transform, "[Enemy]");
|
||||
Transform savePointRoot = GetOrCreateChild(root.transform, "[SavePoint]");
|
||||
Transform cameraRoot = GetOrCreateChild(root.transform, "[Camera]");
|
||||
|
||||
DisableRenderCamerasUnderRoot(root.transform, report);
|
||||
|
||||
Object playerStatsConfigAsset = EnsurePlayerStatsConfigAsset(report);
|
||||
Object playerMovementConfigAsset = EnsurePlayerMovementConfigAsset(report);
|
||||
Object playerAnimationConfigAsset = EnsurePlayerAnimationConfigAsset(report);
|
||||
|
||||
GameObject groundGridGo = GetOrCreateChild(environment, "GroundGrid").gameObject;
|
||||
GetOrAddComponent<Grid>(groundGridGo);
|
||||
GameObject groundGo = GetOrCreateChild(groundGridGo.transform, "Ground").gameObject;
|
||||
TilemapCollider2D tilemapCollider = GetOrAddComponent<TilemapCollider2D>(groundGo);
|
||||
tilemapCollider.usedByComposite = true;
|
||||
GetOrAddComponent<Tilemap>(groundGo);
|
||||
GetOrAddComponent<TilemapRenderer>(groundGo);
|
||||
Rigidbody2D groundBody = GetOrAddComponent<Rigidbody2D>(groundGo);
|
||||
groundBody.bodyType = RigidbodyType2D.Static;
|
||||
GetOrAddComponent<CompositeCollider2D>(groundGo);
|
||||
SetLayer(groundGo, "Ground", report);
|
||||
|
||||
GameObject fallbackFloorGo = GetOrCreateChild(environment, "GroundFallback").gameObject;
|
||||
fallbackFloorGo.transform.position = new Vector3(0f, -2f, 0f);
|
||||
BoxCollider2D fallbackFloorCollider = GetOrAddComponent<BoxCollider2D>(fallbackFloorGo);
|
||||
fallbackFloorCollider.size = new Vector2(40f, 1f);
|
||||
Rigidbody2D fallbackFloorBody = GetOrAddComponent<Rigidbody2D>(fallbackFloorGo);
|
||||
fallbackFloorBody.bodyType = RigidbodyType2D.Static;
|
||||
SetLayer(fallbackFloorGo, "Ground", report);
|
||||
EnsureVisualSprite(fallbackFloorGo.transform, "Visual", new Color(0.25f, 0.7f, 0.3f, 1f), new Vector2(40f, 1f), -10);
|
||||
AddScaffoldNote(fallbackFloorGo, "保底地板用于测试:若 Tilemap 资源未提供碰撞形状,角色仍不会穿地。", report);
|
||||
|
||||
GameObject navSurfaceGo = GetOrCreateChild(environment, "NavSurfaceRoot").gameObject;
|
||||
GetOrAddComponent<NavSurface>(navSurfaceGo);
|
||||
AddScaffoldNote(navSurfaceGo, "NavSurface 已创建,仍需在 Unity Inspector 中点击 Bake。", report);
|
||||
|
||||
GameObject playerGo = GetOrCreateChild(playerRoot, "Player").gameObject;
|
||||
RemoveMissingScripts(playerGo, recursive: true, report);
|
||||
playerGo.transform.position = new Vector3(0f, 1f, 0f);
|
||||
playerGo.tag = "Player";
|
||||
SetLayer(playerGo, "Player", report);
|
||||
PlayerController playerController = GetOrAddComponent<PlayerController>(playerGo);
|
||||
InputBuffer inputBuffer = GetOrAddComponent<InputBuffer>(playerGo);
|
||||
PlayerMovement playerMovement = GetOrAddComponent<PlayerMovement>(playerGo);
|
||||
PlayerStats playerStats = GetOrAddComponent<PlayerStats>(playerGo);
|
||||
PlayerCombat playerCombat = GetOrAddComponent<PlayerCombat>(playerGo);
|
||||
FormController formController = GetOrAddComponent<FormController>(playerGo);
|
||||
WeaponManager weaponManager = GetOrAddComponent<WeaponManager>(playerGo);
|
||||
SkillManager skillManager = GetOrAddComponent<SkillManager>(playerGo);
|
||||
SpringSystem springSystem = GetOrAddComponent<SpringSystem>(playerGo);
|
||||
Component parrySystem = AddComponentIfTypeExists(playerGo, "BaseGames.Parry.ParrySystem", report);
|
||||
ShieldComponent shieldComponent = GetOrAddComponent<ShieldComponent>(playerGo);
|
||||
Rigidbody2D playerBody = GetOrAddComponent<Rigidbody2D>(playerGo);
|
||||
playerBody.bodyType = RigidbodyType2D.Dynamic;
|
||||
playerBody.gravityScale = 2f;
|
||||
playerBody.constraints = RigidbodyConstraints2D.FreezeRotation;
|
||||
GetOrAddComponent<CapsuleCollider2D>(playerGo);
|
||||
Animancer.AnimancerComponent playerAnimancer = GetOrAddComponent<Animancer.AnimancerComponent>(playerGo);
|
||||
EnsureVisualSprite(playerGo.transform, "Visual", new Color(0.3f, 0.7f, 1f, 1f), new Vector2(0.8f, 1.6f), 20);
|
||||
|
||||
GameObject groundCheckGo = GetOrCreateChild(playerGo.transform, "GroundCheck").gameObject;
|
||||
groundCheckGo.transform.localPosition = new Vector3(0f, -0.5f, 0f);
|
||||
|
||||
GameObject hitBoxGroundGo = GetOrCreateChild(playerGo.transform, "HitBoxGround").gameObject;
|
||||
BoxCollider2D hitBoxGroundCollider = GetOrAddComponent<BoxCollider2D>(hitBoxGroundGo);
|
||||
hitBoxGroundCollider.isTrigger = true;
|
||||
HitBox hitBoxGround = GetOrAddComponent<HitBox>(hitBoxGroundGo);
|
||||
SetLayer(hitBoxGroundGo, "PlayerHitBox", report);
|
||||
|
||||
GameObject hitBoxUpGo = GetOrCreateChild(playerGo.transform, "HitBoxUp").gameObject;
|
||||
BoxCollider2D hitBoxUpCollider = GetOrAddComponent<BoxCollider2D>(hitBoxUpGo);
|
||||
hitBoxUpCollider.isTrigger = true;
|
||||
HitBox hitBoxUp = GetOrAddComponent<HitBox>(hitBoxUpGo);
|
||||
SetLayer(hitBoxUpGo, "PlayerHitBox", report);
|
||||
|
||||
GameObject hitBoxDownGo = GetOrCreateChild(playerGo.transform, "HitBoxDown").gameObject;
|
||||
BoxCollider2D hitBoxDownCollider = GetOrAddComponent<BoxCollider2D>(hitBoxDownGo);
|
||||
hitBoxDownCollider.isTrigger = true;
|
||||
HitBox hitBoxDown = GetOrAddComponent<HitBox>(hitBoxDownGo);
|
||||
SetLayer(hitBoxDownGo, "PlayerHitBox", report);
|
||||
|
||||
GameObject hitBoxAirGo = GetOrCreateChild(playerGo.transform, "HitBoxAir").gameObject;
|
||||
BoxCollider2D hitBoxAirCollider = GetOrAddComponent<BoxCollider2D>(hitBoxAirGo);
|
||||
hitBoxAirCollider.isTrigger = true;
|
||||
HitBox hitBoxAir = GetOrAddComponent<HitBox>(hitBoxAirGo);
|
||||
SetLayer(hitBoxAirGo, "PlayerHitBox", report);
|
||||
|
||||
GameObject playerHurtBoxGo = GetOrCreateChild(playerGo.transform, "HurtBox").gameObject;
|
||||
CapsuleCollider2D playerHurtCollider = GetOrAddComponent<CapsuleCollider2D>(playerHurtBoxGo);
|
||||
playerHurtCollider.isTrigger = true;
|
||||
HurtBox playerHurtBox = GetOrAddComponent<HurtBox>(playerHurtBoxGo);
|
||||
SetLayer(playerHurtBoxGo, "PlayerHurtBox", report);
|
||||
|
||||
GameObject enemyGo = GetOrCreateChild(enemyRoot, "BasicEnemy").gameObject;
|
||||
SetLayer(enemyGo, "Enemy", report);
|
||||
EnemyBase enemyBase = GetOrAddComponent<EnemyBase>(enemyGo);
|
||||
EnemyStats enemyStats = GetOrAddComponent<EnemyStats>(enemyGo);
|
||||
GetOrAddComponent<Rigidbody2D>(enemyGo).bodyType = RigidbodyType2D.Dynamic;
|
||||
GetOrAddComponent<CapsuleCollider2D>(enemyGo);
|
||||
GetOrAddComponent<Animancer.AnimancerComponent>(enemyGo);
|
||||
EnsureVisualSprite(enemyGo.transform, "Visual", new Color(1f, 0.45f, 0.45f, 1f), new Vector2(0.8f, 1.4f), 15);
|
||||
AddComponentIfTypeExists(enemyGo, "PathBerserker2d.NavAgent", report);
|
||||
|
||||
GameObject enemyHurtBoxGo = GetOrCreateChild(enemyGo.transform, "HurtBox").gameObject;
|
||||
CapsuleCollider2D enemyHurtCollider = GetOrAddComponent<CapsuleCollider2D>(enemyHurtBoxGo);
|
||||
enemyHurtCollider.isTrigger = true;
|
||||
HurtBox enemyHurtBox = GetOrAddComponent<HurtBox>(enemyHurtBoxGo);
|
||||
SetLayer(enemyHurtBoxGo, "EnemyHurtBox", report);
|
||||
|
||||
GameObject savePointGo = GetOrCreateChild(savePointRoot, "SavePointObject").gameObject;
|
||||
SavePoint savePoint = GetOrAddComponent<SavePoint>(savePointGo);
|
||||
BoxCollider2D savePointCollider = GetOrAddComponent<BoxCollider2D>(savePointGo);
|
||||
savePointCollider.isTrigger = true;
|
||||
SetLayer(savePointGo, "TriggerZone", report);
|
||||
EnsureVisualSprite(savePointGo.transform, "Visual", new Color(1f, 0.9f, 0.3f, 0.9f), new Vector2(0.8f, 1.2f), 10);
|
||||
AssignAsset(savePoint, "_onSavePointActivated", report, false, "EVT_SavePointActivated");
|
||||
AssignAsset(savePoint, "_onFastTravelOpen", report, false, "EVT_FastTravelOpen");
|
||||
|
||||
GameObject roomCameraGo = GetOrCreateChild(cameraRoot, "RoomCamera").gameObject;
|
||||
CinemachineCamera roomCameraComponent = GetOrAddComponent<CinemachineCamera>(roomCameraGo);
|
||||
RoomCamera roomCamera = GetOrAddComponent<RoomCamera>(roomCameraGo);
|
||||
UnityEngine.Camera roomRenderCamera = roomCameraGo.GetComponent<UnityEngine.Camera>();
|
||||
if (roomRenderCamera != null)
|
||||
{
|
||||
roomRenderCamera.orthographic = false;
|
||||
roomRenderCamera.fieldOfView = 60f;
|
||||
roomRenderCamera.enabled = false;
|
||||
report.Add("RoomCamera 上的 Unity Camera 已禁用(Additive 测试时仅保留 Persistent/Main Camera 渲染)。");
|
||||
}
|
||||
AudioListener roomAudioListener = roomCameraGo.GetComponent<AudioListener>();
|
||||
if (roomAudioListener != null)
|
||||
{
|
||||
Undo.DestroyObjectImmediate(roomAudioListener);
|
||||
report.Add("RoomCamera 上的 AudioListener 已移除(Additive 测试时由 Persistent/Main Camera 保留唯一监听器)。");
|
||||
}
|
||||
GameObject roomBoundaryGo = GetOrCreateChild(roomCameraGo.transform, "RoomBoundary").gameObject;
|
||||
RemoveMissingScripts(roomBoundaryGo, recursive: true, report);
|
||||
RoomVisibleArea roomVisibleArea = GetOrAddComponent<RoomVisibleArea>(roomBoundaryGo);
|
||||
CinemachineConfiner2D confiner2D = GetOrAddComponent<CinemachineConfiner2D>(roomCameraGo);
|
||||
AssignReference(roomCamera, "_visibleArea", roomVisibleArea);
|
||||
AssignReferenceByCandidates(roomCameraComponent, playerGo.transform, report, "Follow", "m_Follow");
|
||||
AssignReferenceByCandidates(confiner2D, roomVisibleArea.Collider, report, "BoundingShape2D", "m_BoundingShape2D");
|
||||
AddScaffoldNote(roomBoundaryGo, "RoomBoundary 已挂 RoomVisibleArea,需要手工编辑 PolygonCollider2D 顶点定义房间边界。", report);
|
||||
|
||||
if (inputReaderAsset != null)
|
||||
{
|
||||
AssignReference(playerController, "_inputReader", inputReaderAsset, report);
|
||||
AssignReference(inputBuffer, "_inputReader", inputReaderAsset, report);
|
||||
}
|
||||
else
|
||||
{
|
||||
report.Add("未找到 InputReader 资产,PlayerController/InputBuffer 的 _inputReader 未能绑定。");
|
||||
}
|
||||
|
||||
AssignReference(playerController, "_movement", playerMovement, report);
|
||||
AssignReference(playerController, "_animancer", playerAnimancer, report);
|
||||
AssignReference(playerController, "_combat", playerCombat, report);
|
||||
AssignReference(playerController, "_formController", formController, report);
|
||||
AssignReference(playerController, "_weaponManager", weaponManager, report);
|
||||
AssignReference(playerController, "_skillManager", skillManager, report);
|
||||
AssignReference(playerController, "_springSystem", springSystem, report);
|
||||
AssignReference(playerController, "_parrySystem", parrySystem, report);
|
||||
AssignReference(playerController, "_shield", shieldComponent, report);
|
||||
AssignReference(playerController, "_statsConfig", playerStatsConfigAsset, report);
|
||||
AssignReference(playerController, "_movementConfig", playerMovementConfigAsset, report);
|
||||
AssignReference(playerController, "_animConfig", playerAnimationConfigAsset, report);
|
||||
AssignReference(playerStats, "_config", playerStatsConfigAsset, report);
|
||||
AssignReference(playerMovement, "_config", playerMovementConfigAsset, report);
|
||||
AssignReference(playerMovement, "_groundCheck", groundCheckGo.transform, report);
|
||||
AssignLayerMask(playerMovement, "_groundLayer", "Ground", report);
|
||||
AssignReference(playerCombat, "_weaponManager", weaponManager, report);
|
||||
AssignReference(playerCombat, "_hitBoxGround", hitBoxGround, report);
|
||||
AssignReference(playerCombat, "_hitBoxUp", hitBoxUp, report);
|
||||
AssignReference(playerCombat, "_hitBoxDown", hitBoxDown, report);
|
||||
AssignReference(playerCombat, "_hitBoxAir", hitBoxAir, report);
|
||||
AssignAsset(playerController, "_onPlayerDied", report, false, "EVT_PlayerDied");
|
||||
AssignAsset(playerController, "_onHPChanged", report, false, "EVT_HPChanged");
|
||||
AssignReference(playerController, "_stats", playerStats);
|
||||
AssignReference(playerController, "_hurtBox", playerHurtBox);
|
||||
|
||||
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, "_onGeoChanged", report, false, "EVT_GeoChanged");
|
||||
AssignAsset(playerStats, "_onAbilityUnlocked", report, false, "EVT_AbilityUnlocked");
|
||||
AssignAsset(playerStats, "_onPlayerDied", report, false, "EVT_PlayerDied");
|
||||
|
||||
AssignAsset(playerHurtBox, "_onDamageDealt", report, false, "EVT_DamageDealt");
|
||||
AssignAsset(playerHurtBox, "_onHitConfirmed", report, false, "EVT_HitConfirmed");
|
||||
AssignAsset(enemyHurtBox, "_onDamageDealt", report, false, "EVT_DamageDealt");
|
||||
AssignAsset(enemyHurtBox, "_onHitConfirmed", report, false, "EVT_HitConfirmed");
|
||||
|
||||
AssignReference(enemyBase, "_stats", enemyStats);
|
||||
AssignAsset(enemyBase, "_onEnemyDied", report, false, "EVT_EnemyDied");
|
||||
AssignAsset(enemyBase, "_statsSO", report, false, "BasicEnemyStats", "EnemyStatsSO");
|
||||
|
||||
AddScaffoldNote(playerGo, "Player 已生成基础控制节点。PlayerMovement、Combat、Form、Weapon、Skill 等复杂依赖需按实际 prefab/配置继续补齐。", report);
|
||||
AddScaffoldNote(enemyGo, "Enemy 已生成基础节点。行为树、导航参数、动画配置和战斗组件仍需手工配置。", report);
|
||||
|
||||
MarkDirtyAndLog("TestRoom 场景脚手架", root, report);
|
||||
}
|
||||
|
||||
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 Component AddComponentIfTypeExists(GameObject go, string typeName, List<string> report)
|
||||
{
|
||||
System.Type type = FindType(typeName);
|
||||
if (type == null)
|
||||
{
|
||||
report.Add($"未找到类型 {typeName},已跳过对应组件挂载。");
|
||||
return null;
|
||||
}
|
||||
|
||||
Component component = go.GetComponent(type);
|
||||
if (component != null)
|
||||
return component;
|
||||
|
||||
return Undo.AddComponent(go, type);
|
||||
}
|
||||
|
||||
private static System.Type FindType(string typeName)
|
||||
{
|
||||
foreach (var assembly in System.AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
System.Type type = assembly.GetType(typeName);
|
||||
if (type != null)
|
||||
return type;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
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 AssignReferenceByCandidates(Object target, Object value, List<string> report, params string[] candidates)
|
||||
{
|
||||
if (target == null || candidates == null || candidates.Length == 0)
|
||||
return;
|
||||
|
||||
foreach (string candidate in candidates)
|
||||
{
|
||||
if (TryAssignSerializedReference(target, candidate, value))
|
||||
return;
|
||||
|
||||
if (TryAssignMemberReference(target, candidate, value))
|
||||
return;
|
||||
}
|
||||
|
||||
report?.Add($"{target.GetType().Name} 未找到可写引用字段: {string.Join(" / ", candidates)}");
|
||||
}
|
||||
|
||||
private static bool TryAssignSerializedReference(Object target, string propertyName, Object value)
|
||||
{
|
||||
SerializedObject serializedObject = new SerializedObject(target);
|
||||
SerializedProperty property = serializedObject.FindProperty(propertyName);
|
||||
if (property == null || property.propertyType != SerializedPropertyType.ObjectReference)
|
||||
return false;
|
||||
|
||||
property.objectReferenceValue = value;
|
||||
serializedObject.ApplyModifiedPropertiesWithoutUndo();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryAssignMemberReference(Object target, string memberName, Object value)
|
||||
{
|
||||
System.Type targetType = target.GetType();
|
||||
|
||||
PropertyInfo property = targetType.GetProperty(memberName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
|
||||
if (property != null && property.CanWrite && typeof(Object).IsAssignableFrom(property.PropertyType))
|
||||
{
|
||||
if (value == null || property.PropertyType.IsAssignableFrom(value.GetType()))
|
||||
{
|
||||
property.SetValue(target, value);
|
||||
EditorUtility.SetDirty(target);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
FieldInfo field = targetType.GetField(memberName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
|
||||
if (field != null && typeof(Object).IsAssignableFrom(field.FieldType))
|
||||
{
|
||||
if (value == null || field.FieldType.IsAssignableFrom(value.GetType()))
|
||||
{
|
||||
field.SetValue(target, value);
|
||||
EditorUtility.SetDirty(target);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
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/Data/Input";
|
||||
if (!AssetDatabase.IsValidFolder("Assets/Data"))
|
||||
AssetDatabase.CreateFolder("Assets", "Data");
|
||||
if (!AssetDatabase.IsValidFolder(inputFolder))
|
||||
AssetDatabase.CreateFolder("Assets/Data", "Input");
|
||||
|
||||
const string assetPath = "Assets/Data/Input/InputReader.asset";
|
||||
InputReaderSO created = ScriptableObject.CreateInstance<InputReaderSO>();
|
||||
AssetDatabase.CreateAsset(created, assetPath);
|
||||
AssetDatabase.SaveAssets();
|
||||
AssetDatabase.Refresh();
|
||||
|
||||
report?.Add("未找到 InputReaderSO,已自动创建 Assets/Data/Input/InputReader.asset。");
|
||||
return created;
|
||||
}
|
||||
|
||||
private static Object EnsurePlayerStatsConfigAsset(List<string> report)
|
||||
{
|
||||
Object existing = FindFirstAssetByType<PlayerStatsSO>("PlayerStats", "PLY_PlayerStats", "PlayerStatsSO");
|
||||
if (existing != null)
|
||||
return existing;
|
||||
|
||||
const string folder = "Assets/Data/Player";
|
||||
EnsureFolder(folder);
|
||||
const string assetPath = "Assets/Data/Player/PLY_PlayerStats.asset";
|
||||
PlayerStatsSO created = ScriptableObject.CreateInstance<PlayerStatsSO>();
|
||||
AssetDatabase.CreateAsset(created, assetPath);
|
||||
AssetDatabase.SaveAssets();
|
||||
AssetDatabase.Refresh();
|
||||
report?.Add("未找到 PlayerStatsSO,已自动创建 Assets/Data/Player/PLY_PlayerStats.asset。");
|
||||
return created;
|
||||
}
|
||||
|
||||
private static Object EnsurePlayerMovementConfigAsset(List<string> report)
|
||||
{
|
||||
Object existing = FindFirstAssetByType<PlayerMovementConfigSO>("PlayerMovementConfig", "PLY_PlayerMovementConfig", "PlayerMovementConfigSO");
|
||||
if (existing != null)
|
||||
return existing;
|
||||
|
||||
const string folder = "Assets/Data/Player";
|
||||
EnsureFolder(folder);
|
||||
const string assetPath = "Assets/Data/Player/PLY_PlayerMovementConfig.asset";
|
||||
PlayerMovementConfigSO created = ScriptableObject.CreateInstance<PlayerMovementConfigSO>();
|
||||
AssetDatabase.CreateAsset(created, assetPath);
|
||||
AssetDatabase.SaveAssets();
|
||||
AssetDatabase.Refresh();
|
||||
report?.Add("未找到 PlayerMovementConfigSO,已自动创建 Assets/Data/Player/PLY_PlayerMovementConfig.asset。");
|
||||
return created;
|
||||
}
|
||||
|
||||
private static Object EnsurePlayerAnimationConfigAsset(List<string> report)
|
||||
{
|
||||
Object existing = FindFirstAssetByType<PlayerAnimationConfigSO>("PlayerAnimationConfig", "PLY_PlayerAnimationConfig", "PlayerAnimationConfigSO");
|
||||
if (existing != null)
|
||||
return existing;
|
||||
|
||||
const string folder = "Assets/Data/Player";
|
||||
EnsureFolder(folder);
|
||||
const string assetPath = "Assets/Data/Player/PLY_PlayerAnimationConfig.asset";
|
||||
PlayerAnimationConfigSO created = ScriptableObject.CreateInstance<PlayerAnimationConfigSO>();
|
||||
AssetDatabase.CreateAsset(created, assetPath);
|
||||
AssetDatabase.SaveAssets();
|
||||
AssetDatabase.Refresh();
|
||||
report?.Add("未找到 PlayerAnimationConfigSO,已自动创建 Assets/Data/Player/PLY_PlayerAnimationConfig.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 RemoveMissingScripts(GameObject go, bool recursive, List<string> report)
|
||||
{
|
||||
if (go == null)
|
||||
return;
|
||||
|
||||
int removed = 0;
|
||||
if (!recursive)
|
||||
{
|
||||
removed = RemoveMissingScriptsOnSingleObject(go);
|
||||
}
|
||||
else
|
||||
{
|
||||
var stack = new Stack<Transform>();
|
||||
stack.Push(go.transform);
|
||||
while (stack.Count > 0)
|
||||
{
|
||||
Transform current = stack.Pop();
|
||||
removed += RemoveMissingScriptsOnSingleObject(current.gameObject);
|
||||
foreach (Transform child in current)
|
||||
stack.Push(child);
|
||||
}
|
||||
}
|
||||
|
||||
if (removed > 0)
|
||||
report?.Add($"{go.name}: 已清理 Missing Behaviour x{removed}。");
|
||||
}
|
||||
|
||||
private static int RemoveMissingScriptsOnSingleObject(GameObject go)
|
||||
{
|
||||
int before = GameObjectUtility.GetMonoBehavioursWithMissingScriptCount(go);
|
||||
if (before <= 0)
|
||||
return 0;
|
||||
|
||||
Undo.RegisterCompleteObjectUndo(go, "Remove Missing Scripts");
|
||||
GameObjectUtility.RemoveMonoBehavioursWithMissingScript(go);
|
||||
int after = GameObjectUtility.GetMonoBehavioursWithMissingScriptCount(go);
|
||||
return before - after;
|
||||
}
|
||||
|
||||
private static void SetLayer(GameObject go, string layerName, List<string> report)
|
||||
{
|
||||
int layer = LayerMask.NameToLayer(layerName);
|
||||
if (layer < 0)
|
||||
{
|
||||
report.Add($"Layer '{layerName}' 不存在,{go.name} 保持默认 Layer。");
|
||||
return;
|
||||
}
|
||||
|
||||
go.layer = layer;
|
||||
}
|
||||
|
||||
private static void AssignLayerMask(Object target, string propertyName, string layerName, List<string> report)
|
||||
{
|
||||
int layer = LayerMask.NameToLayer(layerName);
|
||||
if (layer < 0)
|
||||
{
|
||||
report?.Add($"Layer '{layerName}' 不存在,{target.GetType().Name}.{propertyName} 无法写入。");
|
||||
return;
|
||||
}
|
||||
|
||||
SerializedObject serializedObject = new SerializedObject(target);
|
||||
SerializedProperty property = serializedObject.FindProperty(propertyName);
|
||||
if (property == null)
|
||||
{
|
||||
report?.Add($"{target.GetType().Name}.{propertyName} 字段不存在,未写入 LayerMask。");
|
||||
return;
|
||||
}
|
||||
|
||||
property.intValue = 1 << layer;
|
||||
serializedObject.ApplyModifiedPropertiesWithoutUndo();
|
||||
}
|
||||
|
||||
private static void EnsureVisualSprite(Transform parent, string childName, Color color, Vector2 size, int sortingOrder)
|
||||
{
|
||||
Transform visualTransform = GetOrCreateChild(parent, childName);
|
||||
SpriteRenderer renderer = GetOrAddComponent<SpriteRenderer>(visualTransform.gameObject);
|
||||
renderer.sprite = GetBuiltinDefaultSprite();
|
||||
renderer.color = color;
|
||||
renderer.drawMode = SpriteDrawMode.Sliced;
|
||||
renderer.size = size;
|
||||
renderer.sortingOrder = sortingOrder;
|
||||
visualTransform.localPosition = Vector3.zero;
|
||||
visualTransform.localRotation = Quaternion.identity;
|
||||
visualTransform.localScale = Vector3.one;
|
||||
}
|
||||
|
||||
private static Sprite GetBuiltinDefaultSprite()
|
||||
=> AssetDatabase.GetBuiltinExtraResource<Sprite>("UI/Skin/UISprite.psd");
|
||||
|
||||
private static void AddScaffoldNote(GameObject go, string message)
|
||||
{
|
||||
AddScaffoldNote(go, message, null);
|
||||
}
|
||||
|
||||
private static void DisableRenderCamerasUnderRoot(Transform root, List<string> report)
|
||||
{
|
||||
if (root == null)
|
||||
return;
|
||||
|
||||
UnityEngine.Camera[] cameras = root.GetComponentsInChildren<UnityEngine.Camera>(true);
|
||||
foreach (UnityEngine.Camera camera in cameras)
|
||||
{
|
||||
if (camera == null)
|
||||
continue;
|
||||
|
||||
if (camera.enabled)
|
||||
{
|
||||
camera.enabled = false;
|
||||
report?.Add($"已禁用 TestRoom 内渲染相机: {camera.gameObject.name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
11
Assets/Scripts/Editor/SceneScaffoldTools.cs.meta
Normal file
11
Assets/Scripts/Editor/SceneScaffoldTools.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: eb2b7f90961ee3344a5f39c68931a26d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
142
Assets/Scripts/Editor/ScriptExecutionOrderTools.cs
Normal file
142
Assets/Scripts/Editor/ScriptExecutionOrderTools.cs
Normal file
@@ -0,0 +1,142 @@
|
||||
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("SaveManager", -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);
|
||||
}
|
||||
|
||||
private static bool TryFindMonoScript(string className, out MonoScript script, out string issue)
|
||||
{
|
||||
script = null;
|
||||
issue = null;
|
||||
|
||||
string[] guids = AssetDatabase.FindAssets($"{className} 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 && type.Name == className)
|
||||
matches.Add(candidate);
|
||||
}
|
||||
|
||||
if (matches.Count == 0)
|
||||
{
|
||||
issue = $"未找到脚本: {className}";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (matches.Count > 1)
|
||||
{
|
||||
issue = $"存在多个同名脚本: {className}(请消歧后重试)";
|
||||
return false;
|
||||
}
|
||||
|
||||
script = matches[0];
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Editor/ScriptExecutionOrderTools.cs.meta
Normal file
11
Assets/Scripts/Editor/ScriptExecutionOrderTools.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1d2bcc35606ec6a47b82e00462955dbe
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Assets/Scripts/Editor/Validation.meta
Normal file
8
Assets/Scripts/Editor/Validation.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0ed4f9a0a8f4ccb4595bc120c7203eb1
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
78
Assets/Scripts/Editor/Validation/SOValidationRunner.cs
Normal file
78
Assets/Scripts/Editor/Validation/SOValidationRunner.cs
Normal file
@@ -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 报告验证结果。同时作为构建前处理器,发现错误时中止构建。
|
||||
///
|
||||
/// 菜单: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("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($"\u274c {result.Message} ({path})");
|
||||
else
|
||||
warnings.Add($"\u26a0\ufe0f {result.Message} ({path})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (errors, warnings);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Editor/Validation/SOValidationRunner.cs.meta
Normal file
11
Assets/Scripts/Editor/Validation/SOValidationRunner.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: df49131d7ec874241ad90c09c3c071a0
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Assets/Scripts/Editor/World.meta
Normal file
8
Assets/Scripts/Editor/World.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f755c6c204ed63b4cab86b617889f465
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
52
Assets/Scripts/Editor/World/DestructibleTileEditor.cs
Normal file
52
Assets/Scripts/Editor/World/DestructibleTileEditor.cs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Editor/World/DestructibleTileEditor.cs.meta
Normal file
11
Assets/Scripts/Editor/World/DestructibleTileEditor.cs.meta
Normal file
@@ -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