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

@@ -0,0 +1,24 @@
using UnityEngine;
namespace BaseGames.Core.Events
{
/// <summary>
/// NPC 对话切换事件负载(强类型,替代 "{npcId}:{sequenceId}" 字符串拼接)。
/// </summary>
public struct NpcDialogueChangeEvent
{
/// <summary>NPC 的唯一 ID对应 NpcSO.npcId。</summary>
public string npcId;
/// <summary>要切换到的对话序列 ID对应 DialogueSequenceSO.sequenceId。</summary>
public string newSequenceId;
}
/// <summary>
/// NPC 对话切换事件频道。
/// 由 ChangeNPCDialogueAction 在事件链中触发NPC 组件订阅后根据自身 npcId 过滤处理。
/// 资产路径建议Assets/ScriptableObjects/Events/EVT_NpcDialogueChange.asset
/// </summary>
[CreateAssetMenu(menuName = "BaseGames/Events/NpcDialogueChange")]
public class NpcDialogueChangeEventChannelSO : BaseEventChannelSO<NpcDialogueChangeEvent> { }
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 111b5e123d3c3bc4ab5114666d8d2641
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -9,7 +9,9 @@ namespace BaseGames.Core.Events
Available = 1,
Active = 2,
Completed = 3,
Failed = 4
Failed = 4,
/// <summary>任务已接取但被暂停(如剧情锁定),不推进目标,不触发失败判定。</summary>
Paused = 5,
}
/// <summary>
@@ -23,7 +25,7 @@ namespace BaseGames.Core.Events
}
/// <summary>
/// 任务目标进度事件负载。
/// 任务目标进度事件负载(单目标)
/// </summary>
[System.Serializable]
public struct QuestObjectiveEvent
@@ -33,4 +35,17 @@ namespace BaseGames.Core.Events
public int Progress;
public int Required;
}
/// <summary>
/// 同帧内某任务多个目标同时更新时的批量事件负载。
/// 订阅此事件可在一帧内一次性处理同任务的所有目标变更,避免 UI 多次重绘。
/// </summary>
[System.Serializable]
public struct QuestObjectiveBatchEvent
{
/// <summary>发生目标进度变更的任务 ID。</summary>
public string QuestId;
/// <summary>本帧内该任务所有更新过的单目标事件列表(至少 1 个)。</summary>
public System.Collections.Generic.List<QuestObjectiveEvent> Updates;
}
}

View File

@@ -4,4 +4,13 @@ namespace BaseGames.Core.Events
{
[CreateAssetMenu(menuName = "BaseGames/Events/QuestObjective")]
public class QuestObjectiveEventChannelSO : BaseEventChannelSO<QuestObjectiveEvent> { }
/// <summary>
/// 批量任务目标进度事件频道。
/// 同帧内同一任务多个目标同时更新时,聚合为一次广播,
/// 供 UI 侧监听以避免同帧多次重绘任务追踪 HUD。
/// 资产路径建议Assets/ScriptableObjects/Events/EVT_QuestObjectiveBatchUpdated.asset
/// </summary>
[CreateAssetMenu(menuName = "BaseGames/Events/QuestObjectiveBatch")]
public class QuestObjectiveBatchEventChannelSO : BaseEventChannelSO<QuestObjectiveBatchEvent> { }
}

View File

@@ -0,0 +1,12 @@
namespace BaseGames.Core
{
/// <summary>
/// 只读世界状态查询接口(用于对话版本条件判断)。
/// 解耦 NarrativeNPC / DialogueVersion 对 WorldStateRegistry 具体类型的直接依赖。
/// </summary>
public interface IWorldStateReader
{
/// <summary>检查指定 Flag 是否已设置。</summary>
bool HasFlag(string key);
}
}

View File

@@ -134,8 +134,19 @@ namespace BaseGames.Core.Save
[Serializable]
public class QuestState
{
/// <summary>
/// 此 QuestState 数据格式版本号。
/// 1 = 原始格式ProgressCounts 按索引,已弃用)
/// 2 = Round 24+ 格式ObjectiveProgress 按 objectiveId 键值对)
/// OnLoad 按此字段显式选择解析路径,杜绝依赖 Count > 0 的隐式推断。
/// </summary>
public int DataVersion = 2;
public string Status; // "NotStarted"|"Active"|"Completed"|"Failed"
public int ObjectiveIndex;
/// <summary>新格式Round 24+DataVersion=2objectiveId → progressCount重排目标顺序后存档不会错位。</summary>
public Dictionary<string, int> ObjectiveProgress = new();
/// <summary>旧格式按数组索引DataVersion=1仅用于迁移旧版存档新存档不再写入。已弃用将在后续版本移除。</summary>
[System.Obsolete("旧格式存档兼容字段,仅供 OnLoad DataVersion=1 迁移使用。新存档改用 ObjectiveProgressobjectiveId 键值对)。")]
public List<int> ProgressCounts = new();
public string GiverNpcId;
}

