Files
zeling_v2/Assets/_Game/Scripts/Quest/QuestObjectiveSO.cs
Joywayer 6eaa83dc71 feat: Round 48 narrative systems improvements
- QuestSO: Add ValidateBranchCycles() DFS detection for branches[].nextQuest loop
- QuestSO: Mark three legacy prerequisite fields with v2.0 removal warning in Tooltip
- IQuestManager: Add QuestLockReason enum + QuestLockInfo struct (strongly-typed lock info)
- IQuestManager: Add GetQuestLockInfo() method to interface; GetQuestLockReason() now delegates to it
- IQuestEventSource: Add OnQuestStateChanged(questId, oldState, newState) unified event
- QuestManager: Implement GetQuestLockInfo(); fire OnQuestStateChanged on all state transitions
- DialogueManager: Add one-frame yield in HandleChoices before ShowChoices (skip-debounce fix)
- DialogueManager: Increment _playbackId in ForceEnd() to invalidate residual choice callbacks
- DialogueSequenceSO: Add UNITY_EDITOR debug log in TryGetActiveVariant on variant match
- WorldStateRegistry: Add OnBatchStateChanged event + BatchMark() batch-write API
- DialogueModule: List badge shows warning indicator for unconditional-shadowing variants
- DialogueModule: BuildVariantsCard shows logic mode (AND/OR) alongside flag conditions

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-25 00:05:15 +08:00

283 lines
13 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;
/// <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>
/// 在 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;
}
}