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:
@@ -138,13 +138,23 @@ namespace BaseGames.Core.Save
|
|||||||
/// 此 QuestState 数据格式版本号。
|
/// 此 QuestState 数据格式版本号。
|
||||||
/// 1 = 原始格式(ProgressCounts 按索引,已弃用)
|
/// 1 = 原始格式(ProgressCounts 按索引,已弃用)
|
||||||
/// 2 = Round 24+ 格式(ObjectiveProgress 按 objectiveId 键值对)
|
/// 2 = Round 24+ 格式(ObjectiveProgress 按 objectiveId 键值对)
|
||||||
|
/// 3 = Round 52+ 格式(新增 ObjectiveCompleted、StartedAtUtc、CompletedAtUtc)
|
||||||
/// OnLoad 按此字段显式选择解析路径,杜绝依赖 Count > 0 的隐式推断。
|
/// OnLoad 按此字段显式选择解析路径,杜绝依赖 Count > 0 的隐式推断。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int DataVersion = 2;
|
public int DataVersion = 3;
|
||||||
public string Status; // "Unavailable"|"Available"|"Active"|"Paused"|"Completed"|"Failed"
|
/// <summary>任务运行时状态字符串。有效值:Unavailable|Available|Active|Paused|Completed|Failed。
|
||||||
|
/// OnLoad 通过 Enum.TryParse 解析;无效值将触发开发模式警告并降级为 Unavailable。</summary>
|
||||||
|
public string Status;
|
||||||
public int ObjectiveIndex;
|
public int ObjectiveIndex;
|
||||||
/// <summary>新格式(Round 24+,DataVersion=2):objectiveId → progressCount,重排目标顺序后存档不会错位。</summary>
|
/// <summary>新格式(DataVersion≥2):objectiveId → progressCount,重排目标顺序后存档不会错位。</summary>
|
||||||
public Dictionary<string, int> ObjectiveProgress = new();
|
public Dictionary<string, int> ObjectiveProgress = new();
|
||||||
|
/// <summary>各目标是否已判定完成(objectiveId → completed)。
|
||||||
|
/// 防止 GetRequiredCount 在版本迭代中变更后,重新计算结果与存档实际状态不一致。DataVersion≥3 写入。</summary>
|
||||||
|
public Dictionary<string, bool> ObjectiveCompleted = new();
|
||||||
|
/// <summary>任务接取时间(Unix 秒时间戳,UTC)。0 = 未记录(旧存档)。DataVersion≥3 写入。</summary>
|
||||||
|
public long StartedAtUtc;
|
||||||
|
/// <summary>任务完成时间(Unix 秒时间戳,UTC)。0 = 未完成或未记录。DataVersion≥3 写入。</summary>
|
||||||
|
public long CompletedAtUtc;
|
||||||
/// <summary>旧格式(按数组索引,DataVersion=1):仅用于迁移旧版存档,新存档不再写入。已弃用,将在后续版本移除。</summary>
|
/// <summary>旧格式(按数组索引,DataVersion=1):仅用于迁移旧版存档,新存档不再写入。已弃用,将在后续版本移除。</summary>
|
||||||
[System.Obsolete("旧格式存档兼容字段,仅供 OnLoad DataVersion=1 迁移使用。新存档改用 ObjectiveProgress(objectiveId 键值对)。")]
|
[System.Obsolete("旧格式存档兼容字段,仅供 OnLoad DataVersion=1 迁移使用。新存档改用 ObjectiveProgress(objectiveId 键值对)。")]
|
||||||
public List<int> ProgressCounts = new();
|
public List<int> ProgressCounts = new();
|
||||||
|
|||||||
@@ -104,8 +104,9 @@ namespace BaseGames.Dialogue
|
|||||||
/// 当 IsDialogueActive 时排队等待的对话请求。
|
/// 当 IsDialogueActive 时排队等待的对话请求。
|
||||||
/// 支持脚本触发的连续对话序列(如剧情链、事件链触发的对话),
|
/// 支持脚本触发的连续对话序列(如剧情链、事件链触发的对话),
|
||||||
/// 但容量上限为 8,避免因误触导致无限排队。
|
/// 但容量上限为 8,避免因误触导致无限排队。
|
||||||
|
/// 使用 List 而非 Queue,以支持基于优先级的抢占式淘汰(队满时丢弃最低优先级项目)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private readonly Queue<(DialogueSequenceSO seq, string npcId, int priority)> _pending = new();
|
private readonly List<(DialogueSequenceSO seq, string npcId, int priority)> _pending = new();
|
||||||
|
|
||||||
/// <summary>当前是否有对话正在播放。</summary>
|
/// <summary>当前是否有对话正在播放。</summary>
|
||||||
public bool IsDialogueActive { get; private set; }
|
public bool IsDialogueActive { get; private set; }
|
||||||
@@ -178,13 +179,35 @@ namespace BaseGames.Dialogue
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (_pending.Count < _pendingQueueCapacity)
|
if (_pending.Count < _pendingQueueCapacity)
|
||||||
_pending.Enqueue((sequence, npcId, priority));
|
{
|
||||||
|
_pending.Add((sequence, npcId, priority));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// 队满时:查找优先级最低的项目,若新请求优先级更高则淘汰之,否则丢弃新请求
|
||||||
|
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
|
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
||||||
else
|
else
|
||||||
Debug.LogWarning(
|
Debug.LogWarning(
|
||||||
$"[DialogueManager] 待播队列已满(容量 {_pendingQueueCapacity})," +
|
$"[DialogueManager] 待播队列已满(容量 {_pendingQueueCapacity})," +
|
||||||
$"序列 '{sequence.sequenceId}' 被丢弃。可在 Inspector 中调大 _pendingQueueCapacity。");
|
$"序列 '{sequence.sequenceId}'(优先级 {priority})被丢弃,队列中最低优先级为 {_pending[minIdx].priority}。");
|
||||||
#endif
|
#endif
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
PlayImmediate(sequence, npcId, priority);
|
PlayImmediate(sequence, npcId, priority);
|
||||||
@@ -412,8 +435,9 @@ namespace BaseGames.Dialogue
|
|||||||
// 自动播放队首等待中的对话(脚本触发的连续序列)
|
// 自动播放队首等待中的对话(脚本触发的连续序列)
|
||||||
if (_pending.Count > 0)
|
if (_pending.Count > 0)
|
||||||
{
|
{
|
||||||
var (seq, id, pri) = _pending.Dequeue();
|
var item = _pending[0];
|
||||||
PlayImmediate(seq, id, pri);
|
_pending.RemoveAt(0);
|
||||||
|
PlayImmediate(item.seq, item.npcId, item.priority);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ namespace BaseGames.Dialogue
|
|||||||
"UI 侧可用此值绘制好感度进度条满格。")]
|
"UI 侧可用此值绘制好感度进度条满格。")]
|
||||||
[Min(0)] public int maxAffinity = 0;
|
[Min(0)] public int maxAffinity = 0;
|
||||||
|
|
||||||
|
[Header("本地化")]
|
||||||
|
[Tooltip("nameKey 所在的本地化表名(默认 \"UI\")。\n" +
|
||||||
|
"若 NPC 名称存储在非默认表(如 \"Character\"),在此修改后 NpcSOEditor 预览和跳转按钮将使用正确的表。")]
|
||||||
|
public string localizationTable = "UI";
|
||||||
|
|
||||||
[Header("交互提示")]
|
[Header("交互提示")]
|
||||||
[Tooltip("与此 NPC 交互时显示的提示本地化 Key(如 \"INTERACT_Talk\")。\n" +
|
[Tooltip("与此 NPC 交互时显示的提示本地化 Key(如 \"INTERACT_Talk\")。\n" +
|
||||||
"留空时 InteractableNPC 回退到内置字符串 \"对话\"。")]
|
"留空时 InteractableNPC 回退到内置字符串 \"对话\"。")]
|
||||||
|
|||||||
@@ -34,13 +34,13 @@ namespace BaseGames.Editor.Dialogue
|
|||||||
s_previewStyle.normal.textColor = new Color(0.55f, 0.90f, 0.55f);
|
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);
|
EditorGUILayout.Space(4);
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(resolved))
|
if (string.IsNullOrEmpty(resolved))
|
||||||
{
|
{
|
||||||
EditorGUILayout.HelpBox(
|
EditorGUILayout.HelpBox(
|
||||||
$"nameKey「{npc.nameKey}」在本地化表中未找到对应文本(或 LocalizationManager 未初始化)。\n" +
|
$"nameKey「{npc.nameKey}」在本地化表「{npc.localizationTable}」中未找到对应文本(或 LocalizationManager 未初始化)。\n" +
|
||||||
"请检查本地化表中是否存在此 Key。",
|
"请检查本地化表中是否存在此 Key。",
|
||||||
MessageType.Warning);
|
MessageType.Warning);
|
||||||
}
|
}
|
||||||
@@ -55,9 +55,9 @@ namespace BaseGames.Editor.Dialogue
|
|||||||
EditorGUILayout.Space(4);
|
EditorGUILayout.Space(4);
|
||||||
EditorGUILayout.BeginHorizontal();
|
EditorGUILayout.BeginHorizontal();
|
||||||
GUILayout.FlexibleSpace();
|
GUILayout.FlexibleSpace();
|
||||||
if (GUILayout.Button("跳转到本地化文件(UI 表)", GUILayout.Width(200)))
|
if (GUILayout.Button($"跳转到本地化文件({npc.localizationTable} 表)", GUILayout.Width(220)))
|
||||||
{
|
{
|
||||||
PingLocalizationFile("UI");
|
PingLocalizationFile(npc.localizationTable);
|
||||||
}
|
}
|
||||||
EditorGUILayout.EndHorizontal();
|
EditorGUILayout.EndHorizontal();
|
||||||
}
|
}
|
||||||
@@ -66,12 +66,12 @@ namespace BaseGames.Editor.Dialogue
|
|||||||
/// 尝试通过 LocalizationManager(若已加载)解析 nameKey;
|
/// 尝试通过 LocalizationManager(若已加载)解析 nameKey;
|
||||||
/// 如未初始化或找不到 Key,返回 null。
|
/// 如未初始化或找不到 Key,返回 null。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static string TryResolveNameKey(string key)
|
private static string TryResolveNameKey(string key, string table = "UI")
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// LocalizationManager.Get 在编辑器下可能返回空字符串(未初始化),视为未找到
|
// LocalizationManager.Get 在编辑器下可能返回空字符串(未初始化),视为未找到
|
||||||
var resolved = BaseGames.Localization.LocalizationManager.Get(key, "UI");
|
var resolved = BaseGames.Localization.LocalizationManager.Get(key, table);
|
||||||
return string.IsNullOrEmpty(resolved) ? null : resolved;
|
return string.IsNullOrEmpty(resolved) ? null : resolved;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
|
|||||||
@@ -175,6 +175,34 @@ namespace BaseGames.Quest
|
|||||||
event Action<string, QuestStateEnum, QuestStateEnum> OnQuestStateChanged;
|
event Action<string, QuestStateEnum, QuestStateEnum> OnQuestStateChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// IQuestEventPayload ── 强类型事件载荷接口
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 任务事件载荷接口,用于 <see cref="QuestManager"/> 强类型 <c>DispatchEvent</c> 重载。
|
||||||
|
/// 实现此接口可在不修改 <see cref="QuestObjectiveSO.TryHandleEvent"/> 原有 string 签名的前提下,
|
||||||
|
/// 逐步迁移到类型安全的载荷传递。
|
||||||
|
/// </summary>
|
||||||
|
public interface IQuestEventPayload
|
||||||
|
{
|
||||||
|
/// <summary>以字符串形式返回载荷内容,兼容旧版 TryHandleEvent(string payload) 签名。</summary>
|
||||||
|
string AsString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 字符串载荷适配器。封装现有 string payload,使其可作为 <see cref="IQuestEventPayload"/> 传递。
|
||||||
|
/// 旧版 <c>DispatchEvent(QuestEventType, string)</c> 内部通过此结构透明转换,不破坏现有调用方。
|
||||||
|
/// </summary>
|
||||||
|
public readonly struct StringQuestPayload : IQuestEventPayload
|
||||||
|
{
|
||||||
|
private readonly string _value;
|
||||||
|
public StringQuestPayload(string value) => _value = value;
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public string AsString() => _value;
|
||||||
|
public static implicit operator StringQuestPayload(string s) => new StringQuestPayload(s);
|
||||||
|
}
|
||||||
|
|
||||||
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 任务调试接口(仅编辑器 / 开发构建可用)。
|
/// 任务调试接口(仅编辑器 / 开发构建可用)。
|
||||||
|
|||||||
@@ -126,6 +126,10 @@ namespace BaseGames.Quest
|
|||||||
/// <summary>任务暂停时记录的 realtimeSinceStartup(供 ResumeQuest 计算暂停持续时长并日志输出)。</summary>
|
/// <summary>任务暂停时记录的 realtimeSinceStartup(供 ResumeQuest 计算暂停持续时长并日志输出)。</summary>
|
||||||
private readonly Dictionary<string, float> _pauseTimestamps = new();
|
private readonly Dictionary<string, float> _pauseTimestamps = new();
|
||||||
#endif
|
#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>
|
/// <summary>供需要直接订阅 SO 频道的系统使用(推荐使用 IQuestEventSource C# 事件)。</summary>
|
||||||
public StringEventChannelSO QuestStartedChannel => _onQuestStarted;
|
public StringEventChannelSO QuestStartedChannel => _onQuestStarted;
|
||||||
/// <summary>供需要直接订阅 SO 频道的系统使用(推荐使用 IQuestEventSource C# 事件)。</summary>
|
/// <summary>供需要直接订阅 SO 频道的系统使用(推荐使用 IQuestEventSource C# 事件)。</summary>
|
||||||
@@ -248,6 +252,7 @@ namespace BaseGames.Quest
|
|||||||
if (!CanAccept(questId)) return;
|
if (!CanAccept(questId)) return;
|
||||||
var oldState = GetState(questId);
|
var oldState = GetState(questId);
|
||||||
_questStates[questId] = QuestStateEnum.Active;
|
_questStates[questId] = QuestStateEnum.Active;
|
||||||
|
_startedAtUtc[questId] = System.DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||||
OnQuestStateChanged?.Invoke(questId, oldState, QuestStateEnum.Active);
|
OnQuestStateChanged?.Invoke(questId, oldState, QuestStateEnum.Active);
|
||||||
Chan_QuestStarted?.Raise(questId);
|
Chan_QuestStarted?.Raise(questId);
|
||||||
OnQuestStarted?.Invoke(questId);
|
OnQuestStarted?.Invoke(questId);
|
||||||
@@ -357,6 +362,7 @@ namespace BaseGames.Quest
|
|||||||
|
|
||||||
// 先更新状态再发放奖励:确保 Apply 即使抛出异常,任务状态也已正确写入
|
// 先更新状态再发放奖励:确保 Apply 即使抛出异常,任务状态也已正确写入
|
||||||
_questStates[questId] = QuestStateEnum.Completed;
|
_questStates[questId] = QuestStateEnum.Completed;
|
||||||
|
_completedAtUtc[questId] = System.DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||||
_notifiedReadyQuests.Remove(questId);
|
_notifiedReadyQuests.Remove(questId);
|
||||||
OnQuestStateChanged?.Invoke(questId, QuestStateEnum.Active, QuestStateEnum.Completed);
|
OnQuestStateChanged?.Invoke(questId, QuestStateEnum.Active, QuestStateEnum.Completed);
|
||||||
Chan_QuestCompleted?.Raise(questId);
|
Chan_QuestCompleted?.Raise(questId);
|
||||||
@@ -661,9 +667,12 @@ namespace BaseGames.Quest
|
|||||||
{
|
{
|
||||||
data.Quests.QuestStates[id] = new BaseGames.Core.Save.QuestState
|
data.Quests.QuestStates[id] = new BaseGames.Core.Save.QuestState
|
||||||
{
|
{
|
||||||
DataVersion = 2,
|
DataVersion = 3,
|
||||||
Status = state.ToString(),
|
Status = state.ToString(),
|
||||||
ObjectiveProgress = BuildObjectiveProgress(id),
|
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 好感度缓存回写到存档
|
// 将本地 NPC 好感度缓存回写到存档
|
||||||
@@ -680,6 +689,13 @@ namespace BaseGames.Quest
|
|||||||
{
|
{
|
||||||
if (System.Enum.TryParse<QuestStateEnum>(saved.Status, out var parsedState))
|
if (System.Enum.TryParse<QuestStateEnum>(saved.Status, out var parsedState))
|
||||||
_questStates[id] = 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);
|
var quest = GetQuestSO(id);
|
||||||
if (quest?.objectives == null) continue;
|
if (quest?.objectives == null) continue;
|
||||||
@@ -700,6 +716,10 @@ namespace BaseGames.Quest
|
|||||||
if (!_objectiveStates.TryGetValue(compositeKey, out var os))
|
if (!_objectiveStates.TryGetValue(compositeKey, out var os))
|
||||||
os = _objectiveStates[compositeKey] = new QuestObjectiveState();
|
os = _objectiveStates[compositeKey] = new QuestObjectiveState();
|
||||||
os.progressCount = count;
|
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
|
else if (saved.ProgressCounts != null
|
||||||
@@ -718,6 +738,9 @@ namespace BaseGames.Quest
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
#pragma warning restore CS0618
|
#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 门槛检查使用)
|
// 从存档恢复 NPC 好感度缓存(供 CanAccept 门槛检查使用)
|
||||||
_npcRelations = new Dictionary<string, int>(data.World.NpcRelations);
|
_npcRelations = new Dictionary<string, int>(data.World.NpcRelations);
|
||||||
@@ -946,12 +969,43 @@ namespace BaseGames.Quest
|
|||||||
return dict;
|
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 自行判断是否匹配。
|
// 统一分派入口:所有事件频道均路由到此方法,由各目标 SO 自行判断是否匹配。
|
||||||
// 新增目标类型只需在 QuestObjectiveSO 子类中 override TryHandleEvent,
|
// 新增目标类型只需在 QuestObjectiveSO 子类中 override TryHandleEvent,
|
||||||
// 此处无需任何修改。
|
// 此处无需任何修改。
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 字符串载荷重载(向后兼容)。内部将 string 包装为 <see cref="StringQuestPayload"/> 后委托给强类型重载。
|
||||||
|
/// 所有外部调用方(订阅者、频道处理器)保持原有 string 签名,无需修改。
|
||||||
|
/// </summary>
|
||||||
private void DispatchEvent(QuestEventType eventType, string payload)
|
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次遍历:更新目标进度 + 同步收集失败候选 ─────────────────────
|
// ─ 第1次遍历:更新目标进度 + 同步收集失败候选 ─────────────────────
|
||||||
// 将 CheckQuestFailConditions 内联到此处,避免对 _questStates 的独立第2次迭代。
|
// 将 CheckQuestFailConditions 内联到此处,避免对 _questStates 的独立第2次迭代。
|
||||||
@@ -973,6 +1027,13 @@ namespace BaseGames.Quest
|
|||||||
foreach (var obj in quest.objectives)
|
foreach (var obj in quest.objectives)
|
||||||
{
|
{
|
||||||
if (obj == null || string.IsNullOrEmpty(obj.objectiveId)) continue;
|
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);
|
string compositeKey = GetCompositeKey(qid, obj.objectiveId);
|
||||||
if (!_objectiveStates.TryGetValue(compositeKey, out var os))
|
if (!_objectiveStates.TryGetValue(compositeKey, out var os))
|
||||||
os = _objectiveStates[compositeKey] = new QuestObjectiveState();
|
os = _objectiveStates[compositeKey] = new QuestObjectiveState();
|
||||||
@@ -1002,12 +1063,31 @@ namespace BaseGames.Quest
|
|||||||
// 失败条件检查(同次遍历内完成,惰性分配 toFail 列表)
|
// 失败条件检查(同次遍历内完成,惰性分配 toFail 列表)
|
||||||
// Paused 任务在此处已被跳过(见上方 state != Active continue),
|
// Paused 任务在此处已被跳过(见上方 state != Active continue),
|
||||||
// 设计意图:暂停期间目标冻结,失败条件也不判定,恢复后再继续检查。
|
// 设计意图:暂停期间目标冻结,失败条件也不判定,恢复后再继续检查。
|
||||||
if (quest.canFail && quest.failCondition != null && CheckObjective(qid, quest.failCondition))
|
if (quest.canFail)
|
||||||
|
{
|
||||||
|
// 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 ??= new List<string>();
|
||||||
toFail.Add(qid);
|
toFail.Add(qid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 批量目标事件:每个有变更的任务广播一次聚合事件
|
// 批量目标事件:每个有变更的任务广播一次聚合事件
|
||||||
if (pendingBatchUpdates != null)
|
if (pendingBatchUpdates != null)
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ namespace BaseGames.Quest
|
|||||||
public string displayTextKey;
|
public string displayTextKey;
|
||||||
[Tooltip("勾选后此目标为可选项:完成可获奖励,但不阻塞任务交接。")]
|
[Tooltip("勾选后此目标为可选项:完成可获奖励,但不阻塞任务交接。")]
|
||||||
public bool IsOptional;
|
public bool IsOptional;
|
||||||
|
[Tooltip("前置目标 objectiveId(留空 = 无依赖,目标与其他目标并行激活)。\n" +
|
||||||
|
"设置后,在指定目标的 completed 标志置为 true 之前,本目标不接收任何事件路由,即便事件满足匹配条件。\n" +
|
||||||
|
"用于实现顺序解锁的目标链(如先对话再交物品)。")]
|
||||||
|
public string prerequisiteObjectiveId;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 目标所需完成数量。用于 UI 显示进度条分母(如 "3/5 击败")。
|
/// 目标所需完成数量。用于 UI 显示进度条分母(如 "3/5 击败")。
|
||||||
@@ -75,6 +79,14 @@ namespace BaseGames.Quest
|
|||||||
public virtual bool TryHandleEvent(QuestEventType eventType, string payload, QuestObjectiveState state)
|
public virtual bool TryHandleEvent(QuestEventType eventType, string payload, QuestObjectiveState state)
|
||||||
=> false;
|
=> false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 强类型载荷重载(<see cref="IQuestEventPayload"/>)。
|
||||||
|
/// 默认实现委托给 string 版本(向后兼容),子类可 override 以直接消费结构化载荷,避免字符串解析。
|
||||||
|
/// QuestManager 优先调用此重载。
|
||||||
|
/// </summary>
|
||||||
|
public virtual bool TryHandleEvent(QuestEventType eventType, IQuestEventPayload payload, QuestObjectiveState state)
|
||||||
|
=> TryHandleEvent(eventType, payload?.AsString(), state);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 在 DataHub / 编辑器工具中显示的类型徽章文字。
|
/// 在 DataHub / 编辑器工具中显示的类型徽章文字。
|
||||||
/// 子类应 override 返回简洁中文标签(如 "[对话]")。
|
/// 子类应 override 返回简洁中文标签(如 "[对话]")。
|
||||||
|
|||||||
@@ -83,9 +83,14 @@ namespace BaseGames.Quest
|
|||||||
public RewardSO reward;
|
public RewardSO reward;
|
||||||
|
|
||||||
[Header("失败条件(可选)")]
|
[Header("失败条件(可选)")]
|
||||||
[Tooltip("勾选后,failCondition 目标一旦完成,本任务立即失败并触发 EVT_QuestFailed 事件。")]
|
[Tooltip("勾选后,failConditions 中任意一个目标完成,本任务立即失败并触发 EVT_QuestFailed 事件。")]
|
||||||
public bool canFail;
|
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;
|
public QuestObjectiveSO failCondition;
|
||||||
|
|
||||||
[Header("接取/完成对话")]
|
[Header("接取/完成对话")]
|
||||||
|
|||||||
Reference in New Issue
Block a user