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>
This commit is contained in:
@@ -1,8 +1,70 @@
|
||||
using System;
|
||||
using BaseGames.Core.Events;
|
||||
using QuestStateEnum = BaseGames.Core.Events.QuestState;
|
||||
|
||||
namespace BaseGames.Quest
|
||||
{
|
||||
// =========================================================================
|
||||
// QuestLockReason / QuestLockInfo ── 任务锁定原因(强类型 API)
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>任务无法接取的原因枚举。<see cref="None"/> 表示无锁定(可接取)。</summary>
|
||||
public enum QuestLockReason
|
||||
{
|
||||
/// <summary>无锁定,任务当前可以接取。</summary>
|
||||
None,
|
||||
/// <summary>任务已在进行中(Active)。</summary>
|
||||
AlreadyActive,
|
||||
/// <summary>任务已完成(Completed)。</summary>
|
||||
AlreadyCompleted,
|
||||
/// <summary>任务已失败(Failed)。</summary>
|
||||
Failed,
|
||||
/// <summary>任务已暂停(Paused)。</summary>
|
||||
Paused,
|
||||
/// <summary>任务 ID 未找到或资产未加载。</summary>
|
||||
NotFound,
|
||||
/// <summary>好感度或存档数据尚未初始化。</summary>
|
||||
DataNotLoaded,
|
||||
/// <summary>NPC 好感度不足。<see cref="QuestLockInfo.Param"/> 格式:"{actual}/{min}"。</summary>
|
||||
InsufficientAffinity,
|
||||
/// <summary>前置任务未完成。<see cref="QuestLockInfo.Param"/> 为该前置任务的 questId。</summary>
|
||||
RequiresQuest,
|
||||
/// <summary>世界状态标志条件未满足。</summary>
|
||||
FlagConditionNotMet,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 任务锁定信息(强类型版本)。
|
||||
/// 相比字符串 Key,可在编译期检查原因类型,UI 层无需手动解析冒号分隔的参数。
|
||||
/// 通过 <see cref="ToLocalizationKey"/> 可转换为与旧版 <c>GetQuestLockReason</c> 兼容的 Key 格式。
|
||||
/// </summary>
|
||||
public struct QuestLockInfo
|
||||
{
|
||||
/// <summary>锁定原因枚举值。<see cref="QuestLockReason.None"/> 表示无锁定(可接取)。</summary>
|
||||
public QuestLockReason Reason;
|
||||
|
||||
/// <summary>
|
||||
/// 附带参数(可选):<br/>
|
||||
/// - <see cref="QuestLockReason.RequiresQuest"/>:前置任务 questId<br/>
|
||||
/// - <see cref="QuestLockReason.InsufficientAffinity"/>:格式 "{actual}/{min}"
|
||||
/// </summary>
|
||||
public string Param;
|
||||
|
||||
/// <summary>任务当前是否处于锁定状态(不可接取)。</summary>
|
||||
public bool IsLocked => Reason != QuestLockReason.None;
|
||||
|
||||
/// <summary>
|
||||
/// 转换为本地化 Key 格式,与旧版 <see cref="IQuestManager.GetQuestLockReason"/> 完全兼容。
|
||||
/// 格式:<c>"Quest.LockReason.{Reason}"</c>;有参数时为 <c>"Quest.LockReason.{Reason}:{Param}"</c>。
|
||||
/// </summary>
|
||||
public string ToLocalizationKey() =>
|
||||
Reason == QuestLockReason.None
|
||||
? string.Empty
|
||||
: string.IsNullOrEmpty(Param)
|
||||
? $"Quest.LockReason.{Reason}"
|
||||
: $"Quest.LockReason.{Reason}:{Param}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 任务管理器的公开契约。ServiceLocator.Get<IQuestManager>() 获取实例,
|
||||
/// 避免外部代码直接依赖 QuestManager 具体类型。
|
||||
@@ -12,13 +74,99 @@ namespace BaseGames.Quest
|
||||
/// <summary>接取任务(幂等)。</summary>
|
||||
void AcceptQuest(string questId);
|
||||
|
||||
/// <summary>
|
||||
/// 主动放弃进行中的任务(Active → Available/Unavailable),清除目标进度。
|
||||
/// 非 Active 状态的任务调用此方法无效。
|
||||
/// </summary>
|
||||
void AbandonQuest(string questId);
|
||||
|
||||
/// <summary>完成任务并发放奖励。rewardTarget 接收奖励(如玩家)。</summary>
|
||||
void CompleteQuest(string questId, IRewardTarget rewardTarget);
|
||||
|
||||
/// <summary>
|
||||
/// 暂停进行中的任务(Active → Paused)。暂停期间目标不推进,失败条件不判定。
|
||||
/// 非 Active 状态的任务调用此方法无效。
|
||||
/// </summary>
|
||||
void PauseQuest(string questId);
|
||||
|
||||
/// <summary>
|
||||
/// 恢复已暂停的任务(Paused → Active)。
|
||||
/// 非 Paused 状态的任务调用此方法无效。
|
||||
/// </summary>
|
||||
void ResumeQuest(string questId);
|
||||
|
||||
/// <summary>返回当前任务状态。未知 questId 返回 Unavailable。</summary>
|
||||
QuestStateEnum GetState(string questId);
|
||||
|
||||
/// <summary>判断任务是否满足完成条件。</summary>
|
||||
bool IsReadyToComplete(string questId);
|
||||
|
||||
/// <summary>返回指定 NPC 的当前好感度数值(未记录时返回 0)。</summary>
|
||||
int GetNpcAffinity(string npcId);
|
||||
|
||||
/// <summary>
|
||||
/// 返回任务无法被接取的原因(本地化 Key 格式)。
|
||||
/// 若任务当前可以接取,返回空字符串。
|
||||
/// Key 格式:<c>"Quest.LockReason.{Reason}"</c>;带动态参数时以冒号分隔,如
|
||||
/// <c>"Quest.LockReason.RequiresQuest:Quest_FindMushroom"</c>。
|
||||
/// <para>推荐新代码使用 <see cref="GetQuestLockInfo"/> 获取强类型结果,无需手动解析字符串。</para>
|
||||
/// </summary>
|
||||
string GetQuestLockReason(string questId);
|
||||
|
||||
/// <summary>
|
||||
/// 返回任务无法被接取的强类型锁定信息。
|
||||
/// 相比 <see cref="GetQuestLockReason"/>,可在编译期检查原因枚举,UI 层无需解析字符串。
|
||||
/// 若任务当前可以接取,返回 <see cref="QuestLockInfo.Reason"/> 为 <see cref="QuestLockReason.None"/> 的实例。
|
||||
/// </summary>
|
||||
QuestLockInfo GetQuestLockInfo(string questId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 任务事件订阅接口。
|
||||
/// 外部系统(成就、地图标记、HUD、埋点)通过此接口订阅任务生命周期事件,
|
||||
/// 无需直接持有 StringEventChannelSO,保持与 QuestManager 具体实现的解耦。
|
||||
/// 获取方式:<c>ServiceLocator.Get<IQuestManager>() as IQuestEventSource</c>
|
||||
/// </summary>
|
||||
public interface IQuestEventSource
|
||||
{
|
||||
/// <summary>任务成功接取时触发。参数 = questId。</summary>
|
||||
event Action<string> OnQuestStarted;
|
||||
/// <summary>任务完成时触发。参数 = questId。</summary>
|
||||
event Action<string> OnQuestCompleted;
|
||||
/// <summary>任务失败时触发。参数 = questId。</summary>
|
||||
event Action<string> OnQuestFailed;
|
||||
/// <summary>任务被主动放弃时触发。参数 = questId。</summary>
|
||||
event Action<string> OnQuestAbandoned;
|
||||
/// <summary>任务暂停时触发(Active → Paused)。参数 = questId。供埋点/分析系统使用。</summary>
|
||||
event Action<string> OnQuestPaused;
|
||||
/// <summary>任务从暂停恢复时触发(Paused → Active)。参数 = questId。供埋点/分析系统使用。</summary>
|
||||
event Action<string> OnQuestResumed;
|
||||
/// <summary>目标全部达成、可回去交任务时触发(去重,同任务只触发一次)。参数 = questId。</summary>
|
||||
event Action<string> OnQuestReadyToComplete;
|
||||
/// <summary>
|
||||
/// 任务状态发生任意转换时触发(涵盖所有状态变更,含旧状态和新状态)。
|
||||
/// 供状态机审计面板、通用 UI 绑定(无需分别订阅六个离散事件)使用。
|
||||
/// 参数:(questId, oldState, newState)。
|
||||
/// </summary>
|
||||
event Action<string, QuestStateEnum, QuestStateEnum> OnQuestStateChanged;
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
||||
/// <summary>
|
||||
/// 任务调试接口(仅编辑器 / 开发构建可用)。
|
||||
/// 通过 <c>(IQuestManager as IQuestDebugger)?.ResetQuest(id)</c> 使用,
|
||||
/// 正式发布构建中此接口不存在,调用方无需任何 #if 守卫。
|
||||
/// </summary>
|
||||
public interface IQuestDebugger
|
||||
{
|
||||
/// <summary>
|
||||
/// 将任务重置为 Available(前置满足)或 Unavailable(前置未满足),并清除目标进度。
|
||||
/// 不广播 QuestStarted / QuestCompleted 等运行时事件,仅用于开发/调试。
|
||||
/// </summary>
|
||||
/// <param name="questId">要重置的任务 ID。</param>
|
||||
/// <param name="rollbackAffinity">若为 true(默认),同步回滚此任务对应 NPC 的好感度增量,
|
||||
/// 防止调试期间重复完成导致好感度叠加。</param>
|
||||
void ResetQuest(string questId, bool rollbackAffinity = true);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
27
Assets/_Game/Scripts/Quest/NpcAffinityEvent.cs
Normal file
27
Assets/_Game/Scripts/Quest/NpcAffinityEvent.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using UnityEngine;
|
||||
using BaseGames.Core.Events;
|
||||
|
||||
namespace BaseGames.Quest
|
||||
{
|
||||
/// <summary>
|
||||
/// NPC 好感度变化事件的强类型负载。
|
||||
/// 替代原 "npcId|delta" 字符串分割方案,杜绝接收方 Split 解析脆弱性。
|
||||
/// </summary>
|
||||
[System.Serializable]
|
||||
public struct NpcAffinityEvent
|
||||
{
|
||||
/// <summary>发生好感度变化的 NPC ID(与 QuestSO.giverNpcId 保持一致)。</summary>
|
||||
public string npcId;
|
||||
/// <summary>好感度变化量(正值=增加,负值=减少)。</summary>
|
||||
public int delta;
|
||||
/// <summary>变化后的当前总好感度数值。</summary>
|
||||
public int newTotal;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EVT_NpcAffinityChanged 专用事件频道 SO(强类型,负载 <see cref="NpcAffinityEvent"/>)。
|
||||
/// 放置路径: Assets/ScriptableObjects/Events/EVT_NpcAffinityChanged.asset
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "BaseGames/Events/NpcAffinity")]
|
||||
public class NpcAffinityEventChannelSO : BaseEventChannelSO<NpcAffinityEvent> { }
|
||||
}
|
||||
11
Assets/_Game/Scripts/Quest/NpcAffinityEvent.cs.meta
Normal file
11
Assets/_Game/Scripts/Quest/NpcAffinityEvent.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: df5e857463388a249893d48dda71c54b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
61
Assets/_Game/Scripts/Quest/QuestEventChannelRegistry.cs
Normal file
61
Assets/_Game/Scripts/Quest/QuestEventChannelRegistry.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
using BaseGames.Core.Events;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Quest
|
||||
{
|
||||
/// <summary>
|
||||
/// 任务事件频道注册表 SO(架构 22_QuestChallengeModule §4.1)。
|
||||
/// 将 QuestManager 的 10+ 个分散事件频道字段集中到一个可复用的 ScriptableObject 中,
|
||||
/// 便于多场景共享同一套频道配置,同时减少 QuestManager Inspector 的视觉复杂度。
|
||||
///
|
||||
/// 使用方式:
|
||||
/// 1. 创建一个 QuestEventChannelRegistry 资产(菜单:BaseGames/Quest/EventChannelRegistry)。
|
||||
/// 2. 在资产中将现有各 EventChannelSO 拖入对应字段。
|
||||
/// 3. 将资产引用填入 QuestManager 的 "事件频道注册表" 字段。
|
||||
/// 4. QuestManager 的独立频道字段将自动隐藏(通过注册表覆盖)。
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "BaseGames/Quest/EventChannelRegistry", fileName = "QuestEventChannelRegistry")]
|
||||
public class QuestEventChannelRegistry : ScriptableObject
|
||||
{
|
||||
[Header("输入频道(QuestManager 监听)")]
|
||||
[Tooltip("EVT_EnemyDied:payload = enemyId(string)。敌人死亡时由战斗系统广播,驱动击败类目标进度。")]
|
||||
public StringEventChannelSO onEnemyDied;
|
||||
[Tooltip("EVT_CollectiblePickup:payload = itemId(string)。拾取物品时广播,驱动收集类目标进度。")]
|
||||
public StringEventChannelSO onCollectiblePickup;
|
||||
[Tooltip("EVT_SceneLoaded:payload = sceneName(string)。场景切换完成时广播,驱动到达类目标进度。")]
|
||||
public StringEventChannelSO onSceneLoaded;
|
||||
[Tooltip("EVT_NpcDialogueCompleted:payload = npcId(string)。DialogueManager 播完一段对话后广播,驱动对话类目标进度。")]
|
||||
public StringEventChannelSO onNpcDialogueCompleted;
|
||||
[Tooltip("EVT_SkillUsed:payload = AbilityType.ToString()(string)。玩家使用技能时广播,驱动技能使用类目标进度。")]
|
||||
public StringEventChannelSO onSkillUsed;
|
||||
[Tooltip("EVT_AreaReached:payload = markerTag(string)。TriggerZone 在玩家进入时广播,驱动区域到达类目标进度。")]
|
||||
public StringEventChannelSO onAreaReached;
|
||||
|
||||
[Header("广播频道(QuestManager 广播)")]
|
||||
[Tooltip("EVT_QuestStarted:payload = questId。AcceptQuest 成功后广播。")]
|
||||
public StringEventChannelSO onQuestStarted;
|
||||
[Tooltip("EVT_QuestCompleted:payload = questId。CompleteQuest 成功后广播。")]
|
||||
public StringEventChannelSO onQuestCompleted;
|
||||
[Tooltip("EVT_QuestFailed:payload = questId。失败条件触发后广播。")]
|
||||
public StringEventChannelSO onQuestFailed;
|
||||
[Tooltip("EVT_QuestAbandoned:payload = questId。玩家放弃任务时广播。")]
|
||||
public StringEventChannelSO onQuestAbandoned;
|
||||
[Tooltip("EVT_QuestPaused:payload = questId。PauseQuest 成功后广播。")]
|
||||
public StringEventChannelSO onQuestPaused;
|
||||
[Tooltip("EVT_QuestResumed:payload = questId。ResumeQuest 成功后广播。")]
|
||||
public StringEventChannelSO onQuestResumed;
|
||||
[Tooltip("EVT_QuestReadyToComplete:payload = questId。目标全部达成时广播一次(去重)。")]
|
||||
public StringEventChannelSO onQuestReadyToComplete;
|
||||
[Tooltip("EVT_QuestObjectiveUpdated:目标进度变化时广播(强类型 QuestObjectiveEvent)。")]
|
||||
public QuestObjectiveEventChannelSO onObjectiveUpdated;
|
||||
[Tooltip("EVT_QuestObjectiveBatchUpdated:同帧内多目标聚合后广播一次(避免 HUD 同帧多次重绘)。")]
|
||||
public QuestObjectiveBatchEventChannelSO onObjectiveBatchUpdated;
|
||||
[Tooltip("EVT_NpcAffinityChanged:NPC 好感度变化(强类型 NpcAffinityEvent)。")]
|
||||
public NpcAffinityEventChannelSO onNpcAffinityChanged;
|
||||
[Tooltip("EVT_DialogueKeyUnlocked:payload = unlockDialogueKey,供 NPC 台词系统监听。")]
|
||||
public StringEventChannelSO onDialogueKeyUnlocked;
|
||||
[Tooltip("EVT_DialogueChoiceSelected:玩家选择对话选项时广播(payload = \"sequenceId/choiceIndex\")。\n" +
|
||||
"供 QA 埋点、成就系统、或数据分析监听,以还原玩家的对话选择路径。")]
|
||||
public StringEventChannelSO onDialogueChoiceSelected;
|
||||
}
|
||||
}
|
||||
@@ -14,16 +14,42 @@ namespace BaseGames.Quest
|
||||
public class QuestGiver : InteractableNPC
|
||||
{
|
||||
[Header("任务")]
|
||||
[SerializeField] private QuestSO[] _offeredQuests; // 该 NPC 可提供的所有任务(按优先级排列)
|
||||
[Tooltip("该 NPC 可提供的所有任务,按优先级从高到低排列。\n" +
|
||||
"交互时从列表头部找到第一个 Available 或 Active 状态的任务作为当前任务;\n" +
|
||||
"若全部已完成,显示最后一个已完成任务的 completedDialogue。")]
|
||||
[SerializeField] private QuestSO[] _offeredQuests;
|
||||
|
||||
[Header("对话版本(根据任务状态切换)")]
|
||||
[SerializeField] private DialogueSequenceSO _availableDialogue; // 任务可接时
|
||||
[SerializeField] private DialogueSequenceSO _activeDialogue; // 任务进行中
|
||||
[SerializeField] private DialogueSequenceSO _readyDialogue; // 完成条件满足时
|
||||
[SerializeField] private DialogueSequenceSO _completedDialogue; // 任务已完成后
|
||||
[Tooltip("任务尚未接取(QuestState.Available)时播放。通常是 NPC 发布任务、介绍背景的对话。")]
|
||||
[SerializeField] private DialogueSequenceSO _availableDialogue;
|
||||
[Tooltip("任务已接取、目标尚未全部完成(QuestState.Active)时播放。通常是 NPC 催促或加油打气的对话。")]
|
||||
[SerializeField] private DialogueSequenceSO _activeDialogue;
|
||||
[Tooltip("全部非可选目标已达成、任务可以交付时播放(IsReadyToComplete = true)。\n" +
|
||||
"通常是 NPC 感谢、确认收取物品的对话,播放后执行 CompleteQuest 逻辑。")]
|
||||
[SerializeField] private DialogueSequenceSO _readyDialogue;
|
||||
[Tooltip("任务已完成(QuestState.Completed)后再次交互时播放。通常是 NPC 闲聊或后续剧情的对话。")]
|
||||
[SerializeField] private DialogueSequenceSO _completedDialogue;
|
||||
|
||||
// ── InteractableNPC 覆盖 ──────────────────────────────────────────────
|
||||
|
||||
public override string InteractPrompt
|
||||
{
|
||||
get
|
||||
{
|
||||
var qm = SL.GetOrDefault<IQuestManager>();
|
||||
var quest = GetCurrentOrCompletedQuest(qm);
|
||||
if (quest == null || qm == null) return base.InteractPrompt;
|
||||
return qm.GetState(quest.questId) switch
|
||||
{
|
||||
QuestStateEnum.Available => "接受任务",
|
||||
QuestStateEnum.Active => qm.IsReadyToComplete(quest.questId) ? "提交任务" : "进行中…",
|
||||
QuestStateEnum.Paused => "暂停中…",
|
||||
QuestStateEnum.Completed => "对话",
|
||||
_ => base.InteractPrompt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
protected override void Interact_Internal(Transform player)
|
||||
{
|
||||
var qm = SL.GetOrDefault<IQuestManager>();
|
||||
@@ -57,6 +83,7 @@ namespace BaseGames.Quest
|
||||
QuestStateEnum.Available => _availableDialogue,
|
||||
QuestStateEnum.Active => qm.IsReadyToComplete(quest.questId)
|
||||
? _readyDialogue : _activeDialogue,
|
||||
QuestStateEnum.Paused => _activeDialogue, // 暂停中显示"催促"对话,不触发任何状态推进
|
||||
QuestStateEnum.Completed => _completedDialogue,
|
||||
_ => base.GetCurrentDialogue(),
|
||||
};
|
||||
@@ -79,7 +106,7 @@ namespace BaseGames.Quest
|
||||
{
|
||||
if (q == null) continue;
|
||||
var s = qm.GetState(q.questId);
|
||||
if (s == QuestStateEnum.Available || s == QuestStateEnum.Active) return q;
|
||||
if (s == QuestStateEnum.Available || s == QuestStateEnum.Active || s == QuestStateEnum.Paused) return q;
|
||||
if (s == QuestStateEnum.Completed) lastCompleted = q;
|
||||
}
|
||||
return lastCompleted;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,40 @@
|
||||
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)。
|
||||
/// 所有具体目标类型均继承此类,通过多态实现零代码扩展。
|
||||
/// 每种目标在事件驱动下由 QuestManager 调用 EvaluateCompletion()。
|
||||
///
|
||||
/// 【自注册机制】子类通过 override <see cref="TryHandleEvent"/> 声明自己
|
||||
/// 感兴趣的事件类型及匹配条件,QuestManager 统一路由,无需为每种目标类型
|
||||
/// 硬编码处理器。新增目标类型只需:
|
||||
/// 1. 继承 QuestObjectiveSO
|
||||
/// 2. override TryHandleEvent
|
||||
/// 3. override EvaluateCompletion
|
||||
/// 4. 创建 CreateAssetMenu
|
||||
/// QuestManager 代码**无需任何修改**。
|
||||
/// </summary>
|
||||
public abstract class QuestObjectiveSO : ScriptableObject
|
||||
{
|
||||
@@ -19,9 +47,34 @@ namespace BaseGames.Quest
|
||||
[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 返回简洁中文标签(如 "[对话]")。
|
||||
@@ -30,11 +83,52 @@ namespace BaseGames.Quest
|
||||
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()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(objectiveId)) return;
|
||||
objectiveId = name;
|
||||
UnityEditor.EditorUtility.SetDirty(this);
|
||||
// 若 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
|
||||
}
|
||||
@@ -57,6 +151,14 @@ namespace BaseGames.Quest
|
||||
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>
|
||||
@@ -68,7 +170,16 @@ namespace BaseGames.Quest
|
||||
[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>
|
||||
@@ -80,19 +191,54 @@ namespace BaseGames.Quest
|
||||
[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 中的场景名称)。")]
|
||||
[Tooltip("需到达的场景名(Unity Build Settings 中的场景名称)。留空时仅依赖 markerTag 判定。")]
|
||||
public string sceneName;
|
||||
[Tooltip("场景内的目标标记 Tag(预留字段,当前未启用)。")]
|
||||
[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>
|
||||
@@ -104,6 +250,33 @@ namespace BaseGames.Quest
|
||||
[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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
using BaseGames.Core;
|
||||
using BaseGames.Dialogue;
|
||||
|
||||
namespace BaseGames.Quest
|
||||
@@ -12,29 +13,88 @@ namespace BaseGames.Quest
|
||||
public class QuestSO : ScriptableObject
|
||||
{
|
||||
[Header("标识")]
|
||||
public string questId; // 唯一 ID,如 "Quest_FindMushroom"
|
||||
[Tooltip("任务唯一 ID,如 \"Quest_FindMushroom\"。运行时由 QuestManager 以此为键索引,必须全局唯一。")]
|
||||
public string questId;
|
||||
|
||||
[Tooltip("发布/完成该任务的 NPC(直接引用 NpcSO 资产,推荐)。\n" +
|
||||
"用于完成任务后向该 NPC 应用 affinityBonus,及 CanAccept 好感度门槛检查。\n" +
|
||||
"留空时跳过好感度相关逻辑;与旧字段 giverNpcId 同时有值时以此 SO 为准。")]
|
||||
public NpcSO giverNpc;
|
||||
|
||||
[System.Obsolete("已废弃,请改用 giverNpc(NpcSO 直接引用)。保留以兼容现有资产序列化。")]
|
||||
[HideInInspector]
|
||||
public string giverNpcId;
|
||||
|
||||
/// <summary>运行时使用的 NPC ID:giverNpc 优先,回退到旧字段 giverNpcId。</summary>
|
||||
public string GiverNpcId => (giverNpc != null && !string.IsNullOrEmpty(giverNpc.npcId))
|
||||
? giverNpc.npcId
|
||||
#pragma warning disable CS0618
|
||||
: giverNpcId;
|
||||
#pragma warning restore CS0618
|
||||
|
||||
[Tooltip("本地化 Key,格式如 \"Quest_FindMushroom_Name\"。通过 LocalizationManager.Get(displayNameKey, \"Quest\") 显示。")]
|
||||
public string displayNameKey;
|
||||
[Tooltip("本地化 Key,格式如 \"Quest_FindMushroom_Desc\"。通过 LocalizationManager.Get(descriptionKey, \"Quest\") 显示。")]
|
||||
public string descriptionKey;
|
||||
[Tooltip("任务图标,显示在日志 UI 和 DataHub 列表中(可选)。")]
|
||||
public Sprite icon;
|
||||
|
||||
[Header("分类")]
|
||||
[Tooltip("任务分类,供任务日志 UI 分区显示和 DataHub 快速过滤使用。\n" +
|
||||
" Main = 主线任务(必做,推动剧情)\n" +
|
||||
" Side = 支线任务(可选,丰富世界观)\n" +
|
||||
" Daily = 日常/重复任务(可重置)\n" +
|
||||
" Hidden = 隐藏任务(不主动显示,触发后才出现)")]
|
||||
public QuestCategory category = QuestCategory.Side;
|
||||
|
||||
[Header("目标链")]
|
||||
public QuestObjectiveSO[] objectives; // 按顺序完成,全部完成 = 可交完
|
||||
[Tooltip("按顺序完成的目标列表。全部非可选目标完成后任务可交付。每个目标为独立的 QuestObjectiveSO 资产。")]
|
||||
public QuestObjectiveSO[] objectives;
|
||||
|
||||
[Header("前置条件")]
|
||||
public QuestSO[] prerequisiteQuests; // 所有前置任务 Completed 后才可接
|
||||
public int minAffinityToAccept; // NPC 好感度门槛(0 = 无限制)
|
||||
[Tooltip("任务前置条件(统一配置版)。将前置任务依赖和世界标志依赖合并为单一结构,便于 Inspector 管理。\n" +
|
||||
"如旧版字段(prerequisiteQuests / prerequisiteFlags)已有数据,运行时将自动回退使用旧版字段,无需手动迁移。")]
|
||||
public QuestPrerequisite prerequisites = new QuestPrerequisite();
|
||||
|
||||
// ── 旧版前置字段(向后兼容,新配置请改用 prerequisites)────────────────
|
||||
[HideInInspector]
|
||||
[Tooltip("【已归入 prerequisites.questDependencies,此字段仅用于旧资产兼容】\n" +
|
||||
"所有前置任务必须处于 Completed 状态,本任务才能被接取。\n" +
|
||||
"⚠ 此字段计划在 v2.0 移除,请尽快通过 QuestSOEditor 迁移至 prerequisites。")]
|
||||
public QuestSO[] prerequisiteQuests;
|
||||
|
||||
[HideInInspector]
|
||||
[Tooltip("【已归入 prerequisites.flagCondition.logic,此字段仅用于旧资产兼容】\n" +
|
||||
"⚠ 此字段计划在 v2.0 移除,请尽快通过 QuestSOEditor 迁移至 prerequisites。")]
|
||||
public BaseGames.Core.WorldStateFlagLogic prerequisiteFlagsLogic = BaseGames.Core.WorldStateFlagLogic.And;
|
||||
|
||||
[HideInInspector]
|
||||
[Tooltip("【已归入 prerequisites.flagCondition.flags,此字段仅用于旧资产兼容】\n" +
|
||||
"⚠ 此字段计划在 v2.0 移除,请尽快通过 QuestSOEditor 迁移至 prerequisites。")]
|
||||
[BaseGames.Core.WorldStateFlag]
|
||||
public string[] prerequisiteFlags;
|
||||
|
||||
[Tooltip("接取本任务所需的 NPC 好感度下限(0 = 无限制)。由好感度系统提供实际数值。")]
|
||||
public int minAffinityToAccept;
|
||||
|
||||
[Header("奖励")]
|
||||
[Tooltip("任务完成时发放的奖励(RewardSO)。留空表示无奖励。\n" +
|
||||
"QuestManager.CompleteQuest() 调用 reward.Apply(rewardTarget),通过 IRewardTarget 接口发放。")]
|
||||
public RewardSO reward;
|
||||
|
||||
[Header("失败条件(可选)")]
|
||||
[Tooltip("勾选后,failCondition 目标一旦完成,本任务立即失败并触发 EVT_QuestFailed 事件。")]
|
||||
public bool canFail;
|
||||
[Tooltip("失败判定目标。canFail=true 时有效;此目标达成即视为任务失败。")]
|
||||
public QuestObjectiveSO failCondition;
|
||||
|
||||
[Header("接取/完成对话")]
|
||||
[Tooltip("玩家接取任务时自动触发的 NPC 对话序列(如 NPC 委托台词)。\n" +
|
||||
"AcceptQuest 成功后立即播放;留空则不触发。")]
|
||||
public DialogueSequenceSO acceptDialogueSequence;
|
||||
|
||||
[Header("完成后续任务(分支)")]
|
||||
[Tooltip("完成本任务后解锁的后续任务。conditionQuest=null 为默认分支;多个分支可同时满足(允许同时解锁多条支线)。")]
|
||||
public QuestBranch[] branches;
|
||||
|
||||
// ── 编辑器校验 ────────────────────────────────────────────────────────
|
||||
@@ -85,6 +145,128 @@ namespace BaseGames.Quest
|
||||
}
|
||||
|
||||
ValidateBranchDialogueKeys();
|
||||
ValidateObjectiveIds();
|
||||
ValidatePrerequisiteCycles();
|
||||
ValidateBranchCycles();
|
||||
}
|
||||
|
||||
private void ValidateObjectiveIds()
|
||||
{
|
||||
if (objectives == null || objectives.Length == 0) return;
|
||||
var seen = new System.Collections.Generic.HashSet<string>(System.StringComparer.Ordinal);
|
||||
for (int i = 0; i < objectives.Length; i++)
|
||||
{
|
||||
var obj = objectives[i];
|
||||
if (obj == null || string.IsNullOrEmpty(obj.objectiveId)) continue;
|
||||
if (!seen.Add(obj.objectiveId))
|
||||
Debug.LogError(
|
||||
$"[QuestSO] '{name}' 的 objectives[{i}] objectiveId '{obj.objectiveId}' " +
|
||||
"在本任务内重复!同一个 ObjectiveSO 资产被引用多次会导致进度互相覆盖," +
|
||||
"请为每个目标使用独立的 SO 资产。", this);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检测前置任务链是否形成循环依赖(如 A 前置 B、B 前置 A)。
|
||||
/// 循环会导致两个任务互相锁定,运行时无法被接取,属于配置错误。
|
||||
/// </summary>
|
||||
private void ValidatePrerequisiteCycles()
|
||||
{
|
||||
if (string.IsNullOrEmpty(questId)) return;
|
||||
|
||||
var visited = new System.Collections.Generic.HashSet<string>(System.StringComparer.Ordinal);
|
||||
visited.Add(questId);
|
||||
|
||||
#pragma warning disable CS0618
|
||||
QuestSO[] deps = prerequisites.HasAny ? prerequisites.questDependencies : prerequisiteQuests;
|
||||
#pragma warning restore CS0618
|
||||
|
||||
if (deps == null) return;
|
||||
|
||||
foreach (var dep in deps)
|
||||
{
|
||||
if (dep == null) continue;
|
||||
if (HasPrerequisiteCycle(dep, visited))
|
||||
{
|
||||
Debug.LogError(
|
||||
$"[QuestSO] '{name}'(questId='{questId}')的前置任务链存在循环依赖!" +
|
||||
$"前置任务 '{dep.name}' 最终指回自身或已访问任务," +
|
||||
"运行时将导致任务无法被接取。请检查 prerequisites/prerequisiteQuests 配置。", this);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 深度优先遍历前置链,检测是否存在环路。
|
||||
/// <para>已访问节点集 <paramref name="visited"/> 在回溯时移除,保证同一链条中不误报平行分支。</para>
|
||||
/// </summary>
|
||||
private static bool HasPrerequisiteCycle(QuestSO quest,
|
||||
System.Collections.Generic.HashSet<string> visited)
|
||||
{
|
||||
if (string.IsNullOrEmpty(quest.questId)) return false;
|
||||
if (!visited.Add(quest.questId)) return true; // 已在当前路径上 = 环路
|
||||
|
||||
#pragma warning disable CS0618
|
||||
QuestSO[] deps = quest.prerequisites.HasAny ? quest.prerequisites.questDependencies : quest.prerequisiteQuests;
|
||||
#pragma warning restore CS0618
|
||||
|
||||
if (deps != null)
|
||||
{
|
||||
foreach (var dep in deps)
|
||||
{
|
||||
if (dep != null && HasPrerequisiteCycle(dep, visited))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
visited.Remove(quest.questId); // 回溯
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检测 branches[].nextQuest 解锁链是否形成循环(如 A 完成解锁 B,B 完成解锁 A)。
|
||||
/// 循环会导致运行时 UnlockBranches 无限递归设置任务状态,属于配置错误。
|
||||
/// </summary>
|
||||
private void ValidateBranchCycles()
|
||||
{
|
||||
if (branches == null || branches.Length == 0) return;
|
||||
if (string.IsNullOrEmpty(questId)) return;
|
||||
|
||||
foreach (var branch in branches)
|
||||
{
|
||||
if (branch?.nextQuest == null) continue;
|
||||
var visited = new System.Collections.Generic.HashSet<string>(System.StringComparer.Ordinal);
|
||||
visited.Add(questId);
|
||||
if (HasBranchCycle(branch.nextQuest, visited))
|
||||
{
|
||||
Debug.LogError(
|
||||
$"[QuestSO] '{name}'(questId='{questId}')的分支 nextQuest '{branch.nextQuest.name}' " +
|
||||
"解锁链存在循环!最终将指回自身或已访问任务,运行时 UnlockBranches 会进入无限递归。" +
|
||||
"请检查 branches[].nextQuest 配置。", this);
|
||||
return; // 一次只报首个问题
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 深度优先遍历 branches[].nextQuest 链,检测是否存在环路(DFS 回溯)。
|
||||
/// </summary>
|
||||
private static bool HasBranchCycle(QuestSO quest,
|
||||
System.Collections.Generic.HashSet<string> visited)
|
||||
{
|
||||
if (string.IsNullOrEmpty(quest.questId)) return false;
|
||||
if (!visited.Add(quest.questId)) return true; // 已在路径上 = 环路
|
||||
|
||||
if (quest.branches != null)
|
||||
{
|
||||
foreach (var branch in quest.branches)
|
||||
{
|
||||
if (branch?.nextQuest != null && HasBranchCycle(branch.nextQuest, visited))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
visited.Remove(quest.questId); // 回溯
|
||||
return false;
|
||||
}
|
||||
|
||||
private void ValidateBranchDialogueKeys()
|
||||
@@ -113,14 +295,75 @@ namespace BaseGames.Quest
|
||||
[Serializable]
|
||||
public class QuestBranch
|
||||
{
|
||||
/// <summary>若此前置任务已完成 → 走本分支(null = 默认分支)。</summary>
|
||||
[Tooltip("条件任务:该任务已 Completed 时走本分支(留空 = 默认分支,总是满足)。")]
|
||||
public QuestSO conditionQuest;
|
||||
[Tooltip("世界状态标志条件判断逻辑:\n" +
|
||||
" And(默认)= 全部 conditionFlags 均满足才走本分支\n" +
|
||||
" Or = 任意一个 conditionFlag 满足即可走本分支")]
|
||||
public BaseGames.Core.WorldStateFlagLogic conditionFlagsLogic = BaseGames.Core.WorldStateFlagLogic.And;
|
||||
[Tooltip("世界状态标志条件(可选)。按 conditionFlagsLogic 逻辑与 conditionQuest 共同决定分支是否激活。\n" +
|
||||
"标志由 ISaveService.GetFlag(flagId) 查询,通常由 SetFlagAction 或其他系统写入。")]
|
||||
[WorldStateFlag]
|
||||
public string[] conditionFlags;
|
||||
[Tooltip("本分支解锁的后续任务。满足所有条件后,此任务将被设为 Available。")]
|
||||
public QuestSO nextQuest;
|
||||
/// <summary>完成后触发的 NPC 对话序列(直接引用,避免手写 sequenceId 字符串出错)。</summary>
|
||||
[Tooltip("完成本任务后触发的 NPC 对话序列(直接引用 DialogueSequenceSO 资产,无需手写 ID)。")]
|
||||
public DialogueSequenceSO npcDialogueSequence;
|
||||
|
||||
[System.Obsolete("已废弃,请改用 npcDialogueSequence(直接 SO 引用)。保留字段以兼容现有资产序列化。")]
|
||||
[HideInInspector]
|
||||
public string npcDialogueKey;
|
||||
}
|
||||
|
||||
/// <summary>任务分类,供日志 UI 分区和 DataHub 过滤使用。</summary>
|
||||
public enum QuestCategory
|
||||
{
|
||||
/// <summary>主线任务:必做,推动主剧情进展。</summary>
|
||||
Main,
|
||||
/// <summary>支线任务:可选,丰富世界观与 NPC 关系。</summary>
|
||||
Side,
|
||||
/// <summary>日常/重复任务:可在满足条件后重置。</summary>
|
||||
Daily,
|
||||
/// <summary>隐藏任务:不主动在日志中显示,由特定条件触发后才浮现。</summary>
|
||||
Hidden,
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// QuestPrerequisite ── 任务前置条件(统一配置结构)
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// 任务前置条件统一配置结构。
|
||||
/// 将旧版三个独立字段(prerequisiteQuests / prerequisiteFlags / prerequisiteFlagsLogic)
|
||||
/// 合并为单一可序列化类,便于 Inspector 统一管理与代码维护。
|
||||
/// 运行时通过 <see cref="HasAny"/> 判断是否启用新格式;若未配置则自动回退到旧版字段。
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class QuestPrerequisite
|
||||
{
|
||||
[Tooltip("所有前置任务必须处于 Completed 状态,本任务才能被接取。留空表示无前置任务限制。")]
|
||||
public QuestSO[] questDependencies;
|
||||
|
||||
[Tooltip("世界状态标志前置条件(支持 And / Or 逻辑)。")]
|
||||
public FlagCondition flagCondition;
|
||||
|
||||
/// <summary>此前置结构是否配置了任何条件(用于判断是否启用新格式,回退到旧字段)。</summary>
|
||||
public bool HasAny =>
|
||||
(questDependencies != null && questDependencies.Length > 0) ||
|
||||
(flagCondition.flags != null && flagCondition.flags.Length > 0);
|
||||
|
||||
/// <summary>
|
||||
/// 世界状态标志前置条件,支持 And(全部满足)或 Or(任一满足)逻辑。
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public struct FlagCondition
|
||||
{
|
||||
[Tooltip("标志逻辑模式:\n And(默认)= 全部标志均须为 true\n Or = 任意一个标志为 true 即可解锁")]
|
||||
public BaseGames.Core.WorldStateFlagLogic logic;
|
||||
|
||||
[Tooltip("前置世界状态标志 ID 列表。留空表示无标志前置限制。")]
|
||||
[BaseGames.Core.WorldStateFlag]
|
||||
public string[] flags;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using UnityEngine;
|
||||
using BaseGames.Core.Events;
|
||||
using BaseGames.Player;
|
||||
|
||||
namespace BaseGames.Quest
|
||||
{
|
||||
@@ -11,18 +12,42 @@ namespace BaseGames.Quest
|
||||
[CreateAssetMenu(menuName = "BaseGames/Quest/Reward")]
|
||||
public class RewardSO : ScriptableObject
|
||||
{
|
||||
public int lingZhu; // LingZhu 货币奖励
|
||||
public int soulBonus; // 灵魂槽扩展(+MaxSoulPower)
|
||||
public string[] itemIds; // 物品/护符 ID 列表(通过 InventoryManager 发放)
|
||||
public int affinityBonus; // 对发布 NPC 的好感度增量(存入 SaveData.World.NpcRelations)
|
||||
public string unlockDialogueKey; // 解锁 NPC 新台词集合 key(架构 §4)
|
||||
[Header("货币与属性")]
|
||||
[Tooltip("完成任务奖励的灵珠数量(0 = 不奖励)。")]
|
||||
public int lingZhu;
|
||||
|
||||
[Tooltip("是否解锁能力(AbilityType 无 None 值,用 bool 标识)")]
|
||||
public bool unlocksAbility; // ⚠️ AbilityType 无 None,用 bool 标识
|
||||
public uint unlockedAbilityFlag; // AbilityType 的 uint 位掩码值(仅当 unlocksAbility == true 有效)
|
||||
[Tooltip("完成任务后永久增加的灵魂槽上限数值(0 = 不奖励)。")]
|
||||
public int soulBonus;
|
||||
|
||||
[Header("物品")]
|
||||
[Tooltip("完成任务奖励的物品/护符 ID 列表。每个 ID 通过 EVT_CollectiblePickup 事件频道广播,由 InventoryManager 处理。\n" +
|
||||
"格式如 [\"Charm_DashBoost\", \"Item_HealShard\"]。留空表示无物品奖励。")]
|
||||
public string[] itemIds;
|
||||
|
||||
[Header("NPC 关系")]
|
||||
[Tooltip("完成任务后对 giverNpcId(QuestSO 中配置)的好感度增量。\n" +
|
||||
"正值=好感增加,负值=好感降低。0 = 不影响好感度。\n" +
|
||||
"增量以强类型 NpcAffinityEvent(npcId + delta + newTotal)广播至 EVT_NpcAffinityChanged,\n" +
|
||||
"并持久化到 SaveData.World.NpcRelations。接收方无需字符串解析。")]
|
||||
public int affinityBonus;
|
||||
|
||||
[Tooltip("完成任务后解锁的 NPC 台词集合 Key(格式如 \"DLG_Elder_PostQuest\")。\n" +
|
||||
"非空时广播 EVT_DialogueKeyUnlocked 事件,供 NPC 台词管理系统监听并切换对话集。\n" +
|
||||
"留空表示不解锁新台词。")]
|
||||
public string unlockDialogueKey;
|
||||
|
||||
[Header("能力解锁")]
|
||||
[Tooltip("勾选后,unlockedAbilityFlag 中指定的能力将在完成任务时解锁。")]
|
||||
public bool unlocksAbility;
|
||||
|
||||
[Tooltip("要解锁的能力(AbilityType 组合标志位,仅当 unlocksAbility == true 有效)。\n" +
|
||||
"可多选,支持组合解锁多项能力。")]
|
||||
public AbilityType unlockedAbilityFlag;
|
||||
|
||||
[Header("物品发放事件")]
|
||||
[Tooltip("EVT_CollectiblePickup:向 QuestManager/EquipmentManager 广播 itemId")]
|
||||
[Tooltip("EVT_CollectiblePickup 事件频道(StringEventChannelSO)。\n" +
|
||||
"Apply() 调用时,每个 itemId 都会通过此频道广播,供 EquipmentManager/QuestManager 处理。\n" +
|
||||
"未连线时物品奖励不生效。")]
|
||||
[SerializeField] private StringEventChannelSO _onCollectiblePickup;
|
||||
|
||||
/// <summary>
|
||||
@@ -35,14 +60,25 @@ namespace BaseGames.Quest
|
||||
|
||||
if (lingZhu > 0) target.AddLingZhu(lingZhu);
|
||||
if (soulBonus > 0) target.AddSoulPower(soulBonus);
|
||||
if (unlocksAbility && unlockedAbilityFlag != 0)
|
||||
target.UnlockAbilityFlag(unlockedAbilityFlag);
|
||||
if (unlocksAbility && unlockedAbilityFlag != AbilityType.None)
|
||||
target.UnlockAbilityFlag((uint)unlockedAbilityFlag);
|
||||
|
||||
// 通过 EVT_CollectiblePickup 事件频道广播每个物品 ID
|
||||
if (itemIds != null && _onCollectiblePickup != null)
|
||||
if (itemIds != null && itemIds.Length > 0)
|
||||
{
|
||||
foreach (var id in itemIds)
|
||||
_onCollectiblePickup.Raise(id);
|
||||
if (_onCollectiblePickup != null)
|
||||
{
|
||||
foreach (var id in itemIds)
|
||||
_onCollectiblePickup.Raise(id);
|
||||
}
|
||||
else
|
||||
{
|
||||
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
||||
Debug.LogWarning(
|
||||
$"[RewardSO] '{name}' 配置了 {itemIds.Length} 个 itemIds," +
|
||||
"但 _onCollectiblePickup 事件频道未连线,物品奖励不会发放。请在 Inspector 中指定 EVT_CollectiblePickup 频道。", this);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user