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,12 +1,40 @@
using UnityEngine;
using BaseGames.Core.Events;
using BaseGames.Player;
namespace BaseGames.Quest
{
/// <summary>
/// 任务事件类型枚举,对应 QuestManager 订阅的各事件频道。
/// 新增事件类型时在此扩展,无需修改 QuestManager。
/// </summary>
public enum QuestEventType
{
EnemyDefeated,
ItemCollected,
NpcDialogueCompleted,
SceneLoaded,
SkillUsed,
/// <summary>
/// 玩家进入场景内的具体区域标记(由 TriggerZone 广播)。
/// payload = TriggerZone.markerTagstring
/// 与 SceneLoaded场景级互补实现精确的区域到达判定。
/// </summary>
AreaReached,
}
/// <summary>
/// 任务目标基类(抽象,架构 22_QuestChallengeModule §3
/// 所有具体目标类型均继承此类,通过多态实现零代码扩展。
/// 每种目标在事件驱动下由 QuestManager 调用 EvaluateCompletion()。
///
/// 【自注册机制】子类通过 override <see cref="TryHandleEvent"/> 声明自己
/// 感兴趣的事件类型及匹配条件QuestManager 统一路由,无需为每种目标类型
/// 硬编码处理器。新增目标类型只需:
/// 1. 继承 QuestObjectiveSO
/// 2. override TryHandleEvent
/// 3. override EvaluateCompletion
/// 4. 创建 CreateAssetMenu
/// QuestManager 代码**无需任何修改**。
/// </summary>
public abstract class QuestObjectiveSO : ScriptableObject
{
@@ -19,9 +47,34 @@ namespace BaseGames.Quest
[Tooltip("勾选后此目标为可选项:完成可获奖励,但不阻塞任务交接。")]
public bool IsOptional;
/// <summary>
/// 目标所需完成数量。用于 UI 显示进度条分母(如 "3/5 击败")。
/// 子类应 override 返回相应计数字段defeatCount、collectCount 等)。
/// 默认返回 1表示"完成一次即可"的目标类型)。
/// </summary>
public virtual int GetRequiredCount() => 1;
/// <summary>根据当前进度判断目标是否完成。</summary>
public abstract bool EvaluateCompletion(QuestObjectiveState state);
/// <summary>
/// 尝试处理一个运行时事件。
/// QuestManager 在每次事件到来时对所有活跃目标调用此方法。
/// 子类 override若事件与自身条件匹配递增 state.progressCount 并返回 true
/// 不匹配时返回 false基类默认实现
///
/// <para>参数 <paramref name="payload"/> 含义由事件类型决定:</para>
/// <list type="bullet">
/// <item>EnemyDefeated → enemyId (string)</item>
/// <item>ItemCollected → itemId (string)</item>
/// <item>NpcDialogueCompleted → npcId (string)</item>
/// <item>SceneLoaded → sceneName (string)</item>
/// <item>SkillUsed → AbilityType.ToString() (string)</item>
/// </list>
/// </summary>
public virtual bool TryHandleEvent(QuestEventType eventType, string payload, QuestObjectiveState state)
=> false;
/// <summary>
/// 在 DataHub / 编辑器工具中显示的类型徽章文字。
/// 子类应 override 返回简洁中文标签(如 "[对话]")。
@@ -30,11 +83,52 @@ namespace BaseGames.Quest
public virtual string BadgeLabel => "[目标]";
#if UNITY_EDITOR
// objectiveId → 资产路径5 秒 TTL跨所有 QuestObjectiveSO 子类 OnValidate 共用。
// 与 QuestSO / DialogueSequenceSO / DialogueActorSO 保持一致的 O(1) 重复检测策略。
private static System.Collections.Generic.Dictionary<string, string> s_objIdToPath;
private static double s_objIdsCacheTime = -10.0;
private static System.Collections.Generic.Dictionary<string, string> GetObjectiveIdCache()
{
double now = UnityEditor.EditorApplication.timeSinceStartup;
if (s_objIdToPath != null && now - s_objIdsCacheTime < 5.0)
return s_objIdToPath;
s_objIdToPath = new System.Collections.Generic.Dictionary<string, string>(System.StringComparer.Ordinal);
string[] guids = UnityEditor.AssetDatabase.FindAssets("t:QuestObjectiveSO");
foreach (var guid in guids)
{
var path = UnityEditor.AssetDatabase.GUIDToAssetPath(guid);
var obj = UnityEditor.AssetDatabase.LoadAssetAtPath<QuestObjectiveSO>(path);
if (obj != null && !string.IsNullOrEmpty(obj.objectiveId) && !s_objIdToPath.ContainsKey(obj.objectiveId))
s_objIdToPath[obj.objectiveId] = path;
}
s_objIdsCacheTime = now;
return s_objIdToPath;
}
private void OnValidate()
{
if (!string.IsNullOrEmpty(objectiveId)) return;
objectiveId = name;
UnityEditor.EditorUtility.SetDirty(this);
// 若 objectiveId 为空,自动以资产文件名填充
if (string.IsNullOrEmpty(objectiveId))
{
objectiveId = name;
UnityEditor.EditorUtility.SetDirty(this);
}
// 检测重复:缓存路径 vs 自身路径比对O(1)5 秒内无需重扫
var cache = GetObjectiveIdCache();
string myPath = UnityEditor.AssetDatabase.GetAssetPath(this);
if (!string.IsNullOrEmpty(myPath) &&
cache.TryGetValue(objectiveId, out var existingPath) &&
existingPath != myPath)
{
Debug.LogError(
$"[QuestObjectiveSO] objectiveId '{objectiveId}' 与 " +
$"'{System.IO.Path.GetFileNameWithoutExtension(existingPath)}' 重复!" +
"重复 ID 会导致任务进度互相覆盖,请修改其中一个。", this);
s_objIdsCacheTime = -10.0;
}
}
#endif
}
@@ -57,6 +151,14 @@ namespace BaseGames.Quest
public string targetNpcId;
public override string BadgeLabel => "[对话]";
public override bool EvaluateCompletion(QuestObjectiveState s) => s.progressCount >= 1;
public override bool TryHandleEvent(QuestEventType eventType, string payload, QuestObjectiveState state)
{
if (eventType != QuestEventType.NpcDialogueCompleted) return false;
if (payload != targetNpcId) return false;
state.progressCount++;
return true;
}
}
/// <summary>击败指定 ID 的敌人若干次。</summary>
@@ -68,7 +170,16 @@ namespace BaseGames.Quest
[Tooltip("需击败的次数,默认 1。")]
[Min(1)] public int defeatCount = 1;
public override string BadgeLabel => "[击败]";
public override int GetRequiredCount() => defeatCount;
public override bool EvaluateCompletion(QuestObjectiveState s) => s.progressCount >= defeatCount;
public override bool TryHandleEvent(QuestEventType eventType, string payload, QuestObjectiveState state)
{
if (eventType != QuestEventType.EnemyDefeated) return false;
if (payload != targetEnemyId) return false;
state.progressCount++;
return true;
}
}
/// <summary>收集指定 ID 的物品若干件。</summary>
@@ -80,19 +191,54 @@ namespace BaseGames.Quest
[Tooltip("需收集的数量,默认 1。")]
[Min(1)] public int collectCount = 1;
public override string BadgeLabel => "[收集]";
public override int GetRequiredCount() => collectCount;
public override bool EvaluateCompletion(QuestObjectiveState s) => s.progressCount >= collectCount;
public override bool TryHandleEvent(QuestEventType eventType, string payload, QuestObjectiveState state)
{
if (eventType != QuestEventType.ItemCollected) return false;
if (payload != itemId) return false;
state.progressCount++;
return true;
}
}
/// <summary>到达指定场景/区域标记点后完成。</summary>
[CreateAssetMenu(menuName = "BaseGames/Quest/Objective/Reach")]
public class ReachAreaObjective : QuestObjectiveSO
{
[Tooltip("需到达的场景名Unity Build Settings 中的场景名称)。")]
[Tooltip("需到达的场景名Unity Build Settings 中的场景名称)。留空时仅依赖 markerTag 判定。")]
public string sceneName;
[Tooltip("场景内的目标标记 Tag预留字段,当前未启用)。")]
[Tooltip("场景内的区域标记 Tag与 TriggerZone.markerTag 保持一致)。\n" +
"非空时:玩家进入挂有对应 markerTag 的 TriggerZone 碰撞体即触发。\n" +
"留空时:整个场景切换即触发(粗粒度,仅检查 sceneName。")]
public string markerTag;
public override string BadgeLabel => "[到达]";
public override bool EvaluateCompletion(QuestObjectiveState s) => s.progressCount >= 1;
public override bool TryHandleEvent(QuestEventType eventType, string payload, QuestObjectiveState state)
{
// 精确区域到达TriggerZone 广播 AreaReachedpayload = markerTag
if (eventType == QuestEventType.AreaReached &&
!string.IsNullOrEmpty(markerTag) &&
payload == markerTag)
{
state.progressCount++;
return true;
}
// 场景级到达payload = sceneName仅当 markerTag 为空时生效(否则等待精确触发)
if (eventType == QuestEventType.SceneLoaded &&
!string.IsNullOrEmpty(sceneName) &&
payload == sceneName &&
string.IsNullOrEmpty(markerTag))
{
state.progressCount++;
return true;
}
return false;
}
}
/// <summary>使用指定能力若干次后完成。</summary>
@@ -104,6 +250,33 @@ namespace BaseGames.Quest
[Tooltip("需使用的次数,默认 1。")]
[Min(1)] public int useCount = 1;
public override string BadgeLabel => "[使用]";
public override int GetRequiredCount() => useCount;
public override bool EvaluateCompletion(QuestObjectiveState s) => s.progressCount >= useCount;
public override bool TryHandleEvent(QuestEventType eventType, string payload, QuestObjectiveState state)
{
if (eventType != QuestEventType.SkillUsed) return false;
// Enum.TryParse 避免大小写/格式差异导致静默失败
if (!System.Enum.TryParse<AbilityType>(payload, ignoreCase: true, out var parsed)) return false;
if (parsed != requiredAbility) return false;
state.progressCount++;
return true;
}
}
// ── 扩展事件频道绑定(供 QuestManager Inspector 使用)────────────────────
/// <summary>
/// 自定义事件频道与任务事件类型的绑定。
/// 在 QuestManager Inspector 的"扩展事件频道"数组中添加条目,即可不修改代码
/// 支持未来新增的 <see cref="QuestEventType"/> 枚举值与 SO 频道的映射。
/// </summary>
[System.Serializable]
public struct QuestEventChannelBinding
{
[Tooltip("要监听的事件类型(需与 QuestObjectiveSO 子类中 TryHandleEvent 处理的类型一致)。")]
public QuestEventType eventType;
[Tooltip("该类型对应的 StringEventChannelSO 资产。由广播方(如战斗系统、场景系统)负责 Raise。")]
public StringEventChannelSO channel;
}
}