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:
8
Assets/_Game/Data/Dialogue.meta
Normal file
8
Assets/_Game/Data/Dialogue.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ad561a9e6beaaf04aa0aae9ea4cc7840
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
17
Assets/_Game/Data/Dialogue/DLG_New.asset
Normal file
17
Assets/_Game/Data/Dialogue/DLG_New.asset
Normal file
@@ -0,0 +1,17 @@
|
||||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!114 &11400000
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 037a9d55368dde649ac6c1c6a1e80dad, type: 3}
|
||||
m_Name: DLG_New
|
||||
m_EditorClassIdentifier:
|
||||
sequenceId:
|
||||
lines: []
|
||||
variants: []
|
||||
8
Assets/_Game/Data/Dialogue/DLG_New.asset.meta
Normal file
8
Assets/_Game/Data/Dialogue/DLG_New.asset.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 544a0224ccca01d45b8cd8c543b73d06
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -21339,6 +21339,7 @@ GameObject:
|
||||
- component: {fileID: 1354690328}
|
||||
- component: {fileID: 1354690327}
|
||||
- component: {fileID: 1354690326}
|
||||
- component: {fileID: 1354690329}
|
||||
m_Layer: 8
|
||||
m_Name: Square (1)
|
||||
m_TagString: Untagged
|
||||
@@ -21458,6 +21459,32 @@ Transform:
|
||||
m_Children: []
|
||||
m_Father: {fileID: 0}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
--- !u!114 &1354690329
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 1354690325}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 87d47b3e0cb42914b8b2ae885bebf30b, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
costOverride: -1
|
||||
linkType: 1
|
||||
clearance: 2
|
||||
navTag: 0
|
||||
avgWaitTime: 0
|
||||
maxTraversableDistance: 0
|
||||
autoMap: 1
|
||||
start: {x: -2, y: 0}
|
||||
goal: {x: 2, y: 0}
|
||||
isBidirectional: 1
|
||||
visualizationType: 5
|
||||
traversalAngle: 0
|
||||
horizontalSpeed: 1
|
||||
bezierControlPoint: {x: 0, y: 3}
|
||||
--- !u!1 &1357827205
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Core.Events
|
||||
{
|
||||
/// <summary>
|
||||
/// NPC 对话切换事件负载(强类型,替代 "{npcId}:{sequenceId}" 字符串拼接)。
|
||||
/// </summary>
|
||||
public struct NpcDialogueChangeEvent
|
||||
{
|
||||
/// <summary>NPC 的唯一 ID(对应 NpcSO.npcId)。</summary>
|
||||
public string npcId;
|
||||
|
||||
/// <summary>要切换到的对话序列 ID(对应 DialogueSequenceSO.sequenceId)。</summary>
|
||||
public string newSequenceId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// NPC 对话切换事件频道。
|
||||
/// 由 ChangeNPCDialogueAction 在事件链中触发;NPC 组件订阅后根据自身 npcId 过滤处理。
|
||||
/// 资产路径建议:Assets/ScriptableObjects/Events/EVT_NpcDialogueChange.asset
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "BaseGames/Events/NpcDialogueChange")]
|
||||
public class NpcDialogueChangeEventChannelSO : BaseEventChannelSO<NpcDialogueChangeEvent> { }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 111b5e123d3c3bc4ab5114666d8d2641
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -9,7 +9,9 @@ namespace BaseGames.Core.Events
|
||||
Available = 1,
|
||||
Active = 2,
|
||||
Completed = 3,
|
||||
Failed = 4
|
||||
Failed = 4,
|
||||
/// <summary>任务已接取但被暂停(如剧情锁定),不推进目标,不触发失败判定。</summary>
|
||||
Paused = 5,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -23,7 +25,7 @@ namespace BaseGames.Core.Events
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 任务目标进度事件负载。
|
||||
/// 任务目标进度事件负载(单目标)。
|
||||
/// </summary>
|
||||
[System.Serializable]
|
||||
public struct QuestObjectiveEvent
|
||||
@@ -33,4 +35,17 @@ namespace BaseGames.Core.Events
|
||||
public int Progress;
|
||||
public int Required;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 同帧内某任务多个目标同时更新时的批量事件负载。
|
||||
/// 订阅此事件可在一帧内一次性处理同任务的所有目标变更,避免 UI 多次重绘。
|
||||
/// </summary>
|
||||
[System.Serializable]
|
||||
public struct QuestObjectiveBatchEvent
|
||||
{
|
||||
/// <summary>发生目标进度变更的任务 ID。</summary>
|
||||
public string QuestId;
|
||||
/// <summary>本帧内该任务所有更新过的单目标事件列表(至少 1 个)。</summary>
|
||||
public System.Collections.Generic.List<QuestObjectiveEvent> Updates;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,4 +4,13 @@ namespace BaseGames.Core.Events
|
||||
{
|
||||
[CreateAssetMenu(menuName = "BaseGames/Events/QuestObjective")]
|
||||
public class QuestObjectiveEventChannelSO : BaseEventChannelSO<QuestObjectiveEvent> { }
|
||||
|
||||
/// <summary>
|
||||
/// 批量任务目标进度事件频道。
|
||||
/// 同帧内同一任务多个目标同时更新时,聚合为一次广播,
|
||||
/// 供 UI 侧监听以避免同帧多次重绘任务追踪 HUD。
|
||||
/// 资产路径建议:Assets/ScriptableObjects/Events/EVT_QuestObjectiveBatchUpdated.asset
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "BaseGames/Events/QuestObjectiveBatch")]
|
||||
public class QuestObjectiveBatchEventChannelSO : BaseEventChannelSO<QuestObjectiveBatchEvent> { }
|
||||
}
|
||||
|
||||
12
Assets/_Game/Scripts/Core/IWorldStateReader.cs
Normal file
12
Assets/_Game/Scripts/Core/IWorldStateReader.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace BaseGames.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// 只读世界状态查询接口(用于对话版本条件判断)。
|
||||
/// 解耦 NarrativeNPC / DialogueVersion 对 WorldStateRegistry 具体类型的直接依赖。
|
||||
/// </summary>
|
||||
public interface IWorldStateReader
|
||||
{
|
||||
/// <summary>检查指定 Flag 是否已设置。</summary>
|
||||
bool HasFlag(string key);
|
||||
}
|
||||
}
|
||||
@@ -134,8 +134,19 @@ namespace BaseGames.Core.Save
|
||||
[Serializable]
|
||||
public class QuestState
|
||||
{
|
||||
/// <summary>
|
||||
/// 此 QuestState 数据格式版本号。
|
||||
/// 1 = 原始格式(ProgressCounts 按索引,已弃用)
|
||||
/// 2 = Round 24+ 格式(ObjectiveProgress 按 objectiveId 键值对)
|
||||
/// OnLoad 按此字段显式选择解析路径,杜绝依赖 Count > 0 的隐式推断。
|
||||
/// </summary>
|
||||
public int DataVersion = 2;
|
||||
public string Status; // "NotStarted"|"Active"|"Completed"|"Failed"
|
||||
public int ObjectiveIndex;
|
||||
/// <summary>新格式(Round 24+,DataVersion=2):objectiveId → progressCount,重排目标顺序后存档不会错位。</summary>
|
||||
public Dictionary<string, int> ObjectiveProgress = new();
|
||||
/// <summary>旧格式(按数组索引,DataVersion=1):仅用于迁移旧版存档,新存档不再写入。已弃用,将在后续版本移除。</summary>
|
||||
[System.Obsolete("旧格式存档兼容字段,仅供 OnLoad DataVersion=1 迁移使用。新存档改用 ObjectiveProgress(objectiveId 键值对)。")]
|
||||
public List<int> ProgressCounts = new();
|
||||
public string GiverNpcId;
|
||||
}
|
||||
|
||||
74
Assets/_Game/Scripts/Core/WorldFlagRegistrySO.cs
Normal file
74
Assets/_Game/Scripts/Core/WorldFlagRegistrySO.cs
Normal file
@@ -0,0 +1,74 @@
|
||||
using UnityEngine;
|
||||
#if UNITY_EDITOR
|
||||
using UnityEditor;
|
||||
#endif
|
||||
|
||||
namespace BaseGames.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// 单个世界状态标志的定义条目。
|
||||
/// </summary>
|
||||
[System.Serializable]
|
||||
public class FlagEntry
|
||||
{
|
||||
[Tooltip("标志唯一 ID,与 SetFlagAction / FlagSetCondition 中填写的字符串完全一致。")]
|
||||
public string id;
|
||||
|
||||
[Tooltip("描述该标志代表的游戏事件或状态(仅供编辑器参考,运行时不使用)。")]
|
||||
public string description;
|
||||
|
||||
[Tooltip("下拉菜单中的分组路径,使用 '/' 分隔层级,例如 '剧情/Boss'。留空则不分组。")]
|
||||
public string group;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 世界状态标志注册表 —— 统一维护项目中所有合法的世界标志 ID、描述和分组。
|
||||
/// 在 Inspector 中为 [WorldStateFlag] 属性提供下拉补全,减少手输错误。
|
||||
/// 创建方式:Project 右键 → Create / BaseGames / Core / WorldFlagRegistry
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "BaseGames/Core/WorldFlagRegistry", fileName = "WorldFlagRegistry")]
|
||||
public class WorldFlagRegistrySO : ScriptableObject
|
||||
{
|
||||
[Tooltip("所有合法的世界状态标志定义。由策划/程序在此集中维护。")]
|
||||
public FlagEntry[] flags;
|
||||
|
||||
#if UNITY_EDITOR
|
||||
private static WorldFlagRegistrySO _editorInstance;
|
||||
private static double _editorInstanceExpiry;
|
||||
|
||||
/// <summary>
|
||||
/// 编辑器下 30 秒缓存的单例引用(扫描 AssetDatabase 得到)。
|
||||
/// 运行时不可用,请在 UNITY_EDITOR 条件块中调用。
|
||||
/// </summary>
|
||||
public static WorldFlagRegistrySO EditorInstance
|
||||
{
|
||||
get
|
||||
{
|
||||
double now = EditorApplication.timeSinceStartup;
|
||||
if (_editorInstance != null && now < _editorInstanceExpiry)
|
||||
return _editorInstance;
|
||||
|
||||
var guids = AssetDatabase.FindAssets("t:WorldFlagRegistrySO");
|
||||
if (guids.Length == 0)
|
||||
{
|
||||
_editorInstance = null;
|
||||
_editorInstanceExpiry = now + 30.0;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (guids.Length > 1)
|
||||
Debug.LogWarning($"[WorldFlagRegistrySO] 发现 {guids.Length} 个 WorldFlagRegistry.asset," +
|
||||
"将使用第一个。建议项目中只保留一个。");
|
||||
|
||||
string path = AssetDatabase.GUIDToAssetPath(guids[0]);
|
||||
_editorInstance = AssetDatabase.LoadAssetAtPath<WorldFlagRegistrySO>(path);
|
||||
_editorInstanceExpiry = now + 30.0;
|
||||
return _editorInstance;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>强制下次访问 EditorInstance 时重新扫描 AssetDatabase。</summary>
|
||||
public static void InvalidateEditorCache() => _editorInstance = null;
|
||||
#endif
|
||||
}
|
||||
}
|
||||
22
Assets/_Game/Scripts/Core/WorldStateFlagAttribute.cs
Normal file
22
Assets/_Game/Scripts/Core/WorldStateFlagAttribute.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// 标记一个 string 或 string[] 字段为世界状态标志 Key。
|
||||
/// 在 Inspector 中会显示已知标志下拉菜单,支持直接输入新标志。
|
||||
/// 定义于 BaseGames.Core,可被 Dialogue / Quest / EventChain 等模块无耦合使用。
|
||||
/// </summary>
|
||||
public sealed class WorldStateFlagAttribute : PropertyAttribute { }
|
||||
|
||||
/// <summary>
|
||||
/// 世界状态标志的逻辑组合模式,供 Dialogue 条件变体和 Quest 分支条件共用。
|
||||
/// </summary>
|
||||
public enum WorldStateFlagLogic
|
||||
{
|
||||
/// <summary>全部 requiredFlags 均满足时条件成立(默认,向后兼容)。</summary>
|
||||
And,
|
||||
/// <summary>任意一个 requiredFlag 满足即可使条件成立。</summary>
|
||||
Or,
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/Core/WorldStateFlagAttribute.cs.meta
Normal file
11
Assets/_Game/Scripts/Core/WorldStateFlagAttribute.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e10b4c60cc9052f4e83381ceb09424a3
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
11
Assets/_Game/Scripts/Dialogue/DialogueActorSO.cs.meta
Normal file
11
Assets/_Game/Scripts/Dialogue/DialogueActorSO.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c4ef7fae4d515f649bc8e5f51ad9510b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -15,18 +15,61 @@ namespace BaseGames.Dialogue
|
||||
/// </summary>
|
||||
public class DialogueManager : MonoBehaviour, IDialogueService
|
||||
{
|
||||
[Header("依赖")]
|
||||
[Tooltip("对话 UI 组件。负责打字机效果、头像/说话人渲染、显示隐藏。")]
|
||||
[SerializeField] private DialogueUI _dialogueBox;
|
||||
[Tooltip("输入读取器 SO。监听 SubmitEvent(确认/跳过)推进对话行。")]
|
||||
[SerializeField] private InputReaderSO _inputReader;
|
||||
[Tooltip("世界状态注册表 SO。对话序列 variants 条件分支据此读取标志位。")]
|
||||
[SerializeField] private WorldStateRegistry _worldState;
|
||||
|
||||
[Header("事件频道")]
|
||||
[Tooltip("EVT_DialogueStarted:对话序列开始时广播(无 payload)。供输入系统切换 Action Map 至 UI 模式等监听。")]
|
||||
[SerializeField] private VoidEventChannelSO _onDialogueStarted;
|
||||
[Tooltip("EVT_DialogueEnded:对话序列全部行播完后广播(无 payload)。\n" +
|
||||
"【重要】输入系统应监听此事件切回 Gameplay Action Map;\n" +
|
||||
"若未连接此频道,DialogueManager 会直接调用 InputReader.EnableGameplayInput() 作为兜底。")]
|
||||
[SerializeField] private VoidEventChannelSO _onDialogueEnded;
|
||||
[SerializeField] private StringEventChannelSO _onNpcDialogueCompleted; // → EVT_NpcDialogueCompleted (npcId)
|
||||
[SerializeField] private VoidEventChannelSO _onLineStarted; // 每行开始打字时广播
|
||||
[SerializeField] private VoidEventChannelSO _onLineEnded; // 每行玩家确认后广播
|
||||
[Tooltip("EVT_NpcDialogueCompleted:payload = npcId(string)。每段对话结束时广播,驱动 QuestManager 中对话类目标进度。")]
|
||||
[SerializeField] private StringEventChannelSO _onNpcDialogueCompleted;
|
||||
[Tooltip("EVT_LineStarted:每行对话开始打字时广播(无 payload)。供音效/震动/打字音效系统监听。")]
|
||||
[SerializeField] private VoidEventChannelSO _onLineStarted;
|
||||
[Tooltip("EVT_LineEnded:玩家按确认键推进到下一行时广播(无 payload)。供音效/震动系统监听。")]
|
||||
[SerializeField] private VoidEventChannelSO _onLineEnded;
|
||||
[Tooltip("EVT_DialogueForceEnded:对话序列因超时被强制终止时广播(payload = npcId)。\n" +
|
||||
"供埋点/异常追踪系统监听,以区分正常结束和超时强制中断。\n" +
|
||||
"可选字段,留空则不广播此专用事件(ForceEnd 仍正常执行)。")]
|
||||
[SerializeField] private StringEventChannelSO _onDialogueForceEnded;
|
||||
[Tooltip("EVT_DialogueChoiceSelected:玩家选择对话选项时广播(payload = \"sequenceId/choiceIndex\")。\n" +
|
||||
"供 QA 埋点、成就系统、或数据分析监听,以还原玩家的对话选择路径。\n" +
|
||||
"可选字段,留空则不广播。")]
|
||||
[SerializeField] private StringEventChannelSO _onDialogueChoiceSelected;
|
||||
|
||||
[Header("运行时限制")]
|
||||
[Tooltip("分支选项最大嵌套深度。超过此深度触发循环引用保护,跳过当前分支继续播放。\n" +
|
||||
"普通对话通常不超过 6 层;极端场景可调高,但推荐保持默认值 16。")]
|
||||
[Min(1)] [SerializeField] private int _maxChoiceDepth = 16;
|
||||
[Tooltip("待播对话序列队列容量上限。超过后新请求将被丢弃并记录警告。\n" +
|
||||
"用于防止事件链或脚本误触导致无限排队。")]
|
||||
[Min(1)] [SerializeField] private int _pendingQueueCapacity = 8;
|
||||
[Tooltip("单段对话序列的最长播放时间(秒)。超时后强制结束当前序列,防止异常卡死。\n" +
|
||||
"0 = 不限时(不推荐用于正式发布)。推荐 300s(5 分钟)覆盖最长剧情段落。")]
|
||||
[Min(0)] [SerializeField] private float _sequenceTimeoutSeconds = 300f;
|
||||
|
||||
private bool _skipRequested;
|
||||
private int _selectedChoiceIndex = -1;
|
||||
private int _choiceDepth;
|
||||
/// <summary>
|
||||
/// 每次 PlayImmediate 递增。HandleChoices 的选项回调在写入 _selectedChoiceIndex 前
|
||||
/// 比对此值,确保打断后遗留的回调不会污染新序列的状态。
|
||||
/// </summary>
|
||||
private int _playbackId;
|
||||
|
||||
// ── 子协程通信字段(避免协程间 ref/out 参数)─────────────────────────
|
||||
/// <summary>HandleChoices 子协程写入结果:玩家选中选项后的后续序列(null = 无后续)。</summary>
|
||||
private DialogueSequenceSO _choiceBranchResult;
|
||||
/// <summary>HandleChoices 子协程写入结果:true = 分支深度超限,优雅降级(继续播放后续行)。</summary>
|
||||
private bool _branchDepthExceeded;
|
||||
|
||||
// ── 复用 Yield 指令,避免协程每行 new WaitUntil 闭包 ───────────────
|
||||
private sealed class WaitTypingOrSkip : CustomYieldInstruction
|
||||
@@ -41,21 +84,34 @@ namespace BaseGames.Dialogue
|
||||
public WaitSkip(DialogueManager m) => _m = m;
|
||||
public override bool keepWaiting => !_m._skipRequested;
|
||||
}
|
||||
// 等待玩家从分支选项中做出选择(_selectedChoiceIndex >= 0 时解除阻塞)
|
||||
private sealed class WaitForChoice : CustomYieldInstruction
|
||||
{
|
||||
private readonly DialogueManager _m;
|
||||
public WaitForChoice(DialogueManager m) => _m = m;
|
||||
public override bool keepWaiting => _m._selectedChoiceIndex < 0;
|
||||
}
|
||||
|
||||
private WaitTypingOrSkip _waitTypingOrSkip;
|
||||
private WaitSkip _waitSkip;
|
||||
private WaitForChoice _waitForChoice;
|
||||
|
||||
/// <summary>
|
||||
/// 当 IsDialogueActive 时排队等待的对话请求。
|
||||
/// 支持脚本触发的连续对话序列(如剧情链、事件链触发的对话),
|
||||
/// 但容量上限为 8,避免因误触导致无限排队。
|
||||
/// </summary>
|
||||
private readonly Queue<(DialogueSequenceSO seq, string npcId)> _pending = new();
|
||||
private const int PendingCapacity = 8;
|
||||
private readonly Queue<(DialogueSequenceSO seq, string npcId, int priority)> _pending = new();
|
||||
|
||||
/// <summary>当前是否有对话正在播放。</summary>
|
||||
public bool IsDialogueActive { get; private set; }
|
||||
|
||||
/// <summary>当前正在播放的对话优先级(0 = 默认)。高优先级请求可打断低优先级。</summary>
|
||||
private int _currentPriority;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public event System.Action OnDialogueEnded;
|
||||
|
||||
// ── 生命周期 ──────────────────────────────────────────────────────
|
||||
|
||||
private void Awake()
|
||||
@@ -64,6 +120,7 @@ namespace BaseGames.Dialogue
|
||||
ServiceLocator.Register<IDialogueService>(this);
|
||||
_waitTypingOrSkip = new WaitTypingOrSkip(this);
|
||||
_waitSkip = new WaitSkip(this);
|
||||
_waitForChoice = new WaitForChoice(this);
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
@@ -79,48 +136,109 @@ namespace BaseGames.Dialogue
|
||||
private void OnDisable()
|
||||
{
|
||||
if (_inputReader != null) _inputReader.SubmitEvent -= OnSubmit;
|
||||
_pending.Clear();
|
||||
|
||||
// 若对话协程在组件禁用或场景切换时仍在运行,Unity 会强制杀死协程但不调用
|
||||
// EndDialogue(),导致 Action Map 永久停留在 UI 模式。此处主动恢复。
|
||||
if (IsDialogueActive)
|
||||
{
|
||||
StopAllCoroutines();
|
||||
_dialogueBox?.Hide();
|
||||
IsDialogueActive = false;
|
||||
_inputReader?.EnableGameplayInput();
|
||||
}
|
||||
// EndDialogue(),导致 Action Map 永久停留在 UI 模式。复用 ForceEnd() 统一处理。
|
||||
ForceEnd();
|
||||
}
|
||||
|
||||
// ── 公开 API ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 启动对话序列。
|
||||
/// 若已有对话在播放,请求会进入等待队列(上限 <see cref="PendingCapacity"/>),
|
||||
/// 待当前对话结束后依序自动播放;超出上限的请求被丢弃。
|
||||
/// 若已有对话在播放:
|
||||
/// - 当 <paramref name="priority"/> 高于当前对话优先级时,立即打断并播放新序列;
|
||||
/// - 否则进入等待队列(上限 <see cref="_pendingQueueCapacity"/>),超出上限的请求被丢弃。
|
||||
/// 由 InteractableNPC.Interact() 调用。
|
||||
/// </summary>
|
||||
/// <param name="sequence">要播放的对话序列 SO。</param>
|
||||
/// <param name="npcId">NPC 标识符,对话结束时随 EVT_NpcDialogueCompleted 广播。</param>
|
||||
public void StartDialogue(DialogueSequenceSO sequence, string npcId = "")
|
||||
/// <param name="priority">优先级。数值越大越优先;相同或更低优先级不会打断当前对话。</param>
|
||||
public void StartDialogue(DialogueSequenceSO sequence, string npcId = "", int priority = 0)
|
||||
{
|
||||
if (sequence == null) return;
|
||||
if (IsDialogueActive)
|
||||
{
|
||||
if (_pending.Count < PendingCapacity)
|
||||
_pending.Enqueue((sequence, npcId));
|
||||
// 高优先级:打断当前对话,立即播放
|
||||
if (priority > _currentPriority)
|
||||
{
|
||||
StopAllCoroutines();
|
||||
_dialogueBox?.HideChoices();
|
||||
IsDialogueActive = false;
|
||||
_skipRequested = false;
|
||||
_selectedChoiceIndex = -1;
|
||||
_choiceDepth = 0;
|
||||
// 不清空队列,被打断的对话之后仍可继续播放
|
||||
PlayImmediate(sequence, npcId, priority);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_pending.Count < _pendingQueueCapacity)
|
||||
_pending.Enqueue((sequence, npcId, priority));
|
||||
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
||||
else
|
||||
Debug.LogWarning(
|
||||
$"[DialogueManager] 待播队列已满(容量 {_pendingQueueCapacity})," +
|
||||
$"序列 '{sequence.sequenceId}' 被丢弃。可在 Inspector 中调大 _pendingQueueCapacity。");
|
||||
#endif
|
||||
return;
|
||||
}
|
||||
PlayImmediate(sequence, npcId);
|
||||
PlayImmediate(sequence, npcId, priority);
|
||||
}
|
||||
|
||||
private void PlayImmediate(DialogueSequenceSO sequence, string npcId)
|
||||
private void PlayImmediate(DialogueSequenceSO sequence, string npcId, int priority = 0)
|
||||
{
|
||||
IsDialogueActive = true;
|
||||
_skipRequested = false;
|
||||
IsDialogueActive = true;
|
||||
_currentPriority = priority;
|
||||
_skipRequested = false;
|
||||
_selectedChoiceIndex = -1;
|
||||
_choiceDepth = 0;
|
||||
_playbackId++;
|
||||
if (_inputReader != null) _inputReader.EnableUIInput();
|
||||
_onDialogueStarted?.Raise();
|
||||
StartCoroutine(PlaySequence(sequence, npcId));
|
||||
// 启动超时守卫(0 = 不限时)
|
||||
if (_sequenceTimeoutSeconds > 0f)
|
||||
StartCoroutine(SequenceTimeoutGuard(npcId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 超时守卫协程:若对话在 <see cref="_sequenceTimeoutSeconds"/> 内未正常结束,
|
||||
/// 强制终止并记录错误,防止游戏卡死在对话状态。
|
||||
/// </summary>
|
||||
private IEnumerator SequenceTimeoutGuard(string npcId)
|
||||
{
|
||||
yield return new WaitForSeconds(_sequenceTimeoutSeconds);
|
||||
if (!IsDialogueActive) yield break;
|
||||
Debug.LogError(
|
||||
$"[DialogueManager] 对话序列 (npcId='{npcId}') 超时 {_sequenceTimeoutSeconds}s 未结束," +
|
||||
"强制终止。请检查是否存在无法退出的等待分支。");
|
||||
_onDialogueForceEnded?.Raise(npcId);
|
||||
ForceEnd();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 强制立即终止当前对话,清空等待队列,恢复游戏输入。
|
||||
/// 场景切换/演出打断时调用;若无对话活跃则无操作。
|
||||
/// </summary>
|
||||
public void ForceEnd()
|
||||
{
|
||||
if (!IsDialogueActive) return;
|
||||
StopAllCoroutines();
|
||||
_playbackId++; // 使所有残余的选项回调失效,防止下一帧写入新序列状态
|
||||
_pending.Clear();
|
||||
_dialogueBox?.HideChoices();
|
||||
_dialogueBox?.Hide();
|
||||
IsDialogueActive = false;
|
||||
_currentPriority = 0;
|
||||
_skipRequested = false;
|
||||
_selectedChoiceIndex = -1;
|
||||
_choiceDepth = 0;
|
||||
// 优先通过 _onDialogueEnded 事件让 InputManager 决定如何恢复输入;
|
||||
// 若未连接事件频道(旧场景配置),直接恢复 Gameplay 输入作为兜底。
|
||||
_onDialogueEnded?.Raise();
|
||||
if (_onDialogueEnded == null) _inputReader?.EnableGameplayInput();
|
||||
OnDialogueEnded?.Invoke();
|
||||
}
|
||||
|
||||
// ── 输入回调 ──────────────────────────────────────────────────────
|
||||
@@ -129,43 +247,144 @@ namespace BaseGames.Dialogue
|
||||
|
||||
// ── 内部协程 ──────────────────────────────────────────────────────
|
||||
|
||||
private IEnumerator PlaySequence(DialogueSequenceSO sequence, string npcId)
|
||||
private IEnumerator PlaySequence(DialogueSequenceSO startSequence, string npcId)
|
||||
{
|
||||
var resolved = ResolveVariant(sequence);
|
||||
|
||||
if (resolved.lines == null || resolved.lines.Length == 0)
|
||||
if (_dialogueBox == null)
|
||||
{
|
||||
Debug.LogError("[DialogueManager] _dialogueBox 未配置,对话无法显示。请在 Inspector 中指定 DialogueUI 组件。", this);
|
||||
EndDialogue(npcId);
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (var line in resolved.lines)
|
||||
// 使用显式序列栈替代递归,防止深链(100+ 序列)时 C# 调用栈溢出
|
||||
var sequenceStack = new System.Collections.Generic.Stack<DialogueSequenceSO>();
|
||||
sequenceStack.Push(startSequence);
|
||||
|
||||
while (sequenceStack.Count > 0)
|
||||
{
|
||||
_skipRequested = false;
|
||||
_dialogueBox.ShowLine(line);
|
||||
_onLineStarted?.Raise();
|
||||
var sequence = sequenceStack.Pop();
|
||||
var resolved = ResolveVariant(sequence);
|
||||
|
||||
// 等待打字完成,期间允许跳过
|
||||
yield return _waitTypingOrSkip;
|
||||
if (_dialogueBox.IsTyping) _dialogueBox.SkipTyping();
|
||||
if (resolved.lines == null || resolved.lines.Length == 0)
|
||||
{
|
||||
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
||||
Debug.LogWarning(
|
||||
$"[DialogueManager] 序列 '{resolved.sequenceId}' 没有对话行(lines 为空)。" +
|
||||
"对话将静默跳过此序列,可能是未完成配置。");
|
||||
#endif
|
||||
continue;
|
||||
}
|
||||
|
||||
// 等待玩家按 Submit 推进下一行
|
||||
_skipRequested = false;
|
||||
yield return _waitSkip;
|
||||
_onLineEnded?.Raise();
|
||||
bool branchChosen = false;
|
||||
foreach (var line in resolved.lines)
|
||||
{
|
||||
yield return StartCoroutine(PlayOneLine(line));
|
||||
|
||||
if (line.choices != null && line.choices.Length > 0)
|
||||
{
|
||||
yield return StartCoroutine(HandleChoices(line, resolved.sequenceId));
|
||||
|
||||
if (!_branchDepthExceeded)
|
||||
{
|
||||
if (_choiceBranchResult != null)
|
||||
sequenceStack.Push(_choiceBranchResult);
|
||||
branchChosen = true;
|
||||
break;
|
||||
}
|
||||
// 深度超限:优雅降级,继续播放当前序列后续行
|
||||
continue;
|
||||
}
|
||||
|
||||
// 普通行:等待玩家按 Submit 推进
|
||||
_skipRequested = false;
|
||||
yield return _waitSkip;
|
||||
_onLineEnded?.Raise();
|
||||
}
|
||||
|
||||
_ = branchChosen;
|
||||
}
|
||||
|
||||
EndDialogue(npcId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 显示一行对话并等待打字机效果完成(期间允许跳过)。
|
||||
/// 广播 EVT_LineStarted。不广播 EVT_LineEnded(由调用方在推进后广播)。
|
||||
/// </summary>
|
||||
private IEnumerator PlayOneLine(DialogueLine line)
|
||||
{
|
||||
_skipRequested = false;
|
||||
_dialogueBox.ShowLine(line);
|
||||
_onLineStarted?.Raise();
|
||||
yield return _waitTypingOrSkip;
|
||||
if (_dialogueBox.IsTyping) _dialogueBox.SkipTyping();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 显示分支选项,等待玩家选择,并将结果写入 <see cref="_choiceBranchResult"/>。
|
||||
/// <para>若选项嵌套深度超过 <see cref="_maxChoiceDepth"/>,将 <see cref="_branchDepthExceeded"/>
|
||||
/// 置为 true 并立即返回,调用方应优雅降级继续播放后续行而不是终止对话。</para>
|
||||
/// </summary>
|
||||
private IEnumerator HandleChoices(DialogueLine line, string sequenceId)
|
||||
{
|
||||
_choiceBranchResult = null;
|
||||
_branchDepthExceeded = false;
|
||||
|
||||
_choiceDepth++;
|
||||
if (_choiceDepth >= _maxChoiceDepth)
|
||||
{
|
||||
Debug.LogError(
|
||||
$"[DialogueManager] 分支对话深度超过 {_maxChoiceDepth}," +
|
||||
$"序列 \"{sequenceId}\" 可能存在循环引用。" +
|
||||
"已跳过当前选项分支,继续播放后续内容。");
|
||||
_dialogueBox?.HideChoices();
|
||||
_skipRequested = false;
|
||||
_selectedChoiceIndex = -1;
|
||||
_branchDepthExceeded = true;
|
||||
yield break;
|
||||
}
|
||||
|
||||
// 清除打字机阶段积压的输入,防止选项显示后被立即误触发
|
||||
_skipRequested = false;
|
||||
_selectedChoiceIndex = -1;
|
||||
// 延迟一帧:确保此前积压的"确认键"输入在下一帧开始前已被消耗,
|
||||
// 防止快速连击(先跳过打字机→再误触发选项0)的穿透问题。
|
||||
yield return null;
|
||||
// 捕获当前播放标记,防止被打断后遗留回调写入新序列的选择索引
|
||||
int capturedId = _playbackId;
|
||||
_dialogueBox.ShowChoices(line.choices, idx =>
|
||||
{
|
||||
if (_playbackId == capturedId) _selectedChoiceIndex = idx;
|
||||
});
|
||||
yield return _waitForChoice;
|
||||
_dialogueBox.HideChoices();
|
||||
_skipRequested = false;
|
||||
_onLineEnded?.Raise();
|
||||
|
||||
var chosen = line.choices[_selectedChoiceIndex];
|
||||
|
||||
// 广播选择事件(供 QA 埋点、成就系统、数据分析使用)
|
||||
_onDialogueChoiceSelected?.Raise($"{sequenceId}/{_selectedChoiceIndex}");
|
||||
|
||||
// 可选:将世界状态标志写入 WorldStateRegistry
|
||||
if (!string.IsNullOrEmpty(chosen.setWorldFlag) && _worldState != null)
|
||||
_worldState.SetFlag(chosen.setWorldFlag);
|
||||
|
||||
_choiceBranchResult = chosen.nextSequence;
|
||||
}
|
||||
|
||||
private void EndDialogue(string npcId)
|
||||
{
|
||||
_dialogueBox.Hide();
|
||||
_dialogueBox?.Hide();
|
||||
IsDialogueActive = false;
|
||||
_currentPriority = 0;
|
||||
|
||||
if (_inputReader != null) _inputReader.EnableGameplayInput();
|
||||
|
||||
// 优先通过 _onDialogueEnded 事件让 InputManager 决定如何恢复输入;
|
||||
// 若未连接事件频道(旧场景配置),直接恢复 Gameplay 输入作为兜底。
|
||||
_onDialogueEnded?.Raise();
|
||||
if (_onDialogueEnded == null) _inputReader?.EnableGameplayInput();
|
||||
|
||||
OnDialogueEnded?.Invoke();
|
||||
|
||||
if (!string.IsNullOrEmpty(npcId))
|
||||
_onNpcDialogueCompleted?.Raise(npcId);
|
||||
@@ -173,52 +392,26 @@ namespace BaseGames.Dialogue
|
||||
// 自动播放队首等待中的对话(脚本触发的连续序列)
|
||||
if (_pending.Count > 0)
|
||||
{
|
||||
var (seq, id) = _pending.Dequeue();
|
||||
PlayImmediate(seq, id);
|
||||
var (seq, id, pri) = _pending.Dequeue();
|
||||
PlayImmediate(seq, id, pri);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据 ConditionalVariant 选择正确的序列版本。
|
||||
/// 按顺序检查 variants:所有 requiredFlags 均满足的第一个变体胜出(AND 关系);
|
||||
/// 未注入 WorldStateRegistry 或无满足条件的变体时,返回原序列(默认)。
|
||||
/// 根据 WorldState 标志选择正确的序列版本。
|
||||
/// 委托给 <see cref="DialogueSequenceSO.TryGetActiveVariant"/> 统一处理,消除重复逻辑。
|
||||
/// </summary>
|
||||
private DialogueSequenceSO ResolveVariant(DialogueSequenceSO sequence)
|
||||
{
|
||||
if (sequence.variants == null || sequence.variants.Length == 0)
|
||||
return sequence;
|
||||
|
||||
if (_worldState != null)
|
||||
{
|
||||
foreach (var variant in sequence.variants)
|
||||
{
|
||||
if (variant.sequence == null) continue;
|
||||
if (variant.requiredFlags == null || variant.requiredFlags.Length == 0)
|
||||
return variant.sequence; // 无条件变体:直接采用
|
||||
|
||||
bool allMet = true;
|
||||
foreach (var flag in variant.requiredFlags)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(flag) && !_worldState.HasFlag(flag))
|
||||
{
|
||||
allMet = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (allMet) return variant.sequence;
|
||||
}
|
||||
}
|
||||
var resolved = sequence.TryGetActiveVariant(_worldState);
|
||||
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
||||
else if (sequence.variants.Length > 0)
|
||||
{
|
||||
if (resolved == sequence && sequence.variants != null && sequence.variants.Length > 0 && _worldState == null)
|
||||
Debug.LogWarning(
|
||||
$"[DialogueManager] 序列 '{sequence.sequenceId}' 有 {sequence.variants.Length} 个条件变体," +
|
||||
"但 WorldStateRegistry 未注入,将使用默认序列。请检查 Inspector 中的 _worldState 字段。",
|
||||
this);
|
||||
}
|
||||
#endif
|
||||
|
||||
return sequence;
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using UnityEngine;
|
||||
using BaseGames.Core;
|
||||
|
||||
namespace BaseGames.Dialogue
|
||||
{
|
||||
@@ -17,14 +18,23 @@ namespace BaseGames.Dialogue
|
||||
|
||||
[Tooltip("说话人本地化 key,留空时使用 actor.nameKey")]
|
||||
public string speakerNameKey;
|
||||
[Tooltip("对话文本本地化 Key,如 \"DLG_Elder_001\"。运行时通过 LocalizationManager.Get(textKey, \"Dialogue\") 获取实际文字。")]
|
||||
[TextArea(2, 5)]
|
||||
public string textKey; // 本地化文本 key(如 "DLG_Elder_001")
|
||||
public string textKey;
|
||||
|
||||
[Tooltip("说话人头像,留空时使用 actor.portrait")]
|
||||
public Sprite portraitSprite;
|
||||
public AudioClip voiceClip; // 可选语音
|
||||
[Tooltip("对应该行对话的语音片段(可选)。由 DialogueUI 通过 AudioSource 播放,打字机阶段同步开始。")]
|
||||
public AudioClip voiceClip;
|
||||
[Tooltip("打字机每字符延迟(秒)。0 = 使用 DialogueUI 默认值(推荐 0.03s)。\n" +
|
||||
"调小 = 打字更快;调大 = 打字更慢。仅影响本行,不影响其他行。")]
|
||||
[Min(0.01f)]
|
||||
public float typewriterDelay; // 每字符延迟(秒,0 = 使用默认 0.03f)
|
||||
public float typewriterDelay;
|
||||
|
||||
[Tooltip("玩家选项(可选)。有值时,本行打字机效果结束后显示选项列表,等待玩家选择。\n" +
|
||||
"选择后根据 nextSequence 播放续集(或结束对话),并可选地设置 setWorldFlag 标志。\n" +
|
||||
"留空 = 普通对话行,玩家按确认键推进。")]
|
||||
public DialogueChoice[] choices;
|
||||
|
||||
/// <summary>
|
||||
/// 获取最终使用的说话人名称 Key:actor 优先,回退到直接字段。
|
||||
@@ -37,6 +47,31 @@ namespace BaseGames.Dialogue
|
||||
/// </summary>
|
||||
public Sprite ResolvedPortrait => actor != null && actor.portrait != null
|
||||
? actor.portrait : portraitSprite;
|
||||
|
||||
/// <summary>
|
||||
/// 获取最终使用的主题颜色:actor 有值时取 actor.accentColor,否则返回 white。
|
||||
/// </summary>
|
||||
public Color ResolvedAccentColor => actor != null ? actor.accentColor : Color.white;
|
||||
|
||||
/// <summary>
|
||||
/// 当前行是否由玩家角色说话(影响 UI 排版方向)。
|
||||
/// </summary>
|
||||
public bool ResolvedIsPlayer => actor != null && actor.isPlayer;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 玩家可选的对话分支选项。
|
||||
/// 在对话行打字机效果结束后呈现给玩家,选择后播放对应续集序列或结束对话。
|
||||
/// </summary>
|
||||
[System.Serializable]
|
||||
public struct DialogueChoice
|
||||
{
|
||||
[Tooltip("选项文字本地化 Key,如 \"DLG_Choice_AcceptQuest\"。\n运行时由 LocalizationManager 解析为实际文字。")]
|
||||
public string textKey;
|
||||
[Tooltip("选择此选项后继续播放的对话序列(留空 = 对话立即结束)。")]
|
||||
public DialogueSequenceSO nextSequence;
|
||||
[Tooltip("选择此选项后设置的世界状态标志(留空 = 不修改任何标志)。\n与 nextSequence 同时生效。")]
|
||||
public string setWorldFlag;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -47,23 +82,90 @@ namespace BaseGames.Dialogue
|
||||
[CreateAssetMenu(menuName = "BaseGames/Dialogue/DialogueSequence")]
|
||||
public class DialogueSequenceSO : ScriptableObject
|
||||
{
|
||||
[Header("标识")]
|
||||
[Tooltip("序列唯一 ID,如 \"DLG_Elder_Quest_Available\"。OnValidate 会自动以资产名填充,也可手动指定。")]
|
||||
public string sequenceId; // 全局唯一,如 "DLG_Elder_Quest_Available"
|
||||
|
||||
[Header("对话行")]
|
||||
[Tooltip("按顺序播放的对话行列表。每行包含说话人(actor 优先)、文本本地化 Key、可选头像与语音。")]
|
||||
public DialogueLine[] lines;
|
||||
|
||||
/// <summary>
|
||||
/// 条件变体:所有 requiredFlags 均满足时替换整个序列(AND 关系)。
|
||||
/// 与 NarrativeNPC.DialogueVersion 的多条件语义保持一致。
|
||||
/// 条件变体:requiredFlags 按 logic 逻辑满足时替换整个序列。
|
||||
/// </summary>
|
||||
[System.Serializable]
|
||||
public struct ConditionalVariant
|
||||
{
|
||||
[Tooltip("全部满足时激活此变体(AND 关系)。留空表示无条件。")]
|
||||
[Tooltip("条件判断逻辑:And(默认,全部满足)或 Or(任一满足)。\n" +
|
||||
"先选好逻辑再填标志,阅读顺序更自然。")]
|
||||
public BaseGames.Core.WorldStateFlagLogic logic;
|
||||
[Tooltip("条件标志列表。logic=And 时全部满足激活;logic=Or 时任一满足激活。留空表示无条件(总是激活)。")]
|
||||
[WorldStateFlag]
|
||||
public string[] requiredFlags;
|
||||
public DialogueSequenceSO sequence;
|
||||
public string[] requiredFlags;
|
||||
public DialogueSequenceSO sequence;
|
||||
}
|
||||
|
||||
[Header("条件变体(可选)")]
|
||||
[Tooltip("运行时根据 WorldState 标志动态替换整个序列。按优先级从高到低排列:满足条件的第一个变体胜出。\n" +
|
||||
"每个变体支持 And(全部满足)或 Or(任一满足)两种判断逻辑。\n" +
|
||||
"留空表示无变体,始终使用本序列默认台词。")]
|
||||
public ConditionalVariant[] variants;
|
||||
|
||||
// ── 运行时变体解析 ─────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 检查单个条件变体的 requiredFlags 在给定 reader 下是否满足。
|
||||
/// 无条件(requiredFlags 为空)的变体始终返回 true。
|
||||
/// </summary>
|
||||
public bool CheckVariant(ConditionalVariant variant, BaseGames.Core.IWorldStateReader reader)
|
||||
{
|
||||
if (variant.sequence == null) return false;
|
||||
if (variant.requiredFlags == null || variant.requiredFlags.Length == 0) return true;
|
||||
if (reader == null) return false;
|
||||
|
||||
if (variant.logic == BaseGames.Core.WorldStateFlagLogic.Or)
|
||||
{
|
||||
foreach (var flag in variant.requiredFlags)
|
||||
if (!string.IsNullOrEmpty(flag) && reader.HasFlag(flag)) return true;
|
||||
return false;
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var flag in variant.requiredFlags)
|
||||
if (!string.IsNullOrEmpty(flag) && !reader.HasFlag(flag)) return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据 <paramref name="reader"/> 提供的世界状态,返回第一个满足条件的变体序列;
|
||||
/// 无满足变体或 reader 为 null 时返回 this(默认序列)。
|
||||
/// 开发构建下会在控制台输出命中的变体索引和标志,方便调试。
|
||||
/// </summary>
|
||||
public DialogueSequenceSO TryGetActiveVariant(BaseGames.Core.IWorldStateReader reader)
|
||||
{
|
||||
if (variants == null || variants.Length == 0) return this;
|
||||
if (reader != null)
|
||||
{
|
||||
for (int i = 0; i < variants.Length; i++)
|
||||
{
|
||||
var variant = variants[i];
|
||||
if (!CheckVariant(variant, reader)) continue;
|
||||
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
||||
string matchedFlags = variant.requiredFlags != null && variant.requiredFlags.Length > 0
|
||||
? string.Join(", ", variant.requiredFlags)
|
||||
: "(无条件)";
|
||||
string targetId = variant.sequence != null ? variant.sequence.sequenceId : "null";
|
||||
Debug.Log(
|
||||
$"[DialogueSequenceSO] '{sequenceId}' 选中变体[{i}]({matchedFlags})→ '{targetId}'",
|
||||
this);
|
||||
#endif
|
||||
return variant.sequence;
|
||||
}
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
// sequenceId → 资产路径,5 秒 TTL,跨所有 DialogueSequenceSO.OnValidate 共用,
|
||||
// 避免每次 Save 都重扫所有同类 SO(O(1) 路径比对代替 O(n) 全量扫描)。
|
||||
@@ -109,6 +211,108 @@ namespace BaseGames.Dialogue
|
||||
$"'{System.IO.Path.GetFileNameWithoutExtension(existingPath)}' 重复!请修改其中一个。", this);
|
||||
s_seqIdsCacheTime = -10.0;
|
||||
}
|
||||
|
||||
ValidateChoiceCycles();
|
||||
ValidateVariantOrder();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检查 variants 数组中是否存在"无条件变体遮蔽后续变体"的配置错误:
|
||||
/// 若某变体 requiredFlags 为空(无条件)且不在数组末尾,则其后所有变体永远不会被匹配。
|
||||
/// </summary>
|
||||
private void ValidateVariantOrder()
|
||||
{
|
||||
if (variants == null || variants.Length <= 1) return;
|
||||
|
||||
for (int i = 0; i < variants.Length - 1; i++)
|
||||
{
|
||||
var v = variants[i];
|
||||
bool isUnconditional = v.requiredFlags == null || v.requiredFlags.Length == 0;
|
||||
if (!isUnconditional) continue;
|
||||
|
||||
if (v.sequence == null) continue; // 无效变体,忽略
|
||||
|
||||
Debug.LogWarning(
|
||||
$"[DialogueSequenceSO] '{name}' 的 variants[{i}] 没有设置任何条件(requiredFlags 为空)," +
|
||||
$"该变体将始终优先匹配,其后的 {variants.Length - 1 - i} 个变体永远不会生效。\n" +
|
||||
"请将无条件变体移到数组末尾作为兜底,或为此变体添加具体条件。", this);
|
||||
return; // 一次只报第一个问题
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateChoiceCycles()
|
||||
{
|
||||
if (lines == null && (variants == null || variants.Length == 0)) return;
|
||||
var visited = new System.Collections.Generic.HashSet<string>(System.StringComparer.Ordinal);
|
||||
visited.Add(sequenceId);
|
||||
|
||||
// 检查选项链循环
|
||||
if (lines != null)
|
||||
{
|
||||
foreach (var line in lines)
|
||||
{
|
||||
if (line.choices == null) continue;
|
||||
foreach (var choice in line.choices)
|
||||
{
|
||||
if (choice.nextSequence == null) continue;
|
||||
if (HasChoiceCycle(choice.nextSequence, visited))
|
||||
{
|
||||
Debug.LogError(
|
||||
$"[DialogueSequenceSO] '{name}' 的选项链存在循环引用!" +
|
||||
$"序列 '{choice.nextSequence.name}' 最终指回自身或已访问序列," +
|
||||
"运行时将触发递归保护(强制终止对话)。请检查 nextSequence 配置。", this);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查条件变体链循环(variant.sequence 也可能引用形成环路)
|
||||
if (variants != null)
|
||||
{
|
||||
foreach (var variant in variants)
|
||||
{
|
||||
if (variant.sequence == null) continue;
|
||||
if (HasChoiceCycle(variant.sequence, visited))
|
||||
{
|
||||
Debug.LogError(
|
||||
$"[DialogueSequenceSO] '{name}' 的条件变体链存在循环引用!" +
|
||||
$"变体序列 '{variant.sequence.name}' 最终指回自身或已访问序列," +
|
||||
"运行时将触发递归保护(强制终止对话)。请检查 variants 配置。", this);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool HasChoiceCycle(DialogueSequenceSO seq,
|
||||
System.Collections.Generic.HashSet<string> visited)
|
||||
{
|
||||
if (string.IsNullOrEmpty(seq.sequenceId)) return false;
|
||||
if (!visited.Add(seq.sequenceId)) return true;
|
||||
if (seq.lines != null)
|
||||
{
|
||||
foreach (var line in seq.lines)
|
||||
{
|
||||
if (line.choices == null) continue;
|
||||
foreach (var choice in line.choices)
|
||||
{
|
||||
if (choice.nextSequence != null && HasChoiceCycle(choice.nextSequence, visited))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 同时遍历条件变体序列,防止变体链形成环路
|
||||
if (seq.variants != null)
|
||||
{
|
||||
foreach (var variant in seq.variants)
|
||||
{
|
||||
if (variant.sequence != null && HasChoiceCycle(variant.sequence, visited))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
visited.Remove(seq.sequenceId);
|
||||
return false;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
@@ -20,20 +21,61 @@ namespace BaseGames.Dialogue
|
||||
[SerializeField] private GameObject _speakerNamePanel; // 无名称时隐藏整个名称框
|
||||
[SerializeField] private GameObject _continuePrompt; // "▼" 图标,打字完成后显示
|
||||
[SerializeField] private Image _speakerPortrait; // 角色头像框
|
||||
[SerializeField] private Image _speakerNameBackground; // 说话人名称框背景,用于应用 accentColor(可选)
|
||||
[SerializeField] private AudioSource _voiceSource; // 语音播放源(可不配置)
|
||||
|
||||
private Coroutine _typingCoroutine;
|
||||
[Header("选项系统(可选)")]
|
||||
[Tooltip("选项按钮的父节点容器。ShowChoices 通过对象池激活/停用按钮,HideChoices 停用全部。\n留空则分支选项功能静默禁用。")]
|
||||
[SerializeField] private Transform _choicesContainer;
|
||||
[Tooltip("选项按钮预制体(需包含 Button 组件和 TMP_Text 子组件)。\n首次使用时预热 PoolInitialSize 个到对象池,后续零 GC。")]
|
||||
[SerializeField] private GameObject _choiceButtonPrefab;
|
||||
[Tooltip("选项按钮池初始大小。设为预期最大选项数,默认 8 覆盖绝大多数情况。")]
|
||||
[SerializeField] [Range(2, 16)] private int _choicePoolSize = 8;
|
||||
|
||||
// 说话人名称框背景的默认色(Awake 时记录,切换角色后可还原)
|
||||
private Color _defaultNameBgColor = Color.white;
|
||||
// 缓存名称框 RectTransform,避免 ShowLine 每次调用 GetComponent(零堆分配)
|
||||
private RectTransform _speakerNamePanelRT;
|
||||
|
||||
// 选项按钮对象池:Awake 时按 _choicePoolSize 预热,ShowChoices/HideChoices 零 GC
|
||||
private readonly List<(GameObject go, Button btn, TMP_Text lbl)> _choicePool = new();
|
||||
|
||||
private Coroutine _typingCoroutine;
|
||||
private DialogueLine _currentLine;
|
||||
private const float DefaultTypewriterDelay = 0.03f;
|
||||
|
||||
// 缓存单个 WaitForSecondsRealtime,避免 TypeLine 每字符 new 分配。
|
||||
// 当 delay 值改变时才重新创建(不同行可能有不同打字速度)。
|
||||
// 缓存 WaitForSecondsRealtime:delay 值不变时直接复用,避免每行 new 分配。
|
||||
private WaitForSecondsRealtime _cachedTypeDelay;
|
||||
private float _cachedTypeDelayValue = -1f;
|
||||
|
||||
// 缓存 StringBuilder:每行 Clear() 复用,避免每行 new StringBuilder(n) 的堆分配。
|
||||
// 初始容量 256,足以容纳绝大多数对话行,超长时会自动扩容(扩容极少发生)。
|
||||
private readonly StringBuilder _typingSB = new(256);
|
||||
|
||||
/// <summary>当前是否仍在执行打字机效果。</summary>
|
||||
public bool IsTyping { get; private set; }
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (_speakerNameBackground != null)
|
||||
_defaultNameBgColor = _speakerNameBackground.color;
|
||||
if (_speakerNamePanel != null)
|
||||
_speakerNamePanelRT = _speakerNamePanel.GetComponent<RectTransform>();
|
||||
|
||||
// 预热选项按钮对象池:在此时创建可避免首次对话时的 Instantiate 停顿
|
||||
if (_choicesContainer != null && _choiceButtonPrefab != null)
|
||||
{
|
||||
for (int i = 0; i < _choicePoolSize; i++)
|
||||
{
|
||||
var go = Instantiate(_choiceButtonPrefab, _choicesContainer);
|
||||
var btn = go.GetComponent<Button>();
|
||||
var lbl = go.GetComponentInChildren<TMP_Text>();
|
||||
go.SetActive(false);
|
||||
_choicePool.Add((go, btn, lbl));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 公开 API ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>显示一行对话并开始打字机效果。</summary>
|
||||
@@ -50,6 +92,13 @@ namespace BaseGames.Dialogue
|
||||
if (hasSpeaker && _speakerNameText != null)
|
||||
_speakerNameText.text = LocalizationManager.Get(resolvedNameKey, "Dialogue");
|
||||
|
||||
// 说话人名称框背景颜色(accentColor):有 actor 时着色,无 actor 时还原默认色
|
||||
if (_speakerNameBackground != null)
|
||||
_speakerNameBackground.color = hasSpeaker ? line.ResolvedAccentColor : _defaultNameBgColor;
|
||||
|
||||
// 排版方向:玩家角色说话时名称框靠右,NPC 靠左
|
||||
SetLayoutSide(line.ResolvedIsPlayer);
|
||||
|
||||
// 头像(actor 优先,回退到直接字段)
|
||||
var resolvedPortrait = line.ResolvedPortrait;
|
||||
if (_speakerPortrait != null)
|
||||
@@ -85,7 +134,20 @@ namespace BaseGames.Dialogue
|
||||
}
|
||||
_voiceSource?.Stop();
|
||||
if (_dialogueText != null)
|
||||
_dialogueText.text = LocalizationManager.Get(_currentLine.textKey ?? "", "Dialogue");
|
||||
{
|
||||
string key = _currentLine.textKey;
|
||||
if (string.IsNullOrEmpty(key))
|
||||
{
|
||||
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
||||
Debug.LogWarning("[DialogueUI] 当前对话行 textKey 为空,跳过打字机后显示空文本。");
|
||||
#endif
|
||||
_dialogueText.text = "";
|
||||
}
|
||||
else
|
||||
{
|
||||
_dialogueText.text = LocalizationManager.Get(key, "Dialogue");
|
||||
}
|
||||
}
|
||||
IsTyping = false;
|
||||
if (_continuePrompt != null) _continuePrompt.SetActive(true);
|
||||
}
|
||||
@@ -93,9 +155,80 @@ namespace BaseGames.Dialogue
|
||||
/// <summary>隐藏对话框面板。</summary>
|
||||
public void Hide()
|
||||
{
|
||||
_voiceSource?.Stop();
|
||||
if (_rootPanel != null) _rootPanel.SetActive(false);
|
||||
}
|
||||
|
||||
// ── 布局辅助 ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 切换名称框的横向对齐方向:玩家说话时靠右,NPC 靠左。
|
||||
/// 修改 RectTransform 的 anchorMin.x / anchorMax.x / pivot.x,保持纵向不变。
|
||||
/// </summary>
|
||||
private void SetLayoutSide(bool isPlayer)
|
||||
{
|
||||
if (_speakerNamePanelRT == null) return;
|
||||
float x = isPlayer ? 1f : 0f;
|
||||
_speakerNamePanelRT.anchorMin = new Vector2(x, _speakerNamePanelRT.anchorMin.y);
|
||||
_speakerNamePanelRT.anchorMax = new Vector2(x, _speakerNamePanelRT.anchorMax.y);
|
||||
_speakerNamePanelRT.pivot = new Vector2(x, _speakerNamePanelRT.pivot.y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 显示玩家可选的分支选项列表。打字机效果结束后由 DialogueManager 调用。
|
||||
/// 使用对象池(零 GC):池不足时动态扩容;点击后回调 onSelected(index)。
|
||||
/// 若 _choicesContainer 或 _choiceButtonPrefab 未配置,则静默跳过(不影响流程)。
|
||||
/// </summary>
|
||||
public void ShowChoices(DialogueChoice[] choices, System.Action<int> onSelected)
|
||||
{
|
||||
if (_choicesContainer == null || _choiceButtonPrefab == null) return;
|
||||
|
||||
// 确保池中有足够按钮
|
||||
while (_choicePool.Count < choices.Length)
|
||||
{
|
||||
var go = Instantiate(_choiceButtonPrefab, _choicesContainer);
|
||||
var btn = go.GetComponent<Button>();
|
||||
var lbl = go.GetComponentInChildren<TMP_Text>();
|
||||
go.SetActive(false);
|
||||
_choicePool.Add((go, btn, lbl));
|
||||
}
|
||||
|
||||
// 激活前 N 个并绑定数据
|
||||
for (int i = 0; i < choices.Length; i++)
|
||||
{
|
||||
int captured = i;
|
||||
var (go, btn, lbl) = _choicePool[i];
|
||||
go.SetActive(true);
|
||||
if (lbl != null)
|
||||
lbl.text = LocalizationManager.Get(choices[i].textKey ?? "", "Dialogue");
|
||||
if (btn != null)
|
||||
{
|
||||
btn.onClick.RemoveAllListeners();
|
||||
btn.onClick.AddListener(() => onSelected?.Invoke(captured));
|
||||
}
|
||||
}
|
||||
|
||||
// 多余的池对象保持隐藏
|
||||
for (int i = choices.Length; i < _choicePool.Count; i++)
|
||||
_choicePool[i].go.SetActive(false);
|
||||
|
||||
// 有选项时隐藏继续提示,避免与选项按钮视觉重叠
|
||||
if (_continuePrompt != null) _continuePrompt.SetActive(false);
|
||||
_choicesContainer.gameObject.SetActive(true);
|
||||
}
|
||||
|
||||
/// <summary>隐藏选项列表,将池中按钮全部停用(零 GC)。</summary>
|
||||
public void HideChoices()
|
||||
{
|
||||
if (_choicesContainer == null) return;
|
||||
foreach (var (go, btn, _) in _choicePool)
|
||||
{
|
||||
if (btn != null) btn.onClick.RemoveAllListeners();
|
||||
go.SetActive(false);
|
||||
}
|
||||
_choicesContainer.gameObject.SetActive(false);
|
||||
}
|
||||
|
||||
// ── 内部协程 ──────────────────────────────────────────────────────
|
||||
|
||||
private IEnumerator TypeLine(DialogueLine line)
|
||||
@@ -110,16 +243,27 @@ namespace BaseGames.Dialogue
|
||||
_cachedTypeDelayValue = delay;
|
||||
}
|
||||
|
||||
string text = LocalizationManager.Get(line.textKey ?? "", "Dialogue");
|
||||
string text;
|
||||
if (string.IsNullOrEmpty(line.textKey))
|
||||
{
|
||||
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
||||
Debug.LogWarning("[DialogueUI] 对话行 textKey 为空,打字机将显示空文本。请检查 DialogueSequenceSO 配置。");
|
||||
#endif
|
||||
text = "";
|
||||
}
|
||||
else
|
||||
{
|
||||
text = LocalizationManager.Get(line.textKey, "Dialogue");
|
||||
}
|
||||
|
||||
// 性能:StringBuilder 避免每帧字符串分配(O(n²) → O(n))
|
||||
var sb = new StringBuilder(text.Length);
|
||||
// 复用缓存 StringBuilder,避免每行 new 分配;TMP SetText(StringBuilder) 零分配
|
||||
_typingSB.Clear();
|
||||
if (_dialogueText != null) _dialogueText.text = "";
|
||||
|
||||
foreach (char c in text)
|
||||
{
|
||||
sb.Append(c);
|
||||
if (_dialogueText != null) _dialogueText.SetText(sb); // TMP SetText(StringBuilder) 零分配
|
||||
_typingSB.Append(c);
|
||||
if (_dialogueText != null) _dialogueText.SetText(_typingSB);
|
||||
yield return _cachedTypeDelay;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,10 +9,25 @@ namespace BaseGames.Dialogue
|
||||
bool IsDialogueActive { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 启动对话序列。若已有对话在播放则忽略新请求。
|
||||
/// 每次对话序列(含分支链)全部播完后触发。
|
||||
/// 测试代码可订阅此事件等待对话结束,无需依赖 VoidEventChannelSO 资产。
|
||||
/// </summary>
|
||||
event System.Action OnDialogueEnded;
|
||||
|
||||
/// <summary>
|
||||
/// 启动对话序列。
|
||||
/// 若已有对话在播放:priority 高于当前对话时立即打断;否则进入队列(上限 8),超出丢弃。
|
||||
/// </summary>
|
||||
/// <param name="sequence">要播放的对话序列 SO。</param>
|
||||
/// <param name="npcId">NPC 标识符,对话结束时随 EVT_NpcDialogueCompleted 广播。</param>
|
||||
void StartDialogue(DialogueSequenceSO sequence, string npcId = "");
|
||||
/// <param name="priority">优先级(默认 0)。数值越大越优先;高优先级可打断低优先级对话。</param>
|
||||
void StartDialogue(DialogueSequenceSO sequence, string npcId = "", int priority = 0);
|
||||
|
||||
/// <summary>
|
||||
/// 立即强制结束当前对话(含清空等待队列),恢复游戏输入。
|
||||
/// 适用于:场景切换、演出系统打断、死亡/传送等需要硬中断的场合。
|
||||
/// 若当前没有活跃对话,则无操作。
|
||||
/// </summary>
|
||||
void ForceEnd();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using UnityEngine;
|
||||
using BaseGames.World;
|
||||
using BaseGames.Localization;
|
||||
|
||||
namespace BaseGames.Dialogue
|
||||
{
|
||||
@@ -10,13 +11,46 @@ namespace BaseGames.Dialogue
|
||||
/// </summary>
|
||||
public class InteractableNPC : MonoBehaviour, IInteractable
|
||||
{
|
||||
[Header("NPC 基础")]
|
||||
[Tooltip("NPC 唯一 ID(如 \"NPC_Elder\")。对话结束时随 EVT_NpcDialogueCompleted 广播,用于驱动对话类任务目标进度。\n" +
|
||||
"需与 QuestSO 目标中 targetNpcId 保持一致。")]
|
||||
[SerializeField] protected string _npcId;
|
||||
[Tooltip("默认对话序列。无其他逻辑覆盖时播放此序列。NarrativeNPC/QuestGiver 子类通过 GetCurrentDialogue() 返回更精确的版本。")]
|
||||
[SerializeField] protected DialogueSequenceSO _defaultDialogue;
|
||||
[Tooltip("玩家进入此半径(单位:Unity 单位)后显示交互提示。建议 1.0–2.0。\n编辑器下在场景视图中以黄色圆圈可视化。")]
|
||||
[SerializeField] protected float _interactRadius = 1.5f;
|
||||
[Tooltip("交互提示本地化 Key(如 \"INTERACT_Talk\")。运行时通过 LocalizationManager 解析为实际文字。\n" +
|
||||
"留空时回退到内置字符串 \"对话\"。")]
|
||||
[SerializeField] protected string _interactPromptKey = "INTERACT_Talk";
|
||||
|
||||
[Header("范围检测")]
|
||||
[Tooltip("玩家所在的物理层。OnTriggerEnter2D / OnTriggerExit2D 仅响应属于此层的碰撞体,\n" +
|
||||
"实现 NPC 自包含的交互范围检测,无需外部 PlayerInteractionDetector 组件。\n" +
|
||||
"将玩家 GameObject 的 Layer 与此 Mask 对齐即可(推荐专用 \"Player\" 层)。\n" +
|
||||
"若留空(值为 0),则跳过层级过滤,任意碰撞体均可触发(调试用,不推荐上线)。")]
|
||||
[SerializeField] protected LayerMask _playerLayer;
|
||||
|
||||
// ── IInteractable ──────────────────────────────────────────────────
|
||||
public virtual bool CanInteract => true;
|
||||
public virtual string InteractPrompt => "对话";
|
||||
public virtual bool CanInteract => true;
|
||||
|
||||
public virtual string InteractPrompt
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_interactPromptKey))
|
||||
{
|
||||
var resolved = LocalizationManager.Get(_interactPromptKey, "UI");
|
||||
if (!string.IsNullOrEmpty(resolved)) return resolved;
|
||||
}
|
||||
return "对话";
|
||||
}
|
||||
}
|
||||
|
||||
// ── 范围进出通知(供子组件订阅,如 InteractionPromptController)──────
|
||||
/// <summary>玩家进入交互范围时触发。参数为玩家 Transform。</summary>
|
||||
public event System.Action<Transform> PlayerEnteredRange;
|
||||
/// <summary>玩家离开交互范围时触发。</summary>
|
||||
public event System.Action PlayerExitedRange;
|
||||
|
||||
public void Interact(Transform player)
|
||||
{
|
||||
@@ -27,8 +61,24 @@ namespace BaseGames.Dialogue
|
||||
PlayDialogue(dialogue, player);
|
||||
}
|
||||
|
||||
public virtual void OnPlayerEnterRange(Transform player) { }
|
||||
public virtual void OnPlayerExitRange() { }
|
||||
public virtual void OnPlayerEnterRange(Transform player) { PlayerEnteredRange?.Invoke(player); }
|
||||
public virtual void OnPlayerExitRange() { PlayerExitedRange?.Invoke(); }
|
||||
|
||||
// ── 自包含物理范围检测 ─────────────────────────────────────────────
|
||||
// 需在 NPC Prefab 上挂载 Collider2D(设为 IsTrigger),并将 Collider2D.size/radius
|
||||
// 配置为期望的交互半径。OnTriggerEnter2D / Exit2D 会自动过滤非玩家碰撞体。
|
||||
|
||||
private void OnTriggerEnter2D(Collider2D other)
|
||||
{
|
||||
if (_playerLayer.value != 0 && ((1 << other.gameObject.layer) & _playerLayer.value) == 0) return;
|
||||
OnPlayerEnterRange(other.transform);
|
||||
}
|
||||
|
||||
private void OnTriggerExit2D(Collider2D other)
|
||||
{
|
||||
if (_playerLayer.value != 0 && ((1 << other.gameObject.layer) & _playerLayer.value) == 0) return;
|
||||
OnPlayerExitRange();
|
||||
}
|
||||
|
||||
// ── 子类覆盖点 ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -51,5 +101,42 @@ namespace BaseGames.Dialogue
|
||||
}
|
||||
manager.StartDialogue(sequence, _npcId);
|
||||
}
|
||||
|
||||
// ── 编辑器辅助 ────────────────────────────────────────────────────
|
||||
// 注意:OnValidate 声明在 #if 外,确保子类在非编辑器构建中调用 base.OnValidate() 不会编译失败。
|
||||
|
||||
protected virtual void OnValidate()
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
if (_playerLayer.value == 0)
|
||||
Debug.LogWarning(
|
||||
$"[InteractableNPC:{name}] _playerLayer 未设置(value=0)。" +
|
||||
"OnTriggerEnter2D 将响应所有层,建议在 Inspector 中指定玩家所在层。", this);
|
||||
|
||||
// 检测 _interactRadius 与 CircleCollider2D.radius 是否同步(仅输出一次,非逐帧)
|
||||
var circle = GetComponent<UnityEngine.CircleCollider2D>();
|
||||
if (circle != null && !Mathf.Approximately(circle.radius, _interactRadius))
|
||||
Debug.LogWarning(
|
||||
$"[InteractableNPC:{name}] _interactRadius({_interactRadius:F2}) 与 " +
|
||||
$"CircleCollider2D.radius({circle.radius:F2}) 不一致," +
|
||||
"交互范围视觉(Gizmos)与物理碰撞可能不匹配,请手动对齐。", this);
|
||||
#endif
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
protected virtual void OnDrawGizmosSelected()
|
||||
{
|
||||
// Collider2D 不一致时改绘红色(警告已在 OnValidate 中输出,此处不重复 LogWarning)
|
||||
bool mismatch = false;
|
||||
var circle = GetComponent<UnityEngine.CircleCollider2D>();
|
||||
if (circle != null && !Mathf.Approximately(circle.radius, _interactRadius))
|
||||
mismatch = true;
|
||||
|
||||
UnityEditor.Handles.color = mismatch
|
||||
? new Color(1f, 0.2f, 0.2f, 0.8f)
|
||||
: new Color(1f, 0.92f, 0.016f, 0.6f);
|
||||
UnityEditor.Handles.DrawWireDisc(transform.position, Vector3.forward, _interactRadius);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
using UnityEngine.UI;
|
||||
@@ -5,34 +6,144 @@ using UnityEngine.UI;
|
||||
namespace BaseGames.Dialogue
|
||||
{
|
||||
/// <summary>
|
||||
/// 交互提示 UI 控制器(架构 14_NarrativeModule §2)。
|
||||
/// 挂载在每个 IInteractable GameObject 的子节点(Prefab 实例),默认隐藏。
|
||||
/// 根据当前活跃输入设备自动切换图标(键盘/手柄)。
|
||||
/// 世界空间交互提示控制器(架构 14_NarrativeModule §2 升级版)。
|
||||
/// 挂在每个 InteractableNPC 子节点(Prefab 实例),默认隐藏。
|
||||
///
|
||||
/// 功能:
|
||||
/// • 自动订阅父级 InteractableNPC 的进/出范围事件,免手动调用 Show/Hide
|
||||
/// • TMP_Text 实时显示 InteractPrompt(如"接受任务"/"提交任务"),随任务状态动态刷新
|
||||
/// • 根据当前活跃输入设备自动切换按键图标(键盘/手柄)
|
||||
/// • 支持淡入/淡出动画
|
||||
/// </summary>
|
||||
public class InteractionPromptController : MonoBehaviour
|
||||
{
|
||||
[Header("UI 引用")]
|
||||
[Tooltip("整个提示根节点(包含图标和文字),控制显示/隐藏。")]
|
||||
[SerializeField] private GameObject _promptRoot;
|
||||
[Tooltip("按键图标 Image 组件(可选)。有输入设备时显示对应图标。")]
|
||||
[SerializeField] private Image _icon;
|
||||
[SerializeField] private Sprite _keyboardIcon;
|
||||
[SerializeField] private Sprite _gamepadIcon;
|
||||
[Tooltip("提示文字 TMP_Text 组件(可选)。自动显示 InteractableNPC.InteractPrompt 的当前值。")]
|
||||
[SerializeField] private TMP_Text _label;
|
||||
|
||||
[Header("按键图标")]
|
||||
[Tooltip("键盘/鼠标设备激活时使用的按键图标 Sprite。")]
|
||||
[SerializeField] private Sprite _keyboardIcon;
|
||||
[Tooltip("手柄设备激活时使用的按键图标 Sprite。")]
|
||||
[SerializeField] private Sprite _gamepadIcon;
|
||||
|
||||
[Header("位置与动画")]
|
||||
[Tooltip("相对于本组件 transform 的世界空间偏移。调整此值可控制气泡与 NPC 的相对位置。")]
|
||||
[SerializeField] private Vector3 _offset = new Vector3(0f, 1.8f, 0f);
|
||||
[Tooltip("是否随相机方向 Billboard 朝向(世界空间 Canvas 推荐开启)。")]
|
||||
[SerializeField] private bool _billboard = true;
|
||||
[Tooltip("淡入持续时间(秒)。0 = 立即显示,无动画。")]
|
||||
[SerializeField] [Min(0f)] private float _fadeInDuration = 0.12f;
|
||||
[Tooltip("淡出持续时间(秒)。0 = 立即隐藏,无动画。")]
|
||||
[SerializeField] [Min(0f)] private float _fadeOutDuration = 0.08f;
|
||||
|
||||
// ── 运行时状态 ────────────────────────────────────────────────────────
|
||||
private InteractableNPC _npc;
|
||||
private bool _visible;
|
||||
private float _alpha;
|
||||
private Camera _cam;
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (_promptRoot != null) _promptRoot.SetActive(false);
|
||||
// 自动连接父级 InteractableNPC 事件(无需手动调用 Show/Hide)
|
||||
_npc = GetComponentInParent<InteractableNPC>();
|
||||
if (_npc != null)
|
||||
{
|
||||
_npc.PlayerEnteredRange += OnPlayerEntered;
|
||||
_npc.PlayerExitedRange += OnPlayerExited;
|
||||
}
|
||||
|
||||
SetVisible(false, immediate: true);
|
||||
}
|
||||
|
||||
/// <summary>显示交互提示,根据输入设备选择图标。</summary>
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (_npc != null)
|
||||
{
|
||||
_npc.PlayerEnteredRange -= OnPlayerEntered;
|
||||
_npc.PlayerExitedRange -= OnPlayerExited;
|
||||
}
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
// 位置偏移(世界空间气泡)
|
||||
if (_offset != Vector3.zero)
|
||||
transform.position = (_npc != null ? _npc.transform.position : transform.parent.position) + _offset;
|
||||
|
||||
// Billboard
|
||||
if (_billboard && _visible)
|
||||
{
|
||||
if (_cam == null) _cam = Camera.main;
|
||||
if (_cam != null)
|
||||
transform.forward = _cam.transform.forward;
|
||||
}
|
||||
|
||||
// 淡入/淡出
|
||||
if (_promptRoot == null) return;
|
||||
if (_visible && _alpha < 1f)
|
||||
{
|
||||
float speed = _fadeInDuration > 0f ? Time.deltaTime / _fadeInDuration : 1f;
|
||||
_alpha = Mathf.MoveTowards(_alpha, 1f, speed);
|
||||
ApplyAlpha(_alpha);
|
||||
}
|
||||
else if (!_visible && _alpha > 0f)
|
||||
{
|
||||
float speed = _fadeOutDuration > 0f ? Time.deltaTime / _fadeOutDuration : 1f;
|
||||
_alpha = Mathf.MoveTowards(_alpha, 0f, speed);
|
||||
ApplyAlpha(_alpha);
|
||||
if (_alpha <= 0f) _promptRoot.SetActive(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 公开 API(兼容旧调用 / 脚本手动控制)────────────────────────────
|
||||
|
||||
/// <summary>手动显示提示。通常由 InteractableNPC 自动调用,无需手动触发。</summary>
|
||||
public void Show()
|
||||
{
|
||||
if (_promptRoot == null) return;
|
||||
_promptRoot.SetActive(true);
|
||||
if (_npc != null) _label.text = _npc.InteractPrompt;
|
||||
SetVisible(true, immediate: false);
|
||||
UpdateIcon();
|
||||
}
|
||||
|
||||
/// <summary>隐藏交互提示。</summary>
|
||||
public void Hide()
|
||||
/// <summary>手动隐藏提示。通常由 InteractableNPC 自动调用,无需手动触发。</summary>
|
||||
public void Hide() => SetVisible(false, immediate: false);
|
||||
|
||||
// ── 私有辅助 ─────────────────────────────────────────────────────────
|
||||
|
||||
private void OnPlayerEntered(Transform player)
|
||||
{
|
||||
if (_promptRoot != null) _promptRoot.SetActive(false);
|
||||
// 刷新文字(每次进入都读取最新 InteractPrompt,确保任务状态变化后文字正确)
|
||||
if (_label != null && _npc != null)
|
||||
_label.text = _npc.InteractPrompt;
|
||||
SetVisible(true, immediate: false);
|
||||
UpdateIcon();
|
||||
}
|
||||
|
||||
private void OnPlayerExited() => SetVisible(false, immediate: false);
|
||||
|
||||
private void SetVisible(bool show, bool immediate)
|
||||
{
|
||||
_visible = show;
|
||||
if (immediate)
|
||||
{
|
||||
_alpha = show ? 1f : 0f;
|
||||
if (_promptRoot != null)
|
||||
{
|
||||
_promptRoot.SetActive(show);
|
||||
ApplyAlpha(_alpha);
|
||||
}
|
||||
}
|
||||
else if (show && _promptRoot != null)
|
||||
{
|
||||
_promptRoot.SetActive(true); // 淡出由 Update 结束时 SetActive(false)
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateIcon()
|
||||
@@ -41,5 +152,12 @@ namespace BaseGames.Dialogue
|
||||
bool isGamepad = Gamepad.current != null && Gamepad.current.enabled;
|
||||
_icon.sprite = isGamepad ? _gamepadIcon : _keyboardIcon;
|
||||
}
|
||||
|
||||
private void ApplyAlpha(float a)
|
||||
{
|
||||
if (_icon != null) { var c = _icon.color; c.a = a; _icon.color = c; }
|
||||
if (_label != null) { var c = _label.color; c.a = a; _label.color = c; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using BaseGames.Core;
|
||||
using BaseGames.World;
|
||||
using UnityEngine;
|
||||
|
||||
@@ -6,23 +7,35 @@ namespace BaseGames.Dialogue
|
||||
{
|
||||
/// <summary>
|
||||
/// 条件对话 NPC(架构 14_NarrativeModule §7)。
|
||||
/// 扩展 InteractableNPC,根据 WorldStateRegistry 标志动态选择对话版本。
|
||||
/// 扩展 InteractableNPC,根据世界状态标志动态选择对话版本。
|
||||
/// 版本列表从高到低优先级排列;第一个满足条件的版本生效。
|
||||
///
|
||||
/// _worldState 可留空:留空时自动从 ServiceLocator 获取全局注册的 IWorldStateReader,
|
||||
/// 便于无需在每个 NPC Prefab 上手动拖入 WorldStateRegistry 资产。
|
||||
/// </summary>
|
||||
public class NarrativeNPC : InteractableNPC
|
||||
{
|
||||
[Header("台词版本集(从高到低优先级排列)")]
|
||||
[Tooltip("条件对话版本列表。运行时从上到下检查,第一个满足条件的版本被播放。\n" +
|
||||
"版本之间的优先级由列表顺序决定——请将最具体的条件放在最上方。")]
|
||||
[SerializeField] private DialogueVersion[] _dialogueVersions;
|
||||
[SerializeField] private DialogueSequenceSO _fallbackDialogue; // 无条件满足时的兜底台词
|
||||
[SerializeField] private WorldStateRegistry _worldState; // SO 注入
|
||||
[Tooltip("所有版本均不满足条件时的兜底对话。务必配置,否则运行时会输出 LogWarning 且 NPC 无对话。")]
|
||||
[SerializeField] private DialogueSequenceSO _fallbackDialogue;
|
||||
[Tooltip("世界状态 SO(可选)。留空时自动从 ServiceLocator 获取全局 IWorldStateReader。\n" +
|
||||
"通常同场景下多个 NPC 共用同一个 WorldStateRegistry;\n" +
|
||||
"若全局已通过 ServiceLocator 注册,可不在此处手动指定。")]
|
||||
[SerializeField] private WorldStateRegistry _worldState;
|
||||
|
||||
protected override DialogueSequenceSO GetCurrentDialogue()
|
||||
{
|
||||
IWorldStateReader reader = _worldState
|
||||
?? ServiceLocator.GetOrDefault<IWorldStateReader>();
|
||||
|
||||
if (_dialogueVersions == null) return _fallbackDialogue;
|
||||
|
||||
foreach (var version in _dialogueVersions)
|
||||
{
|
||||
if (version != null && version.CheckConditions(_worldState))
|
||||
if (version != null && version.CheckConditions(reader))
|
||||
return version.dialogue;
|
||||
}
|
||||
|
||||
@@ -43,6 +56,7 @@ namespace BaseGames.Dialogue
|
||||
{
|
||||
[Tooltip("编辑器显示名,如'森林 Boss 击败后'")]
|
||||
public string versionLabel;
|
||||
[Tooltip("此版本对应的对话序列 SO。条件满足时播放。留空时等同于跳过此版本。")]
|
||||
public DialogueSequenceSO dialogue;
|
||||
|
||||
[Tooltip("全部满足才激活此版本(AND 关系)")]
|
||||
@@ -53,18 +67,21 @@ namespace BaseGames.Dialogue
|
||||
[WorldStateFlag]
|
||||
public string[] blockedByFlags;
|
||||
|
||||
/// <summary>检查此版本的激活条件(AND requiredFlags / NOT blockedByFlags)。</summary>
|
||||
public bool CheckConditions(WorldStateRegistry registry)
|
||||
/// <summary>
|
||||
/// 检查此版本的激活条件(AND requiredFlags / NOT blockedByFlags)。
|
||||
/// reader 为 null 时直接返回 false(无法判断,视为条件不满足)。
|
||||
/// </summary>
|
||||
public bool CheckConditions(IWorldStateReader reader)
|
||||
{
|
||||
if (registry == null) return false;
|
||||
if (reader == null) return false;
|
||||
|
||||
if (requiredFlags != null)
|
||||
foreach (var f in requiredFlags)
|
||||
if (!registry.HasFlag(f)) return false;
|
||||
if (!reader.HasFlag(f)) return false;
|
||||
|
||||
if (blockedByFlags != null)
|
||||
foreach (var f in blockedByFlags)
|
||||
if (registry.HasFlag(f)) return false;
|
||||
if (reader.HasFlag(f)) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
86
Assets/_Game/Scripts/Dialogue/NpcSO.cs
Normal file
86
Assets/_Game/Scripts/Dialogue/NpcSO.cs
Normal file
@@ -0,0 +1,86 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Dialogue
|
||||
{
|
||||
/// <summary>
|
||||
/// NPC 元数据资产(架构 14_NarrativeModule §2)。
|
||||
/// 将 NPC 的唯一 ID、本地化名称 Key、头像、好感度上限集中在一处管理。
|
||||
///
|
||||
/// 关联:
|
||||
/// • <see cref="InteractableNPC"/> 通过 _npcId 字段与此 SO 对应。
|
||||
/// • <see cref="DialogueActorSO"/> 管理对话 UI 侧头像/颜色(二者可共享同一 Sprite,或独立维护)。
|
||||
/// • <see cref="BaseGames.Quest.QuestSO"/> 的 <c>giverNpc</c> 字段直接引用此 SO,避免手填字符串。
|
||||
///
|
||||
/// 资产路径:Assets/_Game/Data/NPC/NPC_{npcId}.asset
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "BaseGames/NPC/NPC")]
|
||||
public class NpcSO : ScriptableObject
|
||||
{
|
||||
[Header("标识")]
|
||||
[Tooltip("NPC 唯一 ID,如 \"NPC_Elder\"。需与 InteractableNPC._npcId、QuestSO.giverNpcId 保持一致。")]
|
||||
public string npcId;
|
||||
|
||||
[Header("显示")]
|
||||
[Tooltip("本地化 Key,如 \"NPC_Elder_Name\"。通过 LocalizationManager 解析为实际名称。")]
|
||||
public string nameKey;
|
||||
[Tooltip("NPC 头像,用于地图、任务日志、DataHub 等 UI。")]
|
||||
public Sprite portrait;
|
||||
|
||||
[Header("好感度")]
|
||||
[Tooltip("该 NPC 的好感度上限(0 = 无上限)。\n" +
|
||||
"QuestManager.CompleteQuest 发放 affinityBonus 时,不超过此数值。\n" +
|
||||
"UI 侧可用此值绘制好感度进度条满格。")]
|
||||
[Min(0)] public int maxAffinity = 0;
|
||||
|
||||
[Header("交互提示")]
|
||||
[Tooltip("与此 NPC 交互时显示的提示本地化 Key(如 \"INTERACT_Talk\")。\n" +
|
||||
"留空时 InteractableNPC 回退到内置字符串 \"对话\"。")]
|
||||
public string interactPromptKey = "INTERACT_Talk";
|
||||
|
||||
#if UNITY_EDITOR
|
||||
// npcId → 资产路径,5 秒 TTL,跨所有 NpcSO.OnValidate 共用,O(1) 重复检测。
|
||||
private static System.Collections.Generic.Dictionary<string, string> s_npcIdToPath;
|
||||
private static double s_npcIdsCacheTime = -10.0;
|
||||
|
||||
private static System.Collections.Generic.Dictionary<string, string> GetNpcIdCache()
|
||||
{
|
||||
double now = UnityEditor.EditorApplication.timeSinceStartup;
|
||||
if (s_npcIdToPath != null && now - s_npcIdsCacheTime < 5.0)
|
||||
return s_npcIdToPath;
|
||||
|
||||
s_npcIdToPath = new System.Collections.Generic.Dictionary<string, string>(System.StringComparer.Ordinal);
|
||||
string[] guids = UnityEditor.AssetDatabase.FindAssets("t:NpcSO");
|
||||
foreach (var guid in guids)
|
||||
{
|
||||
var path = UnityEditor.AssetDatabase.GUIDToAssetPath(guid);
|
||||
var npc = UnityEditor.AssetDatabase.LoadAssetAtPath<NpcSO>(path);
|
||||
if (npc != null && !string.IsNullOrEmpty(npc.npcId) && !s_npcIdToPath.ContainsKey(npc.npcId))
|
||||
s_npcIdToPath[npc.npcId] = path;
|
||||
}
|
||||
s_npcIdsCacheTime = now;
|
||||
return s_npcIdToPath;
|
||||
}
|
||||
|
||||
private void OnValidate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(npcId))
|
||||
{
|
||||
UnityEditor.EditorUtility.SetDirty(this);
|
||||
npcId = name;
|
||||
}
|
||||
|
||||
var cache = GetNpcIdCache();
|
||||
string myPath = UnityEditor.AssetDatabase.GetAssetPath(this);
|
||||
if (!string.IsNullOrEmpty(myPath) &&
|
||||
cache.TryGetValue(npcId, out var existingPath) &&
|
||||
existingPath != myPath)
|
||||
{
|
||||
Debug.LogError(
|
||||
$"[NpcSO] npcId '{npcId}' 与 " +
|
||||
$"'{System.IO.Path.GetFileNameWithoutExtension(existingPath)}' 重复!请修改其中一个。", this);
|
||||
s_npcIdsCacheTime = -10.0;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/Dialogue/NpcSO.cs.meta
Normal file
11
Assets/_Game/Scripts/Dialogue/NpcSO.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7a534ec2815a6bd4ebd50cf4b7bccf3e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,10 +0,0 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Dialogue
|
||||
{
|
||||
/// <summary>
|
||||
/// 标记一个 string 或 string[] 字段为世界状态标志 Key。
|
||||
/// 在 Inspector 中会显示已知标志下拉菜单,支持直接输入新标志。
|
||||
/// </summary>
|
||||
public sealed class WorldStateFlagAttribute : PropertyAttribute { }
|
||||
}
|
||||
@@ -33,6 +33,7 @@
|
||||
"BaseGames.World.Streaming",
|
||||
"BaseGames.EventChain",
|
||||
"BaseGames.VFX",
|
||||
"BaseGames.Localization",
|
||||
"Unity.InputSystem"
|
||||
],
|
||||
"includePlatforms": [
|
||||
|
||||
8
Assets/_Game/Scripts/Editor/Dialogue.meta
Normal file
8
Assets/_Game/Scripts/Editor/Dialogue.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 76d7c0ea7917c4444b0eede5ed06e14c
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,379 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEditor.UIElements;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
using BaseGames.Dialogue;
|
||||
|
||||
namespace BaseGames.Editor.Dialogue
|
||||
{
|
||||
/// <summary>
|
||||
/// 对话变体预览窗口。
|
||||
/// 给定一个 DialogueSequenceSO,模拟世界状态标志的开关组合,
|
||||
/// 实时显示各条件变体是否满足,并高亮胜出的变体。
|
||||
/// 菜单:BaseGames/Dialogue/Variant Preview
|
||||
/// </summary>
|
||||
public class DialogueVariantPreviewWindow : EditorWindow
|
||||
{
|
||||
private DialogueSequenceSO _target;
|
||||
private readonly HashSet<string> _enabledFlags = new(System.StringComparer.Ordinal);
|
||||
private readonly List<string> _allFlags = new();
|
||||
|
||||
private ObjectField _targetField;
|
||||
private VisualElement _flagContainer;
|
||||
private VisualElement _resultContainer;
|
||||
|
||||
private static readonly Color ColWin = new(0.20f, 0.75f, 0.35f, 1f);
|
||||
private static readonly Color ColFail = new(0.55f, 0.55f, 0.55f, 1f);
|
||||
private static readonly Color ColOverride = new(0.70f, 0.70f, 0.25f, 1f);
|
||||
private static readonly Color ColBlocked = new(0.85f, 0.35f, 0.30f, 1f);
|
||||
|
||||
[MenuItem("BaseGames/Dialogue/Variant Preview")]
|
||||
public static void Open()
|
||||
{
|
||||
var win = GetWindow<DialogueVariantPreviewWindow>("对话变体预览");
|
||||
win.minSize = new Vector2(480, 400);
|
||||
}
|
||||
|
||||
/// <summary>从外部打开并预填目标 SO。</summary>
|
||||
public static void OpenWith(DialogueSequenceSO target)
|
||||
{
|
||||
var win = GetWindow<DialogueVariantPreviewWindow>("对话变体预览");
|
||||
win.minSize = new Vector2(480, 400);
|
||||
win.SetTarget(target);
|
||||
}
|
||||
|
||||
private void CreateGUI()
|
||||
{
|
||||
_mockReader = new MockFlagReader(_enabledFlags);
|
||||
rootVisualElement.style.paddingLeft = 10;
|
||||
rootVisualElement.style.paddingRight = 10;
|
||||
rootVisualElement.style.paddingTop = 10;
|
||||
rootVisualElement.style.paddingBottom = 10;
|
||||
|
||||
// ── 标题栏 ──
|
||||
var header = new Label("对话变体预览工具");
|
||||
header.style.fontSize = 14;
|
||||
header.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
header.style.marginBottom = 8;
|
||||
rootVisualElement.Add(header);
|
||||
|
||||
var desc = new Label("在模拟的世界状态标志组合下,预览哪个条件变体会被选中。");
|
||||
desc.style.fontSize = 11;
|
||||
desc.style.opacity = 0.6f;
|
||||
desc.style.marginBottom = 10;
|
||||
rootVisualElement.Add(desc);
|
||||
|
||||
// ── 目标选择器 ──
|
||||
_targetField = new ObjectField("对话序列 SO")
|
||||
{
|
||||
objectType = typeof(DialogueSequenceSO),
|
||||
allowSceneObjects = false
|
||||
};
|
||||
_targetField.value = _target;
|
||||
_targetField.RegisterValueChangedCallback(evt =>
|
||||
{
|
||||
SetTarget(evt.newValue as DialogueSequenceSO);
|
||||
});
|
||||
rootVisualElement.Add(_targetField);
|
||||
|
||||
rootVisualElement.Add(MakeDivider());
|
||||
|
||||
// ── 标志模拟区 ──
|
||||
var flagHeader = new Label("模拟世界状态标志");
|
||||
flagHeader.style.fontSize = 12;
|
||||
flagHeader.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
flagHeader.style.marginBottom = 4;
|
||||
rootVisualElement.Add(flagHeader);
|
||||
|
||||
_flagContainer = new VisualElement();
|
||||
rootVisualElement.Add(_flagContainer);
|
||||
|
||||
rootVisualElement.Add(MakeDivider());
|
||||
|
||||
// ── 变体结果区 ──
|
||||
var resultHeader = new Label("变体求值结果");
|
||||
resultHeader.style.fontSize = 12;
|
||||
resultHeader.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
resultHeader.style.marginBottom = 4;
|
||||
rootVisualElement.Add(resultHeader);
|
||||
|
||||
var scrollView = new ScrollView(ScrollViewMode.Vertical);
|
||||
scrollView.style.flexGrow = 1;
|
||||
rootVisualElement.Add(scrollView);
|
||||
|
||||
_resultContainer = new VisualElement();
|
||||
scrollView.Add(_resultContainer);
|
||||
|
||||
Rebuild();
|
||||
}
|
||||
|
||||
private void SetTarget(DialogueSequenceSO target)
|
||||
{
|
||||
_target = target;
|
||||
_enabledFlags.Clear();
|
||||
if (_targetField != null && _targetField.value != target)
|
||||
_targetField.SetValueWithoutNotify(target);
|
||||
Rebuild();
|
||||
}
|
||||
|
||||
// ── 重建 ─────────────────────────────────────────────────────────────
|
||||
|
||||
private void Rebuild()
|
||||
{
|
||||
RebuildFlagToggles();
|
||||
RebuildResults();
|
||||
}
|
||||
|
||||
private void RebuildFlagToggles()
|
||||
{
|
||||
if (_flagContainer == null) return;
|
||||
_flagContainer.Clear();
|
||||
_allFlags.Clear();
|
||||
|
||||
if (_target == null || _target.variants == null || _target.variants.Length == 0)
|
||||
{
|
||||
var empty = new Label(_target == null
|
||||
? "(请选择一个 DialogueSequenceSO)"
|
||||
: "(该序列无条件变体,无需模拟)");
|
||||
empty.style.opacity = 0.5f;
|
||||
empty.style.fontSize = 11;
|
||||
_flagContainer.Add(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
// 收集所有变体中涉及的 Flag
|
||||
var flagSet = new HashSet<string>(System.StringComparer.Ordinal);
|
||||
foreach (var v in _target.variants)
|
||||
{
|
||||
if (v.requiredFlags != null)
|
||||
foreach (var f in v.requiredFlags)
|
||||
if (!string.IsNullOrEmpty(f)) flagSet.Add(f);
|
||||
}
|
||||
_allFlags.AddRange(flagSet.OrderBy(x => x));
|
||||
|
||||
if (_allFlags.Count == 0)
|
||||
{
|
||||
var empty = new Label("(变体未使用任何 requiredFlags)");
|
||||
empty.style.opacity = 0.5f;
|
||||
empty.style.fontSize = 11;
|
||||
_flagContainer.Add(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
// 全选 / 全不选 快速按钮
|
||||
var btnRow = new VisualElement();
|
||||
btnRow.style.flexDirection = FlexDirection.Row;
|
||||
btnRow.style.marginBottom = 4;
|
||||
|
||||
var btnAll = new Button(() =>
|
||||
{
|
||||
foreach (var f in _allFlags) _enabledFlags.Add(f);
|
||||
Rebuild();
|
||||
}) { text = "全选" };
|
||||
btnAll.style.fontSize = 10;
|
||||
btnAll.style.height = 18;
|
||||
btnRow.Add(btnAll);
|
||||
|
||||
var btnNone = new Button(() =>
|
||||
{
|
||||
_enabledFlags.Clear();
|
||||
Rebuild();
|
||||
}) { text = "全不选" };
|
||||
btnNone.style.fontSize = 10;
|
||||
btnNone.style.height = 18;
|
||||
btnRow.Add(btnNone);
|
||||
|
||||
_flagContainer.Add(btnRow);
|
||||
|
||||
// 每个 Flag 对应一个 Toggle
|
||||
foreach (var flag in _allFlags)
|
||||
{
|
||||
bool isOn = _enabledFlags.Contains(flag);
|
||||
var toggle = new Toggle(flag) { value = isOn };
|
||||
toggle.style.fontSize = 11;
|
||||
toggle.RegisterValueChangedCallback(evt =>
|
||||
{
|
||||
if (evt.newValue) _enabledFlags.Add(flag);
|
||||
else _enabledFlags.Remove(flag);
|
||||
RebuildResults();
|
||||
});
|
||||
_flagContainer.Add(toggle);
|
||||
}
|
||||
}
|
||||
|
||||
private void RebuildResults()
|
||||
{
|
||||
if (_resultContainer == null) return;
|
||||
_resultContainer.Clear();
|
||||
|
||||
if (_target == null)
|
||||
return;
|
||||
|
||||
if (_target.variants == null || _target.variants.Length == 0)
|
||||
{
|
||||
var msg = new Label("(序列无条件变体,直接使用本序列默认台词)");
|
||||
msg.style.opacity = 0.5f;
|
||||
msg.style.fontSize = 11;
|
||||
_resultContainer.Add(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
bool winnerFound = false;
|
||||
|
||||
for (int i = 0; i < _target.variants.Length; i++)
|
||||
{
|
||||
var variant = _target.variants[i];
|
||||
var row = BuildVariantRow(i, variant, winnerFound);
|
||||
_resultContainer.Add(row);
|
||||
|
||||
if (!winnerFound && EvaluateVariant(variant))
|
||||
winnerFound = true;
|
||||
}
|
||||
|
||||
// 若无变体胜出,提示将回退到本序列默认台词
|
||||
if (!winnerFound)
|
||||
{
|
||||
var fallback = new Label("↳ 无变体满足,将使用本序列默认台词(无变体覆盖)");
|
||||
fallback.style.fontSize = 11;
|
||||
fallback.style.opacity = 0.6f;
|
||||
fallback.style.marginTop = 4;
|
||||
_resultContainer.Add(fallback);
|
||||
}
|
||||
}
|
||||
|
||||
private VisualElement BuildVariantRow(int index, DialogueSequenceSO.ConditionalVariant variant, bool higherWon)
|
||||
{
|
||||
bool condMet = EvaluateVariant(variant);
|
||||
bool isWinner = condMet && !higherWon;
|
||||
|
||||
var card = new VisualElement();
|
||||
card.style.borderLeftWidth = 3;
|
||||
card.style.paddingLeft = 8;
|
||||
card.style.paddingRight = 8;
|
||||
card.style.paddingTop = 5;
|
||||
card.style.paddingBottom = 5;
|
||||
card.style.marginBottom = 4;
|
||||
card.style.backgroundColor = new StyleColor(new Color(0.18f, 0.18f, 0.18f, 1f));
|
||||
|
||||
Color borderColor;
|
||||
string statusText;
|
||||
Color statusColor;
|
||||
|
||||
if (isWinner)
|
||||
{
|
||||
borderColor = ColWin;
|
||||
statusText = "✓ 胜出";
|
||||
statusColor = ColWin;
|
||||
}
|
||||
else if (condMet)
|
||||
{
|
||||
borderColor = ColOverride;
|
||||
statusText = "⏩ 被更高优先级覆盖";
|
||||
statusColor = ColOverride;
|
||||
}
|
||||
else
|
||||
{
|
||||
borderColor = ColFail;
|
||||
statusText = "✗ 条件不满足";
|
||||
statusColor = ColFail;
|
||||
}
|
||||
card.style.borderLeftColor = new StyleColor(borderColor);
|
||||
|
||||
// 标题行
|
||||
var titleRow = new VisualElement();
|
||||
titleRow.style.flexDirection = FlexDirection.Row;
|
||||
titleRow.style.alignItems = Align.Center;
|
||||
titleRow.style.marginBottom = 3;
|
||||
|
||||
var idxLabel = new Label($"变体 {index}");
|
||||
idxLabel.style.fontSize = 11;
|
||||
idxLabel.style.flexGrow = 1;
|
||||
idxLabel.style.unityFontStyleAndWeight = isWinner ? FontStyle.Bold : FontStyle.Normal;
|
||||
titleRow.Add(idxLabel);
|
||||
|
||||
var seqName = new Label(variant.sequence != null ? variant.sequence.name : "(未设置序列)");
|
||||
seqName.style.fontSize = 10;
|
||||
seqName.style.opacity = 0.6f;
|
||||
seqName.style.width = 160;
|
||||
titleRow.Add(seqName);
|
||||
|
||||
var statusLabel = new Label(statusText);
|
||||
statusLabel.style.fontSize = 10;
|
||||
statusLabel.style.color = new StyleColor(statusColor);
|
||||
statusLabel.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
titleRow.Add(statusLabel);
|
||||
card.Add(titleRow);
|
||||
|
||||
// 逻辑类型
|
||||
var logicLabel = new Label($"逻辑:{variant.logic}");
|
||||
logicLabel.style.fontSize = 10;
|
||||
logicLabel.style.opacity = 0.5f;
|
||||
card.Add(logicLabel);
|
||||
|
||||
// 条件详情
|
||||
if (variant.requiredFlags != null && variant.requiredFlags.Length > 0)
|
||||
{
|
||||
foreach (var flag in variant.requiredFlags)
|
||||
{
|
||||
if (string.IsNullOrEmpty(flag)) continue;
|
||||
bool flagOn = _enabledFlags.Contains(flag);
|
||||
var flagRow = new VisualElement();
|
||||
flagRow.style.flexDirection = FlexDirection.Row;
|
||||
flagRow.style.alignItems = Align.Center;
|
||||
flagRow.style.marginTop = 1;
|
||||
|
||||
var icon = new Label(flagOn ? "✓" : "✗");
|
||||
icon.style.fontSize = 10;
|
||||
icon.style.color = new StyleColor(flagOn ? ColWin : ColBlocked);
|
||||
icon.style.width = 16;
|
||||
flagRow.Add(icon);
|
||||
|
||||
var flagLabel = new Label(flag);
|
||||
flagLabel.style.fontSize = 10;
|
||||
flagRow.Add(flagLabel);
|
||||
|
||||
card.Add(flagRow);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var noFlags = new Label("(无 requiredFlags — 无条件激活)");
|
||||
noFlags.style.fontSize = 10;
|
||||
noFlags.style.opacity = 0.5f;
|
||||
card.Add(noFlags);
|
||||
}
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
private bool EvaluateVariant(DialogueSequenceSO.ConditionalVariant variant)
|
||||
{
|
||||
// 使用 DialogueSequenceSO.CheckVariant 统一变体求值逻辑,避免重复实现
|
||||
return _target != null && _target.CheckVariant(variant, _mockReader);
|
||||
}
|
||||
|
||||
/// <summary>将 _enabledFlags 包装为 IWorldStateReader,供 CheckVariant 调用。</summary>
|
||||
private MockFlagReader _mockReader;
|
||||
|
||||
private sealed class MockFlagReader : BaseGames.Core.IWorldStateReader
|
||||
{
|
||||
private readonly System.Collections.Generic.HashSet<string> _flags;
|
||||
public MockFlagReader(System.Collections.Generic.HashSet<string> flags) => _flags = flags;
|
||||
public bool HasFlag(string key) => _flags.Contains(key);
|
||||
}
|
||||
|
||||
// ── 辅助 ─────────────────────────────────────────────────────────────
|
||||
|
||||
private static VisualElement MakeDivider()
|
||||
{
|
||||
var d = new VisualElement();
|
||||
d.style.height = 1;
|
||||
d.style.backgroundColor = new StyleColor(new Color(0.35f, 0.35f, 0.35f, 0.5f));
|
||||
d.style.marginTop = 6;
|
||||
d.style.marginBottom = 6;
|
||||
return d;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,8 @@ namespace BaseGames.Editor.Dialogue
|
||||
|
||||
if (registry == null)
|
||||
{
|
||||
EditorGUILayout.HelpBox("未指定 WorldStateRegistry,无法预览激活状态。\n请在 Inspector 中设置 World State 字段。", MessageType.Warning);
|
||||
EditorGUILayout.HelpBox("未指定 WorldStateRegistry,无法预览激活状态。\n" +
|
||||
"可在 Inspector 中设置 World State 字段,或确保已通过 ServiceLocator 注册全局 IWorldStateReader。", MessageType.Warning);
|
||||
DrawVersionLabelsOnly(versionsProp);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8217e62b4f33e3547895b6884c06bbea
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using BaseGames.Core;
|
||||
using BaseGames.Dialogue;
|
||||
using BaseGames.EventChain;
|
||||
using BaseGames.Editor;
|
||||
@@ -35,6 +36,15 @@ namespace BaseGames.Editor.Dialogue
|
||||
if (GUI.Button(btnRect, EditorGUIUtility.IconContent("d_icon dropdown"), EditorStyles.iconButton))
|
||||
{
|
||||
var flags = WorldStateFlagCollector.CollectKnownFlags();
|
||||
var registry = BaseGames.Core.WorldFlagRegistrySO.EditorInstance;
|
||||
// 构建注册表 id→entry 快速查找,用于在菜单项中显示分组和描述
|
||||
var registryMap = new System.Collections.Generic.Dictionary<string, BaseGames.Core.FlagEntry>(
|
||||
System.StringComparer.Ordinal);
|
||||
if (registry?.flags != null)
|
||||
foreach (var e in registry.flags)
|
||||
if (e != null && !string.IsNullOrEmpty(e.id))
|
||||
registryMap[e.id] = e;
|
||||
|
||||
var menu = new GenericMenu();
|
||||
var current = property.stringValue;
|
||||
var propPath = property.propertyPath;
|
||||
@@ -43,8 +53,12 @@ namespace BaseGames.Editor.Dialogue
|
||||
foreach (var flag in flags)
|
||||
{
|
||||
var captured = flag;
|
||||
// 显示格式:分组/ID (分组来自注册表;无注册表条目则仅显示 ID)
|
||||
string menuPath = registryMap.TryGetValue(flag, out var entry) && !string.IsNullOrEmpty(entry.group)
|
||||
? $"{entry.group}/{flag}"
|
||||
: flag;
|
||||
menu.AddItem(
|
||||
new GUIContent(captured),
|
||||
new GUIContent(menuPath),
|
||||
current == captured,
|
||||
() =>
|
||||
{
|
||||
@@ -62,6 +76,8 @@ namespace BaseGames.Editor.Dialogue
|
||||
|
||||
menu.AddSeparator(string.Empty);
|
||||
menu.AddItem(new GUIContent("刷新列表"), false, WorldStateFlagCollector.Invalidate);
|
||||
if (registry == null)
|
||||
menu.AddDisabledItem(new GUIContent("⚠ 未找到 WorldFlagRegistry.asset(可在 BaseGames 菜单创建)"));
|
||||
|
||||
menu.ShowAsContext();
|
||||
}
|
||||
@@ -139,6 +155,12 @@ namespace BaseGames.Editor.Dialogue
|
||||
|
||||
private static void CollectSoFlags(SortedSet<string> found)
|
||||
{
|
||||
// 优先加入注册表中的"官方"标志(排在下拉前列)
|
||||
var registry = BaseGames.Core.WorldFlagRegistrySO.EditorInstance;
|
||||
if (registry?.flags != null)
|
||||
foreach (var entry in registry.flags)
|
||||
if (!string.IsNullOrWhiteSpace(entry?.id)) found.Add(entry.id.Trim());
|
||||
|
||||
foreach (var a in AssetOperations.FindAll<SetFlagAction>())
|
||||
if (!string.IsNullOrWhiteSpace(a.flagId)) found.Add(a.flagId.Trim());
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 787ab8ef75ae1cf439b9f7ee6bf21692
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -37,6 +37,9 @@ namespace BaseGames.Editor
|
||||
private IDataModule _activeModule;
|
||||
|
||||
private VisualElement _navSidebar;
|
||||
// NavBar 搜索:按 DisplayName 过滤可见模块
|
||||
private string _navFilter = "";
|
||||
private readonly Dictionary<string, Button> _navButtons = new();
|
||||
|
||||
// 缓存:列表区和详情区引用(由 TwoPaneSplitView 子节点提供)
|
||||
private VisualElement _listWrapper;
|
||||
@@ -70,16 +73,31 @@ namespace BaseGames.Editor
|
||||
private void RegisterModules()
|
||||
{
|
||||
_modules.Clear();
|
||||
_modules.Add(new WeaponModule());
|
||||
_modules.Add(new SkillModule());
|
||||
_modules.Add(new EnemyModule());
|
||||
_modules.Add(new FormModule());
|
||||
_modules.Add(new BossSkillModule());
|
||||
_modules.Add(new CharmModule());
|
||||
_modules.Add(new StreamingModule());
|
||||
_modules.Add(new DialogueModule());
|
||||
_modules.Add(new QuestModule());
|
||||
_modules.Add(new ActorModule());
|
||||
|
||||
// 自动发现所有实现 IDataModule 的非抽象类型(排除接口/抽象类本身)
|
||||
// TypeCache 仅在 UnityEditor 中可用,零运行时开销
|
||||
var moduleTypes = UnityEditor.TypeCache.GetTypesDerivedFrom<IDataModule>();
|
||||
var instances = new List<IDataModule>(moduleTypes.Count);
|
||||
foreach (var t in moduleTypes)
|
||||
{
|
||||
if (t.IsAbstract || t.IsInterface) continue;
|
||||
try { instances.Add((IDataModule)Activator.CreateInstance(t)); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"[DataHubWindow] 无法实例化模块 {t.Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// 按 DisplayOrder 升序排列(若模块实现了 IDataModuleOrdered 则使用其值,否则默认 0)
|
||||
instances.Sort((a, b) =>
|
||||
{
|
||||
int oa = a is IDataModuleOrdered ao ? ao.DisplayOrder : 0;
|
||||
int ob = b is IDataModuleOrdered bo ? bo.DisplayOrder : 0;
|
||||
int c = oa.CompareTo(ob);
|
||||
return c != 0 ? c : string.Compare(a.DisplayName, b.DisplayName, StringComparison.OrdinalIgnoreCase);
|
||||
});
|
||||
|
||||
_modules.AddRange(instances);
|
||||
}
|
||||
|
||||
// ── 布局 ─────────────────────────────────────────────────────────────
|
||||
@@ -136,13 +154,31 @@ namespace BaseGames.Editor
|
||||
title.style.fontSize = 10;
|
||||
title.style.opacity = 0.5f;
|
||||
title.style.paddingLeft = 10;
|
||||
title.style.marginBottom = 6;
|
||||
title.style.marginBottom = 4;
|
||||
title.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
sidebar.Add(title);
|
||||
|
||||
// 搜索框
|
||||
var searchField = new TextField();
|
||||
searchField.style.marginLeft = 6;
|
||||
searchField.style.marginRight = 6;
|
||||
searchField.style.marginBottom = 6;
|
||||
searchField.tooltip = "按模块名称过滤";
|
||||
// 自定义占位符效果
|
||||
if (string.IsNullOrEmpty(searchField.value))
|
||||
searchField.value = "";
|
||||
searchField.RegisterValueChangedCallback(evt =>
|
||||
{
|
||||
_navFilter = evt.newValue ?? "";
|
||||
ApplyNavFilter();
|
||||
});
|
||||
sidebar.Add(searchField);
|
||||
|
||||
_navButtons.Clear();
|
||||
foreach (var module in _modules)
|
||||
{
|
||||
var btn = BuildNavItem(module);
|
||||
_navButtons[module.ModuleId] = btn;
|
||||
sidebar.Add(btn);
|
||||
}
|
||||
|
||||
@@ -154,6 +190,18 @@ namespace BaseGames.Editor
|
||||
return sidebar;
|
||||
}
|
||||
|
||||
private void ApplyNavFilter()
|
||||
{
|
||||
foreach (var module in _modules)
|
||||
{
|
||||
if (!_navButtons.TryGetValue(module.ModuleId, out var btn)) continue;
|
||||
bool visible = string.IsNullOrEmpty(_navFilter) ||
|
||||
module.DisplayName.IndexOf(_navFilter, System.StringComparison.OrdinalIgnoreCase) >= 0 ||
|
||||
module.ModuleId.IndexOf(_navFilter, System.StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
btn.style.display = visible ? DisplayStyle.Flex : DisplayStyle.None;
|
||||
}
|
||||
}
|
||||
|
||||
private Button BuildNavItem(IDataModule module)
|
||||
{
|
||||
var btn = new Button(() => ActivateModule(module));
|
||||
|
||||
@@ -25,4 +25,17 @@ namespace BaseGames.Editor
|
||||
/// <summary>切换到本模块时调用,可用于刷新数据。</summary>
|
||||
void OnActivated();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 可选排序接口。DataHubWindow 自动发现模块时按 <see cref="DisplayOrder"/> 升序排列。
|
||||
/// 未实现此接口的模块默认顺序为 0,再按 DisplayName 字母序排列。
|
||||
/// </summary>
|
||||
public interface IDataModuleOrdered
|
||||
{
|
||||
/// <summary>
|
||||
/// 导航侧边栏排列顺序。数值越小越靠前。
|
||||
/// 建议使用 10, 20, 30… 间隔,便于插入新模块。
|
||||
/// </summary>
|
||||
int DisplayOrder { get; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace BaseGames.Editor.Modules
|
||||
/// DataHub 对话角色模块 —— 管理 DialogueActorSO 资产。
|
||||
/// 统一查看、创建、重命名、删除 NPC/玩家角色定义(头像、名称 Key、强调色)。
|
||||
/// </summary>
|
||||
public class ActorModule : IDataModule
|
||||
public class ActorModule : IDataModule, IDataModuleOrdered
|
||||
{
|
||||
private const string Folder = "Assets/_Game/Data/Dialogue/Actors";
|
||||
private const string Prefix = "Actor_";
|
||||
@@ -19,6 +19,7 @@ namespace BaseGames.Editor.Modules
|
||||
public string ModuleId => "actor";
|
||||
public string DisplayName => "角色";
|
||||
public string IconName => "d_Prefab Icon";
|
||||
public int DisplayOrder => 80;
|
||||
|
||||
private SoListPane<DialogueActorSO> _listPane;
|
||||
private DetailHeader _header;
|
||||
@@ -76,7 +77,15 @@ namespace BaseGames.Editor.Modules
|
||||
var card = SkillModule.MakeCard();
|
||||
|
||||
SkillModule.AddChip(card, "ID", string.IsNullOrEmpty(a.actorId) ? "(未设置)" : a.actorId);
|
||||
SkillModule.AddChip(card, "名称 Key", string.IsNullOrEmpty(a.nameKey) ? "(未设置)" : a.nameKey);
|
||||
|
||||
// 名称:优先显示本地化实际文本,回退到 key 本身
|
||||
string nameDisplay = string.IsNullOrEmpty(a.nameKey)
|
||||
? "(未设置)"
|
||||
: (BaseGames.Localization.LocalizationManager.GetEditorPreview(a.nameKey, "Dialogue") ?? a.nameKey);
|
||||
SkillModule.AddChip(card, "名称", nameDisplay);
|
||||
if (!string.IsNullOrEmpty(a.nameKey))
|
||||
SkillModule.AddChip(card, "名称 Key", a.nameKey);
|
||||
|
||||
if (a.isPlayer)
|
||||
SkillModule.AddChip(card, "类型", "玩家");
|
||||
|
||||
|
||||
11
Assets/_Game/Scripts/Editor/Modules/ActorModule.cs.meta
Normal file
11
Assets/_Game/Scripts/Editor/Modules/ActorModule.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1432dc664312c954e9b4adb0cbb6f25e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -10,7 +10,7 @@ namespace BaseGames.Editor.Modules
|
||||
/// <summary>
|
||||
/// DataHub Boss技能模块 —— Tab 切换管理 BossSkillSO 和 SkillSequenceSO。
|
||||
/// </summary>
|
||||
public class BossSkillModule : IDataModule
|
||||
public class BossSkillModule : IDataModule, IDataModuleOrdered
|
||||
{
|
||||
private const string SkillFolder = "Assets/_Game/Data/Boss/Skills";
|
||||
private const string SeqFolder = "Assets/_Game/Data/Boss/Sequences";
|
||||
@@ -18,6 +18,7 @@ namespace BaseGames.Editor.Modules
|
||||
public string ModuleId => "boss";
|
||||
public string DisplayName => "Boss技能";
|
||||
public string IconName => null;
|
||||
public int DisplayOrder => 50;
|
||||
|
||||
private int _activeTab = 0;
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace BaseGames.Editor.Modules
|
||||
/// <summary>
|
||||
/// DataHub 护符模块 —— Tab 切换管理 CharmCatalogSO(目录)和 CharmSO(护符)资产。
|
||||
/// </summary>
|
||||
public class CharmModule : IDataModule
|
||||
public class CharmModule : IDataModule, IDataModuleOrdered
|
||||
{
|
||||
private const string CharmFolder = "Assets/_Game/Data/Progression/Charms";
|
||||
private const string CatalogPrefix = "CHM_Catalog";
|
||||
@@ -19,6 +19,7 @@ namespace BaseGames.Editor.Modules
|
||||
public string ModuleId => "charm";
|
||||
public string DisplayName => "护符";
|
||||
public string IconName => null;
|
||||
public int DisplayOrder => 60;
|
||||
|
||||
private int _activeTab = 0; // 0 = 目录, 1 = 护符
|
||||
|
||||
|
||||
@@ -4,13 +4,15 @@ using UnityEditor.UIElements;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
using BaseGames.Dialogue;
|
||||
using BaseGames.Editor.Dialogue;
|
||||
using BaseGames.Editor.Shared;
|
||||
|
||||
namespace BaseGames.Editor.Modules
|
||||
{
|
||||
/// <summary>
|
||||
/// DataHub 对话序列模块 —— 管理 DialogueSequenceSO 资产。
|
||||
/// </summary>
|
||||
public class DialogueModule : IDataModule
|
||||
public class DialogueModule : IDataModule, IDataModuleOrdered
|
||||
{
|
||||
private const string Folder = "Assets/_Game/Data/Dialogue";
|
||||
private const string Prefix = "DLG_";
|
||||
@@ -18,6 +20,7 @@ namespace BaseGames.Editor.Modules
|
||||
public string ModuleId => "dialogue";
|
||||
public string DisplayName => "对话";
|
||||
public string IconName => "d_UnityEditor.ConsoleWindow";
|
||||
public int DisplayOrder => 100;
|
||||
|
||||
private SoListPane<DialogueSequenceSO> _listPane;
|
||||
private DetailHeader _header;
|
||||
@@ -29,9 +32,20 @@ namespace BaseGames.Editor.Modules
|
||||
Folder, Prefix,
|
||||
s =>
|
||||
{
|
||||
int v = s.variants != null ? s.variants.Length : 0;
|
||||
return v > 0 ? $"{v}变体" : null;
|
||||
if (s.variants == null || s.variants.Length == 0) return null;
|
||||
int v = s.variants.Length;
|
||||
// 检测无条件变体遮蔽(非末尾的无条件变体会让后续变体永不命中)
|
||||
bool hasShadow = false;
|
||||
for (int i = 0; i < s.variants.Length - 1; i++)
|
||||
{
|
||||
var vv = s.variants[i];
|
||||
if (vv.sequence != null && (vv.requiredFlags == null || vv.requiredFlags.Length == 0))
|
||||
{ hasShadow = true; break; }
|
||||
}
|
||||
return hasShadow ? $"{v}变体 ⚠" : $"{v}变体";
|
||||
});
|
||||
// 扩展搜索:sequenceId
|
||||
_listPane.GetExtraSearchText = d => d.sequenceId;
|
||||
}
|
||||
|
||||
public void BuildListPane(VisualElement container, Action<UnityEngine.Object> onSelected)
|
||||
@@ -41,6 +55,52 @@ namespace BaseGames.Editor.Modules
|
||||
_selected = sel;
|
||||
onSelected?.Invoke(sel);
|
||||
};
|
||||
|
||||
// ── 快速过滤标签行 ────────────────────────────────────────────
|
||||
var filterRow = new VisualElement();
|
||||
filterRow.style.flexDirection = FlexDirection.Row;
|
||||
filterRow.style.flexWrap = Wrap.Wrap;
|
||||
filterRow.style.paddingLeft = 6;
|
||||
filterRow.style.paddingRight = 6;
|
||||
filterRow.style.paddingBottom = 3;
|
||||
container.Add(filterRow);
|
||||
|
||||
bool filterVariants = false, filterBranches = false, filterNoVoice = false;
|
||||
|
||||
void RebuildFilter()
|
||||
{
|
||||
if (!filterVariants && !filterBranches && !filterNoVoice)
|
||||
{
|
||||
_listPane.ExtraFilter = null;
|
||||
return;
|
||||
}
|
||||
_listPane.ExtraFilter = s =>
|
||||
{
|
||||
if (filterVariants && (s.variants == null || s.variants.Length == 0)) return false;
|
||||
if (filterBranches)
|
||||
{
|
||||
bool hasBranch = false;
|
||||
if (s.lines != null)
|
||||
foreach (var l in s.lines)
|
||||
if (l.choices != null && l.choices.Length > 0) { hasBranch = true; break; }
|
||||
if (!hasBranch) return false;
|
||||
}
|
||||
if (filterNoVoice)
|
||||
{
|
||||
bool hasVoice = false;
|
||||
if (s.lines != null)
|
||||
foreach (var l in s.lines)
|
||||
if (l.voiceClip != null) { hasVoice = true; break; }
|
||||
if (hasVoice) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
filterRow.Add(QuestModule.MakeFilterChip("有变体", v => { filterVariants = v; RebuildFilter(); }));
|
||||
filterRow.Add(QuestModule.MakeFilterChip("有分支", v => { filterBranches = v; RebuildFilter(); }));
|
||||
filterRow.Add(QuestModule.MakeFilterChip("无语音", v => { filterNoVoice = v; RebuildFilter(); }));
|
||||
|
||||
container.Add(_listPane);
|
||||
_listPane.Refresh();
|
||||
}
|
||||
@@ -90,8 +150,11 @@ namespace BaseGames.Editor.Modules
|
||||
return card;
|
||||
}
|
||||
|
||||
private static VisualElement BuildLinesPreview(DialogueSequenceSO s)
|
||||
private VisualElement BuildLinesPreview(DialogueSequenceSO s)
|
||||
{
|
||||
var so = new SerializedObject(s);
|
||||
var linesProp = so.FindProperty("lines");
|
||||
|
||||
var section = new VisualElement();
|
||||
section.style.paddingLeft = 12;
|
||||
section.style.paddingRight = 12;
|
||||
@@ -114,14 +177,14 @@ namespace BaseGames.Editor.Modules
|
||||
return section;
|
||||
}
|
||||
|
||||
int preview = Mathf.Min(5, s.lines.Length);
|
||||
for (int i = 0; i < preview; i++)
|
||||
int previewCount = Mathf.Min(5, s.lines.Length);
|
||||
for (int i = 0; i < previewCount; i++)
|
||||
{
|
||||
var line = s.lines[i];
|
||||
var row = new VisualElement();
|
||||
row.style.flexDirection = FlexDirection.Row;
|
||||
row.style.alignItems = Align.Center;
|
||||
row.style.marginBottom = 3;
|
||||
row.style.marginBottom = 2;
|
||||
|
||||
// 头像图标(actor 优先,回退到直接字段)
|
||||
var portrait = line.ResolvedPortrait;
|
||||
@@ -152,36 +215,126 @@ namespace BaseGames.Editor.Modules
|
||||
}
|
||||
}
|
||||
|
||||
// 说话人(actor 优先,回退到直接字段)
|
||||
// 说话人(actor 优先,回退到直接字段;尝试解析本地化实际文本)
|
||||
string speakerKey = line.ResolvedNameKey;
|
||||
if (!string.IsNullOrEmpty(speakerKey))
|
||||
{
|
||||
var spk = new Label(speakerKey + ":");
|
||||
var speakerResolved = BaseGames.Localization.LocalizationManager.GetEditorPreview(speakerKey, "Dialogue");
|
||||
bool speakerMissing = speakerResolved == null;
|
||||
string speakerText = speakerMissing ? speakerKey : speakerResolved;
|
||||
var spk = new Label(speakerText + ":");
|
||||
spk.style.fontSize = 11;
|
||||
spk.style.opacity = 0.55f;
|
||||
spk.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
spk.style.marginRight = 4;
|
||||
spk.style.flexShrink = 0;
|
||||
var accent = line.ResolvedAccentColor;
|
||||
if (speakerMissing)
|
||||
{
|
||||
// 说话人 Key 缺少本地化 → 橙色警告
|
||||
spk.style.color = new StyleColor(new Color(1f, 0.6f, 0.1f));
|
||||
spk.style.opacity = 1.0f;
|
||||
}
|
||||
else if (accent != Color.white)
|
||||
{
|
||||
spk.style.color = new StyleColor(accent);
|
||||
spk.style.opacity = 1.0f;
|
||||
}
|
||||
else
|
||||
{
|
||||
spk.style.opacity = 0.55f;
|
||||
}
|
||||
row.Add(spk);
|
||||
}
|
||||
|
||||
// 文本 key(尝试显示本地化实际内容,回退到 key 本身)
|
||||
string rawText = string.IsNullOrEmpty(line.textKey) ? "(空)" : line.textKey;
|
||||
string preview = string.IsNullOrEmpty(line.textKey)
|
||||
? "(空)"
|
||||
: (BaseGames.Localization.LocalizationManager.GetEditorPreview(line.textKey, "Dialogue") ?? rawText);
|
||||
if (preview.Length > 48) preview = preview[..48] + "…";
|
||||
var lbl = new Label(preview);
|
||||
// 文本(本地化预览;Key 有值但无本地化内容时橙色 ⚠ 警告)
|
||||
string textPreview;
|
||||
bool textL10nMissing = false;
|
||||
if (string.IsNullOrEmpty(line.textKey))
|
||||
{
|
||||
textPreview = "(空)";
|
||||
}
|
||||
else
|
||||
{
|
||||
var resolved = BaseGames.Localization.LocalizationManager.GetEditorPreview(line.textKey, "Dialogue");
|
||||
if (resolved != null)
|
||||
{
|
||||
textPreview = resolved;
|
||||
}
|
||||
else
|
||||
{
|
||||
textPreview = line.textKey + " ⚠";
|
||||
textL10nMissing = true;
|
||||
}
|
||||
}
|
||||
if (textPreview.Length > 48) textPreview = textPreview[..48] + "…";
|
||||
var lbl = new Label(textPreview);
|
||||
lbl.style.fontSize = 11;
|
||||
lbl.style.overflow = Overflow.Hidden;
|
||||
lbl.style.flexGrow = 1;
|
||||
if (textL10nMissing)
|
||||
lbl.style.color = new StyleColor(new Color(1f, 0.6f, 0.1f));
|
||||
row.Add(lbl);
|
||||
|
||||
// 选项分支徽章(有 choices 时显示"→N选")
|
||||
if (line.choices != null && line.choices.Length > 0)
|
||||
{
|
||||
var choiceBadge = new Label($"→{line.choices.Length}选");
|
||||
choiceBadge.style.fontSize = 9;
|
||||
choiceBadge.style.color = new StyleColor(new Color(0.3f, 0.8f, 1f));
|
||||
choiceBadge.style.marginLeft = 4;
|
||||
choiceBadge.style.flexShrink = 0;
|
||||
row.Add(choiceBadge);
|
||||
}
|
||||
|
||||
// ▾ 内联编辑按钮
|
||||
var editBtn = new Button { text = "▾" };
|
||||
editBtn.style.fontSize = 9;
|
||||
editBtn.style.marginLeft = 4;
|
||||
editBtn.style.paddingLeft = 3;
|
||||
editBtn.style.paddingRight = 3;
|
||||
editBtn.style.height = 16;
|
||||
row.Add(editBtn);
|
||||
|
||||
// 内联编辑区域(默认隐藏,点击 ▾ 展开)
|
||||
int capturedIdx = i;
|
||||
var editRow = new VisualElement();
|
||||
editRow.style.display = DisplayStyle.None;
|
||||
editRow.style.paddingLeft = 26;
|
||||
editRow.style.paddingRight = 8;
|
||||
editRow.style.marginBottom = 4;
|
||||
|
||||
if (linesProp != null)
|
||||
{
|
||||
var lineProp = linesProp.GetArrayElementAtIndex(capturedIdx);
|
||||
var textKeyProp = lineProp?.FindPropertyRelative("textKey");
|
||||
if (textKeyProp != null)
|
||||
{
|
||||
var tf = new TextField("文本 Key") { value = textKeyProp.stringValue };
|
||||
tf.style.fontSize = 10;
|
||||
tf.RegisterValueChangedCallback(evt =>
|
||||
{
|
||||
so.Update();
|
||||
textKeyProp.stringValue = evt.newValue;
|
||||
so.ApplyModifiedProperties();
|
||||
});
|
||||
editRow.Add(tf);
|
||||
}
|
||||
}
|
||||
|
||||
editBtn.clicked += () =>
|
||||
{
|
||||
bool open = editRow.style.display == DisplayStyle.None;
|
||||
editRow.style.display = open ? DisplayStyle.Flex : DisplayStyle.None;
|
||||
editBtn.text = open ? "▴" : "▾";
|
||||
};
|
||||
|
||||
section.Add(row);
|
||||
section.Add(editRow);
|
||||
}
|
||||
|
||||
if (s.lines.Length > preview)
|
||||
if (s.lines.Length > previewCount)
|
||||
{
|
||||
var more = new Label($"… 还有 {s.lines.Length - preview} 行");
|
||||
var more = new Label($"… 还有 {s.lines.Length - previewCount} 行");
|
||||
more.style.opacity = 0.4f;
|
||||
more.style.fontSize = 10;
|
||||
section.Add(more);
|
||||
@@ -202,29 +355,207 @@ namespace BaseGames.Editor.Modules
|
||||
title.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
card.Add(title);
|
||||
|
||||
foreach (var v in s.variants)
|
||||
for (int i = 0; i < s.variants.Length; i++)
|
||||
{
|
||||
var v = s.variants[i];
|
||||
var row = new VisualElement();
|
||||
row.style.flexDirection = FlexDirection.Row;
|
||||
row.style.marginBottom = 2;
|
||||
|
||||
string flags = v.requiredFlags != null && v.requiredFlags.Length > 0
|
||||
? string.Join(", ", v.requiredFlags)
|
||||
: "(无条件)";
|
||||
SkillModule.AddChip(row, "条件", flags);
|
||||
SkillModule.AddChip(row, "替换序列", v.sequence != null ? v.sequence.name : "(未设置)");
|
||||
bool isUnconditional = v.requiredFlags == null || v.requiredFlags.Length == 0;
|
||||
bool isShadowing = isUnconditional && v.sequence != null && i < s.variants.Length - 1;
|
||||
|
||||
// 条件徽章:显示逻辑模式(And/Or)和标志列表
|
||||
string conditionText;
|
||||
if (isUnconditional)
|
||||
{
|
||||
conditionText = isShadowing ? "⚠ (无条件)" : "(无条件)";
|
||||
}
|
||||
else
|
||||
{
|
||||
string logicPrefix = v.requiredFlags.Length > 1
|
||||
? $"[{(v.logic == BaseGames.Core.WorldStateFlagLogic.Or ? "OR" : "AND")}] "
|
||||
: "";
|
||||
conditionText = logicPrefix + string.Join(", ", v.requiredFlags);
|
||||
}
|
||||
|
||||
SkillModule.AddChip(row, "条件", conditionText);
|
||||
|
||||
// 替换序列徽章
|
||||
string seqName = v.sequence != null ? v.sequence.name : "(未设置)";
|
||||
int seqLines = v.sequence != null && v.sequence.lines != null ? v.sequence.lines.Length : 0;
|
||||
string seqLabel = v.sequence != null ? $"{seqName}({seqLines}行)" : seqName;
|
||||
SkillModule.AddChip(row, "替换序列", seqLabel);
|
||||
|
||||
card.Add(row);
|
||||
|
||||
// 遮蔽警告行
|
||||
if (isShadowing)
|
||||
{
|
||||
int remaining = s.variants.Length - 1 - i;
|
||||
var warn = new Label($" ↑ 此变体无条件,其后 {remaining} 个变体永不生效。请移至末尾或添加条件。");
|
||||
warn.style.fontSize = 9;
|
||||
warn.style.color = new StyleColor(new Color(1f, 0.6f, 0.1f));
|
||||
warn.style.marginBottom = 2;
|
||||
card.Add(warn);
|
||||
}
|
||||
}
|
||||
return card;
|
||||
}
|
||||
|
||||
private VisualElement BuildActionBar(DialogueSequenceSO s)
|
||||
{
|
||||
return SkillModule.BuildStandardActionBar(
|
||||
var bar = SkillModule.BuildStandardActionBar(
|
||||
s, Folder, Prefix,
|
||||
onCreated: c => _listPane.Refresh(c),
|
||||
onCloned: c => _listPane.Refresh(c),
|
||||
onDeleted: () => _listPane.Refresh(null));
|
||||
onDeleted: () => _listPane.Refresh(null),
|
||||
wizardCreate: cb => AssetCreationWizard.Show<DialogueSequenceSO>(
|
||||
Folder, Prefix,
|
||||
(d, id) =>
|
||||
{
|
||||
d.sequenceId = id;
|
||||
EditorUtility.SetDirty(d);
|
||||
AssetDatabase.SaveAssets();
|
||||
cb(d);
|
||||
}));
|
||||
|
||||
new Button(ValidateAllSequences) { text = "批量验证" }.AlsoAddTo(bar);
|
||||
|
||||
if (s.variants != null && s.variants.Length > 0)
|
||||
{
|
||||
var capturedS = s;
|
||||
new Button(() => DialogueVariantPreviewWindow.OpenWith(capturedS))
|
||||
{ text = "预览变体" }.AlsoAddTo(bar);
|
||||
}
|
||||
|
||||
return bar;
|
||||
}
|
||||
|
||||
// ── 批量验证 ─────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 遍历所有 DialogueSequenceSO,检查:
|
||||
/// 1. sequenceId 为空
|
||||
/// 2. sequenceId 重复
|
||||
/// 3. 每行 textKey 是否在本地化表中存在
|
||||
/// 4. 每行 speakerNameKey(无 actor 时)是否在本地化表中存在
|
||||
/// 5. 每个选项 textKey 是否在本地化表中存在
|
||||
/// 结果显示在 QuestValidationResultWindow 中,每项问题附"选中"按钮可一键定位资产。
|
||||
/// </summary>
|
||||
private static void ValidateAllSequences()
|
||||
{
|
||||
var allSeqs = AssetOperations.FindAll<DialogueSequenceSO>();
|
||||
var issues = new System.Collections.Generic.List<QuestValidationResultWindow.Issue>();
|
||||
int errorCount = 0, warnCount = 0;
|
||||
|
||||
// 预构建本地化缓存(整个验证过程只查询一次,避免大批量序列时重复读取本地化表)
|
||||
var locCache = new System.Collections.Generic.Dictionary<string, string>(System.StringComparer.Ordinal);
|
||||
string GetLoc(string key)
|
||||
{
|
||||
if (locCache.TryGetValue(key, out var v)) return v;
|
||||
v = BaseGames.Localization.LocalizationManager.GetEditorPreview(key, "Dialogue");
|
||||
locCache[key] = v;
|
||||
return v;
|
||||
}
|
||||
|
||||
void AddError(string msg, UnityEngine.Object asset = null)
|
||||
{
|
||||
issues.Add(new QuestValidationResultWindow.Issue { message = msg, isError = true, asset = asset });
|
||||
errorCount++;
|
||||
}
|
||||
void AddWarn(string msg, UnityEngine.Object asset = null)
|
||||
{
|
||||
issues.Add(new QuestValidationResultWindow.Issue { message = msg, isError = false, asset = asset });
|
||||
warnCount++;
|
||||
}
|
||||
|
||||
// 1 & 2:空 / 重复 sequenceId
|
||||
var idMap = new System.Collections.Generic.Dictionary<string, DialogueSequenceSO>(System.StringComparer.Ordinal);
|
||||
var keyFormatRegex = new System.Text.RegularExpressions.Regex(@"^[\w\-\.]+$");
|
||||
foreach (var seq in allSeqs)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(seq.sequenceId))
|
||||
{
|
||||
AddError($"{seq.name}: sequenceId 为空。", seq);
|
||||
continue;
|
||||
}
|
||||
if (idMap.TryGetValue(seq.sequenceId, out var existing))
|
||||
AddError($"重复 sequenceId \"{seq.sequenceId}\":{seq.name} 与 {existing.name}", seq);
|
||||
else
|
||||
idMap[seq.sequenceId] = seq;
|
||||
|
||||
// 2b. sequenceId 格式异常
|
||||
if (!keyFormatRegex.IsMatch(seq.sequenceId))
|
||||
AddWarn($"{seq.name}: sequenceId \"{seq.sequenceId}\" 含有空格或非法字符,建议只使用字母、数字、_、-、.。", seq);
|
||||
}
|
||||
|
||||
// 3-5:本地化 Key 存在性 + 格式检查
|
||||
foreach (var seq in allSeqs)
|
||||
{
|
||||
if (seq.lines == null) continue;
|
||||
for (int i = 0; i < seq.lines.Length; i++)
|
||||
{
|
||||
var line = seq.lines[i];
|
||||
string lineDesc = $"{seq.name} 行[{i}]";
|
||||
|
||||
// 文本 Key
|
||||
if (string.IsNullOrEmpty(line.textKey))
|
||||
{
|
||||
AddWarn($"{lineDesc}: textKey 为空,运行时显示空文本。", seq);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (GetLoc(line.textKey) == null)
|
||||
AddWarn($"{lineDesc}: textKey \"{line.textKey}\" 在本地化表中不存在。", seq);
|
||||
if (!keyFormatRegex.IsMatch(line.textKey))
|
||||
AddWarn($"{lineDesc}: textKey \"{line.textKey}\" 含有空格或非法字符。", seq);
|
||||
}
|
||||
|
||||
// 说话人 Key(无 actor 时检查直接字段)
|
||||
if (line.actor == null && !string.IsNullOrEmpty(line.speakerNameKey))
|
||||
{
|
||||
if (GetLoc(line.speakerNameKey) == null)
|
||||
AddWarn($"{lineDesc}: speakerNameKey \"{line.speakerNameKey}\" 在本地化表中不存在。", seq);
|
||||
if (!keyFormatRegex.IsMatch(line.speakerNameKey))
|
||||
AddWarn($"{lineDesc}: speakerNameKey \"{line.speakerNameKey}\" 含有空格或非法字符。", seq);
|
||||
}
|
||||
|
||||
// 选项 Key
|
||||
if (line.choices != null)
|
||||
{
|
||||
for (int j = 0; j < line.choices.Length; j++)
|
||||
{
|
||||
var choice = line.choices[j];
|
||||
if (string.IsNullOrEmpty(choice.textKey))
|
||||
{
|
||||
AddWarn($"{lineDesc} 选项[{j}]: textKey 为空。", seq);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (GetLoc(choice.textKey) == null)
|
||||
AddWarn($"{lineDesc} 选项[{j}]: textKey \"{choice.textKey}\" 在本地化表中不存在。", seq);
|
||||
if (!keyFormatRegex.IsMatch(choice.textKey))
|
||||
AddWarn($"{lineDesc} 选项[{j}]: textKey \"{choice.textKey}\" 含有空格或非法字符。", seq);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6. variants[i].sequence 为 null(变体存在但序列引用为空)
|
||||
foreach (var seq in allSeqs)
|
||||
{
|
||||
if (seq.variants == null) continue;
|
||||
for (int vi = 0; vi < seq.variants.Length; vi++)
|
||||
{
|
||||
if (seq.variants[vi].sequence == null)
|
||||
AddWarn($"{seq.name}: variants[{vi}].sequence 为 null,满足条件时会回退默认序列(可能是未完成配置)。", seq);
|
||||
}
|
||||
}
|
||||
|
||||
Debug.Log($"[DialogueModule] 验证完成:{allSeqs.Count} 个序列,{errorCount} 个错误,{warnCount} 个警告。");
|
||||
QuestValidationResultWindow.Show(issues, errorCount, warnCount, allSeqs.Count, "对话批量验证结果", "序列");
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/Editor/Modules/DialogueModule.cs.meta
Normal file
11
Assets/_Game/Scripts/Editor/Modules/DialogueModule.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 76733bfe043064b4a980287067333483
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -10,7 +10,7 @@ namespace BaseGames.Editor.Modules
|
||||
/// <summary>
|
||||
/// DataHub 敌人模块 —— Tab 切换管理 EnemyStatsSO 和 LootTableSO。
|
||||
/// </summary>
|
||||
public class EnemyModule : IDataModule
|
||||
public class EnemyModule : IDataModule, IDataModuleOrdered
|
||||
{
|
||||
private const string StatsFolder = "Assets/_Game/Data/Enemies/Stats";
|
||||
private const string LootFolder = "Assets/_Game/Data/Enemies/Loot";
|
||||
@@ -18,6 +18,7 @@ namespace BaseGames.Editor.Modules
|
||||
public string ModuleId => "enemy";
|
||||
public string DisplayName => "敌人";
|
||||
public string IconName => null;
|
||||
public int DisplayOrder => 30;
|
||||
|
||||
private int _activeTab = 0; // 0=Stats, 1=Loot
|
||||
|
||||
|
||||
526
Assets/_Game/Scripts/Editor/Modules/EventChainModule.cs
Normal file
526
Assets/_Game/Scripts/Editor/Modules/EventChainModule.cs
Normal file
@@ -0,0 +1,526 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
using BaseGames.EventChain;
|
||||
using BaseGames.Editor.Shared;
|
||||
|
||||
namespace BaseGames.Editor.Modules
|
||||
{
|
||||
/// <summary>
|
||||
/// DataHub 事件链模块 —— 管理 EventChainSO 资产。
|
||||
/// 支持浏览、创建、删除、预览条件/动作列表,以及批量验证。
|
||||
/// </summary>
|
||||
public class EventChainModule : IDataModule, IDataModuleOrdered
|
||||
{
|
||||
private const string Folder = "Assets/_Game/Data/EventChains";
|
||||
private const string Prefix = "Chain_";
|
||||
|
||||
public string ModuleId => "eventchain";
|
||||
public string DisplayName => "事件链";
|
||||
public string IconName => "d_Animation.Play";
|
||||
public int DisplayOrder => 120;
|
||||
|
||||
private SoListPane<EventChainSO> _listPane;
|
||||
private DetailHeader _header;
|
||||
private EventChainSO _selected;
|
||||
|
||||
// ── IDataModule ───────────────────────────────────────────────────────
|
||||
|
||||
public void Initialize()
|
||||
{
|
||||
_listPane = new SoListPane<EventChainSO>(
|
||||
Folder, Prefix,
|
||||
c => c.repeatable ? "可重复" : null);
|
||||
// 扩展搜索:chainId
|
||||
_listPane.GetExtraSearchText = c => c.chainId;
|
||||
}
|
||||
|
||||
public void BuildListPane(VisualElement container, Action<UnityEngine.Object> onSelected)
|
||||
{
|
||||
_listPane.SelectionChanged = sel =>
|
||||
{
|
||||
_selected = sel;
|
||||
onSelected?.Invoke(sel);
|
||||
};
|
||||
|
||||
// 快速过滤标签行
|
||||
var filterRow = new VisualElement();
|
||||
filterRow.style.flexDirection = FlexDirection.Row;
|
||||
filterRow.style.flexWrap = Wrap.Wrap;
|
||||
filterRow.style.paddingLeft = 6;
|
||||
filterRow.style.paddingRight = 6;
|
||||
filterRow.style.paddingBottom = 3;
|
||||
container.Add(filterRow);
|
||||
|
||||
bool filterRepeatable = false, filterNoCondition = false, filterNoAction = false;
|
||||
|
||||
void RebuildFilter()
|
||||
{
|
||||
if (!filterRepeatable && !filterNoCondition && !filterNoAction)
|
||||
{
|
||||
_listPane.ExtraFilter = null;
|
||||
return;
|
||||
}
|
||||
_listPane.ExtraFilter = c =>
|
||||
{
|
||||
if (filterRepeatable && !c.repeatable) return false;
|
||||
if (filterNoCondition && c.conditions != null && c.conditions.Length > 0) return false;
|
||||
if (filterNoAction && c.actions != null && c.actions.Length > 0) return false;
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
filterRow.Add(QuestModule.MakeFilterChip("可重复", v => { filterRepeatable = v; RebuildFilter(); }));
|
||||
filterRow.Add(QuestModule.MakeFilterChip("无条件", v => { filterNoCondition = v; RebuildFilter(); }));
|
||||
filterRow.Add(QuestModule.MakeFilterChip("无动作", v => { filterNoAction = v; RebuildFilter(); }));
|
||||
|
||||
container.Add(_listPane);
|
||||
_listPane.Refresh();
|
||||
}
|
||||
|
||||
public void BuildDetailPane(VisualElement container, UnityEngine.Object selected)
|
||||
{
|
||||
_selected = selected as EventChainSO;
|
||||
|
||||
_header = new DetailHeader();
|
||||
_header.SetAsset(_selected);
|
||||
_header.RenameRequested += OnRenameRequested;
|
||||
container.Add(_header);
|
||||
|
||||
if (_selected == null) return;
|
||||
|
||||
container.Add(BuildInfoCard(_selected));
|
||||
container.Add(BuildConditionsList(_selected));
|
||||
container.Add(BuildActionsList(_selected));
|
||||
container.Add(BuildActionBar(_selected));
|
||||
container.Add(SkillModule.MakeDivider());
|
||||
container.Add(new UnityEditor.UIElements.InspectorElement(_selected));
|
||||
}
|
||||
|
||||
public void OnActivated() => _listPane?.Refresh();
|
||||
|
||||
// ── 内部 ──────────────────────────────────────────────────────────────
|
||||
|
||||
private void OnRenameRequested(string newName)
|
||||
{
|
||||
if (_selected == null) return;
|
||||
var (ok, err) = AssetOperations.Rename(_selected, newName);
|
||||
if (!ok) EditorUtility.DisplayDialog("重命名失败", err, "确定");
|
||||
else { _header.SetAsset(_selected); _listPane.Invalidate(); }
|
||||
}
|
||||
|
||||
private static VisualElement BuildInfoCard(EventChainSO c)
|
||||
{
|
||||
var card = SkillModule.MakeCard();
|
||||
|
||||
SkillModule.AddChip(card, "Chain ID",
|
||||
string.IsNullOrEmpty(c.chainId) ? "(未设置)" : c.chainId);
|
||||
SkillModule.AddChip(card, "可重复", c.repeatable ? "是" : "否");
|
||||
|
||||
if (c.actionDelay > 0f)
|
||||
SkillModule.AddChip(card, "动作间隔", $"{c.actionDelay:F2}s");
|
||||
|
||||
int condCount = c.conditions?.Length ?? 0;
|
||||
int actCount = c.actions?.Length ?? 0;
|
||||
SkillModule.AddChip(card, "条件数量", condCount.ToString());
|
||||
SkillModule.AddChip(card, "动作数量", actCount.ToString());
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
private static VisualElement BuildConditionsList(EventChainSO c)
|
||||
{
|
||||
var section = new VisualElement();
|
||||
section.style.paddingLeft = 12;
|
||||
section.style.paddingRight = 12;
|
||||
section.style.paddingTop = 6;
|
||||
section.style.paddingBottom = 4;
|
||||
|
||||
var title = new Label("触发条件");
|
||||
title.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
title.style.fontSize = 11;
|
||||
title.style.opacity = 0.8f;
|
||||
title.style.marginBottom = 4;
|
||||
section.Add(title);
|
||||
|
||||
bool hasGroups = c.conditionGroups != null && c.conditionGroups.Length > 0;
|
||||
bool hasLegacy = c.conditions != null && c.conditions.Length > 0;
|
||||
|
||||
if (!hasGroups && !hasLegacy)
|
||||
{
|
||||
var empty = new Label("(无条件,链将立即在首次 EvaluateAll 时触发)");
|
||||
empty.style.opacity = 0.5f;
|
||||
empty.style.fontSize = 11;
|
||||
section.Add(empty);
|
||||
return section;
|
||||
}
|
||||
|
||||
if (hasGroups)
|
||||
{
|
||||
// 新版 conditionGroups 展示
|
||||
for (int g = 0; g < c.conditionGroups.Length; g++)
|
||||
{
|
||||
var group = c.conditionGroups[g];
|
||||
|
||||
// 组标题
|
||||
var groupHeader = new VisualElement();
|
||||
groupHeader.style.flexDirection = FlexDirection.Row;
|
||||
groupHeader.style.alignItems = Align.Center;
|
||||
groupHeader.style.marginBottom = 2;
|
||||
groupHeader.style.marginTop = g > 0 ? 4 : 0;
|
||||
|
||||
var gLabel = new Label($"条件组 {g + 1}");
|
||||
gLabel.style.fontSize = 11;
|
||||
gLabel.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
gLabel.style.flexGrow = 1;
|
||||
groupHeader.Add(gLabel);
|
||||
|
||||
var logicBadge = new Label(group.logic == BaseGames.Core.WorldStateFlagLogic.Or ? "Or(任一满足)" : "And(全部满足)");
|
||||
logicBadge.style.fontSize = 10;
|
||||
logicBadge.style.opacity = 0.6f;
|
||||
groupHeader.Add(logicBadge);
|
||||
section.Add(groupHeader);
|
||||
|
||||
if (group.conditions == null || group.conditions.Length == 0)
|
||||
{
|
||||
var noCondLabel = new Label(" (空组 — 无条件即视为满足)");
|
||||
noCondLabel.style.opacity = 0.45f;
|
||||
noCondLabel.style.fontSize = 11;
|
||||
section.Add(noCondLabel);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (int i = 0; i < group.conditions.Length; i++)
|
||||
section.Add(BuildConditionRow(i, group.conditions[i], indent: true));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// 旧版 conditions[](隐式 And)
|
||||
var legacyNote = new Label("(旧版条件列表,隐式 And 逻辑;建议迁移至 conditionGroups)");
|
||||
legacyNote.style.opacity = 0.45f;
|
||||
legacyNote.style.fontSize = 10;
|
||||
legacyNote.style.marginBottom = 2;
|
||||
section.Add(legacyNote);
|
||||
for (int i = 0; i < c.conditions.Length; i++)
|
||||
section.Add(BuildConditionRow(i, c.conditions[i], indent: false));
|
||||
}
|
||||
|
||||
// ── 运行时求值按钮(仅 Play Mode)──────────────────────────────
|
||||
if (Application.isPlaying)
|
||||
{
|
||||
section.Add(BuildRuntimeEvalPanel(c));
|
||||
}
|
||||
|
||||
return section;
|
||||
}
|
||||
|
||||
private static VisualElement BuildConditionRow(int index, ChainCondition cond, bool indent)
|
||||
{
|
||||
var row = new VisualElement();
|
||||
row.style.flexDirection = FlexDirection.Row;
|
||||
row.style.alignItems = Align.Center;
|
||||
row.style.marginBottom = 2;
|
||||
if (indent) row.style.paddingLeft = 12;
|
||||
|
||||
var idx = new Label($"{index + 1}.");
|
||||
idx.style.width = 18;
|
||||
idx.style.opacity = 0.5f;
|
||||
idx.style.fontSize = 11;
|
||||
row.Add(idx);
|
||||
|
||||
if (cond == null)
|
||||
{
|
||||
var warn = new Label("⚠ null(Inspector 中有空槽,请检查)");
|
||||
warn.style.color = new StyleColor(new Color(0.9f, 0.5f, 0.2f));
|
||||
warn.style.fontSize = 11;
|
||||
row.Add(warn);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 运行时:直接显示 IsMet() 结果
|
||||
if (Application.isPlaying)
|
||||
{
|
||||
bool met = cond.IsMet();
|
||||
var statusIcon = new Label(met ? "✓" : "✗");
|
||||
statusIcon.style.fontSize = 11;
|
||||
statusIcon.style.width = 14;
|
||||
statusIcon.style.color = new StyleColor(met
|
||||
? new Color(0.2f, 0.75f, 0.35f)
|
||||
: new Color(0.85f, 0.35f, 0.30f));
|
||||
row.Add(statusIcon);
|
||||
}
|
||||
|
||||
var typeName = new Label(cond.GetType().Name);
|
||||
typeName.style.flexGrow = 1;
|
||||
typeName.style.fontSize = 11;
|
||||
row.Add(typeName);
|
||||
|
||||
var ping = new Button(() => { EditorGUIUtility.PingObject(cond); Selection.activeObject = cond; })
|
||||
{ text = "定位" };
|
||||
ping.style.fontSize = 10;
|
||||
ping.style.height = 18;
|
||||
row.Add(ping);
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
private static VisualElement BuildRuntimeEvalPanel(EventChainSO c)
|
||||
{
|
||||
var panel = new VisualElement();
|
||||
panel.style.marginTop = 6;
|
||||
panel.style.paddingTop = 4;
|
||||
panel.style.paddingBottom = 4;
|
||||
panel.style.paddingLeft = 8;
|
||||
panel.style.paddingRight = 8;
|
||||
panel.style.backgroundColor = new StyleColor(new Color(0.15f, 0.22f, 0.15f, 1f));
|
||||
|
||||
var header = new VisualElement();
|
||||
header.style.flexDirection = FlexDirection.Row;
|
||||
header.style.alignItems = Align.Center;
|
||||
header.style.marginBottom = 2;
|
||||
|
||||
var title = new Label("▶ 运行时状态");
|
||||
title.style.fontSize = 10;
|
||||
title.style.opacity = 0.7f;
|
||||
title.style.flexGrow = 1;
|
||||
title.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
header.Add(title);
|
||||
|
||||
var resultLabel = new Label();
|
||||
resultLabel.style.fontSize = 10;
|
||||
|
||||
void RefreshEval()
|
||||
{
|
||||
// 简单逐条 IsMet 求值用于显示(不触发实际执行)
|
||||
bool hasGroups = c.conditionGroups != null && c.conditionGroups.Length > 0;
|
||||
bool allPass = true;
|
||||
if (hasGroups)
|
||||
{
|
||||
foreach (var g in c.conditionGroups)
|
||||
{
|
||||
if (g.conditions == null || g.conditions.Length == 0) continue;
|
||||
bool groupPass = g.logic == BaseGames.Core.WorldStateFlagLogic.Or
|
||||
? System.Array.Exists(g.conditions, x => x != null && x.IsMet())
|
||||
: System.Array.TrueForAll(g.conditions, x => x == null || x.IsMet());
|
||||
if (!groupPass) { allPass = false; break; }
|
||||
}
|
||||
}
|
||||
else if (c.conditions != null)
|
||||
{
|
||||
allPass = System.Array.TrueForAll(c.conditions, x => x == null || x.IsMet());
|
||||
}
|
||||
|
||||
resultLabel.text = allPass ? "✓ 当前满足触发条件" : "✗ 当前不满足触发条件";
|
||||
resultLabel.style.color = new StyleColor(allPass
|
||||
? new Color(0.2f, 0.75f, 0.35f)
|
||||
: new Color(0.85f, 0.35f, 0.30f));
|
||||
}
|
||||
|
||||
RefreshEval();
|
||||
|
||||
var refreshBtn = new Button(RefreshEval) { text = "刷新" };
|
||||
refreshBtn.style.fontSize = 9;
|
||||
refreshBtn.style.height = 16;
|
||||
header.Add(refreshBtn);
|
||||
|
||||
panel.Add(header);
|
||||
panel.Add(resultLabel);
|
||||
return panel;
|
||||
}
|
||||
|
||||
private static VisualElement BuildActionsList(EventChainSO c)
|
||||
{
|
||||
var section = new VisualElement();
|
||||
section.style.paddingLeft = 12;
|
||||
section.style.paddingRight = 12;
|
||||
section.style.paddingTop = 6;
|
||||
section.style.paddingBottom = 4;
|
||||
|
||||
var title = new Label("执行动作");
|
||||
title.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
title.style.fontSize = 11;
|
||||
title.style.opacity = 0.8f;
|
||||
title.style.marginBottom = 4;
|
||||
section.Add(title);
|
||||
|
||||
if (c.actions == null || c.actions.Length == 0)
|
||||
{
|
||||
var empty = new Label("(无动作,链触发后不执行任何操作)");
|
||||
empty.style.opacity = 0.5f;
|
||||
empty.style.fontSize = 11;
|
||||
section.Add(empty);
|
||||
return section;
|
||||
}
|
||||
|
||||
for (int i = 0; i < c.actions.Length; i++)
|
||||
{
|
||||
var act = c.actions[i];
|
||||
var row = new VisualElement();
|
||||
row.style.flexDirection = FlexDirection.Row;
|
||||
row.style.alignItems = Align.Center;
|
||||
row.style.marginBottom = 2;
|
||||
|
||||
var idx = new Label($"{i + 1}.");
|
||||
idx.style.width = 18;
|
||||
idx.style.opacity = 0.5f;
|
||||
idx.style.fontSize = 11;
|
||||
row.Add(idx);
|
||||
|
||||
if (act == null)
|
||||
{
|
||||
var warn = new Label("⚠ null(Inspector 中有空槽,请检查)");
|
||||
warn.style.color = new StyleColor(new Color(0.9f, 0.5f, 0.2f));
|
||||
warn.style.fontSize = 11;
|
||||
row.Add(warn);
|
||||
}
|
||||
else
|
||||
{
|
||||
var typeName = new Label($"{act.GetType().Name} {act.name}");
|
||||
typeName.style.flexGrow = 1;
|
||||
typeName.style.fontSize = 11;
|
||||
row.Add(typeName);
|
||||
|
||||
var ping = new Button(() => { EditorGUIUtility.PingObject(act); Selection.activeObject = act; })
|
||||
{ text = "定位" };
|
||||
ping.style.fontSize = 10;
|
||||
ping.style.height = 18;
|
||||
row.Add(ping);
|
||||
}
|
||||
|
||||
section.Add(row);
|
||||
}
|
||||
|
||||
return section;
|
||||
}
|
||||
|
||||
private VisualElement BuildActionBar(EventChainSO c)
|
||||
{
|
||||
var bar = SkillModule.MakeActionBar();
|
||||
|
||||
new Button(() =>
|
||||
{
|
||||
AssetCreationWizard.Show<EventChainSO>(Folder, Prefix, (asset, id) =>
|
||||
{
|
||||
asset.chainId = id;
|
||||
EditorUtility.SetDirty(asset);
|
||||
AssetDatabase.SaveAssets();
|
||||
_listPane.Refresh(asset);
|
||||
});
|
||||
}) { text = "创建" }.AlsoAddTo(bar);
|
||||
|
||||
new Button(() => { EditorGUIUtility.PingObject(c); Selection.activeObject = c; })
|
||||
{ text = "定位" }.AlsoAddTo(bar);
|
||||
|
||||
new Button(() =>
|
||||
{
|
||||
string path = AssetDatabase.GetAssetPath(c);
|
||||
if (!string.IsNullOrEmpty(path)) EditorGUIUtility.systemCopyBuffer = path;
|
||||
}) { text = "复制路径" }.AlsoAddTo(bar);
|
||||
|
||||
new Button(ValidateAllChains) { text = "批量验证" }.AlsoAddTo(bar);
|
||||
|
||||
// 强制触发按钮仅在 Play Mode 下显示,用于调试绕过条件检查
|
||||
if (Application.isPlaying)
|
||||
{
|
||||
var capturedC = c;
|
||||
var forceBtn = new Button(() =>
|
||||
{
|
||||
var mgr = UnityEngine.Object.FindObjectOfType<EventChainManager>();
|
||||
if (mgr == null)
|
||||
{
|
||||
Debug.LogWarning("[EventChainModule] 场景中未找到 EventChainManager,无法强制触发。");
|
||||
return;
|
||||
}
|
||||
Debug.Log($"[EventChainModule] 强制触发链:{capturedC.chainId}");
|
||||
mgr.ForceExecute(capturedC.chainId);
|
||||
}) { text = "⚡ 强制触发" };
|
||||
forceBtn.style.color = new StyleColor(new Color(1f, 0.75f, 0.2f));
|
||||
forceBtn.AlsoAddTo(bar);
|
||||
}
|
||||
|
||||
var del = new Button(() =>
|
||||
{
|
||||
if (AssetOperations.Delete(c)) _listPane.Refresh(null);
|
||||
}) { text = "删除" };
|
||||
SkillModule.ApplyDeleteStyle(del);
|
||||
del.AlsoAddTo(bar);
|
||||
|
||||
return bar;
|
||||
}
|
||||
|
||||
// ── 批量验证 ──────────────────────────────────────────────────────────
|
||||
|
||||
private static void ValidateAllChains()
|
||||
{
|
||||
var allChains = AssetOperations.FindAll<EventChainSO>();
|
||||
if (allChains.Count == 0)
|
||||
{
|
||||
EditorUtility.DisplayDialog("事件链验证", "项目中未找到任何 EventChainSO。", "确定");
|
||||
return;
|
||||
}
|
||||
|
||||
var issues = new List<QuestValidationResultWindow.Issue>();
|
||||
int errorCount = 0, warnCount = 0;
|
||||
|
||||
void AddError(string msg, UnityEngine.Object asset)
|
||||
{
|
||||
issues.Add(new QuestValidationResultWindow.Issue { message = msg, isError = true, asset = asset });
|
||||
errorCount++;
|
||||
}
|
||||
void AddWarn(string msg, UnityEngine.Object asset)
|
||||
{
|
||||
issues.Add(new QuestValidationResultWindow.Issue { message = msg, isError = false, asset = asset });
|
||||
warnCount++;
|
||||
}
|
||||
|
||||
// ① 空 chainId
|
||||
foreach (var c in allChains)
|
||||
if (string.IsNullOrWhiteSpace(c.chainId))
|
||||
AddError($"{c.name}: chainId 为空,运行时无法存档或被 ChainCompletedCondition 引用。", c);
|
||||
|
||||
// ② 重复 chainId
|
||||
var idMap = new Dictionary<string, EventChainSO>(StringComparer.Ordinal);
|
||||
foreach (var c in allChains)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(c.chainId)) continue;
|
||||
if (!idMap.TryAdd(c.chainId, c))
|
||||
AddError($"chainId '{c.chainId}' 重复({idMap[c.chainId].name} 与 {c.name}),运行时存档键将互串。", c);
|
||||
}
|
||||
|
||||
// ③ 无动作
|
||||
foreach (var c in allChains)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(c.chainId)) continue;
|
||||
if (c.actions == null || c.actions.Length == 0)
|
||||
AddWarn($"{c.chainId}: actions 为空,链触发后不执行任何操作。", c);
|
||||
}
|
||||
|
||||
// ④ conditions 含空槽
|
||||
foreach (var c in allChains)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(c.chainId) || c.conditions == null) continue;
|
||||
for (int i = 0; i < c.conditions.Length; i++)
|
||||
if (c.conditions[i] == null)
|
||||
AddError($"{c.chainId}: conditions[{i}] 为 null,运行时将触发 NullReferenceException。", c);
|
||||
}
|
||||
|
||||
// ⑤ actions 含空槽
|
||||
foreach (var c in allChains)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(c.chainId) || c.actions == null) continue;
|
||||
for (int i = 0; i < c.actions.Length; i++)
|
||||
if (c.actions[i] == null)
|
||||
AddError($"{c.chainId}: actions[{i}] 为 null,运行时将触发 NullReferenceException。", c);
|
||||
}
|
||||
|
||||
Debug.Log($"[EventChainModule] 验证完成:{allChains.Count} 条事件链,{errorCount} 个错误,{warnCount} 个警告。");
|
||||
QuestValidationResultWindow.Show(issues, errorCount, warnCount, allChains.Count, "事件链批量验证结果", "事件链");
|
||||
}
|
||||
}
|
||||
}
|
||||
508
Assets/_Game/Scripts/Editor/Modules/FlagAuditModule.cs
Normal file
508
Assets/_Game/Scripts/Editor/Modules/FlagAuditModule.cs
Normal file
@@ -0,0 +1,508 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
using BaseGames.Core;
|
||||
using BaseGames.Dialogue;
|
||||
using BaseGames.Quest;
|
||||
using BaseGames.EventChain;
|
||||
using BaseGames.Editor;
|
||||
|
||||
namespace BaseGames.Editor.Modules
|
||||
{
|
||||
/// <summary>
|
||||
/// DataHub 标志审计模块 —— 扫描项目所有 WorldStateFlag 引用,
|
||||
/// 检测孤立标志(已注册但从未使用)和未注册标志(已使用但未在注册表定义)。
|
||||
/// </summary>
|
||||
public class FlagAuditModule : IDataModule, IDataModuleOrdered
|
||||
{
|
||||
public string ModuleId => "flagaudit";
|
||||
public string DisplayName => "标志审计";
|
||||
public string IconName => "d_FilterByLabel";
|
||||
public int DisplayOrder => 130;
|
||||
|
||||
// ── 数据 ─────────────────────────────────────────────────────────────
|
||||
|
||||
private readonly List<FlagRecord> _records = new();
|
||||
private FlagRecord _selected;
|
||||
private bool _hasScanned;
|
||||
|
||||
private class FlagRecord
|
||||
{
|
||||
public string id;
|
||||
public string description;
|
||||
public string group;
|
||||
public bool isRegistered;
|
||||
public readonly List<(string label, UnityEngine.Object asset)> setLocations = new();
|
||||
public readonly List<(string label, UnityEngine.Object asset)> readLocations = new();
|
||||
|
||||
public bool IsOrphan => isRegistered && TotalUsages == 0;
|
||||
public bool IsUnregistered => !isRegistered;
|
||||
public int TotalUsages => setLocations.Count + readLocations.Count;
|
||||
}
|
||||
|
||||
// ── UI 引用 ───────────────────────────────────────────────────────────
|
||||
|
||||
private VisualElement _listItems;
|
||||
private Label _summaryLabel;
|
||||
private VisualElement _detailRoot;
|
||||
private bool _filterOrphan, _filterUnregistered;
|
||||
|
||||
// ── IDataModule ───────────────────────────────────────────────────────
|
||||
|
||||
public void Initialize() { }
|
||||
|
||||
public void BuildListPane(VisualElement container, Action<UnityEngine.Object> onSelected)
|
||||
{
|
||||
// 扫描按钮
|
||||
var scanBtn = new Button(RunScan) { text = "🔍 扫描标志使用情况" };
|
||||
scanBtn.style.marginTop = 8;
|
||||
scanBtn.style.marginLeft = 8;
|
||||
scanBtn.style.marginRight = 8;
|
||||
scanBtn.style.marginBottom = 4;
|
||||
container.Add(scanBtn);
|
||||
|
||||
// 统计行
|
||||
_summaryLabel = new Label("尚未扫描,点击上方按钮开始。");
|
||||
_summaryLabel.style.fontSize = 10;
|
||||
_summaryLabel.style.opacity = 0.6f;
|
||||
_summaryLabel.style.paddingLeft = 10;
|
||||
_summaryLabel.style.marginBottom = 4;
|
||||
container.Add(_summaryLabel);
|
||||
|
||||
// 过滤标签行
|
||||
var filterRow = new VisualElement();
|
||||
filterRow.style.flexDirection = FlexDirection.Row;
|
||||
filterRow.style.flexWrap = Wrap.Wrap;
|
||||
filterRow.style.paddingLeft = 6;
|
||||
filterRow.style.paddingRight = 6;
|
||||
filterRow.style.paddingBottom = 3;
|
||||
container.Add(filterRow);
|
||||
|
||||
filterRow.Add(QuestModule.MakeFilterChip("仅孤立", v => { _filterOrphan = v; RebuildList(); }));
|
||||
filterRow.Add(QuestModule.MakeFilterChip("仅未注册", v => { _filterUnregistered = v; RebuildList(); }));
|
||||
|
||||
// 列表 ScrollView
|
||||
var scroll = new ScrollView();
|
||||
scroll.style.flexGrow = 1;
|
||||
container.Add(scroll);
|
||||
|
||||
_listItems = new VisualElement();
|
||||
scroll.Add(_listItems);
|
||||
}
|
||||
|
||||
public void BuildDetailPane(VisualElement container, UnityEngine.Object selected)
|
||||
{
|
||||
_detailRoot = container;
|
||||
RebuildDetail();
|
||||
}
|
||||
|
||||
public void OnActivated() { }
|
||||
|
||||
// ── 扫描 ──────────────────────────────────────────────────────────────
|
||||
|
||||
private void RunScan()
|
||||
{
|
||||
_records.Clear();
|
||||
_hasScanned = true;
|
||||
|
||||
var byId = new Dictionary<string, FlagRecord>(StringComparer.Ordinal);
|
||||
FlagRecord GetOrCreate(string id)
|
||||
{
|
||||
if (!byId.TryGetValue(id, out var r))
|
||||
{
|
||||
r = new FlagRecord { id = id };
|
||||
byId[id] = r;
|
||||
_records.Add(r);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
// 1. 从 WorldFlagRegistrySO 导入注册表
|
||||
var registry = WorldFlagRegistrySO.EditorInstance;
|
||||
if (registry?.flags != null)
|
||||
foreach (var entry in registry.flags)
|
||||
{
|
||||
if (string.IsNullOrEmpty(entry.id)) continue;
|
||||
var r = GetOrCreate(entry.id);
|
||||
r.isRegistered = true;
|
||||
r.description = entry.description;
|
||||
r.group = entry.group;
|
||||
}
|
||||
|
||||
// 2. 扫描 DialogueSequenceSO
|
||||
foreach (var seq in AssetOperations.FindAll<DialogueSequenceSO>())
|
||||
{
|
||||
// variants[i].requiredFlags → 读取
|
||||
if (seq.variants != null)
|
||||
foreach (var v in seq.variants)
|
||||
if (v.requiredFlags != null)
|
||||
foreach (var fid in v.requiredFlags)
|
||||
if (!string.IsNullOrEmpty(fid))
|
||||
GetOrCreate(fid).readLocations.Add(($"对话变体条件 [{seq.name}]", seq));
|
||||
|
||||
// lines[i].choices[j].setWorldFlag → 设置
|
||||
if (seq.lines != null)
|
||||
foreach (var line in seq.lines)
|
||||
if (line.choices != null)
|
||||
foreach (var ch in line.choices)
|
||||
if (!string.IsNullOrEmpty(ch.setWorldFlag))
|
||||
GetOrCreate(ch.setWorldFlag).setLocations.Add(($"对话选项设置 [{seq.name}]", seq));
|
||||
}
|
||||
|
||||
// 3. 扫描 QuestSO
|
||||
foreach (var quest in AssetOperations.FindAll<QuestSO>())
|
||||
{
|
||||
// branches[i].conditionFlags → 读取
|
||||
if (quest.branches != null)
|
||||
foreach (var branch in quest.branches)
|
||||
if (branch.conditionFlags != null)
|
||||
foreach (var fid in branch.conditionFlags)
|
||||
if (!string.IsNullOrEmpty(fid))
|
||||
GetOrCreate(fid).readLocations.Add(($"任务分支条件 [{quest.name}]", quest));
|
||||
|
||||
// prerequisiteFlags → 读取
|
||||
if (quest.prerequisiteFlags != null)
|
||||
foreach (var fid in quest.prerequisiteFlags)
|
||||
if (!string.IsNullOrEmpty(fid))
|
||||
GetOrCreate(fid).readLocations.Add(($"任务前置标志 [{quest.name}]", quest));
|
||||
}
|
||||
|
||||
// 4. 扫描 FlagSetCondition(EventChain 条件)→ 读取
|
||||
foreach (var cond in AssetOperations.FindAll<FlagSetCondition>())
|
||||
if (!string.IsNullOrEmpty(cond.flagId))
|
||||
GetOrCreate(cond.flagId).readLocations.Add(($"链条件 [{cond.name}]", cond));
|
||||
|
||||
// 5. 扫描 SetFlagAction(EventChain 动作)→ 设置
|
||||
foreach (var act in AssetOperations.FindAll<SetFlagAction>())
|
||||
if (!string.IsNullOrEmpty(act.flagId))
|
||||
GetOrCreate(act.flagId).setLocations.Add(($"链动作 [{act.name}]", act));
|
||||
|
||||
// 6. 扫描 NarrativeNPC 预制件中的 DialogueVersion 条件标志
|
||||
// NarrativeNPC 是 MonoBehaviour,使用 SerializedObject 读取序列化字段以避免反射。
|
||||
var prefabGuids = AssetDatabase.FindAssets("t:Prefab", new[] { "Assets/_Game" });
|
||||
foreach (var guid in prefabGuids)
|
||||
{
|
||||
string path = AssetDatabase.GUIDToAssetPath(guid);
|
||||
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(path);
|
||||
if (prefab == null) continue;
|
||||
foreach (var npc in prefab.GetComponentsInChildren<NarrativeNPC>(true))
|
||||
{
|
||||
var so = new SerializedObject(npc);
|
||||
var vProp = so.FindProperty("_dialogueVersions");
|
||||
if (vProp == null || !vProp.isArray) continue;
|
||||
for (int i = 0; i < vProp.arraySize; i++)
|
||||
{
|
||||
var elem = vProp.GetArrayElementAtIndex(i);
|
||||
var reqProp = elem.FindPropertyRelative("requiredFlags");
|
||||
var blockProp = elem.FindPropertyRelative("blockedByFlags");
|
||||
if (reqProp != null && reqProp.isArray)
|
||||
for (int j = 0; j < reqProp.arraySize; j++)
|
||||
{
|
||||
string fid = reqProp.GetArrayElementAtIndex(j).stringValue;
|
||||
if (!string.IsNullOrEmpty(fid))
|
||||
GetOrCreate(fid).readLocations.Add(($"NPC版本条件 [{prefab.name}]", prefab));
|
||||
}
|
||||
if (blockProp != null && blockProp.isArray)
|
||||
for (int j = 0; j < blockProp.arraySize; j++)
|
||||
{
|
||||
string fid = blockProp.GetArrayElementAtIndex(j).stringValue;
|
||||
if (!string.IsNullOrEmpty(fid))
|
||||
GetOrCreate(fid).readLocations.Add(($"NPC版本屏蔽 [{prefab.name}]", prefab));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 排序:未注册 → 孤立 → 正常,再按 ID 字典序
|
||||
_records.Sort((a, b) =>
|
||||
{
|
||||
int pa = a.IsUnregistered ? 0 : a.IsOrphan ? 1 : 2;
|
||||
int pb = b.IsUnregistered ? 0 : b.IsOrphan ? 1 : 2;
|
||||
int c = pa.CompareTo(pb);
|
||||
return c != 0 ? c : string.Compare(a.id, b.id, StringComparison.Ordinal);
|
||||
});
|
||||
|
||||
RebuildList();
|
||||
RebuildDetail();
|
||||
}
|
||||
|
||||
// ── 列表重建 ─────────────────────────────────────────────────────────
|
||||
|
||||
private void RebuildList()
|
||||
{
|
||||
if (_listItems == null) return;
|
||||
_listItems.Clear();
|
||||
if (!_hasScanned) return;
|
||||
|
||||
int total = _records.Count;
|
||||
int orphanCount = _records.Count(r => r.IsOrphan);
|
||||
int unregCount = _records.Count(r => r.IsUnregistered);
|
||||
if (_summaryLabel != null)
|
||||
_summaryLabel.text = $"共 {total} 个标志 · 孤立 {orphanCount} · 未注册 {unregCount}";
|
||||
|
||||
foreach (var rec in _records)
|
||||
{
|
||||
if (_filterOrphan && !rec.IsOrphan) continue;
|
||||
if (_filterUnregistered && !rec.IsUnregistered) continue;
|
||||
|
||||
bool isSelected = rec == _selected;
|
||||
|
||||
var row = new VisualElement();
|
||||
row.style.flexDirection = FlexDirection.Row;
|
||||
row.style.alignItems = Align.Center;
|
||||
row.style.paddingTop = 3;
|
||||
row.style.paddingBottom = 3;
|
||||
row.style.paddingLeft = 8;
|
||||
row.style.paddingRight = 8;
|
||||
row.style.backgroundColor = isSelected
|
||||
? new StyleColor(new Color(0.25f, 0.5f, 1f, 0.2f))
|
||||
: StyleKeyword.None;
|
||||
|
||||
// 状态图标 + 颜色
|
||||
string icon = rec.IsUnregistered ? "⚠" : rec.IsOrphan ? "○" : "●";
|
||||
Color iconColor = rec.IsUnregistered
|
||||
? new Color(1f, 0.4f, 0.2f)
|
||||
: rec.IsOrphan
|
||||
? new Color(1f, 0.85f, 0.1f)
|
||||
: new Color(0.4f, 0.85f, 0.4f);
|
||||
|
||||
var iconLbl = new Label(icon);
|
||||
iconLbl.style.fontSize = 10;
|
||||
iconLbl.style.color = new StyleColor(iconColor);
|
||||
iconLbl.style.width = 14;
|
||||
iconLbl.style.flexShrink = 0;
|
||||
row.Add(iconLbl);
|
||||
|
||||
var idLbl = new Label(rec.id);
|
||||
idLbl.style.fontSize = 11;
|
||||
idLbl.style.flexGrow = 1;
|
||||
row.Add(idLbl);
|
||||
|
||||
// 使用次数徽章
|
||||
if (rec.TotalUsages > 0)
|
||||
{
|
||||
var badge = new Label(rec.TotalUsages.ToString());
|
||||
badge.style.fontSize = 9;
|
||||
badge.style.opacity = 0.6f;
|
||||
badge.style.paddingLeft = 4;
|
||||
badge.style.paddingRight = 4;
|
||||
badge.style.paddingTop = 1;
|
||||
badge.style.paddingBottom = 1;
|
||||
badge.style.borderTopLeftRadius = 8;
|
||||
badge.style.borderTopRightRadius = 8;
|
||||
badge.style.borderBottomLeftRadius = 8;
|
||||
badge.style.borderBottomRightRadius = 8;
|
||||
badge.style.backgroundColor = new StyleColor(new Color(0.5f, 0.5f, 0.5f, 0.25f));
|
||||
row.Add(badge);
|
||||
}
|
||||
|
||||
var capturedRec = rec;
|
||||
row.RegisterCallback<ClickEvent>(_ =>
|
||||
{
|
||||
_selected = capturedRec;
|
||||
RebuildList();
|
||||
RebuildDetail();
|
||||
});
|
||||
|
||||
_listItems.Add(row);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 详情重建 ─────────────────────────────────────────────────────────
|
||||
|
||||
private void RebuildDetail()
|
||||
{
|
||||
if (_detailRoot == null) return;
|
||||
_detailRoot.Clear();
|
||||
|
||||
if (!_hasScanned)
|
||||
{
|
||||
var hint = new Label("请先点击「扫描标志使用情况」按钮。");
|
||||
hint.style.opacity = 0.5f;
|
||||
hint.style.marginTop = 24;
|
||||
hint.style.unityTextAlign = TextAnchor.UpperCenter;
|
||||
_detailRoot.Add(hint);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_selected == null)
|
||||
{
|
||||
var hint = new Label("← 从左侧选择一个标志查看详情。");
|
||||
hint.style.opacity = 0.5f;
|
||||
hint.style.marginTop = 24;
|
||||
hint.style.unityTextAlign = TextAnchor.UpperCenter;
|
||||
_detailRoot.Add(hint);
|
||||
return;
|
||||
}
|
||||
|
||||
var r = _selected;
|
||||
|
||||
// 标题
|
||||
var titleLbl = new Label(r.id);
|
||||
titleLbl.style.fontSize = 15;
|
||||
titleLbl.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
titleLbl.style.paddingLeft = 12;
|
||||
titleLbl.style.paddingTop = 12;
|
||||
titleLbl.style.paddingBottom = 2;
|
||||
_detailRoot.Add(titleLbl);
|
||||
|
||||
// 状态徽章
|
||||
string statusText = r.IsUnregistered ? "⚠ 未在注册表中定义" : r.IsOrphan ? "○ 已注册但从未使用(孤立)" : "● 正常";
|
||||
Color statusColor = r.IsUnregistered ? new Color(1f, 0.4f, 0.2f) : r.IsOrphan ? new Color(1f, 0.85f, 0.1f) : new Color(0.4f, 0.85f, 0.4f);
|
||||
var statusLbl = new Label(statusText);
|
||||
statusLbl.style.fontSize = 11;
|
||||
statusLbl.style.color = new StyleColor(statusColor);
|
||||
statusLbl.style.paddingLeft = 12;
|
||||
statusLbl.style.marginBottom = 4;
|
||||
_detailRoot.Add(statusLbl);
|
||||
|
||||
// "注册到注册表" 快捷按钮(仅未注册标志显示)
|
||||
if (r.IsUnregistered)
|
||||
{
|
||||
var capturedRec = r;
|
||||
var regBtn = new Button(() => RegisterFlagToRegistry(capturedRec))
|
||||
{
|
||||
text = "+ 注册到注册表",
|
||||
tooltip = "将此标志 ID 追加到 WorldFlagRegistrySO.flags[] 中,并重新扫描。",
|
||||
};
|
||||
regBtn.style.marginLeft = 10;
|
||||
regBtn.style.marginBottom = 6;
|
||||
regBtn.style.width = 130;
|
||||
_detailRoot.Add(regBtn);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(r.group)) AddDetailRow("分组", r.group);
|
||||
if (!string.IsNullOrEmpty(r.description)) AddDetailRow("描述", r.description);
|
||||
|
||||
_detailRoot.Add(SkillModule.MakeDivider());
|
||||
|
||||
AddLocationSection("📝 设置位置", r.setLocations, "无设置记录(标志只被读取,从不被写入)");
|
||||
AddLocationSection("🔎 读取位置", r.readLocations, "无读取记录(标志只被写入,从不被读取)");
|
||||
}
|
||||
|
||||
private void AddDetailRow(string label, string value)
|
||||
{
|
||||
var row = new VisualElement();
|
||||
row.style.flexDirection = FlexDirection.Row;
|
||||
row.style.paddingLeft = 12;
|
||||
row.style.paddingBottom = 2;
|
||||
|
||||
var lbl = new Label($"{label}:");
|
||||
lbl.style.fontSize = 11;
|
||||
lbl.style.opacity = 0.55f;
|
||||
lbl.style.width = 48;
|
||||
lbl.style.flexShrink = 0;
|
||||
|
||||
var val = new Label(value);
|
||||
val.style.fontSize = 11;
|
||||
val.style.flexGrow = 1;
|
||||
val.style.flexWrap = Wrap.Wrap;
|
||||
|
||||
row.Add(lbl);
|
||||
row.Add(val);
|
||||
_detailRoot.Add(row);
|
||||
}
|
||||
|
||||
private void AddLocationSection(string sectionTitle, List<(string label, UnityEngine.Object asset)> locations, string emptyText)
|
||||
{
|
||||
var header = new Label(sectionTitle);
|
||||
header.style.fontSize = 11;
|
||||
header.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
header.style.paddingLeft = 12;
|
||||
header.style.paddingTop = 8;
|
||||
header.style.paddingBottom = 3;
|
||||
_detailRoot.Add(header);
|
||||
|
||||
if (locations.Count == 0)
|
||||
{
|
||||
var empty = new Label(emptyText);
|
||||
empty.style.fontSize = 10;
|
||||
empty.style.opacity = 0.45f;
|
||||
empty.style.paddingLeft = 20;
|
||||
empty.style.marginBottom = 4;
|
||||
_detailRoot.Add(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var (lbl, asset) in locations)
|
||||
{
|
||||
var row = new VisualElement();
|
||||
row.style.flexDirection = FlexDirection.Row;
|
||||
row.style.alignItems = Align.Center;
|
||||
row.style.paddingLeft = 14;
|
||||
row.style.paddingRight = 8;
|
||||
row.style.marginBottom = 2;
|
||||
|
||||
var caption = new Label(lbl);
|
||||
caption.style.fontSize = 11;
|
||||
caption.style.flexGrow = 1;
|
||||
row.Add(caption);
|
||||
|
||||
if (asset != null)
|
||||
{
|
||||
var pingBtn = new Button(() =>
|
||||
{
|
||||
EditorGUIUtility.PingObject(asset);
|
||||
Selection.activeObject = asset;
|
||||
}) { text = "选中" };
|
||||
pingBtn.style.fontSize = 10;
|
||||
pingBtn.style.width = 36;
|
||||
pingBtn.style.height = 18;
|
||||
pingBtn.style.paddingTop = 0;
|
||||
pingBtn.style.paddingBottom = 0;
|
||||
row.Add(pingBtn);
|
||||
}
|
||||
|
||||
_detailRoot.Add(row);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 注册快捷操作 ──────────────────────────────────────────────────────
|
||||
|
||||
private void RegisterFlagToRegistry(FlagRecord rec)
|
||||
{
|
||||
var registry = WorldFlagRegistrySO.EditorInstance;
|
||||
if (registry == null)
|
||||
{
|
||||
EditorUtility.DisplayDialog(
|
||||
"注册表不存在",
|
||||
"项目中未找到 WorldFlagRegistrySO 资产。\n" +
|
||||
"请先通过 Create → BaseGames/Core/WorldFlagRegistry 创建注册表。",
|
||||
"确定");
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否已存在(理论上不可能,但防御性检查)
|
||||
if (registry.flags != null)
|
||||
{
|
||||
foreach (var entry in registry.flags)
|
||||
if (entry.id == rec.id) return;
|
||||
}
|
||||
|
||||
var newEntry = new FlagEntry
|
||||
{
|
||||
id = rec.id,
|
||||
description = "",
|
||||
group = "",
|
||||
};
|
||||
|
||||
var flags = registry.flags ?? System.Array.Empty<FlagEntry>();
|
||||
var list = new System.Collections.Generic.List<FlagEntry>(flags) { newEntry };
|
||||
Undo.RegisterCompleteObjectUndo(registry, $"注册标志 {rec.id}");
|
||||
registry.flags = list.ToArray();
|
||||
|
||||
EditorUtility.SetDirty(registry);
|
||||
AssetDatabase.SaveAssets();
|
||||
|
||||
// 将记录标记为已注册并重建 UI
|
||||
rec.isRegistered = true;
|
||||
RebuildList();
|
||||
RebuildDetail();
|
||||
|
||||
Debug.Log($"[FlagAuditModule] 已将标志 '{rec.id}' 注册到 WorldFlagRegistrySO。");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ namespace BaseGames.Editor.Modules
|
||||
/// <summary>
|
||||
/// DataHub 形态模块 —— Tab 切换管理 FormConfigSO 和 FormSO 资产。
|
||||
/// </summary>
|
||||
public class FormModule : IDataModule
|
||||
public class FormModule : IDataModule, IDataModuleOrdered
|
||||
{
|
||||
private const string ConfigFolder = "Assets/_Game/Data/Player/Forms";
|
||||
private const string FormFolder = "Assets/_Game/Data/Player/Forms";
|
||||
@@ -18,6 +18,7 @@ namespace BaseGames.Editor.Modules
|
||||
public string ModuleId => "form";
|
||||
public string DisplayName => "形态";
|
||||
public string IconName => null;
|
||||
public int DisplayOrder => 40;
|
||||
|
||||
private int _activeTab = 0; // 0=FormConfig, 1=FormSO
|
||||
|
||||
|
||||
197
Assets/_Game/Scripts/Editor/Modules/IdCodegenModule.cs
Normal file
197
Assets/_Game/Scripts/Editor/Modules/IdCodegenModule.cs
Normal file
@@ -0,0 +1,197 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
using BaseGames.Dialogue;
|
||||
using BaseGames.Quest;
|
||||
using BaseGames.EventChain;
|
||||
|
||||
namespace BaseGames.Editor.Modules
|
||||
{
|
||||
/// <summary>
|
||||
/// DataHub ID 生成模块 —— 扫描 QuestSO / NpcSO / DialogueSequenceSO / EventChainSO 资产,
|
||||
/// 自动生成 <c>Assets/_Game/Scripts/Core/GameIds.Generated.cs</c>,
|
||||
/// 提供编译期 ID 常量,消除代码中的魔法字符串。
|
||||
/// </summary>
|
||||
public class IdCodegenModule : IDataModule, IDataModuleOrdered
|
||||
{
|
||||
private const string OutputPath = "Assets/_Game/Scripts/Core/GameIds.Generated.cs";
|
||||
|
||||
public string ModuleId => "idcodegen";
|
||||
public string DisplayName => "ID 生成";
|
||||
public string IconName => "d_cs Script Icon";
|
||||
public int DisplayOrder => 140;
|
||||
|
||||
private Label _statusLabel;
|
||||
private string _lastResult;
|
||||
|
||||
// ── IDataModule ───────────────────────────────────────────────────────
|
||||
|
||||
public void Initialize() { }
|
||||
|
||||
public void BuildListPane(VisualElement container, Action<UnityEngine.Object> onSelected)
|
||||
{
|
||||
var desc = new Label(
|
||||
"扫描项目中的 QuestSO / NpcSO / DialogueSequenceSO / EventChainSO 资产,\n" +
|
||||
$"生成 {OutputPath} 常量文件。\n\n" +
|
||||
"生成后在代码中通过 GameIdsGenerated.Quest.XXX 等访问。");
|
||||
desc.style.whiteSpace = WhiteSpace.Normal;
|
||||
desc.style.marginBottom = 12;
|
||||
desc.style.paddingLeft = 8;
|
||||
desc.style.paddingRight = 8;
|
||||
desc.style.fontSize = 11;
|
||||
container.Add(desc);
|
||||
|
||||
var btn = new Button(RunCodegen) { text = "⚡ 生成 GameIds.Generated.cs" };
|
||||
btn.style.marginLeft = 8;
|
||||
btn.style.marginRight = 8;
|
||||
btn.style.height = 28;
|
||||
container.Add(btn);
|
||||
|
||||
_statusLabel = new Label(_lastResult ?? "");
|
||||
_statusLabel.style.whiteSpace = WhiteSpace.Normal;
|
||||
_statusLabel.style.marginTop = 10;
|
||||
_statusLabel.style.marginLeft = 8;
|
||||
_statusLabel.style.marginRight = 8;
|
||||
_statusLabel.style.fontSize = 11;
|
||||
container.Add(_statusLabel);
|
||||
}
|
||||
|
||||
public void BuildDetailPane(VisualElement container, UnityEngine.Object selected)
|
||||
{
|
||||
// 无需详情面板
|
||||
}
|
||||
|
||||
public void OnActivated() { }
|
||||
|
||||
// ── 代码生成 ──────────────────────────────────────────────────────────
|
||||
|
||||
private void RunCodegen()
|
||||
{
|
||||
try
|
||||
{
|
||||
var quests = AssetOperations.FindAll<QuestSO>()
|
||||
.Where(q => !string.IsNullOrEmpty(q.questId))
|
||||
.Select(q => q.questId)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(s => s, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var npcs = AssetOperations.FindAll<NpcSO>()
|
||||
.Where(n => !string.IsNullOrEmpty(n.npcId))
|
||||
.Select(n => n.npcId)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(s => s, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var dialogues = AssetOperations.FindAll<DialogueSequenceSO>()
|
||||
.Where(d => !string.IsNullOrEmpty(d.sequenceId))
|
||||
.Select(d => d.sequenceId)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(s => s, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var chains = AssetOperations.FindAll<EventChainSO>()
|
||||
.Where(c => !string.IsNullOrEmpty(c.chainId))
|
||||
.Select(c => c.chainId)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(s => s, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
string code = BuildSourceCode(quests, npcs, dialogues, chains);
|
||||
|
||||
string fullPath = Path.GetFullPath(OutputPath);
|
||||
string dir = Path.GetDirectoryName(fullPath);
|
||||
if (!Directory.Exists(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
File.WriteAllText(fullPath, code, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
|
||||
AssetDatabase.ImportAsset(OutputPath, ImportAssetOptions.ForceUpdate);
|
||||
|
||||
int total = quests.Count + npcs.Count + dialogues.Count + chains.Count;
|
||||
_lastResult = $"✅ 生成完成({total} 个常量:Quest×{quests.Count} Npc×{npcs.Count} " +
|
||||
$"Dialogue×{dialogues.Count} Chain×{chains.Count})";
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_lastResult = $"❌ 生成失败:{e.Message}";
|
||||
Debug.LogException(e);
|
||||
}
|
||||
|
||||
if (_statusLabel != null)
|
||||
_statusLabel.text = _lastResult;
|
||||
}
|
||||
|
||||
private static string BuildSourceCode(List<string> quests, List<string> npcs,
|
||||
List<string> dialogues, List<string> chains)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("// <auto-generated>");
|
||||
sb.AppendLine("// 此文件由 DataHub > ID生成 模块自动生成,请勿手动编辑。");
|
||||
sb.AppendLine("// 手动维护的 ID 常量请放在 GameIds.cs 中。");
|
||||
sb.AppendLine("// </auto-generated>");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("namespace BaseGames.Core");
|
||||
sb.AppendLine("{");
|
||||
sb.AppendLine(" /// <summary>自动生成的游戏资产 ID 常量。每次执行 DataHub > ID生成 后刷新。</summary>");
|
||||
sb.AppendLine(" public static class GameIdsGenerated");
|
||||
sb.AppendLine(" {");
|
||||
|
||||
AppendSection(sb, "Quest", "QuestSO.questId", quests, "Quest_");
|
||||
AppendSection(sb, "Npc", "NpcSO.npcId", npcs, "NPC_");
|
||||
AppendSection(sb, "Dialogue", "DialogueSequenceSO.sequenceId", dialogues, "DLG_");
|
||||
AppendSection(sb, "Chain", "EventChainSO.chainId", chains, "Chain_");
|
||||
|
||||
sb.AppendLine(" }");
|
||||
sb.AppendLine("}");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static void AppendSection(StringBuilder sb, string className, string docSource,
|
||||
List<string> ids, string stripPrefix)
|
||||
{
|
||||
sb.AppendLine($" /// <summary>来自 {docSource} 的 ID 常量。</summary>");
|
||||
sb.AppendLine($" public static class {className}");
|
||||
sb.AppendLine(" {");
|
||||
|
||||
if (ids.Count == 0)
|
||||
sb.AppendLine(" // 项目中暂无此类资产");
|
||||
else
|
||||
foreach (string id in ids)
|
||||
sb.AppendLine($" public const string {ToFieldName(id, stripPrefix)} = \"{id}\";");
|
||||
|
||||
sb.AppendLine(" }");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将原始 ID 字符串转为合法的 C# 标识符。
|
||||
/// 剥离常见前缀(如 "Quest_"),然后将剩余部分中的非字母数字字符替换为下划线,
|
||||
/// 确保不以数字开头。
|
||||
/// </summary>
|
||||
private static string ToFieldName(string rawId, string stripPrefix)
|
||||
{
|
||||
string name = rawId;
|
||||
if (!string.IsNullOrEmpty(stripPrefix) &&
|
||||
name.StartsWith(stripPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
name = name.Substring(stripPrefix.Length);
|
||||
|
||||
// 将非字母数字字符替换为下划线
|
||||
name = Regex.Replace(name, @"[^A-Za-z0-9_]", "_");
|
||||
|
||||
// 不能以数字开头
|
||||
if (name.Length > 0 && char.IsDigit(name[0]))
|
||||
name = "_" + name;
|
||||
|
||||
if (string.IsNullOrEmpty(name))
|
||||
name = "_";
|
||||
|
||||
return name;
|
||||
}
|
||||
}
|
||||
}
|
||||
335
Assets/_Game/Scripts/Editor/Modules/NpcModule.cs
Normal file
335
Assets/_Game/Scripts/Editor/Modules/NpcModule.cs
Normal file
@@ -0,0 +1,335 @@
|
||||
using System;
|
||||
using UnityEditor;
|
||||
using UnityEditor.UIElements;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
using BaseGames.Dialogue;
|
||||
using BaseGames.Quest;
|
||||
using BaseGames.Editor.Shared;
|
||||
|
||||
namespace BaseGames.Editor.Modules
|
||||
{
|
||||
/// <summary>
|
||||
/// DataHub NPC 模块 —— 管理 NpcSO 资产。
|
||||
/// 统一查看、创建、重命名、删除 NPC 定义(ID、名称 Key、头像、好感度上限)。
|
||||
/// </summary>
|
||||
public class NpcModule : IDataModule, IDataModuleOrdered
|
||||
{
|
||||
private const string Folder = "Assets/_Game/Data/NPC";
|
||||
private const string Prefix = "NPC_";
|
||||
|
||||
public string ModuleId => "npc";
|
||||
public string DisplayName => "NPC";
|
||||
public string IconName => "d_GameObject Icon";
|
||||
public int DisplayOrder => 90;
|
||||
|
||||
private SoListPane<NpcSO> _listPane;
|
||||
private DetailHeader _header;
|
||||
private NpcSO _selected;
|
||||
|
||||
public void Initialize()
|
||||
{
|
||||
_listPane = new SoListPane<NpcSO>(
|
||||
Folder, Prefix,
|
||||
n => n.maxAffinity > 0 ? $"亲密{n.maxAffinity}" : null);
|
||||
// 扩展搜索:npcId + nameKey
|
||||
_listPane.GetExtraSearchText = n => $"{n.npcId} {n.nameKey}";
|
||||
}
|
||||
|
||||
public void BuildListPane(VisualElement container, Action<UnityEngine.Object> onSelected)
|
||||
{
|
||||
_listPane.SelectionChanged = sel =>
|
||||
{
|
||||
_selected = sel;
|
||||
onSelected?.Invoke(sel);
|
||||
};
|
||||
|
||||
// ── 快速过滤标签行 ─────────────────────────────────────────────
|
||||
var filterRow = new VisualElement();
|
||||
filterRow.style.flexDirection = FlexDirection.Row;
|
||||
filterRow.style.flexWrap = Wrap.Wrap;
|
||||
filterRow.style.paddingLeft = 6;
|
||||
filterRow.style.paddingRight = 6;
|
||||
filterRow.style.paddingBottom = 3;
|
||||
container.Add(filterRow);
|
||||
|
||||
bool filterAffinity = false, filterPortrait = false;
|
||||
|
||||
void RebuildFilter()
|
||||
{
|
||||
if (!filterAffinity && !filterPortrait)
|
||||
{
|
||||
_listPane.ExtraFilter = null;
|
||||
return;
|
||||
}
|
||||
_listPane.ExtraFilter = n =>
|
||||
{
|
||||
if (filterAffinity && n.maxAffinity <= 0) return false;
|
||||
if (filterPortrait && n.portrait == null) return false;
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
filterRow.Add(QuestModule.MakeFilterChip("有好感度", v => { filterAffinity = v; RebuildFilter(); }));
|
||||
filterRow.Add(QuestModule.MakeFilterChip("有头像", v => { filterPortrait = v; RebuildFilter(); }));
|
||||
|
||||
container.Add(_listPane);
|
||||
_listPane.Refresh();
|
||||
}
|
||||
|
||||
public void BuildDetailPane(VisualElement container, UnityEngine.Object selected)
|
||||
{
|
||||
_selected = selected as NpcSO;
|
||||
|
||||
_header = new DetailHeader();
|
||||
_header.SetAsset(_selected);
|
||||
_header.RenameRequested += OnRenameRequested;
|
||||
container.Add(_header);
|
||||
|
||||
if (_selected == null) return;
|
||||
|
||||
container.Add(BuildInfoCard(_selected));
|
||||
container.Add(BuildActionBar(_selected));
|
||||
container.Add(SkillModule.MakeDivider());
|
||||
container.Add(new InspectorElement(_selected));
|
||||
}
|
||||
|
||||
public void OnActivated() => _listPane?.Refresh();
|
||||
|
||||
// ── 内部 ──────────────────────────────────────────────────────────────
|
||||
|
||||
private void OnRenameRequested(string newName)
|
||||
{
|
||||
if (_selected == null) return;
|
||||
var (ok, err) = AssetOperations.Rename(_selected, newName);
|
||||
if (!ok) EditorUtility.DisplayDialog("重命名失败", err, "确定");
|
||||
else { _header.SetAsset(_selected); _listPane.Invalidate(); }
|
||||
}
|
||||
|
||||
private static VisualElement BuildInfoCard(NpcSO n)
|
||||
{
|
||||
var card = SkillModule.MakeCard();
|
||||
|
||||
SkillModule.AddChip(card, "NPC ID", string.IsNullOrEmpty(n.npcId) ? "(未设置)" : n.npcId);
|
||||
|
||||
string nameDisplay = string.IsNullOrEmpty(n.nameKey)
|
||||
? "(未设置)"
|
||||
: (BaseGames.Localization.LocalizationManager.GetEditorPreview(n.nameKey, "Dialogue") ?? n.nameKey);
|
||||
SkillModule.AddChip(card, "名称", nameDisplay);
|
||||
if (!string.IsNullOrEmpty(n.nameKey))
|
||||
SkillModule.AddChip(card, "名称 Key", n.nameKey);
|
||||
if (n.maxAffinity > 0)
|
||||
SkillModule.AddChip(card, "好感度上限", n.maxAffinity.ToString());
|
||||
|
||||
// 头像预览
|
||||
if (n.portrait != null)
|
||||
{
|
||||
var row = new VisualElement();
|
||||
row.style.flexDirection = FlexDirection.Row;
|
||||
row.style.alignItems = Align.Center;
|
||||
row.style.paddingLeft = 8;
|
||||
row.style.paddingTop = 4;
|
||||
|
||||
var img = new Image { image = n.portrait.texture };
|
||||
img.style.width = 40;
|
||||
img.style.height = 40;
|
||||
img.style.borderTopLeftRadius = 4;
|
||||
img.style.borderTopRightRadius = 4;
|
||||
img.style.borderBottomLeftRadius = 4;
|
||||
img.style.borderBottomRightRadius = 4;
|
||||
row.Add(img);
|
||||
card.Add(row);
|
||||
}
|
||||
|
||||
// 关联任务反查:显示哪些任务以此 NPC 为发布者
|
||||
var referencingQuests = FindQuestsReferencingNpc(n);
|
||||
if (referencingQuests.Count > 0)
|
||||
{
|
||||
SkillModule.AddChip(card, "关联任务", $"共 {referencingQuests.Count} 个");
|
||||
var refFold = new UnityEngine.UIElements.Foldout
|
||||
{
|
||||
text = $"关联任务({referencingQuests.Count})",
|
||||
value = false,
|
||||
};
|
||||
refFold.style.paddingLeft = 8;
|
||||
foreach (var q in referencingQuests)
|
||||
{
|
||||
var btn = new UnityEngine.UIElements.Button(() => UnityEditor.EditorGUIUtility.PingObject(q))
|
||||
{
|
||||
text = string.IsNullOrEmpty(q.questId) ? q.name : $"{q.questId} ({q.name})"
|
||||
};
|
||||
btn.style.unityTextAlign = UnityEngine.TextAnchor.MiddleLeft;
|
||||
btn.style.fontSize = 10;
|
||||
btn.style.marginBottom = 1;
|
||||
refFold.Add(btn);
|
||||
}
|
||||
card.Add(refFold);
|
||||
}
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
// ── 关联任务缓存(5 秒 TTL,避免每次切换 NPC 时全量扫描资产数据库)──────────────
|
||||
private static System.Collections.Generic.Dictionary<NpcSO, System.Collections.Generic.List<QuestSO>>
|
||||
s_npcQuestCache;
|
||||
private static double s_npcQuestCacheTime = -10.0;
|
||||
|
||||
private static System.Collections.Generic.List<QuestSO> FindQuestsReferencingNpc(NpcSO n)
|
||||
{
|
||||
double now = UnityEditor.EditorApplication.timeSinceStartup;
|
||||
if (s_npcQuestCache != null && now - s_npcQuestCacheTime < 5.0)
|
||||
{
|
||||
s_npcQuestCache.TryGetValue(n, out var cached);
|
||||
return cached ?? new System.Collections.Generic.List<QuestSO>();
|
||||
}
|
||||
|
||||
// TTL 过期,重建全量缓存(单次扫描所有 QuestSO,分组存储)
|
||||
s_npcQuestCache = new System.Collections.Generic.Dictionary<NpcSO, System.Collections.Generic.List<QuestSO>>();
|
||||
var guids = UnityEditor.AssetDatabase.FindAssets("t:QuestSO");
|
||||
foreach (var guid in guids)
|
||||
{
|
||||
var path = UnityEditor.AssetDatabase.GUIDToAssetPath(guid);
|
||||
var q = UnityEditor.AssetDatabase.LoadAssetAtPath<QuestSO>(path);
|
||||
if (q == null || q.giverNpc == null) continue;
|
||||
if (!s_npcQuestCache.TryGetValue(q.giverNpc, out var list))
|
||||
{
|
||||
list = new System.Collections.Generic.List<QuestSO>();
|
||||
s_npcQuestCache[q.giverNpc] = list;
|
||||
}
|
||||
list.Add(q);
|
||||
}
|
||||
s_npcQuestCacheTime = now;
|
||||
|
||||
s_npcQuestCache.TryGetValue(n, out var result);
|
||||
return result ?? new System.Collections.Generic.List<QuestSO>();
|
||||
}
|
||||
|
||||
private VisualElement BuildActionBar(NpcSO n)
|
||||
{
|
||||
var bar = SkillModule.BuildStandardActionBar(
|
||||
n, Folder, Prefix,
|
||||
onCreated: c => _listPane.Refresh(c),
|
||||
onCloned: c => _listPane.Refresh(c),
|
||||
onDeleted: () => _listPane.Refresh(null),
|
||||
wizardCreate: cb => AssetCreationWizard.Show<NpcSO>(
|
||||
Folder, Prefix,
|
||||
(npc, id) =>
|
||||
{
|
||||
npc.npcId = id;
|
||||
EditorUtility.SetDirty(npc);
|
||||
AssetDatabase.SaveAssets();
|
||||
cb(npc);
|
||||
}));
|
||||
|
||||
// 批量验证按钮
|
||||
var validateBtn = new Button(ValidateAllNpcs) { text = "批量验证" };
|
||||
validateBtn.style.marginLeft = 4;
|
||||
bar.Add(validateBtn);
|
||||
return bar;
|
||||
}
|
||||
|
||||
// ── 批量验证 ─────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 遍历所有 NpcSO,检查:
|
||||
/// 1. npcId 为空
|
||||
/// 2. npcId 重复(全局)
|
||||
/// 3. nameKey 为空(NPC 无显示名称)
|
||||
/// 4. maxAffinity > 0 但 portrait 为 null(好感度 UI 无头像可展示)
|
||||
/// 5. nameKey 在本地化表中不存在
|
||||
/// 6. interactPromptKey 非空但在本地化表中不存在
|
||||
/// 7. 与同 npcId 的 DialogueActorSO portrait 不一致
|
||||
/// 结果在 QuestValidationResultWindow 中展示,每项问题附"选中"按钮可一键定位资产。
|
||||
/// </summary>
|
||||
private static void ValidateAllNpcs()
|
||||
{
|
||||
var allNpcs = AssetOperations.FindAll<NpcSO>();
|
||||
var issues = new System.Collections.Generic.List<QuestValidationResultWindow.Issue>();
|
||||
int errorCount = 0, warnCount = 0;
|
||||
|
||||
void AddError(string msg, UnityEngine.Object asset = null)
|
||||
{
|
||||
issues.Add(new QuestValidationResultWindow.Issue { message = msg, isError = true, asset = asset });
|
||||
errorCount++;
|
||||
}
|
||||
void AddWarn(string msg, UnityEngine.Object asset = null)
|
||||
{
|
||||
issues.Add(new QuestValidationResultWindow.Issue { message = msg, isError = false, asset = asset });
|
||||
warnCount++;
|
||||
}
|
||||
|
||||
// 预构建本地化缓存(单次查询,整个验证过程复用)
|
||||
var locCache = new System.Collections.Generic.Dictionary<string, string>(System.StringComparer.Ordinal);
|
||||
string GetLoc(string key)
|
||||
{
|
||||
if (locCache.TryGetValue(key, out var v)) return v;
|
||||
v = BaseGames.Localization.LocalizationManager.GetEditorPreview(key, "Dialogue");
|
||||
locCache[key] = v;
|
||||
return v;
|
||||
}
|
||||
|
||||
// 预构建 DialogueActorSO 映射 actorId → ActorSO(用于 portrait 一致性检查)
|
||||
var actorMap = new System.Collections.Generic.Dictionary<string, BaseGames.Dialogue.DialogueActorSO>(
|
||||
System.StringComparer.Ordinal);
|
||||
var actorGuids = UnityEditor.AssetDatabase.FindAssets("t:DialogueActorSO");
|
||||
foreach (var g in actorGuids)
|
||||
{
|
||||
var path = UnityEditor.AssetDatabase.GUIDToAssetPath(g);
|
||||
var actor = UnityEditor.AssetDatabase.LoadAssetAtPath<BaseGames.Dialogue.DialogueActorSO>(path);
|
||||
if (actor != null && !string.IsNullOrEmpty(actor.actorId) && !actorMap.ContainsKey(actor.actorId))
|
||||
actorMap[actor.actorId] = actor;
|
||||
}
|
||||
|
||||
// 1 & 2:空 npcId / 重复 npcId
|
||||
var idMap = new System.Collections.Generic.Dictionary<string, NpcSO>(System.StringComparer.Ordinal);
|
||||
foreach (var n in allNpcs)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(n.npcId))
|
||||
{
|
||||
AddError($"{n.name}: npcId 为空,NPC 无法被系统引用。", n);
|
||||
continue;
|
||||
}
|
||||
if (idMap.TryGetValue(n.npcId, out var existing))
|
||||
AddError($"重复 npcId \"{n.npcId}\":{n.name} 与 {existing.name}", n);
|
||||
else
|
||||
idMap[n.npcId] = n;
|
||||
}
|
||||
|
||||
// 3-7:其余字段检查
|
||||
foreach (var n in allNpcs)
|
||||
{
|
||||
// 3. nameKey 为空
|
||||
if (string.IsNullOrEmpty(n.nameKey))
|
||||
AddWarn($"{n.name}({n.npcId}): nameKey 为空,运行时显示空名称。", n);
|
||||
|
||||
// 4. 有好感度但无头像
|
||||
if (n.maxAffinity > 0 && n.portrait == null)
|
||||
AddWarn($"{n.name}({n.npcId}): maxAffinity={n.maxAffinity} 但 portrait 为 null,好感度进度条 UI 无头像可展示。", n);
|
||||
|
||||
// 5. nameKey 本地化不存在
|
||||
if (!string.IsNullOrEmpty(n.nameKey) && GetLoc(n.nameKey) == null)
|
||||
AddWarn($"{n.name}({n.npcId}): nameKey \"{n.nameKey}\" 在本地化表中不存在。", n);
|
||||
|
||||
// 5b. nameKey 格式异常(含空格或非法字符)
|
||||
if (!string.IsNullOrEmpty(n.nameKey) &&
|
||||
!System.Text.RegularExpressions.Regex.IsMatch(n.nameKey, @"^[\w\-\.]+$"))
|
||||
AddWarn($"{n.name}({n.npcId}): nameKey \"{n.nameKey}\" 含有空格或非法字符,建议只使用字母、数字、_、-、.。", n);
|
||||
|
||||
// 6. interactPromptKey 本地化不存在
|
||||
if (!string.IsNullOrEmpty(n.interactPromptKey) && GetLoc(n.interactPromptKey) == null)
|
||||
AddWarn($"{n.name}({n.npcId}): interactPromptKey \"{n.interactPromptKey}\" 在本地化表中不存在,运行时交互提示显示 Key 原文。", n);
|
||||
|
||||
// 7. portrait 与同 npcId 的 DialogueActorSO 不一致
|
||||
if (!string.IsNullOrEmpty(n.npcId) && actorMap.TryGetValue(n.npcId, out var actor))
|
||||
{
|
||||
if (actor.portrait != n.portrait)
|
||||
AddWarn($"{n.name}({n.npcId}): portrait 与 DialogueActorSO \"{actor.name}\" 的 portrait 不一致," +
|
||||
"对话框中显示的头像与 NPC 信息面板头像可能不同。", n);
|
||||
}
|
||||
}
|
||||
|
||||
UnityEngine.Debug.Log($"[NpcModule] 验证完成:{allNpcs.Count} 个 NPC,{errorCount} 个错误,{warnCount} 个警告。");
|
||||
QuestValidationResultWindow.Show(issues, errorCount, warnCount, allNpcs.Count, "NPC 批量验证结果", "NPC");
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/Editor/Modules/NpcModule.cs.meta
Normal file
11
Assets/_Game/Scripts/Editor/Modules/NpcModule.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5d7d769256156a542bf7efb80f93f3c4
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -7,13 +7,14 @@ using UnityEditor.UIElements;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
using BaseGames.Quest;
|
||||
using BaseGames.Editor.Shared;
|
||||
|
||||
namespace BaseGames.Editor.Modules
|
||||
{
|
||||
/// <summary>
|
||||
/// DataHub 任务模块 —— 管理 QuestSO 资产。
|
||||
/// </summary>
|
||||
public class QuestModule : IDataModule
|
||||
public class QuestModule : IDataModule, IDataModuleOrdered
|
||||
{
|
||||
private const string Folder = "Assets/_Game/Data/Quest";
|
||||
private const string Prefix = "Quest_";
|
||||
@@ -21,11 +22,15 @@ namespace BaseGames.Editor.Modules
|
||||
public string ModuleId => "quest";
|
||||
public string DisplayName => "任务";
|
||||
public string IconName => "d_UnityEditor.InspectorWindow";
|
||||
public int DisplayOrder => 110;
|
||||
|
||||
private SoListPane<QuestSO> _listPane;
|
||||
private DetailHeader _header;
|
||||
private QuestSO _selected;
|
||||
|
||||
// playModeStateChanged 订阅的字段引用,便于在重建 ActionBar 时退订旧订阅,避免内存泄漏
|
||||
private System.Action<UnityEditor.PlayModeStateChange> _playModeHandler;
|
||||
|
||||
public void Initialize()
|
||||
{
|
||||
_listPane = new SoListPane<QuestSO>(
|
||||
@@ -33,8 +38,20 @@ namespace BaseGames.Editor.Modules
|
||||
s =>
|
||||
{
|
||||
bool hasPre = s.prerequisiteQuests != null && s.prerequisiteQuests.Length > 0;
|
||||
// 徽章:分类 + 有前置
|
||||
string catLabel = s.category switch
|
||||
{
|
||||
QuestCategory.Main => "主线",
|
||||
QuestCategory.Daily => "日常",
|
||||
QuestCategory.Hidden => "隐藏",
|
||||
_ => null, // Side 不显示(默认值,减少视觉噪声)
|
||||
};
|
||||
if (catLabel != null) return catLabel;
|
||||
return hasPre ? "有前置" : null;
|
||||
});
|
||||
// 扩展搜索:questId + displayNameKey + category
|
||||
_listPane.GetExtraSearchText = q =>
|
||||
$"{q.questId} {q.displayNameKey} {q.category}";
|
||||
}
|
||||
|
||||
public void BuildListPane(VisualElement container, Action<UnityEngine.Object> onSelected)
|
||||
@@ -44,10 +61,93 @@ namespace BaseGames.Editor.Modules
|
||||
_selected = sel;
|
||||
onSelected?.Invoke(sel);
|
||||
};
|
||||
|
||||
// ── 快速过滤标签行 ─────────────────────────────────────────────
|
||||
var filterRow = new VisualElement();
|
||||
filterRow.style.flexDirection = FlexDirection.Row;
|
||||
filterRow.style.flexWrap = Wrap.Wrap;
|
||||
filterRow.style.paddingLeft = 6;
|
||||
filterRow.style.paddingRight = 6;
|
||||
filterRow.style.paddingBottom = 3;
|
||||
container.Add(filterRow);
|
||||
|
||||
bool filterPrereq = false, filterNoObj = false, filterCanFail = false;
|
||||
QuestCategory? filterCategory = null;
|
||||
|
||||
void RebuildFilter()
|
||||
{
|
||||
if (!filterPrereq && !filterNoObj && !filterCanFail && filterCategory == null)
|
||||
{
|
||||
_listPane.ExtraFilter = null;
|
||||
return;
|
||||
}
|
||||
_listPane.ExtraFilter = q =>
|
||||
{
|
||||
if (filterPrereq && (q.prerequisiteQuests == null || q.prerequisiteQuests.Length == 0)) return false;
|
||||
if (filterNoObj && (q.objectives != null && q.objectives.Length > 0)) return false;
|
||||
if (filterCanFail && !q.canFail) return false;
|
||||
if (filterCategory.HasValue && q.category != filterCategory.Value) return false;
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
filterRow.Add(MakeFilterChip("主线", v => { filterCategory = v ? QuestCategory.Main : (QuestCategory?)null; RebuildFilter(); }));
|
||||
filterRow.Add(MakeFilterChip("支线", v => { filterCategory = v ? QuestCategory.Side : (QuestCategory?)null; RebuildFilter(); }));
|
||||
filterRow.Add(MakeFilterChip("日常", v => { filterCategory = v ? QuestCategory.Daily : (QuestCategory?)null; RebuildFilter(); }));
|
||||
filterRow.Add(MakeFilterChip("隐藏", v => { filterCategory = v ? QuestCategory.Hidden : (QuestCategory?)null; RebuildFilter(); }));
|
||||
// 分隔
|
||||
var sep = new Label("|");
|
||||
sep.style.opacity = 0.3f;
|
||||
sep.style.marginLeft = 2;
|
||||
sep.style.marginRight = 2;
|
||||
filterRow.Add(sep);
|
||||
filterRow.Add(MakeFilterChip("有前置", v => { filterPrereq = v; RebuildFilter(); }));
|
||||
filterRow.Add(MakeFilterChip("无目标", v => { filterNoObj = v; RebuildFilter(); }));
|
||||
filterRow.Add(MakeFilterChip("可失败", v => { filterCanFail = v; RebuildFilter(); }));
|
||||
|
||||
container.Add(_listPane);
|
||||
_listPane.Refresh();
|
||||
}
|
||||
|
||||
internal static VisualElement MakeFilterChip(string label, System.Action<bool> onToggle)
|
||||
{
|
||||
bool active = false;
|
||||
var chip = new Label(label);
|
||||
chip.style.fontSize = 10;
|
||||
chip.style.paddingLeft = 6;
|
||||
chip.style.paddingRight = 6;
|
||||
chip.style.paddingTop = 2;
|
||||
chip.style.paddingBottom = 2;
|
||||
chip.style.marginRight = 4;
|
||||
chip.style.marginBottom = 2;
|
||||
chip.style.borderTopLeftRadius = 8;
|
||||
chip.style.borderTopRightRadius = 8;
|
||||
chip.style.borderBottomLeftRadius = 8;
|
||||
chip.style.borderBottomRightRadius = 8;
|
||||
chip.style.borderTopWidth = 1;
|
||||
chip.style.borderRightWidth = 1;
|
||||
chip.style.borderBottomWidth = 1;
|
||||
chip.style.borderLeftWidth = 1;
|
||||
chip.style.borderTopColor = new StyleColor(new Color(0.5f, 0.5f, 0.5f, 0.4f));
|
||||
chip.style.borderRightColor = new StyleColor(new Color(0.5f, 0.5f, 0.5f, 0.4f));
|
||||
chip.style.borderBottomColor = new StyleColor(new Color(0.5f, 0.5f, 0.5f, 0.4f));
|
||||
chip.style.borderLeftColor = new StyleColor(new Color(0.5f, 0.5f, 0.5f, 0.4f));
|
||||
chip.style.opacity = 0.6f;
|
||||
|
||||
void SetActive(bool on)
|
||||
{
|
||||
active = on;
|
||||
chip.style.opacity = on ? 1f : 0.6f;
|
||||
chip.style.backgroundColor = on
|
||||
? new StyleColor(new Color(0.3f, 0.6f, 1f, 0.25f))
|
||||
: StyleKeyword.None;
|
||||
onToggle(on);
|
||||
}
|
||||
|
||||
chip.RegisterCallback<ClickEvent>(_ => SetActive(!active));
|
||||
return chip;
|
||||
}
|
||||
|
||||
public void BuildDetailPane(VisualElement container, UnityEngine.Object selected)
|
||||
{
|
||||
_selected = selected as QuestSO;
|
||||
@@ -63,6 +163,7 @@ namespace BaseGames.Editor.Modules
|
||||
container.Add(BuildObjectivesList(_selected));
|
||||
if (_selected.branches != null && _selected.branches.Length > 0)
|
||||
container.Add(BuildBranchesCard(_selected));
|
||||
container.Add(BuildDependencyGraph(_selected));
|
||||
container.Add(BuildActionBar(_selected));
|
||||
container.Add(SkillModule.MakeDivider());
|
||||
container.Add(new InspectorElement(_selected));
|
||||
@@ -86,11 +187,42 @@ namespace BaseGames.Editor.Modules
|
||||
int objCount = s.objectives != null ? s.objectives.Length : 0;
|
||||
|
||||
SkillModule.AddChip(card, "ID", string.IsNullOrEmpty(s.questId) ? "(未设置)" : s.questId);
|
||||
SkillModule.AddChip(card, "名称 Key", string.IsNullOrEmpty(s.displayNameKey) ? "(未设置)" : s.displayNameKey);
|
||||
|
||||
// 名称:优先显示本地化实际文本,回退到 Key 本身(与 ActorModule 保持一致)
|
||||
string nameDisplay;
|
||||
if (string.IsNullOrEmpty(s.displayNameKey))
|
||||
{
|
||||
nameDisplay = "(未设置)";
|
||||
}
|
||||
else
|
||||
{
|
||||
var resolved = BaseGames.Localization.LocalizationManager.GetEditorPreview(s.displayNameKey, "Quest");
|
||||
nameDisplay = resolved != null ? resolved : s.displayNameKey + " ⚠ [缺少本地化]";
|
||||
}
|
||||
SkillModule.AddChip(card, "名称", nameDisplay);
|
||||
if (!string.IsNullOrEmpty(s.displayNameKey))
|
||||
SkillModule.AddChip(card, "名称 Key", s.displayNameKey);
|
||||
|
||||
if (!string.IsNullOrEmpty(s.descriptionKey))
|
||||
SkillModule.AddChip(card, "描述 Key", s.descriptionKey);
|
||||
SkillModule.AddChip(card, "目标数", objCount.ToString());
|
||||
|
||||
// 分类标签
|
||||
string catDisplay = s.category switch
|
||||
{
|
||||
QuestCategory.Main => "主线",
|
||||
QuestCategory.Side => "支线",
|
||||
QuestCategory.Daily => "日常",
|
||||
QuestCategory.Hidden => "隐藏",
|
||||
_ => s.category.ToString(),
|
||||
};
|
||||
SkillModule.AddChip(card, "分类", catDisplay);
|
||||
|
||||
// 发布 NPC:优先显示 giverNpc.npcId,回退旧 giverNpcId
|
||||
string giverId = s.GiverNpcId;
|
||||
if (!string.IsNullOrEmpty(giverId))
|
||||
SkillModule.AddChip(card, "发布 NPC", giverId);
|
||||
|
||||
if (s.prerequisiteQuests != null && s.prerequisiteQuests.Length > 0)
|
||||
{
|
||||
// 显示每个前置任务的 questId,方便策划一眼看清依赖链
|
||||
@@ -110,7 +242,24 @@ namespace BaseGames.Editor.Modules
|
||||
if (s.canFail)
|
||||
SkillModule.AddChip(card, "可失败", "✓");
|
||||
if (s.reward != null)
|
||||
SkillModule.AddChip(card, "奖励", s.reward.name);
|
||||
{
|
||||
SkillModule.AddChip(card, "奖励资产", s.reward.name);
|
||||
// 展示奖励具体内容,方便策划确认配置
|
||||
var rewardDetail = new System.Text.StringBuilder();
|
||||
if (s.reward.lingZhu > 0) rewardDetail.Append($"灵珠×{s.reward.lingZhu} ");
|
||||
if (s.reward.soulBonus > 0) rewardDetail.Append($"灵魂槽+{s.reward.soulBonus} ");
|
||||
if (s.reward.itemIds != null && s.reward.itemIds.Length > 0)
|
||||
rewardDetail.Append($"物品×{s.reward.itemIds.Length} ");
|
||||
if (s.reward.affinityBonus != 0)
|
||||
rewardDetail.Append($"好感{(s.reward.affinityBonus > 0 ? "+" : "")}{s.reward.affinityBonus} ");
|
||||
if (s.reward.unlocksAbility)
|
||||
rewardDetail.Append("能力解锁 ");
|
||||
if (!string.IsNullOrEmpty(s.reward.unlockDialogueKey))
|
||||
rewardDetail.Append("台词解锁 ");
|
||||
string detail = rewardDetail.ToString().TrimEnd();
|
||||
if (!string.IsNullOrEmpty(detail))
|
||||
SkillModule.AddChip(card, "奖励内容", detail);
|
||||
}
|
||||
return card;
|
||||
}
|
||||
|
||||
@@ -185,6 +334,23 @@ namespace BaseGames.Editor.Modules
|
||||
}
|
||||
|
||||
section.Add(row);
|
||||
|
||||
// 目标描述(本地化预览,灰色小字,显示策划填写的实际内容)
|
||||
if (!string.IsNullOrEmpty(obj.displayTextKey))
|
||||
{
|
||||
var resolved = BaseGames.Localization.LocalizationManager.GetEditorPreview(obj.displayTextKey, "Quest");
|
||||
bool l10nMissing = resolved == null;
|
||||
string descText = l10nMissing ? obj.displayTextKey + " ⚠ [缺少本地化]" : resolved;
|
||||
var desc = new Label(descText);
|
||||
desc.style.fontSize = 10;
|
||||
desc.style.opacity = l10nMissing ? 1.0f : 0.55f;
|
||||
desc.style.color = l10nMissing
|
||||
? new StyleColor(new Color(1f, 0.6f, 0.1f))
|
||||
: new StyleColor(StyleKeyword.Null);
|
||||
desc.style.paddingLeft = 26;
|
||||
desc.style.marginBottom = 2;
|
||||
section.Add(desc);
|
||||
}
|
||||
}
|
||||
|
||||
return section;
|
||||
@@ -226,20 +392,508 @@ namespace BaseGames.Editor.Modules
|
||||
return card;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构建当前任务的依赖关系可视图(折叠面板形式):
|
||||
/// - 上方:前置任务链(此任务需要哪些任务先完成)
|
||||
/// - 下方:后续任务链(此任务完成后可解锁哪些任务)
|
||||
/// 数据来源:allQuests 中所有 QuestSO 的 prerequisiteQuests 引用,无运行时副作用。
|
||||
/// 节点可点击→选中对应资产(EditorGUIUtility.PingObject)。
|
||||
/// </summary>
|
||||
private static VisualElement BuildDependencyGraph(QuestSO s)
|
||||
{
|
||||
var foldout = new Foldout { text = "依赖关系", value = false };
|
||||
foldout.style.paddingLeft = 12;
|
||||
foldout.style.paddingRight = 12;
|
||||
foldout.style.marginTop = 4;
|
||||
foldout.style.marginBottom = 4;
|
||||
|
||||
// 懒加载:展开时才扫描资产,避免初始化开销
|
||||
bool built = false;
|
||||
foldout.RegisterValueChangedCallback(evt =>
|
||||
{
|
||||
if (!evt.newValue || built) return;
|
||||
built = true;
|
||||
PopulateDependencyGraph(foldout.contentContainer, s);
|
||||
});
|
||||
|
||||
return foldout;
|
||||
}
|
||||
|
||||
private static void PopulateDependencyGraph(VisualElement container, QuestSO s)
|
||||
{
|
||||
var allQuests = AssetOperations.FindAll<QuestSO>();
|
||||
|
||||
// ── 前置任务(上游)────────────────────────────────────────────────
|
||||
bool hasPrereqs = s.prerequisiteQuests != null && s.prerequisiteQuests.Length > 0;
|
||||
AddDepSection(container, "▲ 前置任务(需先完成)",
|
||||
hasPrereqs
|
||||
? System.Array.ConvertAll(s.prerequisiteQuests, q => (q, "前置"))
|
||||
: null,
|
||||
hasPrereqs ? null : "(无前置条件,可直接接取)");
|
||||
|
||||
// ── 后续任务(下游):扫描 allQuests,找出以 s 为前置的任务 ───────
|
||||
var downstream = new List<(QuestSO q, string label)>();
|
||||
foreach (var quest in allQuests)
|
||||
{
|
||||
if (quest == null || quest == s) continue;
|
||||
if (quest.prerequisiteQuests == null) continue;
|
||||
foreach (var pre in quest.prerequisiteQuests)
|
||||
{
|
||||
if (pre == s) { downstream.Add((quest, "解锁")); break; }
|
||||
}
|
||||
}
|
||||
|
||||
// ── 分支后续(branch.nextQuest)────────────────────────────────────
|
||||
if (s.branches != null)
|
||||
{
|
||||
foreach (var branch in s.branches)
|
||||
{
|
||||
if (branch.nextQuest == null) continue;
|
||||
string label = branch.conditionQuest != null
|
||||
? $"分支(条件={branch.conditionQuest.questId})"
|
||||
: "分支(默认)";
|
||||
downstream.Add((branch.nextQuest, label));
|
||||
}
|
||||
}
|
||||
|
||||
AddDepSection(container, "▼ 后续任务(完成后解锁)",
|
||||
downstream.Count > 0 ? downstream.ToArray() : null,
|
||||
downstream.Count == 0 ? "(无后续任务)" : null);
|
||||
|
||||
// ── 环形依赖检测 ─────────────────────────────────────────────────
|
||||
// 检查当前任务的前置链中是否存在循环引用(如 A 需要 B,B 又需要 A)
|
||||
if (HasPrerequisiteCycle(s, s, new System.Collections.Generic.HashSet<string>(System.StringComparer.Ordinal)))
|
||||
{
|
||||
var cycleWarn = new UnityEngine.UIElements.Label("⚠ 检测到前置任务循环引用!此任务永远无法接取,请检查前置任务链。");
|
||||
cycleWarn.style.color = new StyleColor(new UnityEngine.Color(1f, 0.4f, 0.2f));
|
||||
cycleWarn.style.fontSize = 11;
|
||||
cycleWarn.style.marginTop = 6;
|
||||
cycleWarn.style.paddingLeft = 8;
|
||||
cycleWarn.style.whiteSpace = WhiteSpace.Normal;
|
||||
container.Add(cycleWarn);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 递归检测任务是否存在循环前置依赖(DFS)。
|
||||
/// visited 存储已访问的 questId,origin 为检测起点。
|
||||
/// </summary>
|
||||
private static bool HasPrerequisiteCycle(QuestSO origin, QuestSO current, System.Collections.Generic.HashSet<string> visited)
|
||||
{
|
||||
if (current?.prerequisiteQuests == null) return false;
|
||||
foreach (var pre in current.prerequisiteQuests)
|
||||
{
|
||||
if (pre == null || string.IsNullOrEmpty(pre.questId)) continue;
|
||||
if (pre == origin) return true; // 回到起点,发现循环
|
||||
if (!visited.Add(pre.questId)) continue; // 已访问,跳过防止重复 DFS
|
||||
if (HasPrerequisiteCycle(origin, pre, visited)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>添加一个依赖关系分区(标题 + 节点列表)。</summary>
|
||||
private static void AddDepSection(VisualElement container,
|
||||
string sectionTitle,
|
||||
(QuestSO q, string label)[] items,
|
||||
string emptyText)
|
||||
{
|
||||
var header = new Label(sectionTitle);
|
||||
header.style.fontSize = 10;
|
||||
header.style.opacity = 0.55f;
|
||||
header.style.marginTop = 6;
|
||||
header.style.marginBottom = 3;
|
||||
header.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
container.Add(header);
|
||||
|
||||
if (items == null || items.Length == 0)
|
||||
{
|
||||
var empty = new Label(emptyText ?? "(无)");
|
||||
empty.style.fontSize = 11;
|
||||
empty.style.opacity = 0.4f;
|
||||
empty.style.paddingLeft = 10;
|
||||
container.Add(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var (q, label) in items)
|
||||
{
|
||||
if (q == null) continue;
|
||||
var row = new VisualElement();
|
||||
row.style.flexDirection = FlexDirection.Row;
|
||||
row.style.alignItems = Align.Center;
|
||||
row.style.marginBottom = 2;
|
||||
row.style.paddingLeft = 10;
|
||||
|
||||
// 关系标签徽章
|
||||
var badge = new Label($"[{label}]");
|
||||
badge.style.fontSize = 9;
|
||||
badge.style.opacity = 0.6f;
|
||||
badge.style.marginRight = 5;
|
||||
badge.style.flexShrink = 0;
|
||||
row.Add(badge);
|
||||
|
||||
// 任务名按钮(点击 Ping 资产)
|
||||
string displayName = string.IsNullOrEmpty(q.questId) ? q.name : q.questId;
|
||||
var btn = new Button(() => EditorGUIUtility.PingObject(q)) { text = displayName };
|
||||
btn.style.fontSize = 11;
|
||||
btn.style.flexGrow = 1;
|
||||
btn.style.paddingTop = 1;
|
||||
btn.style.paddingBottom = 1;
|
||||
btn.style.unityTextAlign = TextAnchor.MiddleLeft;
|
||||
row.Add(btn);
|
||||
|
||||
container.Add(row);
|
||||
}
|
||||
}
|
||||
|
||||
private VisualElement BuildActionBar(QuestSO s)
|
||||
{
|
||||
var bar = SkillModule.BuildStandardActionBar(
|
||||
s, Folder, Prefix,
|
||||
onCreated: c => _listPane.Refresh(c),
|
||||
onCloned: c => _listPane.Refresh(c),
|
||||
onDeleted: () => _listPane.Refresh(null));
|
||||
onDeleted: () => _listPane.Refresh(null),
|
||||
wizardCreate: cb => AssetCreationWizard.Show<QuestSO>(
|
||||
Folder, Prefix,
|
||||
(q, id) =>
|
||||
{
|
||||
q.questId = id;
|
||||
EditorUtility.SetDirty(q);
|
||||
AssetDatabase.SaveAssets();
|
||||
cb(q);
|
||||
}));
|
||||
|
||||
// 任务模块额外:代码常量生成
|
||||
new Button(GenerateQuestKeys) { text = "生成常量" }.AlsoAddTo(bar);
|
||||
// 任务模块额外:代码常量生成 + 批量配置验证
|
||||
new Button(GenerateQuestKeys) { text = "生成常量" }.AlsoAddTo(bar);
|
||||
new Button(ValidateAllQuests) { text = "批量验证" }.AlsoAddTo(bar);
|
||||
|
||||
// 运行时模拟按钮(仅 PlayMode 可用)
|
||||
var simulateBtn = new Button(() => SimulateQuest(_selected)) { text = "▶ 模拟" };
|
||||
simulateBtn.tooltip =
|
||||
"PlayMode 下推进任务状态机:\n" +
|
||||
" • Available → AcceptQuest(接取)\n" +
|
||||
" • Active → 弹窗选择:CompleteQuest 或 AbandonQuest\n" +
|
||||
" • 其他状态 → ResetQuest(重置为 Available 供重测)\n" +
|
||||
"EditMode 下按钮灰显。";
|
||||
simulateBtn.SetEnabled(UnityEditor.EditorApplication.isPlaying);
|
||||
|
||||
// 退订旧订阅,避免每次 BuildDetailPane 时重复追加 lambda 导致内存泄漏
|
||||
if (_playModeHandler != null)
|
||||
UnityEditor.EditorApplication.playModeStateChanged -= _playModeHandler;
|
||||
_playModeHandler = s =>
|
||||
{
|
||||
bool playing = s == UnityEditor.PlayModeStateChange.EnteredPlayMode
|
||||
|| UnityEditor.EditorApplication.isPlaying;
|
||||
simulateBtn.SetEnabled(playing);
|
||||
};
|
||||
UnityEditor.EditorApplication.playModeStateChanged += _playModeHandler;
|
||||
simulateBtn.AlsoAddTo(bar);
|
||||
|
||||
return bar;
|
||||
}
|
||||
|
||||
// ── 运行时模拟 ────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// PlayMode 下对当前选中的 QuestSO 模拟状态推进或重置:
|
||||
/// - Available → AcceptQuest
|
||||
/// - Active → CompleteQuest(传入 null rewardTarget,跳过奖励发放)
|
||||
/// - Completed / Failed / Unavailable → ResetQuest(重置为 Available 供重测)
|
||||
/// 用于策划/开发人员在不启动游戏流程的情况下快速验证任务状态机。
|
||||
/// </summary>
|
||||
private static void SimulateQuest(QuestSO quest)
|
||||
{
|
||||
if (!UnityEditor.EditorApplication.isPlaying)
|
||||
{
|
||||
UnityEditor.EditorUtility.DisplayDialog("模拟测试", "请先进入 PlayMode。", "确定");
|
||||
return;
|
||||
}
|
||||
if (quest == null)
|
||||
{
|
||||
Debug.LogWarning("[QuestModule] 请先在左侧列表选中一个任务再点击模拟。");
|
||||
return;
|
||||
}
|
||||
|
||||
var qm = BaseGames.Core.ServiceLocator.GetOrDefault<IQuestManager>();
|
||||
if (qm == null)
|
||||
{
|
||||
Debug.LogWarning("[QuestModule] IQuestManager 未注册到 ServiceLocator,请确认 QuestManager 已在场景中。");
|
||||
return;
|
||||
}
|
||||
|
||||
var state = qm.GetState(quest.questId);
|
||||
switch (state)
|
||||
{
|
||||
case BaseGames.Core.Events.QuestState.Available:
|
||||
qm.AcceptQuest(quest.questId);
|
||||
Debug.Log($"[QuestModule] 模拟接受任务:{quest.questId}");
|
||||
break;
|
||||
case BaseGames.Core.Events.QuestState.Active:
|
||||
// Active 状态提供三个操作:完成 / 暂停 / 放弃
|
||||
int choice = UnityEditor.EditorUtility.DisplayDialogComplex(
|
||||
"模拟 Active 任务",
|
||||
$"任务 [{quest.questId}] 当前进行中,请选择操作:",
|
||||
"完成任务", // 0
|
||||
"取消", // 1
|
||||
"暂停任务"); // 2
|
||||
if (choice == 0)
|
||||
{
|
||||
qm.CompleteQuest(quest.questId, null);
|
||||
Debug.Log($"[QuestModule] 模拟完成任务:{quest.questId}");
|
||||
}
|
||||
else if (choice == 2)
|
||||
{
|
||||
qm.PauseQuest(quest.questId);
|
||||
Debug.Log($"[QuestModule] 模拟暂停任务:{quest.questId}");
|
||||
}
|
||||
break;
|
||||
case BaseGames.Core.Events.QuestState.Paused:
|
||||
// Paused 状态:恢复 或 放弃
|
||||
int pauseChoice = UnityEditor.EditorUtility.DisplayDialogComplex(
|
||||
"模拟 Paused 任务",
|
||||
$"任务 [{quest.questId}] 当前已暂停,请选择操作:",
|
||||
"恢复任务", // 0
|
||||
"取消", // 1
|
||||
"放弃任务"); // 2
|
||||
if (pauseChoice == 0)
|
||||
{
|
||||
qm.ResumeQuest(quest.questId);
|
||||
Debug.Log($"[QuestModule] 模拟恢复任务:{quest.questId}");
|
||||
}
|
||||
else if (pauseChoice == 2)
|
||||
{
|
||||
// Paused 不能直接调用 AbandonQuest(需先恢复)
|
||||
qm.ResumeQuest(quest.questId);
|
||||
qm.AbandonQuest(quest.questId);
|
||||
Debug.Log($"[QuestModule] 模拟放弃暂停中的任务:{quest.questId}");
|
||||
}
|
||||
break;
|
||||
default:
|
||||
// Completed / Failed / Unavailable → 通过 IQuestDebugger 重置为 Available 供重测
|
||||
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
||||
if (qm is IQuestDebugger debugger)
|
||||
{
|
||||
debugger.ResetQuest(quest.questId);
|
||||
Debug.Log($"[QuestModule] 任务 '{quest.questId}' 已从 [{state}] 重置,可重新接取。");
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning($"[QuestModule] IQuestManager 未实现 IQuestDebugger,无法重置任务 '{quest.questId}'。");
|
||||
}
|
||||
#endif
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 批量验证 ─────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 遍历所有 QuestSO,执行以下检查并汇总结果:
|
||||
/// 1. questId 为空
|
||||
/// 2. questId 重复
|
||||
/// 3. objectives 为空(无目标任务)
|
||||
/// 4. prerequisiteQuests 含空引用
|
||||
/// 5. 前置任务循环依赖(DFS)
|
||||
/// 6. canFail=true 但 failCondition 为空
|
||||
/// 7. reward.affinityBonus != 0 但 giverNpcId 为空(好感度会丢失)
|
||||
/// 8. TriggerZone ↔ ReachAreaObjective markerTag 孤儿交叉检测
|
||||
/// 9. 同任务内 objectiveId 重复(运行时 compositeKey 碰撞)
|
||||
/// 10. branches[i].conditionFlags 含空白字符串(策划配置遗漏 flag 名)
|
||||
/// 11. reward.itemIds 含空白字符串或无对应 Collectible 预制件(孤儿奖励 ID)
|
||||
/// 结果在可交互的 QuestValidationResultWindow 中展示,每项问题附"选中"按钮可一键定位资产。
|
||||
/// </summary>
|
||||
private static void ValidateAllQuests()
|
||||
{
|
||||
var allQuests = AssetOperations.FindAll<QuestSO>();
|
||||
var issues = new List<QuestValidationResultWindow.Issue>();
|
||||
int errorCount = 0, warnCount = 0;
|
||||
|
||||
void AddError(string msg, UnityEngine.Object asset = null)
|
||||
{
|
||||
issues.Add(new QuestValidationResultWindow.Issue { message = msg, isError = true, asset = asset });
|
||||
errorCount++;
|
||||
}
|
||||
void AddWarn(string msg, UnityEngine.Object asset = null)
|
||||
{
|
||||
issues.Add(new QuestValidationResultWindow.Issue { message = msg, isError = false, asset = asset });
|
||||
warnCount++;
|
||||
}
|
||||
|
||||
var idMap = ValidateIds(allQuests, AddError);
|
||||
ValidateStructure(allQuests, idMap, AddError, AddWarn);
|
||||
ValidateTriggerZones(AddWarn);
|
||||
ValidateObjectiveIds(allQuests, AddError);
|
||||
ValidateBranchFlags(allQuests, AddWarn);
|
||||
ValidateRewards(allQuests, AddWarn);
|
||||
|
||||
Debug.Log($"[QuestModule] 验证完成:{allQuests.Count} 个任务,{errorCount} 个错误,{warnCount} 个警告。");
|
||||
QuestValidationResultWindow.Show(issues, errorCount, warnCount, allQuests.Count, "任务批量验证结果", "任务");
|
||||
}
|
||||
|
||||
// 检查 1 & 2:空 questId / 重复 questId;返回 id→SO 映射供后续检查使用
|
||||
private static Dictionary<string, QuestSO> ValidateIds(
|
||||
List<QuestSO> allQuests,
|
||||
System.Action<string, UnityEngine.Object> addError)
|
||||
{
|
||||
var idMap = new Dictionary<string, QuestSO>(StringComparer.Ordinal);
|
||||
foreach (var q in allQuests)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(q.questId))
|
||||
{
|
||||
addError($"{q.name}: questId 为空,任务无法被系统引用。", q);
|
||||
continue;
|
||||
}
|
||||
if (idMap.TryGetValue(q.questId, out var existing))
|
||||
addError($"重复 questId \"{q.questId}\":{q.name} 与 {existing.name}", q);
|
||||
else
|
||||
idMap[q.questId] = q;
|
||||
}
|
||||
return idMap;
|
||||
}
|
||||
|
||||
// 检查 3–7:结构完整性(无目标、空引用前置、循环依赖、canFail 配置、好感度配置)
|
||||
private static void ValidateStructure(
|
||||
List<QuestSO> allQuests,
|
||||
Dictionary<string, QuestSO> idMap,
|
||||
System.Action<string, UnityEngine.Object> addError,
|
||||
System.Action<string, UnityEngine.Object> addWarn)
|
||||
{
|
||||
foreach (var q in allQuests)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(q.questId)) continue;
|
||||
|
||||
if (q.objectives == null || q.objectives.Length == 0)
|
||||
addWarn($"{q.questId}: objectives 为空,任务无任何目标。", q);
|
||||
|
||||
if (q.prerequisiteQuests != null)
|
||||
foreach (var pre in q.prerequisiteQuests)
|
||||
if (pre == null) { addWarn($"{q.questId}: prerequisiteQuests 含空引用,请清理 Inspector 中的空槽。", q); break; }
|
||||
|
||||
if (HasCircularPrerequisite(q, idMap, new HashSet<string>(StringComparer.Ordinal)))
|
||||
addError($"{q.questId}: 前置任务链存在循环依赖,将导致任务永远无法变为 Available!", q);
|
||||
|
||||
if (q.canFail && q.failCondition == null)
|
||||
addWarn($"{q.questId}: canFail=true 但 failCondition 为空,失败条件永不触发。", q);
|
||||
|
||||
if (q.reward != null && q.reward.affinityBonus != 0 && string.IsNullOrEmpty(q.GiverNpcId))
|
||||
addWarn($"{q.questId}: reward.affinityBonus={q.reward.affinityBonus} 但 GiverNpcId 为空,好感度增量将丢失。", q);
|
||||
}
|
||||
}
|
||||
|
||||
// 检查 8:TriggerZone ↔ ReachAreaObjective markerTag 孤儿交叉检测
|
||||
private static void ValidateTriggerZones(System.Action<string, UnityEngine.Object> addWarn)
|
||||
{
|
||||
var reachTagToSO = new Dictionary<string, BaseGames.Quest.ReachAreaObjective>(StringComparer.Ordinal);
|
||||
foreach (var obj in AssetOperations.FindAll<BaseGames.Quest.ReachAreaObjective>())
|
||||
if (!string.IsNullOrEmpty(obj.markerTag))
|
||||
reachTagToSO[obj.markerTag] = obj;
|
||||
|
||||
var triggerTagToPrefab = new Dictionary<string, GameObject>(StringComparer.Ordinal);
|
||||
foreach (var guid in AssetDatabase.FindAssets("t:Prefab"))
|
||||
{
|
||||
var prefabPath = AssetDatabase.GUIDToAssetPath(guid);
|
||||
var prefabGo = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath);
|
||||
if (prefabGo == null) continue;
|
||||
foreach (var zone in prefabGo.GetComponentsInChildren<BaseGames.World.TriggerZone>(true))
|
||||
if (!string.IsNullOrEmpty(zone.MarkerTag))
|
||||
triggerTagToPrefab.TryAdd(zone.MarkerTag, prefabGo);
|
||||
}
|
||||
|
||||
foreach (var (tag, so) in reachTagToSO)
|
||||
if (!triggerTagToPrefab.ContainsKey(tag))
|
||||
addWarn($"ReachAreaObjective.markerTag=\"{tag}\" 无对应 Prefab 中的 TriggerZone(孤儿目标 Tag)。", so);
|
||||
|
||||
foreach (var (tag, prefab) in triggerTagToPrefab)
|
||||
if (!reachTagToSO.ContainsKey(tag))
|
||||
addWarn($"TriggerZone.markerTag=\"{tag}\" 无对应 ReachAreaObjective(孤儿触发器 Tag)。", prefab);
|
||||
}
|
||||
|
||||
// 检查 9:同任务内 objectiveId 重复
|
||||
private static void ValidateObjectiveIds(
|
||||
List<QuestSO> allQuests,
|
||||
System.Action<string, UnityEngine.Object> addError)
|
||||
{
|
||||
foreach (var q in allQuests)
|
||||
{
|
||||
if (q.objectives == null || q.objectives.Length == 0) continue;
|
||||
var seenIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var obj in q.objectives)
|
||||
{
|
||||
if (obj == null || string.IsNullOrEmpty(obj.objectiveId)) continue;
|
||||
if (!seenIds.Add(obj.objectiveId))
|
||||
addError($"任务 '{q.questId}' 存在重复 objectiveId '{obj.objectiveId}',运行时状态将互串。", q);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查 10:branches[i].conditionFlags 含空白字符串
|
||||
private static void ValidateBranchFlags(
|
||||
List<QuestSO> allQuests,
|
||||
System.Action<string, UnityEngine.Object> addWarn)
|
||||
{
|
||||
foreach (var q in allQuests)
|
||||
{
|
||||
if (q.branches == null || q.branches.Length == 0) continue;
|
||||
for (int bi = 0; bi < q.branches.Length; bi++)
|
||||
{
|
||||
var branch = q.branches[bi];
|
||||
if (branch.conditionFlags == null || branch.conditionFlags.Length == 0) continue;
|
||||
for (int fi = 0; fi < branch.conditionFlags.Length; fi++)
|
||||
if (string.IsNullOrWhiteSpace(branch.conditionFlags[fi]))
|
||||
addWarn($"任务 '{q.questId}' 分支[{bi}].conditionFlags[{fi}] 为空白字符串,运行时将被跳过,请检查是否遗漏标志名。", q);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查 11:reward.itemIds 含空白字符串或无对应 Collectible 预制件
|
||||
private static void ValidateRewards(
|
||||
List<QuestSO> allQuests,
|
||||
System.Action<string, UnityEngine.Object> addWarn)
|
||||
{
|
||||
var knownIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var guid in AssetDatabase.FindAssets("t:Prefab"))
|
||||
{
|
||||
var prefabPath = AssetDatabase.GUIDToAssetPath(guid);
|
||||
var go = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath);
|
||||
if (go == null) continue;
|
||||
var col = go.GetComponent<BaseGames.World.Collectible>();
|
||||
if (col == null) continue;
|
||||
var so = new UnityEditor.SerializedObject(col);
|
||||
var idProp = so.FindProperty("_collectibleId") ?? so.FindProperty("collectibleId");
|
||||
if (idProp != null && !string.IsNullOrEmpty(idProp.stringValue))
|
||||
knownIds.Add(idProp.stringValue);
|
||||
}
|
||||
|
||||
foreach (var q in allQuests)
|
||||
{
|
||||
if (q.reward == null || q.reward.itemIds == null) continue;
|
||||
for (int ii = 0; ii < q.reward.itemIds.Length; ii++)
|
||||
{
|
||||
var itemId = q.reward.itemIds[ii];
|
||||
if (string.IsNullOrWhiteSpace(itemId))
|
||||
addWarn($"任务 '{q.questId}' reward.itemIds[{ii}] 为空白字符串,将被跳过。", q);
|
||||
else if (knownIds.Count > 0 && !knownIds.Contains(itemId))
|
||||
addWarn($"任务 '{q.questId}' reward.itemIds[{ii}]=\"{itemId}\" 在项目 Prefab 中无对应 Collectible,奖励可能无效。", q);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool HasCircularPrerequisite(QuestSO start, Dictionary<string, QuestSO> idMap,
|
||||
HashSet<string> visited)
|
||||
{
|
||||
if (!visited.Add(start.questId)) return true;
|
||||
if (start.prerequisiteQuests == null) return false;
|
||||
foreach (var pre in start.prerequisiteQuests)
|
||||
{
|
||||
if (pre == null || string.IsNullOrEmpty(pre.questId)) continue;
|
||||
if (!idMap.TryGetValue(pre.questId, out var preQuest)) continue;
|
||||
if (HasCircularPrerequisite(preQuest, idMap, visited)) return true;
|
||||
}
|
||||
visited.Remove(start.questId);
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── QuestKeys.cs 常量生成器 ──────────────────────────────────────────
|
||||
|
||||
private const string GeneratedFolder = "Assets/_Game/Scripts/Generated";
|
||||
|
||||
11
Assets/_Game/Scripts/Editor/Modules/QuestModule.cs.meta
Normal file
11
Assets/_Game/Scripts/Editor/Modules/QuestModule.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cdb78fdcbe3a25f40b0f77d1b42002b9
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,171 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Editor.Modules
|
||||
{
|
||||
/// <summary>
|
||||
/// 批量验证结果窗口:以可滚动列表展示各项配置问题,
|
||||
/// 每条记录附带"选中"按钮,点击后高亮并选中对应资产(EditorGUIUtility.PingObject)。
|
||||
/// 支持 Error/Warning Tab 切换与文本搜索过滤。
|
||||
/// 由各验证模块(QuestModule、DialogueModule、NpcModule)在检测到问题时弹出。
|
||||
/// </summary>
|
||||
internal class QuestValidationResultWindow : EditorWindow
|
||||
{
|
||||
internal struct Issue
|
||||
{
|
||||
public string message;
|
||||
public bool isError;
|
||||
public UnityEngine.Object asset; // null = 无对应资产(如孤儿触发器)
|
||||
}
|
||||
|
||||
private List<Issue> _issues;
|
||||
private int _errorCount;
|
||||
private int _warnCount;
|
||||
private int _totalItems;
|
||||
private string _itemLabel = "资产";
|
||||
private Vector2 _scroll;
|
||||
|
||||
// ── 过滤状态 ─────────────────────────────────────────────────────────
|
||||
private enum TabMode { All, ErrorsOnly, WarnsOnly }
|
||||
private TabMode _tab = TabMode.All;
|
||||
private string _filter = "";
|
||||
|
||||
private static readonly GUIStyle s_tabActive = null; // 延迟初始化
|
||||
private static readonly GUIStyle s_tabInactive = null;
|
||||
|
||||
// ── 打开入口 ──────────────────────────────────────────────────────────
|
||||
|
||||
internal static void Show(List<Issue> issues, int errorCount, int warnCount, int totalItems, string windowTitle = "批量验证结果", string itemLabel = "资产")
|
||||
{
|
||||
var win = GetWindow<QuestValidationResultWindow>(true, windowTitle, true);
|
||||
win._issues = issues;
|
||||
win._errorCount = errorCount;
|
||||
win._warnCount = warnCount;
|
||||
win._totalItems = totalItems;
|
||||
win._itemLabel = itemLabel;
|
||||
win._tab = TabMode.All;
|
||||
win._filter = "";
|
||||
win.minSize = new Vector2(560, 380);
|
||||
win.Show();
|
||||
}
|
||||
|
||||
// ── 绘制 ──────────────────────────────────────────────────────────────
|
||||
|
||||
private void OnGUI()
|
||||
{
|
||||
if (_issues == null) { EditorGUILayout.LabelField("无数据。"); return; }
|
||||
|
||||
bool clean = _errorCount == 0 && _warnCount == 0;
|
||||
|
||||
// ── 标题摘要 ──
|
||||
EditorGUILayout.Space(6);
|
||||
var summaryStyle = new GUIStyle(EditorStyles.boldLabel) { fontSize = 12 };
|
||||
if (clean) summaryStyle.normal.textColor = new Color(0.2f, 0.75f, 0.2f);
|
||||
|
||||
string prefix = clean ? "✅ " : (_errorCount > 0 ? "❌ " : "⚠ ");
|
||||
string summary = $"{prefix}验证完成:{_totalItems} 个{_itemLabel} · {_errorCount} 个错误 · {_warnCount} 个警告";
|
||||
EditorGUILayout.LabelField(summary, summaryStyle);
|
||||
EditorGUILayout.Space(4);
|
||||
|
||||
if (clean)
|
||||
{
|
||||
EditorGUILayout.LabelField($"所有 {_itemLabel} 配置均合法!", EditorStyles.centeredGreyMiniLabel);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 分隔线 ──
|
||||
var divRect = EditorGUILayout.GetControlRect(false, 1);
|
||||
EditorGUI.DrawRect(divRect, new Color(0.35f, 0.35f, 0.35f));
|
||||
EditorGUILayout.Space(4);
|
||||
|
||||
// ── Tab 切换 + 搜索框 ──
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
DrawTab("全部", TabMode.All, _issues.Count);
|
||||
DrawTab("错误", TabMode.ErrorsOnly, _errorCount);
|
||||
DrawTab("警告", TabMode.WarnsOnly, _warnCount);
|
||||
GUILayout.FlexibleSpace();
|
||||
GUILayout.Label("🔍", GUILayout.Width(18));
|
||||
_filter = EditorGUILayout.TextField(_filter, GUILayout.Width(180));
|
||||
if (GUILayout.Button("×", GUILayout.Width(22))) _filter = "";
|
||||
EditorGUILayout.EndHorizontal();
|
||||
EditorGUILayout.Space(4);
|
||||
|
||||
// ── 问题列表 ──
|
||||
_scroll = EditorGUILayout.BeginScrollView(_scroll);
|
||||
int shown = 0;
|
||||
foreach (var issue in _issues)
|
||||
{
|
||||
if (!MatchesFilter(issue)) continue;
|
||||
shown++;
|
||||
DrawIssueRow(issue);
|
||||
EditorGUILayout.Space(2);
|
||||
}
|
||||
if (shown == 0)
|
||||
EditorGUILayout.LabelField("(当前过滤条件下无结果)", EditorStyles.centeredGreyMiniLabel);
|
||||
EditorGUILayout.EndScrollView();
|
||||
|
||||
// ── 底部状态栏 ──
|
||||
EditorGUILayout.Space(2);
|
||||
var statusRect = EditorGUILayout.GetControlRect(false, 1);
|
||||
EditorGUI.DrawRect(statusRect, new Color(0.3f, 0.3f, 0.3f));
|
||||
EditorGUILayout.LabelField(
|
||||
$"显示 {shown} / {_issues.Count} 条",
|
||||
EditorStyles.centeredGreyMiniLabel);
|
||||
}
|
||||
|
||||
// ── 辅助方法 ─────────────────────────────────────────────────────────
|
||||
|
||||
private bool MatchesFilter(Issue issue)
|
||||
{
|
||||
if (_tab == TabMode.ErrorsOnly && !issue.isError) return false;
|
||||
if (_tab == TabMode.WarnsOnly && issue.isError) return false;
|
||||
if (!string.IsNullOrEmpty(_filter))
|
||||
{
|
||||
bool msgMatch = issue.message.Contains(_filter, System.StringComparison.OrdinalIgnoreCase);
|
||||
bool assetMatch = issue.asset != null &&
|
||||
issue.asset.name.Contains(_filter, System.StringComparison.OrdinalIgnoreCase);
|
||||
if (!msgMatch && !assetMatch) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void DrawTab(string label, TabMode mode, int count)
|
||||
{
|
||||
bool active = _tab == mode;
|
||||
var style = active
|
||||
? new GUIStyle(EditorStyles.miniButtonMid) { fontStyle = FontStyle.Bold }
|
||||
: EditorStyles.miniButtonMid;
|
||||
if (active) style.normal.textColor = new Color(0.4f, 0.8f, 1f);
|
||||
string text = $"{label} ({count})";
|
||||
if (GUILayout.Button(text, style, GUILayout.MinWidth(72)))
|
||||
_tab = mode;
|
||||
}
|
||||
|
||||
private static void DrawIssueRow(Issue issue)
|
||||
{
|
||||
EditorGUILayout.BeginHorizontal(EditorStyles.helpBox);
|
||||
|
||||
// 图标
|
||||
var iconContent = issue.isError
|
||||
? EditorGUIUtility.IconContent("console.erroricon.sml")
|
||||
: EditorGUIUtility.IconContent("console.warnicon.sml");
|
||||
GUILayout.Label(iconContent, GUILayout.Width(20), GUILayout.Height(18));
|
||||
|
||||
// 消息文字
|
||||
EditorGUILayout.LabelField(issue.message, EditorStyles.wordWrappedLabel);
|
||||
|
||||
// 定位按钮(有资产引用时显示)
|
||||
if (issue.asset != null)
|
||||
{
|
||||
if (GUILayout.Button("选中", GUILayout.Width(40), GUILayout.Height(18)))
|
||||
{
|
||||
EditorGUIUtility.PingObject(issue.asset);
|
||||
Selection.activeObject = issue.asset;
|
||||
}
|
||||
}
|
||||
|
||||
EditorGUILayout.EndHorizontal();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cd040e3f82e3b3040a92fac14502ef4a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -10,7 +10,7 @@ namespace BaseGames.Editor.Modules
|
||||
/// <summary>
|
||||
/// DataHub 技能模块 —— 管理 FormSkillSO 资产。
|
||||
/// </summary>
|
||||
public class SkillModule : IDataModule
|
||||
public class SkillModule : IDataModule, IDataModuleOrdered
|
||||
{
|
||||
private const string Folder = "Assets/_Game/Data/Skills";
|
||||
private const string Prefix = "SKL_";
|
||||
@@ -18,6 +18,7 @@ namespace BaseGames.Editor.Modules
|
||||
public string ModuleId => "skill";
|
||||
public string DisplayName => "技能";
|
||||
public string IconName => null;
|
||||
public int DisplayOrder => 20;
|
||||
|
||||
private SoListPane<FormSkillSO> _listPane;
|
||||
private DetailHeader _header;
|
||||
@@ -193,16 +194,24 @@ namespace BaseGames.Editor.Modules
|
||||
T asset,
|
||||
string folder,
|
||||
string prefix,
|
||||
Action<T> onCreated,
|
||||
Action<T> onCloned,
|
||||
Action onDeleted) where T : UnityEngine.ScriptableObject
|
||||
Action<T> onCreated,
|
||||
Action<T> onCloned,
|
||||
Action onDeleted,
|
||||
Action<Action<T>> wizardCreate = null) where T : UnityEngine.ScriptableObject
|
||||
{
|
||||
var bar = MakeActionBar();
|
||||
|
||||
new Button(() =>
|
||||
{
|
||||
var c = AssetOperations.Create<T>(folder, prefix + "New");
|
||||
if (c != null) onCreated?.Invoke(c);
|
||||
if (wizardCreate != null)
|
||||
{
|
||||
wizardCreate(c => onCreated?.Invoke(c));
|
||||
}
|
||||
else
|
||||
{
|
||||
var c = AssetOperations.Create<T>(folder, prefix + "New");
|
||||
if (c != null) onCreated?.Invoke(c);
|
||||
}
|
||||
}) { text = "新建" }.AlsoAddTo(bar);
|
||||
|
||||
new Button(() =>
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace BaseGames.Editor.Modules
|
||||
/// <summary>
|
||||
/// DataHub 流式加载模块 —— 管理 <see cref="StreamingBudgetConfigSO"/> 资产。
|
||||
/// </summary>
|
||||
public class StreamingModule : IDataModule
|
||||
public class StreamingModule : IDataModule, IDataModuleOrdered
|
||||
{
|
||||
private const string Folder = "Assets/_Game/Data/Streaming";
|
||||
private const string Prefix = "STR_";
|
||||
@@ -18,6 +18,7 @@ namespace BaseGames.Editor.Modules
|
||||
public string ModuleId => "streaming";
|
||||
public string DisplayName => "流式加载";
|
||||
public string IconName => "d_RectTransformBlueprint";
|
||||
public int DisplayOrder => 70;
|
||||
|
||||
private SoListPane<StreamingBudgetConfigSO> _listPane;
|
||||
private DetailHeader _header;
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace BaseGames.Editor.Modules
|
||||
/// <summary>
|
||||
/// DataHub 武器模块 —— 管理 WeaponSO 资产。
|
||||
/// </summary>
|
||||
public class WeaponModule : IDataModule
|
||||
public class WeaponModule : IDataModule, IDataModuleOrdered
|
||||
{
|
||||
private const string Folder = "Assets/_Game/Data/Weapons";
|
||||
private const string Prefix = "WPN_";
|
||||
@@ -18,6 +18,7 @@ namespace BaseGames.Editor.Modules
|
||||
public string ModuleId => "weapon";
|
||||
public string DisplayName => "武器";
|
||||
public string IconName => null;
|
||||
public int DisplayOrder => 10;
|
||||
|
||||
private SoListPane<WeaponSO> _listPane;
|
||||
private DetailHeader _header;
|
||||
|
||||
8
Assets/_Game/Scripts/Editor/Quest.meta
Normal file
8
Assets/_Game/Scripts/Editor/Quest.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ba51d204b9cde834f9a521996715f883
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 832e6ad6d64454d4286183c6d7cfdc3e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
118
Assets/_Game/Scripts/Editor/Quest/QuestSOEditor.cs
Normal file
118
Assets/_Game/Scripts/Editor/Quest/QuestSOEditor.cs
Normal file
@@ -0,0 +1,118 @@
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using BaseGames.Quest;
|
||||
using BaseGames.Core;
|
||||
|
||||
namespace BaseGames.Editor.Quest
|
||||
{
|
||||
/// <summary>
|
||||
/// QuestSO 自定义 Inspector。
|
||||
/// 在检测到旧版前置字段(prerequisiteQuests / prerequisiteFlags)有数据时,
|
||||
/// 显示迁移提示框和一键迁移按钮,引导策划将数据迁移到 QuestPrerequisite 统一结构。
|
||||
/// </summary>
|
||||
[CustomEditor(typeof(QuestSO))]
|
||||
public class QuestSOEditor : UnityEditor.Editor
|
||||
{
|
||||
private bool _showMigrationBox = true;
|
||||
|
||||
public override void OnInspectorGUI()
|
||||
{
|
||||
serializedObject.Update();
|
||||
|
||||
var quest = (QuestSO)target;
|
||||
|
||||
// ── 旧版字段迁移提示 ──────────────────────────────────────────────
|
||||
#pragma warning disable CS0618
|
||||
bool hasLegacyQuests = quest.prerequisiteQuests != null && quest.prerequisiteQuests.Length > 0;
|
||||
bool hasLegacyFlags = quest.prerequisiteFlags != null && quest.prerequisiteFlags.Length > 0;
|
||||
#pragma warning restore CS0618
|
||||
|
||||
if (hasLegacyQuests || hasLegacyFlags)
|
||||
{
|
||||
_showMigrationBox = EditorGUILayout.BeginFoldoutHeaderGroup(_showMigrationBox, "⚠ 旧版前置字段迁移");
|
||||
if (_showMigrationBox)
|
||||
{
|
||||
EditorGUILayout.HelpBox(
|
||||
"检测到旧版前置字段有数据:\n" +
|
||||
(hasLegacyQuests ? $" • prerequisiteQuests:{quest.prerequisiteQuests.Length} 项\n" : "") +
|
||||
(hasLegacyFlags ? $" • prerequisiteFlags:{quest.prerequisiteFlags.Length} 项\n" : "") +
|
||||
"\n新版 'prerequisites'(QuestPrerequisite)字段已支持更完整的前置配置。\n" +
|
||||
"点击下方按钮可将旧版数据自动迁移至新字段,迁移后旧字段将被清空。\n" +
|
||||
"迁移操作可撤销(Ctrl+Z)。",
|
||||
MessageType.Warning);
|
||||
|
||||
bool hasNewData = quest.prerequisites.HasAny;
|
||||
if (hasNewData)
|
||||
EditorGUILayout.HelpBox(
|
||||
"新版 prerequisites 字段已有数据。点击迁移将与旧版数据合并(去重),不会覆盖现有配置。",
|
||||
MessageType.Info);
|
||||
|
||||
if (GUILayout.Button("一键迁移旧版前置字段 → prerequisites"))
|
||||
{
|
||||
MigrateLegacyPrerequisites(quest);
|
||||
}
|
||||
}
|
||||
EditorGUILayout.EndFoldoutHeaderGroup();
|
||||
EditorGUILayout.Space(4);
|
||||
}
|
||||
|
||||
// ── 默认 Inspector ────────────────────────────────────────────────
|
||||
DrawDefaultInspector();
|
||||
|
||||
serializedObject.ApplyModifiedProperties();
|
||||
}
|
||||
|
||||
private static void MigrateLegacyPrerequisites(QuestSO quest)
|
||||
{
|
||||
Undo.RecordObject(quest, "迁移 QuestSO 旧版前置字段");
|
||||
|
||||
#pragma warning disable CS0618
|
||||
int legacyQuestCount = quest.prerequisiteQuests?.Length ?? 0;
|
||||
int legacyFlagCount = quest.prerequisiteFlags?.Length ?? 0;
|
||||
|
||||
// 迁移 prerequisiteQuests → prerequisites.questDependencies(合并去重)
|
||||
if (quest.prerequisiteQuests != null && quest.prerequisiteQuests.Length > 0)
|
||||
{
|
||||
var existing = quest.prerequisites.questDependencies ?? System.Array.Empty<QuestSO>();
|
||||
var merged = new System.Collections.Generic.HashSet<QuestSO>(existing);
|
||||
foreach (var q in quest.prerequisiteQuests)
|
||||
if (q != null) merged.Add(q);
|
||||
|
||||
quest.prerequisites.questDependencies = new QuestSO[merged.Count];
|
||||
merged.CopyTo(quest.prerequisites.questDependencies);
|
||||
quest.prerequisiteQuests = System.Array.Empty<QuestSO>();
|
||||
}
|
||||
|
||||
// 迁移 prerequisiteFlags → prerequisites.flagCondition(合并去重)
|
||||
if (quest.prerequisiteFlags != null && quest.prerequisiteFlags.Length > 0)
|
||||
{
|
||||
var existing = quest.prerequisites.flagCondition.flags ?? System.Array.Empty<string>();
|
||||
var merged = new System.Collections.Generic.HashSet<string>(
|
||||
existing, System.StringComparer.Ordinal);
|
||||
foreach (var f in quest.prerequisiteFlags)
|
||||
if (!string.IsNullOrEmpty(f)) merged.Add(f);
|
||||
|
||||
quest.prerequisites.flagCondition.flags = new string[merged.Count];
|
||||
merged.CopyTo(quest.prerequisites.flagCondition.flags);
|
||||
// 迁移逻辑模式(旧字段覆盖新字段,以旧配置为准)
|
||||
quest.prerequisites.flagCondition.logic = quest.prerequisiteFlagsLogic;
|
||||
|
||||
quest.prerequisiteFlags = System.Array.Empty<string>();
|
||||
quest.prerequisiteFlagsLogic = WorldStateFlagLogic.And;
|
||||
}
|
||||
#pragma warning restore CS0618
|
||||
|
||||
EditorUtility.SetDirty(quest);
|
||||
AssetDatabase.SaveAssets();
|
||||
|
||||
Debug.Log($"[QuestSOEditor] '{quest.name}' 旧版前置字段迁移完成(任务:{legacyQuestCount} 项,标志:{legacyFlagCount} 项)。", quest);
|
||||
EditorUtility.DisplayDialog(
|
||||
"迁移完成",
|
||||
$"任务 \"{quest.name}\" 旧版前置字段已成功迁移:\n\n" +
|
||||
$" 前置任务:{legacyQuestCount} 项 → prerequisites.questDependencies\n" +
|
||||
$" 前置标志:{legacyFlagCount} 项 → prerequisites.flagCondition.flags\n\n" +
|
||||
"旧版字段已清空。操作可通过 Ctrl+Z 撤销。",
|
||||
"确定");
|
||||
}
|
||||
}
|
||||
}
|
||||
161
Assets/_Game/Scripts/Editor/Shared/AssetCreationWizard.cs
Normal file
161
Assets/_Game/Scripts/Editor/Shared/AssetCreationWizard.cs
Normal file
@@ -0,0 +1,161 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.RegularExpressions;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
|
||||
namespace BaseGames.Editor.Shared
|
||||
{
|
||||
/// <summary>
|
||||
/// 资产快速创建向导 —— 弹出式 EditorWindow,引导输入 ID 并预览文件名,
|
||||
/// 一键创建 ScriptableObject 到指定文件夹。
|
||||
/// 用法: AssetCreationWizard.Show<QuestSO>(folder, prefix, (asset, id) => { asset.questId = id; });
|
||||
/// </summary>
|
||||
public class AssetCreationWizard : EditorWindow
|
||||
{
|
||||
private string _folder;
|
||||
private string _prefix;
|
||||
private string _idInput = "";
|
||||
private string _typeName;
|
||||
private Type _assetType;
|
||||
private Action<ScriptableObject, string> _onCreated;
|
||||
|
||||
private TextField _idField;
|
||||
private Label _previewLabel;
|
||||
|
||||
// ── 公开入口 ─────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 打开向导。资产创建完成后以 (asset, id) 形式回调,调用方可在回调中自行设置 ID 字段。
|
||||
/// </summary>
|
||||
public static void Show<T>(string folder, string prefix, Action<T, string> onCreated)
|
||||
where T : ScriptableObject
|
||||
{
|
||||
string displayName = typeof(T).Name.EndsWith("SO")
|
||||
? typeof(T).Name[..^2]
|
||||
: typeof(T).Name;
|
||||
|
||||
var win = CreateInstance<AssetCreationWizard>();
|
||||
win.titleContent = new GUIContent($"新建 {displayName}");
|
||||
win._folder = folder;
|
||||
win._prefix = prefix;
|
||||
win._assetType = typeof(T);
|
||||
win._typeName = displayName;
|
||||
win._onCreated = (so, id) => onCreated((T)so, id);
|
||||
win.minSize = new Vector2(320, 132);
|
||||
win.maxSize = new Vector2(480, 132);
|
||||
win.ShowUtility();
|
||||
}
|
||||
|
||||
// ── UI 构建 ──────────────────────────────────────────────────────────
|
||||
|
||||
private void CreateGUI()
|
||||
{
|
||||
var root = rootVisualElement;
|
||||
root.style.paddingTop = 12;
|
||||
root.style.paddingBottom = 12;
|
||||
root.style.paddingLeft = 16;
|
||||
root.style.paddingRight = 16;
|
||||
|
||||
// 说明行
|
||||
var desc = new Label($"将在 {_folder} 中新建一个 {_typeName}");
|
||||
desc.style.fontSize = 10;
|
||||
desc.style.opacity = 0.55f;
|
||||
desc.style.marginBottom = 10;
|
||||
root.Add(desc);
|
||||
|
||||
// ID 输入行
|
||||
var row = new VisualElement();
|
||||
row.style.flexDirection = FlexDirection.Row;
|
||||
row.style.alignItems = Align.Center;
|
||||
row.style.marginBottom = 6;
|
||||
|
||||
var idLabel = new Label("ID:");
|
||||
idLabel.style.width = 36;
|
||||
idLabel.style.unityTextAlign = TextAnchor.MiddleLeft;
|
||||
idLabel.style.flexShrink = 0;
|
||||
row.Add(idLabel);
|
||||
|
||||
_idField = new TextField { value = "" };
|
||||
_idField.style.flexGrow = 1;
|
||||
_idField.RegisterValueChangedCallback(evt =>
|
||||
{
|
||||
_idInput = evt.newValue;
|
||||
RefreshPreview();
|
||||
});
|
||||
row.Add(_idField);
|
||||
root.Add(row);
|
||||
|
||||
// 文件名预览
|
||||
_previewLabel = new Label("文件名:(请输入 ID)");
|
||||
_previewLabel.style.fontSize = 10;
|
||||
_previewLabel.style.opacity = 0.5f;
|
||||
_previewLabel.style.marginBottom = 12;
|
||||
root.Add(_previewLabel);
|
||||
|
||||
// 按钮行
|
||||
var btnRow = new VisualElement();
|
||||
btnRow.style.flexDirection = FlexDirection.Row;
|
||||
btnRow.style.justifyContent = Justify.FlexEnd;
|
||||
|
||||
var cancelBtn = new Button(Close) { text = "取消" };
|
||||
cancelBtn.style.width = 56;
|
||||
cancelBtn.style.marginRight = 6;
|
||||
btnRow.Add(cancelBtn);
|
||||
|
||||
var createBtn = new Button(DoCreate) { text = "创建" };
|
||||
createBtn.style.width = 56;
|
||||
btnRow.Add(createBtn);
|
||||
|
||||
root.Add(btnRow);
|
||||
|
||||
// 自动聚焦 ID 输入框
|
||||
_idField.schedule.Execute(() => _idField.Focus()).StartingIn(50);
|
||||
}
|
||||
|
||||
// ── 私有逻辑 ─────────────────────────────────────────────────────────
|
||||
|
||||
private void RefreshPreview()
|
||||
{
|
||||
if (_previewLabel == null) return;
|
||||
_previewLabel.text = string.IsNullOrWhiteSpace(_idInput)
|
||||
? "文件名:(请输入 ID)"
|
||||
: $"文件名:{_prefix}{_idInput}.asset";
|
||||
}
|
||||
|
||||
private void DoCreate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_idInput))
|
||||
{
|
||||
EditorUtility.DisplayDialog("ID 不能为空", "请输入有效的 ID 后再创建。", "确定");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Regex.IsMatch(_idInput, @"^[\w\-]+$"))
|
||||
{
|
||||
EditorUtility.DisplayDialog("ID 格式有误", "ID 只能包含字母、数字、下划线或连字符。", "确定");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Directory.Exists(_folder))
|
||||
Directory.CreateDirectory(_folder);
|
||||
|
||||
string path = $"{_folder}/{_prefix}{_idInput}.asset";
|
||||
if (File.Exists(path))
|
||||
{
|
||||
EditorUtility.DisplayDialog("文件已存在", $"路径已存在:\n{path}\n请更换 ID。", "确定");
|
||||
return;
|
||||
}
|
||||
|
||||
var asset = CreateInstance(_assetType) as ScriptableObject;
|
||||
AssetDatabase.CreateAsset(asset, path);
|
||||
Undo.RegisterCreatedObjectUndo(asset, $"创建 {_typeName} {_idInput}");
|
||||
AssetDatabase.SaveAssets();
|
||||
AssetDatabase.Refresh();
|
||||
|
||||
_onCreated?.Invoke(asset, _idInput);
|
||||
Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,29 @@ namespace BaseGames.Editor
|
||||
// ── 事件(使用字段委托,允许外部直接赋值替换,避免累积)─────────────
|
||||
public Action<T> SelectionChanged;
|
||||
|
||||
/// <summary>
|
||||
/// 附加过滤条件(可选)。返回 true = 保留;返回 false = 过滤掉。
|
||||
/// 赋值后调用 <see cref="ApplyFilter()"/> 使其生效;置 null 时仅按文本搜索过滤。
|
||||
/// </summary>
|
||||
public Func<T, bool> ExtraFilter
|
||||
{
|
||||
get => _extraFilter;
|
||||
set { _extraFilter = value; ApplyFilter(); }
|
||||
}
|
||||
private Func<T, bool> _extraFilter;
|
||||
|
||||
/// <summary>
|
||||
/// 扩展搜索文本提供器(可选)。返回除资产名以外也纳入搜索的附加文本(如 ID、本地化 Key 等)。
|
||||
/// 搜索时将对 "资产名 + 返回值" 拼接文本做不区分大小写的包含匹配。
|
||||
/// 赋值后立即重新应用过滤。
|
||||
/// </summary>
|
||||
public Func<T, string> GetExtraSearchText
|
||||
{
|
||||
get => _getExtraSearchText;
|
||||
set { _getExtraSearchText = value; ApplyFilter(); }
|
||||
}
|
||||
private Func<T, string> _getExtraSearchText;
|
||||
|
||||
// ── 字段 ─────────────────────────────────────────────────────────────
|
||||
private readonly string _defaultFolder;
|
||||
private readonly string _defaultPrefix;
|
||||
@@ -205,9 +228,25 @@ namespace BaseGames.Editor
|
||||
_filtered.Clear();
|
||||
foreach (var item in _all)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_search) ||
|
||||
item.name.IndexOf(_search, StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
_filtered.Add(item);
|
||||
bool nameMatch;
|
||||
if (string.IsNullOrEmpty(_search))
|
||||
{
|
||||
nameMatch = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
nameMatch = item.name.IndexOf(_search, StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
if (!nameMatch && _getExtraSearchText != null)
|
||||
{
|
||||
var extra = _getExtraSearchText(item);
|
||||
if (!string.IsNullOrEmpty(extra))
|
||||
nameMatch = extra.IndexOf(_search, StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
}
|
||||
}
|
||||
if (!nameMatch) continue;
|
||||
if (_extraFilter != null && !_extraFilter(item)) continue;
|
||||
_filtered.Add(item);
|
||||
}
|
||||
}
|
||||
|
||||
_listView.RefreshItems();
|
||||
|
||||
@@ -18,6 +18,11 @@ namespace BaseGames.EventChain
|
||||
[Header("所有事件链")]
|
||||
[SerializeField] private EventChainSO[] _chains;
|
||||
|
||||
[Header("执行保护")]
|
||||
[Tooltip("单个 Action 的最长执行时长(秒,unscaled time)。超时后强制跳过并记录警告。0 = 不限时。")]
|
||||
[Min(0f)]
|
||||
[SerializeField] private float _maxActionTimeout = 30f;
|
||||
|
||||
[Header("事件频道(中继)")]
|
||||
[SerializeField] private StringEventChannelSO _onBossDefeated; // EVT_EnemyDied(bossId)
|
||||
[SerializeField] private StringEventChannelSO _onCollectiblePickedUp; // EVT_CollectiblePickup
|
||||
@@ -33,6 +38,39 @@ namespace BaseGames.EventChain
|
||||
public event Action<string> OnDialogueCompleted;
|
||||
/// <summary>链执行完成时广播 chainId(供 ChainCompletedCondition)。</summary>
|
||||
public event Action<string> OnChainCompleted;
|
||||
/// <summary>
|
||||
/// 世界标志变更事件(供 FlagSetCondition 事件驱动订阅,避免每帧轮询)。
|
||||
/// 由 SetFlagAction.ExecuteAsync 在写入标志后调用 NotifyFlagChanged 触发。
|
||||
/// </summary>
|
||||
public event Action<string> OnWorldFlagChanged;
|
||||
|
||||
/// <summary>通知所有 FlagSetCondition 指定标志已变更,并立即重评估标志相关链条件。</summary>
|
||||
public void NotifyFlagChanged(string flagId)
|
||||
{
|
||||
OnWorldFlagChanged?.Invoke(flagId);
|
||||
EvaluateForMask(ChainEventMask.FlagChanged);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 强制执行指定链,完全跳过条件检查。
|
||||
/// 用于调试、关卡测试,或 QA 快速验证后续事件逻辑。
|
||||
/// 仅在 Play Mode 中有效(链执行需要运行时环境)。
|
||||
/// </summary>
|
||||
public void ForceExecute(string chainId)
|
||||
{
|
||||
if (_chains == null)
|
||||
{
|
||||
Debug.LogWarning("[EventChainManager] ForceExecute: _chains 为空,没有可执行的事件链。", this);
|
||||
return;
|
||||
}
|
||||
foreach (var chain in _chains)
|
||||
{
|
||||
if (chain == null || chain.chainId != chainId) continue;
|
||||
StartCoroutine(ExecuteChain(chain));
|
||||
return;
|
||||
}
|
||||
Debug.LogWarning($"[EventChainManager] ForceExecute: 未找到 chainId='{chainId}' 的事件链。", this);
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
/// <summary>
|
||||
@@ -46,6 +84,15 @@ namespace BaseGames.EventChain
|
||||
private readonly HashSet<string> _completedChains = new();
|
||||
private readonly CompositeDisposable _subs = new();
|
||||
|
||||
// 每个链对应的事件掩码:OnEnable 后通过 BuildChainMasks 构建
|
||||
private List<ChainMaskEntry> _chainMasks;
|
||||
|
||||
private struct ChainMaskEntry
|
||||
{
|
||||
public EventChainSO chain;
|
||||
public ChainEventMask mask;
|
||||
}
|
||||
|
||||
// ── 生命周期 ──────────────────────────────────────────────────────
|
||||
|
||||
private void Awake()
|
||||
@@ -56,11 +103,11 @@ namespace BaseGames.EventChain
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
_onBossDefeated?.Subscribe(id => { OnBossDefeated?.Invoke(id); EvaluateAll(); }).AddTo(_subs);
|
||||
_onCollectiblePickedUp?.Subscribe(id => { OnCollectiblePickedUp?.Invoke(id); EvaluateAll(); }).AddTo(_subs);
|
||||
_onAbilityUnlocked?.Subscribe(id => { OnAbilityUnlocked?.Invoke(id); EvaluateAll(); }).AddTo(_subs);
|
||||
_onRoomEntered?.Subscribe(id => { OnRoomEntered?.Invoke(id); EvaluateAll(); }).AddTo(_subs);
|
||||
_onDialogueCompleted?.Subscribe(id => { OnDialogueCompleted?.Invoke(id); EvaluateAll(); }).AddTo(_subs);
|
||||
_onBossDefeated?.Subscribe(id => { OnBossDefeated?.Invoke(id); EvaluateForMask(ChainEventMask.Boss); }).AddTo(_subs);
|
||||
_onCollectiblePickedUp?.Subscribe(id => { OnCollectiblePickedUp?.Invoke(id); EvaluateForMask(ChainEventMask.Collectible); }).AddTo(_subs);
|
||||
_onAbilityUnlocked?.Subscribe(id => { OnAbilityUnlocked?.Invoke(id); EvaluateForMask(ChainEventMask.Ability); }).AddTo(_subs);
|
||||
_onRoomEntered?.Subscribe(id => { OnRoomEntered?.Invoke(id); EvaluateForMask(ChainEventMask.Room); }).AddTo(_subs);
|
||||
_onDialogueCompleted?.Subscribe(id => { OnDialogueCompleted?.Invoke(id); EvaluateForMask(ChainEventMask.Dialogue); }).AddTo(_subs);
|
||||
|
||||
ServiceLocator.GetOrDefault<ISaveableRegistry>()?.Register(this);
|
||||
|
||||
@@ -68,12 +115,26 @@ namespace BaseGames.EventChain
|
||||
// 先重置 SO 资产上的运行时状态,防止跨 PlayMode 会话或多场景加载时状态残留
|
||||
if (_chains == null) return;
|
||||
foreach (var chain in _chains)
|
||||
{
|
||||
// 旧版 conditions[] 字段
|
||||
if (chain?.conditions != null)
|
||||
foreach (var cond in chain.conditions)
|
||||
{
|
||||
cond?.ResetState();
|
||||
cond?.Register(this);
|
||||
}
|
||||
// 新版 conditionGroups[] 字段
|
||||
if (chain?.conditionGroups != null)
|
||||
foreach (var group in chain.conditionGroups)
|
||||
if (group?.conditions != null)
|
||||
foreach (var cond in group.conditions)
|
||||
{
|
||||
cond?.ResetState();
|
||||
cond?.Register(this);
|
||||
}
|
||||
}
|
||||
|
||||
BuildChainMasks();
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
@@ -83,9 +144,16 @@ namespace BaseGames.EventChain
|
||||
|
||||
if (_chains == null) return;
|
||||
foreach (var chain in _chains)
|
||||
{
|
||||
if (chain?.conditions != null)
|
||||
foreach (var cond in chain.conditions)
|
||||
cond?.Unregister(this);
|
||||
if (chain?.conditionGroups != null)
|
||||
foreach (var group in chain.conditionGroups)
|
||||
if (group?.conditions != null)
|
||||
foreach (var cond in group.conditions)
|
||||
cond?.Unregister(this);
|
||||
}
|
||||
}
|
||||
|
||||
// ── ISaveable ─────────────────────────────────────────────────────────
|
||||
@@ -108,26 +176,98 @@ namespace BaseGames.EventChain
|
||||
}
|
||||
|
||||
// ── 评估逻辑 ──────────────────────────────────────────────────────
|
||||
/// <summary>收到新事件时立即评估所有链条件(无帧延迟)。</summary>
|
||||
private void EvaluateAll() => DoEvaluateAll();
|
||||
|
||||
private void DoEvaluateAll()
|
||||
/// <summary>
|
||||
/// 构建每条链的 ChainEventMask(链内所有条件 RelevantEvents 的并集)。
|
||||
/// OnEnable 在所有条件 Register 完毕后调用,之后事件触发只评估掩码相交的链。
|
||||
/// </summary>
|
||||
private void BuildChainMasks()
|
||||
{
|
||||
_chainMasks = new List<ChainMaskEntry>(_chains?.Length ?? 0);
|
||||
if (_chains == null) return;
|
||||
foreach (var chain in _chains)
|
||||
{
|
||||
if (chain == null) continue;
|
||||
ChainEventMask mask = ChainEventMask.None;
|
||||
|
||||
bool hasGroups = chain.conditionGroups != null && chain.conditionGroups.Length > 0;
|
||||
if (hasGroups)
|
||||
{
|
||||
foreach (var group in chain.conditionGroups)
|
||||
if (group?.conditions != null)
|
||||
foreach (var cond in group.conditions)
|
||||
if (cond != null) mask |= cond.RelevantEvents;
|
||||
}
|
||||
else if (chain.conditions != null)
|
||||
{
|
||||
foreach (var cond in chain.conditions)
|
||||
if (cond != null) mask |= cond.RelevantEvents;
|
||||
}
|
||||
|
||||
// 无条件链:任何事件均可触发(等同 Any)
|
||||
if (mask == ChainEventMask.None) mask = ChainEventMask.Any;
|
||||
_chainMasks.Add(new ChainMaskEntry { chain = chain, mask = mask });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 懒评估:仅评估 mask 与 <paramref name="triggerMask"/> 相交的链。
|
||||
/// ChainEventMask.Any(-1)与任何非零 mask 均相交,确保自定义条件链不被跳过。
|
||||
/// </summary>
|
||||
private void EvaluateForMask(ChainEventMask triggerMask)
|
||||
{
|
||||
if (_chainMasks == null) return;
|
||||
foreach (var entry in _chainMasks)
|
||||
{
|
||||
if ((entry.mask & triggerMask) == 0) continue;
|
||||
var chain = entry.chain;
|
||||
if (!chain.repeatable && _completedChains.Contains(chain.chainId)) continue;
|
||||
|
||||
bool allMet = true;
|
||||
if (chain.conditions != null)
|
||||
foreach (var cond in chain.conditions)
|
||||
if (cond != null && !cond.IsMet()) { allMet = false; break; }
|
||||
|
||||
if (allMet) StartCoroutine(ExecuteChain(chain));
|
||||
if (EvaluateChainConditions(chain)) StartCoroutine(ExecuteChain(chain));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 评估链的所有触发条件。
|
||||
/// 若链配置了 <see cref="EventChainSO.conditionGroups"/>,按组逻辑评估(组内 And/Or,组间 And);
|
||||
/// 否则回退到旧版 <see cref="EventChainSO.conditions"/> 隐式 And 逻辑。
|
||||
/// </summary>
|
||||
private static bool EvaluateChainConditions(EventChainSO chain)
|
||||
{
|
||||
bool hasGroups = chain.conditionGroups != null && chain.conditionGroups.Length > 0;
|
||||
if (hasGroups)
|
||||
{
|
||||
// 组间为 And:所有组均须通过
|
||||
foreach (var group in chain.conditionGroups)
|
||||
{
|
||||
if (group?.conditions == null || group.conditions.Length == 0) continue;
|
||||
if (!EvaluateConditionGroup(group)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 旧版:conditions[] 隐式 And
|
||||
if (chain.conditions != null)
|
||||
foreach (var cond in chain.conditions)
|
||||
if (cond != null && !cond.IsMet()) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>按组内 logic(And/Or)评估单个条件组是否通过。</summary>
|
||||
private static bool EvaluateConditionGroup(ConditionGroup group)
|
||||
{
|
||||
if (group.logic == WorldStateFlagLogic.Or)
|
||||
{
|
||||
foreach (var cond in group.conditions)
|
||||
if (cond != null && cond.IsMet()) return true;
|
||||
return false;
|
||||
}
|
||||
// And(默认)
|
||||
foreach (var cond in group.conditions)
|
||||
if (cond != null && !cond.IsMet()) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private IEnumerator ExecuteChain(EventChainSO chain)
|
||||
{
|
||||
// 防重入:一次性链立即标记为已完成
|
||||
@@ -137,7 +277,62 @@ namespace BaseGames.EventChain
|
||||
foreach (var action in chain.actions)
|
||||
{
|
||||
if (action == null) continue;
|
||||
yield return action.ExecuteAsync(this);
|
||||
|
||||
bool actionFailed = false;
|
||||
if (_maxActionTimeout > 0f)
|
||||
{
|
||||
bool finished = false;
|
||||
Coroutine co = null;
|
||||
try
|
||||
{
|
||||
co = StartCoroutine(SetTrueOnFinish(action.ExecuteAsync(this), () => finished = true));
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
Debug.LogError(
|
||||
$"[EventChainManager] 链 '{chain.chainId}' 动作 '{action.GetType().Name}' 启动异常:{ex.Message}",
|
||||
this);
|
||||
actionFailed = true;
|
||||
}
|
||||
|
||||
if (!actionFailed)
|
||||
{
|
||||
// 用 realtimeSinceStartup 计时,确保 PlayMode 暂停时超时仍能触发
|
||||
float deadline = Time.realtimeSinceStartup + _maxActionTimeout;
|
||||
while (!finished)
|
||||
{
|
||||
if (Time.realtimeSinceStartup >= deadline)
|
||||
{
|
||||
if (co != null) StopCoroutine(co);
|
||||
Debug.LogWarning(
|
||||
$"[EventChainManager] 链 '{chain.chainId}' 的动作 '{action.GetType().Name}' " +
|
||||
$"执行超时({_maxActionTimeout}s),已强制跳过。");
|
||||
#if UNITY_EDITOR
|
||||
OnChainExecutedInEditor?.Invoke(chain.chainId,
|
||||
$"超时跳过:{action.GetType().Name}(>{_maxActionTimeout}s)");
|
||||
#endif
|
||||
break;
|
||||
}
|
||||
yield return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
bool exceptionThrown = false;
|
||||
IEnumerator routine = null;
|
||||
try { routine = action.ExecuteAsync(this); }
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
Debug.LogError(
|
||||
$"[EventChainManager] 链 '{chain.chainId}' 动作 '{action.GetType().Name}' 异常:{ex.Message}",
|
||||
this);
|
||||
exceptionThrown = true;
|
||||
}
|
||||
if (!exceptionThrown && routine != null)
|
||||
yield return routine;
|
||||
}
|
||||
|
||||
if (chain.actionDelay > 0f)
|
||||
yield return new WaitForSeconds(chain.actionDelay);
|
||||
}
|
||||
@@ -148,5 +343,15 @@ namespace BaseGames.EventChain
|
||||
OnChainExecutedInEditor?.Invoke(chain.chainId, "执行完成");
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将 inner 协程执行完毕后触发 onFinish 回调,供超时保护使用。
|
||||
/// StopCoroutine 此协程时,内嵌的 inner(通过 yield return 直接展开)也会一并停止。
|
||||
/// </summary>
|
||||
private static IEnumerator SetTrueOnFinish(IEnumerator inner, Action onFinish)
|
||||
{
|
||||
yield return inner;
|
||||
onFinish();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ using System;
|
||||
using System.Collections;
|
||||
using BaseGames.Core;
|
||||
using BaseGames.Core.Events;
|
||||
using BaseGames.Dialogue;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.EventChain
|
||||
@@ -26,8 +25,14 @@ namespace BaseGames.EventChain
|
||||
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;
|
||||
}
|
||||
@@ -46,12 +51,40 @@ namespace BaseGames.EventChain
|
||||
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,
|
||||
}
|
||||
|
||||
// ─── 内置条件 ──────────────────────────────────────────────────────────
|
||||
@@ -65,6 +98,7 @@ namespace BaseGames.EventChain
|
||||
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; }
|
||||
}
|
||||
|
||||
@@ -72,9 +106,47 @@ namespace BaseGames.EventChain
|
||||
public class FlagSetCondition : ChainCondition
|
||||
{
|
||||
public string flagId;
|
||||
public override void Register(EventChainManager m) { } // 持续轮询,无需订阅
|
||||
public override void Unregister(EventChainManager m) { }
|
||||
public override bool IsMet() { var sm = ServiceLocator.GetOrDefault<ISaveService>(); return sm != null && sm.GetFlag(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")]
|
||||
@@ -86,6 +158,7 @@ namespace BaseGames.EventChain
|
||||
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; }
|
||||
}
|
||||
|
||||
@@ -98,6 +171,7 @@ namespace BaseGames.EventChain
|
||||
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; }
|
||||
}
|
||||
|
||||
@@ -110,6 +184,7 @@ namespace BaseGames.EventChain
|
||||
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; }
|
||||
}
|
||||
|
||||
@@ -122,6 +197,7 @@ namespace BaseGames.EventChain
|
||||
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; }
|
||||
}
|
||||
|
||||
@@ -134,9 +210,29 @@ namespace BaseGames.EventChain
|
||||
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 抽象基类 + 内置实现
|
||||
// =====================================================================
|
||||
@@ -164,13 +260,22 @@ namespace BaseGames.EventChain
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>设置/清除存档标志。</summary>
|
||||
/// <summary>设置/清除存档标志。设置后通知 EventChainManager 触发条件重评估。</summary>
|
||||
[CreateAssetMenu(menuName = "BaseGames/EventChain/Action/SetFlag")]
|
||||
public class SetFlagAction : ChainAction
|
||||
{
|
||||
[WorldStateFlag]
|
||||
[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>();
|
||||
@@ -180,6 +285,11 @@ namespace BaseGames.EventChain
|
||||
yield break;
|
||||
}
|
||||
saveService.SetFlag(flagId, value);
|
||||
|
||||
// 通知 EventChainManager 标志已变更,触发 FlagSetCondition 重新评估(事件驱动,无需轮询)
|
||||
if (runner is EventChainManager ecm)
|
||||
ecm.NotifyFlagChanged(flagId);
|
||||
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
@@ -204,28 +314,67 @@ namespace BaseGames.EventChain
|
||||
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);
|
||||
_onPlayCutscene?.Raise(cutsceneId);
|
||||
yield return new WaitUntil(() => done);
|
||||
sub.Dispose();
|
||||
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>
|
||||
/// <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;
|
||||
[SerializeField] private StringEventChannelSO _onNPCDialogueChange; // EVT_NPCDialogueChange → NPC 订阅
|
||||
/// <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($"{npcId}:{newSequenceId}");
|
||||
_onNPCDialogueChange?.Raise(new BaseGames.Core.Events.NpcDialogueChangeEvent
|
||||
{
|
||||
npcId = npcId,
|
||||
newSequenceId = newSequenceId
|
||||
});
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
76
Assets/_Game/Scripts/World/TriggerZone.cs
Normal file
76
Assets/_Game/Scripts/World/TriggerZone.cs
Normal file
@@ -0,0 +1,76 @@
|
||||
using BaseGames.Core.Events;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.World
|
||||
{
|
||||
/// <summary>
|
||||
/// 区域到达触发器(架构 22_QuestChallengeModule §3 扩展)。
|
||||
/// 挂在场景中的 2D 触发碰撞体上,玩家进入时广播 EVT_AreaReached 事件,
|
||||
/// 驱动 ReachAreaObjective(markerTag 模式)的任务目标进度。
|
||||
///
|
||||
/// 使用方式:
|
||||
/// 1. 在目标区域创建空 GameObject,添加 Collider2D(勾选 IsTrigger)。
|
||||
/// 2. 挂上 TriggerZone,填写 markerTag(与 ReachAreaObjective.markerTag 保持一致)。
|
||||
/// 3. 将 EVT_AreaReached 事件频道资产拖入 _onAreaReached 字段。
|
||||
/// 4. 将 QuestManager 同一 _onAreaReached 频道字段也引用同一资产即可联通。
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(Collider2D))]
|
||||
public class TriggerZone : MonoBehaviour
|
||||
{
|
||||
[Header("区域标识")]
|
||||
[Tooltip("区域唯一标记(如 \"ZONE_ForestEntry\")。需与 ReachAreaObjective.markerTag 保持完全一致。")]
|
||||
[SerializeField] private string _markerTag;
|
||||
|
||||
[Tooltip("触发后是否只生效一次(推荐勾选,防止重复广播)。")]
|
||||
[SerializeField] private bool _triggerOnce = true;
|
||||
|
||||
[Header("玩家层级")]
|
||||
[Tooltip("玩家所在的物理层,用于过滤非玩家碰撞体进入。只有属于此层的碰撞体才触发广播。")]
|
||||
[SerializeField] private LayerMask _playerLayer;
|
||||
|
||||
[Header("事件频道")]
|
||||
[Tooltip("EVT_AreaReached 事件频道(StringEventChannelSO)。触发时以 markerTag 为 payload 广播。\n" +
|
||||
"需与 QuestManager._onAreaReached 字段引用同一资产。")]
|
||||
[SerializeField] private StringEventChannelSO _onAreaReached;
|
||||
|
||||
private bool _triggered;
|
||||
|
||||
/// <summary>区域唯一标记(只读)。供编辑器工具(QuestModule 批量验证)交叉比对使用。</summary>
|
||||
public string MarkerTag => _markerTag;
|
||||
|
||||
private void OnTriggerEnter2D(Collider2D other)
|
||||
{
|
||||
if (_triggered) return;
|
||||
if (string.IsNullOrEmpty(_markerTag)) return;
|
||||
if (((1 << other.gameObject.layer) & _playerLayer.value) == 0) return;
|
||||
|
||||
_onAreaReached?.Raise(_markerTag);
|
||||
|
||||
if (_triggerOnce) _triggered = true;
|
||||
}
|
||||
|
||||
/// <summary>重置触发状态(如读档/重新进入关卡时调用)。</summary>
|
||||
public void ResetTrigger() => _triggered = false;
|
||||
|
||||
#if UNITY_EDITOR
|
||||
private void OnDrawGizmos()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_markerTag)) return;
|
||||
Gizmos.color = new Color(0.2f, 0.8f, 0.4f, 0.25f);
|
||||
var col = GetComponent<Collider2D>();
|
||||
if (col != null) Gizmos.DrawWireSphere(col.bounds.center, 0.3f);
|
||||
}
|
||||
|
||||
private void OnDrawGizmosSelected()
|
||||
{
|
||||
var col = GetComponent<Collider2D>();
|
||||
if (col == null) return;
|
||||
Gizmos.color = new Color(0.2f, 0.9f, 0.4f, 0.5f);
|
||||
Gizmos.DrawWireCube(col.bounds.center, col.bounds.size);
|
||||
UnityEditor.Handles.Label(
|
||||
col.bounds.center + Vector3.up * (col.bounds.extents.y + 0.2f),
|
||||
string.IsNullOrEmpty(_markerTag) ? "(未设置 markerTag)" : _markerTag);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/World/TriggerZone.cs.meta
Normal file
11
Assets/_Game/Scripts/World/TriggerZone.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a77b179b1f28b6048bdf8aa9a92a1ea6
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using BaseGames.Core;
|
||||
using BaseGames.Core.Save;
|
||||
using UnityEngine;
|
||||
|
||||
@@ -22,16 +23,23 @@ namespace BaseGames.World
|
||||
/// LoadAsync → WorldStateRegistrySaver.OnLoad → 调用 LoadFromSave(data) 恢复缓存。
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "BaseGames/World/WorldStateRegistry")]
|
||||
public class WorldStateRegistry : ScriptableObject
|
||||
public class WorldStateRegistry : ScriptableObject, IWorldStateReader
|
||||
{
|
||||
// ── 统一状态字典 ─────────────────────────────────────────────────────
|
||||
private readonly Dictionary<WorldObjectCategory, HashSet<string>> _states = new();
|
||||
|
||||
/// <summary>
|
||||
/// 状态变更时广播:(类别, id)。UI / 测试代码可订阅此事件做响应式刷新。
|
||||
/// 若需批量写入多个 ID,推荐使用 <see cref="BatchMark"/> 避免同帧多次重绘。
|
||||
/// </summary>
|
||||
public event Action<WorldObjectCategory, string> OnStateChanged;
|
||||
|
||||
/// <summary>
|
||||
/// 批次状态变更时广播:(类别, 新增标记的 ID 数组)。
|
||||
/// 由 <see cref="BatchMark"/> 触发,一次性广播所有新增 ID,避免 UI 同帧重绘 N 次。
|
||||
/// </summary>
|
||||
public event Action<WorldObjectCategory, string[]> OnBatchStateChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Editor 重新进入 Play Mode 时 ScriptableObject 保留上一次运行的状态,
|
||||
/// OnEnable 在域重载(Domain Reload)和每次 Play 开始时都会调用,确保状态干净。
|
||||
@@ -70,10 +78,39 @@ namespace BaseGames.World
|
||||
|
||||
public bool HasFlag(string key) => IsMarked(WorldObjectCategory.Flag, key);
|
||||
public void SetFlag(string key) => Mark(WorldObjectCategory.Flag, key);
|
||||
public void ClearFlag(string key)
|
||||
public void ClearFlag(string key) => Clear(WorldObjectCategory.Flag, key);
|
||||
|
||||
/// <summary>
|
||||
/// 通用清除接口:移除指定类别中 id 的标记状态(幂等)。
|
||||
/// 用于调试重置、测试、或撤销错误标记。
|
||||
/// </summary>
|
||||
public void Clear(WorldObjectCategory category, string id)
|
||||
{
|
||||
if (_states.TryGetValue(WorldObjectCategory.Flag, out var set) && set.Remove(key))
|
||||
OnStateChanged?.Invoke(WorldObjectCategory.Flag, key);
|
||||
if (_states.TryGetValue(category, out var set) && set.Remove(id))
|
||||
OnStateChanged?.Invoke(category, id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 一次性标记多个 ID(批次写入)。已标记的 ID 被幂等跳过;
|
||||
/// 全部写入后触发单次 <see cref="OnBatchStateChanged"/>,而非逐个触发 <see cref="OnStateChanged"/>,
|
||||
/// 适合 EventChain 同帧连续设置多个标志时使用以避免 UI 同帧重绘 N 次。
|
||||
/// </summary>
|
||||
/// <returns>实际新增标记的 ID 数量。</returns>
|
||||
public int BatchMark(WorldObjectCategory category, System.Collections.Generic.IEnumerable<string> ids)
|
||||
{
|
||||
if (ids == null) return 0;
|
||||
if (!_states.TryGetValue(category, out var set))
|
||||
{
|
||||
set = new HashSet<string>();
|
||||
_states[category] = set;
|
||||
}
|
||||
var added = new System.Collections.Generic.List<string>();
|
||||
foreach (var id in ids)
|
||||
if (!string.IsNullOrEmpty(id) && set.Add(id))
|
||||
added.Add(id);
|
||||
if (added.Count > 0)
|
||||
OnBatchStateChanged?.Invoke(category, added.ToArray());
|
||||
return added.Count;
|
||||
}
|
||||
|
||||
// ── Persistence ───────────────────────────────────────────────────────
|
||||
@@ -98,14 +135,18 @@ namespace BaseGames.World
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 返回指定分类中所有已标记的 ID(只读视图,非副本)。
|
||||
/// 返回指定分类中所有已标记 ID 的快照副本(数组)。
|
||||
/// 由 WorldStateRegistrySaver.OnSave 调用,将运行时状态写入 SaveData。
|
||||
/// 注意:不保证跨帧稳定性,调用方若需持久快照请自行 ToArray()。
|
||||
/// 返回数组副本而非内部集合引用,防止调用方意外修改内部状态。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<string> GetAllIds(WorldObjectCategory category)
|
||||
{
|
||||
if (_states.TryGetValue(category, out var set))
|
||||
return set;
|
||||
if (_states.TryGetValue(category, out var set) && set.Count > 0)
|
||||
{
|
||||
var copy = new string[set.Count];
|
||||
set.CopyTo(copy);
|
||||
return copy;
|
||||
}
|
||||
return System.Array.Empty<string>();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user