View File

@@ -0,0 +1,74 @@
using UnityEngine;
#if UNITY_EDITOR
using UnityEditor;
#endif
namespace BaseGames.Core
{
/// <summary>
/// 单个世界状态标志的定义条目。
/// </summary>
[System.Serializable]
public class FlagEntry
{
[Tooltip("标志唯一 ID与 SetFlagAction / FlagSetCondition 中填写的字符串完全一致。")]
public string id;
[Tooltip("描述该标志代表的游戏事件或状态(仅供编辑器参考,运行时不使用)。")]
public string description;
[Tooltip("下拉菜单中的分组路径,使用 '/' 分隔层级,例如 '剧情/Boss'。留空则不分组。")]
public string group;
}
/// <summary>
/// 世界状态标志注册表 —— 统一维护项目中所有合法的世界标志 ID、描述和分组。
/// 在 Inspector 中为 [WorldStateFlag] 属性提供下拉补全,减少手输错误。
/// 创建方式Project 右键 → Create / BaseGames / Core / WorldFlagRegistry
/// </summary>
[CreateAssetMenu(menuName = "BaseGames/Core/WorldFlagRegistry", fileName = "WorldFlagRegistry")]
public class WorldFlagRegistrySO : ScriptableObject
{
[Tooltip("所有合法的世界状态标志定义。由策划/程序在此集中维护。")]
public FlagEntry[] flags;
#if UNITY_EDITOR
private static WorldFlagRegistrySO _editorInstance;
private static double _editorInstanceExpiry;
/// <summary>
/// 编辑器下 30 秒缓存的单例引用(扫描 AssetDatabase 得到)。
/// 运行时不可用,请在 UNITY_EDITOR 条件块中调用。
/// </summary>
public static WorldFlagRegistrySO EditorInstance
{
get
{
double now = EditorApplication.timeSinceStartup;
if (_editorInstance != null && now < _editorInstanceExpiry)
return _editorInstance;
var guids = AssetDatabase.FindAssets("t:WorldFlagRegistrySO");
if (guids.Length == 0)
{
_editorInstance = null;
_editorInstanceExpiry = now + 30.0;
return null;
}
if (guids.Length > 1)
Debug.LogWarning($"[WorldFlagRegistrySO] 发现 {guids.Length} 个 WorldFlagRegistry.asset" +
"将使用第一个。建议项目中只保留一个。");
string path = AssetDatabase.GUIDToAssetPath(guids[0]);
_editorInstance = AssetDatabase.LoadAssetAtPath<WorldFlagRegistrySO>(path);
_editorInstanceExpiry = now + 30.0;
return _editorInstance;
}
}
/// <summary>强制下次访问 EditorInstance 时重新扫描 AssetDatabase。</summary>
public static void InvalidateEditorCache() => _editorInstance = null;
#endif
}
}

View File

@@ -0,0 +1,22 @@
using UnityEngine;
namespace BaseGames.Core
{
/// <summary>
/// 标记一个 string 或 string[] 字段为世界状态标志 Key。
/// 在 Inspector 中会显示已知标志下拉菜单,支持直接输入新标志。
/// 定义于 BaseGames.Core可被 Dialogue / Quest / EventChain 等模块无耦合使用。
/// </summary>
public sealed class WorldStateFlagAttribute : PropertyAttribute { }
/// <summary>
/// 世界状态标志的逻辑组合模式,供 Dialogue 条件变体和 Quest 分支条件共用。
/// </summary>
public enum WorldStateFlagLogic
{
/// <summary>全部 requiredFlags 均满足时条件成立(默认,向后兼容)。</summary>
And,
/// <summary>任意一个 requiredFlag 满足即可使条件成立。</summary>
Or,
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e10b4c60cc9052f4e83381ceb09424a3
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: