feat: Round 48 narrative systems improvements

- QuestSO: Add ValidateBranchCycles() DFS detection for branches[].nextQuest loop
- QuestSO: Mark three legacy prerequisite fields with v2.0 removal warning in Tooltip
- IQuestManager: Add QuestLockReason enum + QuestLockInfo struct (strongly-typed lock info)
- IQuestManager: Add GetQuestLockInfo() method to interface; GetQuestLockReason() now delegates to it
- IQuestEventSource: Add OnQuestStateChanged(questId, oldState, newState) unified event
- QuestManager: Implement GetQuestLockInfo(); fire OnQuestStateChanged on all state transitions
- DialogueManager: Add one-frame yield in HandleChoices before ShowChoices (skip-debounce fix)
- DialogueManager: Increment _playbackId in ForceEnd() to invalidate residual choice callbacks
- DialogueSequenceSO: Add UNITY_EDITOR debug log in TryGetActiveVariant on variant match
- WorldStateRegistry: Add OnBatchStateChanged event + BatchMark() batch-write API
- DialogueModule: List badge shows warning indicator for unconditional-shadowing variants
- DialogueModule: BuildVariantsCard shows logic mode (AND/OR) alongside flag conditions

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-05-25 00:05:15 +08:00
parent 446fd5dcd0
commit 6eaa83dc71
72 changed files with 7080 additions and 373 deletions

View File

@@ -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("已废弃,请改用 giverNpcNpcSO 直接引用)。保留以兼容现有资产序列化。")]
[HideInInspector]
public string giverNpcId;
/// <summary>运行时使用的 NPC IDgiverNpc 优先,回退到旧字段 giverNpcId。</summary>
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<string>(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);
}
}
/// <summary>
/// 检测前置任务链是否形成循环依赖(如 A 前置 B、B 前置 A
/// 循环会导致两个任务互相锁定,运行时无法被接取,属于配置错误。
/// </summary>
private void ValidatePrerequisiteCycles()
{
if (string.IsNullOrEmpty(questId)) return;
var visited = new System.Collections.Generic.HashSet<string>(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;
}
}
}
/// <summary>
/// 深度优先遍历前置链,检测是否存在环路。
/// <para>已访问节点集 <paramref name="visited"/> 在回溯时移除,保证同一链条中不误报平行分支。</para>
/// </summary>
private static bool HasPrerequisiteCycle(QuestSO quest,
System.Collections.Generic.HashSet<string> 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;
}
/// <summary>
/// 检测 branches[].nextQuest 解锁链是否形成循环(如 A 完成解锁 BB 完成解锁 A
/// 循环会导致运行时 UnlockBranches 无限递归设置任务状态,属于配置错误。
/// </summary>
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<string>(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; // 一次只报首个问题
}
}
}
/// <summary>
/// 深度优先遍历 branches[].nextQuest 链检测是否存在环路DFS 回溯)。
/// </summary>
private static bool HasBranchCycle(QuestSO quest,
System.Collections.Generic.HashSet<string> 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
{
/// <summary>若此前置任务已完成 → 走本分支(null = 默认分支)。</summary>
[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;
/// <summary>完成后触发的 NPC 对话序列(直接引用,避免手写 sequenceId 字符串出错)。</summary>
[Tooltip("完成本任务后触发的 NPC 对话序列(直接引用 DialogueSequenceSO 资产,无需手写 ID。")]
public DialogueSequenceSO npcDialogueSequence;
[System.Obsolete("已废弃,请改用 npcDialogueSequence直接 SO 引用)。保留字段以兼容现有资产序列化。")]
[HideInInspector]
public string npcDialogueKey;
}
/// <summary>任务分类,供日志 UI 分区和 DataHub 过滤使用。</summary>
public enum QuestCategory
{
/// <summary>主线任务:必做,推动主剧情进展。</summary>
Main,
/// <summary>支线任务:可选,丰富世界观与 NPC 关系。</summary>
Side,
/// <summary>日常/重复任务:可在满足条件后重置。</summary>
Daily,
/// <summary>隐藏任务:不主动在日志中显示,由特定条件触发后才浮现。</summary>
Hidden,
}
// =========================================================================
// QuestPrerequisite ── 任务前置条件(统一配置结构)
// =========================================================================
/// <summary>
/// 任务前置条件统一配置结构。
/// 将旧版三个独立字段prerequisiteQuests / prerequisiteFlags / prerequisiteFlagsLogic
/// 合并为单一可序列化类,便于 Inspector 统一管理与代码维护。
/// 运行时通过 <see cref="HasAny"/> 判断是否启用新格式;若未配置则自动回退到旧版字段。
/// </summary>
[Serializable]
public class QuestPrerequisite
{
[Tooltip("所有前置任务必须处于 Completed 状态,本任务才能被接取。留空表示无前置任务限制。")]
public QuestSO[] questDependencies;
[Tooltip("世界状态标志前置条件(支持 And / Or 逻辑)。")]
public FlagCondition flagCondition;
/// <summary>此前置结构是否配置了任何条件(用于判断是否启用新格式,回退到旧字段)。</summary>
public bool HasAny =>
(questDependencies != null && questDependencies.Length > 0) ||
(flagCondition.flags != null && flagCondition.flags.Length > 0);
/// <summary>
/// 世界状态标志前置条件,支持 And全部满足或 Or任一满足逻辑。
/// </summary>
[Serializable]
public struct FlagCondition
{
[Tooltip("标志逻辑模式:\n And默认= 全部标志均须为 true\n Or = 任意一个标志为 true 即可解锁")]
public BaseGames.Core.WorldStateFlagLogic logic;
[Tooltip("前置世界状态标志 ID 列表。留空表示无标志前置限制。")]
[BaseGames.Core.WorldStateFlag]
public string[] flags;
}
}
}