From 0b28cabba48151d2f1ca7f9f675191f42605cdc6 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Mon, 25 May 2026 00:47:44 +0800 Subject: [PATCH] feat: Round 52 narrative systems improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1-A: QuestManager.OnLoad Enum.TryParse failure warning (dev builds) P1-B: SaveData.QuestState ObjectiveCompleted dict; BuildObjectiveCompleted helper; OnSave/OnLoad wiring (DataVersion 2→3) P2-A: Quest start/complete timestamps (_startedAtUtc/_completedAtUtc dicts; StartedAtUtc/CompletedAtUtc in SaveData; AcceptQuest/CompleteQuest/ OnSave/OnLoad wiring) P2-B: DialogueManager pending queue Queue→List + priority-eviction on full (lowest-priority item evicted when higher-priority request arrives) P2-C: NpcSO.localizationTable field; NpcSOEditor uses npc.localizationTable in TryResolveNameKey, PingLocalizationFile, and button label P3-A: QuestSO.failConditions[] multi-fail array; Obsolete failCondition; DispatchEvent updates fail check to any-of-array logic with fallback P3-B: QuestObjectiveSO.prerequisiteObjectiveId; DispatchEvent gates objective event routing behind prerequisite completed check P3-C: IQuestEventPayload interface + StringQuestPayload struct; QuestObjectiveSO typed TryHandleEvent(IQuestEventPayload) overload; DispatchEvent string overload delegates to typed IQuestEventPayload overload Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Assets/_Game/Scripts/Core/Save/SaveData.cs | 16 +++- .../_Game/Scripts/Dialogue/DialogueManager.cs | 40 ++++++-- Assets/_Game/Scripts/Dialogue/NpcSO.cs | 5 + .../Scripts/Editor/Dialogue/NpcSOEditor.cs | 12 +-- Assets/_Game/Scripts/Quest/IQuestManager.cs | 28 ++++++ Assets/_Game/Scripts/Quest/QuestManager.cs | 92 +++++++++++++++++-- .../_Game/Scripts/Quest/QuestObjectiveSO.cs | 12 +++ Assets/_Game/Scripts/Quest/QuestSO.cs | 9 +- 8 files changed, 189 insertions(+), 25 deletions(-) diff --git a/Assets/_Game/Scripts/Core/Save/SaveData.cs b/Assets/_Game/Scripts/Core/Save/SaveData.cs index b5e53fc..54060ec 100644 --- a/Assets/_Game/Scripts/Core/Save/SaveData.cs +++ b/Assets/_Game/Scripts/Core/Save/SaveData.cs @@ -138,13 +138,23 @@ namespace BaseGames.Core.Save /// 此 QuestState 数据格式版本号。 /// 1 = 原始格式(ProgressCounts 按索引,已弃用) /// 2 = Round 24+ 格式(ObjectiveProgress 按 objectiveId 键值对) + /// 3 = Round 52+ 格式(新增 ObjectiveCompleted、StartedAtUtc、CompletedAtUtc) /// OnLoad 按此字段显式选择解析路径,杜绝依赖 Count > 0 的隐式推断。 /// - public int DataVersion = 2; - public string Status; // "Unavailable"|"Available"|"Active"|"Paused"|"Completed"|"Failed" + public int DataVersion = 3; + /// 任务运行时状态字符串。有效值:Unavailable|Available|Active|Paused|Completed|Failed。 + /// OnLoad 通过 Enum.TryParse 解析;无效值将触发开发模式警告并降级为 Unavailable。 + public string Status; public int ObjectiveIndex; - /// 新格式(Round 24+,DataVersion=2):objectiveId → progressCount,重排目标顺序后存档不会错位。 + /// 新格式(DataVersion≥2):objectiveId → progressCount,重排目标顺序后存档不会错位。 public Dictionary ObjectiveProgress = new(); + /// 各目标是否已判定完成(objectiveId → completed)。 + /// 防止 GetRequiredCount 在版本迭代中变更后,重新计算结果与存档实际状态不一致。DataVersion≥3 写入。 + public Dictionary ObjectiveCompleted = new(); + /// 任务接取时间(Unix 秒时间戳,UTC)。0 = 未记录(旧存档)。DataVersion≥3 写入。 + public long StartedAtUtc; + /// 任务完成时间(Unix 秒时间戳,UTC)。0 = 未完成或未记录。DataVersion≥3 写入。 + public long CompletedAtUtc; /// 旧格式(按数组索引,DataVersion=1):仅用于迁移旧版存档,新存档不再写入。已弃用,将在后续版本移除。 [System.Obsolete("旧格式存档兼容字段,仅供 OnLoad DataVersion=1 迁移使用。新存档改用 ObjectiveProgress(objectiveId 键值对)。")] public List ProgressCounts = new(); diff --git a/Assets/_Game/Scripts/Dialogue/DialogueManager.cs b/Assets/_Game/Scripts/Dialogue/DialogueManager.cs index af61db4..c745572 100644 --- a/Assets/_Game/Scripts/Dialogue/DialogueManager.cs +++ b/Assets/_Game/Scripts/Dialogue/DialogueManager.cs @@ -104,8 +104,9 @@ namespace BaseGames.Dialogue /// 当 IsDialogueActive 时排队等待的对话请求。 /// 支持脚本触发的连续对话序列(如剧情链、事件链触发的对话), /// 但容量上限为 8,避免因误触导致无限排队。 + /// 使用 List 而非 Queue,以支持基于优先级的抢占式淘汰(队满时丢弃最低优先级项目)。 /// - private readonly Queue<(DialogueSequenceSO seq, string npcId, int priority)> _pending = new(); + private readonly List<(DialogueSequenceSO seq, string npcId, int priority)> _pending = new(); /// 当前是否有对话正在播放。 public bool IsDialogueActive { get; private set; } @@ -178,13 +179,35 @@ namespace BaseGames.Dialogue } if (_pending.Count < _pendingQueueCapacity) - _pending.Enqueue((sequence, npcId, priority)); -#if UNITY_EDITOR || DEVELOPMENT_BUILD + { + _pending.Add((sequence, npcId, priority)); + } else - Debug.LogWarning( - $"[DialogueManager] 待播队列已满(容量 {_pendingQueueCapacity})," + - $"序列 '{sequence.sequenceId}' 被丢弃。可在 Inspector 中调大 _pendingQueueCapacity。"); + { + // 队满时:查找优先级最低的项目,若新请求优先级更高则淘汰之,否则丢弃新请求 + int minIdx = 0; + for (int i = 1; i < _pending.Count; i++) + { + if (_pending[i].priority < _pending[minIdx].priority) minIdx = i; + } + if (priority > _pending[minIdx].priority) + { +#if UNITY_EDITOR || DEVELOPMENT_BUILD + Debug.LogWarning( + $"[DialogueManager] 待播队列已满(容量 {_pendingQueueCapacity})," + + $"序列 '{_pending[minIdx].seq.sequenceId}'(优先级 {_pending[minIdx].priority})" + + $"被优先级更高的 '{sequence.sequenceId}'(优先级 {priority})淘汰。"); #endif + _pending.RemoveAt(minIdx); + _pending.Add((sequence, npcId, priority)); + } +#if UNITY_EDITOR || DEVELOPMENT_BUILD + else + Debug.LogWarning( + $"[DialogueManager] 待播队列已满(容量 {_pendingQueueCapacity})," + + $"序列 '{sequence.sequenceId}'(优先级 {priority})被丢弃,队列中最低优先级为 {_pending[minIdx].priority}。"); +#endif + } return; } PlayImmediate(sequence, npcId, priority); @@ -412,8 +435,9 @@ namespace BaseGames.Dialogue // 自动播放队首等待中的对话(脚本触发的连续序列) if (_pending.Count > 0) { - var (seq, id, pri) = _pending.Dequeue(); - PlayImmediate(seq, id, pri); + var item = _pending[0]; + _pending.RemoveAt(0); + PlayImmediate(item.seq, item.npcId, item.priority); } } diff --git a/Assets/_Game/Scripts/Dialogue/NpcSO.cs b/Assets/_Game/Scripts/Dialogue/NpcSO.cs index bc71b36..42d7271 100644 --- a/Assets/_Game/Scripts/Dialogue/NpcSO.cs +++ b/Assets/_Game/Scripts/Dialogue/NpcSO.cs @@ -32,6 +32,11 @@ namespace BaseGames.Dialogue "UI 侧可用此值绘制好感度进度条满格。")] [Min(0)] public int maxAffinity = 0; + [Header("本地化")] + [Tooltip("nameKey 所在的本地化表名(默认 \"UI\")。\n" + + "若 NPC 名称存储在非默认表(如 \"Character\"),在此修改后 NpcSOEditor 预览和跳转按钮将使用正确的表。")] + public string localizationTable = "UI"; + [Header("交互提示")] [Tooltip("与此 NPC 交互时显示的提示本地化 Key(如 \"INTERACT_Talk\")。\n" + "留空时 InteractableNPC 回退到内置字符串 \"对话\"。")] diff --git a/Assets/_Game/Scripts/Editor/Dialogue/NpcSOEditor.cs b/Assets/_Game/Scripts/Editor/Dialogue/NpcSOEditor.cs index d6da0db..e400987 100644 --- a/Assets/_Game/Scripts/Editor/Dialogue/NpcSOEditor.cs +++ b/Assets/_Game/Scripts/Editor/Dialogue/NpcSOEditor.cs @@ -34,13 +34,13 @@ namespace BaseGames.Editor.Dialogue s_previewStyle.normal.textColor = new Color(0.55f, 0.90f, 0.55f); } - string resolved = TryResolveNameKey(npc.nameKey); + string resolved = TryResolveNameKey(npc.nameKey, npc.localizationTable); EditorGUILayout.Space(4); if (string.IsNullOrEmpty(resolved)) { EditorGUILayout.HelpBox( - $"nameKey「{npc.nameKey}」在本地化表中未找到对应文本(或 LocalizationManager 未初始化)。\n" + + $"nameKey「{npc.nameKey}」在本地化表「{npc.localizationTable}」中未找到对应文本(或 LocalizationManager 未初始化)。\n" + "请检查本地化表中是否存在此 Key。", MessageType.Warning); } @@ -55,9 +55,9 @@ namespace BaseGames.Editor.Dialogue EditorGUILayout.Space(4); EditorGUILayout.BeginHorizontal(); GUILayout.FlexibleSpace(); - if (GUILayout.Button("跳转到本地化文件(UI 表)", GUILayout.Width(200))) + if (GUILayout.Button($"跳转到本地化文件({npc.localizationTable} 表)", GUILayout.Width(220))) { - PingLocalizationFile("UI"); + PingLocalizationFile(npc.localizationTable); } EditorGUILayout.EndHorizontal(); } @@ -66,12 +66,12 @@ namespace BaseGames.Editor.Dialogue /// 尝试通过 LocalizationManager(若已加载)解析 nameKey; /// 如未初始化或找不到 Key,返回 null。 /// - private static string TryResolveNameKey(string key) + private static string TryResolveNameKey(string key, string table = "UI") { try { // LocalizationManager.Get 在编辑器下可能返回空字符串(未初始化),视为未找到 - var resolved = BaseGames.Localization.LocalizationManager.Get(key, "UI"); + var resolved = BaseGames.Localization.LocalizationManager.Get(key, table); return string.IsNullOrEmpty(resolved) ? null : resolved; } catch diff --git a/Assets/_Game/Scripts/Quest/IQuestManager.cs b/Assets/_Game/Scripts/Quest/IQuestManager.cs index 4284013..58e7b75 100644 --- a/Assets/_Game/Scripts/Quest/IQuestManager.cs +++ b/Assets/_Game/Scripts/Quest/IQuestManager.cs @@ -175,6 +175,34 @@ namespace BaseGames.Quest event Action OnQuestStateChanged; } + // ========================================================================= + // IQuestEventPayload ── 强类型事件载荷接口 + // ========================================================================= + + /// + /// 任务事件载荷接口,用于 强类型 DispatchEvent 重载。 + /// 实现此接口可在不修改 原有 string 签名的前提下, + /// 逐步迁移到类型安全的载荷传递。 + /// + public interface IQuestEventPayload + { + /// 以字符串形式返回载荷内容,兼容旧版 TryHandleEvent(string payload) 签名。 + string AsString(); + } + + /// + /// 字符串载荷适配器。封装现有 string payload,使其可作为 传递。 + /// 旧版 DispatchEvent(QuestEventType, string) 内部通过此结构透明转换,不破坏现有调用方。 + /// + public readonly struct StringQuestPayload : IQuestEventPayload + { + private readonly string _value; + public StringQuestPayload(string value) => _value = value; + /// + public string AsString() => _value; + public static implicit operator StringQuestPayload(string s) => new StringQuestPayload(s); + } + #if UNITY_EDITOR || DEVELOPMENT_BUILD /// /// 任务调试接口(仅编辑器 / 开发构建可用)。 diff --git a/Assets/_Game/Scripts/Quest/QuestManager.cs b/Assets/_Game/Scripts/Quest/QuestManager.cs index 340d5e5..a971657 100644 --- a/Assets/_Game/Scripts/Quest/QuestManager.cs +++ b/Assets/_Game/Scripts/Quest/QuestManager.cs @@ -126,6 +126,10 @@ namespace BaseGames.Quest /// 任务暂停时记录的 realtimeSinceStartup(供 ResumeQuest 计算暂停持续时长并日志输出)。 private readonly Dictionary _pauseTimestamps = new(); #endif + /// questId → 接取时间(Unix 秒,UTC)。用于存档和统计分析。 + private readonly Dictionary _startedAtUtc = new(); + /// questId → 完成时间(Unix 秒,UTC)。0 表示未完成或旧存档未记录。 + private readonly Dictionary _completedAtUtc = new(); /// 供需要直接订阅 SO 频道的系统使用(推荐使用 IQuestEventSource C# 事件)。 public StringEventChannelSO QuestStartedChannel => _onQuestStarted; /// 供需要直接订阅 SO 频道的系统使用(推荐使用 IQuestEventSource C# 事件)。 @@ -248,6 +252,7 @@ namespace BaseGames.Quest if (!CanAccept(questId)) return; var oldState = GetState(questId); _questStates[questId] = QuestStateEnum.Active; + _startedAtUtc[questId] = System.DateTimeOffset.UtcNow.ToUnixTimeSeconds(); OnQuestStateChanged?.Invoke(questId, oldState, QuestStateEnum.Active); Chan_QuestStarted?.Raise(questId); OnQuestStarted?.Invoke(questId); @@ -357,6 +362,7 @@ namespace BaseGames.Quest // 先更新状态再发放奖励:确保 Apply 即使抛出异常,任务状态也已正确写入 _questStates[questId] = QuestStateEnum.Completed; + _completedAtUtc[questId] = System.DateTimeOffset.UtcNow.ToUnixTimeSeconds(); _notifiedReadyQuests.Remove(questId); OnQuestStateChanged?.Invoke(questId, QuestStateEnum.Active, QuestStateEnum.Completed); Chan_QuestCompleted?.Raise(questId); @@ -661,9 +667,12 @@ namespace BaseGames.Quest { data.Quests.QuestStates[id] = new BaseGames.Core.Save.QuestState { - DataVersion = 2, - Status = state.ToString(), - ObjectiveProgress = BuildObjectiveProgress(id), + DataVersion = 3, + Status = state.ToString(), + ObjectiveProgress = BuildObjectiveProgress(id), + ObjectiveCompleted = BuildObjectiveCompleted(id), + StartedAtUtc = _startedAtUtc.TryGetValue(id, out var sta) ? sta : 0L, + CompletedAtUtc = _completedAtUtc.TryGetValue(id, out var cta) ? cta : 0L, }; } // 将本地 NPC 好感度缓存回写到存档 @@ -680,6 +689,13 @@ namespace BaseGames.Quest { if (System.Enum.TryParse(saved.Status, out var parsedState)) _questStates[id] = parsedState; +#if UNITY_EDITOR || DEVELOPMENT_BUILD + else + Debug.LogWarning( + $"[QuestManager] OnLoad:任务 '{id}' 的 Status 值 '{saved.Status}' 无法解析为 QuestStateEnum," + + "已忽略(该任务将不出现在运行时 _questStates,等同于 Unavailable)。" + + "请检查存档文件是否损坏,或任务状态枚举定义是否发生变更。"); +#endif var quest = GetQuestSO(id); if (quest?.objectives == null) continue; @@ -700,6 +716,10 @@ namespace BaseGames.Quest if (!_objectiveStates.TryGetValue(compositeKey, out var os)) os = _objectiveStates[compositeKey] = new QuestObjectiveState(); os.progressCount = count; + // DataVersion >= 3:从存档恢复 completed 标志(防止 GetRequiredCount 变更后判定漂移) + if (saved.ObjectiveCompleted != null && + saved.ObjectiveCompleted.TryGetValue(obj.objectiveId, out bool done)) + os.completed = done; } } else if (saved.ProgressCounts != null @@ -718,6 +738,9 @@ namespace BaseGames.Quest } } #pragma warning restore CS0618 + // DataVersion >= 3:恢复任务开始 / 完成时间戳(0 = 旧存档未记录,跳过) + if (saved.StartedAtUtc != 0) _startedAtUtc[id] = saved.StartedAtUtc; + if (saved.CompletedAtUtc != 0) _completedAtUtc[id] = saved.CompletedAtUtc; } // 从存档恢复 NPC 好感度缓存(供 CanAccept 门槛检查使用) _npcRelations = new Dictionary(data.World.NpcRelations); @@ -946,12 +969,43 @@ namespace BaseGames.Quest return dict; } + /// + /// 构建当前任务各目标的 completed 标志字典(objectiveId → completed)。 + /// 存入 , + /// 防止版本迭代中 GetRequiredCount 变更后,进度数值与实际完成状态脱钩。 + /// + private Dictionary BuildObjectiveCompleted(string questId) + { + var quest = GetQuestSO(questId); + if (quest?.objectives == null) return new Dictionary(0); + var dict = new Dictionary(quest.objectives.Length, System.StringComparer.Ordinal); + foreach (var obj in quest.objectives) + { + if (obj == null || string.IsNullOrEmpty(obj.objectiveId)) continue; + _objectiveStates.TryGetValue(GetCompositeKey(questId, obj.objectiveId), out var os); + dict[obj.objectiveId] = os?.completed ?? false; + } + return dict; + } + + // ── 事件路由 ───────────────────────────────────────────────────────── // 统一分派入口:所有事件频道均路由到此方法,由各目标 SO 自行判断是否匹配。 // 新增目标类型只需在 QuestObjectiveSO 子类中 override TryHandleEvent, // 此处无需任何修改。 + /// + /// 字符串载荷重载(向后兼容)。内部将 string 包装为 后委托给强类型重载。 + /// 所有外部调用方(订阅者、频道处理器)保持原有 string 签名,无需修改。 + /// private void DispatchEvent(QuestEventType eventType, string payload) + => DispatchEvent(eventType, new StringQuestPayload(payload)); + + /// + /// 强类型载荷主实现。子类目标 SO 可 override TryHandleEvent(QuestEventType, IQuestEventPayload, QuestObjectiveState) + /// 以直接获取结构化载荷,避免字符串解析开销。 + /// + private void DispatchEvent(QuestEventType eventType, IQuestEventPayload payload) { // ─ 第1次遍历:更新目标进度 + 同步收集失败候选 ───────────────────── // 将 CheckQuestFailConditions 内联到此处,避免对 _questStates 的独立第2次迭代。 @@ -973,6 +1027,13 @@ namespace BaseGames.Quest foreach (var obj in quest.objectives) { if (obj == null || string.IsNullOrEmpty(obj.objectiveId)) continue; + // P3-B:前置目标顺序依赖检查——前置目标未完成时跳过本目标的事件路由 + if (!string.IsNullOrEmpty(obj.prerequisiteObjectiveId)) + { + string prereqKey = GetCompositeKey(qid, obj.prerequisiteObjectiveId); + if (!_objectiveStates.TryGetValue(prereqKey, out var prereqOs) || !prereqOs.completed) + continue; + } string compositeKey = GetCompositeKey(qid, obj.objectiveId); if (!_objectiveStates.TryGetValue(compositeKey, out var os)) os = _objectiveStates[compositeKey] = new QuestObjectiveState(); @@ -1002,10 +1063,29 @@ namespace BaseGames.Quest // 失败条件检查(同次遍历内完成,惰性分配 toFail 列表) // Paused 任务在此处已被跳过(见上方 state != Active continue), // 设计意图:暂停期间目标冻结,失败条件也不判定,恢复后再继续检查。 - if (quest.canFail && quest.failCondition != null && CheckObjective(qid, quest.failCondition)) + if (quest.canFail) { - toFail ??= new List(); - toFail.Add(qid); + // P3-A:多失败条件支持——failConditions 数组中任意一个达成即失败 + bool triggered = false; + if (quest.failConditions != null && quest.failConditions.Length > 0) + { + foreach (var fc in quest.failConditions) + { + if (fc != null && CheckObjective(qid, fc)) { triggered = true; break; } + } + } + else + { + // 向后兼容旧版单一 failCondition 字段(Obsolete,将在后续版本移除) +#pragma warning disable CS0618 + triggered = quest.failCondition != null && CheckObjective(qid, quest.failCondition); +#pragma warning restore CS0618 + } + if (triggered) + { + toFail ??= new List(); + toFail.Add(qid); + } } } diff --git a/Assets/_Game/Scripts/Quest/QuestObjectiveSO.cs b/Assets/_Game/Scripts/Quest/QuestObjectiveSO.cs index 8ab659f..f8ae1ae 100644 --- a/Assets/_Game/Scripts/Quest/QuestObjectiveSO.cs +++ b/Assets/_Game/Scripts/Quest/QuestObjectiveSO.cs @@ -46,6 +46,10 @@ namespace BaseGames.Quest public string displayTextKey; [Tooltip("勾选后此目标为可选项:完成可获奖励,但不阻塞任务交接。")] public bool IsOptional; + [Tooltip("前置目标 objectiveId(留空 = 无依赖,目标与其他目标并行激活)。\n" + + "设置后,在指定目标的 completed 标志置为 true 之前,本目标不接收任何事件路由,即便事件满足匹配条件。\n" + + "用于实现顺序解锁的目标链(如先对话再交物品)。")] + public string prerequisiteObjectiveId; /// /// 目标所需完成数量。用于 UI 显示进度条分母(如 "3/5 击败")。 @@ -75,6 +79,14 @@ namespace BaseGames.Quest public virtual bool TryHandleEvent(QuestEventType eventType, string payload, QuestObjectiveState state) => false; + /// + /// 强类型载荷重载()。 + /// 默认实现委托给 string 版本(向后兼容),子类可 override 以直接消费结构化载荷,避免字符串解析。 + /// QuestManager 优先调用此重载。 + /// + public virtual bool TryHandleEvent(QuestEventType eventType, IQuestEventPayload payload, QuestObjectiveState state) + => TryHandleEvent(eventType, payload?.AsString(), state); + /// /// 在 DataHub / 编辑器工具中显示的类型徽章文字。 /// 子类应 override 返回简洁中文标签(如 "[对话]")。 diff --git a/Assets/_Game/Scripts/Quest/QuestSO.cs b/Assets/_Game/Scripts/Quest/QuestSO.cs index f5040a4..c30aa71 100644 --- a/Assets/_Game/Scripts/Quest/QuestSO.cs +++ b/Assets/_Game/Scripts/Quest/QuestSO.cs @@ -83,9 +83,14 @@ namespace BaseGames.Quest public RewardSO reward; [Header("失败条件(可选)")] - [Tooltip("勾选后,failCondition 目标一旦完成,本任务立即失败并触发 EVT_QuestFailed 事件。")] + [Tooltip("勾选后,failConditions 中任意一个目标完成,本任务立即失败并触发 EVT_QuestFailed 事件。")] public bool canFail; - [Tooltip("失败判定目标。canFail=true 时有效;此目标达成即视为任务失败。")] + [Tooltip("失败判定目标列表(任意一个达成即失败)。canFail=true 时有效。\n" + + "支持多个失败条件(如「BOSS 在限时内未被击败」OR「关键 NPC 死亡」)。")] + public QuestObjectiveSO[] failConditions; + [System.Obsolete("已废弃,请改用 failConditions(数组,支持多个失败条件)。保留以兼容现有资产序列化。")] + [HideInInspector] + [Tooltip("(旧版单一失败条件,已被 failConditions 数组取代。保留以兼容现有资产。)")] public QuestObjectiveSO failCondition; [Header("接取/完成对话")]