feat: Add WorldStateFlagAttribute and custom property drawer for enhanced dialogue management
- Implemented WorldStateFlagAttribute to mark string fields as world state flags. - Created NarrativeNPCEditor for custom inspector to visualize dialogue version activation states. - Developed WorldStateFlagDrawer to provide dropdown menu for known flags in the inspector. - Introduced ActorModule for managing DialogueActorSO assets, including viewing, creating, and deleting actors. - Added DialogueModule for managing DialogueSequenceSO assets with detailed previews and action bars. - Established QuestModule for managing QuestSO assets, including objectives and branches. - Implemented QuestManagerPostprocessor to automatically refresh QuestManager's quest list on asset changes.
This commit is contained in:
209
Assets/_Game/Scripts/Editor/Dialogue/NarrativeNPCEditor.cs
Normal file
209
Assets/_Game/Scripts/Editor/Dialogue/NarrativeNPCEditor.cs
Normal file
@@ -0,0 +1,209 @@
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using BaseGames.Dialogue;
|
||||
using BaseGames.World;
|
||||
|
||||
namespace BaseGames.Editor.Dialogue
|
||||
{
|
||||
/// <summary>
|
||||
/// NarrativeNPC 自定义 Inspector。
|
||||
/// 在默认字段之下显示"对话版本激活状态"面板,
|
||||
/// 以色彩提示每个 DialogueVersion 在当前 WorldStateRegistry 中是否激活,
|
||||
/// 方便策划人员在编辑模式下即时核查对话版本切换逻辑。
|
||||
/// </summary>
|
||||
[CustomEditor(typeof(NarrativeNPC))]
|
||||
public class NarrativeNPCEditor : UnityEditor.Editor
|
||||
{
|
||||
private static bool s_foldout = true;
|
||||
|
||||
private static readonly Color ColorActive = new(0.25f, 0.75f, 0.40f, 1f);
|
||||
private static readonly Color ColorInactive = new(0.55f, 0.55f, 0.55f, 1f);
|
||||
private static readonly Color ColorBlocked = new(0.85f, 0.40f, 0.35f, 1f);
|
||||
|
||||
public override void OnInspectorGUI()
|
||||
{
|
||||
DrawDefaultInspector();
|
||||
|
||||
EditorGUILayout.Space(6);
|
||||
|
||||
if (Application.isPlaying)
|
||||
{
|
||||
EditorGUILayout.HelpBox(
|
||||
"PlayMode 中显示的是 WorldStateRegistry SO 的初始序列化值,非运行时动态 flags。\n" +
|
||||
"如需查看实际激活版本,请在运行时检查 NPC 日志或在 SO 上断点调试。",
|
||||
MessageType.Info);
|
||||
}
|
||||
|
||||
s_foldout = EditorGUILayout.BeginFoldoutHeaderGroup(s_foldout, "对话版本激活状态");
|
||||
|
||||
if (s_foldout)
|
||||
{
|
||||
EditorGUI.indentLevel++;
|
||||
DrawVersionStatus();
|
||||
EditorGUI.indentLevel--;
|
||||
}
|
||||
|
||||
EditorGUILayout.EndFoldoutHeaderGroup();
|
||||
}
|
||||
|
||||
// ── 版本状态绘制 ──────────────────────────────────────────────────────
|
||||
|
||||
private void DrawVersionStatus()
|
||||
{
|
||||
// 获取 WorldStateRegistry(运行时或编辑器 SO 引用均可)
|
||||
var worldStateProp = serializedObject.FindProperty("_worldState");
|
||||
var registry = worldStateProp?.objectReferenceValue as WorldStateRegistry;
|
||||
|
||||
var versionsProp = serializedObject.FindProperty("_dialogueVersions");
|
||||
if (versionsProp == null || !versionsProp.isArray || versionsProp.arraySize == 0)
|
||||
{
|
||||
EditorGUILayout.HelpBox("未设置任何对话版本。", MessageType.None);
|
||||
return;
|
||||
}
|
||||
|
||||
if (registry == null)
|
||||
{
|
||||
EditorGUILayout.HelpBox("未指定 WorldStateRegistry,无法预览激活状态。\n请在 Inspector 中设置 World State 字段。", MessageType.Warning);
|
||||
DrawVersionLabelsOnly(versionsProp);
|
||||
return;
|
||||
}
|
||||
|
||||
bool higherActive = false;
|
||||
|
||||
for (int i = 0; i < versionsProp.arraySize; i++)
|
||||
{
|
||||
var element = versionsProp.GetArrayElementAtIndex(i);
|
||||
|
||||
string label = GetStringProp(element, "versionLabel");
|
||||
string displayName = string.IsNullOrEmpty(label) ? $"版本 {i}" : label;
|
||||
string dialogueName = GetObjectPropName(element, "dialogue");
|
||||
|
||||
// 计算激活状态(直接迭代 SerializedProperty,不分配中间 string[])
|
||||
bool missingRequired = false;
|
||||
string missingFlag = null;
|
||||
var reqProp = element.FindPropertyRelative("requiredFlags");
|
||||
if (reqProp != null && reqProp.isArray)
|
||||
{
|
||||
for (int j = 0; j < reqProp.arraySize; j++)
|
||||
{
|
||||
var f = reqProp.GetArrayElementAtIndex(j).stringValue;
|
||||
if (!string.IsNullOrEmpty(f) && !registry.HasFlag(f))
|
||||
{
|
||||
missingRequired = true;
|
||||
missingFlag = f;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool hasBlocker = false;
|
||||
string blockerKey = null;
|
||||
if (!missingRequired)
|
||||
{
|
||||
var blkProp = element.FindPropertyRelative("blockedByFlags");
|
||||
if (blkProp != null && blkProp.isArray)
|
||||
{
|
||||
for (int j = 0; j < blkProp.arraySize; j++)
|
||||
{
|
||||
var f = blkProp.GetArrayElementAtIndex(j).stringValue;
|
||||
if (!string.IsNullOrEmpty(f) && registry.HasFlag(f))
|
||||
{
|
||||
hasBlocker = true;
|
||||
blockerKey = f;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool conditionsMet = !missingRequired && !hasBlocker;
|
||||
bool isActive = conditionsMet && !higherActive;
|
||||
|
||||
Color statusColor;
|
||||
string statusText;
|
||||
|
||||
if (isActive)
|
||||
{
|
||||
statusColor = ColorActive;
|
||||
statusText = "✓ 激活中";
|
||||
}
|
||||
else if (conditionsMet && higherActive)
|
||||
{
|
||||
statusColor = ColorInactive;
|
||||
statusText = "⏩ 被更高优先级覆盖";
|
||||
}
|
||||
else if (hasBlocker)
|
||||
{
|
||||
statusColor = ColorBlocked;
|
||||
statusText = $"✗ blockedByFlag 阻断 [{blockerKey}]";
|
||||
}
|
||||
else
|
||||
{
|
||||
statusColor = ColorInactive;
|
||||
statusText = $"✗ 缺少 requiredFlag [{missingFlag}]";
|
||||
}
|
||||
|
||||
DrawVersionRow(i, displayName, dialogueName, statusText, statusColor);
|
||||
|
||||
if (conditionsMet) higherActive = true;
|
||||
}
|
||||
|
||||
// 兜底说明
|
||||
EditorGUILayout.Space(2);
|
||||
var fallbackProp = serializedObject.FindProperty("_fallbackDialogue");
|
||||
if (fallbackProp?.objectReferenceValue != null)
|
||||
{
|
||||
using (new EditorGUI.DisabledScope(true))
|
||||
EditorGUILayout.LabelField("兜底台词", fallbackProp.objectReferenceValue.name);
|
||||
}
|
||||
}
|
||||
|
||||
private static void DrawVersionLabelsOnly(SerializedProperty versionsProp)
|
||||
{
|
||||
for (int i = 0; i < versionsProp.arraySize; i++)
|
||||
{
|
||||
var element = versionsProp.GetArrayElementAtIndex(i);
|
||||
string label = GetStringProp(element, "versionLabel");
|
||||
string display = string.IsNullOrEmpty(label) ? $"版本 {i}" : label;
|
||||
EditorGUILayout.LabelField($" {i}. {display}", EditorStyles.miniLabel);
|
||||
}
|
||||
}
|
||||
|
||||
private static void DrawVersionRow(int index, string versionLabel, string dialogueName, string statusText, Color statusColor)
|
||||
{
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
// 序号+名称
|
||||
EditorGUILayout.LabelField($"{index}. {versionLabel}", GUILayout.Width(160));
|
||||
|
||||
// 对话 SO 名称
|
||||
if (!string.IsNullOrEmpty(dialogueName))
|
||||
{
|
||||
using (new EditorGUI.DisabledScope(true))
|
||||
EditorGUILayout.LabelField(dialogueName, EditorStyles.miniLabel, GUILayout.Width(140));
|
||||
}
|
||||
|
||||
// 状态徽章
|
||||
var prevColor = GUI.contentColor;
|
||||
GUI.contentColor = statusColor;
|
||||
EditorGUILayout.LabelField(statusText, EditorStyles.boldLabel);
|
||||
GUI.contentColor = prevColor;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 辅助:从 SerializedProperty 读取字段 ─────────────────────────────
|
||||
|
||||
private static string GetStringProp(SerializedProperty parent, string name)
|
||||
{
|
||||
var p = parent.FindPropertyRelative(name);
|
||||
return p != null ? p.stringValue : string.Empty;
|
||||
}
|
||||
|
||||
private static string GetObjectPropName(SerializedProperty parent, string name)
|
||||
{
|
||||
var p = parent.FindPropertyRelative(name);
|
||||
if (p == null || p.objectReferenceValue == null) return string.Empty;
|
||||
return p.objectReferenceValue.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
195
Assets/_Game/Scripts/Editor/Dialogue/WorldStateFlagDrawer.cs
Normal file
195
Assets/_Game/Scripts/Editor/Dialogue/WorldStateFlagDrawer.cs
Normal file
@@ -0,0 +1,195 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using BaseGames.Dialogue;
|
||||
using BaseGames.EventChain;
|
||||
using BaseGames.Editor;
|
||||
|
||||
namespace BaseGames.Editor.Dialogue
|
||||
{
|
||||
/// <summary>
|
||||
/// 在 Inspector 中为 [WorldStateFlag] 标记的 string 字段提供已知标志下拉菜单。
|
||||
/// 扫描项目内所有 SetFlagAction / FlagSetCondition / ConditionalVariant.requiredFlags 收集已使用的标志名,
|
||||
/// 供策划选取;同时保留直接输入新标志的能力。
|
||||
/// </summary>
|
||||
[CustomPropertyDrawer(typeof(WorldStateFlagAttribute))]
|
||||
internal sealed class WorldStateFlagDrawer : PropertyDrawer
|
||||
{
|
||||
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
|
||||
{
|
||||
if (property.propertyType != SerializedPropertyType.String)
|
||||
{
|
||||
EditorGUI.PropertyField(position, property, label);
|
||||
return;
|
||||
}
|
||||
|
||||
EditorGUI.BeginProperty(position, label, property);
|
||||
|
||||
// 文本输入区 + 下拉箭头按钮
|
||||
const float btnW = 22f;
|
||||
var textRect = new Rect(position.x, position.y, position.width - btnW - 2f, position.height);
|
||||
var btnRect = new Rect(position.xMax - btnW, position.y, btnW, position.height);
|
||||
|
||||
property.stringValue = EditorGUI.TextField(textRect, label, property.stringValue);
|
||||
|
||||
if (GUI.Button(btnRect, EditorGUIUtility.IconContent("d_icon dropdown"), EditorStyles.iconButton))
|
||||
{
|
||||
var flags = WorldStateFlagCollector.CollectKnownFlags();
|
||||
var menu = new GenericMenu();
|
||||
var current = property.stringValue;
|
||||
var propPath = property.propertyPath;
|
||||
var so = property.serializedObject;
|
||||
|
||||
foreach (var flag in flags)
|
||||
{
|
||||
var captured = flag;
|
||||
menu.AddItem(
|
||||
new GUIContent(captured),
|
||||
current == captured,
|
||||
() =>
|
||||
{
|
||||
var prop = so.FindProperty(propPath);
|
||||
if (prop != null)
|
||||
{
|
||||
prop.stringValue = captured;
|
||||
so.ApplyModifiedProperties();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (flags.Count == 0)
|
||||
menu.AddDisabledItem(new GUIContent("(项目中尚未定义任何标志)"));
|
||||
|
||||
menu.AddSeparator(string.Empty);
|
||||
menu.AddItem(new GUIContent("刷新列表"), false, WorldStateFlagCollector.Invalidate);
|
||||
|
||||
menu.ShowAsContext();
|
||||
}
|
||||
|
||||
EditorGUI.EndProperty();
|
||||
}
|
||||
}
|
||||
|
||||
// ── 标志扫描器 ────────────────────────────────────────────────────────────
|
||||
// SO 来源(SetFlagAction / FlagSetCondition / DialogueSequenceSO):5 秒 TTL 自动刷新。
|
||||
// NarrativeNPC prefab 来源:仅在用户点击"刷新列表"时执行(开销高,手动触发)。
|
||||
|
||||
internal static class WorldStateFlagCollector
|
||||
{
|
||||
private static double _lastSoCollect = -10.0;
|
||||
private static double _lastPrefabCollect = double.MinValue; // 默认不自动触发
|
||||
private static List<string> _cache = new();
|
||||
private static SortedSet<string> _prefabFlags = new(System.StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// 强制下次 CollectKnownFlags() 时重新扫描 SO 和 prefab(含 NarrativeNPC)。
|
||||
/// 由下拉菜单的"刷新列表"触发。
|
||||
/// </summary>
|
||||
public static void Invalidate()
|
||||
{
|
||||
_lastSoCollect = -10.0;
|
||||
_lastPrefabCollect = -10.0; // 重置 prefab 缓存使其在下次调用时立即扫描
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 收集项目内所有已使用的世界状态标志名。
|
||||
/// - SO 来源(SetFlagAction / FlagSetCondition / ConditionalVariant):5 秒缓存。
|
||||
/// - NarrativeNPC prefab 来源:仅在调用 Invalidate() 后才重新扫描。
|
||||
/// </summary>
|
||||
public static List<string> CollectKnownFlags()
|
||||
{
|
||||
double now = EditorApplication.timeSinceStartup;
|
||||
|
||||
bool soStale = now - _lastSoCollect >= 5.0;
|
||||
bool prefabStale = now - _lastPrefabCollect >= 5.0 && _lastPrefabCollect < 0; // 仅在 Invalidate 后为负
|
||||
|
||||
if (!soStale && !prefabStale)
|
||||
return _cache;
|
||||
|
||||
var found = new SortedSet<string>(System.StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (soStale)
|
||||
{
|
||||
CollectSoFlags(found);
|
||||
_lastSoCollect = now;
|
||||
}
|
||||
else
|
||||
{
|
||||
// SO 缓存仍有效,直接把已有 SO 标志加入合并集
|
||||
foreach (var f in _cache)
|
||||
{
|
||||
// _cache 里混有 prefab 标志,先全加;下面再合并 prefab
|
||||
found.Add(f);
|
||||
}
|
||||
}
|
||||
|
||||
if (prefabStale)
|
||||
{
|
||||
_prefabFlags.Clear();
|
||||
CollectNarrativeNpcFlags(_prefabFlags);
|
||||
_lastPrefabCollect = now;
|
||||
}
|
||||
|
||||
foreach (var f in _prefabFlags)
|
||||
found.Add(f);
|
||||
|
||||
_cache = new List<string>(found);
|
||||
return _cache;
|
||||
}
|
||||
|
||||
private static void CollectSoFlags(SortedSet<string> found)
|
||||
{
|
||||
foreach (var a in AssetOperations.FindAll<SetFlagAction>())
|
||||
if (!string.IsNullOrWhiteSpace(a.flagId)) found.Add(a.flagId.Trim());
|
||||
|
||||
foreach (var c in AssetOperations.FindAll<FlagSetCondition>())
|
||||
if (!string.IsNullOrWhiteSpace(c.flagId)) found.Add(c.flagId.Trim());
|
||||
|
||||
foreach (var seq in AssetOperations.FindAll<DialogueSequenceSO>())
|
||||
{
|
||||
if (seq.variants == null) continue;
|
||||
foreach (var v in seq.variants)
|
||||
{
|
||||
if (v.requiredFlags == null) continue;
|
||||
foreach (var f in v.requiredFlags)
|
||||
if (!string.IsNullOrWhiteSpace(f)) found.Add(f.Trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void CollectNarrativeNpcFlags(SortedSet<string> found)
|
||||
{
|
||||
var prefabGuids = AssetDatabase.FindAssets("t:Prefab");
|
||||
foreach (var guid in prefabGuids)
|
||||
{
|
||||
var path = AssetDatabase.GUIDToAssetPath(guid);
|
||||
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(path);
|
||||
if (prefab == null) continue;
|
||||
|
||||
var npc = prefab.GetComponent<NarrativeNPC>();
|
||||
if (npc == null) continue;
|
||||
|
||||
var so = new SerializedObject(npc);
|
||||
var versProp = so.FindProperty("_dialogueVersions");
|
||||
if (versProp == null || !versProp.isArray) continue;
|
||||
|
||||
for (int i = 0; i < versProp.arraySize; i++)
|
||||
{
|
||||
var elem = versProp.GetArrayElementAtIndex(i);
|
||||
ExtractStringArrayProp(elem.FindPropertyRelative("requiredFlags"), found);
|
||||
ExtractStringArrayProp(elem.FindPropertyRelative("blockedByFlags"), found);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ExtractStringArrayProp(SerializedProperty arr, SortedSet<string> found)
|
||||
{
|
||||
if (arr == null || !arr.isArray) return;
|
||||
for (int i = 0; i < arr.arraySize; i++)
|
||||
{
|
||||
var val = arr.GetArrayElementAtIndex(i).stringValue;
|
||||
if (!string.IsNullOrWhiteSpace(val)) found.Add(val.Trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,9 @@ namespace BaseGames.Editor
|
||||
_modules.Add(new BossSkillModule());
|
||||
_modules.Add(new CharmModule());
|
||||
_modules.Add(new StreamingModule());
|
||||
_modules.Add(new DialogueModule());
|
||||
_modules.Add(new QuestModule());
|
||||
_modules.Add(new ActorModule());
|
||||
}
|
||||
|
||||
// ── 布局 ─────────────────────────────────────────────────────────────
|
||||
|
||||
128
Assets/_Game/Scripts/Editor/Modules/ActorModule.cs
Normal file
128
Assets/_Game/Scripts/Editor/Modules/ActorModule.cs
Normal file
@@ -0,0 +1,128 @@
|
||||
using System;
|
||||
using UnityEditor;
|
||||
using UnityEditor.UIElements;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
using BaseGames.Dialogue;
|
||||
|
||||
namespace BaseGames.Editor.Modules
|
||||
{
|
||||
/// <summary>
|
||||
/// DataHub 对话角色模块 —— 管理 DialogueActorSO 资产。
|
||||
/// 统一查看、创建、重命名、删除 NPC/玩家角色定义(头像、名称 Key、强调色)。
|
||||
/// </summary>
|
||||
public class ActorModule : IDataModule
|
||||
{
|
||||
private const string Folder = "Assets/_Game/Data/Dialogue/Actors";
|
||||
private const string Prefix = "Actor_";
|
||||
|
||||
public string ModuleId => "actor";
|
||||
public string DisplayName => "角色";
|
||||
public string IconName => "d_Prefab Icon";
|
||||
|
||||
private SoListPane<DialogueActorSO> _listPane;
|
||||
private DetailHeader _header;
|
||||
private DialogueActorSO _selected;
|
||||
|
||||
public void Initialize()
|
||||
{
|
||||
_listPane = new SoListPane<DialogueActorSO>(
|
||||
Folder, Prefix,
|
||||
a => a.isPlayer ? "[玩家]" : null);
|
||||
}
|
||||
|
||||
public void BuildListPane(VisualElement container, Action<UnityEngine.Object> onSelected)
|
||||
{
|
||||
_listPane.SelectionChanged = sel =>
|
||||
{
|
||||
_selected = sel;
|
||||
onSelected?.Invoke(sel);
|
||||
};
|
||||
container.Add(_listPane);
|
||||
_listPane.Refresh();
|
||||
}
|
||||
|
||||
public void BuildDetailPane(VisualElement container, UnityEngine.Object selected)
|
||||
{
|
||||
_selected = selected as DialogueActorSO;
|
||||
|
||||
_header = new DetailHeader();
|
||||
_header.SetAsset(_selected);
|
||||
_header.RenameRequested += OnRenameRequested;
|
||||
container.Add(_header);
|
||||
|
||||
if (_selected == null) return;
|
||||
|
||||
container.Add(BuildInfoCard(_selected));
|
||||
container.Add(BuildActionBar(_selected));
|
||||
container.Add(SkillModule.MakeDivider());
|
||||
container.Add(new InspectorElement(_selected));
|
||||
}
|
||||
|
||||
public void OnActivated() => _listPane?.Refresh();
|
||||
|
||||
// ── 内部 ─────────────────────────────────────────────────────────────
|
||||
|
||||
private void OnRenameRequested(string newName)
|
||||
{
|
||||
if (_selected == null) return;
|
||||
var (ok, err) = AssetOperations.Rename(_selected, newName);
|
||||
if (!ok) EditorUtility.DisplayDialog("重命名失败", err, "确定");
|
||||
else { _header.SetAsset(_selected); _listPane.Invalidate(); }
|
||||
}
|
||||
|
||||
private static VisualElement BuildInfoCard(DialogueActorSO a)
|
||||
{
|
||||
var card = SkillModule.MakeCard();
|
||||
|
||||
SkillModule.AddChip(card, "ID", string.IsNullOrEmpty(a.actorId) ? "(未设置)" : a.actorId);
|
||||
SkillModule.AddChip(card, "名称 Key", string.IsNullOrEmpty(a.nameKey) ? "(未设置)" : a.nameKey);
|
||||
if (a.isPlayer)
|
||||
SkillModule.AddChip(card, "类型", "玩家");
|
||||
|
||||
// 头像预览
|
||||
if (a.portrait != null)
|
||||
{
|
||||
var row = new VisualElement();
|
||||
row.style.flexDirection = FlexDirection.Row;
|
||||
row.style.alignItems = Align.Center;
|
||||
row.style.paddingLeft = 8;
|
||||
row.style.paddingTop = 4;
|
||||
|
||||
var img = new Image { image = a.portrait.texture };
|
||||
img.style.width = 40;
|
||||
img.style.height = 40;
|
||||
img.style.borderTopLeftRadius = 4;
|
||||
img.style.borderTopRightRadius = 4;
|
||||
img.style.borderBottomLeftRadius = 4;
|
||||
img.style.borderBottomRightRadius = 4;
|
||||
row.Add(img);
|
||||
|
||||
// 强调色色块
|
||||
var swatch = new VisualElement();
|
||||
swatch.style.width = 14;
|
||||
swatch.style.height = 14;
|
||||
swatch.style.marginLeft = 8;
|
||||
swatch.style.backgroundColor = new StyleColor(a.accentColor);
|
||||
swatch.style.borderTopLeftRadius = 3;
|
||||
swatch.style.borderTopRightRadius = 3;
|
||||
swatch.style.borderBottomLeftRadius = 3;
|
||||
swatch.style.borderBottomRightRadius = 3;
|
||||
row.Add(swatch);
|
||||
|
||||
card.Add(row);
|
||||
}
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
private VisualElement BuildActionBar(DialogueActorSO a)
|
||||
{
|
||||
return SkillModule.BuildStandardActionBar(
|
||||
a, Folder, Prefix,
|
||||
onCreated: c => _listPane.Refresh(c),
|
||||
onCloned: c => _listPane.Refresh(c),
|
||||
onDeleted: () => _listPane.Refresh(null));
|
||||
}
|
||||
}
|
||||
}
|
||||
230
Assets/_Game/Scripts/Editor/Modules/DialogueModule.cs
Normal file
230
Assets/_Game/Scripts/Editor/Modules/DialogueModule.cs
Normal file
@@ -0,0 +1,230 @@
|
||||
using System;
|
||||
using UnityEditor;
|
||||
using UnityEditor.UIElements;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
using BaseGames.Dialogue;
|
||||
|
||||
namespace BaseGames.Editor.Modules
|
||||
{
|
||||
/// <summary>
|
||||
/// DataHub 对话序列模块 —— 管理 DialogueSequenceSO 资产。
|
||||
/// </summary>
|
||||
public class DialogueModule : IDataModule
|
||||
{
|
||||
private const string Folder = "Assets/_Game/Data/Dialogue";
|
||||
private const string Prefix = "DLG_";
|
||||
|
||||
public string ModuleId => "dialogue";
|
||||
public string DisplayName => "对话";
|
||||
public string IconName => "d_UnityEditor.ConsoleWindow";
|
||||
|
||||
private SoListPane<DialogueSequenceSO> _listPane;
|
||||
private DetailHeader _header;
|
||||
private DialogueSequenceSO _selected;
|
||||
|
||||
public void Initialize()
|
||||
{
|
||||
_listPane = new SoListPane<DialogueSequenceSO>(
|
||||
Folder, Prefix,
|
||||
s =>
|
||||
{
|
||||
int v = s.variants != null ? s.variants.Length : 0;
|
||||
return v > 0 ? $"{v}变体" : null;
|
||||
});
|
||||
}
|
||||
|
||||
public void BuildListPane(VisualElement container, Action<UnityEngine.Object> onSelected)
|
||||
{
|
||||
_listPane.SelectionChanged = sel =>
|
||||
{
|
||||
_selected = sel;
|
||||
onSelected?.Invoke(sel);
|
||||
};
|
||||
container.Add(_listPane);
|
||||
_listPane.Refresh();
|
||||
}
|
||||
|
||||
public void BuildDetailPane(VisualElement container, UnityEngine.Object selected)
|
||||
{
|
||||
_selected = selected as DialogueSequenceSO;
|
||||
|
||||
_header = new DetailHeader();
|
||||
_header.SetAsset(_selected);
|
||||
_header.RenameRequested += OnRenameRequested;
|
||||
container.Add(_header);
|
||||
|
||||
if (_selected == null) return;
|
||||
|
||||
container.Add(BuildInfoCard(_selected));
|
||||
container.Add(BuildLinesPreview(_selected));
|
||||
if (_selected.variants != null && _selected.variants.Length > 0)
|
||||
container.Add(BuildVariantsCard(_selected));
|
||||
container.Add(BuildActionBar(_selected));
|
||||
container.Add(SkillModule.MakeDivider());
|
||||
container.Add(new InspectorElement(_selected));
|
||||
}
|
||||
|
||||
public void OnActivated() => _listPane?.Refresh();
|
||||
|
||||
// ── 内部 ─────────────────────────────────────────────────────────────
|
||||
|
||||
private void OnRenameRequested(string newName)
|
||||
{
|
||||
if (_selected == null) return;
|
||||
var (ok, err) = AssetOperations.Rename(_selected, newName);
|
||||
if (!ok) EditorUtility.DisplayDialog("重命名失败", err, "确定");
|
||||
else { _header.SetAsset(_selected); _listPane.Invalidate(); }
|
||||
}
|
||||
|
||||
private static VisualElement BuildInfoCard(DialogueSequenceSO s)
|
||||
{
|
||||
var card = SkillModule.MakeCard();
|
||||
int lineCount = s.lines != null ? s.lines.Length : 0;
|
||||
int variantCount = s.variants != null ? s.variants.Length : 0;
|
||||
|
||||
SkillModule.AddChip(card, "ID", string.IsNullOrEmpty(s.sequenceId) ? "(未设置)" : s.sequenceId);
|
||||
SkillModule.AddChip(card, "行数", lineCount.ToString());
|
||||
if (variantCount > 0)
|
||||
SkillModule.AddChip(card, "变体数", variantCount.ToString());
|
||||
return card;
|
||||
}
|
||||
|
||||
private static VisualElement BuildLinesPreview(DialogueSequenceSO s)
|
||||
{
|
||||
var section = new VisualElement();
|
||||
section.style.paddingLeft = 12;
|
||||
section.style.paddingRight = 12;
|
||||
section.style.paddingTop = 6;
|
||||
section.style.paddingBottom = 6;
|
||||
|
||||
var title = new Label("对话预览(前 5 行)");
|
||||
title.style.fontSize = 11;
|
||||
title.style.opacity = 0.55f;
|
||||
title.style.marginBottom = 4;
|
||||
title.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
section.Add(title);
|
||||
|
||||
if (s.lines == null || s.lines.Length == 0)
|
||||
{
|
||||
var empty = new Label("(无对话行)");
|
||||
empty.style.opacity = 0.4f;
|
||||
empty.style.fontSize = 11;
|
||||
section.Add(empty);
|
||||
return section;
|
||||
}
|
||||
|
||||
int preview = Mathf.Min(5, s.lines.Length);
|
||||
for (int i = 0; i < preview; i++)
|
||||
{
|
||||
var line = s.lines[i];
|
||||
var row = new VisualElement();
|
||||
row.style.flexDirection = FlexDirection.Row;
|
||||
row.style.alignItems = Align.Center;
|
||||
row.style.marginBottom = 3;
|
||||
|
||||
// 头像图标(actor 优先,回退到直接字段)
|
||||
var portrait = line.ResolvedPortrait;
|
||||
if (portrait != null)
|
||||
{
|
||||
var img = new Image { image = portrait.texture };
|
||||
img.style.width = 18;
|
||||
img.style.height = 18;
|
||||
img.style.marginRight = 4;
|
||||
img.style.borderTopLeftRadius = 2;
|
||||
img.style.borderTopRightRadius = 2;
|
||||
img.style.borderBottomLeftRadius = 2;
|
||||
img.style.borderBottomRightRadius = 2;
|
||||
row.Add(img);
|
||||
}
|
||||
|
||||
// 语音图标
|
||||
if (line.voiceClip != null)
|
||||
{
|
||||
var ico = EditorGUIUtility.IconContent("d_AudioClip Icon");
|
||||
if (ico?.image != null)
|
||||
{
|
||||
var img = new Image { image = ico.image };
|
||||
img.style.width = 14;
|
||||
img.style.height = 14;
|
||||
img.style.marginRight = 4;
|
||||
row.Add(img);
|
||||
}
|
||||
}
|
||||
|
||||
// 说话人(actor 优先,回退到直接字段)
|
||||
string speakerKey = line.ResolvedNameKey;
|
||||
if (!string.IsNullOrEmpty(speakerKey))
|
||||
{
|
||||
var spk = new Label(speakerKey + ":");
|
||||
spk.style.fontSize = 11;
|
||||
spk.style.opacity = 0.55f;
|
||||
spk.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
spk.style.marginRight = 4;
|
||||
spk.style.flexShrink = 0;
|
||||
row.Add(spk);
|
||||
}
|
||||
|
||||
// 文本 key(尝试显示本地化实际内容,回退到 key 本身)
|
||||
string rawText = string.IsNullOrEmpty(line.textKey) ? "(空)" : line.textKey;
|
||||
string preview = string.IsNullOrEmpty(line.textKey)
|
||||
? "(空)"
|
||||
: (BaseGames.Localization.LocalizationManager.GetEditorPreview(line.textKey, "Dialogue") ?? rawText);
|
||||
if (preview.Length > 48) preview = preview[..48] + "…";
|
||||
var lbl = new Label(preview);
|
||||
lbl.style.fontSize = 11;
|
||||
lbl.style.overflow = Overflow.Hidden;
|
||||
row.Add(lbl);
|
||||
|
||||
section.Add(row);
|
||||
}
|
||||
|
||||
if (s.lines.Length > preview)
|
||||
{
|
||||
var more = new Label($"… 还有 {s.lines.Length - preview} 行");
|
||||
more.style.opacity = 0.4f;
|
||||
more.style.fontSize = 10;
|
||||
section.Add(more);
|
||||
}
|
||||
|
||||
return section;
|
||||
}
|
||||
|
||||
private static VisualElement BuildVariantsCard(DialogueSequenceSO s)
|
||||
{
|
||||
var card = SkillModule.MakeCard();
|
||||
card.style.flexDirection = FlexDirection.Column;
|
||||
|
||||
var title = new Label("条件变体");
|
||||
title.style.fontSize = 11;
|
||||
title.style.opacity = 0.55f;
|
||||
title.style.marginBottom = 4;
|
||||
title.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
card.Add(title);
|
||||
|
||||
foreach (var v in s.variants)
|
||||
{
|
||||
var row = new VisualElement();
|
||||
row.style.flexDirection = FlexDirection.Row;
|
||||
row.style.marginBottom = 2;
|
||||
|
||||
string flags = v.requiredFlags != null && v.requiredFlags.Length > 0
|
||||
? string.Join(", ", v.requiredFlags)
|
||||
: "(无条件)";
|
||||
SkillModule.AddChip(row, "条件", flags);
|
||||
SkillModule.AddChip(row, "替换序列", v.sequence != null ? v.sequence.name : "(未设置)");
|
||||
card.Add(row);
|
||||
}
|
||||
return card;
|
||||
}
|
||||
|
||||
private VisualElement BuildActionBar(DialogueSequenceSO s)
|
||||
{
|
||||
return SkillModule.BuildStandardActionBar(
|
||||
s, Folder, Prefix,
|
||||
onCreated: c => _listPane.Refresh(c),
|
||||
onCloned: c => _listPane.Refresh(c),
|
||||
onDeleted: () => _listPane.Refresh(null));
|
||||
}
|
||||
}
|
||||
}
|
||||
342
Assets/_Game/Scripts/Editor/Modules/QuestModule.cs
Normal file
342
Assets/_Game/Scripts/Editor/Modules/QuestModule.cs
Normal file
@@ -0,0 +1,342 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using UnityEditor;
|
||||
using UnityEditor.UIElements;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
using BaseGames.Quest;
|
||||
|
||||
namespace BaseGames.Editor.Modules
|
||||
{
|
||||
/// <summary>
|
||||
/// DataHub 任务模块 —— 管理 QuestSO 资产。
|
||||
/// </summary>
|
||||
public class QuestModule : IDataModule
|
||||
{
|
||||
private const string Folder = "Assets/_Game/Data/Quest";
|
||||
private const string Prefix = "Quest_";
|
||||
|
||||
public string ModuleId => "quest";
|
||||
public string DisplayName => "任务";
|
||||
public string IconName => "d_UnityEditor.InspectorWindow";
|
||||
|
||||
private SoListPane<QuestSO> _listPane;
|
||||
private DetailHeader _header;
|
||||
private QuestSO _selected;
|
||||
|
||||
public void Initialize()
|
||||
{
|
||||
_listPane = new SoListPane<QuestSO>(
|
||||
Folder, Prefix,
|
||||
s =>
|
||||
{
|
||||
bool hasPre = s.prerequisiteQuests != null && s.prerequisiteQuests.Length > 0;
|
||||
return hasPre ? "有前置" : null;
|
||||
});
|
||||
}
|
||||
|
||||
public void BuildListPane(VisualElement container, Action<UnityEngine.Object> onSelected)
|
||||
{
|
||||
_listPane.SelectionChanged = sel =>
|
||||
{
|
||||
_selected = sel;
|
||||
onSelected?.Invoke(sel);
|
||||
};
|
||||
container.Add(_listPane);
|
||||
_listPane.Refresh();
|
||||
}
|
||||
|
||||
public void BuildDetailPane(VisualElement container, UnityEngine.Object selected)
|
||||
{
|
||||
_selected = selected as QuestSO;
|
||||
|
||||
_header = new DetailHeader();
|
||||
_header.SetAsset(_selected);
|
||||
_header.RenameRequested += OnRenameRequested;
|
||||
container.Add(_header);
|
||||
|
||||
if (_selected == null) return;
|
||||
|
||||
container.Add(BuildInfoCard(_selected));
|
||||
container.Add(BuildObjectivesList(_selected));
|
||||
if (_selected.branches != null && _selected.branches.Length > 0)
|
||||
container.Add(BuildBranchesCard(_selected));
|
||||
container.Add(BuildActionBar(_selected));
|
||||
container.Add(SkillModule.MakeDivider());
|
||||
container.Add(new InspectorElement(_selected));
|
||||
}
|
||||
|
||||
public void OnActivated() => _listPane?.Refresh();
|
||||
|
||||
// ── 内部 ─────────────────────────────────────────────────────────────
|
||||
|
||||
private void OnRenameRequested(string newName)
|
||||
{
|
||||
if (_selected == null) return;
|
||||
var (ok, err) = AssetOperations.Rename(_selected, newName);
|
||||
if (!ok) EditorUtility.DisplayDialog("重命名失败", err, "确定");
|
||||
else { _header.SetAsset(_selected); _listPane.Invalidate(); }
|
||||
}
|
||||
|
||||
private static VisualElement BuildInfoCard(QuestSO s)
|
||||
{
|
||||
var card = SkillModule.MakeCard();
|
||||
int objCount = s.objectives != null ? s.objectives.Length : 0;
|
||||
|
||||
SkillModule.AddChip(card, "ID", string.IsNullOrEmpty(s.questId) ? "(未设置)" : s.questId);
|
||||
SkillModule.AddChip(card, "名称 Key", string.IsNullOrEmpty(s.displayNameKey) ? "(未设置)" : s.displayNameKey);
|
||||
if (!string.IsNullOrEmpty(s.descriptionKey))
|
||||
SkillModule.AddChip(card, "描述 Key", s.descriptionKey);
|
||||
SkillModule.AddChip(card, "目标数", objCount.ToString());
|
||||
|
||||
if (s.prerequisiteQuests != null && s.prerequisiteQuests.Length > 0)
|
||||
{
|
||||
// 显示每个前置任务的 questId,方便策划一眼看清依赖链
|
||||
var preIds = new System.Text.StringBuilder();
|
||||
foreach (var pre in s.prerequisiteQuests)
|
||||
{
|
||||
if (pre == null) continue;
|
||||
if (preIds.Length > 0) preIds.Append(", ");
|
||||
preIds.Append(string.IsNullOrEmpty(pre.questId) ? pre.name : pre.questId);
|
||||
}
|
||||
if (preIds.Length > 0)
|
||||
SkillModule.AddChip(card, "前置任务", preIds.ToString());
|
||||
}
|
||||
|
||||
if (s.minAffinityToAccept > 0)
|
||||
SkillModule.AddChip(card, "好感门槛", s.minAffinityToAccept.ToString());
|
||||
if (s.canFail)
|
||||
SkillModule.AddChip(card, "可失败", "✓");
|
||||
if (s.reward != null)
|
||||
SkillModule.AddChip(card, "奖励", s.reward.name);
|
||||
return card;
|
||||
}
|
||||
|
||||
private static VisualElement BuildObjectivesList(QuestSO s)
|
||||
{
|
||||
var section = new VisualElement();
|
||||
section.style.paddingLeft = 12;
|
||||
section.style.paddingRight = 12;
|
||||
section.style.paddingTop = 6;
|
||||
section.style.paddingBottom = 6;
|
||||
|
||||
var title = new Label("目标列表");
|
||||
title.style.fontSize = 11;
|
||||
title.style.opacity = 0.55f;
|
||||
title.style.marginBottom = 4;
|
||||
title.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
section.Add(title);
|
||||
|
||||
if (s.objectives == null || s.objectives.Length == 0)
|
||||
{
|
||||
var empty = new Label("(无目标)");
|
||||
empty.style.opacity = 0.4f;
|
||||
empty.style.fontSize = 11;
|
||||
section.Add(empty);
|
||||
return section;
|
||||
}
|
||||
|
||||
for (int i = 0; i < s.objectives.Length; i++)
|
||||
{
|
||||
var obj = s.objectives[i];
|
||||
if (obj == null) continue;
|
||||
|
||||
var row = new VisualElement();
|
||||
row.style.flexDirection = FlexDirection.Row;
|
||||
row.style.alignItems = Align.Center;
|
||||
row.style.marginBottom = 3;
|
||||
|
||||
// 序号
|
||||
var idx = new Label($"{i + 1}.");
|
||||
idx.style.fontSize = 11;
|
||||
idx.style.opacity = 0.5f;
|
||||
idx.style.marginRight = 4;
|
||||
idx.style.width = 16;
|
||||
idx.style.flexShrink = 0;
|
||||
row.Add(idx);
|
||||
|
||||
// 类型徽章
|
||||
string badge = obj.BadgeLabel;
|
||||
var badgeLbl = new Label(badge);
|
||||
badgeLbl.style.fontSize = 10;
|
||||
badgeLbl.style.opacity = 0.7f;
|
||||
badgeLbl.style.marginRight = 6;
|
||||
badgeLbl.style.flexShrink = 0;
|
||||
badgeLbl.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
row.Add(badgeLbl);
|
||||
|
||||
// ID
|
||||
string id = string.IsNullOrEmpty(obj.objectiveId) ? obj.name : obj.objectiveId;
|
||||
var idLbl = new Label(id);
|
||||
idLbl.style.fontSize = 11;
|
||||
idLbl.style.flexGrow = 1;
|
||||
row.Add(idLbl);
|
||||
|
||||
// 可选标记
|
||||
if (obj.IsOptional)
|
||||
{
|
||||
var opt = new Label("[可选]");
|
||||
opt.style.fontSize = 10;
|
||||
opt.style.opacity = 0.5f;
|
||||
opt.style.marginLeft = 4;
|
||||
row.Add(opt);
|
||||
}
|
||||
|
||||
section.Add(row);
|
||||
}
|
||||
|
||||
return section;
|
||||
}
|
||||
|
||||
private static VisualElement BuildBranchesCard(QuestSO s)
|
||||
{
|
||||
var card = SkillModule.MakeCard();
|
||||
card.style.flexDirection = FlexDirection.Column;
|
||||
|
||||
var title = new Label("完成后分支");
|
||||
title.style.fontSize = 11;
|
||||
title.style.opacity = 0.55f;
|
||||
title.style.marginBottom = 4;
|
||||
title.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
card.Add(title);
|
||||
|
||||
foreach (var branch in s.branches)
|
||||
{
|
||||
var row = new VisualElement();
|
||||
row.style.flexDirection = FlexDirection.Row;
|
||||
row.style.marginBottom = 2;
|
||||
|
||||
string condition = branch.conditionQuest != null ? branch.conditionQuest.questId : "(默认)";
|
||||
string next = branch.nextQuest != null ? branch.nextQuest.name : "(无)";
|
||||
SkillModule.AddChip(row, "条件", condition);
|
||||
SkillModule.AddChip(row, "后续任务", next);
|
||||
|
||||
// 优先显示新 SO 引用;回退到旧字段(Obsolete)
|
||||
string seqName = branch.npcDialogueSequence != null
|
||||
? branch.npcDialogueSequence.name
|
||||
#pragma warning disable CS0618
|
||||
: branch.npcDialogueKey;
|
||||
#pragma warning restore CS0618
|
||||
if (!string.IsNullOrEmpty(seqName))
|
||||
SkillModule.AddChip(row, "对话序列", seqName);
|
||||
card.Add(row);
|
||||
}
|
||||
return card;
|
||||
}
|
||||
|
||||
private VisualElement BuildActionBar(QuestSO s)
|
||||
{
|
||||
var bar = SkillModule.BuildStandardActionBar(
|
||||
s, Folder, Prefix,
|
||||
onCreated: c => _listPane.Refresh(c),
|
||||
onCloned: c => _listPane.Refresh(c),
|
||||
onDeleted: () => _listPane.Refresh(null));
|
||||
|
||||
// 任务模块额外:代码常量生成
|
||||
new Button(GenerateQuestKeys) { text = "生成常量" }.AlsoAddTo(bar);
|
||||
|
||||
return bar;
|
||||
}
|
||||
|
||||
// ── QuestKeys.cs 常量生成器 ──────────────────────────────────────────
|
||||
|
||||
private const string GeneratedFolder = "Assets/_Game/Scripts/Generated";
|
||||
private const string QuestKeysPath = GeneratedFolder + "/QuestKeys.cs";
|
||||
|
||||
private static void GenerateQuestKeys()
|
||||
{
|
||||
// 收集所有 questId
|
||||
var questIds = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var q in AssetOperations.FindAll<QuestSO>())
|
||||
if (!string.IsNullOrWhiteSpace(q.questId))
|
||||
questIds.Add(q.questId.Trim());
|
||||
|
||||
// 收集所有 targetNpcId(来自 TalkToNPC 目标 SO)
|
||||
var npcIds = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var obj in AssetOperations.FindAll<TalkToNPCObjective>())
|
||||
if (!string.IsNullOrWhiteSpace(obj.targetNpcId))
|
||||
npcIds.Add(obj.targetNpcId.Trim());
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("// <auto-generated>");
|
||||
sb.AppendLine("// 由 QuestModule 工具自动生成,请勿手动编辑。");
|
||||
sb.AppendLine("// 重新生成:DataHub → 任务 → 任意任务 → 生成常量");
|
||||
sb.AppendLine("// </auto-generated>");
|
||||
sb.AppendLine("namespace BaseGames.Quest");
|
||||
sb.AppendLine("{");
|
||||
sb.AppendLine(" /// <summary>任务 ID 常量(从 QuestSO 自动生成)。</summary>");
|
||||
sb.AppendLine(" public static class QuestKeys");
|
||||
sb.AppendLine(" {");
|
||||
|
||||
sb.AppendLine(" /// <summary>任务唯一 ID 常量。</summary>");
|
||||
sb.AppendLine(" public static class Quest");
|
||||
sb.AppendLine(" {");
|
||||
if (questIds.Count == 0)
|
||||
sb.AppendLine(" // (未发现任何 QuestSO)");
|
||||
foreach (var id in questIds)
|
||||
sb.AppendLine($" public const string {ToIdentifier(id)} = \"{id}\";");
|
||||
sb.AppendLine(" }");
|
||||
|
||||
sb.AppendLine();
|
||||
sb.AppendLine(" /// <summary>TalkToNPC 目标中使用的 NPC ID 常量。</summary>");
|
||||
sb.AppendLine(" public static class NpcId");
|
||||
sb.AppendLine(" {");
|
||||
if (npcIds.Count == 0)
|
||||
sb.AppendLine(" // (未发现任何 TalkToNPCObjective)");
|
||||
foreach (var id in npcIds)
|
||||
sb.AppendLine($" public const string {ToIdentifier(id)} = \"{id}\";");
|
||||
sb.AppendLine(" }");
|
||||
|
||||
sb.AppendLine(" }");
|
||||
sb.AppendLine("}");
|
||||
|
||||
if (!Directory.Exists(GeneratedFolder))
|
||||
Directory.CreateDirectory(GeneratedFolder);
|
||||
|
||||
File.WriteAllText(QuestKeysPath, sb.ToString(), Encoding.UTF8);
|
||||
AssetDatabase.Refresh();
|
||||
Debug.Log($"[QuestModule] QuestKeys.cs 已生成:{questIds.Count} 个任务 ID,{npcIds.Count} 个 NPC ID。");
|
||||
EditorUtility.DisplayDialog("生成成功",
|
||||
$"QuestKeys.cs 已写入 {QuestKeysPath}\n任务 ID: {questIds.Count} NPC ID: {npcIds.Count}",
|
||||
"确定");
|
||||
}
|
||||
|
||||
/// <summary>将任意字符串转换为合法的 C# 标识符(PascalCase)。C# 保留关键字加 @ 前缀。</summary>
|
||||
private static string ToIdentifier(string raw)
|
||||
{
|
||||
if (string.IsNullOrEmpty(raw)) return "_Empty";
|
||||
var parts = raw.Split('_', '-', ' ', '.', '/');
|
||||
var sb = new StringBuilder();
|
||||
foreach (var part in parts)
|
||||
{
|
||||
if (part.Length == 0) continue;
|
||||
sb.Append(char.ToUpperInvariant(part[0]));
|
||||
if (part.Length > 1) sb.Append(part.Substring(1));
|
||||
}
|
||||
string result = sb.ToString();
|
||||
if (result.Length > 0 && char.IsDigit(result[0]))
|
||||
result = "_" + result;
|
||||
if (string.IsNullOrEmpty(result)) return "_Empty";
|
||||
// C# 保留关键字加 @ 前缀,避免生成无法编译的代码
|
||||
if (s_CSharpKeywords.Contains(result))
|
||||
result = "@" + result;
|
||||
return result;
|
||||
}
|
||||
|
||||
private static readonly HashSet<string> s_CSharpKeywords = new HashSet<string>(
|
||||
System.StringComparer.Ordinal)
|
||||
{
|
||||
"abstract","as","base","bool","break","byte","case","catch","char","checked",
|
||||
"class","const","continue","decimal","default","delegate","do","double","else",
|
||||
"enum","event","explicit","extern","false","finally","fixed","float","for",
|
||||
"foreach","goto","if","implicit","in","int","interface","internal","is","lock",
|
||||
"long","namespace","new","null","object","operator","out","override","params",
|
||||
"private","protected","public","readonly","ref","return","sbyte","sealed",
|
||||
"short","sizeof","stackalloc","static","string","struct","switch","this",
|
||||
"throw","true","try","typeof","uint","ulong","unchecked","unsafe","ushort",
|
||||
"using","virtual","void","volatile","while"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -99,15 +99,7 @@ namespace BaseGames.Editor.Modules
|
||||
{ text = "克隆..." }.AlsoAddTo(bar);
|
||||
|
||||
var del = new Button(() => { if (AssetOperations.Delete(s)) _listPane.Refresh(null); }) { text = "删除" };
|
||||
del.style.borderLeftColor = new StyleColor(new Color(0.8f, 0.3f, 0.3f, 0.6f));
|
||||
del.style.borderRightColor = new StyleColor(new Color(0.8f, 0.3f, 0.3f, 0.6f));
|
||||
del.style.borderTopColor = new StyleColor(new Color(0.8f, 0.3f, 0.3f, 0.6f));
|
||||
del.style.borderBottomColor = new StyleColor(new Color(0.8f, 0.3f, 0.3f, 0.6f));
|
||||
del.style.borderLeftWidth = 1;
|
||||
del.style.borderRightWidth = 1;
|
||||
del.style.borderTopWidth = 1;
|
||||
del.style.borderBottomWidth = 1;
|
||||
del.style.marginLeft = 8;
|
||||
ApplyDeleteStyle(del);
|
||||
del.AlsoAddTo(bar);
|
||||
|
||||
return bar;
|
||||
@@ -171,6 +163,69 @@ namespace BaseGames.Editor.Modules
|
||||
d.style.backgroundColor = new StyleColor(new Color(0.5f, 0.5f, 0.5f, 0.2f));
|
||||
return d;
|
||||
}
|
||||
|
||||
/// <summary>将删除按钮染成红色边框,统一各模块样式。</summary>
|
||||
internal static void ApplyDeleteStyle(Button btn)
|
||||
{
|
||||
var red = new StyleColor(new Color(0.8f, 0.3f, 0.3f, 0.6f));
|
||||
btn.style.borderLeftColor = red;
|
||||
btn.style.borderRightColor = red;
|
||||
btn.style.borderTopColor = red;
|
||||
btn.style.borderBottomColor = red;
|
||||
btn.style.borderLeftWidth = 1;
|
||||
btn.style.borderRightWidth = 1;
|
||||
btn.style.borderTopWidth = 1;
|
||||
btn.style.borderBottomWidth = 1;
|
||||
btn.style.marginLeft = 8;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为任意 ScriptableObject 模块生成标准 ActionBar(新建 / 定位 / 克隆 / 删除)。
|
||||
/// 各模块可在返回后向 bar 追加额外按钮。
|
||||
/// </summary>
|
||||
/// <param name="asset">当前选中资产。</param>
|
||||
/// <param name="folder">资产所在文件夹(用于新建 / 克隆)。</param>
|
||||
/// <param name="prefix">新建资产的文件名前缀。</param>
|
||||
/// <param name="onCreated">新建完成回调(传入新资产)。</param>
|
||||
/// <param name="onCloned">克隆完成回调(传入克隆资产)。</param>
|
||||
/// <param name="onDeleted">删除完成回调。</param>
|
||||
internal static VisualElement BuildStandardActionBar<T>(
|
||||
T asset,
|
||||
string folder,
|
||||
string prefix,
|
||||
Action<T> onCreated,
|
||||
Action<T> onCloned,
|
||||
Action onDeleted) where T : UnityEngine.ScriptableObject
|
||||
{
|
||||
var bar = MakeActionBar();
|
||||
|
||||
new Button(() =>
|
||||
{
|
||||
var c = AssetOperations.Create<T>(folder, prefix + "New");
|
||||
if (c != null) onCreated?.Invoke(c);
|
||||
}) { text = "新建" }.AlsoAddTo(bar);
|
||||
|
||||
new Button(() =>
|
||||
{
|
||||
EditorGUIUtility.PingObject(asset);
|
||||
Selection.activeObject = asset;
|
||||
}) { text = "定位" }.AlsoAddTo(bar);
|
||||
|
||||
new Button(() =>
|
||||
{
|
||||
var c = AssetOperations.Clone(asset, folder);
|
||||
if (c != null) onCloned?.Invoke(c);
|
||||
}) { text = "克隆..." }.AlsoAddTo(bar);
|
||||
|
||||
var del = new Button(() =>
|
||||
{
|
||||
if (AssetOperations.Delete(asset)) onDeleted?.Invoke();
|
||||
}) { text = "删除" };
|
||||
ApplyDeleteStyle(del);
|
||||
del.AlsoAddTo(bar);
|
||||
|
||||
return bar;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Button 扩展(模块内共用)─────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.Reflection;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Quest.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// 监听 QuestSO 资产的增删/移动事件,自动刷新场景中 QuestManager 的 _allQuests 列表。
|
||||
/// 保证策划通过 DataHub 创建新任务后无需手动触发 OnValidate。
|
||||
/// </summary>
|
||||
internal sealed class QuestManagerPostprocessor : AssetPostprocessor
|
||||
{
|
||||
private static void OnPostprocessAllAssets(
|
||||
string[] imported, string[] deleted, string[] moved, string[] movedFrom)
|
||||
{
|
||||
if (!NeedsRefresh(imported) && !NeedsRefresh(deleted) && !NeedsRefresh(moved))
|
||||
return;
|
||||
|
||||
RefreshAllQuestManagers();
|
||||
}
|
||||
|
||||
private static bool NeedsRefresh(string[] paths)
|
||||
{
|
||||
if (paths == null) return false;
|
||||
foreach (var p in paths)
|
||||
{
|
||||
if (p.EndsWith(".asset", System.StringComparison.OrdinalIgnoreCase)
|
||||
&& AssetDatabase.GetMainAssetTypeAtPath(p) == typeof(QuestSO))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在所有已加载场景中查找 QuestManager,通过反射调用其
|
||||
/// EditorRefreshQuestList 方法(#if UNITY_EDITOR 区块内定义)。
|
||||
/// </summary>
|
||||
private static void RefreshAllQuestManagers()
|
||||
{
|
||||
var managers = Object.FindObjectsByType<QuestManager>(
|
||||
FindObjectsInactive.Include, FindObjectsSortMode.None);
|
||||
|
||||
if (managers == null || managers.Length == 0) return;
|
||||
|
||||
var method = typeof(QuestManager).GetMethod(
|
||||
"EditorRefreshQuestList",
|
||||
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
|
||||
|
||||
if (method == null)
|
||||
{
|
||||
Debug.LogWarning("[QuestManagerPostprocessor] 未找到 EditorRefreshQuestList 方法,请确认 QuestManager 包含该方法。");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var mgr in managers)
|
||||
{
|
||||
method.Invoke(mgr, null);
|
||||
EditorUtility.SetDirty(mgr);
|
||||
}
|
||||
|
||||
Debug.Log($"[QuestManagerPostprocessor] 已刷新 {managers.Length} 个 QuestManager 的任务列表。");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,20 +55,14 @@ namespace BaseGames.Editor
|
||||
string path = AssetDatabase.GetAssetPath(asset);
|
||||
if (string.IsNullOrEmpty(path)) return (false, "资产不在 AssetDatabase 中");
|
||||
|
||||
// 先更新序列化内部名称
|
||||
string oldName = asset.name;
|
||||
Undo.RecordObject(asset, "Rename " + oldName);
|
||||
asset.name = newName;
|
||||
EditorUtility.SetDirty(asset);
|
||||
|
||||
// 再重命名磁盘文件
|
||||
// 先重命名磁盘文件,成功后再修改内部名称,避免 Undo 状态被失败操作污染
|
||||
string err = AssetDatabase.RenameAsset(path, newName);
|
||||
if (!string.IsNullOrEmpty(err))
|
||||
{
|
||||
asset.name = oldName;
|
||||
EditorUtility.SetDirty(asset);
|
||||
return (false, err);
|
||||
}
|
||||
|
||||
Undo.RecordObject(asset, "Rename " + asset.name);
|
||||
asset.name = newName;
|
||||
EditorUtility.SetDirty(asset);
|
||||
|
||||
AssetDatabase.SaveAssets();
|
||||
AssetDatabase.Refresh();
|
||||
|
||||
Reference in New Issue
Block a user