多轮审查和修复

This commit is contained in:
2026-05-12 15:34:08 +08:00
parent f55d2a57c3
commit ebbbb7332e
805 changed files with 838724 additions and 1905 deletions

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 07ed02361aa3739468cbd36457aecda6
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View 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();
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 95b58f8c5a3285c4abbf929f7bf36946
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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 分组。");
}
// ── 结果结构 ──────────────────────────────────────────────────────────

View 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;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 884c3c28d25877643afa90b72ba2a650
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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
}

View 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 &lt; 0.1 时阶段条变红警告
/// - 点击阶段条高亮对应 AttackPatternSOEditorGUIUtility.PingObject
/// </summary>
public class BossSkillSequenceWindow : EditorWindow
{
// ── State ──────────────────────────────────────────────────────────
private BossSkillSO _loadedSkill;
private SkillSequenceSO _loadedSequence;
private Vector2 _scrollPos;
// ── Layout ─────────────────────────────────────────────────────────
private const float HeaderH = 24f;
private const float RowH = 28f;
private const float LabelW = 180f;
private const float 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();
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d47145d394333184eb3ff822e3c4aa4d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: a884190f06d571d47b05f7693fab90e2
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View 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

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8650ccc7960fe304a95be1c629ef7b1e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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))

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 53f701b15574fcc49bc11d1e8798ba52
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View 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.effectsList&lt;ICharmEffect&gt;)提供友好的 Inspector 体验(架构 09_ProgressionModule §4.1)。
/// - 下拉菜单选类型(显示中文名而非 C# 全称)
/// - 每条效果展开显示字段 + GetEffectDescription() 预览文字
/// - 支持单条删除
/// </summary>
[CustomEditor(typeof(CharmSO))]
public class CharmSOEditor : UnityEditor.Editor
{
// 已注册的所有 ICharmEffect 实现类型(反射收集)
private static readonly Type[] _effectTypes = CollectEffectTypes();
// 策划友好名称映射
private static readonly Dictionary<Type, string> _typeLabels = new()
{
{ typeof(StatModifierEffect), "属性加成" },
{ typeof(AttackSpeedEffect), "攻击速度" },
{ typeof(OnHitEffect), "命中触发" },
{ typeof(SoulSpellEffect), "灵魂法术" },
{ typeof(SkillNumericModifierEffect), "技能数值修改" },
{ typeof(SkillSlotOverrideEffect), "技能插槽替换" },
{ typeof(WeaponOverrideEffect), "武器替换" },
};
private SerializedProperty _effectsProp;
private void OnEnable()
=> _effectsProp = serializedObject.FindProperty("effects");
public override void OnInspectorGUI()
{
serializedObject.Update();
DrawPropertiesExcluding(serializedObject, "effects");
EditorGUILayout.Space(8);
EditorGUILayout.LabelField("Effects", EditorStyles.boldLabel);
if (_effectsProp != null)
{
for (int i = 0; i < _effectsProp.arraySize; i++)
{
var elemProp = _effectsProp.GetArrayElementAtIndex(i);
var effect = elemProp.managedReferenceValue as ICharmEffect;
string label = effect != null && _typeLabels.TryGetValue(effect.GetType(), out var n)
? n : (effect?.GetType().Name ?? "null");
EditorGUILayout.BeginVertical(EditorStyles.helpBox);
EditorGUILayout.BeginHorizontal();
EditorGUILayout.LabelField(label, EditorStyles.boldLabel);
if (GUILayout.Button("✕", GUILayout.Width(24)))
{
_effectsProp.DeleteArrayElementAtIndex(i);
serializedObject.ApplyModifiedProperties();
break;
}
EditorGUILayout.EndHorizontal();
EditorGUILayout.PropertyField(elemProp, GUIContent.none, true);
if (effect != null)
EditorGUILayout.LabelField(effect.GetEffectDescription(),
EditorStyles.miniLabel);
EditorGUILayout.EndVertical();
EditorGUILayout.Space(2);
}
}
// 添加效果按钮(下拉菜单)
if (GUILayout.Button(" 添加效果"))
{
var menu = new GenericMenu();
foreach (var t in _effectTypes)
{
var captured = t;
string menuLabel = _typeLabels.GetValueOrDefault(t, t.Name);
menu.AddItem(new GUIContent(menuLabel), false, () =>
{
if (_effectsProp == null) return;
_effectsProp.arraySize++;
_effectsProp
.GetArrayElementAtIndex(_effectsProp.arraySize - 1)
.managedReferenceValue = Activator.CreateInstance(captured);
serializedObject.ApplyModifiedProperties();
});
}
menu.ShowAsContext();
}
serializedObject.ApplyModifiedProperties();
}
private static Type[] CollectEffectTypes()
{
var baseType = typeof(ICharmEffect);
return AppDomain.CurrentDomain.GetAssemblies()
.SelectMany(a =>
{
try { return a.GetTypes(); }
catch { return Array.Empty<Type>(); }
})
.Where(t => t.IsClass && !t.IsAbstract && baseType.IsAssignableFrom(t))
.ToArray();
}
}
}
#endif

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 38fb3e35ebefbc8418ba2ea0b5781f92
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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();
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 861ce74d8a5c0ce4f957719423a0be7b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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();
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e8cb0e5db63d15d418e73c29e6ff6f1f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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&lt;T&gt; 子类提供 Inspector 内的订阅者数量显示和说明标签。
/// 因泛型限制Raise 按钮由具体类型的派生 Editor 提供(见下方注册器)。
/// </summary>
[CustomEditor(typeof(ScriptableObject), true)]
public class GenericEventChannelSOEditor : UnityEditor.Editor
{
// 仅对 BaseEventChannelSO<T> 子类生效
private bool _isEventChannel;
private void OnEnable()
{
var t = target.GetType();
while (t != null && t != typeof(object))
{
if (t.IsGenericType && t.GetGenericTypeDefinition() == typeof(BaseEventChannelSO<>))
{
_isEventChannel = true;
break;
}
t = t.BaseType;
}
}
public override void OnInspectorGUI()
{
if (!_isEventChannel)
{
DrawDefaultInspector();
return;
}
DrawDefaultInspector();
EditorGUILayout.Space(6);
EditorGUI.BeginDisabledGroup(true);
if (Application.isPlaying)
{
// 反射获取订阅者数量
var field = target.GetType().GetField("OnEventRaised",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
if (field != null)
{
var del = field.GetValue(target) as Delegate;
int count = del?.GetInvocationList().Length ?? 0;
EditorGUILayout.LabelField("当前订阅者数量", count.ToString());
}
}
EditorGUI.EndDisabledGroup();
if (!Application.isPlaying)
{
EditorGUILayout.HelpBox("进入 Play Mode 可查看实时订阅者数量。", MessageType.Info);
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 39fd6fe0ebb5ceb4db85f82919217956
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c992100309cc05a40bb06a3e23076c5b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 08a52815a08c8c3428ccb6a530171ddd
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View 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

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 168d8a104fffcaf4db9849cd8b2140f9
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,67 @@
using UnityEditor;
using UnityEngine;
using PathBerserker2d;
namespace BaseGames.Editor
{
/// <summary>
/// 快捷键BaseGames → Tools → Bake All NavSurfacesCtrl+Shift+B
/// 烘焙当前场景中所有 PathBerserker2d NavSurface 的导航网格。
/// 等效于在每个 NavSurface Inspector 中逐一点击 "Bake"。
/// </summary>
public static class NavSurfaceBakeShortcut
{
[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;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 291f3b33fb176b8469ebaaa8afa317ba
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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 SourceAudioMixer 仍需手工指定。");
}
private static GameObject GetOrCreateRoot(string name)
{
Scene scene = SceneManager.GetActiveScene();
foreach (GameObject rootObject in scene.GetRootGameObjects())
{
if (rootObject.name == name)
return rootObject;
}
GameObject root = new GameObject(name);
Undo.RegisterCreatedObjectUndo(root, $"Create {name}");
return root;
}
private static Transform GetOrCreateChild(Transform parent, string name)
{
Transform child = parent.Find(name);
if (child != null)
return child;
GameObject go = new GameObject(name);
Undo.RegisterCreatedObjectUndo(go, $"Create {name}");
go.transform.SetParent(parent, false);
return go.transform;
}
private static T GetOrAddComponent<T>(GameObject go) where T : Component
{
T component = go.GetComponent<T>();
if (component != null)
return component;
return Undo.AddComponent<T>(go);
}
private static 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);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: eb2b7f90961ee3344a5f39c68931a26d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1d2bcc35606ec6a47b82e00462955dbe
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 0ed4f9a0a8f4ccb4595bc120c7203eb1
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View 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);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: df49131d7ec874241ad90c09c3c071a0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: f755c6c204ed63b4cab86b617889f465
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View 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(
"GizmoScene 视图中橙红色边框 = 可破坏物。\n" +
"子类 DirectionalDestructible 会额外显示攻击方向箭头。",
MessageType.None);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9ccbc749bcda1104ba82ec725c43e7cf
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: