diff --git a/Assets/_Game/Data/Dialogue.meta b/Assets/_Game/Data/Dialogue.meta new file mode 100644 index 0000000..3dd000f --- /dev/null +++ b/Assets/_Game/Data/Dialogue.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ad561a9e6beaaf04aa0aae9ea4cc7840 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Data/Dialogue/DLG_New.asset b/Assets/_Game/Data/Dialogue/DLG_New.asset new file mode 100644 index 0000000..e9d8919 --- /dev/null +++ b/Assets/_Game/Data/Dialogue/DLG_New.asset @@ -0,0 +1,17 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 037a9d55368dde649ac6c1c6a1e80dad, type: 3} + m_Name: DLG_New + m_EditorClassIdentifier: + sequenceId: + lines: [] + variants: [] diff --git a/Assets/_Game/Data/Dialogue/DLG_New.asset.meta b/Assets/_Game/Data/Dialogue/DLG_New.asset.meta new file mode 100644 index 0000000..455c6f8 --- /dev/null +++ b/Assets/_Game/Data/Dialogue/DLG_New.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 544a0224ccca01d45b8cd8c543b73d06 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scenes/Testings/TestRoomA.unity b/Assets/_Game/Scenes/Testings/TestRoomA.unity index 9dabd55..17cf562 100644 --- a/Assets/_Game/Scenes/Testings/TestRoomA.unity +++ b/Assets/_Game/Scenes/Testings/TestRoomA.unity @@ -21339,6 +21339,7 @@ GameObject: - component: {fileID: 1354690328} - component: {fileID: 1354690327} - component: {fileID: 1354690326} + - component: {fileID: 1354690329} m_Layer: 8 m_Name: Square (1) m_TagString: Untagged @@ -21458,6 +21459,32 @@ Transform: m_Children: [] m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!114 &1354690329 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1354690325} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 87d47b3e0cb42914b8b2ae885bebf30b, type: 3} + m_Name: + m_EditorClassIdentifier: + costOverride: -1 + linkType: 1 + clearance: 2 + navTag: 0 + avgWaitTime: 0 + maxTraversableDistance: 0 + autoMap: 1 + start: {x: -2, y: 0} + goal: {x: 2, y: 0} + isBidirectional: 1 + visualizationType: 5 + traversalAngle: 0 + horizontalSpeed: 1 + bezierControlPoint: {x: 0, y: 3} --- !u!1 &1357827205 GameObject: m_ObjectHideFlags: 0 diff --git a/Assets/_Game/Scripts/Core/Events/NpcDialogueChangeEventChannelSO.cs b/Assets/_Game/Scripts/Core/Events/NpcDialogueChangeEventChannelSO.cs new file mode 100644 index 0000000..29c3908 --- /dev/null +++ b/Assets/_Game/Scripts/Core/Events/NpcDialogueChangeEventChannelSO.cs @@ -0,0 +1,24 @@ +using UnityEngine; + +namespace BaseGames.Core.Events +{ + /// + /// NPC 对话切换事件负载(强类型,替代 "{npcId}:{sequenceId}" 字符串拼接)。 + /// + public struct NpcDialogueChangeEvent + { + /// NPC 的唯一 ID(对应 NpcSO.npcId)。 + public string npcId; + + /// 要切换到的对话序列 ID(对应 DialogueSequenceSO.sequenceId)。 + public string newSequenceId; + } + + /// + /// NPC 对话切换事件频道。 + /// 由 ChangeNPCDialogueAction 在事件链中触发;NPC 组件订阅后根据自身 npcId 过滤处理。 + /// 资产路径建议:Assets/ScriptableObjects/Events/EVT_NpcDialogueChange.asset + /// + [CreateAssetMenu(menuName = "BaseGames/Events/NpcDialogueChange")] + public class NpcDialogueChangeEventChannelSO : BaseEventChannelSO { } +} diff --git a/Assets/_Game/Scripts/Core/Events/NpcDialogueChangeEventChannelSO.cs.meta b/Assets/_Game/Scripts/Core/Events/NpcDialogueChangeEventChannelSO.cs.meta new file mode 100644 index 0000000..76f31c6 --- /dev/null +++ b/Assets/_Game/Scripts/Core/Events/NpcDialogueChangeEventChannelSO.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 111b5e123d3c3bc4ab5114666d8d2641 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Core/Events/QuestEvents.cs b/Assets/_Game/Scripts/Core/Events/QuestEvents.cs index 06776f2..e36bf92 100644 --- a/Assets/_Game/Scripts/Core/Events/QuestEvents.cs +++ b/Assets/_Game/Scripts/Core/Events/QuestEvents.cs @@ -9,7 +9,9 @@ namespace BaseGames.Core.Events Available = 1, Active = 2, Completed = 3, - Failed = 4 + Failed = 4, + /// 任务已接取但被暂停(如剧情锁定),不推进目标,不触发失败判定。 + Paused = 5, } /// @@ -23,7 +25,7 @@ namespace BaseGames.Core.Events } /// - /// 任务目标进度事件负载。 + /// 任务目标进度事件负载(单目标)。 /// [System.Serializable] public struct QuestObjectiveEvent @@ -33,4 +35,17 @@ namespace BaseGames.Core.Events public int Progress; public int Required; } + + /// + /// 同帧内某任务多个目标同时更新时的批量事件负载。 + /// 订阅此事件可在一帧内一次性处理同任务的所有目标变更,避免 UI 多次重绘。 + /// + [System.Serializable] + public struct QuestObjectiveBatchEvent + { + /// 发生目标进度变更的任务 ID。 + public string QuestId; + /// 本帧内该任务所有更新过的单目标事件列表(至少 1 个)。 + public System.Collections.Generic.List Updates; + } } diff --git a/Assets/_Game/Scripts/Core/Events/QuestObjectiveEventChannelSO.cs b/Assets/_Game/Scripts/Core/Events/QuestObjectiveEventChannelSO.cs index 1c21c90..8e28344 100644 --- a/Assets/_Game/Scripts/Core/Events/QuestObjectiveEventChannelSO.cs +++ b/Assets/_Game/Scripts/Core/Events/QuestObjectiveEventChannelSO.cs @@ -4,4 +4,13 @@ namespace BaseGames.Core.Events { [CreateAssetMenu(menuName = "BaseGames/Events/QuestObjective")] public class QuestObjectiveEventChannelSO : BaseEventChannelSO { } + + /// + /// 批量任务目标进度事件频道。 + /// 同帧内同一任务多个目标同时更新时,聚合为一次广播, + /// 供 UI 侧监听以避免同帧多次重绘任务追踪 HUD。 + /// 资产路径建议:Assets/ScriptableObjects/Events/EVT_QuestObjectiveBatchUpdated.asset + /// + [CreateAssetMenu(menuName = "BaseGames/Events/QuestObjectiveBatch")] + public class QuestObjectiveBatchEventChannelSO : BaseEventChannelSO { } } diff --git a/Assets/_Game/Scripts/Core/IWorldStateReader.cs b/Assets/_Game/Scripts/Core/IWorldStateReader.cs new file mode 100644 index 0000000..1a882cf --- /dev/null +++ b/Assets/_Game/Scripts/Core/IWorldStateReader.cs @@ -0,0 +1,12 @@ +namespace BaseGames.Core +{ + /// + /// 只读世界状态查询接口(用于对话版本条件判断)。 + /// 解耦 NarrativeNPC / DialogueVersion 对 WorldStateRegistry 具体类型的直接依赖。 + /// + public interface IWorldStateReader + { + /// 检查指定 Flag 是否已设置。 + bool HasFlag(string key); + } +} diff --git a/Assets/_Game/Scripts/Core/Save/SaveData.cs b/Assets/_Game/Scripts/Core/Save/SaveData.cs index b23b0fb..4bd7d0c 100644 --- a/Assets/_Game/Scripts/Core/Save/SaveData.cs +++ b/Assets/_Game/Scripts/Core/Save/SaveData.cs @@ -134,8 +134,19 @@ namespace BaseGames.Core.Save [Serializable] public class QuestState { + /// + /// 此 QuestState 数据格式版本号。 + /// 1 = 原始格式(ProgressCounts 按索引,已弃用) + /// 2 = Round 24+ 格式(ObjectiveProgress 按 objectiveId 键值对) + /// OnLoad 按此字段显式选择解析路径,杜绝依赖 Count > 0 的隐式推断。 + /// + public int DataVersion = 2; public string Status; // "NotStarted"|"Active"|"Completed"|"Failed" public int ObjectiveIndex; + /// 新格式(Round 24+,DataVersion=2):objectiveId → progressCount,重排目标顺序后存档不会错位。 + public Dictionary ObjectiveProgress = new(); + /// 旧格式(按数组索引,DataVersion=1):仅用于迁移旧版存档,新存档不再写入。已弃用,将在后续版本移除。 + [System.Obsolete("旧格式存档兼容字段,仅供 OnLoad DataVersion=1 迁移使用。新存档改用 ObjectiveProgress(objectiveId 键值对)。")] public List ProgressCounts = new(); public string GiverNpcId; } diff --git a/Assets/_Game/Scripts/Core/WorldFlagRegistrySO.cs b/Assets/_Game/Scripts/Core/WorldFlagRegistrySO.cs new file mode 100644 index 0000000..4fdb873 --- /dev/null +++ b/Assets/_Game/Scripts/Core/WorldFlagRegistrySO.cs @@ -0,0 +1,74 @@ +using UnityEngine; +#if UNITY_EDITOR +using UnityEditor; +#endif + +namespace BaseGames.Core +{ + /// + /// 单个世界状态标志的定义条目。 + /// + [System.Serializable] + public class FlagEntry + { + [Tooltip("标志唯一 ID,与 SetFlagAction / FlagSetCondition 中填写的字符串完全一致。")] + public string id; + + [Tooltip("描述该标志代表的游戏事件或状态(仅供编辑器参考,运行时不使用)。")] + public string description; + + [Tooltip("下拉菜单中的分组路径,使用 '/' 分隔层级,例如 '剧情/Boss'。留空则不分组。")] + public string group; + } + + /// + /// 世界状态标志注册表 —— 统一维护项目中所有合法的世界标志 ID、描述和分组。 + /// 在 Inspector 中为 [WorldStateFlag] 属性提供下拉补全,减少手输错误。 + /// 创建方式:Project 右键 → Create / BaseGames / Core / WorldFlagRegistry + /// + [CreateAssetMenu(menuName = "BaseGames/Core/WorldFlagRegistry", fileName = "WorldFlagRegistry")] + public class WorldFlagRegistrySO : ScriptableObject + { + [Tooltip("所有合法的世界状态标志定义。由策划/程序在此集中维护。")] + public FlagEntry[] flags; + +#if UNITY_EDITOR + private static WorldFlagRegistrySO _editorInstance; + private static double _editorInstanceExpiry; + + /// + /// 编辑器下 30 秒缓存的单例引用(扫描 AssetDatabase 得到)。 + /// 运行时不可用,请在 UNITY_EDITOR 条件块中调用。 + /// + public static WorldFlagRegistrySO EditorInstance + { + get + { + double now = EditorApplication.timeSinceStartup; + if (_editorInstance != null && now < _editorInstanceExpiry) + return _editorInstance; + + var guids = AssetDatabase.FindAssets("t:WorldFlagRegistrySO"); + if (guids.Length == 0) + { + _editorInstance = null; + _editorInstanceExpiry = now + 30.0; + return null; + } + + if (guids.Length > 1) + Debug.LogWarning($"[WorldFlagRegistrySO] 发现 {guids.Length} 个 WorldFlagRegistry.asset," + + "将使用第一个。建议项目中只保留一个。"); + + string path = AssetDatabase.GUIDToAssetPath(guids[0]); + _editorInstance = AssetDatabase.LoadAssetAtPath(path); + _editorInstanceExpiry = now + 30.0; + return _editorInstance; + } + } + + /// 强制下次访问 EditorInstance 时重新扫描 AssetDatabase。 + public static void InvalidateEditorCache() => _editorInstance = null; +#endif + } +} diff --git a/Assets/_Game/Scripts/Core/WorldStateFlagAttribute.cs b/Assets/_Game/Scripts/Core/WorldStateFlagAttribute.cs new file mode 100644 index 0000000..fce1f90 --- /dev/null +++ b/Assets/_Game/Scripts/Core/WorldStateFlagAttribute.cs @@ -0,0 +1,22 @@ +using UnityEngine; + +namespace BaseGames.Core +{ + /// + /// 标记一个 string 或 string[] 字段为世界状态标志 Key。 + /// 在 Inspector 中会显示已知标志下拉菜单,支持直接输入新标志。 + /// 定义于 BaseGames.Core,可被 Dialogue / Quest / EventChain 等模块无耦合使用。 + /// + public sealed class WorldStateFlagAttribute : PropertyAttribute { } + + /// + /// 世界状态标志的逻辑组合模式,供 Dialogue 条件变体和 Quest 分支条件共用。 + /// + public enum WorldStateFlagLogic + { + /// 全部 requiredFlags 均满足时条件成立(默认,向后兼容)。 + And, + /// 任意一个 requiredFlag 满足即可使条件成立。 + Or, + } +} diff --git a/Assets/_Game/Scripts/Core/WorldStateFlagAttribute.cs.meta b/Assets/_Game/Scripts/Core/WorldStateFlagAttribute.cs.meta new file mode 100644 index 0000000..ede0b9b --- /dev/null +++ b/Assets/_Game/Scripts/Core/WorldStateFlagAttribute.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e10b4c60cc9052f4e83381ceb09424a3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Dialogue/DialogueActorSO.cs.meta b/Assets/_Game/Scripts/Dialogue/DialogueActorSO.cs.meta new file mode 100644 index 0000000..48dfddb --- /dev/null +++ b/Assets/_Game/Scripts/Dialogue/DialogueActorSO.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c4ef7fae4d515f649bc8e5f51ad9510b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Dialogue/DialogueManager.cs b/Assets/_Game/Scripts/Dialogue/DialogueManager.cs index d63a6ca..5c23ed1 100644 --- a/Assets/_Game/Scripts/Dialogue/DialogueManager.cs +++ b/Assets/_Game/Scripts/Dialogue/DialogueManager.cs @@ -15,18 +15,61 @@ namespace BaseGames.Dialogue /// public class DialogueManager : MonoBehaviour, IDialogueService { + [Header("依赖")] + [Tooltip("对话 UI 组件。负责打字机效果、头像/说话人渲染、显示隐藏。")] [SerializeField] private DialogueUI _dialogueBox; + [Tooltip("输入读取器 SO。监听 SubmitEvent(确认/跳过)推进对话行。")] [SerializeField] private InputReaderSO _inputReader; + [Tooltip("世界状态注册表 SO。对话序列 variants 条件分支据此读取标志位。")] [SerializeField] private WorldStateRegistry _worldState; [Header("事件频道")] + [Tooltip("EVT_DialogueStarted:对话序列开始时广播(无 payload)。供输入系统切换 Action Map 至 UI 模式等监听。")] [SerializeField] private VoidEventChannelSO _onDialogueStarted; + [Tooltip("EVT_DialogueEnded:对话序列全部行播完后广播(无 payload)。\n" + + "【重要】输入系统应监听此事件切回 Gameplay Action Map;\n" + + "若未连接此频道,DialogueManager 会直接调用 InputReader.EnableGameplayInput() 作为兜底。")] [SerializeField] private VoidEventChannelSO _onDialogueEnded; - [SerializeField] private StringEventChannelSO _onNpcDialogueCompleted; // → EVT_NpcDialogueCompleted (npcId) - [SerializeField] private VoidEventChannelSO _onLineStarted; // 每行开始打字时广播 - [SerializeField] private VoidEventChannelSO _onLineEnded; // 每行玩家确认后广播 + [Tooltip("EVT_NpcDialogueCompleted:payload = npcId(string)。每段对话结束时广播,驱动 QuestManager 中对话类目标进度。")] + [SerializeField] private StringEventChannelSO _onNpcDialogueCompleted; + [Tooltip("EVT_LineStarted:每行对话开始打字时广播(无 payload)。供音效/震动/打字音效系统监听。")] + [SerializeField] private VoidEventChannelSO _onLineStarted; + [Tooltip("EVT_LineEnded:玩家按确认键推进到下一行时广播(无 payload)。供音效/震动系统监听。")] + [SerializeField] private VoidEventChannelSO _onLineEnded; + [Tooltip("EVT_DialogueForceEnded:对话序列因超时被强制终止时广播(payload = npcId)。\n" + + "供埋点/异常追踪系统监听,以区分正常结束和超时强制中断。\n" + + "可选字段,留空则不广播此专用事件(ForceEnd 仍正常执行)。")] + [SerializeField] private StringEventChannelSO _onDialogueForceEnded; + [Tooltip("EVT_DialogueChoiceSelected:玩家选择对话选项时广播(payload = \"sequenceId/choiceIndex\")。\n" + + "供 QA 埋点、成就系统、或数据分析监听,以还原玩家的对话选择路径。\n" + + "可选字段,留空则不广播。")] + [SerializeField] private StringEventChannelSO _onDialogueChoiceSelected; + + [Header("运行时限制")] + [Tooltip("分支选项最大嵌套深度。超过此深度触发循环引用保护,跳过当前分支继续播放。\n" + + "普通对话通常不超过 6 层;极端场景可调高,但推荐保持默认值 16。")] + [Min(1)] [SerializeField] private int _maxChoiceDepth = 16; + [Tooltip("待播对话序列队列容量上限。超过后新请求将被丢弃并记录警告。\n" + + "用于防止事件链或脚本误触导致无限排队。")] + [Min(1)] [SerializeField] private int _pendingQueueCapacity = 8; + [Tooltip("单段对话序列的最长播放时间(秒)。超时后强制结束当前序列,防止异常卡死。\n" + + "0 = 不限时(不推荐用于正式发布)。推荐 300s(5 分钟)覆盖最长剧情段落。")] + [Min(0)] [SerializeField] private float _sequenceTimeoutSeconds = 300f; private bool _skipRequested; + private int _selectedChoiceIndex = -1; + private int _choiceDepth; + /// + /// 每次 PlayImmediate 递增。HandleChoices 的选项回调在写入 _selectedChoiceIndex 前 + /// 比对此值,确保打断后遗留的回调不会污染新序列的状态。 + /// + private int _playbackId; + + // ── 子协程通信字段(避免协程间 ref/out 参数)───────────────────────── + /// HandleChoices 子协程写入结果:玩家选中选项后的后续序列(null = 无后续)。 + private DialogueSequenceSO _choiceBranchResult; + /// HandleChoices 子协程写入结果:true = 分支深度超限,优雅降级(继续播放后续行)。 + private bool _branchDepthExceeded; // ── 复用 Yield 指令,避免协程每行 new WaitUntil 闭包 ─────────────── private sealed class WaitTypingOrSkip : CustomYieldInstruction @@ -41,21 +84,34 @@ namespace BaseGames.Dialogue public WaitSkip(DialogueManager m) => _m = m; public override bool keepWaiting => !_m._skipRequested; } + // 等待玩家从分支选项中做出选择(_selectedChoiceIndex >= 0 时解除阻塞) + private sealed class WaitForChoice : CustomYieldInstruction + { + private readonly DialogueManager _m; + public WaitForChoice(DialogueManager m) => _m = m; + public override bool keepWaiting => _m._selectedChoiceIndex < 0; + } private WaitTypingOrSkip _waitTypingOrSkip; private WaitSkip _waitSkip; + private WaitForChoice _waitForChoice; /// /// 当 IsDialogueActive 时排队等待的对话请求。 /// 支持脚本触发的连续对话序列(如剧情链、事件链触发的对话), /// 但容量上限为 8,避免因误触导致无限排队。 /// - private readonly Queue<(DialogueSequenceSO seq, string npcId)> _pending = new(); - private const int PendingCapacity = 8; + private readonly Queue<(DialogueSequenceSO seq, string npcId, int priority)> _pending = new(); /// 当前是否有对话正在播放。 public bool IsDialogueActive { get; private set; } + /// 当前正在播放的对话优先级(0 = 默认)。高优先级请求可打断低优先级。 + private int _currentPriority; + + /// + public event System.Action OnDialogueEnded; + // ── 生命周期 ────────────────────────────────────────────────────── private void Awake() @@ -64,6 +120,7 @@ namespace BaseGames.Dialogue ServiceLocator.Register(this); _waitTypingOrSkip = new WaitTypingOrSkip(this); _waitSkip = new WaitSkip(this); + _waitForChoice = new WaitForChoice(this); } private void OnDestroy() @@ -79,48 +136,109 @@ namespace BaseGames.Dialogue private void OnDisable() { if (_inputReader != null) _inputReader.SubmitEvent -= OnSubmit; - _pending.Clear(); // 若对话协程在组件禁用或场景切换时仍在运行,Unity 会强制杀死协程但不调用 - // EndDialogue(),导致 Action Map 永久停留在 UI 模式。此处主动恢复。 - if (IsDialogueActive) - { - StopAllCoroutines(); - _dialogueBox?.Hide(); - IsDialogueActive = false; - _inputReader?.EnableGameplayInput(); - } + // EndDialogue(),导致 Action Map 永久停留在 UI 模式。复用 ForceEnd() 统一处理。 + ForceEnd(); } // ── 公开 API ────────────────────────────────────────────────────── /// /// 启动对话序列。 - /// 若已有对话在播放,请求会进入等待队列(上限 ), - /// 待当前对话结束后依序自动播放;超出上限的请求被丢弃。 + /// 若已有对话在播放: + /// - 当 高于当前对话优先级时,立即打断并播放新序列; + /// - 否则进入等待队列(上限 ),超出上限的请求被丢弃。 /// 由 InteractableNPC.Interact() 调用。 /// /// 要播放的对话序列 SO。 /// NPC 标识符,对话结束时随 EVT_NpcDialogueCompleted 广播。 - public void StartDialogue(DialogueSequenceSO sequence, string npcId = "") + /// 优先级。数值越大越优先;相同或更低优先级不会打断当前对话。 + public void StartDialogue(DialogueSequenceSO sequence, string npcId = "", int priority = 0) { if (sequence == null) return; if (IsDialogueActive) { - if (_pending.Count < PendingCapacity) - _pending.Enqueue((sequence, npcId)); + // 高优先级:打断当前对话,立即播放 + if (priority > _currentPriority) + { + StopAllCoroutines(); + _dialogueBox?.HideChoices(); + IsDialogueActive = false; + _skipRequested = false; + _selectedChoiceIndex = -1; + _choiceDepth = 0; + // 不清空队列,被打断的对话之后仍可继续播放 + PlayImmediate(sequence, npcId, priority); + return; + } + + if (_pending.Count < _pendingQueueCapacity) + _pending.Enqueue((sequence, npcId, priority)); +#if UNITY_EDITOR || DEVELOPMENT_BUILD + else + Debug.LogWarning( + $"[DialogueManager] 待播队列已满(容量 {_pendingQueueCapacity})," + + $"序列 '{sequence.sequenceId}' 被丢弃。可在 Inspector 中调大 _pendingQueueCapacity。"); +#endif return; } - PlayImmediate(sequence, npcId); + PlayImmediate(sequence, npcId, priority); } - private void PlayImmediate(DialogueSequenceSO sequence, string npcId) + private void PlayImmediate(DialogueSequenceSO sequence, string npcId, int priority = 0) { - IsDialogueActive = true; - _skipRequested = false; + IsDialogueActive = true; + _currentPriority = priority; + _skipRequested = false; + _selectedChoiceIndex = -1; + _choiceDepth = 0; + _playbackId++; if (_inputReader != null) _inputReader.EnableUIInput(); _onDialogueStarted?.Raise(); StartCoroutine(PlaySequence(sequence, npcId)); + // 启动超时守卫(0 = 不限时) + if (_sequenceTimeoutSeconds > 0f) + StartCoroutine(SequenceTimeoutGuard(npcId)); + } + + /// + /// 超时守卫协程:若对话在 内未正常结束, + /// 强制终止并记录错误,防止游戏卡死在对话状态。 + /// + private IEnumerator SequenceTimeoutGuard(string npcId) + { + yield return new WaitForSeconds(_sequenceTimeoutSeconds); + if (!IsDialogueActive) yield break; + Debug.LogError( + $"[DialogueManager] 对话序列 (npcId='{npcId}') 超时 {_sequenceTimeoutSeconds}s 未结束," + + "强制终止。请检查是否存在无法退出的等待分支。"); + _onDialogueForceEnded?.Raise(npcId); + ForceEnd(); + } + + /// + /// 强制立即终止当前对话,清空等待队列,恢复游戏输入。 + /// 场景切换/演出打断时调用;若无对话活跃则无操作。 + /// + public void ForceEnd() + { + if (!IsDialogueActive) return; + StopAllCoroutines(); + _playbackId++; // 使所有残余的选项回调失效,防止下一帧写入新序列状态 + _pending.Clear(); + _dialogueBox?.HideChoices(); + _dialogueBox?.Hide(); + IsDialogueActive = false; + _currentPriority = 0; + _skipRequested = false; + _selectedChoiceIndex = -1; + _choiceDepth = 0; + // 优先通过 _onDialogueEnded 事件让 InputManager 决定如何恢复输入; + // 若未连接事件频道(旧场景配置),直接恢复 Gameplay 输入作为兜底。 + _onDialogueEnded?.Raise(); + if (_onDialogueEnded == null) _inputReader?.EnableGameplayInput(); + OnDialogueEnded?.Invoke(); } // ── 输入回调 ────────────────────────────────────────────────────── @@ -129,43 +247,144 @@ namespace BaseGames.Dialogue // ── 内部协程 ────────────────────────────────────────────────────── - private IEnumerator PlaySequence(DialogueSequenceSO sequence, string npcId) + private IEnumerator PlaySequence(DialogueSequenceSO startSequence, string npcId) { - var resolved = ResolveVariant(sequence); - - if (resolved.lines == null || resolved.lines.Length == 0) + if (_dialogueBox == null) { + Debug.LogError("[DialogueManager] _dialogueBox 未配置,对话无法显示。请在 Inspector 中指定 DialogueUI 组件。", this); EndDialogue(npcId); yield break; } - foreach (var line in resolved.lines) + // 使用显式序列栈替代递归,防止深链(100+ 序列)时 C# 调用栈溢出 + var sequenceStack = new System.Collections.Generic.Stack(); + sequenceStack.Push(startSequence); + + while (sequenceStack.Count > 0) { - _skipRequested = false; - _dialogueBox.ShowLine(line); - _onLineStarted?.Raise(); + var sequence = sequenceStack.Pop(); + var resolved = ResolveVariant(sequence); - // 等待打字完成,期间允许跳过 - yield return _waitTypingOrSkip; - if (_dialogueBox.IsTyping) _dialogueBox.SkipTyping(); + if (resolved.lines == null || resolved.lines.Length == 0) + { +#if UNITY_EDITOR || DEVELOPMENT_BUILD + Debug.LogWarning( + $"[DialogueManager] 序列 '{resolved.sequenceId}' 没有对话行(lines 为空)。" + + "对话将静默跳过此序列,可能是未完成配置。"); +#endif + continue; + } - // 等待玩家按 Submit 推进下一行 - _skipRequested = false; - yield return _waitSkip; - _onLineEnded?.Raise(); + bool branchChosen = false; + foreach (var line in resolved.lines) + { + yield return StartCoroutine(PlayOneLine(line)); + + if (line.choices != null && line.choices.Length > 0) + { + yield return StartCoroutine(HandleChoices(line, resolved.sequenceId)); + + if (!_branchDepthExceeded) + { + if (_choiceBranchResult != null) + sequenceStack.Push(_choiceBranchResult); + branchChosen = true; + break; + } + // 深度超限:优雅降级,继续播放当前序列后续行 + continue; + } + + // 普通行:等待玩家按 Submit 推进 + _skipRequested = false; + yield return _waitSkip; + _onLineEnded?.Raise(); + } + + _ = branchChosen; } EndDialogue(npcId); } + /// + /// 显示一行对话并等待打字机效果完成(期间允许跳过)。 + /// 广播 EVT_LineStarted。不广播 EVT_LineEnded(由调用方在推进后广播)。 + /// + private IEnumerator PlayOneLine(DialogueLine line) + { + _skipRequested = false; + _dialogueBox.ShowLine(line); + _onLineStarted?.Raise(); + yield return _waitTypingOrSkip; + if (_dialogueBox.IsTyping) _dialogueBox.SkipTyping(); + } + + /// + /// 显示分支选项,等待玩家选择,并将结果写入 。 + /// 若选项嵌套深度超过 ,将 + /// 置为 true 并立即返回,调用方应优雅降级继续播放后续行而不是终止对话。 + /// + private IEnumerator HandleChoices(DialogueLine line, string sequenceId) + { + _choiceBranchResult = null; + _branchDepthExceeded = false; + + _choiceDepth++; + if (_choiceDepth >= _maxChoiceDepth) + { + Debug.LogError( + $"[DialogueManager] 分支对话深度超过 {_maxChoiceDepth}," + + $"序列 \"{sequenceId}\" 可能存在循环引用。" + + "已跳过当前选项分支,继续播放后续内容。"); + _dialogueBox?.HideChoices(); + _skipRequested = false; + _selectedChoiceIndex = -1; + _branchDepthExceeded = true; + yield break; + } + + // 清除打字机阶段积压的输入,防止选项显示后被立即误触发 + _skipRequested = false; + _selectedChoiceIndex = -1; + // 延迟一帧:确保此前积压的"确认键"输入在下一帧开始前已被消耗, + // 防止快速连击(先跳过打字机→再误触发选项0)的穿透问题。 + yield return null; + // 捕获当前播放标记,防止被打断后遗留回调写入新序列的选择索引 + int capturedId = _playbackId; + _dialogueBox.ShowChoices(line.choices, idx => + { + if (_playbackId == capturedId) _selectedChoiceIndex = idx; + }); + yield return _waitForChoice; + _dialogueBox.HideChoices(); + _skipRequested = false; + _onLineEnded?.Raise(); + + var chosen = line.choices[_selectedChoiceIndex]; + + // 广播选择事件(供 QA 埋点、成就系统、数据分析使用) + _onDialogueChoiceSelected?.Raise($"{sequenceId}/{_selectedChoiceIndex}"); + + // 可选:将世界状态标志写入 WorldStateRegistry + if (!string.IsNullOrEmpty(chosen.setWorldFlag) && _worldState != null) + _worldState.SetFlag(chosen.setWorldFlag); + + _choiceBranchResult = chosen.nextSequence; + } + private void EndDialogue(string npcId) { - _dialogueBox.Hide(); + _dialogueBox?.Hide(); IsDialogueActive = false; + _currentPriority = 0; - if (_inputReader != null) _inputReader.EnableGameplayInput(); - + // 优先通过 _onDialogueEnded 事件让 InputManager 决定如何恢复输入; + // 若未连接事件频道(旧场景配置),直接恢复 Gameplay 输入作为兜底。 _onDialogueEnded?.Raise(); + if (_onDialogueEnded == null) _inputReader?.EnableGameplayInput(); + + OnDialogueEnded?.Invoke(); if (!string.IsNullOrEmpty(npcId)) _onNpcDialogueCompleted?.Raise(npcId); @@ -173,52 +392,26 @@ namespace BaseGames.Dialogue // 自动播放队首等待中的对话(脚本触发的连续序列) if (_pending.Count > 0) { - var (seq, id) = _pending.Dequeue(); - PlayImmediate(seq, id); + var (seq, id, pri) = _pending.Dequeue(); + PlayImmediate(seq, id, pri); } } /// - /// 根据 ConditionalVariant 选择正确的序列版本。 - /// 按顺序检查 variants:所有 requiredFlags 均满足的第一个变体胜出(AND 关系); - /// 未注入 WorldStateRegistry 或无满足条件的变体时,返回原序列(默认)。 + /// 根据 WorldState 标志选择正确的序列版本。 + /// 委托给 统一处理,消除重复逻辑。 /// private DialogueSequenceSO ResolveVariant(DialogueSequenceSO sequence) { - if (sequence.variants == null || sequence.variants.Length == 0) - return sequence; - - if (_worldState != null) - { - foreach (var variant in sequence.variants) - { - if (variant.sequence == null) continue; - if (variant.requiredFlags == null || variant.requiredFlags.Length == 0) - return variant.sequence; // 无条件变体:直接采用 - - bool allMet = true; - foreach (var flag in variant.requiredFlags) - { - if (!string.IsNullOrEmpty(flag) && !_worldState.HasFlag(flag)) - { - allMet = false; - break; - } - } - if (allMet) return variant.sequence; - } - } + var resolved = sequence.TryGetActiveVariant(_worldState); #if UNITY_EDITOR || DEVELOPMENT_BUILD - else if (sequence.variants.Length > 0) - { + if (resolved == sequence && sequence.variants != null && sequence.variants.Length > 0 && _worldState == null) Debug.LogWarning( $"[DialogueManager] 序列 '{sequence.sequenceId}' 有 {sequence.variants.Length} 个条件变体," + "但 WorldStateRegistry 未注入,将使用默认序列。请检查 Inspector 中的 _worldState 字段。", this); - } #endif - - return sequence; + return resolved; } } } diff --git a/Assets/_Game/Scripts/Dialogue/DialogueSequenceSO.cs b/Assets/_Game/Scripts/Dialogue/DialogueSequenceSO.cs index 56c830c..9fe19c7 100644 --- a/Assets/_Game/Scripts/Dialogue/DialogueSequenceSO.cs +++ b/Assets/_Game/Scripts/Dialogue/DialogueSequenceSO.cs @@ -1,4 +1,5 @@ using UnityEngine; +using BaseGames.Core; namespace BaseGames.Dialogue { @@ -17,14 +18,23 @@ namespace BaseGames.Dialogue [Tooltip("说话人本地化 key,留空时使用 actor.nameKey")] public string speakerNameKey; + [Tooltip("对话文本本地化 Key,如 \"DLG_Elder_001\"。运行时通过 LocalizationManager.Get(textKey, \"Dialogue\") 获取实际文字。")] [TextArea(2, 5)] - public string textKey; // 本地化文本 key(如 "DLG_Elder_001") + public string textKey; [Tooltip("说话人头像,留空时使用 actor.portrait")] public Sprite portraitSprite; - public AudioClip voiceClip; // 可选语音 + [Tooltip("对应该行对话的语音片段(可选)。由 DialogueUI 通过 AudioSource 播放,打字机阶段同步开始。")] + public AudioClip voiceClip; + [Tooltip("打字机每字符延迟(秒)。0 = 使用 DialogueUI 默认值(推荐 0.03s)。\n" + + "调小 = 打字更快;调大 = 打字更慢。仅影响本行,不影响其他行。")] [Min(0.01f)] - public float typewriterDelay; // 每字符延迟(秒,0 = 使用默认 0.03f) + public float typewriterDelay; + + [Tooltip("玩家选项(可选)。有值时,本行打字机效果结束后显示选项列表,等待玩家选择。\n" + + "选择后根据 nextSequence 播放续集(或结束对话),并可选地设置 setWorldFlag 标志。\n" + + "留空 = 普通对话行,玩家按确认键推进。")] + public DialogueChoice[] choices; /// /// 获取最终使用的说话人名称 Key:actor 优先,回退到直接字段。 @@ -37,6 +47,31 @@ namespace BaseGames.Dialogue /// public Sprite ResolvedPortrait => actor != null && actor.portrait != null ? actor.portrait : portraitSprite; + + /// + /// 获取最终使用的主题颜色:actor 有值时取 actor.accentColor,否则返回 white。 + /// + public Color ResolvedAccentColor => actor != null ? actor.accentColor : Color.white; + + /// + /// 当前行是否由玩家角色说话(影响 UI 排版方向)。 + /// + public bool ResolvedIsPlayer => actor != null && actor.isPlayer; + } + + /// + /// 玩家可选的对话分支选项。 + /// 在对话行打字机效果结束后呈现给玩家,选择后播放对应续集序列或结束对话。 + /// + [System.Serializable] + public struct DialogueChoice + { + [Tooltip("选项文字本地化 Key,如 \"DLG_Choice_AcceptQuest\"。\n运行时由 LocalizationManager 解析为实际文字。")] + public string textKey; + [Tooltip("选择此选项后继续播放的对话序列(留空 = 对话立即结束)。")] + public DialogueSequenceSO nextSequence; + [Tooltip("选择此选项后设置的世界状态标志(留空 = 不修改任何标志)。\n与 nextSequence 同时生效。")] + public string setWorldFlag; } /// @@ -47,23 +82,90 @@ namespace BaseGames.Dialogue [CreateAssetMenu(menuName = "BaseGames/Dialogue/DialogueSequence")] public class DialogueSequenceSO : ScriptableObject { + [Header("标识")] + [Tooltip("序列唯一 ID,如 \"DLG_Elder_Quest_Available\"。OnValidate 会自动以资产名填充,也可手动指定。")] public string sequenceId; // 全局唯一,如 "DLG_Elder_Quest_Available" + + [Header("对话行")] + [Tooltip("按顺序播放的对话行列表。每行包含说话人(actor 优先)、文本本地化 Key、可选头像与语音。")] public DialogueLine[] lines; /// - /// 条件变体:所有 requiredFlags 均满足时替换整个序列(AND 关系)。 - /// 与 NarrativeNPC.DialogueVersion 的多条件语义保持一致。 + /// 条件变体:requiredFlags 按 logic 逻辑满足时替换整个序列。 /// [System.Serializable] public struct ConditionalVariant { - [Tooltip("全部满足时激活此变体(AND 关系)。留空表示无条件。")] + [Tooltip("条件判断逻辑:And(默认,全部满足)或 Or(任一满足)。\n" + + "先选好逻辑再填标志,阅读顺序更自然。")] + public BaseGames.Core.WorldStateFlagLogic logic; + [Tooltip("条件标志列表。logic=And 时全部满足激活;logic=Or 时任一满足激活。留空表示无条件(总是激活)。")] [WorldStateFlag] - public string[] requiredFlags; - public DialogueSequenceSO sequence; + public string[] requiredFlags; + public DialogueSequenceSO sequence; } + + [Header("条件变体(可选)")] + [Tooltip("运行时根据 WorldState 标志动态替换整个序列。按优先级从高到低排列:满足条件的第一个变体胜出。\n" + + "每个变体支持 And(全部满足)或 Or(任一满足)两种判断逻辑。\n" + + "留空表示无变体,始终使用本序列默认台词。")] public ConditionalVariant[] variants; + // ── 运行时变体解析 ───────────────────────────────────────────────────── + + /// + /// 检查单个条件变体的 requiredFlags 在给定 reader 下是否满足。 + /// 无条件(requiredFlags 为空)的变体始终返回 true。 + /// + public bool CheckVariant(ConditionalVariant variant, BaseGames.Core.IWorldStateReader reader) + { + if (variant.sequence == null) return false; + if (variant.requiredFlags == null || variant.requiredFlags.Length == 0) return true; + if (reader == null) return false; + + if (variant.logic == BaseGames.Core.WorldStateFlagLogic.Or) + { + foreach (var flag in variant.requiredFlags) + if (!string.IsNullOrEmpty(flag) && reader.HasFlag(flag)) return true; + return false; + } + else + { + foreach (var flag in variant.requiredFlags) + if (!string.IsNullOrEmpty(flag) && !reader.HasFlag(flag)) return false; + return true; + } + } + + /// + /// 根据 提供的世界状态,返回第一个满足条件的变体序列; + /// 无满足变体或 reader 为 null 时返回 this(默认序列)。 + /// 开发构建下会在控制台输出命中的变体索引和标志,方便调试。 + /// + public DialogueSequenceSO TryGetActiveVariant(BaseGames.Core.IWorldStateReader reader) + { + if (variants == null || variants.Length == 0) return this; + if (reader != null) + { + for (int i = 0; i < variants.Length; i++) + { + var variant = variants[i]; + if (!CheckVariant(variant, reader)) continue; +#if UNITY_EDITOR || DEVELOPMENT_BUILD + string matchedFlags = variant.requiredFlags != null && variant.requiredFlags.Length > 0 + ? string.Join(", ", variant.requiredFlags) + : "(无条件)"; + string targetId = variant.sequence != null ? variant.sequence.sequenceId : "null"; + Debug.Log( + $"[DialogueSequenceSO] '{sequenceId}' 选中变体[{i}]({matchedFlags})→ '{targetId}'", + this); +#endif + return variant.sequence; + } + } + return this; + } + #if UNITY_EDITOR // sequenceId → 资产路径,5 秒 TTL,跨所有 DialogueSequenceSO.OnValidate 共用, // 避免每次 Save 都重扫所有同类 SO(O(1) 路径比对代替 O(n) 全量扫描)。 @@ -109,6 +211,108 @@ namespace BaseGames.Dialogue $"'{System.IO.Path.GetFileNameWithoutExtension(existingPath)}' 重复!请修改其中一个。", this); s_seqIdsCacheTime = -10.0; } + + ValidateChoiceCycles(); + ValidateVariantOrder(); + } + + /// + /// 检查 variants 数组中是否存在"无条件变体遮蔽后续变体"的配置错误: + /// 若某变体 requiredFlags 为空(无条件)且不在数组末尾,则其后所有变体永远不会被匹配。 + /// + private void ValidateVariantOrder() + { + if (variants == null || variants.Length <= 1) return; + + for (int i = 0; i < variants.Length - 1; i++) + { + var v = variants[i]; + bool isUnconditional = v.requiredFlags == null || v.requiredFlags.Length == 0; + if (!isUnconditional) continue; + + if (v.sequence == null) continue; // 无效变体,忽略 + + Debug.LogWarning( + $"[DialogueSequenceSO] '{name}' 的 variants[{i}] 没有设置任何条件(requiredFlags 为空)," + + $"该变体将始终优先匹配,其后的 {variants.Length - 1 - i} 个变体永远不会生效。\n" + + "请将无条件变体移到数组末尾作为兜底,或为此变体添加具体条件。", this); + return; // 一次只报第一个问题 + } + } + + private void ValidateChoiceCycles() + { + if (lines == null && (variants == null || variants.Length == 0)) return; + var visited = new System.Collections.Generic.HashSet(System.StringComparer.Ordinal); + visited.Add(sequenceId); + + // 检查选项链循环 + if (lines != null) + { + foreach (var line in lines) + { + if (line.choices == null) continue; + foreach (var choice in line.choices) + { + if (choice.nextSequence == null) continue; + if (HasChoiceCycle(choice.nextSequence, visited)) + { + Debug.LogError( + $"[DialogueSequenceSO] '{name}' 的选项链存在循环引用!" + + $"序列 '{choice.nextSequence.name}' 最终指回自身或已访问序列," + + "运行时将触发递归保护(强制终止对话)。请检查 nextSequence 配置。", this); + return; + } + } + } + } + + // 检查条件变体链循环(variant.sequence 也可能引用形成环路) + if (variants != null) + { + foreach (var variant in variants) + { + if (variant.sequence == null) continue; + if (HasChoiceCycle(variant.sequence, visited)) + { + Debug.LogError( + $"[DialogueSequenceSO] '{name}' 的条件变体链存在循环引用!" + + $"变体序列 '{variant.sequence.name}' 最终指回自身或已访问序列," + + "运行时将触发递归保护(强制终止对话)。请检查 variants 配置。", this); + return; + } + } + } + } + + private static bool HasChoiceCycle(DialogueSequenceSO seq, + System.Collections.Generic.HashSet visited) + { + if (string.IsNullOrEmpty(seq.sequenceId)) return false; + if (!visited.Add(seq.sequenceId)) return true; + if (seq.lines != null) + { + foreach (var line in seq.lines) + { + if (line.choices == null) continue; + foreach (var choice in line.choices) + { + if (choice.nextSequence != null && HasChoiceCycle(choice.nextSequence, visited)) + return true; + } + } + } + // 同时遍历条件变体序列,防止变体链形成环路 + if (seq.variants != null) + { + foreach (var variant in seq.variants) + { + if (variant.sequence != null && HasChoiceCycle(variant.sequence, visited)) + return true; + } + } + visited.Remove(seq.sequenceId); + return false; } #endif } diff --git a/Assets/_Game/Scripts/Dialogue/DialogueUI.cs b/Assets/_Game/Scripts/Dialogue/DialogueUI.cs index 9d47853..1304471 100644 --- a/Assets/_Game/Scripts/Dialogue/DialogueUI.cs +++ b/Assets/_Game/Scripts/Dialogue/DialogueUI.cs @@ -1,4 +1,5 @@ using System.Collections; +using System.Collections.Generic; using System.Text; using TMPro; using UnityEngine; @@ -20,20 +21,61 @@ namespace BaseGames.Dialogue [SerializeField] private GameObject _speakerNamePanel; // 无名称时隐藏整个名称框 [SerializeField] private GameObject _continuePrompt; // "▼" 图标,打字完成后显示 [SerializeField] private Image _speakerPortrait; // 角色头像框 + [SerializeField] private Image _speakerNameBackground; // 说话人名称框背景,用于应用 accentColor(可选) [SerializeField] private AudioSource _voiceSource; // 语音播放源(可不配置) - private Coroutine _typingCoroutine; + [Header("选项系统(可选)")] + [Tooltip("选项按钮的父节点容器。ShowChoices 通过对象池激活/停用按钮,HideChoices 停用全部。\n留空则分支选项功能静默禁用。")] + [SerializeField] private Transform _choicesContainer; + [Tooltip("选项按钮预制体(需包含 Button 组件和 TMP_Text 子组件)。\n首次使用时预热 PoolInitialSize 个到对象池,后续零 GC。")] + [SerializeField] private GameObject _choiceButtonPrefab; + [Tooltip("选项按钮池初始大小。设为预期最大选项数,默认 8 覆盖绝大多数情况。")] + [SerializeField] [Range(2, 16)] private int _choicePoolSize = 8; + + // 说话人名称框背景的默认色(Awake 时记录,切换角色后可还原) + private Color _defaultNameBgColor = Color.white; + // 缓存名称框 RectTransform,避免 ShowLine 每次调用 GetComponent(零堆分配) + private RectTransform _speakerNamePanelRT; + + // 选项按钮对象池:Awake 时按 _choicePoolSize 预热,ShowChoices/HideChoices 零 GC + private readonly List<(GameObject go, Button btn, TMP_Text lbl)> _choicePool = new(); + + private Coroutine _typingCoroutine; private DialogueLine _currentLine; private const float DefaultTypewriterDelay = 0.03f; - // 缓存单个 WaitForSecondsRealtime,避免 TypeLine 每字符 new 分配。 - // 当 delay 值改变时才重新创建(不同行可能有不同打字速度)。 + // 缓存 WaitForSecondsRealtime:delay 值不变时直接复用,避免每行 new 分配。 private WaitForSecondsRealtime _cachedTypeDelay; private float _cachedTypeDelayValue = -1f; + // 缓存 StringBuilder:每行 Clear() 复用,避免每行 new StringBuilder(n) 的堆分配。 + // 初始容量 256,足以容纳绝大多数对话行,超长时会自动扩容(扩容极少发生)。 + private readonly StringBuilder _typingSB = new(256); + /// 当前是否仍在执行打字机效果。 public bool IsTyping { get; private set; } + private void Awake() + { + if (_speakerNameBackground != null) + _defaultNameBgColor = _speakerNameBackground.color; + if (_speakerNamePanel != null) + _speakerNamePanelRT = _speakerNamePanel.GetComponent(); + + // 预热选项按钮对象池:在此时创建可避免首次对话时的 Instantiate 停顿 + if (_choicesContainer != null && _choiceButtonPrefab != null) + { + for (int i = 0; i < _choicePoolSize; i++) + { + var go = Instantiate(_choiceButtonPrefab, _choicesContainer); + var btn = go.GetComponent public class InteractableNPC : MonoBehaviour, IInteractable { + [Header("NPC 基础")] + [Tooltip("NPC 唯一 ID(如 \"NPC_Elder\")。对话结束时随 EVT_NpcDialogueCompleted 广播,用于驱动对话类任务目标进度。\n" + + "需与 QuestSO 目标中 targetNpcId 保持一致。")] [SerializeField] protected string _npcId; + [Tooltip("默认对话序列。无其他逻辑覆盖时播放此序列。NarrativeNPC/QuestGiver 子类通过 GetCurrentDialogue() 返回更精确的版本。")] [SerializeField] protected DialogueSequenceSO _defaultDialogue; + [Tooltip("玩家进入此半径(单位:Unity 单位)后显示交互提示。建议 1.0–2.0。\n编辑器下在场景视图中以黄色圆圈可视化。")] [SerializeField] protected float _interactRadius = 1.5f; + [Tooltip("交互提示本地化 Key(如 \"INTERACT_Talk\")。运行时通过 LocalizationManager 解析为实际文字。\n" + + "留空时回退到内置字符串 \"对话\"。")] + [SerializeField] protected string _interactPromptKey = "INTERACT_Talk"; + + [Header("范围检测")] + [Tooltip("玩家所在的物理层。OnTriggerEnter2D / OnTriggerExit2D 仅响应属于此层的碰撞体,\n" + + "实现 NPC 自包含的交互范围检测,无需外部 PlayerInteractionDetector 组件。\n" + + "将玩家 GameObject 的 Layer 与此 Mask 对齐即可(推荐专用 \"Player\" 层)。\n" + + "若留空(值为 0),则跳过层级过滤,任意碰撞体均可触发(调试用,不推荐上线)。")] + [SerializeField] protected LayerMask _playerLayer; // ── IInteractable ────────────────────────────────────────────────── - public virtual bool CanInteract => true; - public virtual string InteractPrompt => "对话"; + public virtual bool CanInteract => true; + + public virtual string InteractPrompt + { + get + { + if (!string.IsNullOrEmpty(_interactPromptKey)) + { + var resolved = LocalizationManager.Get(_interactPromptKey, "UI"); + if (!string.IsNullOrEmpty(resolved)) return resolved; + } + return "对话"; + } + } + + // ── 范围进出通知(供子组件订阅,如 InteractionPromptController)────── + /// 玩家进入交互范围时触发。参数为玩家 Transform。 + public event System.Action PlayerEnteredRange; + /// 玩家离开交互范围时触发。 + public event System.Action PlayerExitedRange; public void Interact(Transform player) { @@ -27,8 +61,24 @@ namespace BaseGames.Dialogue PlayDialogue(dialogue, player); } - public virtual void OnPlayerEnterRange(Transform player) { } - public virtual void OnPlayerExitRange() { } + public virtual void OnPlayerEnterRange(Transform player) { PlayerEnteredRange?.Invoke(player); } + public virtual void OnPlayerExitRange() { PlayerExitedRange?.Invoke(); } + + // ── 自包含物理范围检测 ───────────────────────────────────────────── + // 需在 NPC Prefab 上挂载 Collider2D(设为 IsTrigger),并将 Collider2D.size/radius + // 配置为期望的交互半径。OnTriggerEnter2D / Exit2D 会自动过滤非玩家碰撞体。 + + private void OnTriggerEnter2D(Collider2D other) + { + if (_playerLayer.value != 0 && ((1 << other.gameObject.layer) & _playerLayer.value) == 0) return; + OnPlayerEnterRange(other.transform); + } + + private void OnTriggerExit2D(Collider2D other) + { + if (_playerLayer.value != 0 && ((1 << other.gameObject.layer) & _playerLayer.value) == 0) return; + OnPlayerExitRange(); + } // ── 子类覆盖点 ────────────────────────────────────────────────────── @@ -51,5 +101,42 @@ namespace BaseGames.Dialogue } manager.StartDialogue(sequence, _npcId); } + + // ── 编辑器辅助 ──────────────────────────────────────────────────── + // 注意:OnValidate 声明在 #if 外,确保子类在非编辑器构建中调用 base.OnValidate() 不会编译失败。 + + protected virtual void OnValidate() + { +#if UNITY_EDITOR + if (_playerLayer.value == 0) + Debug.LogWarning( + $"[InteractableNPC:{name}] _playerLayer 未设置(value=0)。" + + "OnTriggerEnter2D 将响应所有层,建议在 Inspector 中指定玩家所在层。", this); + + // 检测 _interactRadius 与 CircleCollider2D.radius 是否同步(仅输出一次,非逐帧) + var circle = GetComponent(); + if (circle != null && !Mathf.Approximately(circle.radius, _interactRadius)) + Debug.LogWarning( + $"[InteractableNPC:{name}] _interactRadius({_interactRadius:F2}) 与 " + + $"CircleCollider2D.radius({circle.radius:F2}) 不一致," + + "交互范围视觉(Gizmos)与物理碰撞可能不匹配,请手动对齐。", this); +#endif + } + +#if UNITY_EDITOR + protected virtual void OnDrawGizmosSelected() + { + // Collider2D 不一致时改绘红色(警告已在 OnValidate 中输出,此处不重复 LogWarning) + bool mismatch = false; + var circle = GetComponent(); + if (circle != null && !Mathf.Approximately(circle.radius, _interactRadius)) + mismatch = true; + + UnityEditor.Handles.color = mismatch + ? new Color(1f, 0.2f, 0.2f, 0.8f) + : new Color(1f, 0.92f, 0.016f, 0.6f); + UnityEditor.Handles.DrawWireDisc(transform.position, Vector3.forward, _interactRadius); + } +#endif } } diff --git a/Assets/_Game/Scripts/Dialogue/InteractionPromptController.cs b/Assets/_Game/Scripts/Dialogue/InteractionPromptController.cs index 127fb3d..6e9df5c 100644 --- a/Assets/_Game/Scripts/Dialogue/InteractionPromptController.cs +++ b/Assets/_Game/Scripts/Dialogue/InteractionPromptController.cs @@ -1,3 +1,4 @@ +using TMPro; using UnityEngine; using UnityEngine.InputSystem; using UnityEngine.UI; @@ -5,34 +6,144 @@ using UnityEngine.UI; namespace BaseGames.Dialogue { /// - /// 交互提示 UI 控制器(架构 14_NarrativeModule §2)。 - /// 挂载在每个 IInteractable GameObject 的子节点(Prefab 实例),默认隐藏。 - /// 根据当前活跃输入设备自动切换图标(键盘/手柄)。 + /// 世界空间交互提示控制器(架构 14_NarrativeModule §2 升级版)。 + /// 挂在每个 InteractableNPC 子节点(Prefab 实例),默认隐藏。 + /// + /// 功能: + /// • 自动订阅父级 InteractableNPC 的进/出范围事件,免手动调用 Show/Hide + /// • TMP_Text 实时显示 InteractPrompt(如"接受任务"/"提交任务"),随任务状态动态刷新 + /// • 根据当前活跃输入设备自动切换按键图标(键盘/手柄) + /// • 支持淡入/淡出动画 /// public class InteractionPromptController : MonoBehaviour { + [Header("UI 引用")] + [Tooltip("整个提示根节点(包含图标和文字),控制显示/隐藏。")] [SerializeField] private GameObject _promptRoot; + [Tooltip("按键图标 Image 组件(可选)。有输入设备时显示对应图标。")] [SerializeField] private Image _icon; - [SerializeField] private Sprite _keyboardIcon; - [SerializeField] private Sprite _gamepadIcon; + [Tooltip("提示文字 TMP_Text 组件(可选)。自动显示 InteractableNPC.InteractPrompt 的当前值。")] + [SerializeField] private TMP_Text _label; + + [Header("按键图标")] + [Tooltip("键盘/鼠标设备激活时使用的按键图标 Sprite。")] + [SerializeField] private Sprite _keyboardIcon; + [Tooltip("手柄设备激活时使用的按键图标 Sprite。")] + [SerializeField] private Sprite _gamepadIcon; + + [Header("位置与动画")] + [Tooltip("相对于本组件 transform 的世界空间偏移。调整此值可控制气泡与 NPC 的相对位置。")] + [SerializeField] private Vector3 _offset = new Vector3(0f, 1.8f, 0f); + [Tooltip("是否随相机方向 Billboard 朝向(世界空间 Canvas 推荐开启)。")] + [SerializeField] private bool _billboard = true; + [Tooltip("淡入持续时间(秒)。0 = 立即显示,无动画。")] + [SerializeField] [Min(0f)] private float _fadeInDuration = 0.12f; + [Tooltip("淡出持续时间(秒)。0 = 立即隐藏,无动画。")] + [SerializeField] [Min(0f)] private float _fadeOutDuration = 0.08f; + + // ── 运行时状态 ──────────────────────────────────────────────────────── + private InteractableNPC _npc; + private bool _visible; + private float _alpha; + private Camera _cam; + + // ── Lifecycle ───────────────────────────────────────────────────────── private void Awake() { - if (_promptRoot != null) _promptRoot.SetActive(false); + // 自动连接父级 InteractableNPC 事件(无需手动调用 Show/Hide) + _npc = GetComponentInParent(); + if (_npc != null) + { + _npc.PlayerEnteredRange += OnPlayerEntered; + _npc.PlayerExitedRange += OnPlayerExited; + } + + SetVisible(false, immediate: true); } - /// 显示交互提示,根据输入设备选择图标。 + private void OnDestroy() + { + if (_npc != null) + { + _npc.PlayerEnteredRange -= OnPlayerEntered; + _npc.PlayerExitedRange -= OnPlayerExited; + } + } + + private void Update() + { + // 位置偏移(世界空间气泡) + if (_offset != Vector3.zero) + transform.position = (_npc != null ? _npc.transform.position : transform.parent.position) + _offset; + + // Billboard + if (_billboard && _visible) + { + if (_cam == null) _cam = Camera.main; + if (_cam != null) + transform.forward = _cam.transform.forward; + } + + // 淡入/淡出 + if (_promptRoot == null) return; + if (_visible && _alpha < 1f) + { + float speed = _fadeInDuration > 0f ? Time.deltaTime / _fadeInDuration : 1f; + _alpha = Mathf.MoveTowards(_alpha, 1f, speed); + ApplyAlpha(_alpha); + } + else if (!_visible && _alpha > 0f) + { + float speed = _fadeOutDuration > 0f ? Time.deltaTime / _fadeOutDuration : 1f; + _alpha = Mathf.MoveTowards(_alpha, 0f, speed); + ApplyAlpha(_alpha); + if (_alpha <= 0f) _promptRoot.SetActive(false); + } + } + + // ── 公开 API(兼容旧调用 / 脚本手动控制)──────────────────────────── + + /// 手动显示提示。通常由 InteractableNPC 自动调用,无需手动触发。 public void Show() { - if (_promptRoot == null) return; - _promptRoot.SetActive(true); + if (_npc != null) _label.text = _npc.InteractPrompt; + SetVisible(true, immediate: false); UpdateIcon(); } - /// 隐藏交互提示。 - public void Hide() + /// 手动隐藏提示。通常由 InteractableNPC 自动调用,无需手动触发。 + public void Hide() => SetVisible(false, immediate: false); + + // ── 私有辅助 ───────────────────────────────────────────────────────── + + private void OnPlayerEntered(Transform player) { - if (_promptRoot != null) _promptRoot.SetActive(false); + // 刷新文字(每次进入都读取最新 InteractPrompt,确保任务状态变化后文字正确) + if (_label != null && _npc != null) + _label.text = _npc.InteractPrompt; + SetVisible(true, immediate: false); + UpdateIcon(); + } + + private void OnPlayerExited() => SetVisible(false, immediate: false); + + private void SetVisible(bool show, bool immediate) + { + _visible = show; + if (immediate) + { + _alpha = show ? 1f : 0f; + if (_promptRoot != null) + { + _promptRoot.SetActive(show); + ApplyAlpha(_alpha); + } + } + else if (show && _promptRoot != null) + { + _promptRoot.SetActive(true); // 淡出由 Update 结束时 SetActive(false) + } } private void UpdateIcon() @@ -41,5 +152,12 @@ namespace BaseGames.Dialogue bool isGamepad = Gamepad.current != null && Gamepad.current.enabled; _icon.sprite = isGamepad ? _gamepadIcon : _keyboardIcon; } + + private void ApplyAlpha(float a) + { + if (_icon != null) { var c = _icon.color; c.a = a; _icon.color = c; } + if (_label != null) { var c = _label.color; c.a = a; _label.color = c; } + } } } + diff --git a/Assets/_Game/Scripts/Dialogue/NarrativeNPC.cs b/Assets/_Game/Scripts/Dialogue/NarrativeNPC.cs index 0bf4984..056f40e 100644 --- a/Assets/_Game/Scripts/Dialogue/NarrativeNPC.cs +++ b/Assets/_Game/Scripts/Dialogue/NarrativeNPC.cs @@ -1,4 +1,5 @@ using System; +using BaseGames.Core; using BaseGames.World; using UnityEngine; @@ -6,23 +7,35 @@ namespace BaseGames.Dialogue { /// /// 条件对话 NPC(架构 14_NarrativeModule §7)。 - /// 扩展 InteractableNPC,根据 WorldStateRegistry 标志动态选择对话版本。 + /// 扩展 InteractableNPC,根据世界状态标志动态选择对话版本。 /// 版本列表从高到低优先级排列;第一个满足条件的版本生效。 + /// + /// _worldState 可留空:留空时自动从 ServiceLocator 获取全局注册的 IWorldStateReader, + /// 便于无需在每个 NPC Prefab 上手动拖入 WorldStateRegistry 资产。 /// public class NarrativeNPC : InteractableNPC { [Header("台词版本集(从高到低优先级排列)")] + [Tooltip("条件对话版本列表。运行时从上到下检查,第一个满足条件的版本被播放。\n" + + "版本之间的优先级由列表顺序决定——请将最具体的条件放在最上方。")] [SerializeField] private DialogueVersion[] _dialogueVersions; - [SerializeField] private DialogueSequenceSO _fallbackDialogue; // 无条件满足时的兜底台词 - [SerializeField] private WorldStateRegistry _worldState; // SO 注入 + [Tooltip("所有版本均不满足条件时的兜底对话。务必配置,否则运行时会输出 LogWarning 且 NPC 无对话。")] + [SerializeField] private DialogueSequenceSO _fallbackDialogue; + [Tooltip("世界状态 SO(可选)。留空时自动从 ServiceLocator 获取全局 IWorldStateReader。\n" + + "通常同场景下多个 NPC 共用同一个 WorldStateRegistry;\n" + + "若全局已通过 ServiceLocator 注册,可不在此处手动指定。")] + [SerializeField] private WorldStateRegistry _worldState; protected override DialogueSequenceSO GetCurrentDialogue() { + IWorldStateReader reader = _worldState + ?? ServiceLocator.GetOrDefault(); + if (_dialogueVersions == null) return _fallbackDialogue; foreach (var version in _dialogueVersions) { - if (version != null && version.CheckConditions(_worldState)) + if (version != null && version.CheckConditions(reader)) return version.dialogue; } @@ -43,6 +56,7 @@ namespace BaseGames.Dialogue { [Tooltip("编辑器显示名,如'森林 Boss 击败后'")] public string versionLabel; + [Tooltip("此版本对应的对话序列 SO。条件满足时播放。留空时等同于跳过此版本。")] public DialogueSequenceSO dialogue; [Tooltip("全部满足才激活此版本(AND 关系)")] @@ -53,18 +67,21 @@ namespace BaseGames.Dialogue [WorldStateFlag] public string[] blockedByFlags; - /// 检查此版本的激活条件(AND requiredFlags / NOT blockedByFlags)。 - public bool CheckConditions(WorldStateRegistry registry) + /// + /// 检查此版本的激活条件(AND requiredFlags / NOT blockedByFlags)。 + /// reader 为 null 时直接返回 false(无法判断,视为条件不满足)。 + /// + public bool CheckConditions(IWorldStateReader reader) { - if (registry == null) return false; + if (reader == null) return false; if (requiredFlags != null) foreach (var f in requiredFlags) - if (!registry.HasFlag(f)) return false; + if (!reader.HasFlag(f)) return false; if (blockedByFlags != null) foreach (var f in blockedByFlags) - if (registry.HasFlag(f)) return false; + if (reader.HasFlag(f)) return false; return true; } diff --git a/Assets/_Game/Scripts/Dialogue/NpcSO.cs b/Assets/_Game/Scripts/Dialogue/NpcSO.cs new file mode 100644 index 0000000..bc71b36 --- /dev/null +++ b/Assets/_Game/Scripts/Dialogue/NpcSO.cs @@ -0,0 +1,86 @@ +using UnityEngine; + +namespace BaseGames.Dialogue +{ + /// + /// NPC 元数据资产(架构 14_NarrativeModule §2)。 + /// 将 NPC 的唯一 ID、本地化名称 Key、头像、好感度上限集中在一处管理。 + /// + /// 关联: + /// • 通过 _npcId 字段与此 SO 对应。 + /// • 管理对话 UI 侧头像/颜色(二者可共享同一 Sprite,或独立维护)。 + /// • giverNpc 字段直接引用此 SO,避免手填字符串。 + /// + /// 资产路径:Assets/_Game/Data/NPC/NPC_{npcId}.asset + /// + [CreateAssetMenu(menuName = "BaseGames/NPC/NPC")] + public class NpcSO : ScriptableObject + { + [Header("标识")] + [Tooltip("NPC 唯一 ID,如 \"NPC_Elder\"。需与 InteractableNPC._npcId、QuestSO.giverNpcId 保持一致。")] + public string npcId; + + [Header("显示")] + [Tooltip("本地化 Key,如 \"NPC_Elder_Name\"。通过 LocalizationManager 解析为实际名称。")] + public string nameKey; + [Tooltip("NPC 头像,用于地图、任务日志、DataHub 等 UI。")] + public Sprite portrait; + + [Header("好感度")] + [Tooltip("该 NPC 的好感度上限(0 = 无上限)。\n" + + "QuestManager.CompleteQuest 发放 affinityBonus 时,不超过此数值。\n" + + "UI 侧可用此值绘制好感度进度条满格。")] + [Min(0)] public int maxAffinity = 0; + + [Header("交互提示")] + [Tooltip("与此 NPC 交互时显示的提示本地化 Key(如 \"INTERACT_Talk\")。\n" + + "留空时 InteractableNPC 回退到内置字符串 \"对话\"。")] + public string interactPromptKey = "INTERACT_Talk"; + +#if UNITY_EDITOR + // npcId → 资产路径,5 秒 TTL,跨所有 NpcSO.OnValidate 共用,O(1) 重复检测。 + private static System.Collections.Generic.Dictionary s_npcIdToPath; + private static double s_npcIdsCacheTime = -10.0; + + private static System.Collections.Generic.Dictionary GetNpcIdCache() + { + double now = UnityEditor.EditorApplication.timeSinceStartup; + if (s_npcIdToPath != null && now - s_npcIdsCacheTime < 5.0) + return s_npcIdToPath; + + s_npcIdToPath = new System.Collections.Generic.Dictionary(System.StringComparer.Ordinal); + string[] guids = UnityEditor.AssetDatabase.FindAssets("t:NpcSO"); + foreach (var guid in guids) + { + var path = UnityEditor.AssetDatabase.GUIDToAssetPath(guid); + var npc = UnityEditor.AssetDatabase.LoadAssetAtPath(path); + if (npc != null && !string.IsNullOrEmpty(npc.npcId) && !s_npcIdToPath.ContainsKey(npc.npcId)) + s_npcIdToPath[npc.npcId] = path; + } + s_npcIdsCacheTime = now; + return s_npcIdToPath; + } + + private void OnValidate() + { + if (string.IsNullOrWhiteSpace(npcId)) + { + UnityEditor.EditorUtility.SetDirty(this); + npcId = name; + } + + var cache = GetNpcIdCache(); + string myPath = UnityEditor.AssetDatabase.GetAssetPath(this); + if (!string.IsNullOrEmpty(myPath) && + cache.TryGetValue(npcId, out var existingPath) && + existingPath != myPath) + { + Debug.LogError( + $"[NpcSO] npcId '{npcId}' 与 " + + $"'{System.IO.Path.GetFileNameWithoutExtension(existingPath)}' 重复!请修改其中一个。", this); + s_npcIdsCacheTime = -10.0; + } + } +#endif + } +} diff --git a/Assets/_Game/Scripts/Dialogue/NpcSO.cs.meta b/Assets/_Game/Scripts/Dialogue/NpcSO.cs.meta new file mode 100644 index 0000000..45c268f --- /dev/null +++ b/Assets/_Game/Scripts/Dialogue/NpcSO.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7a534ec2815a6bd4ebd50cf4b7bccf3e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Dialogue/WorldStateFlagAttribute.cs b/Assets/_Game/Scripts/Dialogue/WorldStateFlagAttribute.cs deleted file mode 100644 index 5b63591..0000000 --- a/Assets/_Game/Scripts/Dialogue/WorldStateFlagAttribute.cs +++ /dev/null @@ -1,10 +0,0 @@ -using UnityEngine; - -namespace BaseGames.Dialogue -{ - /// - /// 标记一个 string 或 string[] 字段为世界状态标志 Key。 - /// 在 Inspector 中会显示已知标志下拉菜单,支持直接输入新标志。 - /// - public sealed class WorldStateFlagAttribute : PropertyAttribute { } -} diff --git a/Assets/_Game/Scripts/Editor/BaseGames.Editor.asmdef b/Assets/_Game/Scripts/Editor/BaseGames.Editor.asmdef index c42d499..6fb2b19 100644 --- a/Assets/_Game/Scripts/Editor/BaseGames.Editor.asmdef +++ b/Assets/_Game/Scripts/Editor/BaseGames.Editor.asmdef @@ -33,6 +33,7 @@ "BaseGames.World.Streaming", "BaseGames.EventChain", "BaseGames.VFX", + "BaseGames.Localization", "Unity.InputSystem" ], "includePlatforms": [ diff --git a/Assets/_Game/Scripts/Editor/Dialogue.meta b/Assets/_Game/Scripts/Editor/Dialogue.meta new file mode 100644 index 0000000..ac8763f --- /dev/null +++ b/Assets/_Game/Scripts/Editor/Dialogue.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 76d7c0ea7917c4444b0eede5ed06e14c +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Editor/Dialogue/DialogueVariantPreviewWindow.cs b/Assets/_Game/Scripts/Editor/Dialogue/DialogueVariantPreviewWindow.cs new file mode 100644 index 0000000..824805c --- /dev/null +++ b/Assets/_Game/Scripts/Editor/Dialogue/DialogueVariantPreviewWindow.cs @@ -0,0 +1,379 @@ +using System.Collections.Generic; +using System.Linq; +using UnityEditor; +using UnityEditor.UIElements; +using UnityEngine; +using UnityEngine.UIElements; +using BaseGames.Dialogue; + +namespace BaseGames.Editor.Dialogue +{ + /// + /// 对话变体预览窗口。 + /// 给定一个 DialogueSequenceSO,模拟世界状态标志的开关组合, + /// 实时显示各条件变体是否满足,并高亮胜出的变体。 + /// 菜单:BaseGames/Dialogue/Variant Preview + /// + public class DialogueVariantPreviewWindow : EditorWindow + { + private DialogueSequenceSO _target; + private readonly HashSet _enabledFlags = new(System.StringComparer.Ordinal); + private readonly List _allFlags = new(); + + private ObjectField _targetField; + private VisualElement _flagContainer; + private VisualElement _resultContainer; + + private static readonly Color ColWin = new(0.20f, 0.75f, 0.35f, 1f); + private static readonly Color ColFail = new(0.55f, 0.55f, 0.55f, 1f); + private static readonly Color ColOverride = new(0.70f, 0.70f, 0.25f, 1f); + private static readonly Color ColBlocked = new(0.85f, 0.35f, 0.30f, 1f); + + [MenuItem("BaseGames/Dialogue/Variant Preview")] + public static void Open() + { + var win = GetWindow("对话变体预览"); + win.minSize = new Vector2(480, 400); + } + + /// 从外部打开并预填目标 SO。 + public static void OpenWith(DialogueSequenceSO target) + { + var win = GetWindow("对话变体预览"); + win.minSize = new Vector2(480, 400); + win.SetTarget(target); + } + + private void CreateGUI() + { + _mockReader = new MockFlagReader(_enabledFlags); + rootVisualElement.style.paddingLeft = 10; + rootVisualElement.style.paddingRight = 10; + rootVisualElement.style.paddingTop = 10; + rootVisualElement.style.paddingBottom = 10; + + // ── 标题栏 ── + var header = new Label("对话变体预览工具"); + header.style.fontSize = 14; + header.style.unityFontStyleAndWeight = FontStyle.Bold; + header.style.marginBottom = 8; + rootVisualElement.Add(header); + + var desc = new Label("在模拟的世界状态标志组合下,预览哪个条件变体会被选中。"); + desc.style.fontSize = 11; + desc.style.opacity = 0.6f; + desc.style.marginBottom = 10; + rootVisualElement.Add(desc); + + // ── 目标选择器 ── + _targetField = new ObjectField("对话序列 SO") + { + objectType = typeof(DialogueSequenceSO), + allowSceneObjects = false + }; + _targetField.value = _target; + _targetField.RegisterValueChangedCallback(evt => + { + SetTarget(evt.newValue as DialogueSequenceSO); + }); + rootVisualElement.Add(_targetField); + + rootVisualElement.Add(MakeDivider()); + + // ── 标志模拟区 ── + var flagHeader = new Label("模拟世界状态标志"); + flagHeader.style.fontSize = 12; + flagHeader.style.unityFontStyleAndWeight = FontStyle.Bold; + flagHeader.style.marginBottom = 4; + rootVisualElement.Add(flagHeader); + + _flagContainer = new VisualElement(); + rootVisualElement.Add(_flagContainer); + + rootVisualElement.Add(MakeDivider()); + + // ── 变体结果区 ── + var resultHeader = new Label("变体求值结果"); + resultHeader.style.fontSize = 12; + resultHeader.style.unityFontStyleAndWeight = FontStyle.Bold; + resultHeader.style.marginBottom = 4; + rootVisualElement.Add(resultHeader); + + var scrollView = new ScrollView(ScrollViewMode.Vertical); + scrollView.style.flexGrow = 1; + rootVisualElement.Add(scrollView); + + _resultContainer = new VisualElement(); + scrollView.Add(_resultContainer); + + Rebuild(); + } + + private void SetTarget(DialogueSequenceSO target) + { + _target = target; + _enabledFlags.Clear(); + if (_targetField != null && _targetField.value != target) + _targetField.SetValueWithoutNotify(target); + Rebuild(); + } + + // ── 重建 ───────────────────────────────────────────────────────────── + + private void Rebuild() + { + RebuildFlagToggles(); + RebuildResults(); + } + + private void RebuildFlagToggles() + { + if (_flagContainer == null) return; + _flagContainer.Clear(); + _allFlags.Clear(); + + if (_target == null || _target.variants == null || _target.variants.Length == 0) + { + var empty = new Label(_target == null + ? "(请选择一个 DialogueSequenceSO)" + : "(该序列无条件变体,无需模拟)"); + empty.style.opacity = 0.5f; + empty.style.fontSize = 11; + _flagContainer.Add(empty); + return; + } + + // 收集所有变体中涉及的 Flag + var flagSet = new HashSet(System.StringComparer.Ordinal); + foreach (var v in _target.variants) + { + if (v.requiredFlags != null) + foreach (var f in v.requiredFlags) + if (!string.IsNullOrEmpty(f)) flagSet.Add(f); + } + _allFlags.AddRange(flagSet.OrderBy(x => x)); + + if (_allFlags.Count == 0) + { + var empty = new Label("(变体未使用任何 requiredFlags)"); + empty.style.opacity = 0.5f; + empty.style.fontSize = 11; + _flagContainer.Add(empty); + return; + } + + // 全选 / 全不选 快速按钮 + var btnRow = new VisualElement(); + btnRow.style.flexDirection = FlexDirection.Row; + btnRow.style.marginBottom = 4; + + var btnAll = new Button(() => + { + foreach (var f in _allFlags) _enabledFlags.Add(f); + Rebuild(); + }) { text = "全选" }; + btnAll.style.fontSize = 10; + btnAll.style.height = 18; + btnRow.Add(btnAll); + + var btnNone = new Button(() => + { + _enabledFlags.Clear(); + Rebuild(); + }) { text = "全不选" }; + btnNone.style.fontSize = 10; + btnNone.style.height = 18; + btnRow.Add(btnNone); + + _flagContainer.Add(btnRow); + + // 每个 Flag 对应一个 Toggle + foreach (var flag in _allFlags) + { + bool isOn = _enabledFlags.Contains(flag); + var toggle = new Toggle(flag) { value = isOn }; + toggle.style.fontSize = 11; + toggle.RegisterValueChangedCallback(evt => + { + if (evt.newValue) _enabledFlags.Add(flag); + else _enabledFlags.Remove(flag); + RebuildResults(); + }); + _flagContainer.Add(toggle); + } + } + + private void RebuildResults() + { + if (_resultContainer == null) return; + _resultContainer.Clear(); + + if (_target == null) + return; + + if (_target.variants == null || _target.variants.Length == 0) + { + var msg = new Label("(序列无条件变体,直接使用本序列默认台词)"); + msg.style.opacity = 0.5f; + msg.style.fontSize = 11; + _resultContainer.Add(msg); + return; + } + + bool winnerFound = false; + + for (int i = 0; i < _target.variants.Length; i++) + { + var variant = _target.variants[i]; + var row = BuildVariantRow(i, variant, winnerFound); + _resultContainer.Add(row); + + if (!winnerFound && EvaluateVariant(variant)) + winnerFound = true; + } + + // 若无变体胜出,提示将回退到本序列默认台词 + if (!winnerFound) + { + var fallback = new Label("↳ 无变体满足,将使用本序列默认台词(无变体覆盖)"); + fallback.style.fontSize = 11; + fallback.style.opacity = 0.6f; + fallback.style.marginTop = 4; + _resultContainer.Add(fallback); + } + } + + private VisualElement BuildVariantRow(int index, DialogueSequenceSO.ConditionalVariant variant, bool higherWon) + { + bool condMet = EvaluateVariant(variant); + bool isWinner = condMet && !higherWon; + + var card = new VisualElement(); + card.style.borderLeftWidth = 3; + card.style.paddingLeft = 8; + card.style.paddingRight = 8; + card.style.paddingTop = 5; + card.style.paddingBottom = 5; + card.style.marginBottom = 4; + card.style.backgroundColor = new StyleColor(new Color(0.18f, 0.18f, 0.18f, 1f)); + + Color borderColor; + string statusText; + Color statusColor; + + if (isWinner) + { + borderColor = ColWin; + statusText = "✓ 胜出"; + statusColor = ColWin; + } + else if (condMet) + { + borderColor = ColOverride; + statusText = "⏩ 被更高优先级覆盖"; + statusColor = ColOverride; + } + else + { + borderColor = ColFail; + statusText = "✗ 条件不满足"; + statusColor = ColFail; + } + card.style.borderLeftColor = new StyleColor(borderColor); + + // 标题行 + var titleRow = new VisualElement(); + titleRow.style.flexDirection = FlexDirection.Row; + titleRow.style.alignItems = Align.Center; + titleRow.style.marginBottom = 3; + + var idxLabel = new Label($"变体 {index}"); + idxLabel.style.fontSize = 11; + idxLabel.style.flexGrow = 1; + idxLabel.style.unityFontStyleAndWeight = isWinner ? FontStyle.Bold : FontStyle.Normal; + titleRow.Add(idxLabel); + + var seqName = new Label(variant.sequence != null ? variant.sequence.name : "(未设置序列)"); + seqName.style.fontSize = 10; + seqName.style.opacity = 0.6f; + seqName.style.width = 160; + titleRow.Add(seqName); + + var statusLabel = new Label(statusText); + statusLabel.style.fontSize = 10; + statusLabel.style.color = new StyleColor(statusColor); + statusLabel.style.unityFontStyleAndWeight = FontStyle.Bold; + titleRow.Add(statusLabel); + card.Add(titleRow); + + // 逻辑类型 + var logicLabel = new Label($"逻辑:{variant.logic}"); + logicLabel.style.fontSize = 10; + logicLabel.style.opacity = 0.5f; + card.Add(logicLabel); + + // 条件详情 + if (variant.requiredFlags != null && variant.requiredFlags.Length > 0) + { + foreach (var flag in variant.requiredFlags) + { + if (string.IsNullOrEmpty(flag)) continue; + bool flagOn = _enabledFlags.Contains(flag); + var flagRow = new VisualElement(); + flagRow.style.flexDirection = FlexDirection.Row; + flagRow.style.alignItems = Align.Center; + flagRow.style.marginTop = 1; + + var icon = new Label(flagOn ? "✓" : "✗"); + icon.style.fontSize = 10; + icon.style.color = new StyleColor(flagOn ? ColWin : ColBlocked); + icon.style.width = 16; + flagRow.Add(icon); + + var flagLabel = new Label(flag); + flagLabel.style.fontSize = 10; + flagRow.Add(flagLabel); + + card.Add(flagRow); + } + } + else + { + var noFlags = new Label("(无 requiredFlags — 无条件激活)"); + noFlags.style.fontSize = 10; + noFlags.style.opacity = 0.5f; + card.Add(noFlags); + } + + return card; + } + + private bool EvaluateVariant(DialogueSequenceSO.ConditionalVariant variant) + { + // 使用 DialogueSequenceSO.CheckVariant 统一变体求值逻辑,避免重复实现 + return _target != null && _target.CheckVariant(variant, _mockReader); + } + + /// 将 _enabledFlags 包装为 IWorldStateReader,供 CheckVariant 调用。 + private MockFlagReader _mockReader; + + private sealed class MockFlagReader : BaseGames.Core.IWorldStateReader + { + private readonly System.Collections.Generic.HashSet _flags; + public MockFlagReader(System.Collections.Generic.HashSet flags) => _flags = flags; + public bool HasFlag(string key) => _flags.Contains(key); + } + + // ── 辅助 ───────────────────────────────────────────────────────────── + + private static VisualElement MakeDivider() + { + var d = new VisualElement(); + d.style.height = 1; + d.style.backgroundColor = new StyleColor(new Color(0.35f, 0.35f, 0.35f, 0.5f)); + d.style.marginTop = 6; + d.style.marginBottom = 6; + return d; + } + } +} diff --git a/Assets/_Game/Scripts/Editor/Dialogue/NarrativeNPCEditor.cs b/Assets/_Game/Scripts/Editor/Dialogue/NarrativeNPCEditor.cs index bf1cb1f..d467bf0 100644 --- a/Assets/_Game/Scripts/Editor/Dialogue/NarrativeNPCEditor.cs +++ b/Assets/_Game/Scripts/Editor/Dialogue/NarrativeNPCEditor.cs @@ -63,7 +63,8 @@ namespace BaseGames.Editor.Dialogue if (registry == null) { - EditorGUILayout.HelpBox("未指定 WorldStateRegistry,无法预览激活状态。\n请在 Inspector 中设置 World State 字段。", MessageType.Warning); + EditorGUILayout.HelpBox("未指定 WorldStateRegistry,无法预览激活状态。\n" + + "可在 Inspector 中设置 World State 字段,或确保已通过 ServiceLocator 注册全局 IWorldStateReader。", MessageType.Warning); DrawVersionLabelsOnly(versionsProp); return; } diff --git a/Assets/_Game/Scripts/Editor/Dialogue/NarrativeNPCEditor.cs.meta b/Assets/_Game/Scripts/Editor/Dialogue/NarrativeNPCEditor.cs.meta new file mode 100644 index 0000000..3d45ea7 --- /dev/null +++ b/Assets/_Game/Scripts/Editor/Dialogue/NarrativeNPCEditor.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8217e62b4f33e3547895b6884c06bbea +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Editor/Dialogue/WorldStateFlagDrawer.cs b/Assets/_Game/Scripts/Editor/Dialogue/WorldStateFlagDrawer.cs index 25dc579..47bd786 100644 --- a/Assets/_Game/Scripts/Editor/Dialogue/WorldStateFlagDrawer.cs +++ b/Assets/_Game/Scripts/Editor/Dialogue/WorldStateFlagDrawer.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using UnityEditor; using UnityEngine; +using BaseGames.Core; using BaseGames.Dialogue; using BaseGames.EventChain; using BaseGames.Editor; @@ -35,6 +36,15 @@ namespace BaseGames.Editor.Dialogue if (GUI.Button(btnRect, EditorGUIUtility.IconContent("d_icon dropdown"), EditorStyles.iconButton)) { var flags = WorldStateFlagCollector.CollectKnownFlags(); + var registry = BaseGames.Core.WorldFlagRegistrySO.EditorInstance; + // 构建注册表 id→entry 快速查找,用于在菜单项中显示分组和描述 + var registryMap = new System.Collections.Generic.Dictionary( + System.StringComparer.Ordinal); + if (registry?.flags != null) + foreach (var e in registry.flags) + if (e != null && !string.IsNullOrEmpty(e.id)) + registryMap[e.id] = e; + var menu = new GenericMenu(); var current = property.stringValue; var propPath = property.propertyPath; @@ -43,8 +53,12 @@ namespace BaseGames.Editor.Dialogue foreach (var flag in flags) { var captured = flag; + // 显示格式:分组/ID (分组来自注册表;无注册表条目则仅显示 ID) + string menuPath = registryMap.TryGetValue(flag, out var entry) && !string.IsNullOrEmpty(entry.group) + ? $"{entry.group}/{flag}" + : flag; menu.AddItem( - new GUIContent(captured), + new GUIContent(menuPath), current == captured, () => { @@ -62,6 +76,8 @@ namespace BaseGames.Editor.Dialogue menu.AddSeparator(string.Empty); menu.AddItem(new GUIContent("刷新列表"), false, WorldStateFlagCollector.Invalidate); + if (registry == null) + menu.AddDisabledItem(new GUIContent("⚠ 未找到 WorldFlagRegistry.asset(可在 BaseGames 菜单创建)")); menu.ShowAsContext(); } @@ -139,6 +155,12 @@ namespace BaseGames.Editor.Dialogue private static void CollectSoFlags(SortedSet found) { + // 优先加入注册表中的"官方"标志(排在下拉前列) + var registry = BaseGames.Core.WorldFlagRegistrySO.EditorInstance; + if (registry?.flags != null) + foreach (var entry in registry.flags) + if (!string.IsNullOrWhiteSpace(entry?.id)) found.Add(entry.id.Trim()); + foreach (var a in AssetOperations.FindAll()) if (!string.IsNullOrWhiteSpace(a.flagId)) found.Add(a.flagId.Trim()); diff --git a/Assets/_Game/Scripts/Editor/Dialogue/WorldStateFlagDrawer.cs.meta b/Assets/_Game/Scripts/Editor/Dialogue/WorldStateFlagDrawer.cs.meta new file mode 100644 index 0000000..bdd59ba --- /dev/null +++ b/Assets/_Game/Scripts/Editor/Dialogue/WorldStateFlagDrawer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 787ab8ef75ae1cf439b9f7ee6bf21692 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Editor/Hub/DataHubWindow.cs b/Assets/_Game/Scripts/Editor/Hub/DataHubWindow.cs index f1af19e..bc7c93b 100644 --- a/Assets/_Game/Scripts/Editor/Hub/DataHubWindow.cs +++ b/Assets/_Game/Scripts/Editor/Hub/DataHubWindow.cs @@ -37,6 +37,9 @@ namespace BaseGames.Editor private IDataModule _activeModule; private VisualElement _navSidebar; + // NavBar 搜索:按 DisplayName 过滤可见模块 + private string _navFilter = ""; + private readonly Dictionary _navButtons = new(); // 缓存:列表区和详情区引用(由 TwoPaneSplitView 子节点提供) private VisualElement _listWrapper; @@ -70,16 +73,31 @@ namespace BaseGames.Editor private void RegisterModules() { _modules.Clear(); - _modules.Add(new WeaponModule()); - _modules.Add(new SkillModule()); - _modules.Add(new EnemyModule()); - _modules.Add(new FormModule()); - _modules.Add(new BossSkillModule()); - _modules.Add(new CharmModule()); - _modules.Add(new StreamingModule()); - _modules.Add(new DialogueModule()); - _modules.Add(new QuestModule()); - _modules.Add(new ActorModule()); + + // 自动发现所有实现 IDataModule 的非抽象类型(排除接口/抽象类本身) + // TypeCache 仅在 UnityEditor 中可用,零运行时开销 + var moduleTypes = UnityEditor.TypeCache.GetTypesDerivedFrom(); + var instances = new List(moduleTypes.Count); + foreach (var t in moduleTypes) + { + if (t.IsAbstract || t.IsInterface) continue; + try { instances.Add((IDataModule)Activator.CreateInstance(t)); } + catch (Exception ex) + { + Debug.LogWarning($"[DataHubWindow] 无法实例化模块 {t.Name}: {ex.Message}"); + } + } + + // 按 DisplayOrder 升序排列(若模块实现了 IDataModuleOrdered 则使用其值,否则默认 0) + instances.Sort((a, b) => + { + int oa = a is IDataModuleOrdered ao ? ao.DisplayOrder : 0; + int ob = b is IDataModuleOrdered bo ? bo.DisplayOrder : 0; + int c = oa.CompareTo(ob); + return c != 0 ? c : string.Compare(a.DisplayName, b.DisplayName, StringComparison.OrdinalIgnoreCase); + }); + + _modules.AddRange(instances); } // ── 布局 ───────────────────────────────────────────────────────────── @@ -136,13 +154,31 @@ namespace BaseGames.Editor title.style.fontSize = 10; title.style.opacity = 0.5f; title.style.paddingLeft = 10; - title.style.marginBottom = 6; + title.style.marginBottom = 4; title.style.unityFontStyleAndWeight = FontStyle.Bold; sidebar.Add(title); + // 搜索框 + var searchField = new TextField(); + searchField.style.marginLeft = 6; + searchField.style.marginRight = 6; + searchField.style.marginBottom = 6; + searchField.tooltip = "按模块名称过滤"; + // 自定义占位符效果 + if (string.IsNullOrEmpty(searchField.value)) + searchField.value = ""; + searchField.RegisterValueChangedCallback(evt => + { + _navFilter = evt.newValue ?? ""; + ApplyNavFilter(); + }); + sidebar.Add(searchField); + + _navButtons.Clear(); foreach (var module in _modules) { var btn = BuildNavItem(module); + _navButtons[module.ModuleId] = btn; sidebar.Add(btn); } @@ -154,6 +190,18 @@ namespace BaseGames.Editor return sidebar; } + private void ApplyNavFilter() + { + foreach (var module in _modules) + { + if (!_navButtons.TryGetValue(module.ModuleId, out var btn)) continue; + bool visible = string.IsNullOrEmpty(_navFilter) || + module.DisplayName.IndexOf(_navFilter, System.StringComparison.OrdinalIgnoreCase) >= 0 || + module.ModuleId.IndexOf(_navFilter, System.StringComparison.OrdinalIgnoreCase) >= 0; + btn.style.display = visible ? DisplayStyle.Flex : DisplayStyle.None; + } + } + private Button BuildNavItem(IDataModule module) { var btn = new Button(() => ActivateModule(module)); diff --git a/Assets/_Game/Scripts/Editor/Hub/IDataModule.cs b/Assets/_Game/Scripts/Editor/Hub/IDataModule.cs index 171d75f..63275a7 100644 --- a/Assets/_Game/Scripts/Editor/Hub/IDataModule.cs +++ b/Assets/_Game/Scripts/Editor/Hub/IDataModule.cs @@ -25,4 +25,17 @@ namespace BaseGames.Editor /// 切换到本模块时调用,可用于刷新数据。 void OnActivated(); } + + /// + /// 可选排序接口。DataHubWindow 自动发现模块时按 升序排列。 + /// 未实现此接口的模块默认顺序为 0,再按 DisplayName 字母序排列。 + /// + public interface IDataModuleOrdered + { + /// + /// 导航侧边栏排列顺序。数值越小越靠前。 + /// 建议使用 10, 20, 30… 间隔,便于插入新模块。 + /// + int DisplayOrder { get; } + } } diff --git a/Assets/_Game/Scripts/Editor/Modules/ActorModule.cs b/Assets/_Game/Scripts/Editor/Modules/ActorModule.cs index 07985f0..6f00a3a 100644 --- a/Assets/_Game/Scripts/Editor/Modules/ActorModule.cs +++ b/Assets/_Game/Scripts/Editor/Modules/ActorModule.cs @@ -11,7 +11,7 @@ namespace BaseGames.Editor.Modules /// DataHub 对话角色模块 —— 管理 DialogueActorSO 资产。 /// 统一查看、创建、重命名、删除 NPC/玩家角色定义(头像、名称 Key、强调色)。 /// - public class ActorModule : IDataModule + public class ActorModule : IDataModule, IDataModuleOrdered { private const string Folder = "Assets/_Game/Data/Dialogue/Actors"; private const string Prefix = "Actor_"; @@ -19,6 +19,7 @@ namespace BaseGames.Editor.Modules public string ModuleId => "actor"; public string DisplayName => "角色"; public string IconName => "d_Prefab Icon"; + public int DisplayOrder => 80; private SoListPane _listPane; private DetailHeader _header; @@ -76,7 +77,15 @@ namespace BaseGames.Editor.Modules var card = SkillModule.MakeCard(); SkillModule.AddChip(card, "ID", string.IsNullOrEmpty(a.actorId) ? "(未设置)" : a.actorId); - SkillModule.AddChip(card, "名称 Key", string.IsNullOrEmpty(a.nameKey) ? "(未设置)" : a.nameKey); + + // 名称:优先显示本地化实际文本,回退到 key 本身 + string nameDisplay = string.IsNullOrEmpty(a.nameKey) + ? "(未设置)" + : (BaseGames.Localization.LocalizationManager.GetEditorPreview(a.nameKey, "Dialogue") ?? a.nameKey); + SkillModule.AddChip(card, "名称", nameDisplay); + if (!string.IsNullOrEmpty(a.nameKey)) + SkillModule.AddChip(card, "名称 Key", a.nameKey); + if (a.isPlayer) SkillModule.AddChip(card, "类型", "玩家"); diff --git a/Assets/_Game/Scripts/Editor/Modules/ActorModule.cs.meta b/Assets/_Game/Scripts/Editor/Modules/ActorModule.cs.meta new file mode 100644 index 0000000..ba19a86 --- /dev/null +++ b/Assets/_Game/Scripts/Editor/Modules/ActorModule.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1432dc664312c954e9b4adb0cbb6f25e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Editor/Modules/BossSkillModule.cs b/Assets/_Game/Scripts/Editor/Modules/BossSkillModule.cs index 8e68706..efca101 100644 --- a/Assets/_Game/Scripts/Editor/Modules/BossSkillModule.cs +++ b/Assets/_Game/Scripts/Editor/Modules/BossSkillModule.cs @@ -10,7 +10,7 @@ namespace BaseGames.Editor.Modules /// /// DataHub Boss技能模块 —— Tab 切换管理 BossSkillSO 和 SkillSequenceSO。 /// - public class BossSkillModule : IDataModule + public class BossSkillModule : IDataModule, IDataModuleOrdered { private const string SkillFolder = "Assets/_Game/Data/Boss/Skills"; private const string SeqFolder = "Assets/_Game/Data/Boss/Sequences"; @@ -18,6 +18,7 @@ namespace BaseGames.Editor.Modules public string ModuleId => "boss"; public string DisplayName => "Boss技能"; public string IconName => null; + public int DisplayOrder => 50; private int _activeTab = 0; diff --git a/Assets/_Game/Scripts/Editor/Modules/CharmModule.cs b/Assets/_Game/Scripts/Editor/Modules/CharmModule.cs index 4f45392..5881863 100644 --- a/Assets/_Game/Scripts/Editor/Modules/CharmModule.cs +++ b/Assets/_Game/Scripts/Editor/Modules/CharmModule.cs @@ -10,7 +10,7 @@ namespace BaseGames.Editor.Modules /// /// DataHub 护符模块 —— Tab 切换管理 CharmCatalogSO(目录)和 CharmSO(护符)资产。 /// - public class CharmModule : IDataModule + public class CharmModule : IDataModule, IDataModuleOrdered { private const string CharmFolder = "Assets/_Game/Data/Progression/Charms"; private const string CatalogPrefix = "CHM_Catalog"; @@ -19,6 +19,7 @@ namespace BaseGames.Editor.Modules public string ModuleId => "charm"; public string DisplayName => "护符"; public string IconName => null; + public int DisplayOrder => 60; private int _activeTab = 0; // 0 = 目录, 1 = 护符 diff --git a/Assets/_Game/Scripts/Editor/Modules/DialogueModule.cs b/Assets/_Game/Scripts/Editor/Modules/DialogueModule.cs index 1f35bd2..2f147c0 100644 --- a/Assets/_Game/Scripts/Editor/Modules/DialogueModule.cs +++ b/Assets/_Game/Scripts/Editor/Modules/DialogueModule.cs @@ -4,13 +4,15 @@ using UnityEditor.UIElements; using UnityEngine; using UnityEngine.UIElements; using BaseGames.Dialogue; +using BaseGames.Editor.Dialogue; +using BaseGames.Editor.Shared; namespace BaseGames.Editor.Modules { /// /// DataHub 对话序列模块 —— 管理 DialogueSequenceSO 资产。 /// - public class DialogueModule : IDataModule + public class DialogueModule : IDataModule, IDataModuleOrdered { private const string Folder = "Assets/_Game/Data/Dialogue"; private const string Prefix = "DLG_"; @@ -18,6 +20,7 @@ namespace BaseGames.Editor.Modules public string ModuleId => "dialogue"; public string DisplayName => "对话"; public string IconName => "d_UnityEditor.ConsoleWindow"; + public int DisplayOrder => 100; private SoListPane _listPane; private DetailHeader _header; @@ -29,9 +32,20 @@ namespace BaseGames.Editor.Modules Folder, Prefix, s => { - int v = s.variants != null ? s.variants.Length : 0; - return v > 0 ? $"{v}变体" : null; + if (s.variants == null || s.variants.Length == 0) return null; + int v = s.variants.Length; + // 检测无条件变体遮蔽(非末尾的无条件变体会让后续变体永不命中) + bool hasShadow = false; + for (int i = 0; i < s.variants.Length - 1; i++) + { + var vv = s.variants[i]; + if (vv.sequence != null && (vv.requiredFlags == null || vv.requiredFlags.Length == 0)) + { hasShadow = true; break; } + } + return hasShadow ? $"{v}变体 ⚠" : $"{v}变体"; }); + // 扩展搜索:sequenceId + _listPane.GetExtraSearchText = d => d.sequenceId; } public void BuildListPane(VisualElement container, Action onSelected) @@ -41,6 +55,52 @@ namespace BaseGames.Editor.Modules _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 filterVariants = false, filterBranches = false, filterNoVoice = false; + + void RebuildFilter() + { + if (!filterVariants && !filterBranches && !filterNoVoice) + { + _listPane.ExtraFilter = null; + return; + } + _listPane.ExtraFilter = s => + { + if (filterVariants && (s.variants == null || s.variants.Length == 0)) return false; + if (filterBranches) + { + bool hasBranch = false; + if (s.lines != null) + foreach (var l in s.lines) + if (l.choices != null && l.choices.Length > 0) { hasBranch = true; break; } + if (!hasBranch) return false; + } + if (filterNoVoice) + { + bool hasVoice = false; + if (s.lines != null) + foreach (var l in s.lines) + if (l.voiceClip != null) { hasVoice = true; break; } + if (hasVoice) return false; + } + return true; + }; + } + + filterRow.Add(QuestModule.MakeFilterChip("有变体", v => { filterVariants = v; RebuildFilter(); })); + filterRow.Add(QuestModule.MakeFilterChip("有分支", v => { filterBranches = v; RebuildFilter(); })); + filterRow.Add(QuestModule.MakeFilterChip("无语音", v => { filterNoVoice = v; RebuildFilter(); })); + container.Add(_listPane); _listPane.Refresh(); } @@ -90,8 +150,11 @@ namespace BaseGames.Editor.Modules return card; } - private static VisualElement BuildLinesPreview(DialogueSequenceSO s) + private VisualElement BuildLinesPreview(DialogueSequenceSO s) { + var so = new SerializedObject(s); + var linesProp = so.FindProperty("lines"); + var section = new VisualElement(); section.style.paddingLeft = 12; section.style.paddingRight = 12; @@ -114,14 +177,14 @@ namespace BaseGames.Editor.Modules return section; } - int preview = Mathf.Min(5, s.lines.Length); - for (int i = 0; i < preview; i++) + int previewCount = Mathf.Min(5, s.lines.Length); + for (int i = 0; i < previewCount; i++) { var line = s.lines[i]; var row = new VisualElement(); row.style.flexDirection = FlexDirection.Row; row.style.alignItems = Align.Center; - row.style.marginBottom = 3; + row.style.marginBottom = 2; // 头像图标(actor 优先,回退到直接字段) var portrait = line.ResolvedPortrait; @@ -152,36 +215,126 @@ namespace BaseGames.Editor.Modules } } - // 说话人(actor 优先,回退到直接字段) + // 说话人(actor 优先,回退到直接字段;尝试解析本地化实际文本) string speakerKey = line.ResolvedNameKey; if (!string.IsNullOrEmpty(speakerKey)) { - var spk = new Label(speakerKey + ":"); + var speakerResolved = BaseGames.Localization.LocalizationManager.GetEditorPreview(speakerKey, "Dialogue"); + bool speakerMissing = speakerResolved == null; + string speakerText = speakerMissing ? speakerKey : speakerResolved; + var spk = new Label(speakerText + ":"); spk.style.fontSize = 11; - spk.style.opacity = 0.55f; spk.style.unityFontStyleAndWeight = FontStyle.Bold; spk.style.marginRight = 4; spk.style.flexShrink = 0; + var accent = line.ResolvedAccentColor; + if (speakerMissing) + { + // 说话人 Key 缺少本地化 → 橙色警告 + spk.style.color = new StyleColor(new Color(1f, 0.6f, 0.1f)); + spk.style.opacity = 1.0f; + } + else if (accent != Color.white) + { + spk.style.color = new StyleColor(accent); + spk.style.opacity = 1.0f; + } + else + { + spk.style.opacity = 0.55f; + } 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); + // 文本(本地化预览;Key 有值但无本地化内容时橙色 ⚠ 警告) + string textPreview; + bool textL10nMissing = false; + if (string.IsNullOrEmpty(line.textKey)) + { + textPreview = "(空)"; + } + else + { + var resolved = BaseGames.Localization.LocalizationManager.GetEditorPreview(line.textKey, "Dialogue"); + if (resolved != null) + { + textPreview = resolved; + } + else + { + textPreview = line.textKey + " ⚠"; + textL10nMissing = true; + } + } + if (textPreview.Length > 48) textPreview = textPreview[..48] + "…"; + var lbl = new Label(textPreview); lbl.style.fontSize = 11; lbl.style.overflow = Overflow.Hidden; + lbl.style.flexGrow = 1; + if (textL10nMissing) + lbl.style.color = new StyleColor(new Color(1f, 0.6f, 0.1f)); row.Add(lbl); + // 选项分支徽章(有 choices 时显示"→N选") + if (line.choices != null && line.choices.Length > 0) + { + var choiceBadge = new Label($"→{line.choices.Length}选"); + choiceBadge.style.fontSize = 9; + choiceBadge.style.color = new StyleColor(new Color(0.3f, 0.8f, 1f)); + choiceBadge.style.marginLeft = 4; + choiceBadge.style.flexShrink = 0; + row.Add(choiceBadge); + } + + // ▾ 内联编辑按钮 + var editBtn = new Button { text = "▾" }; + editBtn.style.fontSize = 9; + editBtn.style.marginLeft = 4; + editBtn.style.paddingLeft = 3; + editBtn.style.paddingRight = 3; + editBtn.style.height = 16; + row.Add(editBtn); + + // 内联编辑区域(默认隐藏,点击 ▾ 展开) + int capturedIdx = i; + var editRow = new VisualElement(); + editRow.style.display = DisplayStyle.None; + editRow.style.paddingLeft = 26; + editRow.style.paddingRight = 8; + editRow.style.marginBottom = 4; + + if (linesProp != null) + { + var lineProp = linesProp.GetArrayElementAtIndex(capturedIdx); + var textKeyProp = lineProp?.FindPropertyRelative("textKey"); + if (textKeyProp != null) + { + var tf = new TextField("文本 Key") { value = textKeyProp.stringValue }; + tf.style.fontSize = 10; + tf.RegisterValueChangedCallback(evt => + { + so.Update(); + textKeyProp.stringValue = evt.newValue; + so.ApplyModifiedProperties(); + }); + editRow.Add(tf); + } + } + + editBtn.clicked += () => + { + bool open = editRow.style.display == DisplayStyle.None; + editRow.style.display = open ? DisplayStyle.Flex : DisplayStyle.None; + editBtn.text = open ? "▴" : "▾"; + }; + section.Add(row); + section.Add(editRow); } - if (s.lines.Length > preview) + if (s.lines.Length > previewCount) { - var more = new Label($"… 还有 {s.lines.Length - preview} 行"); + var more = new Label($"… 还有 {s.lines.Length - previewCount} 行"); more.style.opacity = 0.4f; more.style.fontSize = 10; section.Add(more); @@ -202,29 +355,207 @@ namespace BaseGames.Editor.Modules title.style.unityFontStyleAndWeight = FontStyle.Bold; card.Add(title); - foreach (var v in s.variants) + for (int i = 0; i < s.variants.Length; i++) { + var v = s.variants[i]; 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 : "(未设置)"); + bool isUnconditional = v.requiredFlags == null || v.requiredFlags.Length == 0; + bool isShadowing = isUnconditional && v.sequence != null && i < s.variants.Length - 1; + + // 条件徽章:显示逻辑模式(And/Or)和标志列表 + string conditionText; + if (isUnconditional) + { + conditionText = isShadowing ? "⚠ (无条件)" : "(无条件)"; + } + else + { + string logicPrefix = v.requiredFlags.Length > 1 + ? $"[{(v.logic == BaseGames.Core.WorldStateFlagLogic.Or ? "OR" : "AND")}] " + : ""; + conditionText = logicPrefix + string.Join(", ", v.requiredFlags); + } + + SkillModule.AddChip(row, "条件", conditionText); + + // 替换序列徽章 + string seqName = v.sequence != null ? v.sequence.name : "(未设置)"; + int seqLines = v.sequence != null && v.sequence.lines != null ? v.sequence.lines.Length : 0; + string seqLabel = v.sequence != null ? $"{seqName}({seqLines}行)" : seqName; + SkillModule.AddChip(row, "替换序列", seqLabel); + card.Add(row); + + // 遮蔽警告行 + if (isShadowing) + { + int remaining = s.variants.Length - 1 - i; + var warn = new Label($" ↑ 此变体无条件,其后 {remaining} 个变体永不生效。请移至末尾或添加条件。"); + warn.style.fontSize = 9; + warn.style.color = new StyleColor(new Color(1f, 0.6f, 0.1f)); + warn.style.marginBottom = 2; + card.Add(warn); + } } return card; } private VisualElement BuildActionBar(DialogueSequenceSO s) { - return SkillModule.BuildStandardActionBar( + var bar = SkillModule.BuildStandardActionBar( s, Folder, Prefix, onCreated: c => _listPane.Refresh(c), onCloned: c => _listPane.Refresh(c), - onDeleted: () => _listPane.Refresh(null)); + onDeleted: () => _listPane.Refresh(null), + wizardCreate: cb => AssetCreationWizard.Show( + Folder, Prefix, + (d, id) => + { + d.sequenceId = id; + EditorUtility.SetDirty(d); + AssetDatabase.SaveAssets(); + cb(d); + })); + + new Button(ValidateAllSequences) { text = "批量验证" }.AlsoAddTo(bar); + + if (s.variants != null && s.variants.Length > 0) + { + var capturedS = s; + new Button(() => DialogueVariantPreviewWindow.OpenWith(capturedS)) + { text = "预览变体" }.AlsoAddTo(bar); + } + + return bar; + } + + // ── 批量验证 ───────────────────────────────────────────────────────── + + /// + /// 遍历所有 DialogueSequenceSO,检查: + /// 1. sequenceId 为空 + /// 2. sequenceId 重复 + /// 3. 每行 textKey 是否在本地化表中存在 + /// 4. 每行 speakerNameKey(无 actor 时)是否在本地化表中存在 + /// 5. 每个选项 textKey 是否在本地化表中存在 + /// 结果显示在 QuestValidationResultWindow 中,每项问题附"选中"按钮可一键定位资产。 + /// + private static void ValidateAllSequences() + { + var allSeqs = AssetOperations.FindAll(); + var issues = new System.Collections.Generic.List(); + int errorCount = 0, warnCount = 0; + + // 预构建本地化缓存(整个验证过程只查询一次,避免大批量序列时重复读取本地化表) + var locCache = new System.Collections.Generic.Dictionary(System.StringComparer.Ordinal); + string GetLoc(string key) + { + if (locCache.TryGetValue(key, out var v)) return v; + v = BaseGames.Localization.LocalizationManager.GetEditorPreview(key, "Dialogue"); + locCache[key] = v; + return v; + } + + 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++; + } + + // 1 & 2:空 / 重复 sequenceId + var idMap = new System.Collections.Generic.Dictionary(System.StringComparer.Ordinal); + var keyFormatRegex = new System.Text.RegularExpressions.Regex(@"^[\w\-\.]+$"); + foreach (var seq in allSeqs) + { + if (string.IsNullOrWhiteSpace(seq.sequenceId)) + { + AddError($"{seq.name}: sequenceId 为空。", seq); + continue; + } + if (idMap.TryGetValue(seq.sequenceId, out var existing)) + AddError($"重复 sequenceId \"{seq.sequenceId}\":{seq.name} 与 {existing.name}", seq); + else + idMap[seq.sequenceId] = seq; + + // 2b. sequenceId 格式异常 + if (!keyFormatRegex.IsMatch(seq.sequenceId)) + AddWarn($"{seq.name}: sequenceId \"{seq.sequenceId}\" 含有空格或非法字符,建议只使用字母、数字、_、-、.。", seq); + } + + // 3-5:本地化 Key 存在性 + 格式检查 + foreach (var seq in allSeqs) + { + if (seq.lines == null) continue; + for (int i = 0; i < seq.lines.Length; i++) + { + var line = seq.lines[i]; + string lineDesc = $"{seq.name} 行[{i}]"; + + // 文本 Key + if (string.IsNullOrEmpty(line.textKey)) + { + AddWarn($"{lineDesc}: textKey 为空,运行时显示空文本。", seq); + } + else + { + if (GetLoc(line.textKey) == null) + AddWarn($"{lineDesc}: textKey \"{line.textKey}\" 在本地化表中不存在。", seq); + if (!keyFormatRegex.IsMatch(line.textKey)) + AddWarn($"{lineDesc}: textKey \"{line.textKey}\" 含有空格或非法字符。", seq); + } + + // 说话人 Key(无 actor 时检查直接字段) + if (line.actor == null && !string.IsNullOrEmpty(line.speakerNameKey)) + { + if (GetLoc(line.speakerNameKey) == null) + AddWarn($"{lineDesc}: speakerNameKey \"{line.speakerNameKey}\" 在本地化表中不存在。", seq); + if (!keyFormatRegex.IsMatch(line.speakerNameKey)) + AddWarn($"{lineDesc}: speakerNameKey \"{line.speakerNameKey}\" 含有空格或非法字符。", seq); + } + + // 选项 Key + if (line.choices != null) + { + for (int j = 0; j < line.choices.Length; j++) + { + var choice = line.choices[j]; + if (string.IsNullOrEmpty(choice.textKey)) + { + AddWarn($"{lineDesc} 选项[{j}]: textKey 为空。", seq); + } + else + { + if (GetLoc(choice.textKey) == null) + AddWarn($"{lineDesc} 选项[{j}]: textKey \"{choice.textKey}\" 在本地化表中不存在。", seq); + if (!keyFormatRegex.IsMatch(choice.textKey)) + AddWarn($"{lineDesc} 选项[{j}]: textKey \"{choice.textKey}\" 含有空格或非法字符。", seq); + } + } + } + } + } + + // 6. variants[i].sequence 为 null(变体存在但序列引用为空) + foreach (var seq in allSeqs) + { + if (seq.variants == null) continue; + for (int vi = 0; vi < seq.variants.Length; vi++) + { + if (seq.variants[vi].sequence == null) + AddWarn($"{seq.name}: variants[{vi}].sequence 为 null,满足条件时会回退默认序列(可能是未完成配置)。", seq); + } + } + + Debug.Log($"[DialogueModule] 验证完成:{allSeqs.Count} 个序列,{errorCount} 个错误,{warnCount} 个警告。"); + QuestValidationResultWindow.Show(issues, errorCount, warnCount, allSeqs.Count, "对话批量验证结果", "序列"); } } -} +} \ No newline at end of file diff --git a/Assets/_Game/Scripts/Editor/Modules/DialogueModule.cs.meta b/Assets/_Game/Scripts/Editor/Modules/DialogueModule.cs.meta new file mode 100644 index 0000000..0876567 --- /dev/null +++ b/Assets/_Game/Scripts/Editor/Modules/DialogueModule.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 76733bfe043064b4a980287067333483 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Editor/Modules/EnemyModule.cs b/Assets/_Game/Scripts/Editor/Modules/EnemyModule.cs index 8648ec1..f9f1bde 100644 --- a/Assets/_Game/Scripts/Editor/Modules/EnemyModule.cs +++ b/Assets/_Game/Scripts/Editor/Modules/EnemyModule.cs @@ -10,7 +10,7 @@ namespace BaseGames.Editor.Modules /// /// DataHub 敌人模块 —— Tab 切换管理 EnemyStatsSO 和 LootTableSO。 /// - public class EnemyModule : IDataModule + public class EnemyModule : IDataModule, IDataModuleOrdered { private const string StatsFolder = "Assets/_Game/Data/Enemies/Stats"; private const string LootFolder = "Assets/_Game/Data/Enemies/Loot"; @@ -18,6 +18,7 @@ namespace BaseGames.Editor.Modules public string ModuleId => "enemy"; public string DisplayName => "敌人"; public string IconName => null; + public int DisplayOrder => 30; private int _activeTab = 0; // 0=Stats, 1=Loot diff --git a/Assets/_Game/Scripts/Editor/Modules/EventChainModule.cs b/Assets/_Game/Scripts/Editor/Modules/EventChainModule.cs new file mode 100644 index 0000000..b1aeb8a --- /dev/null +++ b/Assets/_Game/Scripts/Editor/Modules/EventChainModule.cs @@ -0,0 +1,526 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using UnityEditor; +using UnityEngine; +using UnityEngine.UIElements; +using BaseGames.EventChain; +using BaseGames.Editor.Shared; + +namespace BaseGames.Editor.Modules +{ + /// + /// DataHub 事件链模块 —— 管理 EventChainSO 资产。 + /// 支持浏览、创建、删除、预览条件/动作列表,以及批量验证。 + /// + public class EventChainModule : IDataModule, IDataModuleOrdered + { + private const string Folder = "Assets/_Game/Data/EventChains"; + private const string Prefix = "Chain_"; + + public string ModuleId => "eventchain"; + public string DisplayName => "事件链"; + public string IconName => "d_Animation.Play"; + public int DisplayOrder => 120; + + private SoListPane _listPane; + private DetailHeader _header; + private EventChainSO _selected; + + // ── IDataModule ─────────────────────────────────────────────────────── + + public void Initialize() + { + _listPane = new SoListPane( + Folder, Prefix, + c => c.repeatable ? "可重复" : null); + // 扩展搜索:chainId + _listPane.GetExtraSearchText = c => c.chainId; + } + + public void BuildListPane(VisualElement container, Action 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 filterRepeatable = false, filterNoCondition = false, filterNoAction = false; + + void RebuildFilter() + { + if (!filterRepeatable && !filterNoCondition && !filterNoAction) + { + _listPane.ExtraFilter = null; + return; + } + _listPane.ExtraFilter = c => + { + if (filterRepeatable && !c.repeatable) return false; + if (filterNoCondition && c.conditions != null && c.conditions.Length > 0) return false; + if (filterNoAction && c.actions != null && c.actions.Length > 0) return false; + return true; + }; + } + + filterRow.Add(QuestModule.MakeFilterChip("可重复", v => { filterRepeatable = v; RebuildFilter(); })); + filterRow.Add(QuestModule.MakeFilterChip("无条件", v => { filterNoCondition = v; RebuildFilter(); })); + filterRow.Add(QuestModule.MakeFilterChip("无动作", v => { filterNoAction = v; RebuildFilter(); })); + + container.Add(_listPane); + _listPane.Refresh(); + } + + public void BuildDetailPane(VisualElement container, UnityEngine.Object selected) + { + _selected = selected as EventChainSO; + + _header = new DetailHeader(); + _header.SetAsset(_selected); + _header.RenameRequested += OnRenameRequested; + container.Add(_header); + + if (_selected == null) return; + + container.Add(BuildInfoCard(_selected)); + container.Add(BuildConditionsList(_selected)); + container.Add(BuildActionsList(_selected)); + container.Add(BuildActionBar(_selected)); + container.Add(SkillModule.MakeDivider()); + container.Add(new UnityEditor.UIElements.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(EventChainSO c) + { + var card = SkillModule.MakeCard(); + + SkillModule.AddChip(card, "Chain ID", + string.IsNullOrEmpty(c.chainId) ? "(未设置)" : c.chainId); + SkillModule.AddChip(card, "可重复", c.repeatable ? "是" : "否"); + + if (c.actionDelay > 0f) + SkillModule.AddChip(card, "动作间隔", $"{c.actionDelay:F2}s"); + + int condCount = c.conditions?.Length ?? 0; + int actCount = c.actions?.Length ?? 0; + SkillModule.AddChip(card, "条件数量", condCount.ToString()); + SkillModule.AddChip(card, "动作数量", actCount.ToString()); + + return card; + } + + private static VisualElement BuildConditionsList(EventChainSO c) + { + var section = new VisualElement(); + section.style.paddingLeft = 12; + section.style.paddingRight = 12; + section.style.paddingTop = 6; + section.style.paddingBottom = 4; + + var title = new Label("触发条件"); + title.style.unityFontStyleAndWeight = FontStyle.Bold; + title.style.fontSize = 11; + title.style.opacity = 0.8f; + title.style.marginBottom = 4; + section.Add(title); + + bool hasGroups = c.conditionGroups != null && c.conditionGroups.Length > 0; + bool hasLegacy = c.conditions != null && c.conditions.Length > 0; + + if (!hasGroups && !hasLegacy) + { + var empty = new Label("(无条件,链将立即在首次 EvaluateAll 时触发)"); + empty.style.opacity = 0.5f; + empty.style.fontSize = 11; + section.Add(empty); + return section; + } + + if (hasGroups) + { + // 新版 conditionGroups 展示 + for (int g = 0; g < c.conditionGroups.Length; g++) + { + var group = c.conditionGroups[g]; + + // 组标题 + var groupHeader = new VisualElement(); + groupHeader.style.flexDirection = FlexDirection.Row; + groupHeader.style.alignItems = Align.Center; + groupHeader.style.marginBottom = 2; + groupHeader.style.marginTop = g > 0 ? 4 : 0; + + var gLabel = new Label($"条件组 {g + 1}"); + gLabel.style.fontSize = 11; + gLabel.style.unityFontStyleAndWeight = FontStyle.Bold; + gLabel.style.flexGrow = 1; + groupHeader.Add(gLabel); + + var logicBadge = new Label(group.logic == BaseGames.Core.WorldStateFlagLogic.Or ? "Or(任一满足)" : "And(全部满足)"); + logicBadge.style.fontSize = 10; + logicBadge.style.opacity = 0.6f; + groupHeader.Add(logicBadge); + section.Add(groupHeader); + + if (group.conditions == null || group.conditions.Length == 0) + { + var noCondLabel = new Label(" (空组 — 无条件即视为满足)"); + noCondLabel.style.opacity = 0.45f; + noCondLabel.style.fontSize = 11; + section.Add(noCondLabel); + continue; + } + + for (int i = 0; i < group.conditions.Length; i++) + section.Add(BuildConditionRow(i, group.conditions[i], indent: true)); + } + } + else + { + // 旧版 conditions[](隐式 And) + var legacyNote = new Label("(旧版条件列表,隐式 And 逻辑;建议迁移至 conditionGroups)"); + legacyNote.style.opacity = 0.45f; + legacyNote.style.fontSize = 10; + legacyNote.style.marginBottom = 2; + section.Add(legacyNote); + for (int i = 0; i < c.conditions.Length; i++) + section.Add(BuildConditionRow(i, c.conditions[i], indent: false)); + } + + // ── 运行时求值按钮(仅 Play Mode)────────────────────────────── + if (Application.isPlaying) + { + section.Add(BuildRuntimeEvalPanel(c)); + } + + return section; + } + + private static VisualElement BuildConditionRow(int index, ChainCondition cond, bool indent) + { + var row = new VisualElement(); + row.style.flexDirection = FlexDirection.Row; + row.style.alignItems = Align.Center; + row.style.marginBottom = 2; + if (indent) row.style.paddingLeft = 12; + + var idx = new Label($"{index + 1}."); + idx.style.width = 18; + idx.style.opacity = 0.5f; + idx.style.fontSize = 11; + row.Add(idx); + + if (cond == null) + { + var warn = new Label("⚠ null(Inspector 中有空槽,请检查)"); + warn.style.color = new StyleColor(new Color(0.9f, 0.5f, 0.2f)); + warn.style.fontSize = 11; + row.Add(warn); + } + else + { + // 运行时:直接显示 IsMet() 结果 + if (Application.isPlaying) + { + bool met = cond.IsMet(); + var statusIcon = new Label(met ? "✓" : "✗"); + statusIcon.style.fontSize = 11; + statusIcon.style.width = 14; + statusIcon.style.color = new StyleColor(met + ? new Color(0.2f, 0.75f, 0.35f) + : new Color(0.85f, 0.35f, 0.30f)); + row.Add(statusIcon); + } + + var typeName = new Label(cond.GetType().Name); + typeName.style.flexGrow = 1; + typeName.style.fontSize = 11; + row.Add(typeName); + + var ping = new Button(() => { EditorGUIUtility.PingObject(cond); Selection.activeObject = cond; }) + { text = "定位" }; + ping.style.fontSize = 10; + ping.style.height = 18; + row.Add(ping); + } + + return row; + } + + private static VisualElement BuildRuntimeEvalPanel(EventChainSO c) + { + var panel = new VisualElement(); + panel.style.marginTop = 6; + panel.style.paddingTop = 4; + panel.style.paddingBottom = 4; + panel.style.paddingLeft = 8; + panel.style.paddingRight = 8; + panel.style.backgroundColor = new StyleColor(new Color(0.15f, 0.22f, 0.15f, 1f)); + + var header = new VisualElement(); + header.style.flexDirection = FlexDirection.Row; + header.style.alignItems = Align.Center; + header.style.marginBottom = 2; + + var title = new Label("▶ 运行时状态"); + title.style.fontSize = 10; + title.style.opacity = 0.7f; + title.style.flexGrow = 1; + title.style.unityFontStyleAndWeight = FontStyle.Bold; + header.Add(title); + + var resultLabel = new Label(); + resultLabel.style.fontSize = 10; + + void RefreshEval() + { + // 简单逐条 IsMet 求值用于显示(不触发实际执行) + bool hasGroups = c.conditionGroups != null && c.conditionGroups.Length > 0; + bool allPass = true; + if (hasGroups) + { + foreach (var g in c.conditionGroups) + { + if (g.conditions == null || g.conditions.Length == 0) continue; + bool groupPass = g.logic == BaseGames.Core.WorldStateFlagLogic.Or + ? System.Array.Exists(g.conditions, x => x != null && x.IsMet()) + : System.Array.TrueForAll(g.conditions, x => x == null || x.IsMet()); + if (!groupPass) { allPass = false; break; } + } + } + else if (c.conditions != null) + { + allPass = System.Array.TrueForAll(c.conditions, x => x == null || x.IsMet()); + } + + resultLabel.text = allPass ? "✓ 当前满足触发条件" : "✗ 当前不满足触发条件"; + resultLabel.style.color = new StyleColor(allPass + ? new Color(0.2f, 0.75f, 0.35f) + : new Color(0.85f, 0.35f, 0.30f)); + } + + RefreshEval(); + + var refreshBtn = new Button(RefreshEval) { text = "刷新" }; + refreshBtn.style.fontSize = 9; + refreshBtn.style.height = 16; + header.Add(refreshBtn); + + panel.Add(header); + panel.Add(resultLabel); + return panel; + } + + private static VisualElement BuildActionsList(EventChainSO c) + { + var section = new VisualElement(); + section.style.paddingLeft = 12; + section.style.paddingRight = 12; + section.style.paddingTop = 6; + section.style.paddingBottom = 4; + + var title = new Label("执行动作"); + title.style.unityFontStyleAndWeight = FontStyle.Bold; + title.style.fontSize = 11; + title.style.opacity = 0.8f; + title.style.marginBottom = 4; + section.Add(title); + + if (c.actions == null || c.actions.Length == 0) + { + var empty = new Label("(无动作,链触发后不执行任何操作)"); + empty.style.opacity = 0.5f; + empty.style.fontSize = 11; + section.Add(empty); + return section; + } + + for (int i = 0; i < c.actions.Length; i++) + { + var act = c.actions[i]; + var row = new VisualElement(); + row.style.flexDirection = FlexDirection.Row; + row.style.alignItems = Align.Center; + row.style.marginBottom = 2; + + var idx = new Label($"{i + 1}."); + idx.style.width = 18; + idx.style.opacity = 0.5f; + idx.style.fontSize = 11; + row.Add(idx); + + if (act == null) + { + var warn = new Label("⚠ null(Inspector 中有空槽,请检查)"); + warn.style.color = new StyleColor(new Color(0.9f, 0.5f, 0.2f)); + warn.style.fontSize = 11; + row.Add(warn); + } + else + { + var typeName = new Label($"{act.GetType().Name} {act.name}"); + typeName.style.flexGrow = 1; + typeName.style.fontSize = 11; + row.Add(typeName); + + var ping = new Button(() => { EditorGUIUtility.PingObject(act); Selection.activeObject = act; }) + { text = "定位" }; + ping.style.fontSize = 10; + ping.style.height = 18; + row.Add(ping); + } + + section.Add(row); + } + + return section; + } + + private VisualElement BuildActionBar(EventChainSO c) + { + var bar = SkillModule.MakeActionBar(); + + new Button(() => + { + AssetCreationWizard.Show(Folder, Prefix, (asset, id) => + { + asset.chainId = id; + EditorUtility.SetDirty(asset); + AssetDatabase.SaveAssets(); + _listPane.Refresh(asset); + }); + }) { text = "创建" }.AlsoAddTo(bar); + + new Button(() => { EditorGUIUtility.PingObject(c); Selection.activeObject = c; }) + { text = "定位" }.AlsoAddTo(bar); + + new Button(() => + { + string path = AssetDatabase.GetAssetPath(c); + if (!string.IsNullOrEmpty(path)) EditorGUIUtility.systemCopyBuffer = path; + }) { text = "复制路径" }.AlsoAddTo(bar); + + new Button(ValidateAllChains) { text = "批量验证" }.AlsoAddTo(bar); + + // 强制触发按钮仅在 Play Mode 下显示,用于调试绕过条件检查 + if (Application.isPlaying) + { + var capturedC = c; + var forceBtn = new Button(() => + { + var mgr = UnityEngine.Object.FindObjectOfType(); + if (mgr == null) + { + Debug.LogWarning("[EventChainModule] 场景中未找到 EventChainManager,无法强制触发。"); + return; + } + Debug.Log($"[EventChainModule] 强制触发链:{capturedC.chainId}"); + mgr.ForceExecute(capturedC.chainId); + }) { text = "⚡ 强制触发" }; + forceBtn.style.color = new StyleColor(new Color(1f, 0.75f, 0.2f)); + forceBtn.AlsoAddTo(bar); + } + + var del = new Button(() => + { + if (AssetOperations.Delete(c)) _listPane.Refresh(null); + }) { text = "删除" }; + SkillModule.ApplyDeleteStyle(del); + del.AlsoAddTo(bar); + + return bar; + } + + // ── 批量验证 ────────────────────────────────────────────────────────── + + private static void ValidateAllChains() + { + var allChains = AssetOperations.FindAll(); + if (allChains.Count == 0) + { + EditorUtility.DisplayDialog("事件链验证", "项目中未找到任何 EventChainSO。", "确定"); + return; + } + + var issues = new List(); + int errorCount = 0, warnCount = 0; + + void AddError(string msg, UnityEngine.Object asset) + { + issues.Add(new QuestValidationResultWindow.Issue { message = msg, isError = true, asset = asset }); + errorCount++; + } + void AddWarn(string msg, UnityEngine.Object asset) + { + issues.Add(new QuestValidationResultWindow.Issue { message = msg, isError = false, asset = asset }); + warnCount++; + } + + // ① 空 chainId + foreach (var c in allChains) + if (string.IsNullOrWhiteSpace(c.chainId)) + AddError($"{c.name}: chainId 为空,运行时无法存档或被 ChainCompletedCondition 引用。", c); + + // ② 重复 chainId + var idMap = new Dictionary(StringComparer.Ordinal); + foreach (var c in allChains) + { + if (string.IsNullOrWhiteSpace(c.chainId)) continue; + if (!idMap.TryAdd(c.chainId, c)) + AddError($"chainId '{c.chainId}' 重复({idMap[c.chainId].name} 与 {c.name}),运行时存档键将互串。", c); + } + + // ③ 无动作 + foreach (var c in allChains) + { + if (string.IsNullOrWhiteSpace(c.chainId)) continue; + if (c.actions == null || c.actions.Length == 0) + AddWarn($"{c.chainId}: actions 为空,链触发后不执行任何操作。", c); + } + + // ④ conditions 含空槽 + foreach (var c in allChains) + { + if (string.IsNullOrWhiteSpace(c.chainId) || c.conditions == null) continue; + for (int i = 0; i < c.conditions.Length; i++) + if (c.conditions[i] == null) + AddError($"{c.chainId}: conditions[{i}] 为 null,运行时将触发 NullReferenceException。", c); + } + + // ⑤ actions 含空槽 + foreach (var c in allChains) + { + if (string.IsNullOrWhiteSpace(c.chainId) || c.actions == null) continue; + for (int i = 0; i < c.actions.Length; i++) + if (c.actions[i] == null) + AddError($"{c.chainId}: actions[{i}] 为 null,运行时将触发 NullReferenceException。", c); + } + + Debug.Log($"[EventChainModule] 验证完成:{allChains.Count} 条事件链,{errorCount} 个错误,{warnCount} 个警告。"); + QuestValidationResultWindow.Show(issues, errorCount, warnCount, allChains.Count, "事件链批量验证结果", "事件链"); + } + } +} diff --git a/Assets/_Game/Scripts/Editor/Modules/FlagAuditModule.cs b/Assets/_Game/Scripts/Editor/Modules/FlagAuditModule.cs new file mode 100644 index 0000000..3a007db --- /dev/null +++ b/Assets/_Game/Scripts/Editor/Modules/FlagAuditModule.cs @@ -0,0 +1,508 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using UnityEditor; +using UnityEngine; +using UnityEngine.UIElements; +using BaseGames.Core; +using BaseGames.Dialogue; +using BaseGames.Quest; +using BaseGames.EventChain; +using BaseGames.Editor; + +namespace BaseGames.Editor.Modules +{ + /// + /// DataHub 标志审计模块 —— 扫描项目所有 WorldStateFlag 引用, + /// 检测孤立标志(已注册但从未使用)和未注册标志(已使用但未在注册表定义)。 + /// + public class FlagAuditModule : IDataModule, IDataModuleOrdered + { + public string ModuleId => "flagaudit"; + public string DisplayName => "标志审计"; + public string IconName => "d_FilterByLabel"; + public int DisplayOrder => 130; + + // ── 数据 ───────────────────────────────────────────────────────────── + + private readonly List _records = new(); + private FlagRecord _selected; + private bool _hasScanned; + + private class FlagRecord + { + public string id; + public string description; + public string group; + public bool isRegistered; + public readonly List<(string label, UnityEngine.Object asset)> setLocations = new(); + public readonly List<(string label, UnityEngine.Object asset)> readLocations = new(); + + public bool IsOrphan => isRegistered && TotalUsages == 0; + public bool IsUnregistered => !isRegistered; + public int TotalUsages => setLocations.Count + readLocations.Count; + } + + // ── UI 引用 ─────────────────────────────────────────────────────────── + + private VisualElement _listItems; + private Label _summaryLabel; + private VisualElement _detailRoot; + private bool _filterOrphan, _filterUnregistered; + + // ── IDataModule ─────────────────────────────────────────────────────── + + public void Initialize() { } + + public void BuildListPane(VisualElement container, Action onSelected) + { + // 扫描按钮 + var scanBtn = new Button(RunScan) { text = "🔍 扫描标志使用情况" }; + scanBtn.style.marginTop = 8; + scanBtn.style.marginLeft = 8; + scanBtn.style.marginRight = 8; + scanBtn.style.marginBottom = 4; + container.Add(scanBtn); + + // 统计行 + _summaryLabel = new Label("尚未扫描,点击上方按钮开始。"); + _summaryLabel.style.fontSize = 10; + _summaryLabel.style.opacity = 0.6f; + _summaryLabel.style.paddingLeft = 10; + _summaryLabel.style.marginBottom = 4; + container.Add(_summaryLabel); + + // 过滤标签行 + 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); + + filterRow.Add(QuestModule.MakeFilterChip("仅孤立", v => { _filterOrphan = v; RebuildList(); })); + filterRow.Add(QuestModule.MakeFilterChip("仅未注册", v => { _filterUnregistered = v; RebuildList(); })); + + // 列表 ScrollView + var scroll = new ScrollView(); + scroll.style.flexGrow = 1; + container.Add(scroll); + + _listItems = new VisualElement(); + scroll.Add(_listItems); + } + + public void BuildDetailPane(VisualElement container, UnityEngine.Object selected) + { + _detailRoot = container; + RebuildDetail(); + } + + public void OnActivated() { } + + // ── 扫描 ────────────────────────────────────────────────────────────── + + private void RunScan() + { + _records.Clear(); + _hasScanned = true; + + var byId = new Dictionary(StringComparer.Ordinal); + FlagRecord GetOrCreate(string id) + { + if (!byId.TryGetValue(id, out var r)) + { + r = new FlagRecord { id = id }; + byId[id] = r; + _records.Add(r); + } + return r; + } + + // 1. 从 WorldFlagRegistrySO 导入注册表 + var registry = WorldFlagRegistrySO.EditorInstance; + if (registry?.flags != null) + foreach (var entry in registry.flags) + { + if (string.IsNullOrEmpty(entry.id)) continue; + var r = GetOrCreate(entry.id); + r.isRegistered = true; + r.description = entry.description; + r.group = entry.group; + } + + // 2. 扫描 DialogueSequenceSO + foreach (var seq in AssetOperations.FindAll()) + { + // variants[i].requiredFlags → 读取 + if (seq.variants != null) + foreach (var v in seq.variants) + if (v.requiredFlags != null) + foreach (var fid in v.requiredFlags) + if (!string.IsNullOrEmpty(fid)) + GetOrCreate(fid).readLocations.Add(($"对话变体条件 [{seq.name}]", seq)); + + // lines[i].choices[j].setWorldFlag → 设置 + if (seq.lines != null) + foreach (var line in seq.lines) + if (line.choices != null) + foreach (var ch in line.choices) + if (!string.IsNullOrEmpty(ch.setWorldFlag)) + GetOrCreate(ch.setWorldFlag).setLocations.Add(($"对话选项设置 [{seq.name}]", seq)); + } + + // 3. 扫描 QuestSO + foreach (var quest in AssetOperations.FindAll()) + { + // branches[i].conditionFlags → 读取 + if (quest.branches != null) + foreach (var branch in quest.branches) + if (branch.conditionFlags != null) + foreach (var fid in branch.conditionFlags) + if (!string.IsNullOrEmpty(fid)) + GetOrCreate(fid).readLocations.Add(($"任务分支条件 [{quest.name}]", quest)); + + // prerequisiteFlags → 读取 + if (quest.prerequisiteFlags != null) + foreach (var fid in quest.prerequisiteFlags) + if (!string.IsNullOrEmpty(fid)) + GetOrCreate(fid).readLocations.Add(($"任务前置标志 [{quest.name}]", quest)); + } + + // 4. 扫描 FlagSetCondition(EventChain 条件)→ 读取 + foreach (var cond in AssetOperations.FindAll()) + if (!string.IsNullOrEmpty(cond.flagId)) + GetOrCreate(cond.flagId).readLocations.Add(($"链条件 [{cond.name}]", cond)); + + // 5. 扫描 SetFlagAction(EventChain 动作)→ 设置 + foreach (var act in AssetOperations.FindAll()) + if (!string.IsNullOrEmpty(act.flagId)) + GetOrCreate(act.flagId).setLocations.Add(($"链动作 [{act.name}]", act)); + + // 6. 扫描 NarrativeNPC 预制件中的 DialogueVersion 条件标志 + // NarrativeNPC 是 MonoBehaviour,使用 SerializedObject 读取序列化字段以避免反射。 + var prefabGuids = AssetDatabase.FindAssets("t:Prefab", new[] { "Assets/_Game" }); + foreach (var guid in prefabGuids) + { + string path = AssetDatabase.GUIDToAssetPath(guid); + var prefab = AssetDatabase.LoadAssetAtPath(path); + if (prefab == null) continue; + foreach (var npc in prefab.GetComponentsInChildren(true)) + { + var so = new SerializedObject(npc); + var vProp = so.FindProperty("_dialogueVersions"); + if (vProp == null || !vProp.isArray) continue; + for (int i = 0; i < vProp.arraySize; i++) + { + var elem = vProp.GetArrayElementAtIndex(i); + var reqProp = elem.FindPropertyRelative("requiredFlags"); + var blockProp = elem.FindPropertyRelative("blockedByFlags"); + if (reqProp != null && reqProp.isArray) + for (int j = 0; j < reqProp.arraySize; j++) + { + string fid = reqProp.GetArrayElementAtIndex(j).stringValue; + if (!string.IsNullOrEmpty(fid)) + GetOrCreate(fid).readLocations.Add(($"NPC版本条件 [{prefab.name}]", prefab)); + } + if (blockProp != null && blockProp.isArray) + for (int j = 0; j < blockProp.arraySize; j++) + { + string fid = blockProp.GetArrayElementAtIndex(j).stringValue; + if (!string.IsNullOrEmpty(fid)) + GetOrCreate(fid).readLocations.Add(($"NPC版本屏蔽 [{prefab.name}]", prefab)); + } + } + } + } + + // 排序:未注册 → 孤立 → 正常,再按 ID 字典序 + _records.Sort((a, b) => + { + int pa = a.IsUnregistered ? 0 : a.IsOrphan ? 1 : 2; + int pb = b.IsUnregistered ? 0 : b.IsOrphan ? 1 : 2; + int c = pa.CompareTo(pb); + return c != 0 ? c : string.Compare(a.id, b.id, StringComparison.Ordinal); + }); + + RebuildList(); + RebuildDetail(); + } + + // ── 列表重建 ───────────────────────────────────────────────────────── + + private void RebuildList() + { + if (_listItems == null) return; + _listItems.Clear(); + if (!_hasScanned) return; + + int total = _records.Count; + int orphanCount = _records.Count(r => r.IsOrphan); + int unregCount = _records.Count(r => r.IsUnregistered); + if (_summaryLabel != null) + _summaryLabel.text = $"共 {total} 个标志 · 孤立 {orphanCount} · 未注册 {unregCount}"; + + foreach (var rec in _records) + { + if (_filterOrphan && !rec.IsOrphan) continue; + if (_filterUnregistered && !rec.IsUnregistered) continue; + + bool isSelected = rec == _selected; + + var row = new VisualElement(); + row.style.flexDirection = FlexDirection.Row; + row.style.alignItems = Align.Center; + row.style.paddingTop = 3; + row.style.paddingBottom = 3; + row.style.paddingLeft = 8; + row.style.paddingRight = 8; + row.style.backgroundColor = isSelected + ? new StyleColor(new Color(0.25f, 0.5f, 1f, 0.2f)) + : StyleKeyword.None; + + // 状态图标 + 颜色 + string icon = rec.IsUnregistered ? "⚠" : rec.IsOrphan ? "○" : "●"; + Color iconColor = rec.IsUnregistered + ? new Color(1f, 0.4f, 0.2f) + : rec.IsOrphan + ? new Color(1f, 0.85f, 0.1f) + : new Color(0.4f, 0.85f, 0.4f); + + var iconLbl = new Label(icon); + iconLbl.style.fontSize = 10; + iconLbl.style.color = new StyleColor(iconColor); + iconLbl.style.width = 14; + iconLbl.style.flexShrink = 0; + row.Add(iconLbl); + + var idLbl = new Label(rec.id); + idLbl.style.fontSize = 11; + idLbl.style.flexGrow = 1; + row.Add(idLbl); + + // 使用次数徽章 + if (rec.TotalUsages > 0) + { + var badge = new Label(rec.TotalUsages.ToString()); + badge.style.fontSize = 9; + badge.style.opacity = 0.6f; + badge.style.paddingLeft = 4; + badge.style.paddingRight = 4; + badge.style.paddingTop = 1; + badge.style.paddingBottom = 1; + badge.style.borderTopLeftRadius = 8; + badge.style.borderTopRightRadius = 8; + badge.style.borderBottomLeftRadius = 8; + badge.style.borderBottomRightRadius = 8; + badge.style.backgroundColor = new StyleColor(new Color(0.5f, 0.5f, 0.5f, 0.25f)); + row.Add(badge); + } + + var capturedRec = rec; + row.RegisterCallback(_ => + { + _selected = capturedRec; + RebuildList(); + RebuildDetail(); + }); + + _listItems.Add(row); + } + } + + // ── 详情重建 ───────────────────────────────────────────────────────── + + private void RebuildDetail() + { + if (_detailRoot == null) return; + _detailRoot.Clear(); + + if (!_hasScanned) + { + var hint = new Label("请先点击「扫描标志使用情况」按钮。"); + hint.style.opacity = 0.5f; + hint.style.marginTop = 24; + hint.style.unityTextAlign = TextAnchor.UpperCenter; + _detailRoot.Add(hint); + return; + } + + if (_selected == null) + { + var hint = new Label("← 从左侧选择一个标志查看详情。"); + hint.style.opacity = 0.5f; + hint.style.marginTop = 24; + hint.style.unityTextAlign = TextAnchor.UpperCenter; + _detailRoot.Add(hint); + return; + } + + var r = _selected; + + // 标题 + var titleLbl = new Label(r.id); + titleLbl.style.fontSize = 15; + titleLbl.style.unityFontStyleAndWeight = FontStyle.Bold; + titleLbl.style.paddingLeft = 12; + titleLbl.style.paddingTop = 12; + titleLbl.style.paddingBottom = 2; + _detailRoot.Add(titleLbl); + + // 状态徽章 + string statusText = r.IsUnregistered ? "⚠ 未在注册表中定义" : r.IsOrphan ? "○ 已注册但从未使用(孤立)" : "● 正常"; + Color statusColor = r.IsUnregistered ? new Color(1f, 0.4f, 0.2f) : r.IsOrphan ? new Color(1f, 0.85f, 0.1f) : new Color(0.4f, 0.85f, 0.4f); + var statusLbl = new Label(statusText); + statusLbl.style.fontSize = 11; + statusLbl.style.color = new StyleColor(statusColor); + statusLbl.style.paddingLeft = 12; + statusLbl.style.marginBottom = 4; + _detailRoot.Add(statusLbl); + + // "注册到注册表" 快捷按钮(仅未注册标志显示) + if (r.IsUnregistered) + { + var capturedRec = r; + var regBtn = new Button(() => RegisterFlagToRegistry(capturedRec)) + { + text = "+ 注册到注册表", + tooltip = "将此标志 ID 追加到 WorldFlagRegistrySO.flags[] 中,并重新扫描。", + }; + regBtn.style.marginLeft = 10; + regBtn.style.marginBottom = 6; + regBtn.style.width = 130; + _detailRoot.Add(regBtn); + } + + if (!string.IsNullOrEmpty(r.group)) AddDetailRow("分组", r.group); + if (!string.IsNullOrEmpty(r.description)) AddDetailRow("描述", r.description); + + _detailRoot.Add(SkillModule.MakeDivider()); + + AddLocationSection("📝 设置位置", r.setLocations, "无设置记录(标志只被读取,从不被写入)"); + AddLocationSection("🔎 读取位置", r.readLocations, "无读取记录(标志只被写入,从不被读取)"); + } + + private void AddDetailRow(string label, string value) + { + var row = new VisualElement(); + row.style.flexDirection = FlexDirection.Row; + row.style.paddingLeft = 12; + row.style.paddingBottom = 2; + + var lbl = new Label($"{label}:"); + lbl.style.fontSize = 11; + lbl.style.opacity = 0.55f; + lbl.style.width = 48; + lbl.style.flexShrink = 0; + + var val = new Label(value); + val.style.fontSize = 11; + val.style.flexGrow = 1; + val.style.flexWrap = Wrap.Wrap; + + row.Add(lbl); + row.Add(val); + _detailRoot.Add(row); + } + + private void AddLocationSection(string sectionTitle, List<(string label, UnityEngine.Object asset)> locations, string emptyText) + { + var header = new Label(sectionTitle); + header.style.fontSize = 11; + header.style.unityFontStyleAndWeight = FontStyle.Bold; + header.style.paddingLeft = 12; + header.style.paddingTop = 8; + header.style.paddingBottom = 3; + _detailRoot.Add(header); + + if (locations.Count == 0) + { + var empty = new Label(emptyText); + empty.style.fontSize = 10; + empty.style.opacity = 0.45f; + empty.style.paddingLeft = 20; + empty.style.marginBottom = 4; + _detailRoot.Add(empty); + return; + } + + foreach (var (lbl, asset) in locations) + { + var row = new VisualElement(); + row.style.flexDirection = FlexDirection.Row; + row.style.alignItems = Align.Center; + row.style.paddingLeft = 14; + row.style.paddingRight = 8; + row.style.marginBottom = 2; + + var caption = new Label(lbl); + caption.style.fontSize = 11; + caption.style.flexGrow = 1; + row.Add(caption); + + if (asset != null) + { + var pingBtn = new Button(() => + { + EditorGUIUtility.PingObject(asset); + Selection.activeObject = asset; + }) { text = "选中" }; + pingBtn.style.fontSize = 10; + pingBtn.style.width = 36; + pingBtn.style.height = 18; + pingBtn.style.paddingTop = 0; + pingBtn.style.paddingBottom = 0; + row.Add(pingBtn); + } + + _detailRoot.Add(row); + } + } + + // ── 注册快捷操作 ────────────────────────────────────────────────────── + + private void RegisterFlagToRegistry(FlagRecord rec) + { + var registry = WorldFlagRegistrySO.EditorInstance; + if (registry == null) + { + EditorUtility.DisplayDialog( + "注册表不存在", + "项目中未找到 WorldFlagRegistrySO 资产。\n" + + "请先通过 Create → BaseGames/Core/WorldFlagRegistry 创建注册表。", + "确定"); + return; + } + + // 检查是否已存在(理论上不可能,但防御性检查) + if (registry.flags != null) + { + foreach (var entry in registry.flags) + if (entry.id == rec.id) return; + } + + var newEntry = new FlagEntry + { + id = rec.id, + description = "", + group = "", + }; + + var flags = registry.flags ?? System.Array.Empty(); + var list = new System.Collections.Generic.List(flags) { newEntry }; + Undo.RegisterCompleteObjectUndo(registry, $"注册标志 {rec.id}"); + registry.flags = list.ToArray(); + + EditorUtility.SetDirty(registry); + AssetDatabase.SaveAssets(); + + // 将记录标记为已注册并重建 UI + rec.isRegistered = true; + RebuildList(); + RebuildDetail(); + + Debug.Log($"[FlagAuditModule] 已将标志 '{rec.id}' 注册到 WorldFlagRegistrySO。"); + } + } +} diff --git a/Assets/_Game/Scripts/Editor/Modules/FormModule.cs b/Assets/_Game/Scripts/Editor/Modules/FormModule.cs index 79e3528..7f1e6cb 100644 --- a/Assets/_Game/Scripts/Editor/Modules/FormModule.cs +++ b/Assets/_Game/Scripts/Editor/Modules/FormModule.cs @@ -10,7 +10,7 @@ namespace BaseGames.Editor.Modules /// /// DataHub 形态模块 —— Tab 切换管理 FormConfigSO 和 FormSO 资产。 /// - public class FormModule : IDataModule + public class FormModule : IDataModule, IDataModuleOrdered { private const string ConfigFolder = "Assets/_Game/Data/Player/Forms"; private const string FormFolder = "Assets/_Game/Data/Player/Forms"; @@ -18,6 +18,7 @@ namespace BaseGames.Editor.Modules public string ModuleId => "form"; public string DisplayName => "形态"; public string IconName => null; + public int DisplayOrder => 40; private int _activeTab = 0; // 0=FormConfig, 1=FormSO diff --git a/Assets/_Game/Scripts/Editor/Modules/IdCodegenModule.cs b/Assets/_Game/Scripts/Editor/Modules/IdCodegenModule.cs new file mode 100644 index 0000000..db390f5 --- /dev/null +++ b/Assets/_Game/Scripts/Editor/Modules/IdCodegenModule.cs @@ -0,0 +1,197 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using UnityEditor; +using UnityEngine; +using UnityEngine.UIElements; +using BaseGames.Dialogue; +using BaseGames.Quest; +using BaseGames.EventChain; + +namespace BaseGames.Editor.Modules +{ + /// + /// DataHub ID 生成模块 —— 扫描 QuestSO / NpcSO / DialogueSequenceSO / EventChainSO 资产, + /// 自动生成 Assets/_Game/Scripts/Core/GameIds.Generated.cs, + /// 提供编译期 ID 常量,消除代码中的魔法字符串。 + /// + public class IdCodegenModule : IDataModule, IDataModuleOrdered + { + private const string OutputPath = "Assets/_Game/Scripts/Core/GameIds.Generated.cs"; + + public string ModuleId => "idcodegen"; + public string DisplayName => "ID 生成"; + public string IconName => "d_cs Script Icon"; + public int DisplayOrder => 140; + + private Label _statusLabel; + private string _lastResult; + + // ── IDataModule ─────────────────────────────────────────────────────── + + public void Initialize() { } + + public void BuildListPane(VisualElement container, Action onSelected) + { + var desc = new Label( + "扫描项目中的 QuestSO / NpcSO / DialogueSequenceSO / EventChainSO 资产,\n" + + $"生成 {OutputPath} 常量文件。\n\n" + + "生成后在代码中通过 GameIdsGenerated.Quest.XXX 等访问。"); + desc.style.whiteSpace = WhiteSpace.Normal; + desc.style.marginBottom = 12; + desc.style.paddingLeft = 8; + desc.style.paddingRight = 8; + desc.style.fontSize = 11; + container.Add(desc); + + var btn = new Button(RunCodegen) { text = "⚡ 生成 GameIds.Generated.cs" }; + btn.style.marginLeft = 8; + btn.style.marginRight = 8; + btn.style.height = 28; + container.Add(btn); + + _statusLabel = new Label(_lastResult ?? ""); + _statusLabel.style.whiteSpace = WhiteSpace.Normal; + _statusLabel.style.marginTop = 10; + _statusLabel.style.marginLeft = 8; + _statusLabel.style.marginRight = 8; + _statusLabel.style.fontSize = 11; + container.Add(_statusLabel); + } + + public void BuildDetailPane(VisualElement container, UnityEngine.Object selected) + { + // 无需详情面板 + } + + public void OnActivated() { } + + // ── 代码生成 ────────────────────────────────────────────────────────── + + private void RunCodegen() + { + try + { + var quests = AssetOperations.FindAll() + .Where(q => !string.IsNullOrEmpty(q.questId)) + .Select(q => q.questId) + .Distinct(StringComparer.Ordinal) + .OrderBy(s => s, StringComparer.Ordinal) + .ToList(); + + var npcs = AssetOperations.FindAll() + .Where(n => !string.IsNullOrEmpty(n.npcId)) + .Select(n => n.npcId) + .Distinct(StringComparer.Ordinal) + .OrderBy(s => s, StringComparer.Ordinal) + .ToList(); + + var dialogues = AssetOperations.FindAll() + .Where(d => !string.IsNullOrEmpty(d.sequenceId)) + .Select(d => d.sequenceId) + .Distinct(StringComparer.Ordinal) + .OrderBy(s => s, StringComparer.Ordinal) + .ToList(); + + var chains = AssetOperations.FindAll() + .Where(c => !string.IsNullOrEmpty(c.chainId)) + .Select(c => c.chainId) + .Distinct(StringComparer.Ordinal) + .OrderBy(s => s, StringComparer.Ordinal) + .ToList(); + + string code = BuildSourceCode(quests, npcs, dialogues, chains); + + string fullPath = Path.GetFullPath(OutputPath); + string dir = Path.GetDirectoryName(fullPath); + if (!Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + File.WriteAllText(fullPath, code, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + AssetDatabase.ImportAsset(OutputPath, ImportAssetOptions.ForceUpdate); + + int total = quests.Count + npcs.Count + dialogues.Count + chains.Count; + _lastResult = $"✅ 生成完成({total} 个常量:Quest×{quests.Count} Npc×{npcs.Count} " + + $"Dialogue×{dialogues.Count} Chain×{chains.Count})"; + } + catch (Exception e) + { + _lastResult = $"❌ 生成失败:{e.Message}"; + Debug.LogException(e); + } + + if (_statusLabel != null) + _statusLabel.text = _lastResult; + } + + private static string BuildSourceCode(List quests, List npcs, + List dialogues, List chains) + { + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("// 此文件由 DataHub > ID生成 模块自动生成,请勿手动编辑。"); + sb.AppendLine("// 手动维护的 ID 常量请放在 GameIds.cs 中。"); + sb.AppendLine("// "); + sb.AppendLine(); + sb.AppendLine("namespace BaseGames.Core"); + sb.AppendLine("{"); + sb.AppendLine(" /// 自动生成的游戏资产 ID 常量。每次执行 DataHub > ID生成 后刷新。"); + sb.AppendLine(" public static class GameIdsGenerated"); + sb.AppendLine(" {"); + + AppendSection(sb, "Quest", "QuestSO.questId", quests, "Quest_"); + AppendSection(sb, "Npc", "NpcSO.npcId", npcs, "NPC_"); + AppendSection(sb, "Dialogue", "DialogueSequenceSO.sequenceId", dialogues, "DLG_"); + AppendSection(sb, "Chain", "EventChainSO.chainId", chains, "Chain_"); + + sb.AppendLine(" }"); + sb.AppendLine("}"); + return sb.ToString(); + } + + private static void AppendSection(StringBuilder sb, string className, string docSource, + List ids, string stripPrefix) + { + sb.AppendLine($" /// 来自 {docSource} 的 ID 常量。"); + sb.AppendLine($" public static class {className}"); + sb.AppendLine(" {"); + + if (ids.Count == 0) + sb.AppendLine(" // 项目中暂无此类资产"); + else + foreach (string id in ids) + sb.AppendLine($" public const string {ToFieldName(id, stripPrefix)} = \"{id}\";"); + + sb.AppendLine(" }"); + sb.AppendLine(); + } + + /// + /// 将原始 ID 字符串转为合法的 C# 标识符。 + /// 剥离常见前缀(如 "Quest_"),然后将剩余部分中的非字母数字字符替换为下划线, + /// 确保不以数字开头。 + /// + private static string ToFieldName(string rawId, string stripPrefix) + { + string name = rawId; + if (!string.IsNullOrEmpty(stripPrefix) && + name.StartsWith(stripPrefix, StringComparison.OrdinalIgnoreCase)) + name = name.Substring(stripPrefix.Length); + + // 将非字母数字字符替换为下划线 + name = Regex.Replace(name, @"[^A-Za-z0-9_]", "_"); + + // 不能以数字开头 + if (name.Length > 0 && char.IsDigit(name[0])) + name = "_" + name; + + if (string.IsNullOrEmpty(name)) + name = "_"; + + return name; + } + } +} diff --git a/Assets/_Game/Scripts/Editor/Modules/NpcModule.cs b/Assets/_Game/Scripts/Editor/Modules/NpcModule.cs new file mode 100644 index 0000000..04ce059 --- /dev/null +++ b/Assets/_Game/Scripts/Editor/Modules/NpcModule.cs @@ -0,0 +1,335 @@ +using System; +using UnityEditor; +using UnityEditor.UIElements; +using UnityEngine; +using UnityEngine.UIElements; +using BaseGames.Dialogue; +using BaseGames.Quest; +using BaseGames.Editor.Shared; + +namespace BaseGames.Editor.Modules +{ + /// + /// DataHub NPC 模块 —— 管理 NpcSO 资产。 + /// 统一查看、创建、重命名、删除 NPC 定义(ID、名称 Key、头像、好感度上限)。 + /// + public class NpcModule : IDataModule, IDataModuleOrdered + { + private const string Folder = "Assets/_Game/Data/NPC"; + private const string Prefix = "NPC_"; + + public string ModuleId => "npc"; + public string DisplayName => "NPC"; + public string IconName => "d_GameObject Icon"; + public int DisplayOrder => 90; + + private SoListPane _listPane; + private DetailHeader _header; + private NpcSO _selected; + + public void Initialize() + { + _listPane = new SoListPane( + Folder, Prefix, + n => n.maxAffinity > 0 ? $"亲密{n.maxAffinity}" : null); + // 扩展搜索:npcId + nameKey + _listPane.GetExtraSearchText = n => $"{n.npcId} {n.nameKey}"; + } + + public void BuildListPane(VisualElement container, Action 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 filterAffinity = false, filterPortrait = false; + + void RebuildFilter() + { + if (!filterAffinity && !filterPortrait) + { + _listPane.ExtraFilter = null; + return; + } + _listPane.ExtraFilter = n => + { + if (filterAffinity && n.maxAffinity <= 0) return false; + if (filterPortrait && n.portrait == null) return false; + return true; + }; + } + + filterRow.Add(QuestModule.MakeFilterChip("有好感度", v => { filterAffinity = v; RebuildFilter(); })); + filterRow.Add(QuestModule.MakeFilterChip("有头像", v => { filterPortrait = v; RebuildFilter(); })); + + container.Add(_listPane); + _listPane.Refresh(); + } + + public void BuildDetailPane(VisualElement container, UnityEngine.Object selected) + { + _selected = selected as NpcSO; + + _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(NpcSO n) + { + var card = SkillModule.MakeCard(); + + SkillModule.AddChip(card, "NPC ID", string.IsNullOrEmpty(n.npcId) ? "(未设置)" : n.npcId); + + string nameDisplay = string.IsNullOrEmpty(n.nameKey) + ? "(未设置)" + : (BaseGames.Localization.LocalizationManager.GetEditorPreview(n.nameKey, "Dialogue") ?? n.nameKey); + SkillModule.AddChip(card, "名称", nameDisplay); + if (!string.IsNullOrEmpty(n.nameKey)) + SkillModule.AddChip(card, "名称 Key", n.nameKey); + if (n.maxAffinity > 0) + SkillModule.AddChip(card, "好感度上限", n.maxAffinity.ToString()); + + // 头像预览 + if (n.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 = n.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); + card.Add(row); + } + + // 关联任务反查:显示哪些任务以此 NPC 为发布者 + var referencingQuests = FindQuestsReferencingNpc(n); + if (referencingQuests.Count > 0) + { + SkillModule.AddChip(card, "关联任务", $"共 {referencingQuests.Count} 个"); + var refFold = new UnityEngine.UIElements.Foldout + { + text = $"关联任务({referencingQuests.Count})", + value = false, + }; + refFold.style.paddingLeft = 8; + foreach (var q in referencingQuests) + { + var btn = new UnityEngine.UIElements.Button(() => UnityEditor.EditorGUIUtility.PingObject(q)) + { + text = string.IsNullOrEmpty(q.questId) ? q.name : $"{q.questId} ({q.name})" + }; + btn.style.unityTextAlign = UnityEngine.TextAnchor.MiddleLeft; + btn.style.fontSize = 10; + btn.style.marginBottom = 1; + refFold.Add(btn); + } + card.Add(refFold); + } + + return card; + } + + // ── 关联任务缓存(5 秒 TTL,避免每次切换 NPC 时全量扫描资产数据库)────────────── + private static System.Collections.Generic.Dictionary> + s_npcQuestCache; + private static double s_npcQuestCacheTime = -10.0; + + private static System.Collections.Generic.List FindQuestsReferencingNpc(NpcSO n) + { + double now = UnityEditor.EditorApplication.timeSinceStartup; + if (s_npcQuestCache != null && now - s_npcQuestCacheTime < 5.0) + { + s_npcQuestCache.TryGetValue(n, out var cached); + return cached ?? new System.Collections.Generic.List(); + } + + // TTL 过期,重建全量缓存(单次扫描所有 QuestSO,分组存储) + s_npcQuestCache = new System.Collections.Generic.Dictionary>(); + var guids = UnityEditor.AssetDatabase.FindAssets("t:QuestSO"); + foreach (var guid in guids) + { + var path = UnityEditor.AssetDatabase.GUIDToAssetPath(guid); + var q = UnityEditor.AssetDatabase.LoadAssetAtPath(path); + if (q == null || q.giverNpc == null) continue; + if (!s_npcQuestCache.TryGetValue(q.giverNpc, out var list)) + { + list = new System.Collections.Generic.List(); + s_npcQuestCache[q.giverNpc] = list; + } + list.Add(q); + } + s_npcQuestCacheTime = now; + + s_npcQuestCache.TryGetValue(n, out var result); + return result ?? new System.Collections.Generic.List(); + } + + private VisualElement BuildActionBar(NpcSO n) + { + var bar = SkillModule.BuildStandardActionBar( + n, Folder, Prefix, + onCreated: c => _listPane.Refresh(c), + onCloned: c => _listPane.Refresh(c), + onDeleted: () => _listPane.Refresh(null), + wizardCreate: cb => AssetCreationWizard.Show( + Folder, Prefix, + (npc, id) => + { + npc.npcId = id; + EditorUtility.SetDirty(npc); + AssetDatabase.SaveAssets(); + cb(npc); + })); + + // 批量验证按钮 + var validateBtn = new Button(ValidateAllNpcs) { text = "批量验证" }; + validateBtn.style.marginLeft = 4; + bar.Add(validateBtn); + return bar; + } + + // ── 批量验证 ───────────────────────────────────────────────────────── + + /// + /// 遍历所有 NpcSO,检查: + /// 1. npcId 为空 + /// 2. npcId 重复(全局) + /// 3. nameKey 为空(NPC 无显示名称) + /// 4. maxAffinity > 0 但 portrait 为 null(好感度 UI 无头像可展示) + /// 5. nameKey 在本地化表中不存在 + /// 6. interactPromptKey 非空但在本地化表中不存在 + /// 7. 与同 npcId 的 DialogueActorSO portrait 不一致 + /// 结果在 QuestValidationResultWindow 中展示,每项问题附"选中"按钮可一键定位资产。 + /// + private static void ValidateAllNpcs() + { + var allNpcs = AssetOperations.FindAll(); + var issues = new System.Collections.Generic.List(); + 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 locCache = new System.Collections.Generic.Dictionary(System.StringComparer.Ordinal); + string GetLoc(string key) + { + if (locCache.TryGetValue(key, out var v)) return v; + v = BaseGames.Localization.LocalizationManager.GetEditorPreview(key, "Dialogue"); + locCache[key] = v; + return v; + } + + // 预构建 DialogueActorSO 映射 actorId → ActorSO(用于 portrait 一致性检查) + var actorMap = new System.Collections.Generic.Dictionary( + System.StringComparer.Ordinal); + var actorGuids = UnityEditor.AssetDatabase.FindAssets("t:DialogueActorSO"); + foreach (var g in actorGuids) + { + var path = UnityEditor.AssetDatabase.GUIDToAssetPath(g); + var actor = UnityEditor.AssetDatabase.LoadAssetAtPath(path); + if (actor != null && !string.IsNullOrEmpty(actor.actorId) && !actorMap.ContainsKey(actor.actorId)) + actorMap[actor.actorId] = actor; + } + + // 1 & 2:空 npcId / 重复 npcId + var idMap = new System.Collections.Generic.Dictionary(System.StringComparer.Ordinal); + foreach (var n in allNpcs) + { + if (string.IsNullOrWhiteSpace(n.npcId)) + { + AddError($"{n.name}: npcId 为空,NPC 无法被系统引用。", n); + continue; + } + if (idMap.TryGetValue(n.npcId, out var existing)) + AddError($"重复 npcId \"{n.npcId}\":{n.name} 与 {existing.name}", n); + else + idMap[n.npcId] = n; + } + + // 3-7:其余字段检查 + foreach (var n in allNpcs) + { + // 3. nameKey 为空 + if (string.IsNullOrEmpty(n.nameKey)) + AddWarn($"{n.name}({n.npcId}): nameKey 为空,运行时显示空名称。", n); + + // 4. 有好感度但无头像 + if (n.maxAffinity > 0 && n.portrait == null) + AddWarn($"{n.name}({n.npcId}): maxAffinity={n.maxAffinity} 但 portrait 为 null,好感度进度条 UI 无头像可展示。", n); + + // 5. nameKey 本地化不存在 + if (!string.IsNullOrEmpty(n.nameKey) && GetLoc(n.nameKey) == null) + AddWarn($"{n.name}({n.npcId}): nameKey \"{n.nameKey}\" 在本地化表中不存在。", n); + + // 5b. nameKey 格式异常(含空格或非法字符) + if (!string.IsNullOrEmpty(n.nameKey) && + !System.Text.RegularExpressions.Regex.IsMatch(n.nameKey, @"^[\w\-\.]+$")) + AddWarn($"{n.name}({n.npcId}): nameKey \"{n.nameKey}\" 含有空格或非法字符,建议只使用字母、数字、_、-、.。", n); + + // 6. interactPromptKey 本地化不存在 + if (!string.IsNullOrEmpty(n.interactPromptKey) && GetLoc(n.interactPromptKey) == null) + AddWarn($"{n.name}({n.npcId}): interactPromptKey \"{n.interactPromptKey}\" 在本地化表中不存在,运行时交互提示显示 Key 原文。", n); + + // 7. portrait 与同 npcId 的 DialogueActorSO 不一致 + if (!string.IsNullOrEmpty(n.npcId) && actorMap.TryGetValue(n.npcId, out var actor)) + { + if (actor.portrait != n.portrait) + AddWarn($"{n.name}({n.npcId}): portrait 与 DialogueActorSO \"{actor.name}\" 的 portrait 不一致," + + "对话框中显示的头像与 NPC 信息面板头像可能不同。", n); + } + } + + UnityEngine.Debug.Log($"[NpcModule] 验证完成:{allNpcs.Count} 个 NPC,{errorCount} 个错误,{warnCount} 个警告。"); + QuestValidationResultWindow.Show(issues, errorCount, warnCount, allNpcs.Count, "NPC 批量验证结果", "NPC"); + } + } +} diff --git a/Assets/_Game/Scripts/Editor/Modules/NpcModule.cs.meta b/Assets/_Game/Scripts/Editor/Modules/NpcModule.cs.meta new file mode 100644 index 0000000..4088969 --- /dev/null +++ b/Assets/_Game/Scripts/Editor/Modules/NpcModule.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5d7d769256156a542bf7efb80f93f3c4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Editor/Modules/QuestModule.cs b/Assets/_Game/Scripts/Editor/Modules/QuestModule.cs index b7625a0..db6248f 100644 --- a/Assets/_Game/Scripts/Editor/Modules/QuestModule.cs +++ b/Assets/_Game/Scripts/Editor/Modules/QuestModule.cs @@ -7,13 +7,14 @@ using UnityEditor.UIElements; using UnityEngine; using UnityEngine.UIElements; using BaseGames.Quest; +using BaseGames.Editor.Shared; namespace BaseGames.Editor.Modules { /// /// DataHub 任务模块 —— 管理 QuestSO 资产。 /// - public class QuestModule : IDataModule + public class QuestModule : IDataModule, IDataModuleOrdered { private const string Folder = "Assets/_Game/Data/Quest"; private const string Prefix = "Quest_"; @@ -21,11 +22,15 @@ namespace BaseGames.Editor.Modules public string ModuleId => "quest"; public string DisplayName => "任务"; public string IconName => "d_UnityEditor.InspectorWindow"; + public int DisplayOrder => 110; private SoListPane _listPane; private DetailHeader _header; private QuestSO _selected; + // playModeStateChanged 订阅的字段引用,便于在重建 ActionBar 时退订旧订阅,避免内存泄漏 + private System.Action _playModeHandler; + public void Initialize() { _listPane = new SoListPane( @@ -33,8 +38,20 @@ namespace BaseGames.Editor.Modules 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 onSelected) @@ -44,10 +61,93 @@ namespace BaseGames.Editor.Modules _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 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(_ => SetActive(!active)); + return chip; + } + public void BuildDetailPane(VisualElement container, UnityEngine.Object selected) { _selected = selected as QuestSO; @@ -63,6 +163,7 @@ namespace BaseGames.Editor.Modules 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)); @@ -86,11 +187,42 @@ namespace BaseGames.Editor.Modules 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); + + // 名称:优先显示本地化实际文本,回退到 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,方便策划一眼看清依赖链 @@ -110,7 +242,24 @@ namespace BaseGames.Editor.Modules if (s.canFail) SkillModule.AddChip(card, "可失败", "✓"); if (s.reward != null) - SkillModule.AddChip(card, "奖励", s.reward.name); + { + 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; } @@ -185,6 +334,23 @@ namespace BaseGames.Editor.Modules } 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; @@ -226,20 +392,508 @@ namespace BaseGames.Editor.Modules return card; } + /// + /// 构建当前任务的依赖关系可视图(折叠面板形式): + /// - 上方:前置任务链(此任务需要哪些任务先完成) + /// - 下方:后续任务链(此任务完成后可解锁哪些任务) + /// 数据来源:allQuests 中所有 QuestSO 的 prerequisiteQuests 引用,无运行时副作用。 + /// 节点可点击→选中对应资产(EditorGUIUtility.PingObject)。 + /// + 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) + { + var allQuests = AssetOperations.FindAll(); + + // ── 前置任务(上游)──────────────────────────────────────────────── + 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(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); + } + } + + /// + /// 递归检测任务是否存在循环前置依赖(DFS)。 + /// visited 存储已访问的 questId,origin 为检测起点。 + /// + private static bool HasPrerequisiteCycle(QuestSO origin, QuestSO current, System.Collections.Generic.HashSet 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; + } + + + /// 添加一个依赖关系分区(标题 + 节点列表)。 + 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)); + onDeleted: () => _listPane.Refresh(null), + wizardCreate: cb => AssetCreationWizard.Show( + Folder, Prefix, + (q, id) => + { + q.questId = id; + EditorUtility.SetDirty(q); + AssetDatabase.SaveAssets(); + cb(q); + })); - // 任务模块额外:代码常量生成 - new Button(GenerateQuestKeys) { text = "生成常量" }.AlsoAddTo(bar); + // 任务模块额外:代码常量生成 + 批量配置验证 + 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; } + // ── 运行时模拟 ──────────────────────────────────────────────────────── + + /// + /// PlayMode 下对当前选中的 QuestSO 模拟状态推进或重置: + /// - Available → AcceptQuest + /// - Active → CompleteQuest(传入 null rewardTarget,跳过奖励发放) + /// - Completed / Failed / Unavailable → ResetQuest(重置为 Available 供重测) + /// 用于策划/开发人员在不启动游戏流程的情况下快速验证任务状态机。 + /// + 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(); + 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; + } + } + + // ── 批量验证 ───────────────────────────────────────────────────────── + + /// + /// 遍历所有 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 中展示,每项问题附"选中"按钮可一键定位资产。 + /// + private static void ValidateAllQuests() + { + var allQuests = AssetOperations.FindAll(); + var issues = new List(); + 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 ValidateIds( + List allQuests, + System.Action addError) + { + var idMap = new Dictionary(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 allQuests, + Dictionary idMap, + System.Action addError, + System.Action 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(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 addWarn) + { + var reachTagToSO = new Dictionary(StringComparer.Ordinal); + foreach (var obj in AssetOperations.FindAll()) + if (!string.IsNullOrEmpty(obj.markerTag)) + reachTagToSO[obj.markerTag] = obj; + + var triggerTagToPrefab = new Dictionary(StringComparer.Ordinal); + foreach (var guid in AssetDatabase.FindAssets("t:Prefab")) + { + var prefabPath = AssetDatabase.GUIDToAssetPath(guid); + var prefabGo = AssetDatabase.LoadAssetAtPath(prefabPath); + if (prefabGo == null) continue; + foreach (var zone in prefabGo.GetComponentsInChildren(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 allQuests, + System.Action addError) + { + foreach (var q in allQuests) + { + if (q.objectives == null || q.objectives.Length == 0) continue; + var seenIds = new HashSet(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 allQuests, + System.Action 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 allQuests, + System.Action addWarn) + { + var knownIds = new HashSet(StringComparer.Ordinal); + foreach (var guid in AssetDatabase.FindAssets("t:Prefab")) + { + var prefabPath = AssetDatabase.GUIDToAssetPath(guid); + var go = AssetDatabase.LoadAssetAtPath(prefabPath); + if (go == null) continue; + var col = go.GetComponent(); + 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 idMap, + HashSet 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"; diff --git a/Assets/_Game/Scripts/Editor/Modules/QuestModule.cs.meta b/Assets/_Game/Scripts/Editor/Modules/QuestModule.cs.meta new file mode 100644 index 0000000..c85242b --- /dev/null +++ b/Assets/_Game/Scripts/Editor/Modules/QuestModule.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: cdb78fdcbe3a25f40b0f77d1b42002b9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Editor/Modules/QuestValidationResultWindow.cs b/Assets/_Game/Scripts/Editor/Modules/QuestValidationResultWindow.cs new file mode 100644 index 0000000..c1980eb --- /dev/null +++ b/Assets/_Game/Scripts/Editor/Modules/QuestValidationResultWindow.cs @@ -0,0 +1,171 @@ +using System.Collections.Generic; +using UnityEditor; +using UnityEngine; + +namespace BaseGames.Editor.Modules +{ + /// + /// 批量验证结果窗口:以可滚动列表展示各项配置问题, + /// 每条记录附带"选中"按钮,点击后高亮并选中对应资产(EditorGUIUtility.PingObject)。 + /// 支持 Error/Warning Tab 切换与文本搜索过滤。 + /// 由各验证模块(QuestModule、DialogueModule、NpcModule)在检测到问题时弹出。 + /// + internal class QuestValidationResultWindow : EditorWindow + { + internal struct Issue + { + public string message; + public bool isError; + public UnityEngine.Object asset; // null = 无对应资产(如孤儿触发器) + } + + private List _issues; + private int _errorCount; + private int _warnCount; + private int _totalItems; + private string _itemLabel = "资产"; + private Vector2 _scroll; + + // ── 过滤状态 ───────────────────────────────────────────────────────── + private enum TabMode { All, ErrorsOnly, WarnsOnly } + private TabMode _tab = TabMode.All; + private string _filter = ""; + + private static readonly GUIStyle s_tabActive = null; // 延迟初始化 + private static readonly GUIStyle s_tabInactive = null; + + // ── 打开入口 ────────────────────────────────────────────────────────── + + internal static void Show(List issues, int errorCount, int warnCount, int totalItems, string windowTitle = "批量验证结果", string itemLabel = "资产") + { + var win = GetWindow(true, windowTitle, true); + win._issues = issues; + win._errorCount = errorCount; + win._warnCount = warnCount; + win._totalItems = totalItems; + win._itemLabel = itemLabel; + win._tab = TabMode.All; + win._filter = ""; + win.minSize = new Vector2(560, 380); + win.Show(); + } + + // ── 绘制 ────────────────────────────────────────────────────────────── + + private void OnGUI() + { + if (_issues == null) { EditorGUILayout.LabelField("无数据。"); return; } + + bool clean = _errorCount == 0 && _warnCount == 0; + + // ── 标题摘要 ── + EditorGUILayout.Space(6); + var summaryStyle = new GUIStyle(EditorStyles.boldLabel) { fontSize = 12 }; + if (clean) summaryStyle.normal.textColor = new Color(0.2f, 0.75f, 0.2f); + + string prefix = clean ? "✅ " : (_errorCount > 0 ? "❌ " : "⚠ "); + string summary = $"{prefix}验证完成:{_totalItems} 个{_itemLabel} · {_errorCount} 个错误 · {_warnCount} 个警告"; + EditorGUILayout.LabelField(summary, summaryStyle); + EditorGUILayout.Space(4); + + if (clean) + { + EditorGUILayout.LabelField($"所有 {_itemLabel} 配置均合法!", EditorStyles.centeredGreyMiniLabel); + return; + } + + // ── 分隔线 ── + var divRect = EditorGUILayout.GetControlRect(false, 1); + EditorGUI.DrawRect(divRect, new Color(0.35f, 0.35f, 0.35f)); + EditorGUILayout.Space(4); + + // ── Tab 切换 + 搜索框 ── + EditorGUILayout.BeginHorizontal(); + DrawTab("全部", TabMode.All, _issues.Count); + DrawTab("错误", TabMode.ErrorsOnly, _errorCount); + DrawTab("警告", TabMode.WarnsOnly, _warnCount); + GUILayout.FlexibleSpace(); + GUILayout.Label("🔍", GUILayout.Width(18)); + _filter = EditorGUILayout.TextField(_filter, GUILayout.Width(180)); + if (GUILayout.Button("×", GUILayout.Width(22))) _filter = ""; + EditorGUILayout.EndHorizontal(); + EditorGUILayout.Space(4); + + // ── 问题列表 ── + _scroll = EditorGUILayout.BeginScrollView(_scroll); + int shown = 0; + foreach (var issue in _issues) + { + if (!MatchesFilter(issue)) continue; + shown++; + DrawIssueRow(issue); + EditorGUILayout.Space(2); + } + if (shown == 0) + EditorGUILayout.LabelField("(当前过滤条件下无结果)", EditorStyles.centeredGreyMiniLabel); + EditorGUILayout.EndScrollView(); + + // ── 底部状态栏 ── + EditorGUILayout.Space(2); + var statusRect = EditorGUILayout.GetControlRect(false, 1); + EditorGUI.DrawRect(statusRect, new Color(0.3f, 0.3f, 0.3f)); + EditorGUILayout.LabelField( + $"显示 {shown} / {_issues.Count} 条", + EditorStyles.centeredGreyMiniLabel); + } + + // ── 辅助方法 ───────────────────────────────────────────────────────── + + private bool MatchesFilter(Issue issue) + { + if (_tab == TabMode.ErrorsOnly && !issue.isError) return false; + if (_tab == TabMode.WarnsOnly && issue.isError) return false; + if (!string.IsNullOrEmpty(_filter)) + { + bool msgMatch = issue.message.Contains(_filter, System.StringComparison.OrdinalIgnoreCase); + bool assetMatch = issue.asset != null && + issue.asset.name.Contains(_filter, System.StringComparison.OrdinalIgnoreCase); + if (!msgMatch && !assetMatch) return false; + } + return true; + } + + private void DrawTab(string label, TabMode mode, int count) + { + bool active = _tab == mode; + var style = active + ? new GUIStyle(EditorStyles.miniButtonMid) { fontStyle = FontStyle.Bold } + : EditorStyles.miniButtonMid; + if (active) style.normal.textColor = new Color(0.4f, 0.8f, 1f); + string text = $"{label} ({count})"; + if (GUILayout.Button(text, style, GUILayout.MinWidth(72))) + _tab = mode; + } + + private static void DrawIssueRow(Issue issue) + { + EditorGUILayout.BeginHorizontal(EditorStyles.helpBox); + + // 图标 + var iconContent = issue.isError + ? EditorGUIUtility.IconContent("console.erroricon.sml") + : EditorGUIUtility.IconContent("console.warnicon.sml"); + GUILayout.Label(iconContent, GUILayout.Width(20), GUILayout.Height(18)); + + // 消息文字 + EditorGUILayout.LabelField(issue.message, EditorStyles.wordWrappedLabel); + + // 定位按钮(有资产引用时显示) + if (issue.asset != null) + { + if (GUILayout.Button("选中", GUILayout.Width(40), GUILayout.Height(18))) + { + EditorGUIUtility.PingObject(issue.asset); + Selection.activeObject = issue.asset; + } + } + + EditorGUILayout.EndHorizontal(); + } + } +} diff --git a/Assets/_Game/Scripts/Editor/Modules/QuestValidationResultWindow.cs.meta b/Assets/_Game/Scripts/Editor/Modules/QuestValidationResultWindow.cs.meta new file mode 100644 index 0000000..dc1005e --- /dev/null +++ b/Assets/_Game/Scripts/Editor/Modules/QuestValidationResultWindow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: cd040e3f82e3b3040a92fac14502ef4a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Editor/Modules/SkillModule.cs b/Assets/_Game/Scripts/Editor/Modules/SkillModule.cs index 592db2a..493a54c 100644 --- a/Assets/_Game/Scripts/Editor/Modules/SkillModule.cs +++ b/Assets/_Game/Scripts/Editor/Modules/SkillModule.cs @@ -10,7 +10,7 @@ namespace BaseGames.Editor.Modules /// /// DataHub 技能模块 —— 管理 FormSkillSO 资产。 /// - public class SkillModule : IDataModule + public class SkillModule : IDataModule, IDataModuleOrdered { private const string Folder = "Assets/_Game/Data/Skills"; private const string Prefix = "SKL_"; @@ -18,6 +18,7 @@ namespace BaseGames.Editor.Modules public string ModuleId => "skill"; public string DisplayName => "技能"; public string IconName => null; + public int DisplayOrder => 20; private SoListPane _listPane; private DetailHeader _header; @@ -193,16 +194,24 @@ namespace BaseGames.Editor.Modules T asset, string folder, string prefix, - Action onCreated, - Action onCloned, - Action onDeleted) where T : UnityEngine.ScriptableObject + Action onCreated, + Action onCloned, + Action onDeleted, + Action> wizardCreate = null) where T : UnityEngine.ScriptableObject { var bar = MakeActionBar(); new Button(() => { - var c = AssetOperations.Create(folder, prefix + "New"); - if (c != null) onCreated?.Invoke(c); + if (wizardCreate != null) + { + wizardCreate(c => onCreated?.Invoke(c)); + } + else + { + var c = AssetOperations.Create(folder, prefix + "New"); + if (c != null) onCreated?.Invoke(c); + } }) { text = "新建" }.AlsoAddTo(bar); new Button(() => diff --git a/Assets/_Game/Scripts/Editor/Modules/StreamingModule.cs b/Assets/_Game/Scripts/Editor/Modules/StreamingModule.cs index 90b8b4f..f4d2783 100644 --- a/Assets/_Game/Scripts/Editor/Modules/StreamingModule.cs +++ b/Assets/_Game/Scripts/Editor/Modules/StreamingModule.cs @@ -10,7 +10,7 @@ namespace BaseGames.Editor.Modules /// /// DataHub 流式加载模块 —— 管理 资产。 /// - public class StreamingModule : IDataModule + public class StreamingModule : IDataModule, IDataModuleOrdered { private const string Folder = "Assets/_Game/Data/Streaming"; private const string Prefix = "STR_"; @@ -18,6 +18,7 @@ namespace BaseGames.Editor.Modules public string ModuleId => "streaming"; public string DisplayName => "流式加载"; public string IconName => "d_RectTransformBlueprint"; + public int DisplayOrder => 70; private SoListPane _listPane; private DetailHeader _header; diff --git a/Assets/_Game/Scripts/Editor/Modules/WeaponModule.cs b/Assets/_Game/Scripts/Editor/Modules/WeaponModule.cs index 6d2d47c..2e2a643 100644 --- a/Assets/_Game/Scripts/Editor/Modules/WeaponModule.cs +++ b/Assets/_Game/Scripts/Editor/Modules/WeaponModule.cs @@ -10,7 +10,7 @@ namespace BaseGames.Editor.Modules /// /// DataHub 武器模块 —— 管理 WeaponSO 资产。 /// - public class WeaponModule : IDataModule + public class WeaponModule : IDataModule, IDataModuleOrdered { private const string Folder = "Assets/_Game/Data/Weapons"; private const string Prefix = "WPN_"; @@ -18,6 +18,7 @@ namespace BaseGames.Editor.Modules public string ModuleId => "weapon"; public string DisplayName => "武器"; public string IconName => null; + public int DisplayOrder => 10; private SoListPane _listPane; private DetailHeader _header; diff --git a/Assets/_Game/Scripts/Editor/Quest.meta b/Assets/_Game/Scripts/Editor/Quest.meta new file mode 100644 index 0000000..327bb8c --- /dev/null +++ b/Assets/_Game/Scripts/Editor/Quest.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ba51d204b9cde834f9a521996715f883 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Editor/Quest/QuestManagerPostprocessor.cs.meta b/Assets/_Game/Scripts/Editor/Quest/QuestManagerPostprocessor.cs.meta new file mode 100644 index 0000000..95b1e3f --- /dev/null +++ b/Assets/_Game/Scripts/Editor/Quest/QuestManagerPostprocessor.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 832e6ad6d64454d4286183c6d7cfdc3e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Editor/Quest/QuestSOEditor.cs b/Assets/_Game/Scripts/Editor/Quest/QuestSOEditor.cs new file mode 100644 index 0000000..f5ab5d6 --- /dev/null +++ b/Assets/_Game/Scripts/Editor/Quest/QuestSOEditor.cs @@ -0,0 +1,118 @@ +using UnityEditor; +using UnityEngine; +using BaseGames.Quest; +using BaseGames.Core; + +namespace BaseGames.Editor.Quest +{ + /// + /// QuestSO 自定义 Inspector。 + /// 在检测到旧版前置字段(prerequisiteQuests / prerequisiteFlags)有数据时, + /// 显示迁移提示框和一键迁移按钮,引导策划将数据迁移到 QuestPrerequisite 统一结构。 + /// + [CustomEditor(typeof(QuestSO))] + public class QuestSOEditor : UnityEditor.Editor + { + private bool _showMigrationBox = true; + + public override void OnInspectorGUI() + { + serializedObject.Update(); + + var quest = (QuestSO)target; + + // ── 旧版字段迁移提示 ────────────────────────────────────────────── +#pragma warning disable CS0618 + bool hasLegacyQuests = quest.prerequisiteQuests != null && quest.prerequisiteQuests.Length > 0; + bool hasLegacyFlags = quest.prerequisiteFlags != null && quest.prerequisiteFlags.Length > 0; +#pragma warning restore CS0618 + + if (hasLegacyQuests || hasLegacyFlags) + { + _showMigrationBox = EditorGUILayout.BeginFoldoutHeaderGroup(_showMigrationBox, "⚠ 旧版前置字段迁移"); + if (_showMigrationBox) + { + EditorGUILayout.HelpBox( + "检测到旧版前置字段有数据:\n" + + (hasLegacyQuests ? $" • prerequisiteQuests:{quest.prerequisiteQuests.Length} 项\n" : "") + + (hasLegacyFlags ? $" • prerequisiteFlags:{quest.prerequisiteFlags.Length} 项\n" : "") + + "\n新版 'prerequisites'(QuestPrerequisite)字段已支持更完整的前置配置。\n" + + "点击下方按钮可将旧版数据自动迁移至新字段,迁移后旧字段将被清空。\n" + + "迁移操作可撤销(Ctrl+Z)。", + MessageType.Warning); + + bool hasNewData = quest.prerequisites.HasAny; + if (hasNewData) + EditorGUILayout.HelpBox( + "新版 prerequisites 字段已有数据。点击迁移将与旧版数据合并(去重),不会覆盖现有配置。", + MessageType.Info); + + if (GUILayout.Button("一键迁移旧版前置字段 → prerequisites")) + { + MigrateLegacyPrerequisites(quest); + } + } + EditorGUILayout.EndFoldoutHeaderGroup(); + EditorGUILayout.Space(4); + } + + // ── 默认 Inspector ──────────────────────────────────────────────── + DrawDefaultInspector(); + + serializedObject.ApplyModifiedProperties(); + } + + private static void MigrateLegacyPrerequisites(QuestSO quest) + { + Undo.RecordObject(quest, "迁移 QuestSO 旧版前置字段"); + +#pragma warning disable CS0618 + int legacyQuestCount = quest.prerequisiteQuests?.Length ?? 0; + int legacyFlagCount = quest.prerequisiteFlags?.Length ?? 0; + + // 迁移 prerequisiteQuests → prerequisites.questDependencies(合并去重) + if (quest.prerequisiteQuests != null && quest.prerequisiteQuests.Length > 0) + { + var existing = quest.prerequisites.questDependencies ?? System.Array.Empty(); + var merged = new System.Collections.Generic.HashSet(existing); + foreach (var q in quest.prerequisiteQuests) + if (q != null) merged.Add(q); + + quest.prerequisites.questDependencies = new QuestSO[merged.Count]; + merged.CopyTo(quest.prerequisites.questDependencies); + quest.prerequisiteQuests = System.Array.Empty(); + } + + // 迁移 prerequisiteFlags → prerequisites.flagCondition(合并去重) + if (quest.prerequisiteFlags != null && quest.prerequisiteFlags.Length > 0) + { + var existing = quest.prerequisites.flagCondition.flags ?? System.Array.Empty(); + var merged = new System.Collections.Generic.HashSet( + existing, System.StringComparer.Ordinal); + foreach (var f in quest.prerequisiteFlags) + if (!string.IsNullOrEmpty(f)) merged.Add(f); + + quest.prerequisites.flagCondition.flags = new string[merged.Count]; + merged.CopyTo(quest.prerequisites.flagCondition.flags); + // 迁移逻辑模式(旧字段覆盖新字段,以旧配置为准) + quest.prerequisites.flagCondition.logic = quest.prerequisiteFlagsLogic; + + quest.prerequisiteFlags = System.Array.Empty(); + quest.prerequisiteFlagsLogic = WorldStateFlagLogic.And; + } +#pragma warning restore CS0618 + + EditorUtility.SetDirty(quest); + AssetDatabase.SaveAssets(); + + Debug.Log($"[QuestSOEditor] '{quest.name}' 旧版前置字段迁移完成(任务:{legacyQuestCount} 项,标志:{legacyFlagCount} 项)。", quest); + EditorUtility.DisplayDialog( + "迁移完成", + $"任务 \"{quest.name}\" 旧版前置字段已成功迁移:\n\n" + + $" 前置任务:{legacyQuestCount} 项 → prerequisites.questDependencies\n" + + $" 前置标志:{legacyFlagCount} 项 → prerequisites.flagCondition.flags\n\n" + + "旧版字段已清空。操作可通过 Ctrl+Z 撤销。", + "确定"); + } + } +} diff --git a/Assets/_Game/Scripts/Editor/Shared/AssetCreationWizard.cs b/Assets/_Game/Scripts/Editor/Shared/AssetCreationWizard.cs new file mode 100644 index 0000000..2a07b81 --- /dev/null +++ b/Assets/_Game/Scripts/Editor/Shared/AssetCreationWizard.cs @@ -0,0 +1,161 @@ +using System; +using System.IO; +using System.Text.RegularExpressions; +using UnityEditor; +using UnityEngine; +using UnityEngine.UIElements; + +namespace BaseGames.Editor.Shared +{ + /// + /// 资产快速创建向导 —— 弹出式 EditorWindow,引导输入 ID 并预览文件名, + /// 一键创建 ScriptableObject 到指定文件夹。 + /// 用法: AssetCreationWizard.Show<QuestSO>(folder, prefix, (asset, id) => { asset.questId = id; }); + /// + public class AssetCreationWizard : EditorWindow + { + private string _folder; + private string _prefix; + private string _idInput = ""; + private string _typeName; + private Type _assetType; + private Action _onCreated; + + private TextField _idField; + private Label _previewLabel; + + // ── 公开入口 ───────────────────────────────────────────────────────── + + /// + /// 打开向导。资产创建完成后以 (asset, id) 形式回调,调用方可在回调中自行设置 ID 字段。 + /// + public static void Show(string folder, string prefix, Action onCreated) + where T : ScriptableObject + { + string displayName = typeof(T).Name.EndsWith("SO") + ? typeof(T).Name[..^2] + : typeof(T).Name; + + var win = CreateInstance(); + win.titleContent = new GUIContent($"新建 {displayName}"); + win._folder = folder; + win._prefix = prefix; + win._assetType = typeof(T); + win._typeName = displayName; + win._onCreated = (so, id) => onCreated((T)so, id); + win.minSize = new Vector2(320, 132); + win.maxSize = new Vector2(480, 132); + win.ShowUtility(); + } + + // ── UI 构建 ────────────────────────────────────────────────────────── + + private void CreateGUI() + { + var root = rootVisualElement; + root.style.paddingTop = 12; + root.style.paddingBottom = 12; + root.style.paddingLeft = 16; + root.style.paddingRight = 16; + + // 说明行 + var desc = new Label($"将在 {_folder} 中新建一个 {_typeName}"); + desc.style.fontSize = 10; + desc.style.opacity = 0.55f; + desc.style.marginBottom = 10; + root.Add(desc); + + // ID 输入行 + var row = new VisualElement(); + row.style.flexDirection = FlexDirection.Row; + row.style.alignItems = Align.Center; + row.style.marginBottom = 6; + + var idLabel = new Label("ID:"); + idLabel.style.width = 36; + idLabel.style.unityTextAlign = TextAnchor.MiddleLeft; + idLabel.style.flexShrink = 0; + row.Add(idLabel); + + _idField = new TextField { value = "" }; + _idField.style.flexGrow = 1; + _idField.RegisterValueChangedCallback(evt => + { + _idInput = evt.newValue; + RefreshPreview(); + }); + row.Add(_idField); + root.Add(row); + + // 文件名预览 + _previewLabel = new Label("文件名:(请输入 ID)"); + _previewLabel.style.fontSize = 10; + _previewLabel.style.opacity = 0.5f; + _previewLabel.style.marginBottom = 12; + root.Add(_previewLabel); + + // 按钮行 + var btnRow = new VisualElement(); + btnRow.style.flexDirection = FlexDirection.Row; + btnRow.style.justifyContent = Justify.FlexEnd; + + var cancelBtn = new Button(Close) { text = "取消" }; + cancelBtn.style.width = 56; + cancelBtn.style.marginRight = 6; + btnRow.Add(cancelBtn); + + var createBtn = new Button(DoCreate) { text = "创建" }; + createBtn.style.width = 56; + btnRow.Add(createBtn); + + root.Add(btnRow); + + // 自动聚焦 ID 输入框 + _idField.schedule.Execute(() => _idField.Focus()).StartingIn(50); + } + + // ── 私有逻辑 ───────────────────────────────────────────────────────── + + private void RefreshPreview() + { + if (_previewLabel == null) return; + _previewLabel.text = string.IsNullOrWhiteSpace(_idInput) + ? "文件名:(请输入 ID)" + : $"文件名:{_prefix}{_idInput}.asset"; + } + + private void DoCreate() + { + if (string.IsNullOrWhiteSpace(_idInput)) + { + EditorUtility.DisplayDialog("ID 不能为空", "请输入有效的 ID 后再创建。", "确定"); + return; + } + + if (!Regex.IsMatch(_idInput, @"^[\w\-]+$")) + { + EditorUtility.DisplayDialog("ID 格式有误", "ID 只能包含字母、数字、下划线或连字符。", "确定"); + return; + } + + if (!Directory.Exists(_folder)) + Directory.CreateDirectory(_folder); + + string path = $"{_folder}/{_prefix}{_idInput}.asset"; + if (File.Exists(path)) + { + EditorUtility.DisplayDialog("文件已存在", $"路径已存在:\n{path}\n请更换 ID。", "确定"); + return; + } + + var asset = CreateInstance(_assetType) as ScriptableObject; + AssetDatabase.CreateAsset(asset, path); + Undo.RegisterCreatedObjectUndo(asset, $"创建 {_typeName} {_idInput}"); + AssetDatabase.SaveAssets(); + AssetDatabase.Refresh(); + + _onCreated?.Invoke(asset, _idInput); + Close(); + } + } +} diff --git a/Assets/_Game/Scripts/Editor/Shared/SoListPane.cs b/Assets/_Game/Scripts/Editor/Shared/SoListPane.cs index 3f8a3ac..d9df4e6 100644 --- a/Assets/_Game/Scripts/Editor/Shared/SoListPane.cs +++ b/Assets/_Game/Scripts/Editor/Shared/SoListPane.cs @@ -16,6 +16,29 @@ namespace BaseGames.Editor // ── 事件(使用字段委托,允许外部直接赋值替换,避免累积)───────────── public Action SelectionChanged; + /// + /// 附加过滤条件(可选)。返回 true = 保留;返回 false = 过滤掉。 + /// 赋值后调用 使其生效;置 null 时仅按文本搜索过滤。 + /// + public Func ExtraFilter + { + get => _extraFilter; + set { _extraFilter = value; ApplyFilter(); } + } + private Func _extraFilter; + + /// + /// 扩展搜索文本提供器(可选)。返回除资产名以外也纳入搜索的附加文本(如 ID、本地化 Key 等)。 + /// 搜索时将对 "资产名 + 返回值" 拼接文本做不区分大小写的包含匹配。 + /// 赋值后立即重新应用过滤。 + /// + public Func GetExtraSearchText + { + get => _getExtraSearchText; + set { _getExtraSearchText = value; ApplyFilter(); } + } + private Func _getExtraSearchText; + // ── 字段 ───────────────────────────────────────────────────────────── private readonly string _defaultFolder; private readonly string _defaultPrefix; @@ -205,9 +228,25 @@ namespace BaseGames.Editor _filtered.Clear(); foreach (var item in _all) { - if (string.IsNullOrEmpty(_search) || - item.name.IndexOf(_search, StringComparison.OrdinalIgnoreCase) >= 0) - _filtered.Add(item); + bool nameMatch; + if (string.IsNullOrEmpty(_search)) + { + nameMatch = true; + } + else + { + nameMatch = item.name.IndexOf(_search, StringComparison.OrdinalIgnoreCase) >= 0; + if (!nameMatch && _getExtraSearchText != null) + { + var extra = _getExtraSearchText(item); + if (!string.IsNullOrEmpty(extra)) + nameMatch = extra.IndexOf(_search, StringComparison.OrdinalIgnoreCase) >= 0; + } + } + if (!nameMatch) continue; + if (_extraFilter != null && !_extraFilter(item)) continue; + _filtered.Add(item); + } } _listView.RefreshItems(); diff --git a/Assets/_Game/Scripts/EventChain/EventChainManager.cs b/Assets/_Game/Scripts/EventChain/EventChainManager.cs index 20d7de5..cc92df6 100644 --- a/Assets/_Game/Scripts/EventChain/EventChainManager.cs +++ b/Assets/_Game/Scripts/EventChain/EventChainManager.cs @@ -18,6 +18,11 @@ namespace BaseGames.EventChain [Header("所有事件链")] [SerializeField] private EventChainSO[] _chains; + [Header("执行保护")] + [Tooltip("单个 Action 的最长执行时长(秒,unscaled time)。超时后强制跳过并记录警告。0 = 不限时。")] + [Min(0f)] + [SerializeField] private float _maxActionTimeout = 30f; + [Header("事件频道(中继)")] [SerializeField] private StringEventChannelSO _onBossDefeated; // EVT_EnemyDied(bossId) [SerializeField] private StringEventChannelSO _onCollectiblePickedUp; // EVT_CollectiblePickup @@ -33,6 +38,39 @@ namespace BaseGames.EventChain public event Action OnDialogueCompleted; /// 链执行完成时广播 chainId(供 ChainCompletedCondition)。 public event Action OnChainCompleted; + /// + /// 世界标志变更事件(供 FlagSetCondition 事件驱动订阅,避免每帧轮询)。 + /// 由 SetFlagAction.ExecuteAsync 在写入标志后调用 NotifyFlagChanged 触发。 + /// + public event Action OnWorldFlagChanged; + + /// 通知所有 FlagSetCondition 指定标志已变更,并立即重评估标志相关链条件。 + public void NotifyFlagChanged(string flagId) + { + OnWorldFlagChanged?.Invoke(flagId); + EvaluateForMask(ChainEventMask.FlagChanged); + } + + /// + /// 强制执行指定链,完全跳过条件检查。 + /// 用于调试、关卡测试,或 QA 快速验证后续事件逻辑。 + /// 仅在 Play Mode 中有效(链执行需要运行时环境)。 + /// + public void ForceExecute(string chainId) + { + if (_chains == null) + { + Debug.LogWarning("[EventChainManager] ForceExecute: _chains 为空,没有可执行的事件链。", this); + return; + } + foreach (var chain in _chains) + { + if (chain == null || chain.chainId != chainId) continue; + StartCoroutine(ExecuteChain(chain)); + return; + } + Debug.LogWarning($"[EventChainManager] ForceExecute: 未找到 chainId='{chainId}' 的事件链。", this); + } #if UNITY_EDITOR /// @@ -46,6 +84,15 @@ namespace BaseGames.EventChain private readonly HashSet _completedChains = new(); private readonly CompositeDisposable _subs = new(); + // 每个链对应的事件掩码:OnEnable 后通过 BuildChainMasks 构建 + private List _chainMasks; + + private struct ChainMaskEntry + { + public EventChainSO chain; + public ChainEventMask mask; + } + // ── 生命周期 ────────────────────────────────────────────────────── private void Awake() @@ -56,11 +103,11 @@ namespace BaseGames.EventChain private void OnEnable() { - _onBossDefeated?.Subscribe(id => { OnBossDefeated?.Invoke(id); EvaluateAll(); }).AddTo(_subs); - _onCollectiblePickedUp?.Subscribe(id => { OnCollectiblePickedUp?.Invoke(id); EvaluateAll(); }).AddTo(_subs); - _onAbilityUnlocked?.Subscribe(id => { OnAbilityUnlocked?.Invoke(id); EvaluateAll(); }).AddTo(_subs); - _onRoomEntered?.Subscribe(id => { OnRoomEntered?.Invoke(id); EvaluateAll(); }).AddTo(_subs); - _onDialogueCompleted?.Subscribe(id => { OnDialogueCompleted?.Invoke(id); EvaluateAll(); }).AddTo(_subs); + _onBossDefeated?.Subscribe(id => { OnBossDefeated?.Invoke(id); EvaluateForMask(ChainEventMask.Boss); }).AddTo(_subs); + _onCollectiblePickedUp?.Subscribe(id => { OnCollectiblePickedUp?.Invoke(id); EvaluateForMask(ChainEventMask.Collectible); }).AddTo(_subs); + _onAbilityUnlocked?.Subscribe(id => { OnAbilityUnlocked?.Invoke(id); EvaluateForMask(ChainEventMask.Ability); }).AddTo(_subs); + _onRoomEntered?.Subscribe(id => { OnRoomEntered?.Invoke(id); EvaluateForMask(ChainEventMask.Room); }).AddTo(_subs); + _onDialogueCompleted?.Subscribe(id => { OnDialogueCompleted?.Invoke(id); EvaluateForMask(ChainEventMask.Dialogue); }).AddTo(_subs); ServiceLocator.GetOrDefault()?.Register(this); @@ -68,12 +115,26 @@ namespace BaseGames.EventChain // 先重置 SO 资产上的运行时状态,防止跨 PlayMode 会话或多场景加载时状态残留 if (_chains == null) return; foreach (var chain in _chains) + { + // 旧版 conditions[] 字段 if (chain?.conditions != null) foreach (var cond in chain.conditions) { cond?.ResetState(); cond?.Register(this); } + // 新版 conditionGroups[] 字段 + if (chain?.conditionGroups != null) + foreach (var group in chain.conditionGroups) + if (group?.conditions != null) + foreach (var cond in group.conditions) + { + cond?.ResetState(); + cond?.Register(this); + } + } + + BuildChainMasks(); } private void OnDisable() @@ -83,9 +144,16 @@ namespace BaseGames.EventChain if (_chains == null) return; foreach (var chain in _chains) + { if (chain?.conditions != null) foreach (var cond in chain.conditions) cond?.Unregister(this); + if (chain?.conditionGroups != null) + foreach (var group in chain.conditionGroups) + if (group?.conditions != null) + foreach (var cond in group.conditions) + cond?.Unregister(this); + } } // ── ISaveable ───────────────────────────────────────────────────────── @@ -108,26 +176,98 @@ namespace BaseGames.EventChain } // ── 评估逻辑 ────────────────────────────────────────────────────── - /// 收到新事件时立即评估所有链条件(无帧延迟)。 - private void EvaluateAll() => DoEvaluateAll(); - private void DoEvaluateAll() + /// + /// 构建每条链的 ChainEventMask(链内所有条件 RelevantEvents 的并集)。 + /// OnEnable 在所有条件 Register 完毕后调用,之后事件触发只评估掩码相交的链。 + /// + private void BuildChainMasks() { + _chainMasks = new List(_chains?.Length ?? 0); if (_chains == null) return; foreach (var chain in _chains) { if (chain == null) continue; + ChainEventMask mask = ChainEventMask.None; + + bool hasGroups = chain.conditionGroups != null && chain.conditionGroups.Length > 0; + if (hasGroups) + { + foreach (var group in chain.conditionGroups) + if (group?.conditions != null) + foreach (var cond in group.conditions) + if (cond != null) mask |= cond.RelevantEvents; + } + else if (chain.conditions != null) + { + foreach (var cond in chain.conditions) + if (cond != null) mask |= cond.RelevantEvents; + } + + // 无条件链:任何事件均可触发(等同 Any) + if (mask == ChainEventMask.None) mask = ChainEventMask.Any; + _chainMasks.Add(new ChainMaskEntry { chain = chain, mask = mask }); + } + } + + /// + /// 懒评估:仅评估 mask 与 相交的链。 + /// ChainEventMask.Any(-1)与任何非零 mask 均相交,确保自定义条件链不被跳过。 + /// + private void EvaluateForMask(ChainEventMask triggerMask) + { + if (_chainMasks == null) return; + foreach (var entry in _chainMasks) + { + if ((entry.mask & triggerMask) == 0) continue; + var chain = entry.chain; if (!chain.repeatable && _completedChains.Contains(chain.chainId)) continue; - bool allMet = true; - if (chain.conditions != null) - foreach (var cond in chain.conditions) - if (cond != null && !cond.IsMet()) { allMet = false; break; } - - if (allMet) StartCoroutine(ExecuteChain(chain)); + if (EvaluateChainConditions(chain)) StartCoroutine(ExecuteChain(chain)); } } + /// + /// 评估链的所有触发条件。 + /// 若链配置了 ,按组逻辑评估(组内 And/Or,组间 And); + /// 否则回退到旧版 隐式 And 逻辑。 + /// + private static bool EvaluateChainConditions(EventChainSO chain) + { + bool hasGroups = chain.conditionGroups != null && chain.conditionGroups.Length > 0; + if (hasGroups) + { + // 组间为 And:所有组均须通过 + foreach (var group in chain.conditionGroups) + { + if (group?.conditions == null || group.conditions.Length == 0) continue; + if (!EvaluateConditionGroup(group)) return false; + } + return true; + } + + // 旧版:conditions[] 隐式 And + if (chain.conditions != null) + foreach (var cond in chain.conditions) + if (cond != null && !cond.IsMet()) return false; + return true; + } + + /// 按组内 logic(And/Or)评估单个条件组是否通过。 + private static bool EvaluateConditionGroup(ConditionGroup group) + { + if (group.logic == WorldStateFlagLogic.Or) + { + foreach (var cond in group.conditions) + if (cond != null && cond.IsMet()) return true; + return false; + } + // And(默认) + foreach (var cond in group.conditions) + if (cond != null && !cond.IsMet()) return false; + return true; + } + private IEnumerator ExecuteChain(EventChainSO chain) { // 防重入:一次性链立即标记为已完成 @@ -137,7 +277,62 @@ namespace BaseGames.EventChain foreach (var action in chain.actions) { if (action == null) continue; - yield return action.ExecuteAsync(this); + + bool actionFailed = false; + if (_maxActionTimeout > 0f) + { + bool finished = false; + Coroutine co = null; + try + { + co = StartCoroutine(SetTrueOnFinish(action.ExecuteAsync(this), () => finished = true)); + } + catch (System.Exception ex) + { + Debug.LogError( + $"[EventChainManager] 链 '{chain.chainId}' 动作 '{action.GetType().Name}' 启动异常:{ex.Message}", + this); + actionFailed = true; + } + + if (!actionFailed) + { + // 用 realtimeSinceStartup 计时,确保 PlayMode 暂停时超时仍能触发 + float deadline = Time.realtimeSinceStartup + _maxActionTimeout; + while (!finished) + { + if (Time.realtimeSinceStartup >= deadline) + { + if (co != null) StopCoroutine(co); + Debug.LogWarning( + $"[EventChainManager] 链 '{chain.chainId}' 的动作 '{action.GetType().Name}' " + + $"执行超时({_maxActionTimeout}s),已强制跳过。"); +#if UNITY_EDITOR + OnChainExecutedInEditor?.Invoke(chain.chainId, + $"超时跳过:{action.GetType().Name}(>{_maxActionTimeout}s)"); +#endif + break; + } + yield return null; + } + } + } + else + { + bool exceptionThrown = false; + IEnumerator routine = null; + try { routine = action.ExecuteAsync(this); } + catch (System.Exception ex) + { + Debug.LogError( + $"[EventChainManager] 链 '{chain.chainId}' 动作 '{action.GetType().Name}' 异常:{ex.Message}", + this); + exceptionThrown = true; + } + if (!exceptionThrown && routine != null) + yield return routine; + } + if (chain.actionDelay > 0f) yield return new WaitForSeconds(chain.actionDelay); } @@ -148,5 +343,15 @@ namespace BaseGames.EventChain OnChainExecutedInEditor?.Invoke(chain.chainId, "执行完成"); #endif } + + /// + /// 将 inner 协程执行完毕后触发 onFinish 回调,供超时保护使用。 + /// StopCoroutine 此协程时,内嵌的 inner(通过 yield return 直接展开)也会一并停止。 + /// + private static IEnumerator SetTrueOnFinish(IEnumerator inner, Action onFinish) + { + yield return inner; + onFinish(); + } } } diff --git a/Assets/_Game/Scripts/EventChain/EventChainSO.cs b/Assets/_Game/Scripts/EventChain/EventChainSO.cs index 9233053..4d893e9 100644 --- a/Assets/_Game/Scripts/EventChain/EventChainSO.cs +++ b/Assets/_Game/Scripts/EventChain/EventChainSO.cs @@ -2,7 +2,6 @@ using System; using System.Collections; using BaseGames.Core; using BaseGames.Core.Events; -using BaseGames.Dialogue; using UnityEngine; namespace BaseGames.EventChain @@ -26,8 +25,14 @@ namespace BaseGames.EventChain public float actionDelay = 0f; // 各 action 之间的延迟(秒),0 = 紧接着执行 [Header("触发条件(全部满足才触发)")] + [Tooltip("旧版条件列表(隐式 And 逻辑)。新配置推荐改用 conditionGroups。\n" + + "conditionGroups 非空时,此字段被忽略。")] public ChainCondition[] conditions; + [Tooltip("条件组列表(新版)。每组内部支持 And / Or 逻辑,多组之间为 And 关系(全部组满足才触发)。\n" + + "此字段非空时,旧版 conditions 字段将被忽略。")] + public ConditionGroup[] conditionGroups; + [Header("执行动作(顺序执行)")] public ChainAction[] actions; } @@ -46,12 +51,40 @@ namespace BaseGames.EventChain public abstract void Register(EventChainManager manager); public abstract void Unregister(EventChainManager manager); public abstract bool IsMet(); + /// /// 重置运行时瞬态状态(每次 EventChainManager.OnEnable 时调用)。 /// ScriptableObject 是资产,_met 等字段会跨 PlayMode 会话残留; /// 显式重置确保每次进入游戏/切换场景时条件均从初始状态开始评估。 /// public virtual void ResetState() { } + + /// + /// 声明此条件关心哪类运行时事件。 + /// EventChainManager 在构建链桶时使用此掩码,使评估仅在相关事件到来时触发, + /// 跳过无关事件,降低 EvaluateAll 的无效迭代次数。 + /// 默认返回 Any(适配自定义条件:任何事件均触发评估)。 + /// + public virtual ChainEventMask RelevantEvents => ChainEventMask.Any; + } + + /// + /// 位掩码:标识事件链条件关心的运行时事件类别。 + /// 用于 EventChainManager 构建懒评估桶,减少每次事件触发时的无关链扫描。 + /// + [System.Flags] + public enum ChainEventMask + { + None = 0, + Boss = 1 << 0, + Collectible = 1 << 1, + Ability = 1 << 2, + Room = 1 << 3, + Dialogue = 1 << 4, + FlagChanged = 1 << 5, + Chain = 1 << 6, + /// 不区分事件类别;任何事件均触发评估(自定义条件的默认值)。 + Any = -1, } // ─── 内置条件 ────────────────────────────────────────────────────────── @@ -65,6 +98,7 @@ namespace BaseGames.EventChain public override void Unregister(EventChainManager m) => m.OnBossDefeated -= Check; public override bool IsMet() => _met; public override void ResetState() => _met = false; + public override ChainEventMask RelevantEvents => ChainEventMask.Boss; private void Check(string id) { if (id == bossId) _met = true; } } @@ -72,9 +106,47 @@ namespace BaseGames.EventChain public class FlagSetCondition : ChainCondition { public string flagId; - public override void Register(EventChainManager m) { } // 持续轮询,无需订阅 - public override void Unregister(EventChainManager m) { } - public override bool IsMet() { var sm = ServiceLocator.GetOrDefault(); return sm != null && sm.GetFlag(flagId); } + [System.NonSerialized] private bool _met; + [System.NonSerialized] private bool _initialized; // 延迟初始化标记 + + public override void Register(EventChainManager m) + { + // 订阅事件;实际标志值延迟到首次 IsMet() 调用时从 SaveService 读取, + // 避免 Register 早于 SaveService 注册时读取失败 + m.OnWorldFlagChanged += OnFlagChanged; + } + public override void Unregister(EventChainManager m) + { + m.OnWorldFlagChanged -= OnFlagChanged; + } + public override bool IsMet() + { + if (!_initialized) + { + var sm = ServiceLocator.GetOrDefault(); + if (sm != null) + { + _met = sm.GetFlag(flagId); + _initialized = true; + } +#if UNITY_EDITOR || DEVELOPMENT_BUILD + else + Debug.LogWarning( + $"[FlagSetCondition] 标志 '{flagId}' 首次 IsMet() 时 ISaveService 尚未注册," + + "返回 false(将在 SaveService 注册后通过 OnWorldFlagChanged 更新)。"); +#endif + } + return _met; + } + public override void ResetState() { _met = false; _initialized = false; } + public override ChainEventMask RelevantEvents => ChainEventMask.FlagChanged; + + private void OnFlagChanged(string changedFlagId) + { + if (changedFlagId != flagId) return; + var sm = ServiceLocator.GetOrDefault(); + if (sm != null) { _met = sm.GetFlag(flagId); _initialized = true; } + } } [CreateAssetMenu(menuName = "BaseGames/EventChain/Condition/AbilityUnlocked")] @@ -86,6 +158,7 @@ namespace BaseGames.EventChain public override void Unregister(EventChainManager m) => m.OnAbilityUnlocked -= Check; public override bool IsMet() => _met; public override void ResetState() => _met = false; + public override ChainEventMask RelevantEvents => ChainEventMask.Ability; private void Check(string id) { if (id == abilityId) _met = true; } } @@ -98,6 +171,7 @@ namespace BaseGames.EventChain public override void Unregister(EventChainManager m) => m.OnCollectiblePickedUp -= Check; public override bool IsMet() => _met; public override void ResetState() => _met = false; + public override ChainEventMask RelevantEvents => ChainEventMask.Collectible; private void Check(string id) { if (id == itemId) _met = true; } } @@ -110,6 +184,7 @@ namespace BaseGames.EventChain public override void Unregister(EventChainManager m) => m.OnRoomEntered -= Check; public override bool IsMet() => _met; public override void ResetState() => _met = false; + public override ChainEventMask RelevantEvents => ChainEventMask.Room; private void Check(string id) { if (id == sceneName) _met = true; } } @@ -122,6 +197,7 @@ namespace BaseGames.EventChain public override void Unregister(EventChainManager m) => m.OnDialogueCompleted -= Check; public override bool IsMet() => _met; public override void ResetState() => _met = false; + public override ChainEventMask RelevantEvents => ChainEventMask.Dialogue; private void Check(string id) { if (id == npcId) _met = true; } } @@ -134,9 +210,29 @@ namespace BaseGames.EventChain public override void Unregister(EventChainManager m) => m.OnChainCompleted -= Check; public override bool IsMet() => _met; public override void ResetState() => _met = false; + public override ChainEventMask RelevantEvents => ChainEventMask.Chain; private void Check(string id) { if (id == chainId) _met = true; } } + // ===================================================================== + // ConditionGroup ── 支持 And/Or 逻辑的条件分组 + // ===================================================================== + + /// + /// 条件组:将多个 以 And 或 Or 逻辑组合。 + /// 多个条件组之间始终为 And 关系(所有组均须满足)。 + /// 在 中配置,替代旧版隐式 And 的 conditions[]。 + /// + [System.Serializable] + public class ConditionGroup + { + [Tooltip("组内条件的逻辑关系:\n And(默认)= 全部条件均须满足\n Or = 任意一个条件满足即可触发本组通过")] + public WorldStateFlagLogic logic = WorldStateFlagLogic.And; + + [Tooltip("本组的条件列表。")] + public ChainCondition[] conditions; + } + // ===================================================================== // ChainAction 抽象基类 + 内置实现 // ===================================================================== @@ -164,13 +260,22 @@ namespace BaseGames.EventChain } } - /// 设置/清除存档标志。 + /// 设置/清除存档标志。设置后通知 EventChainManager 触发条件重评估。 [CreateAssetMenu(menuName = "BaseGames/EventChain/Action/SetFlag")] public class SetFlagAction : ChainAction { - [WorldStateFlag] + [Tooltip("世界状态标志 Key(字符串),由 ISaveService 持久化。")] public string flagId; public bool value = true; + +#if UNITY_EDITOR + private void OnValidate() + { + if (string.IsNullOrWhiteSpace(flagId)) + Debug.LogWarning( + $"[SetFlagAction] '{name}': flagId 为空,执行时将静默失败。请填写有效的 flagId。", this); + } +#endif public override IEnumerator ExecuteAsync(MonoBehaviour runner) { var saveService = ServiceLocator.GetOrDefault(); @@ -180,6 +285,11 @@ namespace BaseGames.EventChain yield break; } saveService.SetFlag(flagId, value); + + // 通知 EventChainManager 标志已变更,触发 FlagSetCondition 重新评估(事件驱动,无需轮询) + if (runner is EventChainManager ecm) + ecm.NotifyFlagChanged(flagId); + yield break; } } @@ -204,28 +314,67 @@ namespace BaseGames.EventChain public string cutsceneId; [SerializeField] private StringEventChannelSO _onPlayCutscene; // → CutsceneManager.PlayById [SerializeField] private VoidEventChannelSO _onCutsceneEnded; // ← CutsceneManager 播完后 Raise + [Tooltip("等待过场动画结束的超时时间(秒)。超时后记录错误并继续执行事件链,避免链永久挂起。\n0 = 永不超时(不推荐,可能导致链死锁)。")] + [Min(0f)] [SerializeField] private float timeoutSeconds = 60f; + public override IEnumerator ExecuteAsync(MonoBehaviour runner) { bool done = false; + // 用 try/finally 确保即使协程被强制停止(StopAllCoroutines)时也能正确退订 var sub = _onCutsceneEnded != null ? _onCutsceneEnded.Subscribe(() => done = true) : default(EventSubscription); - _onPlayCutscene?.Raise(cutsceneId); - yield return new WaitUntil(() => done); - sub.Dispose(); + try + { + _onPlayCutscene?.Raise(cutsceneId); + + if (timeoutSeconds > 0f) + { + // 超时保护:防止 CutsceneManager 未触发 Ended 事件导致链永久挂起 + float elapsed = 0f; + while (!done && elapsed < timeoutSeconds) + { + elapsed += UnityEngine.Time.deltaTime; + yield return null; + } + if (!done) + Debug.LogError( + $"[PlayCutsceneAction] 过场动画 '{cutsceneId}' 等待超时({timeoutSeconds}s)。" + + "请确认 CutsceneManager 在动画结束后调用了 _onCutsceneEnded.Raise()。"); + } + else + { + yield return new WaitUntil(() => done); + } + } + finally + { + sub.Dispose(); + } } } - /// 切换 NPC 对话(通过 EVT_NPCDialogueChange 广播,NPC 自行响应)。 + /// 切换 NPC 对话(通过 EVT_NPCDialogueChange 强类型事件广播,NPC 自行响应)。 [CreateAssetMenu(menuName = "BaseGames/EventChain/Action/ChangeNPCDialogue")] public class ChangeNPCDialogueAction : ChainAction { + [Tooltip("目标 NPC 的唯一 ID(对应 NpcSO.npcId)。")] public string npcId; + [Tooltip("要切换到的对话序列 ID(对应 DialogueSequenceSO.sequenceId)。")] public string newSequenceId; - [SerializeField] private StringEventChannelSO _onNPCDialogueChange; // EVT_NPCDialogueChange → NPC 订阅 + /// + /// 强类型事件频道(NpcDialogueChangeEventChannelSO)。 + /// NPC 组件订阅后根据 npcId 字段过滤,无需 Split 字符串。 + /// 资产:Assets/ScriptableObjects/Events/EVT_NpcDialogueChange.asset + /// + [SerializeField] private BaseGames.Core.Events.NpcDialogueChangeEventChannelSO _onNPCDialogueChange; public override IEnumerator ExecuteAsync(MonoBehaviour runner) { - _onNPCDialogueChange?.Raise($"{npcId}:{newSequenceId}"); + _onNPCDialogueChange?.Raise(new BaseGames.Core.Events.NpcDialogueChangeEvent + { + npcId = npcId, + newSequenceId = newSequenceId + }); yield break; } } diff --git a/Assets/_Game/Scripts/Quest/IQuestManager.cs b/Assets/_Game/Scripts/Quest/IQuestManager.cs index 32b5d56..2758429 100644 --- a/Assets/_Game/Scripts/Quest/IQuestManager.cs +++ b/Assets/_Game/Scripts/Quest/IQuestManager.cs @@ -1,8 +1,70 @@ +using System; using BaseGames.Core.Events; using QuestStateEnum = BaseGames.Core.Events.QuestState; namespace BaseGames.Quest { + // ========================================================================= + // QuestLockReason / QuestLockInfo ── 任务锁定原因(强类型 API) + // ========================================================================= + + /// 任务无法接取的原因枚举。 表示无锁定(可接取)。 + public enum QuestLockReason + { + /// 无锁定,任务当前可以接取。 + None, + /// 任务已在进行中(Active)。 + AlreadyActive, + /// 任务已完成(Completed)。 + AlreadyCompleted, + /// 任务已失败(Failed)。 + Failed, + /// 任务已暂停(Paused)。 + Paused, + /// 任务 ID 未找到或资产未加载。 + NotFound, + /// 好感度或存档数据尚未初始化。 + DataNotLoaded, + /// NPC 好感度不足。 格式:"{actual}/{min}"。 + InsufficientAffinity, + /// 前置任务未完成。 为该前置任务的 questId。 + RequiresQuest, + /// 世界状态标志条件未满足。 + FlagConditionNotMet, + } + + /// + /// 任务锁定信息(强类型版本)。 + /// 相比字符串 Key,可在编译期检查原因类型,UI 层无需手动解析冒号分隔的参数。 + /// 通过 可转换为与旧版 GetQuestLockReason 兼容的 Key 格式。 + /// + public struct QuestLockInfo + { + /// 锁定原因枚举值。 表示无锁定(可接取)。 + public QuestLockReason Reason; + + /// + /// 附带参数(可选):
+ /// - :前置任务 questId
+ /// - :格式 "{actual}/{min}" + ///
+ public string Param; + + /// 任务当前是否处于锁定状态(不可接取)。 + public bool IsLocked => Reason != QuestLockReason.None; + + /// + /// 转换为本地化 Key 格式,与旧版 完全兼容。 + /// 格式:"Quest.LockReason.{Reason}";有参数时为 "Quest.LockReason.{Reason}:{Param}"。 + /// + public string ToLocalizationKey() => + Reason == QuestLockReason.None + ? string.Empty + : string.IsNullOrEmpty(Param) + ? $"Quest.LockReason.{Reason}" + : $"Quest.LockReason.{Reason}:{Param}"; + } + /// /// 任务管理器的公开契约。ServiceLocator.Get<IQuestManager>() 获取实例, /// 避免外部代码直接依赖 QuestManager 具体类型。 @@ -12,13 +74,99 @@ namespace BaseGames.Quest /// 接取任务(幂等)。 void AcceptQuest(string questId); + /// + /// 主动放弃进行中的任务(Active → Available/Unavailable),清除目标进度。 + /// 非 Active 状态的任务调用此方法无效。 + /// + void AbandonQuest(string questId); + /// 完成任务并发放奖励。rewardTarget 接收奖励(如玩家)。 void CompleteQuest(string questId, IRewardTarget rewardTarget); + /// + /// 暂停进行中的任务(Active → Paused)。暂停期间目标不推进,失败条件不判定。 + /// 非 Active 状态的任务调用此方法无效。 + /// + void PauseQuest(string questId); + + /// + /// 恢复已暂停的任务(Paused → Active)。 + /// 非 Paused 状态的任务调用此方法无效。 + /// + void ResumeQuest(string questId); + /// 返回当前任务状态。未知 questId 返回 Unavailable。 QuestStateEnum GetState(string questId); /// 判断任务是否满足完成条件。 bool IsReadyToComplete(string questId); + + /// 返回指定 NPC 的当前好感度数值(未记录时返回 0)。 + int GetNpcAffinity(string npcId); + + /// + /// 返回任务无法被接取的原因(本地化 Key 格式)。 + /// 若任务当前可以接取,返回空字符串。 + /// Key 格式:"Quest.LockReason.{Reason}";带动态参数时以冒号分隔,如 + /// "Quest.LockReason.RequiresQuest:Quest_FindMushroom"。 + /// 推荐新代码使用 获取强类型结果,无需手动解析字符串。 + /// + string GetQuestLockReason(string questId); + + /// + /// 返回任务无法被接取的强类型锁定信息。 + /// 相比 ,可在编译期检查原因枚举,UI 层无需解析字符串。 + /// 若任务当前可以接取,返回 的实例。 + /// + QuestLockInfo GetQuestLockInfo(string questId); } + + /// + /// 任务事件订阅接口。 + /// 外部系统(成就、地图标记、HUD、埋点)通过此接口订阅任务生命周期事件, + /// 无需直接持有 StringEventChannelSO,保持与 QuestManager 具体实现的解耦。 + /// 获取方式:ServiceLocator.Get<IQuestManager>() as IQuestEventSource + /// + public interface IQuestEventSource + { + /// 任务成功接取时触发。参数 = questId。 + event Action OnQuestStarted; + /// 任务完成时触发。参数 = questId。 + event Action OnQuestCompleted; + /// 任务失败时触发。参数 = questId。 + event Action OnQuestFailed; + /// 任务被主动放弃时触发。参数 = questId。 + event Action OnQuestAbandoned; + /// 任务暂停时触发(Active → Paused)。参数 = questId。供埋点/分析系统使用。 + event Action OnQuestPaused; + /// 任务从暂停恢复时触发(Paused → Active)。参数 = questId。供埋点/分析系统使用。 + event Action OnQuestResumed; + /// 目标全部达成、可回去交任务时触发(去重,同任务只触发一次)。参数 = questId。 + event Action OnQuestReadyToComplete; + /// + /// 任务状态发生任意转换时触发(涵盖所有状态变更,含旧状态和新状态)。 + /// 供状态机审计面板、通用 UI 绑定(无需分别订阅六个离散事件)使用。 + /// 参数:(questId, oldState, newState)。 + /// + event Action OnQuestStateChanged; + } + +#if UNITY_EDITOR || DEVELOPMENT_BUILD + /// + /// 任务调试接口(仅编辑器 / 开发构建可用)。 + /// 通过 (IQuestManager as IQuestDebugger)?.ResetQuest(id) 使用, + /// 正式发布构建中此接口不存在,调用方无需任何 #if 守卫。 + /// + public interface IQuestDebugger + { + /// + /// 将任务重置为 Available(前置满足)或 Unavailable(前置未满足),并清除目标进度。 + /// 不广播 QuestStarted / QuestCompleted 等运行时事件,仅用于开发/调试。 + /// + /// 要重置的任务 ID。 + /// 若为 true(默认),同步回滚此任务对应 NPC 的好感度增量, + /// 防止调试期间重复完成导致好感度叠加。 + void ResetQuest(string questId, bool rollbackAffinity = true); + } +#endif } diff --git a/Assets/_Game/Scripts/Quest/NpcAffinityEvent.cs b/Assets/_Game/Scripts/Quest/NpcAffinityEvent.cs new file mode 100644 index 0000000..48a76fd --- /dev/null +++ b/Assets/_Game/Scripts/Quest/NpcAffinityEvent.cs @@ -0,0 +1,27 @@ +using UnityEngine; +using BaseGames.Core.Events; + +namespace BaseGames.Quest +{ + /// + /// NPC 好感度变化事件的强类型负载。 + /// 替代原 "npcId|delta" 字符串分割方案,杜绝接收方 Split 解析脆弱性。 + /// + [System.Serializable] + public struct NpcAffinityEvent + { + /// 发生好感度变化的 NPC ID(与 QuestSO.giverNpcId 保持一致)。 + public string npcId; + /// 好感度变化量(正值=增加,负值=减少)。 + public int delta; + /// 变化后的当前总好感度数值。 + public int newTotal; + } + + /// + /// EVT_NpcAffinityChanged 专用事件频道 SO(强类型,负载 )。 + /// 放置路径: Assets/ScriptableObjects/Events/EVT_NpcAffinityChanged.asset + /// + [CreateAssetMenu(menuName = "BaseGames/Events/NpcAffinity")] + public class NpcAffinityEventChannelSO : BaseEventChannelSO { } +} diff --git a/Assets/_Game/Scripts/Quest/NpcAffinityEvent.cs.meta b/Assets/_Game/Scripts/Quest/NpcAffinityEvent.cs.meta new file mode 100644 index 0000000..35f327d --- /dev/null +++ b/Assets/_Game/Scripts/Quest/NpcAffinityEvent.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: df5e857463388a249893d48dda71c54b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Quest/QuestEventChannelRegistry.cs b/Assets/_Game/Scripts/Quest/QuestEventChannelRegistry.cs new file mode 100644 index 0000000..a915248 --- /dev/null +++ b/Assets/_Game/Scripts/Quest/QuestEventChannelRegistry.cs @@ -0,0 +1,61 @@ +using BaseGames.Core.Events; +using UnityEngine; + +namespace BaseGames.Quest +{ + /// + /// 任务事件频道注册表 SO(架构 22_QuestChallengeModule §4.1)。 + /// 将 QuestManager 的 10+ 个分散事件频道字段集中到一个可复用的 ScriptableObject 中, + /// 便于多场景共享同一套频道配置,同时减少 QuestManager Inspector 的视觉复杂度。 + /// + /// 使用方式: + /// 1. 创建一个 QuestEventChannelRegistry 资产(菜单:BaseGames/Quest/EventChannelRegistry)。 + /// 2. 在资产中将现有各 EventChannelSO 拖入对应字段。 + /// 3. 将资产引用填入 QuestManager 的 "事件频道注册表" 字段。 + /// 4. QuestManager 的独立频道字段将自动隐藏(通过注册表覆盖)。 + /// + [CreateAssetMenu(menuName = "BaseGames/Quest/EventChannelRegistry", fileName = "QuestEventChannelRegistry")] + public class QuestEventChannelRegistry : ScriptableObject + { + [Header("输入频道(QuestManager 监听)")] + [Tooltip("EVT_EnemyDied:payload = enemyId(string)。敌人死亡时由战斗系统广播,驱动击败类目标进度。")] + public StringEventChannelSO onEnemyDied; + [Tooltip("EVT_CollectiblePickup:payload = itemId(string)。拾取物品时广播,驱动收集类目标进度。")] + public StringEventChannelSO onCollectiblePickup; + [Tooltip("EVT_SceneLoaded:payload = sceneName(string)。场景切换完成时广播,驱动到达类目标进度。")] + public StringEventChannelSO onSceneLoaded; + [Tooltip("EVT_NpcDialogueCompleted:payload = npcId(string)。DialogueManager 播完一段对话后广播,驱动对话类目标进度。")] + public StringEventChannelSO onNpcDialogueCompleted; + [Tooltip("EVT_SkillUsed:payload = AbilityType.ToString()(string)。玩家使用技能时广播,驱动技能使用类目标进度。")] + public StringEventChannelSO onSkillUsed; + [Tooltip("EVT_AreaReached:payload = markerTag(string)。TriggerZone 在玩家进入时广播,驱动区域到达类目标进度。")] + public StringEventChannelSO onAreaReached; + + [Header("广播频道(QuestManager 广播)")] + [Tooltip("EVT_QuestStarted:payload = questId。AcceptQuest 成功后广播。")] + public StringEventChannelSO onQuestStarted; + [Tooltip("EVT_QuestCompleted:payload = questId。CompleteQuest 成功后广播。")] + public StringEventChannelSO onQuestCompleted; + [Tooltip("EVT_QuestFailed:payload = questId。失败条件触发后广播。")] + public StringEventChannelSO onQuestFailed; + [Tooltip("EVT_QuestAbandoned:payload = questId。玩家放弃任务时广播。")] + public StringEventChannelSO onQuestAbandoned; + [Tooltip("EVT_QuestPaused:payload = questId。PauseQuest 成功后广播。")] + public StringEventChannelSO onQuestPaused; + [Tooltip("EVT_QuestResumed:payload = questId。ResumeQuest 成功后广播。")] + public StringEventChannelSO onQuestResumed; + [Tooltip("EVT_QuestReadyToComplete:payload = questId。目标全部达成时广播一次(去重)。")] + public StringEventChannelSO onQuestReadyToComplete; + [Tooltip("EVT_QuestObjectiveUpdated:目标进度变化时广播(强类型 QuestObjectiveEvent)。")] + public QuestObjectiveEventChannelSO onObjectiveUpdated; + [Tooltip("EVT_QuestObjectiveBatchUpdated:同帧内多目标聚合后广播一次(避免 HUD 同帧多次重绘)。")] + public QuestObjectiveBatchEventChannelSO onObjectiveBatchUpdated; + [Tooltip("EVT_NpcAffinityChanged:NPC 好感度变化(强类型 NpcAffinityEvent)。")] + public NpcAffinityEventChannelSO onNpcAffinityChanged; + [Tooltip("EVT_DialogueKeyUnlocked:payload = unlockDialogueKey,供 NPC 台词系统监听。")] + public StringEventChannelSO onDialogueKeyUnlocked; + [Tooltip("EVT_DialogueChoiceSelected:玩家选择对话选项时广播(payload = \"sequenceId/choiceIndex\")。\n" + + "供 QA 埋点、成就系统、或数据分析监听,以还原玩家的对话选择路径。")] + public StringEventChannelSO onDialogueChoiceSelected; + } +} diff --git a/Assets/_Game/Scripts/Quest/QuestGiver.cs b/Assets/_Game/Scripts/Quest/QuestGiver.cs index 7c0b1c8..448fe9a 100644 --- a/Assets/_Game/Scripts/Quest/QuestGiver.cs +++ b/Assets/_Game/Scripts/Quest/QuestGiver.cs @@ -14,16 +14,42 @@ namespace BaseGames.Quest public class QuestGiver : InteractableNPC { [Header("任务")] - [SerializeField] private QuestSO[] _offeredQuests; // 该 NPC 可提供的所有任务(按优先级排列) + [Tooltip("该 NPC 可提供的所有任务,按优先级从高到低排列。\n" + + "交互时从列表头部找到第一个 Available 或 Active 状态的任务作为当前任务;\n" + + "若全部已完成,显示最后一个已完成任务的 completedDialogue。")] + [SerializeField] private QuestSO[] _offeredQuests; [Header("对话版本(根据任务状态切换)")] - [SerializeField] private DialogueSequenceSO _availableDialogue; // 任务可接时 - [SerializeField] private DialogueSequenceSO _activeDialogue; // 任务进行中 - [SerializeField] private DialogueSequenceSO _readyDialogue; // 完成条件满足时 - [SerializeField] private DialogueSequenceSO _completedDialogue; // 任务已完成后 + [Tooltip("任务尚未接取(QuestState.Available)时播放。通常是 NPC 发布任务、介绍背景的对话。")] + [SerializeField] private DialogueSequenceSO _availableDialogue; + [Tooltip("任务已接取、目标尚未全部完成(QuestState.Active)时播放。通常是 NPC 催促或加油打气的对话。")] + [SerializeField] private DialogueSequenceSO _activeDialogue; + [Tooltip("全部非可选目标已达成、任务可以交付时播放(IsReadyToComplete = true)。\n" + + "通常是 NPC 感谢、确认收取物品的对话,播放后执行 CompleteQuest 逻辑。")] + [SerializeField] private DialogueSequenceSO _readyDialogue; + [Tooltip("任务已完成(QuestState.Completed)后再次交互时播放。通常是 NPC 闲聊或后续剧情的对话。")] + [SerializeField] private DialogueSequenceSO _completedDialogue; // ── InteractableNPC 覆盖 ────────────────────────────────────────────── + public override string InteractPrompt + { + get + { + var qm = SL.GetOrDefault(); + var quest = GetCurrentOrCompletedQuest(qm); + if (quest == null || qm == null) return base.InteractPrompt; + return qm.GetState(quest.questId) switch + { + QuestStateEnum.Available => "接受任务", + QuestStateEnum.Active => qm.IsReadyToComplete(quest.questId) ? "提交任务" : "进行中…", + QuestStateEnum.Paused => "暂停中…", + QuestStateEnum.Completed => "对话", + _ => base.InteractPrompt, + }; + } + } + protected override void Interact_Internal(Transform player) { var qm = SL.GetOrDefault(); @@ -57,6 +83,7 @@ namespace BaseGames.Quest QuestStateEnum.Available => _availableDialogue, QuestStateEnum.Active => qm.IsReadyToComplete(quest.questId) ? _readyDialogue : _activeDialogue, + QuestStateEnum.Paused => _activeDialogue, // 暂停中显示"催促"对话,不触发任何状态推进 QuestStateEnum.Completed => _completedDialogue, _ => base.GetCurrentDialogue(), }; @@ -79,7 +106,7 @@ namespace BaseGames.Quest { if (q == null) continue; var s = qm.GetState(q.questId); - if (s == QuestStateEnum.Available || s == QuestStateEnum.Active) return q; + if (s == QuestStateEnum.Available || s == QuestStateEnum.Active || s == QuestStateEnum.Paused) return q; if (s == QuestStateEnum.Completed) lastCompleted = q; } return lastCompleted; diff --git a/Assets/_Game/Scripts/Quest/QuestManager.cs b/Assets/_Game/Scripts/Quest/QuestManager.cs index c4e3050..459f2ea 100644 --- a/Assets/_Game/Scripts/Quest/QuestManager.cs +++ b/Assets/_Game/Scripts/Quest/QuestManager.cs @@ -15,34 +15,117 @@ namespace BaseGames.Quest /// _allQuests 由编辑器 OnValidate / "刷新任务列表" 右键菜单自动填充, /// 无需策划人员手动拖入 ScriptableObject。 /// - public class QuestManager : MonoBehaviour, ISaveable, IQuestManager + public class QuestManager : MonoBehaviour, ISaveable, IQuestManager, IQuestEventSource +#if UNITY_EDITOR || DEVELOPMENT_BUILD + , IQuestDebugger +#endif { // ── Inspector ──────────────────────────────────────────────────────── [Tooltip("所有 QuestSO 资产。编辑器会自动同步,无需手动维护。")] [SerializeField] private QuestSO[] _allQuests; - [Header("Event Channels(监听)")] - [SerializeField] private StringEventChannelSO _onEnemyDied; // EVT_EnemyDied(enemyId) - [SerializeField] private StringEventChannelSO _onCollectiblePickup; // EVT_CollectiblePickup(itemId) - [SerializeField] private StringEventChannelSO _onSceneLoaded; // EVT_SceneLoaded(sceneName) - [SerializeField] private StringEventChannelSO _onNpcDialogueCompleted; // EVT_NpcDialogueCompleted(npcId) - [SerializeField] private StringEventChannelSO _onSkillUsed; // EVT_SkillUsed(abilityType.ToString()) + [Header("事件频道注册表(推荐)")] + [Tooltip("将全部事件频道集中到一个 SO 中,方便多场景复用。\n" + + "若设置此注册表,下方独立频道字段将被自动忽略(注册表优先)。\n" + + "创建方式:右键菜单 → BaseGames/Quest/EventChannelRegistry。")] + [SerializeField] private QuestEventChannelRegistry _eventChannelRegistry; - [Header("Event Channels(广播)")] - [SerializeField] private StringEventChannelSO _onQuestStarted; // questId - [SerializeField] private StringEventChannelSO _onQuestCompleted; // questId - [SerializeField] private StringEventChannelSO _onQuestFailed; // questId - [SerializeField] private QuestObjectiveEventChannelSO _onObjectiveUpdated; + [Header("Event Channels(监听)— 未设置注册表时生效")] + [Tooltip("EVT_EnemyDied:payload = enemyId(string)。敌人死亡时由战斗系统广播,驱动击败类目标进度。")] + [SerializeField] private StringEventChannelSO _onEnemyDied; + [Tooltip("EVT_CollectiblePickup:payload = itemId(string)。拾取物品时广播,驱动收集类目标进度,同时也作为 RewardSO 物品发放频道。")] + [SerializeField] private StringEventChannelSO _onCollectiblePickup; + [Tooltip("EVT_SceneLoaded:payload = sceneName(string)。场景切换完成时广播,驱动到达类目标进度。")] + [SerializeField] private StringEventChannelSO _onSceneLoaded; + [Tooltip("EVT_NpcDialogueCompleted:payload = npcId(string)。DialogueManager 播完一段对话后广播,驱动对话类目标进度。")] + [SerializeField] private StringEventChannelSO _onNpcDialogueCompleted; + [Tooltip("EVT_SkillUsed:payload = AbilityType.ToString()(string)。玩家使用技能时广播,驱动技能使用类目标进度。")] + [SerializeField] private StringEventChannelSO _onSkillUsed; + [Tooltip("EVT_AreaReached:payload = markerTag(string)。TriggerZone 组件在玩家进入碰撞体时广播,驱动精确区域到达类目标进度。")] + [SerializeField] private StringEventChannelSO _onAreaReached; + + [Header("Event Channels(广播)— 未设置注册表时生效")] + [Tooltip("EVT_QuestStarted:payload = questId。AcceptQuest 成功后广播,供任务日志 UI 新增条目、任务追踪 HUD 激活等监听。")] + [SerializeField] private StringEventChannelSO _onQuestStarted; + [Tooltip("EVT_QuestCompleted:payload = questId。CompleteQuest 成功后广播,供成就系统、任务日志、剧情触发器等监听。")] + [SerializeField] private StringEventChannelSO _onQuestCompleted; + [Tooltip("EVT_QuestFailed:payload = questId。失败条件触发后广播,供失败提示 UI、任务日志、剧情触发器等监听。")] + [SerializeField] private StringEventChannelSO _onQuestFailed; + [Tooltip("EVT_QuestObjectiveUpdated:payload = QuestObjectiveEvent(questId + progress)。目标进度变化时广播,供任务追踪 HUD 更新进度条等监听。")] + [SerializeField] private QuestObjectiveEventChannelSO _onObjectiveUpdated; + [Tooltip("EVT_QuestObjectiveBatchUpdated:同帧内同一任务多目标聚合后广播一次(payload = QuestObjectiveBatchEvent)。\n" + + "供追踪 HUD 订阅以避免同帧多次重绘;留空则仅使用逐条 EVT_QuestObjectiveUpdated。")] + [SerializeField] private QuestObjectiveBatchEventChannelSO _onObjectiveBatchUpdated; + [Tooltip("EVT_NpcAffinityChanged:payload = NpcAffinityEvent(npcId + delta + newTotal 强类型,零字符串解析),供 UI/好感度系统监听。")] + [SerializeField] private NpcAffinityEventChannelSO _onNpcAffinityChanged; + [Tooltip("EVT_DialogueKeyUnlocked:payload = unlockDialogueKey,供 NPC 台词系统监听。")] + [SerializeField] private StringEventChannelSO _onDialogueKeyUnlocked; + [Tooltip("EVT_QuestReadyToComplete:payload = questId。目标全部达成、可回去交任务时广播一次(去重)。\n" + + "供任务日志 UI 高亮、地图标记、提示 HUD 等监听。")] + [SerializeField] private StringEventChannelSO _onQuestReadyToComplete; + [Tooltip("EVT_QuestAbandoned:payload = questId。玩家主动放弃任务(Active → Available)时广播。\n" + + "供任务日志 UI 移除追踪条目、提示 HUD 清空等监听。")] + [SerializeField] private StringEventChannelSO _onQuestAbandoned; + [Tooltip("EVT_QuestPaused:payload = questId。PauseQuest 成功(Active → Paused)后广播。\n" + + "供任务日志 UI 更新状态标记、UI/成就系统监听。")] + [SerializeField] private StringEventChannelSO _onQuestPaused; + [Tooltip("EVT_QuestResumed:payload = questId。ResumeQuest 成功(Paused → Active)后广播。\n" + + "供任务日志 UI 恢复追踪条目、UI/成就系统监听。")] + [SerializeField] private StringEventChannelSO _onQuestResumed; + + [Header("扩展事件频道(自定义目标类型)")] + [Tooltip("标准六类事件(敌人/物品/场景/对话/技能/区域)已在上方独立字段配置。\n" + + "若新增自定义 QuestEventType 和 StringEventChannelSO,在此数组添加绑定即可,无需修改代码。")] + [SerializeField] private QuestEventChannelBinding[] _extraEventChannels; + + // ── 事件频道访问器(注册表优先,回退到独立字段)──────────────────────── + // 支持从 QuestEventChannelRegistry 或独立字段访问,无需修改下游广播/订阅代码。 + + private StringEventChannelSO Chan_EnemyDied => _eventChannelRegistry?.onEnemyDied ?? _onEnemyDied; + private StringEventChannelSO Chan_CollectiblePickup => _eventChannelRegistry?.onCollectiblePickup ?? _onCollectiblePickup; + private StringEventChannelSO Chan_SceneLoaded => _eventChannelRegistry?.onSceneLoaded ?? _onSceneLoaded; + private StringEventChannelSO Chan_NpcDialogueCompleted => _eventChannelRegistry?.onNpcDialogueCompleted ?? _onNpcDialogueCompleted; + private StringEventChannelSO Chan_SkillUsed => _eventChannelRegistry?.onSkillUsed ?? _onSkillUsed; + private StringEventChannelSO Chan_AreaReached => _eventChannelRegistry?.onAreaReached ?? _onAreaReached; + private StringEventChannelSO Chan_QuestStarted => _eventChannelRegistry?.onQuestStarted ?? _onQuestStarted; + private StringEventChannelSO Chan_QuestCompleted => _eventChannelRegistry?.onQuestCompleted ?? _onQuestCompleted; + private StringEventChannelSO Chan_QuestFailed => _eventChannelRegistry?.onQuestFailed ?? _onQuestFailed; + private StringEventChannelSO Chan_QuestAbandoned => _eventChannelRegistry?.onQuestAbandoned ?? _onQuestAbandoned; + private StringEventChannelSO Chan_QuestPaused => _eventChannelRegistry?.onQuestPaused ?? _onQuestPaused; + private StringEventChannelSO Chan_QuestResumed => _eventChannelRegistry?.onQuestResumed ?? _onQuestResumed; + private StringEventChannelSO Chan_QuestReadyToComplete => _eventChannelRegistry?.onQuestReadyToComplete ?? _onQuestReadyToComplete; + private QuestObjectiveEventChannelSO Chan_ObjectiveUpdated => _eventChannelRegistry?.onObjectiveUpdated ?? _onObjectiveUpdated; + private QuestObjectiveBatchEventChannelSO Chan_ObjectiveBatch => _eventChannelRegistry?.onObjectiveBatchUpdated ?? _onObjectiveBatchUpdated; + private NpcAffinityEventChannelSO Chan_NpcAffinityChanged => _eventChannelRegistry?.onNpcAffinityChanged ?? _onNpcAffinityChanged; + private StringEventChannelSO Chan_DialogueKeyUnlocked => _eventChannelRegistry?.onDialogueKeyUnlocked ?? _onDialogueKeyUnlocked; // ── Runtime State ──────────────────────────────────────────────────── private readonly Dictionary _questStates = new(); private readonly Dictionary _objectiveStates = new(); /// questId → QuestSO 快速查找表(由 Awake 构建,将 GetQuestSO O(n) 降为 O(1))。 private Dictionary _questIndex; + /// + /// (questId, objectiveId) → compositeKey 预缓存表。 + /// 由 Awake 与 _questIndex 同步构建,消除 DispatchEvent 高频内循环的字符串拼接分配。 + /// key = (questId, objectiveId),value = CompositeKey(questId, objectiveId)。 + /// + private Dictionary<(string, string), string> _compositeKeyCache; private readonly CompositeDisposable _subs = new(); - public StringEventChannelSO OnQuestStarted => _onQuestStarted; - public StringEventChannelSO OnQuestCompleted => _onQuestCompleted; + /// npcId → 好感度数值(从 SaveData.World.NpcRelations 同步,由 CompleteQuest 更新)。 + private Dictionary _npcRelations = new(); + /// + /// OnLoad 完成后置为 true,标记好感度字典已从存档初始化。 + /// 防止 CanAccept 在 OnLoad 前被调用时,对空字典产生错误的通过判定。 + /// + private bool _affinityInitialized; + /// 已广播过 EVT_QuestReadyToComplete 的任务 ID 集合(防重复通知)。 + /// 任务完成/失败时从集合移除,再次激活后可重新通知。 + private readonly HashSet _notifiedReadyQuests = new(); + /// 供需要直接订阅 SO 频道的系统使用(推荐使用 IQuestEventSource C# 事件)。 + public StringEventChannelSO QuestStartedChannel => _onQuestStarted; + /// 供需要直接订阅 SO 频道的系统使用(推荐使用 IQuestEventSource C# 事件)。 + public StringEventChannelSO QuestCompletedChannel => _onQuestCompleted; /// 供 SaveManager 迭代的任务状态字典(只读视图)。 public IReadOnlyDictionary QuestStates => _questStates; @@ -59,9 +142,30 @@ namespace BaseGames.Quest if (q != null && !string.IsNullOrEmpty(q.questId)) _questIndex[q.questId] = q; + // 预构建 compositeKey 缓存,消除 DispatchEvent 高频内循环的字符串拼接分配 + int cacheCapacity = 0; + if (_allQuests != null) + foreach (var q in _allQuests) + if (q?.objectives != null) cacheCapacity += q.objectives.Length; + _compositeKeyCache = new Dictionary<(string, string), string>(cacheCapacity); + foreach (var q in _questIndex.Values) + { + if (q.objectives == null) continue; + foreach (var obj in q.objectives) + { + if (obj == null || string.IsNullOrEmpty(obj.objectiveId)) continue; + var cacheKey = (q.questId, obj.objectiveId); + if (!_compositeKeyCache.ContainsKey(cacheKey)) + _compositeKeyCache[cacheKey] = CompositeKey(q.questId, obj.objectiveId); + } + } + #if UNITY_EDITOR || DEVELOPMENT_BUILD ValidateQuestIds(); + ValidatePrerequisites(); #endif + // 将无前置条件的任务初始化为 Available,确保冷启动时可接取 + InitializeAvailableQuests(); } #if UNITY_EDITOR || DEVELOPMENT_BUILD @@ -82,11 +186,22 @@ namespace BaseGames.Quest private void OnEnable() { - _onEnemyDied?.Subscribe(HandleEnemyDefeated).AddTo(_subs); - _onCollectiblePickup?.Subscribe(HandleItemCollected).AddTo(_subs); - _onSceneLoaded?.Subscribe(HandleSceneLoaded).AddTo(_subs); - _onNpcDialogueCompleted?.Subscribe(HandleNpcDialogue).AddTo(_subs); - _onSkillUsed?.Subscribe(HandleSkillUsed).AddTo(_subs); + Chan_EnemyDied?.Subscribe(p => DispatchEvent(QuestEventType.EnemyDefeated, p)).AddTo(_subs); + Chan_CollectiblePickup?.Subscribe(p => DispatchEvent(QuestEventType.ItemCollected, p)).AddTo(_subs); + Chan_SceneLoaded?.Subscribe(p => DispatchEvent(QuestEventType.SceneLoaded, p)).AddTo(_subs); + Chan_NpcDialogueCompleted?.Subscribe(p => DispatchEvent(QuestEventType.NpcDialogueCompleted, p)).AddTo(_subs); + Chan_SkillUsed?.Subscribe(p => DispatchEvent(QuestEventType.SkillUsed, p)).AddTo(_subs); + Chan_AreaReached?.Subscribe(p => DispatchEvent(QuestEventType.AreaReached, p)).AddTo(_subs); + + // 扩展绑定:Inspector 中配置的自定义频道,无需修改代码即可支持新 QuestEventType + if (_extraEventChannels != null) + { + foreach (var binding in _extraEventChannels) + { + var capturedType = binding.eventType; + binding.channel?.Subscribe(p => DispatchEvent(capturedType, p)).AddTo(_subs); + } + } BaseGames.Core.ServiceLocator.GetOrDefault()?.Register(this); } @@ -101,14 +216,119 @@ namespace BaseGames.Quest BaseGames.Core.ServiceLocator.Unregister(this); } + // ── IQuestEventSource(代码订阅入口,无需直接持有 SO 频道)────────────── + /// + public event System.Action OnQuestStarted; + /// + public event System.Action OnQuestCompleted; + /// + public event System.Action OnQuestFailed; + /// + public event System.Action OnQuestAbandoned; + /// + public event System.Action OnQuestPaused; + /// + public event System.Action OnQuestResumed; + /// + public event System.Action OnQuestReadyToComplete; + /// + public event System.Action OnQuestStateChanged; + // ── 公共 API ────────────────────────────────────────────────────────── /// NPC 接受任务时调用。 public void AcceptQuest(string questId) { + if (string.IsNullOrEmpty(questId)) return; if (!CanAccept(questId)) return; + var oldState = GetState(questId); _questStates[questId] = QuestStateEnum.Active; - _onQuestStarted?.Raise(questId); + OnQuestStateChanged?.Invoke(questId, oldState, QuestStateEnum.Active); + Chan_QuestStarted?.Raise(questId); + OnQuestStarted?.Invoke(questId); + + // 触发接取任务对话(NPC 委托台词) + var quest = GetQuestSO(questId); + if (quest?.acceptDialogueSequence != null) + { + var dialogueService = BaseGames.Core.ServiceLocator.GetOrDefault(); + if (dialogueService != null) + dialogueService.StartDialogue(quest.acceptDialogueSequence, quest.GiverNpcId ?? ""); +#if UNITY_EDITOR || DEVELOPMENT_BUILD + else + Debug.LogWarning( + $"[QuestManager] 任务 '{questId}' 接取时需播放对话 '{quest.acceptDialogueSequence.name}'," + + "但 IDialogueService 未注册,对话被跳过。"); +#endif + } + } + + /// + /// 玩家主动放弃进行中的任务(Active → Available/Unavailable)。 + /// 清除已积累的目标进度,广播 EVT_QuestAbandoned, + /// 任务回到可重新接取状态(前置满足 → Available,否则 → Unavailable)。 + /// + public void AbandonQuest(string questId) + { + if (string.IsNullOrEmpty(questId)) return; + var curState = GetState(questId); + // Paused 状态:自动恢复为 Active 再放弃,调用方无需手动二步操作 + if (curState == QuestStateEnum.Paused) + _questStates[questId] = QuestStateEnum.Active; + else if (curState != QuestStateEnum.Active) + return; + + // 清除该任务的所有目标进度 + var quest = GetQuestSO(questId); + if (quest?.objectives != null) + { + foreach (var obj in quest.objectives) + { + if (obj == null || string.IsNullOrEmpty(obj.objectiveId)) continue; + _objectiveStates.Remove(GetCompositeKey(questId, obj.objectiveId)); + } + } + _notifiedReadyQuests.Remove(questId); + + // 回到可接取状态(前置满足则 Available,否则 Unavailable) + var questSo = GetQuestSO(questId); + var newState = MeetsPrerequisites(questSo) + ? QuestStateEnum.Available + : QuestStateEnum.Unavailable; + _questStates[questId] = newState; + OnQuestStateChanged?.Invoke(questId, QuestStateEnum.Active, newState); + + Chan_QuestAbandoned?.Raise(questId); + OnQuestAbandoned?.Invoke(questId); + } + + /// + /// 暂停进行中的任务(Active → Paused)。 + /// 暂停期间:目标进度事件不推进,失败条件不判定。 + /// 通过 ResumeQuest 恢复。非 Active 状态调用无效。 + /// + public void PauseQuest(string questId) + { + if (string.IsNullOrEmpty(questId)) return; + if (GetState(questId) != QuestStateEnum.Active) return; + _questStates[questId] = QuestStateEnum.Paused; + OnQuestStateChanged?.Invoke(questId, QuestStateEnum.Active, QuestStateEnum.Paused); + Chan_QuestPaused?.Raise(questId); + OnQuestPaused?.Invoke(questId); + } + + /// + /// 恢复已暂停的任务(Paused → Active)。 + /// 非 Paused 状态调用无效。 + /// + public void ResumeQuest(string questId) + { + if (string.IsNullOrEmpty(questId)) return; + if (GetState(questId) != QuestStateEnum.Paused) return; + _questStates[questId] = QuestStateEnum.Active; + OnQuestStateChanged?.Invoke(questId, QuestStateEnum.Paused, QuestStateEnum.Active); + Chan_QuestResumed?.Raise(questId); + OnQuestResumed?.Invoke(questId); } /// NPC 完成任务时调用。 @@ -116,23 +336,126 @@ namespace BaseGames.Quest { if (!IsReadyToComplete(questId)) return; var quest = GetQuestSO(questId); - quest.reward?.Apply(rewardTarget); - _questStates[questId] = QuestStateEnum.Completed; - _onQuestCompleted?.Raise(questId); + if (quest == null) return; // IsReadyToComplete 已通过,此处防御冗余调用竞态 - // 解锁后续任务(分支) - // conditionQuest == null 表示默认分支,conditionQuest != null 则要求该任务已完成。 - // 不 break —— 允许同时解锁多个后续任务(如完成任务后同时开放多条支线)。 - if (quest.branches != null) + // 先更新状态再发放奖励:确保 Apply 即使抛出异常,任务状态也已正确写入 + _questStates[questId] = QuestStateEnum.Completed; + _notifiedReadyQuests.Remove(questId); + OnQuestStateChanged?.Invoke(questId, QuestStateEnum.Active, QuestStateEnum.Completed); + Chan_QuestCompleted?.Raise(questId); + OnQuestCompleted?.Invoke(questId); + + quest.reward?.Apply(rewardTarget); + ApplyAffinity(quest); + UnlockDialogueKey(quest); + UnlockBranches(questId, quest); + } + + /// 将奖励好感度更新到本地缓存并广播事件。上限由 NpcSO.maxAffinity 控制(>0 时生效)。 + private void ApplyAffinity(QuestSO quest) + { + if (quest.reward == null || quest.reward.affinityBonus == 0) return; + if (string.IsNullOrEmpty(quest.GiverNpcId)) return; + + _npcRelations.TryGetValue(quest.GiverNpcId, out int current); + int newTotal = current + quest.reward.affinityBonus; + + // 上限截断:npcSO.maxAffinity > 0 时好感度不得超过上限 + int maxAffinity = quest.giverNpc?.maxAffinity ?? 0; + if (maxAffinity > 0 && newTotal > maxAffinity) { - foreach (var branch in quest.branches) +#if UNITY_EDITOR || DEVELOPMENT_BUILD + Debug.LogWarning( + $"[QuestManager] 任务 '{quest.questId}' 好感度奖励 +{quest.reward.affinityBonus} " + + $"将超出 NPC '{quest.GiverNpcId}' 的上限 {maxAffinity}(当前 {current}),已截断至 {maxAffinity}。"); +#endif + newTotal = maxAffinity; + } + + _npcRelations[quest.GiverNpcId] = newTotal; + Chan_NpcAffinityChanged?.Raise(new NpcAffinityEvent + { + npcId = quest.GiverNpcId, + delta = quest.reward.affinityBonus, + newTotal = newTotal + }); + } + + /// 广播对话解锁事件,供 NPC 台词管理系统监听并切换新对话集。 + private void UnlockDialogueKey(QuestSO quest) + { + if (quest.reward == null || string.IsNullOrEmpty(quest.reward.unlockDialogueKey)) return; + Chan_DialogueKeyUnlocked?.Raise(quest.reward.unlockDialogueKey); + } + + /// + /// 解锁满足条件的后续任务分支,并触发相应 NPC 完成反应对话。 + /// 允许同时满足多个分支(并行支线解锁)。 + /// + private void UnlockBranches(string questId, QuestSO quest) + { + if (quest.branches == null) return; + + var dialogueService = BaseGames.Core.ServiceLocator.GetOrDefault(); + var saveService = BaseGames.Core.ServiceLocator.GetOrDefault(); + + foreach (var branch in quest.branches) + { + // 任务条件 + bool conditionMet = branch.conditionQuest == null || + GetState(branch.conditionQuest.questId) == QuestStateEnum.Completed; + if (!conditionMet) continue; + + // 世界状态标志条件(And/Or 由 conditionFlagsLogic 决定) + // saveService 未注入时降级:跳过标志检查,仅由 conditionQuest 决定分支 + if (branch.conditionFlags != null && branch.conditionFlags.Length > 0 + && saveService != null) { - if (branch.conditionQuest == null || - GetState(branch.conditionQuest.questId) == QuestStateEnum.Completed) + if (branch.conditionFlagsLogic == BaseGames.Core.WorldStateFlagLogic.Or) { - if (branch.nextQuest != null) - _questStates[branch.nextQuest.questId] = QuestStateEnum.Available; + conditionMet = false; + foreach (var flag in branch.conditionFlags) + { + if (!string.IsNullOrEmpty(flag) && saveService.GetFlag(flag)) + { + conditionMet = true; + break; + } + } } + else + { + // AND(默认):全部标志均须满足 + foreach (var flag in branch.conditionFlags) + { + if (string.IsNullOrEmpty(flag)) continue; + if (!saveService.GetFlag(flag)) { conditionMet = false; break; } + } + } + } +#if UNITY_EDITOR || DEVELOPMENT_BUILD + else if (branch.conditionFlags != null && branch.conditionFlags.Length > 0 + && saveService == null) + { + Debug.LogWarning( + $"[QuestManager] 任务 '{questId}' 分支配置了 conditionFlags,但 ISaveService 未注册," + + "标志条件已跳过(降级为仅 conditionQuest 判断)。"); + } +#endif + if (!conditionMet) continue; + + if (branch.nextQuest != null) + _questStates[branch.nextQuest.questId] = QuestStateEnum.Available; + + // 触发 NPC 完成反应对话(如 NPC 说"太好了,谢谢你!") + if (branch.npcDialogueSequence != null) + { + if (dialogueService != null) + dialogueService.StartDialogue(branch.npcDialogueSequence, quest.GiverNpcId ?? ""); + else + Debug.LogWarning( + $"[QuestManager] 任务 '{questId}' 完成后需播放 NPC 对话 " + + $"'{branch.npcDialogueSequence.name}',但 DialogueService 未注册到 ServiceLocator,对话被跳过。"); } } } @@ -147,11 +470,131 @@ namespace BaseGames.Quest if (quest.objectives == null) return true; foreach (var obj in quest.objectives) { - if (!obj.IsOptional && !IsObjectiveComplete(obj)) return false; + if (obj == null) continue; + if (!obj.IsOptional && !IsObjectiveComplete(questId, obj)) return false; } return true; } + public int GetNpcAffinity(string npcId) + => (!string.IsNullOrEmpty(npcId) && _npcRelations.TryGetValue(npcId, out int v)) ? v : 0; + + /// + /// 返回任务无法被接取的原因(本地化 Key 格式)。 + /// 内部委托给 实现,保持向后兼容。 + /// + public string GetQuestLockReason(string questId) => GetQuestLockInfo(questId).ToLocalizationKey(); + + /// + public QuestLockInfo GetQuestLockInfo(string questId) + { + if (string.IsNullOrEmpty(questId)) return new QuestLockInfo { Reason = QuestLockReason.NotFound }; + + var state = GetState(questId); + switch (state) + { + case QuestStateEnum.Active: return new QuestLockInfo { Reason = QuestLockReason.AlreadyActive }; + case QuestStateEnum.Completed: return new QuestLockInfo { Reason = QuestLockReason.AlreadyCompleted }; + case QuestStateEnum.Failed: return new QuestLockInfo { Reason = QuestLockReason.Failed }; + case QuestStateEnum.Paused: return new QuestLockInfo { Reason = QuestLockReason.Paused }; + } + + // Unavailable / Available 都需要进一步细化 + var quest = GetQuestSO(questId); + if (quest == null) return new QuestLockInfo { Reason = QuestLockReason.NotFound }; + + // 好感度门槛检查 + if (quest.minAffinityToAccept > 0 && !string.IsNullOrEmpty(quest.GiverNpcId)) + { + if (!_affinityInitialized) return new QuestLockInfo { Reason = QuestLockReason.DataNotLoaded }; + _npcRelations.TryGetValue(quest.GiverNpcId, out int affinity); + if (affinity < quest.minAffinityToAccept) + return new QuestLockInfo { Reason = QuestLockReason.InsufficientAffinity, Param = $"{affinity}/{quest.minAffinityToAccept}" }; + } + + // 前置任务依赖检查(新版优先,回退旧版) +#pragma warning disable CS0618 + var deps = quest.prerequisites.HasAny ? quest.prerequisites.questDependencies : quest.prerequisiteQuests; +#pragma warning restore CS0618 + if (deps != null) + { + foreach (var dep in deps) + { + if (dep == null || string.IsNullOrEmpty(dep.questId)) continue; + if (GetState(dep.questId) != QuestStateEnum.Completed) + return new QuestLockInfo { Reason = QuestLockReason.RequiresQuest, Param = dep.questId }; + } + } + + // 世界标志条件检查 + var fc = quest.prerequisites.HasAny ? quest.prerequisites.flagCondition : default; +#pragma warning disable CS0618 + if (!quest.prerequisites.HasAny && quest.prerequisiteFlags != null && quest.prerequisiteFlags.Length > 0) + fc = new QuestPrerequisite.FlagCondition + { flags = quest.prerequisiteFlags, logic = quest.prerequisiteFlagsLogic }; +#pragma warning restore CS0618 + + if (fc.flags != null && fc.flags.Length > 0) + { + var svc = BaseGames.Core.ServiceLocator.GetOrDefault(); + if (svc != null && !EvaluateFlagPrerequisites(fc.flags, fc.logic, svc)) + return new QuestLockInfo { Reason = QuestLockReason.FlagConditionNotMet }; + } + + return new QuestLockInfo { Reason = QuestLockReason.None }; // 无锁定,任务可接取 + } + +#if UNITY_EDITOR || DEVELOPMENT_BUILD + // ── IQuestDebugger ──────────────────────────────────────────────────── + + /// + /// 将任务重置为 Available(前置满足)或 Unavailable(前置未满足),并清除目标进度。 + /// 仅供开发/调试使用(IQuestDebugger),不广播 QuestStarted / QuestCompleted 等运行时事件。 + /// 正式发布构建中此方法不存在;调用方通过 (qm as IQuestDebugger)?.ResetQuest(id) 使用。 + /// + /// 要重置的任务 ID。 + /// true = 同步扣回本任务发放的好感度增量,防止反复完成累积。 + public void ResetQuest(string questId, bool rollbackAffinity = true) + { + if (string.IsNullOrEmpty(questId)) return; + var quest = GetQuestSO(questId); + if (quest == null) return; + + // 好感度回滚(仅当任务已处于 Completed 状态且配置了好感度奖励时) + if (rollbackAffinity + && _questStates.TryGetValue(questId, out var curState) + && curState == QuestStateEnum.Completed + && quest.reward != null + && quest.reward.affinityBonus != 0 + && !string.IsNullOrEmpty(quest.GiverNpcId)) + { + _npcRelations.TryGetValue(quest.GiverNpcId, out int current); + int rolled = current - quest.reward.affinityBonus; + _npcRelations[quest.GiverNpcId] = rolled; + Debug.Log($"[QuestManager] 回滚任务 '{questId}' 的好感度增量 " + + $"{quest.reward.affinityBonus:+#;-#},'{quest.GiverNpcId}' 好感度:{current} → {rolled}。"); + } + + // 清除目标进度 + if (quest.objectives != null) + { + foreach (var obj in quest.objectives) + { + if (obj == null || string.IsNullOrEmpty(obj.objectiveId)) continue; + _objectiveStates.Remove(GetCompositeKey(questId, obj.objectiveId)); + } + } + _notifiedReadyQuests.Remove(questId); + + // 重置状态:前置满足 → Available,否则 Unavailable + _questStates[questId] = MeetsPrerequisites(quest) + ? QuestStateEnum.Available + : QuestStateEnum.Unavailable; + + Debug.Log($"[QuestManager] 任务 '{questId}' 已重置为 [{_questStates[questId]}](好感度回滚:{rollbackAffinity})。"); + } +#endif + // ── ISaveable ───────────────────────────────────────────────────────── public void OnSave(SaveData data) @@ -161,10 +604,15 @@ namespace BaseGames.Quest { data.Quests.QuestStates[id] = new BaseGames.Core.Save.QuestState { - Status = state.ToString(), - ProgressCounts = BuildProgressList(id), + DataVersion = 2, + Status = state.ToString(), + ObjectiveProgress = BuildObjectiveProgress(id), }; } + // 将本地 NPC 好感度缓存回写到存档 + data.World.NpcRelations.Clear(); + foreach (var (npcId, val) in _npcRelations) + data.World.NpcRelations[npcId] = val; } public void OnLoad(SaveData data) @@ -176,20 +624,63 @@ namespace BaseGames.Quest if (System.Enum.TryParse(saved.Status, out var parsedState)) _questStates[id] = parsedState; - // 恢复各目标进度 var quest = GetQuestSO(id); - if (quest?.objectives != null && saved.ProgressCounts != null) + if (quest?.objectives == null) continue; + + bool hasNewFormat = saved.ObjectiveProgress != null && saved.ObjectiveProgress.Count > 0; + // DataVersion >= 2:新格式(objectiveId 键值对);DataVersion <= 1 或遗留存档:旧格式(按索引) + // Count > 0 作为无 DataVersion 字段时的兼容兜底 + bool useNewFormat = saved.DataVersion >= 2 || hasNewFormat; + + if (useNewFormat && saved.ObjectiveProgress != null) { + // 新格式:objectiveId → count,重排顺序后仍可正确恢复 + foreach (var obj in quest.objectives) + { + if (obj == null) continue; + if (!saved.ObjectiveProgress.TryGetValue(obj.objectiveId, out int count)) continue; + string compositeKey = GetCompositeKey(id, obj.objectiveId); + if (!_objectiveStates.TryGetValue(compositeKey, out var os)) + os = _objectiveStates[compositeKey] = new QuestObjectiveState(); + os.progressCount = count; + } + } + else if (saved.ProgressCounts != null +#pragma warning disable CS0618 // ProgressCounts 弃用字段:仅在此处读取用于旧存档迁移,不再写入 + && saved.ProgressCounts.Count > 0) + { + // 旧格式兼容(按数组索引):迁移旧存档用,不再写入新存档 for (int i = 0; i < quest.objectives.Length && i < saved.ProgressCounts.Count; i++) { var obj = quest.objectives[i]; if (obj == null) continue; - if (!_objectiveStates.TryGetValue(obj.objectiveId, out var os)) - os = _objectiveStates[obj.objectiveId] = new QuestObjectiveState(); + string compositeKey = GetCompositeKey(id, obj.objectiveId); + if (!_objectiveStates.TryGetValue(compositeKey, out var os)) + os = _objectiveStates[compositeKey] = new QuestObjectiveState(); os.progressCount = saved.ProgressCounts[i]; } } +#pragma warning restore CS0618 } + // 从存档恢复 NPC 好感度缓存(供 CanAccept 门槛检查使用) + _npcRelations = new Dictionary(data.World.NpcRelations); + _affinityInitialized = true; + +#if UNITY_EDITOR || DEVELOPMENT_BUILD + // 检测存档中存在但当前版本中已不存在的任务(已删除或重命名的旧任务) + if (_questIndex != null) + { + foreach (var savedQuestId in data.Quests.QuestStates.Keys) + { + if (!_questIndex.ContainsKey(savedQuestId)) + Debug.LogWarning( + $"[QuestManager] 存档中任务 '{savedQuestId}' 在当前版本不存在,已自动忽略" + + "(可能为已删除或重命名的任务)。如属正常版本迭代,可忽略此警告。"); + } + } +#endif + // 存档中未记录的无前置任务,在新周目/首次加载后也保证可接取 + InitializeAvailableQuests(); } // ── 私有辅助 ───────────────────────────────────────────────────────── @@ -198,18 +689,218 @@ namespace BaseGames.Quest { if (GetState(questId) != QuestStateEnum.Available) return false; var quest = GetQuestSO(questId); - if (quest?.prerequisiteQuests == null) return true; - foreach (var pre in quest.prerequisiteQuests) + if (quest == null) return false; + + // 好感度门槛检查:_npcRelations 仅在 OnLoad 后有效 + if (quest.minAffinityToAccept > 0 && !string.IsNullOrEmpty(quest.GiverNpcId)) { - if (pre == null) continue; - if (GetState(pre.questId) != QuestStateEnum.Completed) return false; + if (!_affinityInitialized) + { +#if UNITY_EDITOR || DEVELOPMENT_BUILD + Debug.LogWarning( + $"[QuestManager] CanAccept: 好感度数据尚未从存档加载(OnLoad 未完成)," + + $"任务 '{questId}' 的好感度门槛检查暂时拒绝接取。"); +#endif + return false; + } + _npcRelations.TryGetValue(quest.GiverNpcId, out int affinity); + if (affinity < quest.minAffinityToAccept) return false; } + + // 前置条件检查:优先使用新版 prerequisites 结构,回退到旧版字段 + if (quest.prerequisites.HasAny) + { + if (quest.prerequisites.questDependencies != null) + foreach (var dep in quest.prerequisites.questDependencies) + { + if (dep == null) continue; + if (GetState(dep.questId) != QuestStateEnum.Completed) return false; + } + var fc = quest.prerequisites.flagCondition; + if (fc.flags != null && fc.flags.Length > 0) + { + var svc = BaseGames.Core.ServiceLocator.GetOrDefault(); + if (svc != null && !EvaluateFlagPrerequisites(fc.flags, fc.logic, svc)) return false; + } + } + else + { + // 旧版字段回退(兼容现有资产) + if (quest.prerequisiteQuests != null) + foreach (var pre in quest.prerequisiteQuests) + { + if (pre == null) continue; + if (GetState(pre.questId) != QuestStateEnum.Completed) return false; + } + + if (quest.prerequisiteFlags != null && quest.prerequisiteFlags.Length > 0) + { + var svc = BaseGames.Core.ServiceLocator.GetOrDefault(); + if (svc != null) + { + if (!EvaluateFlagPrerequisites(quest.prerequisiteFlags, quest.prerequisiteFlagsLogic, svc)) return false; + } +#if UNITY_EDITOR || DEVELOPMENT_BUILD + else Debug.LogWarning( + $"[QuestManager] CanAccept: 任务 '{questId}' 的 prerequisiteFlags 需要 ISaveService,但服务未注册,标志检查已跳过。"); +#endif + } + } + return true; } - private bool IsObjectiveComplete(QuestObjectiveSO obj) + /// + /// 初始化(或修正)所有任务的 Available/Unavailable 状态。 + /// 在 Awake(冷启动)和 OnLoad(存档恢复)后调用。 + /// OnLoad 后 ISaveService 已就绪,会重新评估 prerequisiteFlags, + /// 修正 Awake 期间因服务未就绪而被跳过的标志检查。 + /// Active/Completed/Failed 状态来自存档,不重置。 + /// + private void InitializeAvailableQuests() { - if (!_objectiveStates.TryGetValue(obj.objectiveId, out var s)) + if (_questIndex == null) return; + foreach (var q in _questIndex.Values) + { + var cur = GetState(q.questId); + // 运行时终态来自存档,不重新评估 + if (cur == QuestStateEnum.Active || cur == QuestStateEnum.Paused || + cur == QuestStateEnum.Completed || cur == QuestStateEnum.Failed) + continue; + +#if UNITY_EDITOR || DEVELOPMENT_BUILD + // _affinityInitialized 为 true 说明是 OnLoad 后调用,Awake 期间不打此日志 + bool isNewToSave = !_questStates.ContainsKey(q.questId) && _affinityInitialized; +#endif + // Available/Unavailable 均重新评估,确保 prerequisiteFlags 变更后状态正确 + _questStates[q.questId] = MeetsPrerequisites(q) ? QuestStateEnum.Available : QuestStateEnum.Unavailable; +#if UNITY_EDITOR || DEVELOPMENT_BUILD + if (isNewToSave) + Debug.Log( + $"[QuestManager] 新增任务 '{q.questId}' 在存档中无记录(DLC/补丁新增)," + + $"初始化状态 → {_questStates[q.questId]}。"); +#endif + } + } + + /// + /// 检查任务是否满足全部前置条件(不含状态检查),用于 InitializeAvailableQuests 初始化。 + /// 与 CanAccept 的区别:CanAccept 需要任务已经是 Available;此方法仅判断前置依赖是否达成。 + /// 优先读取新版 结构;若未配置则回退到旧版字段。 + /// + private bool MeetsPrerequisites(QuestSO quest) + { + if (quest == null) return false; + + if (quest.prerequisites.HasAny) + { + // 新版前置结构:questDependencies + flagCondition + if (quest.prerequisites.questDependencies != null) + foreach (var dep in quest.prerequisites.questDependencies) + { + if (dep == null) continue; + if (string.IsNullOrEmpty(dep.questId)) + { +#if UNITY_EDITOR || DEVELOPMENT_BUILD + Debug.LogWarning( + $"[QuestManager] 任务 '{quest.questId}' 的 prerequisites.questDependencies 含 questId 为空的条目,已跳过。"); +#endif + continue; + } + if (GetState(dep.questId) != QuestStateEnum.Completed) return false; + } + + var fc = quest.prerequisites.flagCondition; + if (fc.flags != null && fc.flags.Length > 0) + { + var svc = BaseGames.Core.ServiceLocator.GetOrDefault(); + if (svc != null && !EvaluateFlagPrerequisites(fc.flags, fc.logic, svc)) return false; + // ISaveService 未就绪(Awake 阶段)→ 保守跳过;OnLoad 后重新评估 + } + } + else + { + // 旧版字段回退(兼容现有资产) + if (quest.prerequisiteQuests != null) + foreach (var pre in quest.prerequisiteQuests) + { + if (pre == null) continue; + if (string.IsNullOrEmpty(pre.questId)) + { +#if UNITY_EDITOR || DEVELOPMENT_BUILD + Debug.LogWarning( + $"[QuestManager] 任务 '{quest.questId}' 的 prerequisiteQuests 含 questId 为空的条目,已跳过该前置条件。"); +#endif + continue; + } + if (GetState(pre.questId) != QuestStateEnum.Completed) return false; + } + + if (quest.prerequisiteFlags != null && quest.prerequisiteFlags.Length > 0) + { + var svc = BaseGames.Core.ServiceLocator.GetOrDefault(); + if (svc != null) + { + if (!EvaluateFlagPrerequisites(quest.prerequisiteFlags, quest.prerequisiteFlagsLogic, svc)) return false; + } + // ISaveService 未就绪(Awake 阶段)→ 保守跳过;OnLoad 后 InitializeAvailableQuests 重新评估 + } + } + + return true; + } + + /// + /// 根据 flags 数组和 logic 评估标志前置条件是否满足。 + /// + private static bool EvaluateFlagPrerequisites(string[] flags, BaseGames.Core.WorldStateFlagLogic logic, ISaveService svc) + { + if (logic == BaseGames.Core.WorldStateFlagLogic.Or) + { + foreach (var flag in flags) + if (!string.IsNullOrEmpty(flag) && svc.GetFlag(flag)) return true; + return false; + } + // And 逻辑(默认) + foreach (var flag in flags) + if (!string.IsNullOrEmpty(flag) && !svc.GetFlag(flag)) return false; + return true; + } + + /// + /// 只读检查目标是否已完成(不修改任何状态)。 + /// 供 DispatchEvent 失败条件评估使用,避免副作用。 + /// + private bool CheckObjective(string questId, QuestObjectiveSO obj) + { + if (obj == null || string.IsNullOrEmpty(obj.objectiveId)) + { +#if UNITY_EDITOR || DEVELOPMENT_BUILD + Debug.LogWarning($"[QuestManager] 任务 '{questId}' 含 objectiveId 为空的目标,无法评估完成状态。"); +#endif + return false; + } + string compositeKey = GetCompositeKey(questId, obj.objectiveId); + _objectiveStates.TryGetValue(compositeKey, out var s); + // EvaluateCompletion 读取 s(可为 null/default),不写回 + return obj.EvaluateCompletion(s ?? new QuestObjectiveState()); + } + + /// + /// 检查目标是否完成,并在首次达成时写回 completed 标志。 + /// 仅由 IsReadyToComplete 调用,防止重复计为完成。 + /// + private bool IsObjectiveComplete(string questId, QuestObjectiveSO obj) + { + if (obj == null || string.IsNullOrEmpty(obj.objectiveId)) + { +#if UNITY_EDITOR || DEVELOPMENT_BUILD + Debug.LogWarning($"[QuestManager] 任务 '{questId}' 含 objectiveId 为空的目标,无法评估完成状态。"); +#endif + return false; + } + string compositeKey = GetCompositeKey(questId, obj.objectiveId); + if (!_objectiveStates.TryGetValue(compositeKey, out var s)) s = new QuestObjectiveState(); bool result = obj.EvaluateCompletion(s); @@ -218,102 +909,154 @@ namespace BaseGames.Quest if (result && !s.completed) { s.completed = true; - _objectiveStates[obj.objectiveId] = s; + _objectiveStates[compositeKey] = s; } return result; } - private List BuildProgressList(string questId) + /// + /// 将当前任务的各目标进度序列化为 objectiveId → count 字典。 + /// 按 objectiveId 键存储,策划重排目标顺序后存档数据不会错位。 + /// + private Dictionary BuildObjectiveProgress(string questId) { var quest = GetQuestSO(questId); - if (quest?.objectives == null) return new List(0); - var list = new List(quest.objectives.Length); + if (quest?.objectives == null) return new Dictionary(0); + var dict = new Dictionary(quest.objectives.Length, System.StringComparer.Ordinal); foreach (var obj in quest.objectives) { - _objectiveStates.TryGetValue(obj?.objectiveId ?? string.Empty, out var os); - list.Add(os?.progressCount ?? 0); + if (obj == null || string.IsNullOrEmpty(obj.objectiveId)) continue; + _objectiveStates.TryGetValue(GetCompositeKey(questId, obj.objectiveId), out var os); + dict[obj.objectiveId] = os?.progressCount ?? 0; } - return list; + return dict; } - // ── 事件处理 ───────────────────────────────────────────────────────── + // ── 事件路由 ───────────────────────────────────────────────────────── + // 统一分派入口:所有事件频道均路由到此方法,由各目标 SO 自行判断是否匹配。 + // 新增目标类型只需在 QuestObjectiveSO 子类中 override TryHandleEvent, + // 此处无需任何修改。 - private void HandleEnemyDefeated(string enemyId) + private void DispatchEvent(QuestEventType eventType, string payload) { - ForEachActiveObjective(obj => + // ─ 第1次遍历:更新目标进度 + 同步收集失败候选 ───────────────────── + // 将 CheckQuestFailConditions 内联到此处,避免对 _questStates 的独立第2次迭代。 + List toFail = null; + // 批量事件暂存:questId → 本帧内该任务所有更新过的目标事件(惰性分配,仅在有目标变更时创建) + Dictionary> pendingBatchUpdates = null; + + foreach (var (qid, state) in _questStates) { - if (obj.targetEnemyId == enemyId) - IncrementProgress(obj.objectiveId); - }); - } + // Paused 任务跳过所有事件处理(目标进度和失败条件均冻结), + // 直到 ResumeQuest() 恢复后才继续推进。 + if (state != QuestStateEnum.Active) continue; + var quest = GetQuestSO(qid); + if (quest == null) continue; - private void HandleItemCollected(string itemId) - { - ForEachActiveObjective(obj => + // 目标进度处理 + if (quest.objectives != null) + { + foreach (var obj in quest.objectives) + { + if (obj == null || string.IsNullOrEmpty(obj.objectiveId)) continue; + string compositeKey = GetCompositeKey(qid, obj.objectiveId); + if (!_objectiveStates.TryGetValue(compositeKey, out var os)) + os = _objectiveStates[compositeKey] = new QuestObjectiveState(); + if (obj.TryHandleEvent(eventType, payload, os)) + { + var evt = new QuestObjectiveEvent + { + QuestId = qid, + ObjectiveId = obj.objectiveId, + Progress = os.progressCount, + Required = obj.GetRequiredCount(), + }; + // 逐条事件:供向后兼容的逐目标监听者使用 + Chan_ObjectiveUpdated?.Raise(evt); + // 批量事件积累(同任务多目标聚合为一次广播,减少 UI 重绘) + if (Chan_ObjectiveBatch != null) + { + pendingBatchUpdates ??= new Dictionary>(System.StringComparer.Ordinal); + if (!pendingBatchUpdates.TryGetValue(qid, out var list)) + pendingBatchUpdates[qid] = list = new List(capacity: 4); + list.Add(evt); + } + } + } + } + + // 失败条件检查(同次遍历内完成,惰性分配 toFail 列表) + // Paused 任务在此处已被跳过(见上方 state != Active continue), + // 设计意图:暂停期间目标冻结,失败条件也不判定,恢复后再继续检查。 + if (quest.canFail && quest.failCondition != null && CheckObjective(qid, quest.failCondition)) + { + toFail ??= new List(); + toFail.Add(qid); + } + } + + // 批量目标事件:每个有变更的任务广播一次聚合事件 + if (pendingBatchUpdates != null) { - if (obj.itemId == itemId) - IncrementProgress(obj.objectiveId); - }); - } + foreach (var kv in pendingBatchUpdates) + Chan_ObjectiveBatch.Raise(new QuestObjectiveBatchEvent + { + QuestId = kv.Key, + Updates = kv.Value, + }); + } - private void HandleNpcDialogue(string npcId) - { - ForEachActiveObjective(obj => + // 在遍历结束后统一应用失败状态,避免迭代中修改字典 + if (toFail != null) { - if (obj.targetNpcId == npcId) - IncrementProgress(obj.objectiveId); - }); - } + foreach (var qid in toFail) + { + _questStates[qid] = QuestStateEnum.Failed; + _notifiedReadyQuests.Remove(qid); + OnQuestStateChanged?.Invoke(qid, QuestStateEnum.Active, QuestStateEnum.Failed); + Chan_QuestFailed?.Raise(qid); + OnQuestFailed?.Invoke(qid); + } + } - private void HandleSceneLoaded(string sceneName) - { - ForEachActiveObjective(obj => - { - if (obj.sceneName == sceneName) - IncrementProgress(obj.objectiveId); - }); - } - - private void HandleSkillUsed(string abilityTypeName) - { - // 用 Enum.TryParse 避免大小写/ToString 格式差异导致静默失败 - if (!System.Enum.TryParse(abilityTypeName, ignoreCase: true, out var parsed)) return; - ForEachActiveObjective(obj => - { - if (obj.requiredAbility == parsed) - IncrementProgress(obj.objectiveId); - }); - } - - private void ForEachActiveObjective(System.Action action) where T : QuestObjectiveSO - { + // ─ 第2次遍历:检查就绪通知(必须在失败状态写入后,避免刚失败的任务误报)─ foreach (var (qid, state) in _questStates) { if (state != QuestStateEnum.Active) continue; - var quest = GetQuestSO(qid); - if (quest?.objectives == null) continue; - foreach (var obj in quest.objectives) + if (_notifiedReadyQuests.Contains(qid)) continue; + if (IsReadyToComplete(qid)) { - if (obj is T typed) action(typed); + _notifiedReadyQuests.Add(qid); + Chan_QuestReadyToComplete?.Raise(qid); + OnQuestReadyToComplete?.Invoke(qid); } } } - private void IncrementProgress(string objectiveId) - { - if (!_objectiveStates.TryGetValue(objectiveId, out var s)) - s = _objectiveStates[objectiveId] = new QuestObjectiveState(); - s.progressCount++; - _onObjectiveUpdated?.Raise(new QuestObjectiveEvent - { - ObjectiveId = objectiveId, - Progress = s.progressCount, - }); - } - private QuestSO GetQuestSO(string id) => _questIndex != null && _questIndex.TryGetValue(id, out var q) ? q : null; + /// + /// 优先从预缓存表查找 compositeKey(O(1),零字符串分配); + /// 缓存未命中时 fallback 到 CompositeKey() 动态构建(运行时新增的目标)。 + /// + private string GetCompositeKey(string questId, string objectiveId) + { + if (_compositeKeyCache != null && + _compositeKeyCache.TryGetValue((questId, objectiveId), out var cached)) + return cached; + return CompositeKey(questId, objectiveId); + } + + /// + /// 组合任务目标的复合键(格式 "{questId}.{objectiveId}")。 + /// 全文统一通过此方法构建,objectiveId 为空时用 "__empty__" 占位保证唯一性。 + /// + private static string CompositeKey(string questId, string objectiveId) + => string.IsNullOrEmpty(objectiveId) + ? $"{questId}.__empty__" + : $"{questId}.{objectiveId}"; + // ── 编辑器自动维护 ──────────────────────────────────────────────────── #if UNITY_EDITOR @@ -347,7 +1090,6 @@ namespace BaseGames.Quest /// 通过 DFS 后序遍历检测 prerequisiteQuests 中是否存在循环引用。 /// 在编辑器 OnValidate 及开发构建 Awake 时调用,发现问题立即打 LogError。 ///
- [System.Diagnostics.Conditional("UNITY_EDITOR")] // ContextMenu 只在编辑器生效 [UnityEngine.ContextMenu("校验前置任务循环引用")] private void ValidatePrerequisites() { diff --git a/Assets/_Game/Scripts/Quest/QuestObjectiveSO.cs b/Assets/_Game/Scripts/Quest/QuestObjectiveSO.cs index b3905d0..8ab659f 100644 --- a/Assets/_Game/Scripts/Quest/QuestObjectiveSO.cs +++ b/Assets/_Game/Scripts/Quest/QuestObjectiveSO.cs @@ -1,12 +1,40 @@ using UnityEngine; +using BaseGames.Core.Events; using BaseGames.Player; namespace BaseGames.Quest { + /// + /// 任务事件类型枚举,对应 QuestManager 订阅的各事件频道。 + /// 新增事件类型时在此扩展,无需修改 QuestManager。 + /// + public enum QuestEventType + { + EnemyDefeated, + ItemCollected, + NpcDialogueCompleted, + SceneLoaded, + SkillUsed, + /// + /// 玩家进入场景内的具体区域标记(由 TriggerZone 广播)。 + /// payload = TriggerZone.markerTag(string)。 + /// 与 SceneLoaded(场景级)互补,实现精确的区域到达判定。 + /// + AreaReached, + } + /// /// 任务目标基类(抽象,架构 22_QuestChallengeModule §3)。 /// 所有具体目标类型均继承此类,通过多态实现零代码扩展。 - /// 每种目标在事件驱动下由 QuestManager 调用 EvaluateCompletion()。 + /// + /// 【自注册机制】子类通过 override 声明自己 + /// 感兴趣的事件类型及匹配条件,QuestManager 统一路由,无需为每种目标类型 + /// 硬编码处理器。新增目标类型只需: + /// 1. 继承 QuestObjectiveSO + /// 2. override TryHandleEvent + /// 3. override EvaluateCompletion + /// 4. 创建 CreateAssetMenu + /// QuestManager 代码**无需任何修改**。 /// public abstract class QuestObjectiveSO : ScriptableObject { @@ -19,9 +47,34 @@ namespace BaseGames.Quest [Tooltip("勾选后此目标为可选项:完成可获奖励,但不阻塞任务交接。")] public bool IsOptional; + /// + /// 目标所需完成数量。用于 UI 显示进度条分母(如 "3/5 击败")。 + /// 子类应 override 返回相应计数字段(defeatCount、collectCount 等)。 + /// 默认返回 1(表示"完成一次即可"的目标类型)。 + /// + public virtual int GetRequiredCount() => 1; + /// 根据当前进度判断目标是否完成。 public abstract bool EvaluateCompletion(QuestObjectiveState state); + /// + /// 尝试处理一个运行时事件。 + /// QuestManager 在每次事件到来时对所有活跃目标调用此方法。 + /// 子类 override:若事件与自身条件匹配,递增 state.progressCount 并返回 true; + /// 不匹配时返回 false(基类默认实现)。 + /// + /// 参数 含义由事件类型决定: + /// + /// EnemyDefeated → enemyId (string) + /// ItemCollected → itemId (string) + /// NpcDialogueCompleted → npcId (string) + /// SceneLoaded → sceneName (string) + /// SkillUsed → AbilityType.ToString() (string) + /// + /// + public virtual bool TryHandleEvent(QuestEventType eventType, string payload, QuestObjectiveState state) + => false; + /// /// 在 DataHub / 编辑器工具中显示的类型徽章文字。 /// 子类应 override 返回简洁中文标签(如 "[对话]")。 @@ -30,11 +83,52 @@ namespace BaseGames.Quest public virtual string BadgeLabel => "[目标]"; #if UNITY_EDITOR + // objectiveId → 资产路径,5 秒 TTL,跨所有 QuestObjectiveSO 子类 OnValidate 共用。 + // 与 QuestSO / DialogueSequenceSO / DialogueActorSO 保持一致的 O(1) 重复检测策略。 + private static System.Collections.Generic.Dictionary s_objIdToPath; + private static double s_objIdsCacheTime = -10.0; + + private static System.Collections.Generic.Dictionary GetObjectiveIdCache() + { + double now = UnityEditor.EditorApplication.timeSinceStartup; + if (s_objIdToPath != null && now - s_objIdsCacheTime < 5.0) + return s_objIdToPath; + + s_objIdToPath = new System.Collections.Generic.Dictionary(System.StringComparer.Ordinal); + string[] guids = UnityEditor.AssetDatabase.FindAssets("t:QuestObjectiveSO"); + foreach (var guid in guids) + { + var path = UnityEditor.AssetDatabase.GUIDToAssetPath(guid); + var obj = UnityEditor.AssetDatabase.LoadAssetAtPath(path); + if (obj != null && !string.IsNullOrEmpty(obj.objectiveId) && !s_objIdToPath.ContainsKey(obj.objectiveId)) + s_objIdToPath[obj.objectiveId] = path; + } + s_objIdsCacheTime = now; + return s_objIdToPath; + } + private void OnValidate() { - if (!string.IsNullOrEmpty(objectiveId)) return; - objectiveId = name; - UnityEditor.EditorUtility.SetDirty(this); + // 若 objectiveId 为空,自动以资产文件名填充 + if (string.IsNullOrEmpty(objectiveId)) + { + objectiveId = name; + UnityEditor.EditorUtility.SetDirty(this); + } + + // 检测重复:缓存路径 vs 自身路径比对(O(1)),5 秒内无需重扫 + var cache = GetObjectiveIdCache(); + string myPath = UnityEditor.AssetDatabase.GetAssetPath(this); + if (!string.IsNullOrEmpty(myPath) && + cache.TryGetValue(objectiveId, out var existingPath) && + existingPath != myPath) + { + Debug.LogError( + $"[QuestObjectiveSO] objectiveId '{objectiveId}' 与 " + + $"'{System.IO.Path.GetFileNameWithoutExtension(existingPath)}' 重复!" + + "重复 ID 会导致任务进度互相覆盖,请修改其中一个。", this); + s_objIdsCacheTime = -10.0; + } } #endif } @@ -57,6 +151,14 @@ namespace BaseGames.Quest public string targetNpcId; public override string BadgeLabel => "[对话]"; public override bool EvaluateCompletion(QuestObjectiveState s) => s.progressCount >= 1; + + public override bool TryHandleEvent(QuestEventType eventType, string payload, QuestObjectiveState state) + { + if (eventType != QuestEventType.NpcDialogueCompleted) return false; + if (payload != targetNpcId) return false; + state.progressCount++; + return true; + } } /// 击败指定 ID 的敌人若干次。 @@ -68,7 +170,16 @@ namespace BaseGames.Quest [Tooltip("需击败的次数,默认 1。")] [Min(1)] public int defeatCount = 1; public override string BadgeLabel => "[击败]"; + public override int GetRequiredCount() => defeatCount; public override bool EvaluateCompletion(QuestObjectiveState s) => s.progressCount >= defeatCount; + + public override bool TryHandleEvent(QuestEventType eventType, string payload, QuestObjectiveState state) + { + if (eventType != QuestEventType.EnemyDefeated) return false; + if (payload != targetEnemyId) return false; + state.progressCount++; + return true; + } } /// 收集指定 ID 的物品若干件。 @@ -80,19 +191,54 @@ namespace BaseGames.Quest [Tooltip("需收集的数量,默认 1。")] [Min(1)] public int collectCount = 1; public override string BadgeLabel => "[收集]"; + public override int GetRequiredCount() => collectCount; public override bool EvaluateCompletion(QuestObjectiveState s) => s.progressCount >= collectCount; + + public override bool TryHandleEvent(QuestEventType eventType, string payload, QuestObjectiveState state) + { + if (eventType != QuestEventType.ItemCollected) return false; + if (payload != itemId) return false; + state.progressCount++; + return true; + } } /// 到达指定场景/区域标记点后完成。 [CreateAssetMenu(menuName = "BaseGames/Quest/Objective/Reach")] public class ReachAreaObjective : QuestObjectiveSO { - [Tooltip("需到达的场景名(Unity Build Settings 中的场景名称)。")] + [Tooltip("需到达的场景名(Unity Build Settings 中的场景名称)。留空时仅依赖 markerTag 判定。")] public string sceneName; - [Tooltip("场景内的目标标记 Tag(预留字段,当前未启用)。")] + [Tooltip("场景内的区域标记 Tag(与 TriggerZone.markerTag 保持一致)。\n" + + "非空时:玩家进入挂有对应 markerTag 的 TriggerZone 碰撞体即触发。\n" + + "留空时:整个场景切换即触发(粗粒度,仅检查 sceneName)。")] public string markerTag; public override string BadgeLabel => "[到达]"; public override bool EvaluateCompletion(QuestObjectiveState s) => s.progressCount >= 1; + + public override bool TryHandleEvent(QuestEventType eventType, string payload, QuestObjectiveState state) + { + // 精确区域到达:TriggerZone 广播 AreaReached,payload = markerTag + if (eventType == QuestEventType.AreaReached && + !string.IsNullOrEmpty(markerTag) && + payload == markerTag) + { + state.progressCount++; + return true; + } + + // 场景级到达:payload = sceneName,仅当 markerTag 为空时生效(否则等待精确触发) + if (eventType == QuestEventType.SceneLoaded && + !string.IsNullOrEmpty(sceneName) && + payload == sceneName && + string.IsNullOrEmpty(markerTag)) + { + state.progressCount++; + return true; + } + + return false; + } } /// 使用指定能力若干次后完成。 @@ -104,6 +250,33 @@ namespace BaseGames.Quest [Tooltip("需使用的次数,默认 1。")] [Min(1)] public int useCount = 1; public override string BadgeLabel => "[使用]"; + public override int GetRequiredCount() => useCount; public override bool EvaluateCompletion(QuestObjectiveState s) => s.progressCount >= useCount; + + public override bool TryHandleEvent(QuestEventType eventType, string payload, QuestObjectiveState state) + { + if (eventType != QuestEventType.SkillUsed) return false; + // Enum.TryParse 避免大小写/格式差异导致静默失败 + if (!System.Enum.TryParse(payload, ignoreCase: true, out var parsed)) return false; + if (parsed != requiredAbility) return false; + state.progressCount++; + return true; + } + } + + // ── 扩展事件频道绑定(供 QuestManager Inspector 使用)──────────────────── + + /// + /// 自定义事件频道与任务事件类型的绑定。 + /// 在 QuestManager Inspector 的"扩展事件频道"数组中添加条目,即可不修改代码 + /// 支持未来新增的 枚举值与 SO 频道的映射。 + /// + [System.Serializable] + public struct QuestEventChannelBinding + { + [Tooltip("要监听的事件类型(需与 QuestObjectiveSO 子类中 TryHandleEvent 处理的类型一致)。")] + public QuestEventType eventType; + [Tooltip("该类型对应的 StringEventChannelSO 资产。由广播方(如战斗系统、场景系统)负责 Raise。")] + public StringEventChannelSO channel; } } diff --git a/Assets/_Game/Scripts/Quest/QuestSO.cs b/Assets/_Game/Scripts/Quest/QuestSO.cs index 7bfc451..a4c1a8e 100644 --- a/Assets/_Game/Scripts/Quest/QuestSO.cs +++ b/Assets/_Game/Scripts/Quest/QuestSO.cs @@ -1,5 +1,6 @@ using System; using UnityEngine; +using BaseGames.Core; using BaseGames.Dialogue; namespace BaseGames.Quest @@ -12,29 +13,88 @@ namespace BaseGames.Quest public class QuestSO : ScriptableObject { [Header("标识")] - public string questId; // 唯一 ID,如 "Quest_FindMushroom" + [Tooltip("任务唯一 ID,如 \"Quest_FindMushroom\"。运行时由 QuestManager 以此为键索引,必须全局唯一。")] + public string questId; + + [Tooltip("发布/完成该任务的 NPC(直接引用 NpcSO 资产,推荐)。\n" + + "用于完成任务后向该 NPC 应用 affinityBonus,及 CanAccept 好感度门槛检查。\n" + + "留空时跳过好感度相关逻辑;与旧字段 giverNpcId 同时有值时以此 SO 为准。")] + public NpcSO giverNpc; + + [System.Obsolete("已废弃,请改用 giverNpc(NpcSO 直接引用)。保留以兼容现有资产序列化。")] + [HideInInspector] + public string giverNpcId; + + /// 运行时使用的 NPC ID:giverNpc 优先,回退到旧字段 giverNpcId。 + public string GiverNpcId => (giverNpc != null && !string.IsNullOrEmpty(giverNpc.npcId)) + ? giverNpc.npcId +#pragma warning disable CS0618 + : giverNpcId; +#pragma warning restore CS0618 [Tooltip("本地化 Key,格式如 \"Quest_FindMushroom_Name\"。通过 LocalizationManager.Get(displayNameKey, \"Quest\") 显示。")] public string displayNameKey; [Tooltip("本地化 Key,格式如 \"Quest_FindMushroom_Desc\"。通过 LocalizationManager.Get(descriptionKey, \"Quest\") 显示。")] public string descriptionKey; + [Tooltip("任务图标,显示在日志 UI 和 DataHub 列表中(可选)。")] public Sprite icon; + [Header("分类")] + [Tooltip("任务分类,供任务日志 UI 分区显示和 DataHub 快速过滤使用。\n" + + " Main = 主线任务(必做,推动剧情)\n" + + " Side = 支线任务(可选,丰富世界观)\n" + + " Daily = 日常/重复任务(可重置)\n" + + " Hidden = 隐藏任务(不主动显示,触发后才出现)")] + public QuestCategory category = QuestCategory.Side; + [Header("目标链")] - public QuestObjectiveSO[] objectives; // 按顺序完成,全部完成 = 可交完 + [Tooltip("按顺序完成的目标列表。全部非可选目标完成后任务可交付。每个目标为独立的 QuestObjectiveSO 资产。")] + public QuestObjectiveSO[] objectives; [Header("前置条件")] - public QuestSO[] prerequisiteQuests; // 所有前置任务 Completed 后才可接 - public int minAffinityToAccept; // NPC 好感度门槛(0 = 无限制) + [Tooltip("任务前置条件(统一配置版)。将前置任务依赖和世界标志依赖合并为单一结构,便于 Inspector 管理。\n" + + "如旧版字段(prerequisiteQuests / prerequisiteFlags)已有数据,运行时将自动回退使用旧版字段,无需手动迁移。")] + public QuestPrerequisite prerequisites = new QuestPrerequisite(); + + // ── 旧版前置字段(向后兼容,新配置请改用 prerequisites)──────────────── + [HideInInspector] + [Tooltip("【已归入 prerequisites.questDependencies,此字段仅用于旧资产兼容】\n" + + "所有前置任务必须处于 Completed 状态,本任务才能被接取。\n" + + "⚠ 此字段计划在 v2.0 移除,请尽快通过 QuestSOEditor 迁移至 prerequisites。")] + public QuestSO[] prerequisiteQuests; + + [HideInInspector] + [Tooltip("【已归入 prerequisites.flagCondition.logic,此字段仅用于旧资产兼容】\n" + + "⚠ 此字段计划在 v2.0 移除,请尽快通过 QuestSOEditor 迁移至 prerequisites。")] + public BaseGames.Core.WorldStateFlagLogic prerequisiteFlagsLogic = BaseGames.Core.WorldStateFlagLogic.And; + + [HideInInspector] + [Tooltip("【已归入 prerequisites.flagCondition.flags,此字段仅用于旧资产兼容】\n" + + "⚠ 此字段计划在 v2.0 移除,请尽快通过 QuestSOEditor 迁移至 prerequisites。")] + [BaseGames.Core.WorldStateFlag] + public string[] prerequisiteFlags; + + [Tooltip("接取本任务所需的 NPC 好感度下限(0 = 无限制)。由好感度系统提供实际数值。")] + public int minAffinityToAccept; [Header("奖励")] + [Tooltip("任务完成时发放的奖励(RewardSO)。留空表示无奖励。\n" + + "QuestManager.CompleteQuest() 调用 reward.Apply(rewardTarget),通过 IRewardTarget 接口发放。")] public RewardSO reward; [Header("失败条件(可选)")] + [Tooltip("勾选后,failCondition 目标一旦完成,本任务立即失败并触发 EVT_QuestFailed 事件。")] public bool canFail; + [Tooltip("失败判定目标。canFail=true 时有效;此目标达成即视为任务失败。")] public QuestObjectiveSO failCondition; + [Header("接取/完成对话")] + [Tooltip("玩家接取任务时自动触发的 NPC 对话序列(如 NPC 委托台词)。\n" + + "AcceptQuest 成功后立即播放;留空则不触发。")] + public DialogueSequenceSO acceptDialogueSequence; + [Header("完成后续任务(分支)")] + [Tooltip("完成本任务后解锁的后续任务。conditionQuest=null 为默认分支;多个分支可同时满足(允许同时解锁多条支线)。")] public QuestBranch[] branches; // ── 编辑器校验 ──────────────────────────────────────────────────────── @@ -85,6 +145,128 @@ namespace BaseGames.Quest } ValidateBranchDialogueKeys(); + ValidateObjectiveIds(); + ValidatePrerequisiteCycles(); + ValidateBranchCycles(); + } + + private void ValidateObjectiveIds() + { + if (objectives == null || objectives.Length == 0) return; + var seen = new System.Collections.Generic.HashSet(System.StringComparer.Ordinal); + for (int i = 0; i < objectives.Length; i++) + { + var obj = objectives[i]; + if (obj == null || string.IsNullOrEmpty(obj.objectiveId)) continue; + if (!seen.Add(obj.objectiveId)) + Debug.LogError( + $"[QuestSO] '{name}' 的 objectives[{i}] objectiveId '{obj.objectiveId}' " + + "在本任务内重复!同一个 ObjectiveSO 资产被引用多次会导致进度互相覆盖," + + "请为每个目标使用独立的 SO 资产。", this); + } + } + + /// + /// 检测前置任务链是否形成循环依赖(如 A 前置 B、B 前置 A)。 + /// 循环会导致两个任务互相锁定,运行时无法被接取,属于配置错误。 + /// + private void ValidatePrerequisiteCycles() + { + if (string.IsNullOrEmpty(questId)) return; + + var visited = new System.Collections.Generic.HashSet(System.StringComparer.Ordinal); + visited.Add(questId); + +#pragma warning disable CS0618 + QuestSO[] deps = prerequisites.HasAny ? prerequisites.questDependencies : prerequisiteQuests; +#pragma warning restore CS0618 + + if (deps == null) return; + + foreach (var dep in deps) + { + if (dep == null) continue; + if (HasPrerequisiteCycle(dep, visited)) + { + Debug.LogError( + $"[QuestSO] '{name}'(questId='{questId}')的前置任务链存在循环依赖!" + + $"前置任务 '{dep.name}' 最终指回自身或已访问任务," + + "运行时将导致任务无法被接取。请检查 prerequisites/prerequisiteQuests 配置。", this); + return; + } + } + } + + /// + /// 深度优先遍历前置链,检测是否存在环路。 + /// 已访问节点集 在回溯时移除,保证同一链条中不误报平行分支。 + /// + private static bool HasPrerequisiteCycle(QuestSO quest, + System.Collections.Generic.HashSet visited) + { + if (string.IsNullOrEmpty(quest.questId)) return false; + if (!visited.Add(quest.questId)) return true; // 已在当前路径上 = 环路 + +#pragma warning disable CS0618 + QuestSO[] deps = quest.prerequisites.HasAny ? quest.prerequisites.questDependencies : quest.prerequisiteQuests; +#pragma warning restore CS0618 + + if (deps != null) + { + foreach (var dep in deps) + { + if (dep != null && HasPrerequisiteCycle(dep, visited)) + return true; + } + } + visited.Remove(quest.questId); // 回溯 + return false; + } + + /// + /// 检测 branches[].nextQuest 解锁链是否形成循环(如 A 完成解锁 B,B 完成解锁 A)。 + /// 循环会导致运行时 UnlockBranches 无限递归设置任务状态,属于配置错误。 + /// + private void ValidateBranchCycles() + { + if (branches == null || branches.Length == 0) return; + if (string.IsNullOrEmpty(questId)) return; + + foreach (var branch in branches) + { + if (branch?.nextQuest == null) continue; + var visited = new System.Collections.Generic.HashSet(System.StringComparer.Ordinal); + visited.Add(questId); + if (HasBranchCycle(branch.nextQuest, visited)) + { + Debug.LogError( + $"[QuestSO] '{name}'(questId='{questId}')的分支 nextQuest '{branch.nextQuest.name}' " + + "解锁链存在循环!最终将指回自身或已访问任务,运行时 UnlockBranches 会进入无限递归。" + + "请检查 branches[].nextQuest 配置。", this); + return; // 一次只报首个问题 + } + } + } + + /// + /// 深度优先遍历 branches[].nextQuest 链,检测是否存在环路(DFS 回溯)。 + /// + private static bool HasBranchCycle(QuestSO quest, + System.Collections.Generic.HashSet visited) + { + if (string.IsNullOrEmpty(quest.questId)) return false; + if (!visited.Add(quest.questId)) return true; // 已在路径上 = 环路 + + if (quest.branches != null) + { + foreach (var branch in quest.branches) + { + if (branch?.nextQuest != null && HasBranchCycle(branch.nextQuest, visited)) + return true; + } + } + visited.Remove(quest.questId); // 回溯 + return false; } private void ValidateBranchDialogueKeys() @@ -113,14 +295,75 @@ namespace BaseGames.Quest [Serializable] public class QuestBranch { - /// 若此前置任务已完成 → 走本分支(null = 默认分支)。 + [Tooltip("条件任务:该任务已 Completed 时走本分支(留空 = 默认分支,总是满足)。")] public QuestSO conditionQuest; + [Tooltip("世界状态标志条件判断逻辑:\n" + + " And(默认)= 全部 conditionFlags 均满足才走本分支\n" + + " Or = 任意一个 conditionFlag 满足即可走本分支")] + public BaseGames.Core.WorldStateFlagLogic conditionFlagsLogic = BaseGames.Core.WorldStateFlagLogic.And; + [Tooltip("世界状态标志条件(可选)。按 conditionFlagsLogic 逻辑与 conditionQuest 共同决定分支是否激活。\n" + + "标志由 ISaveService.GetFlag(flagId) 查询,通常由 SetFlagAction 或其他系统写入。")] + [WorldStateFlag] + public string[] conditionFlags; + [Tooltip("本分支解锁的后续任务。满足所有条件后,此任务将被设为 Available。")] public QuestSO nextQuest; - /// 完成后触发的 NPC 对话序列(直接引用,避免手写 sequenceId 字符串出错)。 + [Tooltip("完成本任务后触发的 NPC 对话序列(直接引用 DialogueSequenceSO 资产,无需手写 ID)。")] public DialogueSequenceSO npcDialogueSequence; [System.Obsolete("已废弃,请改用 npcDialogueSequence(直接 SO 引用)。保留字段以兼容现有资产序列化。")] [HideInInspector] public string npcDialogueKey; } + + /// 任务分类,供日志 UI 分区和 DataHub 过滤使用。 + public enum QuestCategory + { + /// 主线任务:必做,推动主剧情进展。 + Main, + /// 支线任务:可选,丰富世界观与 NPC 关系。 + Side, + /// 日常/重复任务:可在满足条件后重置。 + Daily, + /// 隐藏任务:不主动在日志中显示,由特定条件触发后才浮现。 + Hidden, + } + + // ========================================================================= + // QuestPrerequisite ── 任务前置条件(统一配置结构) + // ========================================================================= + + /// + /// 任务前置条件统一配置结构。 + /// 将旧版三个独立字段(prerequisiteQuests / prerequisiteFlags / prerequisiteFlagsLogic) + /// 合并为单一可序列化类,便于 Inspector 统一管理与代码维护。 + /// 运行时通过 判断是否启用新格式;若未配置则自动回退到旧版字段。 + /// + [Serializable] + public class QuestPrerequisite + { + [Tooltip("所有前置任务必须处于 Completed 状态,本任务才能被接取。留空表示无前置任务限制。")] + public QuestSO[] questDependencies; + + [Tooltip("世界状态标志前置条件(支持 And / Or 逻辑)。")] + public FlagCondition flagCondition; + + /// 此前置结构是否配置了任何条件(用于判断是否启用新格式,回退到旧字段)。 + public bool HasAny => + (questDependencies != null && questDependencies.Length > 0) || + (flagCondition.flags != null && flagCondition.flags.Length > 0); + + /// + /// 世界状态标志前置条件,支持 And(全部满足)或 Or(任一满足)逻辑。 + /// + [Serializable] + public struct FlagCondition + { + [Tooltip("标志逻辑模式:\n And(默认)= 全部标志均须为 true\n Or = 任意一个标志为 true 即可解锁")] + public BaseGames.Core.WorldStateFlagLogic logic; + + [Tooltip("前置世界状态标志 ID 列表。留空表示无标志前置限制。")] + [BaseGames.Core.WorldStateFlag] + public string[] flags; + } + } } diff --git a/Assets/_Game/Scripts/Quest/RewardSO.cs b/Assets/_Game/Scripts/Quest/RewardSO.cs index bde0c73..f4e6846 100644 --- a/Assets/_Game/Scripts/Quest/RewardSO.cs +++ b/Assets/_Game/Scripts/Quest/RewardSO.cs @@ -1,5 +1,6 @@ using UnityEngine; using BaseGames.Core.Events; +using BaseGames.Player; namespace BaseGames.Quest { @@ -11,18 +12,42 @@ namespace BaseGames.Quest [CreateAssetMenu(menuName = "BaseGames/Quest/Reward")] public class RewardSO : ScriptableObject { - public int lingZhu; // LingZhu 货币奖励 - public int soulBonus; // 灵魂槽扩展(+MaxSoulPower) - public string[] itemIds; // 物品/护符 ID 列表(通过 InventoryManager 发放) - public int affinityBonus; // 对发布 NPC 的好感度增量(存入 SaveData.World.NpcRelations) - public string unlockDialogueKey; // 解锁 NPC 新台词集合 key(架构 §4) + [Header("货币与属性")] + [Tooltip("完成任务奖励的灵珠数量(0 = 不奖励)。")] + public int lingZhu; - [Tooltip("是否解锁能力(AbilityType 无 None 值,用 bool 标识)")] - public bool unlocksAbility; // ⚠️ AbilityType 无 None,用 bool 标识 - public uint unlockedAbilityFlag; // AbilityType 的 uint 位掩码值(仅当 unlocksAbility == true 有效) + [Tooltip("完成任务后永久增加的灵魂槽上限数值(0 = 不奖励)。")] + public int soulBonus; + + [Header("物品")] + [Tooltip("完成任务奖励的物品/护符 ID 列表。每个 ID 通过 EVT_CollectiblePickup 事件频道广播,由 InventoryManager 处理。\n" + + "格式如 [\"Charm_DashBoost\", \"Item_HealShard\"]。留空表示无物品奖励。")] + public string[] itemIds; + + [Header("NPC 关系")] + [Tooltip("完成任务后对 giverNpcId(QuestSO 中配置)的好感度增量。\n" + + "正值=好感增加,负值=好感降低。0 = 不影响好感度。\n" + + "增量以强类型 NpcAffinityEvent(npcId + delta + newTotal)广播至 EVT_NpcAffinityChanged,\n" + + "并持久化到 SaveData.World.NpcRelations。接收方无需字符串解析。")] + public int affinityBonus; + + [Tooltip("完成任务后解锁的 NPC 台词集合 Key(格式如 \"DLG_Elder_PostQuest\")。\n" + + "非空时广播 EVT_DialogueKeyUnlocked 事件,供 NPC 台词管理系统监听并切换对话集。\n" + + "留空表示不解锁新台词。")] + public string unlockDialogueKey; + + [Header("能力解锁")] + [Tooltip("勾选后,unlockedAbilityFlag 中指定的能力将在完成任务时解锁。")] + public bool unlocksAbility; + + [Tooltip("要解锁的能力(AbilityType 组合标志位,仅当 unlocksAbility == true 有效)。\n" + + "可多选,支持组合解锁多项能力。")] + public AbilityType unlockedAbilityFlag; [Header("物品发放事件")] - [Tooltip("EVT_CollectiblePickup:向 QuestManager/EquipmentManager 广播 itemId")] + [Tooltip("EVT_CollectiblePickup 事件频道(StringEventChannelSO)。\n" + + "Apply() 调用时,每个 itemId 都会通过此频道广播,供 EquipmentManager/QuestManager 处理。\n" + + "未连线时物品奖励不生效。")] [SerializeField] private StringEventChannelSO _onCollectiblePickup; /// @@ -35,14 +60,25 @@ namespace BaseGames.Quest if (lingZhu > 0) target.AddLingZhu(lingZhu); if (soulBonus > 0) target.AddSoulPower(soulBonus); - if (unlocksAbility && unlockedAbilityFlag != 0) - target.UnlockAbilityFlag(unlockedAbilityFlag); + if (unlocksAbility && unlockedAbilityFlag != AbilityType.None) + target.UnlockAbilityFlag((uint)unlockedAbilityFlag); // 通过 EVT_CollectiblePickup 事件频道广播每个物品 ID - if (itemIds != null && _onCollectiblePickup != null) + if (itemIds != null && itemIds.Length > 0) { - foreach (var id in itemIds) - _onCollectiblePickup.Raise(id); + if (_onCollectiblePickup != null) + { + foreach (var id in itemIds) + _onCollectiblePickup.Raise(id); + } + else + { +#if UNITY_EDITOR || DEVELOPMENT_BUILD + Debug.LogWarning( + $"[RewardSO] '{name}' 配置了 {itemIds.Length} 个 itemIds," + + "但 _onCollectiblePickup 事件频道未连线,物品奖励不会发放。请在 Inspector 中指定 EVT_CollectiblePickup 频道。", this); +#endif + } } } } diff --git a/Assets/_Game/Scripts/World/TriggerZone.cs b/Assets/_Game/Scripts/World/TriggerZone.cs new file mode 100644 index 0000000..b510b8b --- /dev/null +++ b/Assets/_Game/Scripts/World/TriggerZone.cs @@ -0,0 +1,76 @@ +using BaseGames.Core.Events; +using UnityEngine; + +namespace BaseGames.World +{ + /// + /// 区域到达触发器(架构 22_QuestChallengeModule §3 扩展)。 + /// 挂在场景中的 2D 触发碰撞体上,玩家进入时广播 EVT_AreaReached 事件, + /// 驱动 ReachAreaObjective(markerTag 模式)的任务目标进度。 + /// + /// 使用方式: + /// 1. 在目标区域创建空 GameObject,添加 Collider2D(勾选 IsTrigger)。 + /// 2. 挂上 TriggerZone,填写 markerTag(与 ReachAreaObjective.markerTag 保持一致)。 + /// 3. 将 EVT_AreaReached 事件频道资产拖入 _onAreaReached 字段。 + /// 4. 将 QuestManager 同一 _onAreaReached 频道字段也引用同一资产即可联通。 + /// + [RequireComponent(typeof(Collider2D))] + public class TriggerZone : MonoBehaviour + { + [Header("区域标识")] + [Tooltip("区域唯一标记(如 \"ZONE_ForestEntry\")。需与 ReachAreaObjective.markerTag 保持完全一致。")] + [SerializeField] private string _markerTag; + + [Tooltip("触发后是否只生效一次(推荐勾选,防止重复广播)。")] + [SerializeField] private bool _triggerOnce = true; + + [Header("玩家层级")] + [Tooltip("玩家所在的物理层,用于过滤非玩家碰撞体进入。只有属于此层的碰撞体才触发广播。")] + [SerializeField] private LayerMask _playerLayer; + + [Header("事件频道")] + [Tooltip("EVT_AreaReached 事件频道(StringEventChannelSO)。触发时以 markerTag 为 payload 广播。\n" + + "需与 QuestManager._onAreaReached 字段引用同一资产。")] + [SerializeField] private StringEventChannelSO _onAreaReached; + + private bool _triggered; + + /// 区域唯一标记(只读)。供编辑器工具(QuestModule 批量验证)交叉比对使用。 + public string MarkerTag => _markerTag; + + private void OnTriggerEnter2D(Collider2D other) + { + if (_triggered) return; + if (string.IsNullOrEmpty(_markerTag)) return; + if (((1 << other.gameObject.layer) & _playerLayer.value) == 0) return; + + _onAreaReached?.Raise(_markerTag); + + if (_triggerOnce) _triggered = true; + } + + /// 重置触发状态(如读档/重新进入关卡时调用)。 + public void ResetTrigger() => _triggered = false; + +#if UNITY_EDITOR + private void OnDrawGizmos() + { + if (string.IsNullOrEmpty(_markerTag)) return; + Gizmos.color = new Color(0.2f, 0.8f, 0.4f, 0.25f); + var col = GetComponent(); + if (col != null) Gizmos.DrawWireSphere(col.bounds.center, 0.3f); + } + + private void OnDrawGizmosSelected() + { + var col = GetComponent(); + if (col == null) return; + Gizmos.color = new Color(0.2f, 0.9f, 0.4f, 0.5f); + Gizmos.DrawWireCube(col.bounds.center, col.bounds.size); + UnityEditor.Handles.Label( + col.bounds.center + Vector3.up * (col.bounds.extents.y + 0.2f), + string.IsNullOrEmpty(_markerTag) ? "(未设置 markerTag)" : _markerTag); + } +#endif + } +} diff --git a/Assets/_Game/Scripts/World/TriggerZone.cs.meta b/Assets/_Game/Scripts/World/TriggerZone.cs.meta new file mode 100644 index 0000000..a10e67c --- /dev/null +++ b/Assets/_Game/Scripts/World/TriggerZone.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a77b179b1f28b6048bdf8aa9a92a1ea6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/World/WorldStateRegistry.cs b/Assets/_Game/Scripts/World/WorldStateRegistry.cs index 1892a53..bf1efc7 100644 --- a/Assets/_Game/Scripts/World/WorldStateRegistry.cs +++ b/Assets/_Game/Scripts/World/WorldStateRegistry.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using BaseGames.Core; using BaseGames.Core.Save; using UnityEngine; @@ -22,16 +23,23 @@ namespace BaseGames.World /// LoadAsync → WorldStateRegistrySaver.OnLoad → 调用 LoadFromSave(data) 恢复缓存。 /// [CreateAssetMenu(menuName = "BaseGames/World/WorldStateRegistry")] - public class WorldStateRegistry : ScriptableObject + public class WorldStateRegistry : ScriptableObject, IWorldStateReader { // ── 统一状态字典 ───────────────────────────────────────────────────── private readonly Dictionary> _states = new(); /// /// 状态变更时广播:(类别, id)。UI / 测试代码可订阅此事件做响应式刷新。 + /// 若需批量写入多个 ID,推荐使用 避免同帧多次重绘。 /// public event Action OnStateChanged; + /// + /// 批次状态变更时广播:(类别, 新增标记的 ID 数组)。 + /// 由 触发,一次性广播所有新增 ID,避免 UI 同帧重绘 N 次。 + /// + public event Action OnBatchStateChanged; + /// /// Editor 重新进入 Play Mode 时 ScriptableObject 保留上一次运行的状态, /// OnEnable 在域重载(Domain Reload)和每次 Play 开始时都会调用,确保状态干净。 @@ -70,10 +78,39 @@ namespace BaseGames.World public bool HasFlag(string key) => IsMarked(WorldObjectCategory.Flag, key); public void SetFlag(string key) => Mark(WorldObjectCategory.Flag, key); - public void ClearFlag(string key) + public void ClearFlag(string key) => Clear(WorldObjectCategory.Flag, key); + + /// + /// 通用清除接口:移除指定类别中 id 的标记状态(幂等)。 + /// 用于调试重置、测试、或撤销错误标记。 + /// + public void Clear(WorldObjectCategory category, string id) { - if (_states.TryGetValue(WorldObjectCategory.Flag, out var set) && set.Remove(key)) - OnStateChanged?.Invoke(WorldObjectCategory.Flag, key); + if (_states.TryGetValue(category, out var set) && set.Remove(id)) + OnStateChanged?.Invoke(category, id); + } + + /// + /// 一次性标记多个 ID(批次写入)。已标记的 ID 被幂等跳过; + /// 全部写入后触发单次 ,而非逐个触发 , + /// 适合 EventChain 同帧连续设置多个标志时使用以避免 UI 同帧重绘 N 次。 + /// + /// 实际新增标记的 ID 数量。 + public int BatchMark(WorldObjectCategory category, System.Collections.Generic.IEnumerable ids) + { + if (ids == null) return 0; + if (!_states.TryGetValue(category, out var set)) + { + set = new HashSet(); + _states[category] = set; + } + var added = new System.Collections.Generic.List(); + foreach (var id in ids) + if (!string.IsNullOrEmpty(id) && set.Add(id)) + added.Add(id); + if (added.Count > 0) + OnBatchStateChanged?.Invoke(category, added.ToArray()); + return added.Count; } // ── Persistence ─────────────────────────────────────────────────────── @@ -98,14 +135,18 @@ namespace BaseGames.World } /// - /// 返回指定分类中所有已标记的 ID(只读视图,非副本)。 + /// 返回指定分类中所有已标记 ID 的快照副本(数组)。 /// 由 WorldStateRegistrySaver.OnSave 调用,将运行时状态写入 SaveData。 - /// 注意:不保证跨帧稳定性,调用方若需持久快照请自行 ToArray()。 + /// 返回数组副本而非内部集合引用,防止调用方意外修改内部状态。 /// public IReadOnlyCollection GetAllIds(WorldObjectCategory category) { - if (_states.TryGetValue(category, out var set)) - return set; + if (_states.TryGetValue(category, out var set) && set.Count > 0) + { + var copy = new string[set.Count]; + set.CopyTo(copy); + return copy; + } return System.Array.Empty(); }