From 8e88fc42e97caf8cad6a7bf1026d1cd33d9e8d93 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Mon, 25 May 2026 07:37:03 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20Round=2055=20=E9=80=92=E5=BD=92=E7=A1=AC?= =?UTF-8?q?=E4=B8=AD=E6=AD=A2=E3=80=81=E5=AD=98=E6=A1=A3=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E7=BC=93=E5=AD=98=E5=88=B7=E6=96=B0=E3=80=81=E5=A5=BD=E6=84=9F?= =?UTF-8?q?=E5=BA=A6=E7=A9=BA=E5=80=BC=E9=98=B2=E6=8A=A4=E3=80=81=E9=80=89?= =?UTF-8?q?=E9=A1=B9=E7=A9=BF=E9=80=8F=E5=BB=B6=E8=BF=9F=E3=80=81=E5=88=86?= =?UTF-8?q?=E6=94=AF=E5=AF=B9=E8=AF=9D=E5=8E=BB=E9=87=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - QuestSO.HasPrerequisiteCycle/HasBranchCycle: depth>32 改为 LogError+return true 硬中止,防止栈溢出 - DialogueSequenceSO.HasChoiceCycle: 新增 depth 参数及 >32 硬中止,同时更新递归调用传 depth+1 - IQuestEventSource: 新增 OnAfterSaveLoaded 事件接口,供存档加载后统一刷新缓存 - QuestManager.OnLoad: 末尾触发 OnAfterSaveLoaded,确保所有缓存组件收到通知 - QuestGiver: 订阅 OnAfterSaveLoaded 设 _cacheDirty,存档恢复后 NPC 交互提示始终最新 - QuestManager.ApplyAffinity: 新增 giverNpc null 显式 LogWarning、maxAffinity<0 LogError 防护 - DialogueManager: 选项穿透防护改为预创建 WaitForSeconds(0.15f),替代 yield return null - QuestManager.UnlockBranches: 多分支同时满足时只播首个有对话的分支,防止重复播放 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../_Game/Scripts/Dialogue/DialogueManager.cs | 16 +++++---- .../Scripts/Dialogue/DialogueSequenceSO.cs | 11 ++++-- Assets/_Game/Scripts/Quest/IQuestManager.cs | 6 ++++ Assets/_Game/Scripts/Quest/QuestGiver.cs | 7 ++++ Assets/_Game/Scripts/Quest/QuestManager.cs | 36 ++++++++++++++++--- Assets/_Game/Scripts/Quest/QuestSO.cs | 8 ++--- 6 files changed, 67 insertions(+), 17 deletions(-) diff --git a/Assets/_Game/Scripts/Dialogue/DialogueManager.cs b/Assets/_Game/Scripts/Dialogue/DialogueManager.cs index 6f465c1..64daae0 100644 --- a/Assets/_Game/Scripts/Dialogue/DialogueManager.cs +++ b/Assets/_Game/Scripts/Dialogue/DialogueManager.cs @@ -99,6 +99,8 @@ namespace BaseGames.Dialogue private WaitTypingOrSkip _waitTypingOrSkip; private WaitSkip _waitSkip; private WaitForChoice _waitForChoice; + // 延迟 0.15s 防止玩家快速连击穿透:跳过打字机后立即触发选项0 + private WaitForSeconds _waitChoiceInputGuard; /// /// 当 IsDialogueActive 时排队等待的对话请求。 @@ -123,9 +125,10 @@ namespace BaseGames.Dialogue { if (ServiceLocator.GetOrDefault() != null) { Destroy(gameObject); return; } ServiceLocator.Register(this); - _waitTypingOrSkip = new WaitTypingOrSkip(this); - _waitSkip = new WaitSkip(this); - _waitForChoice = new WaitForChoice(this); + _waitTypingOrSkip = new WaitTypingOrSkip(this); + _waitSkip = new WaitSkip(this); + _waitForChoice = new WaitForChoice(this); + _waitChoiceInputGuard = new WaitForSeconds(0.15f); } private void OnDestroy() @@ -390,9 +393,10 @@ namespace BaseGames.Dialogue // 清除打字机阶段积压的输入,防止选项显示后被立即误触发 _skipRequested = false; _selectedChoiceIndex = -1; - // 延迟一帧:确保此前积压的"确认键"输入在下一帧开始前已被消耗, - // 防止快速连击(先跳过打字机→再误触发选项0)的穿透问题。 - yield return null; + // 延迟 0.15s:确保此前积压的"确认键"输入已被彻底消耗, + // 防止快速连击(跳过打字机→立即误选选项0)的穿透问题。 + // 使用预创建的缓存实例,避免每次分配 WaitForSeconds 对象。 + yield return _waitChoiceInputGuard; // 捕获当前播放标记,防止被打断后遗留回调写入新序列的选择索引 int capturedId = _playbackId; _dialogueBox.ShowChoices(line.choices, idx => diff --git a/Assets/_Game/Scripts/Dialogue/DialogueSequenceSO.cs b/Assets/_Game/Scripts/Dialogue/DialogueSequenceSO.cs index 9fe19c7..b5fa8f8 100644 --- a/Assets/_Game/Scripts/Dialogue/DialogueSequenceSO.cs +++ b/Assets/_Game/Scripts/Dialogue/DialogueSequenceSO.cs @@ -286,8 +286,13 @@ namespace BaseGames.Dialogue } private static bool HasChoiceCycle(DialogueSequenceSO seq, - System.Collections.Generic.HashSet visited) + System.Collections.Generic.HashSet visited, int depth = 0) { + if (depth > 32) + { + Debug.LogError($"[DialogueSequenceSO] 选项链深度超过 32 层(路径末端:'{seq.name}'),已视为存在循环引用并中止检测。请减少 nextSequence 嵌套层数。"); + return true; + } if (string.IsNullOrEmpty(seq.sequenceId)) return false; if (!visited.Add(seq.sequenceId)) return true; if (seq.lines != null) @@ -297,7 +302,7 @@ namespace BaseGames.Dialogue if (line.choices == null) continue; foreach (var choice in line.choices) { - if (choice.nextSequence != null && HasChoiceCycle(choice.nextSequence, visited)) + if (choice.nextSequence != null && HasChoiceCycle(choice.nextSequence, visited, depth + 1)) return true; } } @@ -307,7 +312,7 @@ namespace BaseGames.Dialogue { foreach (var variant in seq.variants) { - if (variant.sequence != null && HasChoiceCycle(variant.sequence, visited)) + if (variant.sequence != null && HasChoiceCycle(variant.sequence, visited, depth + 1)) return true; } } diff --git a/Assets/_Game/Scripts/Quest/IQuestManager.cs b/Assets/_Game/Scripts/Quest/IQuestManager.cs index 58e7b75..a628747 100644 --- a/Assets/_Game/Scripts/Quest/IQuestManager.cs +++ b/Assets/_Game/Scripts/Quest/IQuestManager.cs @@ -173,6 +173,12 @@ namespace BaseGames.Quest /// 参数:(questId, oldState, newState)。 /// event Action OnQuestStateChanged; + /// + /// 存档加载完成后触发(OnLoad 结束时)。 + /// 供 QuestGiver、HUD 等缓存组件订阅以刷新本地缓存, + /// 避免因存档加载跳过 OnQuestStateChanged 事件导致显示陈旧数据。 + /// + event System.Action OnAfterSaveLoaded; } // ========================================================================= diff --git a/Assets/_Game/Scripts/Quest/QuestGiver.cs b/Assets/_Game/Scripts/Quest/QuestGiver.cs index 904bd15..e961be9 100644 --- a/Assets/_Game/Scripts/Quest/QuestGiver.cs +++ b/Assets/_Game/Scripts/Quest/QuestGiver.cs @@ -58,7 +58,10 @@ namespace BaseGames.Quest _questManager = SL.GetOrDefault(); _questEvents = _questManager as IQuestEventSource; if (_questEvents != null) + { _questEvents.OnQuestStateChanged += HandleQuestStateChanged; + _questEvents.OnAfterSaveLoaded += HandleAfterSaveLoaded; + } } protected override void OnDisable() @@ -67,6 +70,7 @@ namespace BaseGames.Quest if (_questEvents != null) { _questEvents.OnQuestStateChanged -= HandleQuestStateChanged; + _questEvents.OnAfterSaveLoaded -= HandleAfterSaveLoaded; _questEvents = null; } _questManager = null; @@ -80,6 +84,9 @@ namespace BaseGames.Quest if (q != null && q.questId == questId) { _cacheDirty = true; return; } } + // 存档加载完成后统一刷新缓存,确保 NPC 交互提示反映最新任务状态 + private void HandleAfterSaveLoaded() => _cacheDirty = true; + // 本地化提示词辅助:如 Key 在表中找不到(返回值等于 Key 自身),回退到内联默认文本 private static string GetPrompt(string key, string fallback) { diff --git a/Assets/_Game/Scripts/Quest/QuestManager.cs b/Assets/_Game/Scripts/Quest/QuestManager.cs index 3d09ad8..660d792 100644 --- a/Assets/_Game/Scripts/Quest/QuestManager.cs +++ b/Assets/_Game/Scripts/Quest/QuestManager.cs @@ -241,6 +241,8 @@ namespace BaseGames.Quest public event System.Action OnQuestReadyToComplete; /// public event System.Action OnQuestStateChanged; + /// + public event System.Action OnAfterSaveLoaded; // ── 公共 API ────────────────────────────────────────────────────────── @@ -384,14 +386,29 @@ namespace BaseGames.Quest private void ApplyAffinity(QuestSO quest) { if (quest.reward == null || quest.reward.affinityBonus == 0) return; + + // 明确检查 giverNpc 引用及 npcId 有效性,提供可操作的错误信息 + if (quest.giverNpc == null) + { + Debug.LogWarning( + $"[QuestManager] 任务 '{quest.questId}' 有好感度奖励 +{quest.reward.affinityBonus}," + + "但 giverNpc 未配置,好感度无法发放。请在 QuestSO 中指定 giverNpc。", quest); + 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) + int maxAffinity = quest.giverNpc.maxAffinity; + if (maxAffinity < 0) + { + Debug.LogError( + $"[QuestManager] NPC '{quest.GiverNpcId}' 的 maxAffinity 配置为负数({maxAffinity})," + + "应为 0(无上限)或正整数。已跳过上限截断,请修正 NpcSO 配置。", quest.giverNpc); + } + else if (maxAffinity > 0 && newTotal > maxAffinity) { #if UNITY_EDITOR || DEVELOPMENT_BUILD Debug.LogWarning( @@ -421,7 +438,8 @@ namespace BaseGames.Quest /// /// 解锁满足条件的后续任务分支,并触发相应 NPC 完成反应对话。 - /// 允许同时满足多个分支(并行支线解锁)。 + /// 允许同时满足多个分支(并行支线解锁),但 NPC 完成对话只播放首个有对话的分支, + /// 避免多分支同时满足时连续打断播放导致对话混乱。 /// private void UnlockBranches(string questId, QuestSO quest) { @@ -430,6 +448,9 @@ namespace BaseGames.Quest var dialogueService = BaseGames.Core.ServiceLocator.GetOrDefault(); var saveService = BaseGames.Core.ServiceLocator.GetOrDefault(); + // 同一次完成事件只播放首个有对话配置的满足分支,避免多分支同时满足时重复播放对话 + bool dialoguePlayed = false; + foreach (var branch in quest.branches) { // 任务条件 @@ -478,10 +499,14 @@ namespace BaseGames.Quest _questStates[branch.nextQuest.questId] = QuestStateEnum.Available; // 触发 NPC 完成反应对话(如 NPC 说"太好了,谢谢你!") - if (branch.npcDialogueSequence != null) + // 仅对首个有对话的满足分支播放,防止多分支同时满足时连续播放导致混乱 + if (!dialoguePlayed && branch.npcDialogueSequence != null) { if (dialogueService != null) + { dialogueService.StartDialogue(branch.npcDialogueSequence, quest.GiverNpcId ?? ""); + dialoguePlayed = true; + } else Debug.LogWarning( $"[QuestManager] 任务 '{questId}' 完成后需播放 NPC 对话 " + @@ -713,6 +738,9 @@ namespace BaseGames.Quest #endif // 存档中未记录的无前置任务,在新周目/首次加载后也保证可接取 InitializeAvailableQuests(); + // 通知所有缓存了任务状态的组件(如 QuestGiver)刷新显示, + // 因存档加载不逐条触发 OnQuestStateChanged,需要统一广播一次刷新信号。 + OnAfterSaveLoaded?.Invoke(); } // ── 私有辅助 ───────────────────────────────────────────────────────── diff --git a/Assets/_Game/Scripts/Quest/QuestSO.cs b/Assets/_Game/Scripts/Quest/QuestSO.cs index 0fc0c8d..691c67e 100644 --- a/Assets/_Game/Scripts/Quest/QuestSO.cs +++ b/Assets/_Game/Scripts/Quest/QuestSO.cs @@ -188,8 +188,8 @@ namespace BaseGames.Quest { if (depth > 32) { - Debug.LogWarning($"[QuestSO] 前置链深度超过 32 层(路径末端:'{quest.name}'),已停止检测。请减少任务链深度。"); - return false; + Debug.LogError($"[QuestSO] 前置链深度超过 32 层(路径末端:'{quest.name}'),已视为存在循环依赖并中止检测。请减少任务链深度。"); + return true; } if (string.IsNullOrEmpty(quest.questId)) return false; if (!visited.Add(quest.questId)) return true; // 已在当前路径上 = 环路 @@ -242,8 +242,8 @@ namespace BaseGames.Quest { if (depth > 32) { - Debug.LogWarning($"[QuestSO] 分支链深度超过 32 层(路径末端:'{quest.name}'),已停止检测。请减少分支链深度。"); - return false; + Debug.LogError($"[QuestSO] 分支链深度超过 32 层(路径末端:'{quest.name}'),已视为存在循环依赖并中止检测。请减少分支链深度。"); + return true; } if (string.IsNullOrEmpty(quest.questId)) return false; if (!visited.Add(quest.questId)) return true; // 已在路径上 = 环路