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>
295 lines
14 KiB
C#
295 lines
14 KiB
C#
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.markerTag(string)。
|
||
/// 与 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 广播 AreaReached,payload = 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;
|
||
}
|
||
}
|