- SaveData: update QuestState.Status comment to include Paused state - QuestManager: add inline comment on AcceptQuest duplicate-accept guard - QuestManager: wrap reward.Apply() in try-catch so exceptions don't corrupt already-committed Completed state - QuestManager.UnlockBranches: support new conditionFlagEntries (invert/ NOT logic) with graceful fallback to legacy conditionFlags - QuestGiver: cache IQuestManager field in OnEnable; subscribe to OnQuestStateChanged for automatic cache invalidation instead of manual _cacheDirty = true after each Interact; remove per-call SL.GetOrDefault - QuestGiver: replace hardcoded Chinese prompt strings with LocalizationManager.Get(key, 'UI') + inline fallback via GetPrompt() - QuestSO: add BranchFlagEntry struct (flagId + invert) for NOT-logic branch conditions; add conditionFlagEntries to QuestBranch with HideInInspector on legacy conditionFlags for backward compat - QuestModule: add static TTL cache (5 s) for FindAll<QuestSO>() in PopulateDependencyGraph to avoid re-scanning disk on every foldout open - NpcSOEditor: add 'jump to localization file' button that pings and selects the UI table JSON in the Project window Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1009 lines
48 KiB
C#
1009 lines
48 KiB
C#
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;
|
||
using BaseGames.Editor.Shared;
|
||
|
||
namespace BaseGames.Editor.Modules
|
||
{
|
||
/// <summary>
|
||
/// DataHub 任务模块 —— 管理 QuestSO 资产。
|
||
/// </summary>
|
||
public class QuestModule : IDataModule, IDataModuleOrdered
|
||
{
|
||
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";
|
||
public int DisplayOrder => 110;
|
||
|
||
private SoListPane<QuestSO> _listPane;
|
||
private DetailHeader _header;
|
||
private QuestSO _selected;
|
||
|
||
// playModeStateChanged 订阅的字段引用,便于在重建 ActionBar 时退订旧订阅,避免内存泄漏
|
||
private System.Action<UnityEditor.PlayModeStateChange> _playModeHandler;
|
||
|
||
// 依赖关系图中 FindAll<QuestSO>() 的静态缓存,同一编辑器会话内复用,避免重复扫描磁盘
|
||
private static QuestSO[] s_allQuestCache;
|
||
private static double s_allQuestCacheTime;
|
||
private const double k_AllQuestCacheTtl = 5.0; // 秒;超时后下次打开 foldout 时刷新
|
||
|
||
public void Initialize()
|
||
{
|
||
_listPane = new SoListPane<QuestSO>(
|
||
Folder, Prefix,
|
||
s =>
|
||
{
|
||
bool hasPre = s.prerequisiteQuests != null && s.prerequisiteQuests.Length > 0;
|
||
// 徽章:分类 + 有前置
|
||
string catLabel = s.category switch
|
||
{
|
||
QuestCategory.Main => "主线",
|
||
QuestCategory.Daily => "日常",
|
||
QuestCategory.Hidden => "隐藏",
|
||
_ => null, // Side 不显示(默认值,减少视觉噪声)
|
||
};
|
||
if (catLabel != null) return catLabel;
|
||
return hasPre ? "有前置" : null;
|
||
});
|
||
// 扩展搜索:questId + displayNameKey + category
|
||
_listPane.GetExtraSearchText = q =>
|
||
$"{q.questId} {q.displayNameKey} {q.category}";
|
||
}
|
||
|
||
public void BuildListPane(VisualElement container, Action<UnityEngine.Object> onSelected)
|
||
{
|
||
_listPane.SelectionChanged = sel =>
|
||
{
|
||
_selected = sel;
|
||
onSelected?.Invoke(sel);
|
||
};
|
||
|
||
// ── 快速过滤标签行 ─────────────────────────────────────────────
|
||
var filterRow = new VisualElement();
|
||
filterRow.style.flexDirection = FlexDirection.Row;
|
||
filterRow.style.flexWrap = Wrap.Wrap;
|
||
filterRow.style.paddingLeft = 6;
|
||
filterRow.style.paddingRight = 6;
|
||
filterRow.style.paddingBottom = 3;
|
||
container.Add(filterRow);
|
||
|
||
bool filterPrereq = false, filterNoObj = false, filterCanFail = false;
|
||
QuestCategory? filterCategory = null;
|
||
|
||
void RebuildFilter()
|
||
{
|
||
if (!filterPrereq && !filterNoObj && !filterCanFail && filterCategory == null)
|
||
{
|
||
_listPane.ExtraFilter = null;
|
||
return;
|
||
}
|
||
_listPane.ExtraFilter = q =>
|
||
{
|
||
if (filterPrereq && (q.prerequisiteQuests == null || q.prerequisiteQuests.Length == 0)) return false;
|
||
if (filterNoObj && (q.objectives != null && q.objectives.Length > 0)) return false;
|
||
if (filterCanFail && !q.canFail) return false;
|
||
if (filterCategory.HasValue && q.category != filterCategory.Value) return false;
|
||
return true;
|
||
};
|
||
}
|
||
|
||
filterRow.Add(MakeFilterChip("主线", v => { filterCategory = v ? QuestCategory.Main : (QuestCategory?)null; RebuildFilter(); }));
|
||
filterRow.Add(MakeFilterChip("支线", v => { filterCategory = v ? QuestCategory.Side : (QuestCategory?)null; RebuildFilter(); }));
|
||
filterRow.Add(MakeFilterChip("日常", v => { filterCategory = v ? QuestCategory.Daily : (QuestCategory?)null; RebuildFilter(); }));
|
||
filterRow.Add(MakeFilterChip("隐藏", v => { filterCategory = v ? QuestCategory.Hidden : (QuestCategory?)null; RebuildFilter(); }));
|
||
// 分隔
|
||
var sep = new Label("|");
|
||
sep.style.opacity = 0.3f;
|
||
sep.style.marginLeft = 2;
|
||
sep.style.marginRight = 2;
|
||
filterRow.Add(sep);
|
||
filterRow.Add(MakeFilterChip("有前置", v => { filterPrereq = v; RebuildFilter(); }));
|
||
filterRow.Add(MakeFilterChip("无目标", v => { filterNoObj = v; RebuildFilter(); }));
|
||
filterRow.Add(MakeFilterChip("可失败", v => { filterCanFail = v; RebuildFilter(); }));
|
||
|
||
container.Add(_listPane);
|
||
_listPane.Refresh();
|
||
}
|
||
|
||
internal static VisualElement MakeFilterChip(string label, System.Action<bool> onToggle)
|
||
{
|
||
bool active = false;
|
||
var chip = new Label(label);
|
||
chip.style.fontSize = 10;
|
||
chip.style.paddingLeft = 6;
|
||
chip.style.paddingRight = 6;
|
||
chip.style.paddingTop = 2;
|
||
chip.style.paddingBottom = 2;
|
||
chip.style.marginRight = 4;
|
||
chip.style.marginBottom = 2;
|
||
chip.style.borderTopLeftRadius = 8;
|
||
chip.style.borderTopRightRadius = 8;
|
||
chip.style.borderBottomLeftRadius = 8;
|
||
chip.style.borderBottomRightRadius = 8;
|
||
chip.style.borderTopWidth = 1;
|
||
chip.style.borderRightWidth = 1;
|
||
chip.style.borderBottomWidth = 1;
|
||
chip.style.borderLeftWidth = 1;
|
||
chip.style.borderTopColor = new StyleColor(new Color(0.5f, 0.5f, 0.5f, 0.4f));
|
||
chip.style.borderRightColor = new StyleColor(new Color(0.5f, 0.5f, 0.5f, 0.4f));
|
||
chip.style.borderBottomColor = new StyleColor(new Color(0.5f, 0.5f, 0.5f, 0.4f));
|
||
chip.style.borderLeftColor = new StyleColor(new Color(0.5f, 0.5f, 0.5f, 0.4f));
|
||
chip.style.opacity = 0.6f;
|
||
|
||
void SetActive(bool on)
|
||
{
|
||
active = on;
|
||
chip.style.opacity = on ? 1f : 0.6f;
|
||
chip.style.backgroundColor = on
|
||
? new StyleColor(new Color(0.3f, 0.6f, 1f, 0.25f))
|
||
: StyleKeyword.None;
|
||
onToggle(on);
|
||
}
|
||
|
||
chip.RegisterCallback<ClickEvent>(_ => SetActive(!active));
|
||
return chip;
|
||
}
|
||
|
||
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(BuildDependencyGraph(_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);
|
||
|
||
// 名称:优先显示本地化实际文本,回退到 Key 本身(与 ActorModule 保持一致)
|
||
string nameDisplay;
|
||
if (string.IsNullOrEmpty(s.displayNameKey))
|
||
{
|
||
nameDisplay = "(未设置)";
|
||
}
|
||
else
|
||
{
|
||
var resolved = BaseGames.Localization.LocalizationManager.GetEditorPreview(s.displayNameKey, "Quest");
|
||
nameDisplay = resolved != null ? resolved : s.displayNameKey + " ⚠ [缺少本地化]";
|
||
}
|
||
SkillModule.AddChip(card, "名称", nameDisplay);
|
||
if (!string.IsNullOrEmpty(s.displayNameKey))
|
||
SkillModule.AddChip(card, "名称 Key", s.displayNameKey);
|
||
|
||
if (!string.IsNullOrEmpty(s.descriptionKey))
|
||
SkillModule.AddChip(card, "描述 Key", s.descriptionKey);
|
||
SkillModule.AddChip(card, "目标数", objCount.ToString());
|
||
|
||
// 分类标签
|
||
string catDisplay = s.category switch
|
||
{
|
||
QuestCategory.Main => "主线",
|
||
QuestCategory.Side => "支线",
|
||
QuestCategory.Daily => "日常",
|
||
QuestCategory.Hidden => "隐藏",
|
||
_ => s.category.ToString(),
|
||
};
|
||
SkillModule.AddChip(card, "分类", catDisplay);
|
||
|
||
// 发布 NPC:优先显示 giverNpc.npcId,回退旧 giverNpcId
|
||
string giverId = s.GiverNpcId;
|
||
if (!string.IsNullOrEmpty(giverId))
|
||
SkillModule.AddChip(card, "发布 NPC", giverId);
|
||
|
||
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);
|
||
// 展示奖励具体内容,方便策划确认配置
|
||
var rewardDetail = new System.Text.StringBuilder();
|
||
if (s.reward.lingZhu > 0) rewardDetail.Append($"灵珠×{s.reward.lingZhu} ");
|
||
if (s.reward.soulBonus > 0) rewardDetail.Append($"灵魂槽+{s.reward.soulBonus} ");
|
||
if (s.reward.itemIds != null && s.reward.itemIds.Length > 0)
|
||
rewardDetail.Append($"物品×{s.reward.itemIds.Length} ");
|
||
if (s.reward.affinityBonus != 0)
|
||
rewardDetail.Append($"好感{(s.reward.affinityBonus > 0 ? "+" : "")}{s.reward.affinityBonus} ");
|
||
if (s.reward.unlocksAbility)
|
||
rewardDetail.Append("能力解锁 ");
|
||
if (!string.IsNullOrEmpty(s.reward.unlockDialogueKey))
|
||
rewardDetail.Append("台词解锁 ");
|
||
string detail = rewardDetail.ToString().TrimEnd();
|
||
if (!string.IsNullOrEmpty(detail))
|
||
SkillModule.AddChip(card, "奖励内容", detail);
|
||
}
|
||
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);
|
||
|
||
// 目标描述(本地化预览,灰色小字,显示策划填写的实际内容)
|
||
if (!string.IsNullOrEmpty(obj.displayTextKey))
|
||
{
|
||
var resolved = BaseGames.Localization.LocalizationManager.GetEditorPreview(obj.displayTextKey, "Quest");
|
||
bool l10nMissing = resolved == null;
|
||
string descText = l10nMissing ? obj.displayTextKey + " ⚠ [缺少本地化]" : resolved;
|
||
var desc = new Label(descText);
|
||
desc.style.fontSize = 10;
|
||
desc.style.opacity = l10nMissing ? 1.0f : 0.55f;
|
||
desc.style.color = l10nMissing
|
||
? new StyleColor(new Color(1f, 0.6f, 0.1f))
|
||
: new StyleColor(StyleKeyword.Null);
|
||
desc.style.paddingLeft = 26;
|
||
desc.style.marginBottom = 2;
|
||
section.Add(desc);
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 构建当前任务的依赖关系可视图(折叠面板形式):
|
||
/// - 上方:前置任务链(此任务需要哪些任务先完成)
|
||
/// - 下方:后续任务链(此任务完成后可解锁哪些任务)
|
||
/// 数据来源:allQuests 中所有 QuestSO 的 prerequisiteQuests 引用,无运行时副作用。
|
||
/// 节点可点击→选中对应资产(EditorGUIUtility.PingObject)。
|
||
/// </summary>
|
||
private static VisualElement BuildDependencyGraph(QuestSO s)
|
||
{
|
||
var foldout = new Foldout { text = "依赖关系", value = false };
|
||
foldout.style.paddingLeft = 12;
|
||
foldout.style.paddingRight = 12;
|
||
foldout.style.marginTop = 4;
|
||
foldout.style.marginBottom = 4;
|
||
|
||
// 懒加载:展开时才扫描资产,避免初始化开销
|
||
bool built = false;
|
||
foldout.RegisterValueChangedCallback(evt =>
|
||
{
|
||
if (!evt.newValue || built) return;
|
||
built = true;
|
||
PopulateDependencyGraph(foldout.contentContainer, s);
|
||
});
|
||
|
||
return foldout;
|
||
}
|
||
|
||
private static void PopulateDependencyGraph(VisualElement container, QuestSO s)
|
||
{
|
||
// 静态 TTL 缓存:5 秒内复用上次 FindAll 结果,避免每次展开 foldout 都扫描全量资产
|
||
if (s_allQuestCache == null ||
|
||
EditorApplication.timeSinceStartup - s_allQuestCacheTime > k_AllQuestCacheTtl)
|
||
{
|
||
s_allQuestCache = AssetOperations.FindAll<QuestSO>();
|
||
s_allQuestCacheTime = EditorApplication.timeSinceStartup;
|
||
}
|
||
var allQuests = s_allQuestCache;
|
||
|
||
// ── 前置任务(上游)────────────────────────────────────────────────
|
||
bool hasPrereqs = s.prerequisiteQuests != null && s.prerequisiteQuests.Length > 0;
|
||
AddDepSection(container, "▲ 前置任务(需先完成)",
|
||
hasPrereqs
|
||
? System.Array.ConvertAll(s.prerequisiteQuests, q => (q, "前置"))
|
||
: null,
|
||
hasPrereqs ? null : "(无前置条件,可直接接取)");
|
||
|
||
// ── 后续任务(下游):扫描 allQuests,找出以 s 为前置的任务 ───────
|
||
var downstream = new List<(QuestSO q, string label)>();
|
||
foreach (var quest in allQuests)
|
||
{
|
||
if (quest == null || quest == s) continue;
|
||
if (quest.prerequisiteQuests == null) continue;
|
||
foreach (var pre in quest.prerequisiteQuests)
|
||
{
|
||
if (pre == s) { downstream.Add((quest, "解锁")); break; }
|
||
}
|
||
}
|
||
|
||
// ── 分支后续(branch.nextQuest)────────────────────────────────────
|
||
if (s.branches != null)
|
||
{
|
||
foreach (var branch in s.branches)
|
||
{
|
||
if (branch.nextQuest == null) continue;
|
||
string label = branch.conditionQuest != null
|
||
? $"分支(条件={branch.conditionQuest.questId})"
|
||
: "分支(默认)";
|
||
downstream.Add((branch.nextQuest, label));
|
||
}
|
||
}
|
||
|
||
AddDepSection(container, "▼ 后续任务(完成后解锁)",
|
||
downstream.Count > 0 ? downstream.ToArray() : null,
|
||
downstream.Count == 0 ? "(无后续任务)" : null);
|
||
|
||
// ── 环形依赖检测 ─────────────────────────────────────────────────
|
||
// 检查当前任务的前置链中是否存在循环引用(如 A 需要 B,B 又需要 A)
|
||
if (HasPrerequisiteCycle(s, s, new System.Collections.Generic.HashSet<string>(System.StringComparer.Ordinal)))
|
||
{
|
||
var cycleWarn = new UnityEngine.UIElements.Label("⚠ 检测到前置任务循环引用!此任务永远无法接取,请检查前置任务链。");
|
||
cycleWarn.style.color = new StyleColor(new UnityEngine.Color(1f, 0.4f, 0.2f));
|
||
cycleWarn.style.fontSize = 11;
|
||
cycleWarn.style.marginTop = 6;
|
||
cycleWarn.style.paddingLeft = 8;
|
||
cycleWarn.style.whiteSpace = WhiteSpace.Normal;
|
||
container.Add(cycleWarn);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 递归检测任务是否存在循环前置依赖(DFS)。
|
||
/// visited 存储已访问的 questId,origin 为检测起点。
|
||
/// </summary>
|
||
private static bool HasPrerequisiteCycle(QuestSO origin, QuestSO current, System.Collections.Generic.HashSet<string> visited)
|
||
{
|
||
if (current?.prerequisiteQuests == null) return false;
|
||
foreach (var pre in current.prerequisiteQuests)
|
||
{
|
||
if (pre == null || string.IsNullOrEmpty(pre.questId)) continue;
|
||
if (pre == origin) return true; // 回到起点,发现循环
|
||
if (!visited.Add(pre.questId)) continue; // 已访问,跳过防止重复 DFS
|
||
if (HasPrerequisiteCycle(origin, pre, visited)) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
|
||
/// <summary>添加一个依赖关系分区(标题 + 节点列表)。</summary>
|
||
private static void AddDepSection(VisualElement container,
|
||
string sectionTitle,
|
||
(QuestSO q, string label)[] items,
|
||
string emptyText)
|
||
{
|
||
var header = new Label(sectionTitle);
|
||
header.style.fontSize = 10;
|
||
header.style.opacity = 0.55f;
|
||
header.style.marginTop = 6;
|
||
header.style.marginBottom = 3;
|
||
header.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||
container.Add(header);
|
||
|
||
if (items == null || items.Length == 0)
|
||
{
|
||
var empty = new Label(emptyText ?? "(无)");
|
||
empty.style.fontSize = 11;
|
||
empty.style.opacity = 0.4f;
|
||
empty.style.paddingLeft = 10;
|
||
container.Add(empty);
|
||
return;
|
||
}
|
||
|
||
foreach (var (q, label) in items)
|
||
{
|
||
if (q == null) continue;
|
||
var row = new VisualElement();
|
||
row.style.flexDirection = FlexDirection.Row;
|
||
row.style.alignItems = Align.Center;
|
||
row.style.marginBottom = 2;
|
||
row.style.paddingLeft = 10;
|
||
|
||
// 关系标签徽章
|
||
var badge = new Label($"[{label}]");
|
||
badge.style.fontSize = 9;
|
||
badge.style.opacity = 0.6f;
|
||
badge.style.marginRight = 5;
|
||
badge.style.flexShrink = 0;
|
||
row.Add(badge);
|
||
|
||
// 任务名按钮(点击 Ping 资产)
|
||
string displayName = string.IsNullOrEmpty(q.questId) ? q.name : q.questId;
|
||
var btn = new Button(() => EditorGUIUtility.PingObject(q)) { text = displayName };
|
||
btn.style.fontSize = 11;
|
||
btn.style.flexGrow = 1;
|
||
btn.style.paddingTop = 1;
|
||
btn.style.paddingBottom = 1;
|
||
btn.style.unityTextAlign = TextAnchor.MiddleLeft;
|
||
row.Add(btn);
|
||
|
||
container.Add(row);
|
||
}
|
||
}
|
||
|
||
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),
|
||
wizardCreate: cb => AssetCreationWizard.Show<QuestSO>(
|
||
Folder, Prefix,
|
||
(q, id) =>
|
||
{
|
||
q.questId = id;
|
||
EditorUtility.SetDirty(q);
|
||
AssetDatabase.SaveAssets();
|
||
cb(q);
|
||
}));
|
||
|
||
// 任务模块额外:代码常量生成 + 批量配置验证
|
||
new Button(GenerateQuestKeys) { text = "生成常量" }.AlsoAddTo(bar);
|
||
new Button(ValidateAllQuests) { text = "批量验证" }.AlsoAddTo(bar);
|
||
|
||
// 运行时模拟按钮(仅 PlayMode 可用)
|
||
var simulateBtn = new Button(() => SimulateQuest(_selected)) { text = "▶ 模拟" };
|
||
simulateBtn.tooltip =
|
||
"PlayMode 下推进任务状态机:\n" +
|
||
" • Available → AcceptQuest(接取)\n" +
|
||
" • Active → 弹窗选择:CompleteQuest 或 AbandonQuest\n" +
|
||
" • 其他状态 → ResetQuest(重置为 Available 供重测)\n" +
|
||
"EditMode 下按钮灰显。";
|
||
simulateBtn.SetEnabled(UnityEditor.EditorApplication.isPlaying);
|
||
|
||
// 退订旧订阅,避免每次 BuildDetailPane 时重复追加 lambda 导致内存泄漏
|
||
if (_playModeHandler != null)
|
||
UnityEditor.EditorApplication.playModeStateChanged -= _playModeHandler;
|
||
_playModeHandler = s =>
|
||
{
|
||
bool playing = s == UnityEditor.PlayModeStateChange.EnteredPlayMode
|
||
|| UnityEditor.EditorApplication.isPlaying;
|
||
simulateBtn.SetEnabled(playing);
|
||
};
|
||
UnityEditor.EditorApplication.playModeStateChanged += _playModeHandler;
|
||
simulateBtn.AlsoAddTo(bar);
|
||
|
||
return bar;
|
||
}
|
||
|
||
// ── 运行时模拟 ────────────────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// PlayMode 下对当前选中的 QuestSO 模拟状态推进或重置:
|
||
/// - Available → AcceptQuest
|
||
/// - Active → CompleteQuest(传入 null rewardTarget,跳过奖励发放)
|
||
/// - Completed / Failed / Unavailable → ResetQuest(重置为 Available 供重测)
|
||
/// 用于策划/开发人员在不启动游戏流程的情况下快速验证任务状态机。
|
||
/// </summary>
|
||
private static void SimulateQuest(QuestSO quest)
|
||
{
|
||
if (!UnityEditor.EditorApplication.isPlaying)
|
||
{
|
||
UnityEditor.EditorUtility.DisplayDialog("模拟测试", "请先进入 PlayMode。", "确定");
|
||
return;
|
||
}
|
||
if (quest == null)
|
||
{
|
||
Debug.LogWarning("[QuestModule] 请先在左侧列表选中一个任务再点击模拟。");
|
||
return;
|
||
}
|
||
|
||
var qm = BaseGames.Core.ServiceLocator.GetOrDefault<IQuestManager>();
|
||
if (qm == null)
|
||
{
|
||
Debug.LogWarning("[QuestModule] IQuestManager 未注册到 ServiceLocator,请确认 QuestManager 已在场景中。");
|
||
return;
|
||
}
|
||
|
||
var state = qm.GetState(quest.questId);
|
||
switch (state)
|
||
{
|
||
case BaseGames.Core.Events.QuestState.Available:
|
||
qm.AcceptQuest(quest.questId);
|
||
Debug.Log($"[QuestModule] 模拟接受任务:{quest.questId}");
|
||
break;
|
||
case BaseGames.Core.Events.QuestState.Active:
|
||
// Active 状态提供三个操作:完成 / 暂停 / 放弃
|
||
int choice = UnityEditor.EditorUtility.DisplayDialogComplex(
|
||
"模拟 Active 任务",
|
||
$"任务 [{quest.questId}] 当前进行中,请选择操作:",
|
||
"完成任务", // 0
|
||
"取消", // 1
|
||
"暂停任务"); // 2
|
||
if (choice == 0)
|
||
{
|
||
qm.CompleteQuest(quest.questId, null);
|
||
Debug.Log($"[QuestModule] 模拟完成任务:{quest.questId}");
|
||
}
|
||
else if (choice == 2)
|
||
{
|
||
qm.PauseQuest(quest.questId);
|
||
Debug.Log($"[QuestModule] 模拟暂停任务:{quest.questId}");
|
||
}
|
||
break;
|
||
case BaseGames.Core.Events.QuestState.Paused:
|
||
// Paused 状态:恢复 或 放弃
|
||
int pauseChoice = UnityEditor.EditorUtility.DisplayDialogComplex(
|
||
"模拟 Paused 任务",
|
||
$"任务 [{quest.questId}] 当前已暂停,请选择操作:",
|
||
"恢复任务", // 0
|
||
"取消", // 1
|
||
"放弃任务"); // 2
|
||
if (pauseChoice == 0)
|
||
{
|
||
qm.ResumeQuest(quest.questId);
|
||
Debug.Log($"[QuestModule] 模拟恢复任务:{quest.questId}");
|
||
}
|
||
else if (pauseChoice == 2)
|
||
{
|
||
// Paused 不能直接调用 AbandonQuest(需先恢复)
|
||
qm.ResumeQuest(quest.questId);
|
||
qm.AbandonQuest(quest.questId);
|
||
Debug.Log($"[QuestModule] 模拟放弃暂停中的任务:{quest.questId}");
|
||
}
|
||
break;
|
||
default:
|
||
// Completed / Failed / Unavailable → 通过 IQuestDebugger 重置为 Available 供重测
|
||
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
||
if (qm is IQuestDebugger debugger)
|
||
{
|
||
debugger.ResetQuest(quest.questId);
|
||
Debug.Log($"[QuestModule] 任务 '{quest.questId}' 已从 [{state}] 重置,可重新接取。");
|
||
}
|
||
else
|
||
{
|
||
Debug.LogWarning($"[QuestModule] IQuestManager 未实现 IQuestDebugger,无法重置任务 '{quest.questId}'。");
|
||
}
|
||
#endif
|
||
break;
|
||
}
|
||
}
|
||
|
||
// ── 批量验证 ─────────────────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// 遍历所有 QuestSO,执行以下检查并汇总结果:
|
||
/// 1. questId 为空
|
||
/// 2. questId 重复
|
||
/// 3. objectives 为空(无目标任务)
|
||
/// 4. prerequisiteQuests 含空引用
|
||
/// 5. 前置任务循环依赖(DFS)
|
||
/// 6. canFail=true 但 failCondition 为空
|
||
/// 7. reward.affinityBonus != 0 但 giverNpcId 为空(好感度会丢失)
|
||
/// 8. TriggerZone ↔ ReachAreaObjective markerTag 孤儿交叉检测
|
||
/// 9. 同任务内 objectiveId 重复(运行时 compositeKey 碰撞)
|
||
/// 10. branches[i].conditionFlags 含空白字符串(策划配置遗漏 flag 名)
|
||
/// 11. reward.itemIds 含空白字符串或无对应 Collectible 预制件(孤儿奖励 ID)
|
||
/// 结果在可交互的 QuestValidationResultWindow 中展示,每项问题附"选中"按钮可一键定位资产。
|
||
/// </summary>
|
||
private static void ValidateAllQuests()
|
||
{
|
||
var allQuests = AssetOperations.FindAll<QuestSO>();
|
||
var issues = new List<QuestValidationResultWindow.Issue>();
|
||
int errorCount = 0, warnCount = 0;
|
||
|
||
void AddError(string msg, UnityEngine.Object asset = null)
|
||
{
|
||
issues.Add(new QuestValidationResultWindow.Issue { message = msg, isError = true, asset = asset });
|
||
errorCount++;
|
||
}
|
||
void AddWarn(string msg, UnityEngine.Object asset = null)
|
||
{
|
||
issues.Add(new QuestValidationResultWindow.Issue { message = msg, isError = false, asset = asset });
|
||
warnCount++;
|
||
}
|
||
|
||
var idMap = ValidateIds(allQuests, AddError);
|
||
ValidateStructure(allQuests, idMap, AddError, AddWarn);
|
||
ValidateTriggerZones(AddWarn);
|
||
ValidateObjectiveIds(allQuests, AddError);
|
||
ValidateBranchFlags(allQuests, AddWarn);
|
||
ValidateRewards(allQuests, AddWarn);
|
||
|
||
Debug.Log($"[QuestModule] 验证完成:{allQuests.Count} 个任务,{errorCount} 个错误,{warnCount} 个警告。");
|
||
QuestValidationResultWindow.Show(issues, errorCount, warnCount, allQuests.Count, "任务批量验证结果", "任务");
|
||
}
|
||
|
||
// 检查 1 & 2:空 questId / 重复 questId;返回 id→SO 映射供后续检查使用
|
||
private static Dictionary<string, QuestSO> ValidateIds(
|
||
List<QuestSO> allQuests,
|
||
System.Action<string, UnityEngine.Object> addError)
|
||
{
|
||
var idMap = new Dictionary<string, QuestSO>(StringComparer.Ordinal);
|
||
foreach (var q in allQuests)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(q.questId))
|
||
{
|
||
addError($"{q.name}: questId 为空,任务无法被系统引用。", q);
|
||
continue;
|
||
}
|
||
if (idMap.TryGetValue(q.questId, out var existing))
|
||
addError($"重复 questId \"{q.questId}\":{q.name} 与 {existing.name}", q);
|
||
else
|
||
idMap[q.questId] = q;
|
||
}
|
||
return idMap;
|
||
}
|
||
|
||
// 检查 3–7:结构完整性(无目标、空引用前置、循环依赖、canFail 配置、好感度配置)
|
||
private static void ValidateStructure(
|
||
List<QuestSO> allQuests,
|
||
Dictionary<string, QuestSO> idMap,
|
||
System.Action<string, UnityEngine.Object> addError,
|
||
System.Action<string, UnityEngine.Object> addWarn)
|
||
{
|
||
foreach (var q in allQuests)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(q.questId)) continue;
|
||
|
||
if (q.objectives == null || q.objectives.Length == 0)
|
||
addWarn($"{q.questId}: objectives 为空,任务无任何目标。", q);
|
||
|
||
if (q.prerequisiteQuests != null)
|
||
foreach (var pre in q.prerequisiteQuests)
|
||
if (pre == null) { addWarn($"{q.questId}: prerequisiteQuests 含空引用,请清理 Inspector 中的空槽。", q); break; }
|
||
|
||
if (HasCircularPrerequisite(q, idMap, new HashSet<string>(StringComparer.Ordinal)))
|
||
addError($"{q.questId}: 前置任务链存在循环依赖,将导致任务永远无法变为 Available!", q);
|
||
|
||
if (q.canFail && q.failCondition == null)
|
||
addWarn($"{q.questId}: canFail=true 但 failCondition 为空,失败条件永不触发。", q);
|
||
|
||
if (q.reward != null && q.reward.affinityBonus != 0 && string.IsNullOrEmpty(q.GiverNpcId))
|
||
addWarn($"{q.questId}: reward.affinityBonus={q.reward.affinityBonus} 但 GiverNpcId 为空,好感度增量将丢失。", q);
|
||
}
|
||
}
|
||
|
||
// 检查 8:TriggerZone ↔ ReachAreaObjective markerTag 孤儿交叉检测
|
||
private static void ValidateTriggerZones(System.Action<string, UnityEngine.Object> addWarn)
|
||
{
|
||
var reachTagToSO = new Dictionary<string, BaseGames.Quest.ReachAreaObjective>(StringComparer.Ordinal);
|
||
foreach (var obj in AssetOperations.FindAll<BaseGames.Quest.ReachAreaObjective>())
|
||
if (!string.IsNullOrEmpty(obj.markerTag))
|
||
reachTagToSO[obj.markerTag] = obj;
|
||
|
||
var triggerTagToPrefab = new Dictionary<string, GameObject>(StringComparer.Ordinal);
|
||
foreach (var guid in AssetDatabase.FindAssets("t:Prefab"))
|
||
{
|
||
var prefabPath = AssetDatabase.GUIDToAssetPath(guid);
|
||
var prefabGo = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath);
|
||
if (prefabGo == null) continue;
|
||
foreach (var zone in prefabGo.GetComponentsInChildren<BaseGames.World.TriggerZone>(true))
|
||
if (!string.IsNullOrEmpty(zone.MarkerTag))
|
||
triggerTagToPrefab.TryAdd(zone.MarkerTag, prefabGo);
|
||
}
|
||
|
||
foreach (var (tag, so) in reachTagToSO)
|
||
if (!triggerTagToPrefab.ContainsKey(tag))
|
||
addWarn($"ReachAreaObjective.markerTag=\"{tag}\" 无对应 Prefab 中的 TriggerZone(孤儿目标 Tag)。", so);
|
||
|
||
foreach (var (tag, prefab) in triggerTagToPrefab)
|
||
if (!reachTagToSO.ContainsKey(tag))
|
||
addWarn($"TriggerZone.markerTag=\"{tag}\" 无对应 ReachAreaObjective(孤儿触发器 Tag)。", prefab);
|
||
}
|
||
|
||
// 检查 9:同任务内 objectiveId 重复
|
||
private static void ValidateObjectiveIds(
|
||
List<QuestSO> allQuests,
|
||
System.Action<string, UnityEngine.Object> addError)
|
||
{
|
||
foreach (var q in allQuests)
|
||
{
|
||
if (q.objectives == null || q.objectives.Length == 0) continue;
|
||
var seenIds = new HashSet<string>(StringComparer.Ordinal);
|
||
foreach (var obj in q.objectives)
|
||
{
|
||
if (obj == null || string.IsNullOrEmpty(obj.objectiveId)) continue;
|
||
if (!seenIds.Add(obj.objectiveId))
|
||
addError($"任务 '{q.questId}' 存在重复 objectiveId '{obj.objectiveId}',运行时状态将互串。", q);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 检查 10:branches[i].conditionFlags 含空白字符串
|
||
private static void ValidateBranchFlags(
|
||
List<QuestSO> allQuests,
|
||
System.Action<string, UnityEngine.Object> addWarn)
|
||
{
|
||
foreach (var q in allQuests)
|
||
{
|
||
if (q.branches == null || q.branches.Length == 0) continue;
|
||
for (int bi = 0; bi < q.branches.Length; bi++)
|
||
{
|
||
var branch = q.branches[bi];
|
||
if (branch.conditionFlags == null || branch.conditionFlags.Length == 0) continue;
|
||
for (int fi = 0; fi < branch.conditionFlags.Length; fi++)
|
||
if (string.IsNullOrWhiteSpace(branch.conditionFlags[fi]))
|
||
addWarn($"任务 '{q.questId}' 分支[{bi}].conditionFlags[{fi}] 为空白字符串,运行时将被跳过,请检查是否遗漏标志名。", q);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 检查 11:reward.itemIds 含空白字符串或无对应 Collectible 预制件
|
||
private static void ValidateRewards(
|
||
List<QuestSO> allQuests,
|
||
System.Action<string, UnityEngine.Object> addWarn)
|
||
{
|
||
var knownIds = new HashSet<string>(StringComparer.Ordinal);
|
||
foreach (var guid in AssetDatabase.FindAssets("t:Prefab"))
|
||
{
|
||
var prefabPath = AssetDatabase.GUIDToAssetPath(guid);
|
||
var go = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath);
|
||
if (go == null) continue;
|
||
var col = go.GetComponent<BaseGames.World.Collectible>();
|
||
if (col == null) continue;
|
||
var so = new UnityEditor.SerializedObject(col);
|
||
var idProp = so.FindProperty("_collectibleId") ?? so.FindProperty("collectibleId");
|
||
if (idProp != null && !string.IsNullOrEmpty(idProp.stringValue))
|
||
knownIds.Add(idProp.stringValue);
|
||
}
|
||
|
||
foreach (var q in allQuests)
|
||
{
|
||
if (q.reward == null || q.reward.itemIds == null) continue;
|
||
for (int ii = 0; ii < q.reward.itemIds.Length; ii++)
|
||
{
|
||
var itemId = q.reward.itemIds[ii];
|
||
if (string.IsNullOrWhiteSpace(itemId))
|
||
addWarn($"任务 '{q.questId}' reward.itemIds[{ii}] 为空白字符串,将被跳过。", q);
|
||
else if (knownIds.Count > 0 && !knownIds.Contains(itemId))
|
||
addWarn($"任务 '{q.questId}' reward.itemIds[{ii}]=\"{itemId}\" 在项目 Prefab 中无对应 Collectible,奖励可能无效。", q);
|
||
}
|
||
}
|
||
}
|
||
|
||
private static bool HasCircularPrerequisite(QuestSO start, Dictionary<string, QuestSO> idMap,
|
||
HashSet<string> visited)
|
||
{
|
||
if (!visited.Add(start.questId)) return true;
|
||
if (start.prerequisiteQuests == null) return false;
|
||
foreach (var pre in start.prerequisiteQuests)
|
||
{
|
||
if (pre == null || string.IsNullOrEmpty(pre.questId)) continue;
|
||
if (!idMap.TryGetValue(pre.questId, out var preQuest)) continue;
|
||
if (HasCircularPrerequisite(preQuest, idMap, visited)) return true;
|
||
}
|
||
visited.Remove(start.questId);
|
||
return false;
|
||
}
|
||
|
||
// ── 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"
|
||
};
|
||
}
|
||
}
|