Files
zeling_v2/Assets/_Game/Scripts/Quest/QuestObjectiveSO.cs
Joywayer 0b28cabba4 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>
2026-05-25 00:47:44 +08:00

295 lines
14 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
/// 所有具体目标类型均继承此类,通过多态实现零代码扩展。
///
/// 【自注册机制】子类通过 override <see cref="TryHandleEvent"/> 声明自己
/// 感兴趣的事件类型及匹配条件QuestManager 统一路由,无需为每种目标类型
/// 硬编码处理器。新增目标类型只需:
/// 1. 继承 QuestObjectiveSO
/// 2. override TryHandleEvent
/// 3. override EvaluateCompletion
/// 4. 创建 CreateAssetMenu
/// QuestManager 代码**无需任何修改**。
/// </summary>
public abstract class QuestObjectiveSO : ScriptableObject
{
[Header("标识")]
[Tooltip("目标唯一 ID如 \"OBJ_TalkElder\"。空时由 OnValidate 自动以资产名填充。")]
public string objectiveId;
[Tooltip("本地化 Key格式如 \"Quest_FindMushroom_Obj1\"。通过 LocalizationManager.Get(displayTextKey, \"Quest\") 显示给玩家。")]
[TextArea(1, 4)]
public string displayTextKey;
[Tooltip("勾选后此目标为可选项:完成可获奖励,但不阻塞任务交接。")]
public bool IsOptional;
[Tooltip("前置目标 objectiveId留空 = 无依赖,目标与其他目标并行激活)。\n" +
"设置后,在指定目标的 completed 标志置为 true 之前,本目标不接收任何事件路由,即便事件满足匹配条件。\n" +
"用于实现顺序解锁的目标链(如先对话再交物品)。")]
public string prerequisiteObjectiveId;
/// <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>
/// 强类型载荷重载(<see cref="IQuestEventPayload"/>)。
/// 默认实现委托给 string 版本(向后兼容),子类可 override 以直接消费结构化载荷,避免字符串解析。
/// QuestManager 优先调用此重载。
/// </summary>
public virtual bool TryHandleEvent(QuestEventType eventType, IQuestEventPayload payload, QuestObjectiveState state)
=> TryHandleEvent(eventType, payload?.AsString(), state);
/// <summary>
/// 在 DataHub / 编辑器工具中显示的类型徽章文字。
/// 子类应 override 返回简洁中文标签(如 "[对话]")。
/// 避免工具代码中使用 type-switch 维护列表。
/// </summary>
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()
{
// 若 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
}
// ── 运行时目标进度状态(由 QuestManager 管理,不继承 SO────────────────
public class QuestObjectiveState
{
public bool completed = false;
public int progressCount = 0;
}
// ── 具体目标类型 ────────────────────────────────────────────────────────
/// <summary>与指定 NPC 对话后完成。</summary>
[CreateAssetMenu(menuName = "BaseGames/Quest/Objective/TalkToNPC")]
public class TalkToNPCObjective : QuestObjectiveSO
{
[Tooltip("目标 NPC 的唯一 ID需与 NPC 组件上的 npcId / InteractableNPC.npcId 保持一致。")]
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>
[CreateAssetMenu(menuName = "BaseGames/Quest/Objective/Defeat")]
public class DefeatEnemyObjective : QuestObjectiveSO
{
[Tooltip("目标敌人的唯一 ID需与敌人 SO 或敌人组件上的 enemyId 保持一致。")]
public string targetEnemyId;
[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>
[CreateAssetMenu(menuName = "BaseGames/Quest/Objective/Collect")]
public class CollectItemObjective : QuestObjectiveSO
{
[Tooltip("目标物品的唯一 ID需与拾取事件广播的 itemId 保持一致。")]
public string itemId;
[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 中的场景名称)。留空时仅依赖 markerTag 判定。")]
public string sceneName;
[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>
[CreateAssetMenu(menuName = "BaseGames/Quest/Objective/UseSkill")]
public class UseSkillObjective : QuestObjectiveSO
{
[Tooltip("目标能力类型。事件频道 EVT_SkillUsed 广播 AbilityType.ToString(),与此值匹配时计数。")]
public AbilityType requiredAbility;
[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;
}
}