feat: Round 52 narrative systems improvements
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>
This commit is contained in:
@@ -126,6 +126,10 @@ namespace BaseGames.Quest
|
||||
/// <summary>任务暂停时记录的 realtimeSinceStartup(供 ResumeQuest 计算暂停持续时长并日志输出)。</summary>
|
||||
private readonly Dictionary<string, float> _pauseTimestamps = new();
|
||||
#endif
|
||||
/// <summary>questId → 接取时间(Unix 秒,UTC)。用于存档和统计分析。</summary>
|
||||
private readonly Dictionary<string, long> _startedAtUtc = new();
|
||||
/// <summary>questId → 完成时间(Unix 秒,UTC)。0 表示未完成或旧存档未记录。</summary>
|
||||
private readonly Dictionary<string, long> _completedAtUtc = new();
|
||||
/// <summary>供需要直接订阅 SO 频道的系统使用(推荐使用 IQuestEventSource C# 事件)。</summary>
|
||||
public StringEventChannelSO QuestStartedChannel => _onQuestStarted;
|
||||
/// <summary>供需要直接订阅 SO 频道的系统使用(推荐使用 IQuestEventSource C# 事件)。</summary>
|
||||
@@ -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<QuestStateEnum>(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<string, int>(data.World.NpcRelations);
|
||||
@@ -946,12 +969,43 @@ namespace BaseGames.Quest
|
||||
return dict;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构建当前任务各目标的 completed 标志字典(objectiveId → completed)。
|
||||
/// 存入 <see cref="BaseGames.Core.Save.QuestState.ObjectiveCompleted"/>,
|
||||
/// 防止版本迭代中 <c>GetRequiredCount</c> 变更后,进度数值与实际完成状态脱钩。
|
||||
/// </summary>
|
||||
private Dictionary<string, bool> BuildObjectiveCompleted(string questId)
|
||||
{
|
||||
var quest = GetQuestSO(questId);
|
||||
if (quest?.objectives == null) return new Dictionary<string, bool>(0);
|
||||
var dict = new Dictionary<string, bool>(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,
|
||||
// 此处无需任何修改。
|
||||
|
||||
/// <summary>
|
||||
/// 字符串载荷重载(向后兼容)。内部将 string 包装为 <see cref="StringQuestPayload"/> 后委托给强类型重载。
|
||||
/// 所有外部调用方(订阅者、频道处理器)保持原有 string 签名,无需修改。
|
||||
/// </summary>
|
||||
private void DispatchEvent(QuestEventType eventType, string payload)
|
||||
=> DispatchEvent(eventType, new StringQuestPayload(payload));
|
||||
|
||||
/// <summary>
|
||||
/// 强类型载荷主实现。子类目标 SO 可 override <c>TryHandleEvent(QuestEventType, IQuestEventPayload, QuestObjectiveState)</c>
|
||||
/// 以直接获取结构化载荷,避免字符串解析开销。
|
||||
/// </summary>
|
||||
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<string>();
|
||||
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<string>();
|
||||
toFail.Add(qid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user