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:
2026-05-25 00:05:15 +08:00
parent 446fd5dcd0
commit 6eaa83dc71
72 changed files with 7080 additions and 373 deletions

View File

@@ -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&lt;IQuestManager&gt;() 获取实例,
/// 避免外部代码直接依赖 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&lt;IQuestManager&gt;() 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
}

View 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> { }
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: df5e857463388a249893d48dda71c54b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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_EnemyDiedpayload = enemyIdstring。敌人死亡时由战斗系统广播驱动击败类目标进度。")]
public StringEventChannelSO onEnemyDied;
[Tooltip("EVT_CollectiblePickuppayload = itemIdstring。拾取物品时广播驱动收集类目标进度。")]
public StringEventChannelSO onCollectiblePickup;
[Tooltip("EVT_SceneLoadedpayload = sceneNamestring。场景切换完成时广播驱动到达类目标进度。")]
public StringEventChannelSO onSceneLoaded;
[Tooltip("EVT_NpcDialogueCompletedpayload = npcIdstring。DialogueManager 播完一段对话后广播,驱动对话类目标进度。")]
public StringEventChannelSO onNpcDialogueCompleted;
[Tooltip("EVT_SkillUsedpayload = AbilityType.ToString()string。玩家使用技能时广播驱动技能使用类目标进度。")]
public StringEventChannelSO onSkillUsed;
[Tooltip("EVT_AreaReachedpayload = markerTagstring。TriggerZone 在玩家进入时广播,驱动区域到达类目标进度。")]
public StringEventChannelSO onAreaReached;
[Header("广播频道QuestManager 广播)")]
[Tooltip("EVT_QuestStartedpayload = questId。AcceptQuest 成功后广播。")]
public StringEventChannelSO onQuestStarted;
[Tooltip("EVT_QuestCompletedpayload = questId。CompleteQuest 成功后广播。")]
public StringEventChannelSO onQuestCompleted;
[Tooltip("EVT_QuestFailedpayload = questId。失败条件触发后广播。")]
public StringEventChannelSO onQuestFailed;
[Tooltip("EVT_QuestAbandonedpayload = questId。玩家放弃任务时广播。")]
public StringEventChannelSO onQuestAbandoned;
[Tooltip("EVT_QuestPausedpayload = questId。PauseQuest 成功后广播。")]
public StringEventChannelSO onQuestPaused;
[Tooltip("EVT_QuestResumedpayload = questId。ResumeQuest 成功后广播。")]
public StringEventChannelSO onQuestResumed;
[Tooltip("EVT_QuestReadyToCompletepayload = questId。目标全部达成时广播一次去重。")]
public StringEventChannelSO onQuestReadyToComplete;
[Tooltip("EVT_QuestObjectiveUpdated目标进度变化时广播强类型 QuestObjectiveEvent。")]
public QuestObjectiveEventChannelSO onObjectiveUpdated;
[Tooltip("EVT_QuestObjectiveBatchUpdated同帧内多目标聚合后广播一次避免 HUD 同帧多次重绘)。")]
public QuestObjectiveBatchEventChannelSO onObjectiveBatchUpdated;
[Tooltip("EVT_NpcAffinityChangedNPC 好感度变化(强类型 NpcAffinityEvent。")]
public NpcAffinityEventChannelSO onNpcAffinityChanged;
[Tooltip("EVT_DialogueKeyUnlockedpayload = unlockDialogueKey供 NPC 台词系统监听。")]
public StringEventChannelSO onDialogueKeyUnlocked;
[Tooltip("EVT_DialogueChoiceSelected玩家选择对话选项时广播payload = \"sequenceId/choiceIndex\")。\n" +
"供 QA 埋点、成就系统、或数据分析监听,以还原玩家的对话选择路径。")]
public StringEventChannelSO onDialogueChoiceSelected;
}
}

View File

@@ -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

View File

@@ -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.markerTagstring
/// 与 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 广播 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>
@@ -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;
}
}

View File

@@ -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("已废弃,请改用 giverNpcNpcSO 直接引用)。保留以兼容现有资产序列化。")]
[HideInInspector]
public string giverNpcId;
/// <summary>运行时使用的 NPC IDgiverNpc 优先,回退到旧字段 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 完成解锁 BB 完成解锁 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;
}
}
}

View File

@@ -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("完成任务后对 giverNpcIdQuestSO 中配置)的好感度增量。\n" +
"正值=好感增加,负值=好感降低。0 = 不影响好感度。\n" +
"增量以强类型 NpcAffinityEventnpcId + 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
}
}
}
}