- 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>
446 lines
20 KiB
C#
446 lines
20 KiB
C#
using System;
|
||
using System.Collections;
|
||
using BaseGames.Core;
|
||
using BaseGames.Core.Events;
|
||
using UnityEngine;
|
||
|
||
namespace BaseGames.EventChain
|
||
{
|
||
// =====================================================================
|
||
// EventChainSO —— 世界事件链数据(架构 14_NarrativeModule §9)
|
||
// =====================================================================
|
||
|
||
/// <summary>
|
||
/// 世界事件链:描述"当全部 conditions 满足时,依序执行 actions"。
|
||
/// 策划纯数据配置,无需程序员介入。
|
||
/// 资产路径:Assets/ScriptableObjects/EventChains/Chain_{Context}.asset
|
||
/// </summary>
|
||
[CreateAssetMenu(menuName = "BaseGames/EventChain/EventChain")]
|
||
public class EventChainSO : ScriptableObject
|
||
{
|
||
[Header("基础")]
|
||
public string chainId; // 全局唯一,如 "Chain_BossForest_Defeated"
|
||
public bool repeatable; // false = 只触发一次(触发后存档记录)
|
||
[Min(0f)]
|
||
public float actionDelay = 0f; // 各 action 之间的延迟(秒),0 = 紧接着执行
|
||
|
||
[Header("触发条件(全部满足才触发)")]
|
||
[Tooltip("旧版条件列表(隐式 And 逻辑)。新配置推荐改用 conditionGroups。\n" +
|
||
"conditionGroups 非空时,此字段被忽略。")]
|
||
public ChainCondition[] conditions;
|
||
|
||
[Tooltip("条件组列表(新版)。每组内部支持 And / Or 逻辑,多组之间为 And 关系(全部组满足才触发)。\n" +
|
||
"此字段非空时,旧版 conditions 字段将被忽略。")]
|
||
public ConditionGroup[] conditionGroups;
|
||
|
||
[Header("执行动作(顺序执行)")]
|
||
public ChainAction[] actions;
|
||
}
|
||
|
||
// =====================================================================
|
||
// ChainCondition 抽象基类 + 内置实现
|
||
// =====================================================================
|
||
|
||
/// <summary>
|
||
/// 事件链触发条件抽象基类(Strategy + Observer 混合)。
|
||
/// Register/Unregister 向 EventChainManager 的中继 C# 事件挂钩,
|
||
/// IsMet() 被 EvaluateAll() 调用以检验是否满足触发条件。
|
||
/// </summary>
|
||
public abstract class ChainCondition : ScriptableObject
|
||
{
|
||
public abstract void Register(EventChainManager manager);
|
||
public abstract void Unregister(EventChainManager manager);
|
||
public abstract bool IsMet();
|
||
|
||
/// <summary>
|
||
/// 重置运行时瞬态状态(每次 EventChainManager.OnEnable 时调用)。
|
||
/// ScriptableObject 是资产,_met 等字段会跨 PlayMode 会话残留;
|
||
/// 显式重置确保每次进入游戏/切换场景时条件均从初始状态开始评估。
|
||
/// </summary>
|
||
public virtual void ResetState() { }
|
||
|
||
/// <summary>
|
||
/// 声明此条件关心哪类运行时事件。
|
||
/// EventChainManager 在构建链桶时使用此掩码,使评估仅在相关事件到来时触发,
|
||
/// 跳过无关事件,降低 EvaluateAll 的无效迭代次数。
|
||
/// 默认返回 Any(适配自定义条件:任何事件均触发评估)。
|
||
/// </summary>
|
||
public virtual ChainEventMask RelevantEvents => ChainEventMask.Any;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 位掩码:标识事件链条件关心的运行时事件类别。
|
||
/// 用于 EventChainManager 构建懒评估桶,减少每次事件触发时的无关链扫描。
|
||
/// </summary>
|
||
[System.Flags]
|
||
public enum ChainEventMask
|
||
{
|
||
None = 0,
|
||
Boss = 1 << 0,
|
||
Collectible = 1 << 1,
|
||
Ability = 1 << 2,
|
||
Room = 1 << 3,
|
||
Dialogue = 1 << 4,
|
||
FlagChanged = 1 << 5,
|
||
Chain = 1 << 6,
|
||
/// <summary>不区分事件类别;任何事件均触发评估(自定义条件的默认值)。</summary>
|
||
Any = -1,
|
||
}
|
||
|
||
// ─── 内置条件 ──────────────────────────────────────────────────────────
|
||
|
||
[CreateAssetMenu(menuName = "BaseGames/EventChain/Condition/BossDefeated")]
|
||
public class BossDefeatedCondition : ChainCondition
|
||
{
|
||
public string bossId;
|
||
[System.NonSerialized] private bool _met;
|
||
public override void Register(EventChainManager m) => m.OnBossDefeated += Check;
|
||
public override void Unregister(EventChainManager m) => m.OnBossDefeated -= Check;
|
||
public override bool IsMet() => _met;
|
||
public override void ResetState() => _met = false;
|
||
public override ChainEventMask RelevantEvents => ChainEventMask.Boss;
|
||
private void Check(string id) { if (id == bossId) _met = true; }
|
||
}
|
||
|
||
[CreateAssetMenu(menuName = "BaseGames/EventChain/Condition/FlagSet")]
|
||
public class FlagSetCondition : ChainCondition
|
||
{
|
||
public string flagId;
|
||
[System.NonSerialized] private bool _met;
|
||
[System.NonSerialized] private bool _initialized; // 延迟初始化标记
|
||
|
||
public override void Register(EventChainManager m)
|
||
{
|
||
// 订阅事件;实际标志值延迟到首次 IsMet() 调用时从 SaveService 读取,
|
||
// 避免 Register 早于 SaveService 注册时读取失败
|
||
m.OnWorldFlagChanged += OnFlagChanged;
|
||
}
|
||
public override void Unregister(EventChainManager m)
|
||
{
|
||
m.OnWorldFlagChanged -= OnFlagChanged;
|
||
}
|
||
public override bool IsMet()
|
||
{
|
||
if (!_initialized)
|
||
{
|
||
var sm = ServiceLocator.GetOrDefault<ISaveService>();
|
||
if (sm != null)
|
||
{
|
||
_met = sm.GetFlag(flagId);
|
||
_initialized = true;
|
||
}
|
||
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
||
else
|
||
Debug.LogWarning(
|
||
$"[FlagSetCondition] 标志 '{flagId}' 首次 IsMet() 时 ISaveService 尚未注册," +
|
||
"返回 false(将在 SaveService 注册后通过 OnWorldFlagChanged 更新)。");
|
||
#endif
|
||
}
|
||
return _met;
|
||
}
|
||
public override void ResetState() { _met = false; _initialized = false; }
|
||
public override ChainEventMask RelevantEvents => ChainEventMask.FlagChanged;
|
||
|
||
private void OnFlagChanged(string changedFlagId)
|
||
{
|
||
if (changedFlagId != flagId) return;
|
||
var sm = ServiceLocator.GetOrDefault<ISaveService>();
|
||
if (sm != null) { _met = sm.GetFlag(flagId); _initialized = true; }
|
||
}
|
||
}
|
||
|
||
[CreateAssetMenu(menuName = "BaseGames/EventChain/Condition/AbilityUnlocked")]
|
||
public class AbilityUnlockedCondition : ChainCondition
|
||
{
|
||
public string abilityId;
|
||
[System.NonSerialized] private bool _met;
|
||
public override void Register(EventChainManager m) => m.OnAbilityUnlocked += Check;
|
||
public override void Unregister(EventChainManager m) => m.OnAbilityUnlocked -= Check;
|
||
public override bool IsMet() => _met;
|
||
public override void ResetState() => _met = false;
|
||
public override ChainEventMask RelevantEvents => ChainEventMask.Ability;
|
||
private void Check(string id) { if (id == abilityId) _met = true; }
|
||
}
|
||
|
||
[CreateAssetMenu(menuName = "BaseGames/EventChain/Condition/CollectibleCollected")]
|
||
public class CollectibleCollectedCondition : ChainCondition
|
||
{
|
||
public string itemId;
|
||
[System.NonSerialized] private bool _met;
|
||
public override void Register(EventChainManager m) => m.OnCollectiblePickedUp += Check;
|
||
public override void Unregister(EventChainManager m) => m.OnCollectiblePickedUp -= Check;
|
||
public override bool IsMet() => _met;
|
||
public override void ResetState() => _met = false;
|
||
public override ChainEventMask RelevantEvents => ChainEventMask.Collectible;
|
||
private void Check(string id) { if (id == itemId) _met = true; }
|
||
}
|
||
|
||
[CreateAssetMenu(menuName = "BaseGames/EventChain/Condition/RoomEntered")]
|
||
public class RoomEnteredCondition : ChainCondition
|
||
{
|
||
public string sceneName;
|
||
[System.NonSerialized] private bool _met;
|
||
public override void Register(EventChainManager m) => m.OnRoomEntered += Check;
|
||
public override void Unregister(EventChainManager m) => m.OnRoomEntered -= Check;
|
||
public override bool IsMet() => _met;
|
||
public override void ResetState() => _met = false;
|
||
public override ChainEventMask RelevantEvents => ChainEventMask.Room;
|
||
private void Check(string id) { if (id == sceneName) _met = true; }
|
||
}
|
||
|
||
[CreateAssetMenu(menuName = "BaseGames/EventChain/Condition/DialogueCompleted")]
|
||
public class DialogueCompletedCondition : ChainCondition
|
||
{
|
||
public string npcId;
|
||
[System.NonSerialized] private bool _met;
|
||
public override void Register(EventChainManager m) => m.OnDialogueCompleted += Check;
|
||
public override void Unregister(EventChainManager m) => m.OnDialogueCompleted -= Check;
|
||
public override bool IsMet() => _met;
|
||
public override void ResetState() => _met = false;
|
||
public override ChainEventMask RelevantEvents => ChainEventMask.Dialogue;
|
||
private void Check(string id) { if (id == npcId) _met = true; }
|
||
}
|
||
|
||
[CreateAssetMenu(menuName = "BaseGames/EventChain/Condition/ChainCompleted")]
|
||
public class ChainCompletedCondition : ChainCondition
|
||
{
|
||
public string chainId;
|
||
[System.NonSerialized] private bool _met;
|
||
public override void Register(EventChainManager m) => m.OnChainCompleted += Check;
|
||
public override void Unregister(EventChainManager m) => m.OnChainCompleted -= Check;
|
||
public override bool IsMet() => _met;
|
||
public override void ResetState() => _met = false;
|
||
public override ChainEventMask RelevantEvents => ChainEventMask.Chain;
|
||
private void Check(string id) { if (id == chainId) _met = true; }
|
||
}
|
||
|
||
// =====================================================================
|
||
// ConditionGroup ── 支持 And/Or 逻辑的条件分组
|
||
// =====================================================================
|
||
|
||
/// <summary>
|
||
/// 条件组:将多个 <see cref="ChainCondition"/> 以 And 或 Or 逻辑组合。
|
||
/// 多个条件组之间始终为 And 关系(所有组均须满足)。
|
||
/// 在 <see cref="EventChainSO.conditionGroups"/> 中配置,替代旧版隐式 And 的 <c>conditions[]</c>。
|
||
/// </summary>
|
||
[System.Serializable]
|
||
public class ConditionGroup
|
||
{
|
||
[Tooltip("组内条件的逻辑关系:\n And(默认)= 全部条件均须满足\n Or = 任意一个条件满足即可触发本组通过")]
|
||
public WorldStateFlagLogic logic = WorldStateFlagLogic.And;
|
||
|
||
[Tooltip("本组的条件列表。")]
|
||
public ChainCondition[] conditions;
|
||
}
|
||
|
||
// =====================================================================
|
||
// ChainAction 抽象基类 + 内置实现
|
||
// =====================================================================
|
||
|
||
/// <summary>
|
||
/// 事件链执行动作抽象基类。ExecuteAsync 可即时返回或协程等待。
|
||
/// </summary>
|
||
public abstract class ChainAction : ScriptableObject
|
||
{
|
||
public abstract IEnumerator ExecuteAsync(MonoBehaviour runner);
|
||
}
|
||
|
||
// ─── 内置动作 ──────────────────────────────────────────────────────────
|
||
|
||
/// <summary>打开门(发布 EVT_DoorOpened 事件)。</summary>
|
||
[CreateAssetMenu(menuName = "BaseGames/EventChain/Action/OpenDoor")]
|
||
public class OpenDoorAction : ChainAction
|
||
{
|
||
public string doorId;
|
||
[SerializeField] private StringEventChannelSO _onDoorOpened; // EVT_DoorOpened
|
||
public override IEnumerator ExecuteAsync(MonoBehaviour runner)
|
||
{
|
||
_onDoorOpened?.Raise(doorId);
|
||
yield break;
|
||
}
|
||
}
|
||
|
||
/// <summary>设置/清除存档标志。设置后通知 EventChainManager 触发条件重评估。</summary>
|
||
[CreateAssetMenu(menuName = "BaseGames/EventChain/Action/SetFlag")]
|
||
public class SetFlagAction : ChainAction
|
||
{
|
||
[Tooltip("世界状态标志 Key(字符串),由 ISaveService 持久化。")]
|
||
public string flagId;
|
||
public bool value = true;
|
||
|
||
#if UNITY_EDITOR
|
||
private void OnValidate()
|
||
{
|
||
if (string.IsNullOrWhiteSpace(flagId))
|
||
Debug.LogWarning(
|
||
$"[SetFlagAction] '{name}': flagId 为空,执行时将静默失败。请填写有效的 flagId。", this);
|
||
}
|
||
#endif
|
||
public override IEnumerator ExecuteAsync(MonoBehaviour runner)
|
||
{
|
||
var saveService = ServiceLocator.GetOrDefault<ISaveService>();
|
||
if (saveService == null)
|
||
{
|
||
Debug.LogError($"[SetFlagAction] ISaveService 未注册,标志 '{flagId}' 无法持久化。", this);
|
||
yield break;
|
||
}
|
||
saveService.SetFlag(flagId, value);
|
||
|
||
// 通知 EventChainManager 标志已变更,触发 FlagSetCondition 重新评估(事件驱动,无需轮询)
|
||
if (runner is EventChainManager ecm)
|
||
ecm.NotifyFlagChanged(flagId);
|
||
|
||
yield break;
|
||
}
|
||
}
|
||
|
||
/// <summary>通知地图显示/标记区域(通过事件频道解耦,无直接 MapManager 依赖)。</summary>
|
||
[CreateAssetMenu(menuName = "BaseGames/EventChain/Action/UpdateMap")]
|
||
public class UpdateMapAction : ChainAction
|
||
{
|
||
public string regionId;
|
||
[SerializeField] private StringEventChannelSO _onRevealRegion; // EVT_RevealRegion → MapManager 订阅
|
||
public override IEnumerator ExecuteAsync(MonoBehaviour runner)
|
||
{
|
||
_onRevealRegion?.Raise(regionId);
|
||
yield break;
|
||
}
|
||
}
|
||
|
||
/// <summary>播放过场动画,等待其结束再继续链。</summary>
|
||
[CreateAssetMenu(menuName = "BaseGames/EventChain/Action/PlayCutscene")]
|
||
public class PlayCutsceneAction : ChainAction
|
||
{
|
||
public string cutsceneId;
|
||
[SerializeField] private StringEventChannelSO _onPlayCutscene; // → CutsceneManager.PlayById
|
||
[SerializeField] private VoidEventChannelSO _onCutsceneEnded; // ← CutsceneManager 播完后 Raise
|
||
[Tooltip("等待过场动画结束的超时时间(秒)。超时后记录错误并继续执行事件链,避免链永久挂起。\n0 = 永不超时(不推荐,可能导致链死锁)。")]
|
||
[Min(0f)] [SerializeField] private float timeoutSeconds = 60f;
|
||
|
||
public override IEnumerator ExecuteAsync(MonoBehaviour runner)
|
||
{
|
||
bool done = false;
|
||
// 用 try/finally 确保即使协程被强制停止(StopAllCoroutines)时也能正确退订
|
||
var sub = _onCutsceneEnded != null
|
||
? _onCutsceneEnded.Subscribe(() => done = true)
|
||
: default(EventSubscription);
|
||
try
|
||
{
|
||
_onPlayCutscene?.Raise(cutsceneId);
|
||
|
||
if (timeoutSeconds > 0f)
|
||
{
|
||
// 超时保护:防止 CutsceneManager 未触发 Ended 事件导致链永久挂起
|
||
float elapsed = 0f;
|
||
while (!done && elapsed < timeoutSeconds)
|
||
{
|
||
elapsed += UnityEngine.Time.deltaTime;
|
||
yield return null;
|
||
}
|
||
if (!done)
|
||
Debug.LogError(
|
||
$"[PlayCutsceneAction] 过场动画 '{cutsceneId}' 等待超时({timeoutSeconds}s)。" +
|
||
"请确认 CutsceneManager 在动画结束后调用了 _onCutsceneEnded.Raise()。");
|
||
}
|
||
else
|
||
{
|
||
yield return new WaitUntil(() => done);
|
||
}
|
||
}
|
||
finally
|
||
{
|
||
sub.Dispose();
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>切换 NPC 对话(通过 EVT_NPCDialogueChange 强类型事件广播,NPC 自行响应)。</summary>
|
||
[CreateAssetMenu(menuName = "BaseGames/EventChain/Action/ChangeNPCDialogue")]
|
||
public class ChangeNPCDialogueAction : ChainAction
|
||
{
|
||
[Tooltip("目标 NPC 的唯一 ID(对应 NpcSO.npcId)。")]
|
||
public string npcId;
|
||
[Tooltip("要切换到的对话序列 ID(对应 DialogueSequenceSO.sequenceId)。")]
|
||
public string newSequenceId;
|
||
/// <summary>
|
||
/// 强类型事件频道(NpcDialogueChangeEventChannelSO)。
|
||
/// NPC 组件订阅后根据 npcId 字段过滤,无需 Split 字符串。
|
||
/// 资产:Assets/ScriptableObjects/Events/EVT_NpcDialogueChange.asset
|
||
/// </summary>
|
||
[SerializeField] private BaseGames.Core.Events.NpcDialogueChangeEventChannelSO _onNPCDialogueChange;
|
||
public override IEnumerator ExecuteAsync(MonoBehaviour runner)
|
||
{
|
||
_onNPCDialogueChange?.Raise(new BaseGames.Core.Events.NpcDialogueChangeEvent
|
||
{
|
||
npcId = npcId,
|
||
newSequenceId = newSequenceId
|
||
});
|
||
yield break;
|
||
}
|
||
}
|
||
|
||
/// <summary>在场景中生成一个预制体。</summary>
|
||
[CreateAssetMenu(menuName = "BaseGames/EventChain/Action/SpawnObject")]
|
||
public class SpawnObjectAction : ChainAction
|
||
{
|
||
public GameObject prefab;
|
||
public Vector3 position;
|
||
public override IEnumerator ExecuteAsync(MonoBehaviour runner)
|
||
{
|
||
if (prefab != null)
|
||
UnityEngine.Object.Instantiate(prefab, position, Quaternion.identity);
|
||
yield break;
|
||
}
|
||
}
|
||
|
||
/// <summary>等待指定秒数。</summary>
|
||
[CreateAssetMenu(menuName = "BaseGames/EventChain/Action/Wait")]
|
||
public class WaitAction : ChainAction
|
||
{
|
||
[Min(0f)]
|
||
public float seconds;
|
||
public override IEnumerator ExecuteAsync(MonoBehaviour runner)
|
||
{
|
||
yield return new WaitForSeconds(seconds);
|
||
}
|
||
}
|
||
|
||
/// <summary>发布一个无负载 Void 事件频道。</summary>
|
||
[CreateAssetMenu(menuName = "BaseGames/EventChain/Action/RaiseEvent")]
|
||
public class RaiseEventAction : ChainAction
|
||
{
|
||
[SerializeField] private VoidEventChannelSO _eventChannel;
|
||
public override IEnumerator ExecuteAsync(MonoBehaviour runner)
|
||
{
|
||
_eventChannel?.Raise();
|
||
yield break;
|
||
}
|
||
}
|
||
|
||
/// <summary>解锁能力(通过 EVT_AbilityUnlocked 事件广播,PlayerStats 响应)。</summary>
|
||
[CreateAssetMenu(menuName = "BaseGames/EventChain/Action/UnlockAbility")]
|
||
public class UnlockAbilityAction : ChainAction
|
||
{
|
||
public string abilityId;
|
||
[SerializeField] private StringEventChannelSO _onAbilityUnlock; // EVT_AbilityUnlocked
|
||
public override IEnumerator ExecuteAsync(MonoBehaviour runner)
|
||
{
|
||
_onAbilityUnlock?.Raise(abilityId);
|
||
yield break;
|
||
}
|
||
}
|
||
|
||
/// <summary>切换背景音乐(通过 EVT_PlayBGM 事件广播)。</summary>
|
||
[CreateAssetMenu(menuName = "BaseGames/EventChain/Action/PlayAudio")]
|
||
public class PlayAudioAction : ChainAction
|
||
{
|
||
public string bgmKey;
|
||
[SerializeField] private StringEventChannelSO _onPlayBGM; // EVT_PlayBGM
|
||
public override IEnumerator ExecuteAsync(MonoBehaviour runner)
|
||
{
|
||
_onPlayBGM?.Raise(bgmKey);
|
||
yield break;
|
||
}
|
||
}
|
||
}
|