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
}