diff --git a/Assets/_Game/Scripts/Dialogue/DialogueManager.cs b/Assets/_Game/Scripts/Dialogue/DialogueManager.cs index 64daae0..4d0a4a4 100644 --- a/Assets/_Game/Scripts/Dialogue/DialogueManager.cs +++ b/Assets/_Game/Scripts/Dialogue/DialogueManager.cs @@ -74,6 +74,8 @@ namespace BaseGames.Dialogue private DialogueSequenceSO _choiceBranchResult; /// HandleChoices 子协程写入结果:true = 分支深度超限,优雅降级(继续播放后续行)。 private bool _branchDepthExceeded; + /// 当前正在播放对话的 NPC ID(无对话时为 null)。供外部系统主动查询"谁在说话"。 + private string _currentNpcId; // ── 复用 Yield 指令,避免协程每行 new WaitUntil 闭包 ─────────────── private sealed class WaitTypingOrSkip : CustomYieldInstruction @@ -101,6 +103,8 @@ namespace BaseGames.Dialogue private WaitForChoice _waitForChoice; // 延迟 0.15s 防止玩家快速连击穿透:跳过打字机后立即触发选项0 private WaitForSeconds _waitChoiceInputGuard; + // 超时守卫等待指令(与 _sequenceTimeoutSeconds 同步,在 Awake 初始化,避免每次 PlayImmediate 分配) + private WaitForSeconds _waitSequenceTimeout; /// /// 当 IsDialogueActive 时排队等待的对话请求。 @@ -113,6 +117,12 @@ namespace BaseGames.Dialogue /// 当前是否有对话正在播放。 public bool IsDialogueActive { get; private set; } + /// + /// 当前正在播放对话的 NPC ID。无对话活跃时为 。 + /// 供地图标记、HUD、分析埋点等外部系统主动查询"当前谁在说话",无需订阅事件。 + /// + public string CurrentNpcId => _currentNpcId; + /// 当前正在播放的对话优先级(0 = 默认)。高优先级请求可打断低优先级。 private int _currentPriority; @@ -129,6 +139,8 @@ namespace BaseGames.Dialogue _waitSkip = new WaitSkip(this); _waitForChoice = new WaitForChoice(this); _waitChoiceInputGuard = new WaitForSeconds(0.15f); + if (_sequenceTimeoutSeconds > 0f) + _waitSequenceTimeout = new WaitForSeconds(_sequenceTimeoutSeconds); } private void OnDestroy() @@ -230,6 +242,7 @@ namespace BaseGames.Dialogue private void PlayImmediate(DialogueSequenceSO sequence, string npcId, int priority = 0) { IsDialogueActive = true; + _currentNpcId = npcId; _currentPriority = priority; _skipRequested = false; _selectedChoiceIndex = -1; @@ -249,7 +262,7 @@ namespace BaseGames.Dialogue /// private IEnumerator SequenceTimeoutGuard(string npcId) { - yield return new WaitForSeconds(_sequenceTimeoutSeconds); + yield return _waitSequenceTimeout ?? new WaitForSeconds(_sequenceTimeoutSeconds); if (!IsDialogueActive) yield break; Debug.LogError( $"[DialogueManager] 对话序列 (npcId='{npcId}') 超时 {_sequenceTimeoutSeconds}s 未结束," + @@ -271,6 +284,7 @@ namespace BaseGames.Dialogue _dialogueBox?.HideChoices(); _dialogueBox?.Hide(); IsDialogueActive = false; + _currentNpcId = null; _currentPriority = 0; _skipRequested = false; _selectedChoiceIndex = -1; @@ -424,6 +438,7 @@ namespace BaseGames.Dialogue { _dialogueBox?.Hide(); IsDialogueActive = false; + _currentNpcId = null; _currentPriority = 0; // 优先通过 _onDialogueEnded 事件让 InputManager 决定如何恢复输入; diff --git a/Assets/_Game/Scripts/Dialogue/IDialogueService.cs b/Assets/_Game/Scripts/Dialogue/IDialogueService.cs index 16c3825..4209810 100644 --- a/Assets/_Game/Scripts/Dialogue/IDialogueService.cs +++ b/Assets/_Game/Scripts/Dialogue/IDialogueService.cs @@ -8,6 +8,12 @@ namespace BaseGames.Dialogue /// 当前是否有对话正在播放。 bool IsDialogueActive { get; } + /// + /// 当前正在播放对话的 NPC ID。无对话活跃时为 。 + /// 供地图标记、HUD、分析埋点等外部系统主动查询,无需订阅事件。 + /// + string CurrentNpcId { get; } + /// /// 每次对话序列(含分支链)全部播完后触发。 /// 测试代码可订阅此事件等待对话结束,无需依赖 VoidEventChannelSO 资产。 diff --git a/Assets/_Game/Scripts/Editor/Quest/QuestOverviewEditorWindow.cs b/Assets/_Game/Scripts/Editor/Quest/QuestOverviewEditorWindow.cs new file mode 100644 index 0000000..a025a95 --- /dev/null +++ b/Assets/_Game/Scripts/Editor/Quest/QuestOverviewEditorWindow.cs @@ -0,0 +1,314 @@ +using System.Collections.Generic; +using UnityEditor; +using UnityEngine; +using BaseGames.Quest; +using QuestStateEnum = BaseGames.Core.Events.QuestState; + +namespace BaseGames.Editor.Quest +{ + /// + /// 任务总览编辑器窗口(架构 22_QuestChallengeModule §Editor)。 + /// 菜单:BaseGames/Quest/Quest Overview + /// + /// 功能: + /// - 列出项目中所有 QuestSO 资产,支持按名称/ID/分类过滤 + /// - Play Mode:读取 IQuestManager 运行时状态,以颜色区分 Active / Completed / Failed 等 + /// - 单击行 → 在 Project 窗口中高亮(Ping)对应资产 + /// - 双击行 → 在 Inspector 中选中对应资产 + /// - 顶部统计栏(编辑器):总数 / 缺少 questId / 缺少本地化 Key + /// - Play Mode 统计栏:Active / Completed / Failed 数量 + /// + public class QuestOverviewEditorWindow : EditorWindow + { + // ── State ────────────────────────────────────────────────────────── + private QuestSO[] _allQuests = System.Array.Empty(); + private QuestSO[] _filtered = System.Array.Empty(); + private Vector2 _scroll; + private string _searchText = string.Empty; + private QuestCategory _categoryFilter = (QuestCategory)(-1); // -1 = 全部 + private double _lastRefreshTime = -30.0; + private const double RefreshIntervalSeconds = 5.0; + + // ── Play Mode runtime 状态缓存 ───────────────────────────────────── + private IQuestManager _runtimeManager; + private bool _isPlayMode; + + // ── Colors ───────────────────────────────────────────────────────── + private static readonly Color ColActive = new Color(0.25f, 0.70f, 0.95f, 0.85f); + private static readonly Color ColCompleted = new Color(0.20f, 0.80f, 0.30f, 0.85f); + private static readonly Color ColFailed = new Color(0.90f, 0.25f, 0.25f, 0.85f); + private static readonly Color ColPaused = new Color(0.90f, 0.75f, 0.20f, 0.85f); + private static readonly Color ColAvail = new Color(0.80f, 0.80f, 0.80f, 0.70f); + private static readonly Color ColUnavail = new Color(0.45f, 0.45f, 0.45f, 0.60f); + private static readonly Color ColError = new Color(1.00f, 0.60f, 0.10f, 0.85f); + + // ── Menu ─────────────────────────────────────────────────────────── + [MenuItem("BaseGames/Quest/Quest Overview")] + public static void OpenWindow() + { + var win = GetWindow("Quest Overview"); + win.minSize = new Vector2(680, 420); + win.Show(); + } + + // ── Lifecycle ────────────────────────────────────────────────────── + + private void OnEnable() + { + EditorApplication.playModeStateChanged += OnPlayModeChanged; + RefreshQuestList(); + } + + private void OnDisable() + { + EditorApplication.playModeStateChanged -= OnPlayModeChanged; + } + + private void OnPlayModeChanged(PlayModeStateChange change) + { + _isPlayMode = EditorApplication.isPlaying; + if (_isPlayMode) + RefreshRuntimeManager(); + else + _runtimeManager = null; + Repaint(); + } + + private void OnFocus() + { + RefreshQuestList(); + } + + // ── GUI ──────────────────────────────────────────────────────────── + + private void OnGUI() + { + // 定时自动刷新(Edit Mode 下资产可能被增删) + if (EditorApplication.timeSinceStartup - _lastRefreshTime > RefreshIntervalSeconds) + RefreshQuestList(); + + if (_isPlayMode && _runtimeManager == null) + RefreshRuntimeManager(); + + DrawToolbar(); + DrawStats(); + DrawTable(); + } + + private void DrawToolbar() + { + EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); + + // 搜索框 + GUI.SetNextControlName("SearchField"); + var newSearch = EditorGUILayout.TextField(_searchText, EditorStyles.toolbarSearchField, + GUILayout.Width(220)); + if (newSearch != _searchText) + { + _searchText = newSearch; + ApplyFilter(); + } + + // 分类过滤 + GUILayout.Space(8); + GUILayout.Label("分类:", GUILayout.Width(36)); + var cats = new[] { "全部", "Main", "Side", "Daily", "Hidden" }; + var catVals = new[] { (QuestCategory)(-1), QuestCategory.Main, QuestCategory.Side, + QuestCategory.Daily, QuestCategory.Hidden }; + int curIdx = System.Array.IndexOf(catVals, _categoryFilter); + int newIdx = EditorGUILayout.Popup(curIdx < 0 ? 0 : curIdx, cats, + EditorStyles.toolbarDropDown, GUILayout.Width(80)); + if (newIdx != (curIdx < 0 ? 0 : curIdx)) + { + _categoryFilter = catVals[newIdx]; + ApplyFilter(); + } + + GUILayout.FlexibleSpace(); + + if (GUILayout.Button("刷新", EditorStyles.toolbarButton, GUILayout.Width(48))) + RefreshQuestList(); + + EditorGUILayout.EndHorizontal(); + } + + private void DrawStats() + { + EditorGUILayout.BeginHorizontal(EditorStyles.helpBox); + + GUILayout.Label($"共 {_allQuests.Length} 个任务 显示 {_filtered.Length}", EditorStyles.miniLabel); + + if (!_isPlayMode) + { + int missingId = 0, missingKey = 0; + foreach (var q in _allQuests) + { + if (q == null) continue; + if (string.IsNullOrWhiteSpace(q.questId)) missingId++; + if (string.IsNullOrWhiteSpace(q.displayNameKey)) missingKey++; + } + GUILayout.FlexibleSpace(); + if (missingId > 0) + { + var prev = GUI.color; + GUI.color = ColError; + GUILayout.Label($"⚠ 缺少 questId: {missingId}", EditorStyles.miniLabel); + GUI.color = prev; + } + if (missingKey > 0) + { + var prev = GUI.color; + GUI.color = ColError; + GUILayout.Label($"⚠ 缺少本地化 Key: {missingKey}", EditorStyles.miniLabel); + GUI.color = prev; + } + } + else if (_runtimeManager != null) + { + int active = 0, completed = 0, failed = 0; + foreach (var q in _allQuests) + { + if (q == null || string.IsNullOrEmpty(q.questId)) continue; + var s = _runtimeManager.GetState(q.questId); + if (s == QuestStateEnum.Active) active++; + if (s == QuestStateEnum.Completed) completed++; + if (s == QuestStateEnum.Failed) failed++; + } + GUILayout.FlexibleSpace(); + GUI.color = ColActive; GUILayout.Label($"进行中 {active}", EditorStyles.miniLabel); + GUI.color = ColCompleted; GUILayout.Label($"完成 {completed}", EditorStyles.miniLabel); + GUI.color = ColFailed; GUILayout.Label($"失败 {failed}", EditorStyles.miniLabel); + GUI.color = Color.white; + } + + GUILayout.FlexibleSpace(); + EditorGUILayout.EndHorizontal(); + } + + private void DrawTable() + { + // 表头 + EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); + GUILayout.Label("questId", EditorStyles.miniLabel, GUILayout.Width(200)); + GUILayout.Label("资产名称", EditorStyles.miniLabel, GUILayout.Width(170)); + GUILayout.Label("分类", EditorStyles.miniLabel, GUILayout.Width(55)); + GUILayout.Label("displayNameKey", EditorStyles.miniLabel, GUILayout.Width(160)); + if (_isPlayMode) + GUILayout.Label("运行时状态", EditorStyles.miniLabel, GUILayout.Width(80)); + GUILayout.FlexibleSpace(); + EditorGUILayout.EndHorizontal(); + + _scroll = EditorGUILayout.BeginScrollView(_scroll); + + for (int i = 0; i < _filtered.Length; i++) + { + var quest = _filtered[i]; + if (quest == null) continue; + + Color rowColor = Color.clear; + string stateLabel = string.Empty; + + if (_isPlayMode && _runtimeManager != null && !string.IsNullOrEmpty(quest.questId)) + { + var s = _runtimeManager.GetState(quest.questId); + (rowColor, stateLabel) = s switch + { + QuestStateEnum.Active => (ColActive, "Active"), + QuestStateEnum.Completed => (ColCompleted, "Completed"), + QuestStateEnum.Failed => (ColFailed, "Failed"), + QuestStateEnum.Paused => (ColPaused, "Paused"), + QuestStateEnum.Available => (ColAvail, "Available"), + _ => (ColUnavail, "Unavailable"), + }; + } + else + { + // Edit Mode:对有配置问题的行用警告色 + bool hasError = string.IsNullOrWhiteSpace(quest.questId) + || string.IsNullOrWhiteSpace(quest.displayNameKey); + rowColor = hasError ? new Color(1f, 0.85f, 0.3f, 0.15f) : (i % 2 == 0 + ? new Color(0.2f, 0.2f, 0.2f, 0.1f) + : Color.clear); + } + + // 行背景 + Rect rowRect = EditorGUILayout.BeginHorizontal(); + if (rowColor != Color.clear) + EditorGUI.DrawRect(rowRect, rowColor); + + EditorGUILayout.LabelField( + string.IsNullOrWhiteSpace(quest.questId) ? "⚠ (空)" : quest.questId, + GUILayout.Width(200)); + EditorGUILayout.LabelField(quest.name, GUILayout.Width(170)); + EditorGUILayout.LabelField(quest.category.ToString(), GUILayout.Width(55)); + EditorGUILayout.LabelField( + string.IsNullOrWhiteSpace(quest.displayNameKey) ? "⚠ (空)" : quest.displayNameKey, + GUILayout.Width(160)); + if (_isPlayMode) + EditorGUILayout.LabelField(stateLabel, GUILayout.Width(80)); + + GUILayout.FlexibleSpace(); + EditorGUILayout.EndHorizontal(); + + // 单击 Ping,双击 Select + if (Event.current.type == EventType.MouseDown && rowRect.Contains(Event.current.mousePosition)) + { + if (Event.current.clickCount == 1) + EditorGUIUtility.PingObject(quest); + else + Selection.activeObject = quest; + Event.current.Use(); + } + } + + EditorGUILayout.EndScrollView(); + } + + // ── 数据加载 ────────────────────────────────────────────────────── + + private void RefreshQuestList() + { + var list = new List(); + string[] guids = AssetDatabase.FindAssets("t:QuestSO"); + foreach (var guid in guids) + { + var q = AssetDatabase.LoadAssetAtPath(AssetDatabase.GUIDToAssetPath(guid)); + if (q != null) list.Add(q); + } + list.Sort((a, b) => string.Compare(a.questId, b.questId, System.StringComparison.Ordinal)); + _allQuests = list.ToArray(); + ApplyFilter(); + _lastRefreshTime = EditorApplication.timeSinceStartup; + Repaint(); + } + + private void ApplyFilter() + { + var result = new List(_allQuests.Length); + foreach (var q in _allQuests) + { + if (q == null) continue; + + // 分类过滤 + if ((int)_categoryFilter != -1 && q.category != _categoryFilter) continue; + + // 文字搜索(questId / 资产名 / displayNameKey) + if (!string.IsNullOrEmpty(_searchText)) + { + bool match = q.questId.IndexOf(_searchText, System.StringComparison.OrdinalIgnoreCase) >= 0 + || q.name.IndexOf(_searchText, System.StringComparison.OrdinalIgnoreCase) >= 0 + || (!string.IsNullOrEmpty(q.displayNameKey) && + q.displayNameKey.IndexOf(_searchText, System.StringComparison.OrdinalIgnoreCase) >= 0); + if (!match) continue; + } + result.Add(q); + } + _filtered = result.ToArray(); + } + + private void RefreshRuntimeManager() + { + _runtimeManager = BaseGames.Core.ServiceLocator.GetOrDefault(); + } + } +} diff --git a/Assets/_Game/Scripts/Quest/QuestGiver.cs b/Assets/_Game/Scripts/Quest/QuestGiver.cs index e961be9..3e39753 100644 --- a/Assets/_Game/Scripts/Quest/QuestGiver.cs +++ b/Assets/_Game/Scripts/Quest/QuestGiver.cs @@ -31,6 +31,11 @@ namespace BaseGames.Quest [Tooltip("任务已完成(QuestState.Completed)后再次交互时播放。通常是 NPC 闲聊或后续剧情的对话。")] [SerializeField] private DialogueSequenceSO _completedDialogue; + [Header("交互选项")] + [Tooltip("勾选后,任务进行中(Active 且未完成)时交互提示变为"放弃任务",交互即触发 AbandonQuest。\n" + + "适合允许玩家主动放弃的支线任务;主线任务建议保持取消勾选。")] + [SerializeField] private bool _allowAbandon; + // ── InteractableNPC 覆盖 ────────────────────────────────────────────── // 缓存上次查找结果,避免 InteractPrompt get(每帧调用)重复遍历 _offeredQuests。 @@ -46,6 +51,8 @@ namespace BaseGames.Quest private const string K_InProgress = "QUEST_PROMPT_IN_PROGRESS"; private const string K_Paused = "QUEST_PROMPT_PAUSED"; private const string K_Talk = "QUEST_PROMPT_TALK"; + private const string K_Locked = "QUEST_PROMPT_LOCKED"; + private const string K_Abandon = "QUEST_PROMPT_ABANDON"; // 缓存 IQuestManager + IQuestEventSource 引用,避免每次访问 InteractPrompt 调用 SL.GetOrDefault private IQuestManager _questManager; @@ -100,12 +107,32 @@ namespace BaseGames.Quest { var quest = GetCachedQuest(); if (quest == null || _questManager == null) return base.InteractPrompt; + + if (_cachedState == QuestStateEnum.Available) + { + // 检查亲密度门槛等锁定条件,锁定时显示具体原因而非直接"接受任务" + var lockInfo = _questManager.GetQuestLockInfo(quest.questId); + if (lockInfo.IsLocked) + { + string fallback = lockInfo.Reason == QuestLockReason.InsufficientAffinity + ? $"好感度不足({lockInfo.Param})" + : "条件未满足"; + return GetPrompt(K_Locked, fallback); + } + return GetPrompt(K_Accept, "接受任务"); + } + + if (_cachedState == QuestStateEnum.Active) + { + if (_questManager.IsReadyToComplete(quest.questId)) + return GetPrompt(K_Submit, "提交任务"); + return _allowAbandon + ? GetPrompt(K_Abandon, "放弃任务") + : GetPrompt(K_InProgress, "进行中…"); + } + return _cachedState switch { - QuestStateEnum.Available => GetPrompt(K_Accept, "接受任务"), - QuestStateEnum.Active => _questManager.IsReadyToComplete(quest.questId) - ? GetPrompt(K_Submit, "提交任务") - : GetPrompt(K_InProgress, "进行中…"), QuestStateEnum.Paused => GetPrompt(K_Paused, "暂停中…"), QuestStateEnum.Completed => GetPrompt(K_Talk, "对话"), _ => base.InteractPrompt, @@ -120,15 +147,26 @@ namespace BaseGames.Quest if (_cachedState == QuestStateEnum.Available) { + // 亲密度门槛等锁定条件未满足时静默返回(InteractPrompt 已显示原因,玩家可见) + var lockInfo = _questManager.GetQuestLockInfo(quest.questId); + if (lockInfo.IsLocked) return; _questManager.AcceptQuest(quest.questId); // OnQuestStateChanged 事件会自动触发 HandleQuestStateChanged → _cacheDirty = true } - else if (_cachedState == QuestStateEnum.Active && _questManager.IsReadyToComplete(quest.questId)) + else if (_cachedState == QuestStateEnum.Active) { - // 直接从 player 获取 PlayerStats,避免对 PlayerController 的程序集依赖 - var stats = player.GetComponentInParent(); - _questManager.CompleteQuest(quest.questId, stats); - // OnQuestStateChanged 事件会自动触发 HandleQuestStateChanged → _cacheDirty = true + if (_questManager.IsReadyToComplete(quest.questId)) + { + // 直接从 player 获取 PlayerStats,避免对 PlayerController 的程序集依赖 + var stats = player.GetComponentInParent(); + _questManager.CompleteQuest(quest.questId, stats); + // OnQuestStateChanged 事件会自动触发 HandleQuestStateChanged → _cacheDirty = true + } + else if (_allowAbandon) + { + _questManager.AbandonQuest(quest.questId); + // OnQuestStateChanged 事件会自动触发 HandleQuestStateChanged → _cacheDirty = true + } } } diff --git a/Assets/_Game/Scripts/Quest/QuestManager.cs b/Assets/_Game/Scripts/Quest/QuestManager.cs index 660d792..a9865dc 100644 --- a/Assets/_Game/Scripts/Quest/QuestManager.cs +++ b/Assets/_Game/Scripts/Quest/QuestManager.cs @@ -395,7 +395,13 @@ namespace BaseGames.Quest "但 giverNpc 未配置,好感度无法发放。请在 QuestSO 中指定 giverNpc。", quest); return; } - if (string.IsNullOrEmpty(quest.GiverNpcId)) return; + if (string.IsNullOrEmpty(quest.GiverNpcId)) + { + Debug.LogWarning( + $"[QuestManager] 任务 '{quest.questId}' 的 giverNpc.npcId 为空字符串,好感度无法写入。" + + "请在对应 NpcSO 中填写有效的 npcId 后重新保存 QuestSO。", quest.giverNpc); + return; + } _npcRelations.TryGetValue(quest.GiverNpcId, out int current); int newTotal = current + quest.reward.affinityBonus; @@ -504,7 +510,13 @@ namespace BaseGames.Quest { if (dialogueService != null) { - dialogueService.StartDialogue(branch.npcDialogueSequence, quest.GiverNpcId ?? ""); + string npcId = quest.GiverNpcId; + if (string.IsNullOrEmpty(npcId)) + Debug.LogWarning( + $"[QuestManager] 任务 '{questId}' 完成分支对话的 giverNpc.npcId 为空," + + "EVT_NpcDialogueCompleted 将广播空 npcId,可能错误推进对话类目标进度。" + + "请在 NpcSO 中填写有效的 npcId。"); + dialogueService.StartDialogue(branch.npcDialogueSequence, npcId ?? ""); dialoguePlayed = true; } else diff --git a/Assets/_Game/Scripts/Quest/QuestSO.cs b/Assets/_Game/Scripts/Quest/QuestSO.cs index 691c67e..5266ff3 100644 --- a/Assets/_Game/Scripts/Quest/QuestSO.cs +++ b/Assets/_Game/Scripts/Quest/QuestSO.cs @@ -118,6 +118,16 @@ namespace BaseGames.Quest s_questIdsCacheTime = -10.0; } + // 本地化 Key 完整性检查:空 Key 会导致 UI 显示空文本(未本地化内容) + if (string.IsNullOrWhiteSpace(displayNameKey)) + Debug.LogWarning( + $"[QuestSO] '{name}'(questId='{questId}')的 displayNameKey 为空," + + "任务日志 UI 将显示空白名称。请填写本地化 Key,如 \"Quest_{questId}_Name\"。", this); + if (string.IsNullOrWhiteSpace(descriptionKey)) + Debug.LogWarning( + $"[QuestSO] '{name}'(questId='{questId}')的 descriptionKey 为空," + + "任务详情 UI 将显示空白描述。请填写本地化 Key,如 \"Quest_{questId}_Desc\"。", this); + ValidateObjectiveIds(); ValidatePrerequisiteCycles(); ValidateBranchCycles();