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

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

446 lines
20 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using 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;
}
}
}