fix: Round 55 递归硬中止、存档加载缓存刷新、好感度空值防护、选项穿透延迟、分支对话去重
- 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>
This commit is contained in:
@@ -99,6 +99,8 @@ namespace BaseGames.Dialogue
|
|||||||
private WaitTypingOrSkip _waitTypingOrSkip;
|
private WaitTypingOrSkip _waitTypingOrSkip;
|
||||||
private WaitSkip _waitSkip;
|
private WaitSkip _waitSkip;
|
||||||
private WaitForChoice _waitForChoice;
|
private WaitForChoice _waitForChoice;
|
||||||
|
// 延迟 0.15s 防止玩家快速连击穿透:跳过打字机后立即触发选项0
|
||||||
|
private WaitForSeconds _waitChoiceInputGuard;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 当 IsDialogueActive 时排队等待的对话请求。
|
/// 当 IsDialogueActive 时排队等待的对话请求。
|
||||||
@@ -123,9 +125,10 @@ namespace BaseGames.Dialogue
|
|||||||
{
|
{
|
||||||
if (ServiceLocator.GetOrDefault<IDialogueService>() != null) { Destroy(gameObject); return; }
|
if (ServiceLocator.GetOrDefault<IDialogueService>() != null) { Destroy(gameObject); return; }
|
||||||
ServiceLocator.Register<IDialogueService>(this);
|
ServiceLocator.Register<IDialogueService>(this);
|
||||||
_waitTypingOrSkip = new WaitTypingOrSkip(this);
|
_waitTypingOrSkip = new WaitTypingOrSkip(this);
|
||||||
_waitSkip = new WaitSkip(this);
|
_waitSkip = new WaitSkip(this);
|
||||||
_waitForChoice = new WaitForChoice(this);
|
_waitForChoice = new WaitForChoice(this);
|
||||||
|
_waitChoiceInputGuard = new WaitForSeconds(0.15f);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnDestroy()
|
private void OnDestroy()
|
||||||
@@ -390,9 +393,10 @@ namespace BaseGames.Dialogue
|
|||||||
// 清除打字机阶段积压的输入,防止选项显示后被立即误触发
|
// 清除打字机阶段积压的输入,防止选项显示后被立即误触发
|
||||||
_skipRequested = false;
|
_skipRequested = false;
|
||||||
_selectedChoiceIndex = -1;
|
_selectedChoiceIndex = -1;
|
||||||
// 延迟一帧:确保此前积压的"确认键"输入在下一帧开始前已被消耗,
|
// 延迟 0.15s:确保此前积压的"确认键"输入已被彻底消耗,
|
||||||
// 防止快速连击(先跳过打字机→再误触发选项0)的穿透问题。
|
// 防止快速连击(跳过打字机→立即误选选项0)的穿透问题。
|
||||||
yield return null;
|
// 使用预创建的缓存实例,避免每次分配 WaitForSeconds 对象。
|
||||||
|
yield return _waitChoiceInputGuard;
|
||||||
// 捕获当前播放标记,防止被打断后遗留回调写入新序列的选择索引
|
// 捕获当前播放标记,防止被打断后遗留回调写入新序列的选择索引
|
||||||
int capturedId = _playbackId;
|
int capturedId = _playbackId;
|
||||||
_dialogueBox.ShowChoices(line.choices, idx =>
|
_dialogueBox.ShowChoices(line.choices, idx =>
|
||||||
|
|||||||
@@ -286,8 +286,13 @@ namespace BaseGames.Dialogue
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static bool HasChoiceCycle(DialogueSequenceSO seq,
|
private static bool HasChoiceCycle(DialogueSequenceSO seq,
|
||||||
System.Collections.Generic.HashSet<string> visited)
|
System.Collections.Generic.HashSet<string> visited, int depth = 0)
|
||||||
{
|
{
|
||||||
|
if (depth > 32)
|
||||||
|
{
|
||||||
|
Debug.LogError($"[DialogueSequenceSO] 选项链深度超过 32 层(路径末端:'{seq.name}'),已视为存在循环引用并中止检测。请减少 nextSequence 嵌套层数。");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (string.IsNullOrEmpty(seq.sequenceId)) return false;
|
if (string.IsNullOrEmpty(seq.sequenceId)) return false;
|
||||||
if (!visited.Add(seq.sequenceId)) return true;
|
if (!visited.Add(seq.sequenceId)) return true;
|
||||||
if (seq.lines != null)
|
if (seq.lines != null)
|
||||||
@@ -297,7 +302,7 @@ namespace BaseGames.Dialogue
|
|||||||
if (line.choices == null) continue;
|
if (line.choices == null) continue;
|
||||||
foreach (var choice in line.choices)
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -307,7 +312,7 @@ namespace BaseGames.Dialogue
|
|||||||
{
|
{
|
||||||
foreach (var variant in seq.variants)
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,6 +173,12 @@ namespace BaseGames.Quest
|
|||||||
/// 参数:(questId, oldState, newState)。
|
/// 参数:(questId, oldState, newState)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
event Action<string, QuestStateEnum, QuestStateEnum> OnQuestStateChanged;
|
event Action<string, QuestStateEnum, QuestStateEnum> OnQuestStateChanged;
|
||||||
|
/// <summary>
|
||||||
|
/// 存档加载完成后触发(OnLoad 结束时)。
|
||||||
|
/// 供 QuestGiver、HUD 等缓存组件订阅以刷新本地缓存,
|
||||||
|
/// 避免因存档加载跳过 OnQuestStateChanged 事件导致显示陈旧数据。
|
||||||
|
/// </summary>
|
||||||
|
event System.Action OnAfterSaveLoaded;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|||||||
@@ -58,7 +58,10 @@ namespace BaseGames.Quest
|
|||||||
_questManager = SL.GetOrDefault<IQuestManager>();
|
_questManager = SL.GetOrDefault<IQuestManager>();
|
||||||
_questEvents = _questManager as IQuestEventSource;
|
_questEvents = _questManager as IQuestEventSource;
|
||||||
if (_questEvents != null)
|
if (_questEvents != null)
|
||||||
|
{
|
||||||
_questEvents.OnQuestStateChanged += HandleQuestStateChanged;
|
_questEvents.OnQuestStateChanged += HandleQuestStateChanged;
|
||||||
|
_questEvents.OnAfterSaveLoaded += HandleAfterSaveLoaded;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnDisable()
|
protected override void OnDisable()
|
||||||
@@ -67,6 +70,7 @@ namespace BaseGames.Quest
|
|||||||
if (_questEvents != null)
|
if (_questEvents != null)
|
||||||
{
|
{
|
||||||
_questEvents.OnQuestStateChanged -= HandleQuestStateChanged;
|
_questEvents.OnQuestStateChanged -= HandleQuestStateChanged;
|
||||||
|
_questEvents.OnAfterSaveLoaded -= HandleAfterSaveLoaded;
|
||||||
_questEvents = null;
|
_questEvents = null;
|
||||||
}
|
}
|
||||||
_questManager = null;
|
_questManager = null;
|
||||||
@@ -80,6 +84,9 @@ namespace BaseGames.Quest
|
|||||||
if (q != null && q.questId == questId) { _cacheDirty = true; return; }
|
if (q != null && q.questId == questId) { _cacheDirty = true; return; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 存档加载完成后统一刷新缓存,确保 NPC 交互提示反映最新任务状态
|
||||||
|
private void HandleAfterSaveLoaded() => _cacheDirty = true;
|
||||||
|
|
||||||
// 本地化提示词辅助:如 Key 在表中找不到(返回值等于 Key 自身),回退到内联默认文本
|
// 本地化提示词辅助:如 Key 在表中找不到(返回值等于 Key 自身),回退到内联默认文本
|
||||||
private static string GetPrompt(string key, string fallback)
|
private static string GetPrompt(string key, string fallback)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -241,6 +241,8 @@ namespace BaseGames.Quest
|
|||||||
public event System.Action<string> OnQuestReadyToComplete;
|
public event System.Action<string> OnQuestReadyToComplete;
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public event System.Action<string, QuestStateEnum, QuestStateEnum> OnQuestStateChanged;
|
public event System.Action<string, QuestStateEnum, QuestStateEnum> OnQuestStateChanged;
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public event System.Action OnAfterSaveLoaded;
|
||||||
|
|
||||||
// ── 公共 API ──────────────────────────────────────────────────────────
|
// ── 公共 API ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -384,14 +386,29 @@ namespace BaseGames.Quest
|
|||||||
private void ApplyAffinity(QuestSO quest)
|
private void ApplyAffinity(QuestSO quest)
|
||||||
{
|
{
|
||||||
if (quest.reward == null || quest.reward.affinityBonus == 0) return;
|
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;
|
if (string.IsNullOrEmpty(quest.GiverNpcId)) return;
|
||||||
|
|
||||||
_npcRelations.TryGetValue(quest.GiverNpcId, out int current);
|
_npcRelations.TryGetValue(quest.GiverNpcId, out int current);
|
||||||
int newTotal = current + quest.reward.affinityBonus;
|
int newTotal = current + quest.reward.affinityBonus;
|
||||||
|
|
||||||
// 上限截断:npcSO.maxAffinity > 0 时好感度不得超过上限
|
// 上限截断:npcSO.maxAffinity > 0 时好感度不得超过上限
|
||||||
int maxAffinity = quest.giverNpc?.maxAffinity ?? 0;
|
int maxAffinity = quest.giverNpc.maxAffinity;
|
||||||
if (maxAffinity > 0 && newTotal > 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
|
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
||||||
Debug.LogWarning(
|
Debug.LogWarning(
|
||||||
@@ -421,7 +438,8 @@ namespace BaseGames.Quest
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 解锁满足条件的后续任务分支,并触发相应 NPC 完成反应对话。
|
/// 解锁满足条件的后续任务分支,并触发相应 NPC 完成反应对话。
|
||||||
/// 允许同时满足多个分支(并行支线解锁)。
|
/// 允许同时满足多个分支(并行支线解锁),但 NPC 完成对话只播放首个有对话的分支,
|
||||||
|
/// 避免多分支同时满足时连续打断播放导致对话混乱。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void UnlockBranches(string questId, QuestSO quest)
|
private void UnlockBranches(string questId, QuestSO quest)
|
||||||
{
|
{
|
||||||
@@ -430,6 +448,9 @@ namespace BaseGames.Quest
|
|||||||
var dialogueService = BaseGames.Core.ServiceLocator.GetOrDefault<BaseGames.Dialogue.IDialogueService>();
|
var dialogueService = BaseGames.Core.ServiceLocator.GetOrDefault<BaseGames.Dialogue.IDialogueService>();
|
||||||
var saveService = BaseGames.Core.ServiceLocator.GetOrDefault<BaseGames.Core.ISaveService>();
|
var saveService = BaseGames.Core.ServiceLocator.GetOrDefault<BaseGames.Core.ISaveService>();
|
||||||
|
|
||||||
|
// 同一次完成事件只播放首个有对话配置的满足分支,避免多分支同时满足时重复播放对话
|
||||||
|
bool dialoguePlayed = false;
|
||||||
|
|
||||||
foreach (var branch in quest.branches)
|
foreach (var branch in quest.branches)
|
||||||
{
|
{
|
||||||
// 任务条件
|
// 任务条件
|
||||||
@@ -478,10 +499,14 @@ namespace BaseGames.Quest
|
|||||||
_questStates[branch.nextQuest.questId] = QuestStateEnum.Available;
|
_questStates[branch.nextQuest.questId] = QuestStateEnum.Available;
|
||||||
|
|
||||||
// 触发 NPC 完成反应对话(如 NPC 说"太好了,谢谢你!")
|
// 触发 NPC 完成反应对话(如 NPC 说"太好了,谢谢你!")
|
||||||
if (branch.npcDialogueSequence != null)
|
// 仅对首个有对话的满足分支播放,防止多分支同时满足时连续播放导致混乱
|
||||||
|
if (!dialoguePlayed && branch.npcDialogueSequence != null)
|
||||||
{
|
{
|
||||||
if (dialogueService != null)
|
if (dialogueService != null)
|
||||||
|
{
|
||||||
dialogueService.StartDialogue(branch.npcDialogueSequence, quest.GiverNpcId ?? "");
|
dialogueService.StartDialogue(branch.npcDialogueSequence, quest.GiverNpcId ?? "");
|
||||||
|
dialoguePlayed = true;
|
||||||
|
}
|
||||||
else
|
else
|
||||||
Debug.LogWarning(
|
Debug.LogWarning(
|
||||||
$"[QuestManager] 任务 '{questId}' 完成后需播放 NPC 对话 " +
|
$"[QuestManager] 任务 '{questId}' 完成后需播放 NPC 对话 " +
|
||||||
@@ -713,6 +738,9 @@ namespace BaseGames.Quest
|
|||||||
#endif
|
#endif
|
||||||
// 存档中未记录的无前置任务,在新周目/首次加载后也保证可接取
|
// 存档中未记录的无前置任务,在新周目/首次加载后也保证可接取
|
||||||
InitializeAvailableQuests();
|
InitializeAvailableQuests();
|
||||||
|
// 通知所有缓存了任务状态的组件(如 QuestGiver)刷新显示,
|
||||||
|
// 因存档加载不逐条触发 OnQuestStateChanged,需要统一广播一次刷新信号。
|
||||||
|
OnAfterSaveLoaded?.Invoke();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 私有辅助 ─────────────────────────────────────────────────────────
|
// ── 私有辅助 ─────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -188,8 +188,8 @@ namespace BaseGames.Quest
|
|||||||
{
|
{
|
||||||
if (depth > 32)
|
if (depth > 32)
|
||||||
{
|
{
|
||||||
Debug.LogWarning($"[QuestSO] 前置链深度超过 32 层(路径末端:'{quest.name}'),已停止检测。请减少任务链深度。");
|
Debug.LogError($"[QuestSO] 前置链深度超过 32 层(路径末端:'{quest.name}'),已视为存在循环依赖并中止检测。请减少任务链深度。");
|
||||||
return false;
|
return true;
|
||||||
}
|
}
|
||||||
if (string.IsNullOrEmpty(quest.questId)) return false;
|
if (string.IsNullOrEmpty(quest.questId)) return false;
|
||||||
if (!visited.Add(quest.questId)) return true; // 已在当前路径上 = 环路
|
if (!visited.Add(quest.questId)) return true; // 已在当前路径上 = 环路
|
||||||
@@ -242,8 +242,8 @@ namespace BaseGames.Quest
|
|||||||
{
|
{
|
||||||
if (depth > 32)
|
if (depth > 32)
|
||||||
{
|
{
|
||||||
Debug.LogWarning($"[QuestSO] 分支链深度超过 32 层(路径末端:'{quest.name}'),已停止检测。请减少分支链深度。");
|
Debug.LogError($"[QuestSO] 分支链深度超过 32 层(路径末端:'{quest.name}'),已视为存在循环依赖并中止检测。请减少分支链深度。");
|
||||||
return false;
|
return true;
|
||||||
}
|
}
|
||||||
if (string.IsNullOrEmpty(quest.questId)) return false;
|
if (string.IsNullOrEmpty(quest.questId)) return false;
|
||||||
if (!visited.Add(quest.questId)) return true; // 已在路径上 = 环路
|
if (!visited.Add(quest.questId)) return true; // 已在路径上 = 环路
|
||||||
|
|||||||
Reference in New Issue
Block a user