Files
zeling_v2/Assets/_Game/Scripts/Editor/Modules/QuestModule.cs
Joywayer 446fd5dcd0 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.
2026-05-24 00:36:11 +08:00

343 lines
14 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"
};
}
}