feat: Round 49 narrative systems improvements

QuestManager: extract CheckQuestDepsAndFlags shared method, simplify GetQuestLockInfo/CanAccept/MeetsPrerequisites; add GetQuestsInState+FilterQuests implementations; fix extra brace compile bug; add _pauseTimestamps logging; use actualDelta in ApplyAffinity event.

QuestSO: add depth>32 guard to HasPrerequisiteCycle and HasBranchCycle to prevent editor freeze on deep chains.

EventChainModule: replace FindObjectOfType with ServiceLocator.GetOrDefault in ForceExecute; add self-trigger flag detection (check 6) in ValidateAllChains using reflection.

DialogueVariantPreviewWindow: add matrix analysis section enumerating all 2^N flag combinations (N<=10) with table showing winning variant per combination.

WorldStateRegistry: LoadFromSave null guard on data.World sub-collections (P0 fix).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-05-25 00:17:27 +08:00
parent 6eaa83dc71
commit 3c3ea1ead6
6 changed files with 279 additions and 118 deletions

View File

@@ -122,6 +122,10 @@ namespace BaseGames.Quest
/// <summary>已广播过 EVT_QuestReadyToComplete 的任务 ID 集合(防重复通知)。
/// 任务完成/失败时从集合移除,再次激活后可重新通知。</summary>
private readonly HashSet<string> _notifiedReadyQuests = new();
#if UNITY_EDITOR || DEVELOPMENT_BUILD
/// <summary>任务暂停时记录的 realtimeSinceStartup供 ResumeQuest 计算暂停持续时长并日志输出)。</summary>
private readonly Dictionary<string, float> _pauseTimestamps = new();
#endif
/// <summary>供需要直接订阅 SO 频道的系统使用(推荐使用 IQuestEventSource C# 事件)。</summary>
public StringEventChannelSO QuestStartedChannel => _onQuestStarted;
/// <summary>供需要直接订阅 SO 频道的系统使用(推荐使用 IQuestEventSource C# 事件)。</summary>
@@ -313,6 +317,10 @@ namespace BaseGames.Quest
if (GetState(questId) != QuestStateEnum.Active) return;
_questStates[questId] = QuestStateEnum.Paused;
OnQuestStateChanged?.Invoke(questId, QuestStateEnum.Active, QuestStateEnum.Paused);
#if UNITY_EDITOR || DEVELOPMENT_BUILD
_pauseTimestamps[questId] = UnityEngine.Time.realtimeSinceStartup;
Debug.Log($"[QuestManager] 任务 '{questId}' 已暂停realtimeSinceStartup={UnityEngine.Time.realtimeSinceStartup:F2}s。");
#endif
Chan_QuestPaused?.Raise(questId);
OnQuestPaused?.Invoke(questId);
}
@@ -327,6 +335,14 @@ namespace BaseGames.Quest
if (GetState(questId) != QuestStateEnum.Paused) return;
_questStates[questId] = QuestStateEnum.Active;
OnQuestStateChanged?.Invoke(questId, QuestStateEnum.Paused, QuestStateEnum.Active);
#if UNITY_EDITOR || DEVELOPMENT_BUILD
if (_pauseTimestamps.TryGetValue(questId, out float pausedAt))
{
float duration = UnityEngine.Time.realtimeSinceStartup - pausedAt;
_pauseTimestamps.Remove(questId);
Debug.Log($"[QuestManager] 任务 '{questId}' 已恢复(暂停持续 {duration:F2}s。");
}
#endif
Chan_QuestResumed?.Raise(questId);
OnQuestResumed?.Invoke(questId);
}
@@ -367,16 +383,18 @@ namespace BaseGames.Quest
#if UNITY_EDITOR || DEVELOPMENT_BUILD
Debug.LogWarning(
$"[QuestManager] 任务 '{quest.questId}' 好感度奖励 +{quest.reward.affinityBonus} " +
$"将超出 NPC '{quest.GiverNpcId}' 的上限 {maxAffinity}(当前 {current}),已截断至 {maxAffinity}。");
$"将超出 NPC '{quest.GiverNpcId}' 的上限 {maxAffinity}(当前 {current}),已截断至 {maxAffinity},实际增量为 {maxAffinity - current}。");
#endif
newTotal = maxAffinity;
}
// 广播实际写入的 delta截断后而非请求值UI 层显示 "+5" 而非因截断产生误导的 "+20"
int actualDelta = newTotal - current;
_npcRelations[quest.GiverNpcId] = newTotal;
Chan_NpcAffinityChanged?.Raise(new NpcAffinityEvent
{
npcId = quest.GiverNpcId,
delta = quest.reward.affinityBonus,
delta = actualDelta,
newTotal = newTotal
});
}
@@ -503,7 +521,7 @@ namespace BaseGames.Quest
var quest = GetQuestSO(questId);
if (quest == null) return new QuestLockInfo { Reason = QuestLockReason.NotFound };
// 好感度门槛检查
// 好感度门槛检查(仅 GetQuestLockInfo 关心,不影响 MeetsPrerequisites
if (quest.minAffinityToAccept > 0 && !string.IsNullOrEmpty(quest.GiverNpcId))
{
if (!_affinityInitialized) return new QuestLockInfo { Reason = QuestLockReason.DataNotLoaded };
@@ -512,36 +530,8 @@ namespace BaseGames.Quest
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<ISaveService>();
if (svc != null && !EvaluateFlagPrerequisites(fc.flags, fc.logic, svc))
return new QuestLockInfo { Reason = QuestLockReason.FlagConditionNotMet };
}
return new QuestLockInfo { Reason = QuestLockReason.None }; // 无锁定,任务可接取
// 前置依赖 + 标志检查委托给 CheckQuestDepsAndFlags单一权威实现
return CheckQuestDepsAndFlags(quest);
}
#if UNITY_EDITOR || DEVELOPMENT_BUILD
@@ -687,67 +677,9 @@ namespace BaseGames.Quest
private bool CanAccept(string questId)
{
// 状态必须为 Available其余门槛检查委托给 GetQuestLockInfo单一权威实现
if (GetState(questId) != QuestStateEnum.Available) return false;
var quest = GetQuestSO(questId);
if (quest == null) return false;
// 好感度门槛检查_npcRelations 仅在 OnLoad 后有效
if (quest.minAffinityToAccept > 0 && !string.IsNullOrEmpty(quest.GiverNpcId))
{
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<ISaveService>();
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<ISaveService>();
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;
return !GetQuestLockInfo(questId).IsLocked;
}
/// <summary>
@@ -784,17 +716,26 @@ namespace BaseGames.Quest
}
/// <summary>
/// 检查任务是否满足全部前置条件(不含状态检查),用于 InitializeAvailableQuests 初始化。
/// 与 CanAccept 的区别CanAccept 需要任务已经是 Available此方法仅判断前置依赖是否达成。
/// 优先读取新版 <see cref="QuestPrerequisite"/> 结构;若未配置则回退到旧版字段
/// 检查任务是否满足全部前置条件(不含状态和亲密度检查),用于 InitializeAvailableQuests 初始化。
/// 与 CanAccept 的区别CanAccept 需要任务已经是 Available 且包含亲密度检查;此方法仅判断前置依赖是否达成。
/// 委托给 <see cref="CheckQuestDepsAndFlags"/> 实现,不再重复前置逻辑
/// </summary>
private bool MeetsPrerequisites(QuestSO quest)
{
if (quest == null) return false;
return CheckQuestDepsAndFlags(quest).Reason == QuestLockReason.None;
}
/// <summary>
/// 检查任务的前置依赖(任务完成 + 世界标志),不含亲密度和状态检查。
/// 是 <see cref="CanAccept"/>(经 GetQuestLockInfo 间接调用)、<see cref="MeetsPrerequisites"/>、
/// <see cref="GetQuestLockInfo"/> 共享的单一权威实现,消除三处重复逻辑。
/// </summary>
private QuestLockInfo CheckQuestDepsAndFlags(QuestSO quest)
{
if (quest == null) return new QuestLockInfo { Reason = QuestLockReason.NotFound };
if (quest.prerequisites.HasAny)
{
// 新版前置结构questDependencies + flagCondition
if (quest.prerequisites.questDependencies != null)
foreach (var dep in quest.prerequisites.questDependencies)
{
@@ -802,25 +743,27 @@ namespace BaseGames.Quest
if (string.IsNullOrEmpty(dep.questId))
{
#if UNITY_EDITOR || DEVELOPMENT_BUILD
Debug.LogWarning(
$"[QuestManager] 任务 '{quest.questId}' 的 prerequisites.questDependencies 含 questId 为空的条目,已跳过。");
Debug.LogWarning($"[QuestManager] 任务 '{quest.questId}' 的 prerequisites.questDependencies 含 questId 为空的条目,已跳过。");
#endif
continue;
}
if (GetState(dep.questId) != QuestStateEnum.Completed) return false;
if (GetState(dep.questId) != QuestStateEnum.Completed)
return new QuestLockInfo { Reason = QuestLockReason.RequiresQuest, Param = dep.questId };
}
var fc = quest.prerequisites.flagCondition;
if (fc.flags != null && fc.flags.Length > 0)
{
var svc = BaseGames.Core.ServiceLocator.GetOrDefault<ISaveService>();
if (svc != null && !EvaluateFlagPrerequisites(fc.flags, fc.logic, svc)) return false;
// ISaveService 未就绪Awake 阶段)→ 保守跳过OnLoad 后重新评估
if (svc != null && !EvaluateFlagPrerequisites(fc.flags, fc.logic, svc))
return new QuestLockInfo { Reason = QuestLockReason.FlagConditionNotMet };
}
}
else
{
// 旧版字段回退(兼容现有资产)
#pragma warning disable CS0618
if (quest.prerequisiteQuests != null)
foreach (var pre in quest.prerequisiteQuests)
{
@@ -828,12 +771,12 @@ namespace BaseGames.Quest
if (string.IsNullOrEmpty(pre.questId))
{
#if UNITY_EDITOR || DEVELOPMENT_BUILD
Debug.LogWarning(
$"[QuestManager] 任务 '{quest.questId}' 的 prerequisiteQuests 含 questId 为空的条目,已跳过该前置条件。");
Debug.LogWarning($"[QuestManager] 任务 '{quest.questId}' 的 prerequisiteQuests 含 questId 为空的条目,已跳过该前置条件。");
#endif
continue;
}
if (GetState(pre.questId) != QuestStateEnum.Completed) return false;
if (GetState(pre.questId) != QuestStateEnum.Completed)
return new QuestLockInfo { Reason = QuestLockReason.RequiresQuest, Param = pre.questId };
}
if (quest.prerequisiteFlags != null && quest.prerequisiteFlags.Length > 0)
@@ -841,13 +784,36 @@ namespace BaseGames.Quest
var svc = BaseGames.Core.ServiceLocator.GetOrDefault<ISaveService>();
if (svc != null)
{
if (!EvaluateFlagPrerequisites(quest.prerequisiteFlags, quest.prerequisiteFlagsLogic, svc)) return false;
if (!EvaluateFlagPrerequisites(quest.prerequisiteFlags, quest.prerequisiteFlagsLogic, svc))
return new QuestLockInfo { Reason = QuestLockReason.FlagConditionNotMet };
}
// ISaveService 未就绪Awake 阶段)→ 保守跳过OnLoad 后 InitializeAvailableQuests 重新评估
#if UNITY_EDITOR || DEVELOPMENT_BUILD
else Debug.LogWarning($"[QuestManager] 任务 '{quest.questId}' 的 prerequisiteFlags 需要 ISaveService但服务未注册标志检查已跳过。");
#endif
}
#pragma warning restore CS0618
}
return true;
return new QuestLockInfo { Reason = QuestLockReason.None };
}
/// <inheritdoc cref="IQuestManager.GetQuestsInState"/>
public System.Collections.Generic.IReadOnlyList<string> GetQuestsInState(QuestStateEnum state)
{
var result = new List<string>();
foreach (var (id, s) in _questStates)
if (s == state) result.Add(id);
return result;
}
/// <inheritdoc cref="IQuestManager.FilterQuests"/>
public System.Collections.Generic.IReadOnlyList<string> FilterQuests(System.Func<string, QuestStateEnum, bool> predicate)
{
if (predicate == null) return System.Array.Empty<string>();
var result = new List<string>();
foreach (var (id, state) in _questStates)
if (predicate(id, state)) result.Add(id);
return result;
}
/// <summary>