feat: Add WorldStateFlagAttribute and custom property drawer for enhanced dialogue management
- Implemented WorldStateFlagAttribute to mark string fields as world state flags. - Created NarrativeNPCEditor for custom inspector to visualize dialogue version activation states. - Developed WorldStateFlagDrawer to provide dropdown menu for known flags in the inspector. - Introduced ActorModule for managing DialogueActorSO assets, including viewing, creating, and deleting actors. - Added DialogueModule for managing DialogueSequenceSO assets with detailed previews and action bars. - Established QuestModule for managing QuestSO assets, including objectives and branches. - Implemented QuestManagerPostprocessor to automatically refresh QuestManager's quest list on asset changes.
This commit is contained in:
81
Assets/_Game/Scripts/Dialogue/DialogueActorSO.cs
Normal file
81
Assets/_Game/Scripts/Dialogue/DialogueActorSO.cs
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace BaseGames.Dialogue
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 对话角色定义 SO(架构 14_NarrativeModule §3)。
|
||||||
|
/// 将 NPC 的显示名、头像、对话气泡颜色集中在一处管理。
|
||||||
|
/// DialogueLine.actor 引用此 SO,修改头像/名称只需改一个资产,
|
||||||
|
/// 无需批量编辑所有对话行。
|
||||||
|
///
|
||||||
|
/// 资产路径:Assets/_Game/Data/Dialogue/Actors/Actor_{actorId}.asset
|
||||||
|
/// </summary>
|
||||||
|
[CreateAssetMenu(menuName = "BaseGames/Dialogue/DialogueActor")]
|
||||||
|
public class DialogueActorSO : ScriptableObject
|
||||||
|
{
|
||||||
|
[Header("标识")]
|
||||||
|
[Tooltip("唯一 ID,如 \"NPC_Elder\",供 DialogueLine 引用")]
|
||||||
|
public string actorId;
|
||||||
|
|
||||||
|
[Header("显示")]
|
||||||
|
[Tooltip("本地化 Key,格式如 \"NPC_Elder_Name\"")]
|
||||||
|
public string nameKey;
|
||||||
|
|
||||||
|
[Tooltip("对话 UI 中显示的头像")]
|
||||||
|
public Sprite portrait;
|
||||||
|
|
||||||
|
[Tooltip("对话气泡/说话人名称的主题颜色(可选)")]
|
||||||
|
public Color accentColor = Color.white;
|
||||||
|
|
||||||
|
[Tooltip("是否为玩家角色(影响对话 UI 排版方向)")]
|
||||||
|
public bool isPlayer;
|
||||||
|
|
||||||
|
#if UNITY_EDITOR
|
||||||
|
// actorId → 资产路径,5 秒 TTL,跨所有 DialogueActorSO.OnValidate 共用。
|
||||||
|
// 与 QuestSO / DialogueSequenceSO 保持一致的 O(1) 重复检测策略。
|
||||||
|
private static System.Collections.Generic.Dictionary<string, string> s_actorIdToPath;
|
||||||
|
private static double s_actorIdsCacheTime = -10.0;
|
||||||
|
|
||||||
|
private static System.Collections.Generic.Dictionary<string, string> GetActorIdCache()
|
||||||
|
{
|
||||||
|
double now = UnityEditor.EditorApplication.timeSinceStartup;
|
||||||
|
if (s_actorIdToPath != null && now - s_actorIdsCacheTime < 5.0)
|
||||||
|
return s_actorIdToPath;
|
||||||
|
|
||||||
|
s_actorIdToPath = new System.Collections.Generic.Dictionary<string, string>(System.StringComparer.Ordinal);
|
||||||
|
string[] guids = UnityEditor.AssetDatabase.FindAssets("t:DialogueActorSO");
|
||||||
|
foreach (var guid in guids)
|
||||||
|
{
|
||||||
|
var path = UnityEditor.AssetDatabase.GUIDToAssetPath(guid);
|
||||||
|
var actor = UnityEditor.AssetDatabase.LoadAssetAtPath<DialogueActorSO>(path);
|
||||||
|
if (actor != null && !string.IsNullOrEmpty(actor.actorId) && !s_actorIdToPath.ContainsKey(actor.actorId))
|
||||||
|
s_actorIdToPath[actor.actorId] = path;
|
||||||
|
}
|
||||||
|
s_actorIdsCacheTime = now;
|
||||||
|
return s_actorIdToPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnValidate()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(actorId))
|
||||||
|
{
|
||||||
|
Debug.LogWarning($"[DialogueActorSO] '{name}' 缺少 actorId,保存前请填写。", this);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测重复 actorId:缓存路径 vs 自身路径比对(O(1)),5 秒内无需重扫。
|
||||||
|
var cache = GetActorIdCache();
|
||||||
|
string myPath = UnityEditor.AssetDatabase.GetAssetPath(this);
|
||||||
|
if (!string.IsNullOrEmpty(myPath) &&
|
||||||
|
cache.TryGetValue(actorId, out var existingPath) &&
|
||||||
|
existingPath != myPath)
|
||||||
|
{
|
||||||
|
Debug.LogError(
|
||||||
|
$"[DialogueActorSO] actorId '{actorId}' 与 " +
|
||||||
|
$"'{System.IO.Path.GetFileNameWithoutExtension(existingPath)}' 重复!请修改其中一个。", this);
|
||||||
|
s_actorIdsCacheTime = -10.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Collections;
|
using System.Collections;
|
||||||
|
using System.Collections.Generic;
|
||||||
using BaseGames.Core;
|
using BaseGames.Core;
|
||||||
using BaseGames.Core.Events;
|
using BaseGames.Core.Events;
|
||||||
using BaseGames.Input;
|
using BaseGames.Input;
|
||||||
@@ -22,9 +23,36 @@ namespace BaseGames.Dialogue
|
|||||||
[SerializeField] private VoidEventChannelSO _onDialogueStarted;
|
[SerializeField] private VoidEventChannelSO _onDialogueStarted;
|
||||||
[SerializeField] private VoidEventChannelSO _onDialogueEnded;
|
[SerializeField] private VoidEventChannelSO _onDialogueEnded;
|
||||||
[SerializeField] private StringEventChannelSO _onNpcDialogueCompleted; // → EVT_NpcDialogueCompleted (npcId)
|
[SerializeField] private StringEventChannelSO _onNpcDialogueCompleted; // → EVT_NpcDialogueCompleted (npcId)
|
||||||
|
[SerializeField] private VoidEventChannelSO _onLineStarted; // 每行开始打字时广播
|
||||||
|
[SerializeField] private VoidEventChannelSO _onLineEnded; // 每行玩家确认后广播
|
||||||
|
|
||||||
private bool _skipRequested;
|
private bool _skipRequested;
|
||||||
|
|
||||||
|
// ── 复用 Yield 指令,避免协程每行 new WaitUntil 闭包 ───────────────
|
||||||
|
private sealed class WaitTypingOrSkip : CustomYieldInstruction
|
||||||
|
{
|
||||||
|
private readonly DialogueManager _m;
|
||||||
|
public WaitTypingOrSkip(DialogueManager m) => _m = m;
|
||||||
|
public override bool keepWaiting => _m._dialogueBox.IsTyping && !_m._skipRequested;
|
||||||
|
}
|
||||||
|
private sealed class WaitSkip : CustomYieldInstruction
|
||||||
|
{
|
||||||
|
private readonly DialogueManager _m;
|
||||||
|
public WaitSkip(DialogueManager m) => _m = m;
|
||||||
|
public override bool keepWaiting => !_m._skipRequested;
|
||||||
|
}
|
||||||
|
|
||||||
|
private WaitTypingOrSkip _waitTypingOrSkip;
|
||||||
|
private WaitSkip _waitSkip;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 当 IsDialogueActive 时排队等待的对话请求。
|
||||||
|
/// 支持脚本触发的连续对话序列(如剧情链、事件链触发的对话),
|
||||||
|
/// 但容量上限为 8,避免因误触导致无限排队。
|
||||||
|
/// </summary>
|
||||||
|
private readonly Queue<(DialogueSequenceSO seq, string npcId)> _pending = new();
|
||||||
|
private const int PendingCapacity = 8;
|
||||||
|
|
||||||
/// <summary>当前是否有对话正在播放。</summary>
|
/// <summary>当前是否有对话正在播放。</summary>
|
||||||
public bool IsDialogueActive { get; private set; }
|
public bool IsDialogueActive { get; private set; }
|
||||||
|
|
||||||
@@ -34,6 +62,8 @@ namespace BaseGames.Dialogue
|
|||||||
{
|
{
|
||||||
if (ServiceLocator.GetOrDefault<IDialogueService>() != null) { Destroy(gameObject); return; }
|
if (ServiceLocator.GetOrDefault<IDialogueService>() != null) { Destroy(gameObject); return; }
|
||||||
ServiceLocator.Register<IDialogueService>(this);
|
ServiceLocator.Register<IDialogueService>(this);
|
||||||
|
_waitTypingOrSkip = new WaitTypingOrSkip(this);
|
||||||
|
_waitSkip = new WaitSkip(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnDestroy()
|
private void OnDestroy()
|
||||||
@@ -49,25 +79,46 @@ namespace BaseGames.Dialogue
|
|||||||
private void OnDisable()
|
private void OnDisable()
|
||||||
{
|
{
|
||||||
if (_inputReader != null) _inputReader.SubmitEvent -= OnSubmit;
|
if (_inputReader != null) _inputReader.SubmitEvent -= OnSubmit;
|
||||||
|
_pending.Clear();
|
||||||
|
|
||||||
|
// 若对话协程在组件禁用或场景切换时仍在运行,Unity 会强制杀死协程但不调用
|
||||||
|
// EndDialogue(),导致 Action Map 永久停留在 UI 模式。此处主动恢复。
|
||||||
|
if (IsDialogueActive)
|
||||||
|
{
|
||||||
|
StopAllCoroutines();
|
||||||
|
_dialogueBox?.Hide();
|
||||||
|
IsDialogueActive = false;
|
||||||
|
_inputReader?.EnableGameplayInput();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 公开 API ──────────────────────────────────────────────────────
|
// ── 公开 API ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 启动对话序列。若已有对话在播放则忽略新请求。
|
/// 启动对话序列。
|
||||||
|
/// 若已有对话在播放,请求会进入等待队列(上限 <see cref="PendingCapacity"/>),
|
||||||
|
/// 待当前对话结束后依序自动播放;超出上限的请求被丢弃。
|
||||||
/// 由 InteractableNPC.Interact() 调用。
|
/// 由 InteractableNPC.Interact() 调用。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="sequence">要播放的对话序列 SO。</param>
|
/// <param name="sequence">要播放的对话序列 SO。</param>
|
||||||
/// <param name="npcId">NPC 标识符,对话结束时随 EVT_NpcDialogueCompleted 广播。</param>
|
/// <param name="npcId">NPC 标识符,对话结束时随 EVT_NpcDialogueCompleted 广播。</param>
|
||||||
public void StartDialogue(DialogueSequenceSO sequence, string npcId = "")
|
public void StartDialogue(DialogueSequenceSO sequence, string npcId = "")
|
||||||
{
|
{
|
||||||
if (IsDialogueActive || sequence == null) return;
|
if (sequence == null) return;
|
||||||
|
if (IsDialogueActive)
|
||||||
|
{
|
||||||
|
if (_pending.Count < PendingCapacity)
|
||||||
|
_pending.Enqueue((sequence, npcId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
PlayImmediate(sequence, npcId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PlayImmediate(DialogueSequenceSO sequence, string npcId)
|
||||||
|
{
|
||||||
IsDialogueActive = true;
|
IsDialogueActive = true;
|
||||||
_skipRequested = false;
|
_skipRequested = false;
|
||||||
|
if (_inputReader != null) _inputReader.EnableUIInput();
|
||||||
// 切换到 UI Action Map(禁用玩家移动输入)
|
|
||||||
_inputReader.EnableUIInput();
|
|
||||||
|
|
||||||
_onDialogueStarted?.Raise();
|
_onDialogueStarted?.Raise();
|
||||||
StartCoroutine(PlaySequence(sequence, npcId));
|
StartCoroutine(PlaySequence(sequence, npcId));
|
||||||
}
|
}
|
||||||
@@ -80,21 +131,28 @@ namespace BaseGames.Dialogue
|
|||||||
|
|
||||||
private IEnumerator PlaySequence(DialogueSequenceSO sequence, string npcId)
|
private IEnumerator PlaySequence(DialogueSequenceSO sequence, string npcId)
|
||||||
{
|
{
|
||||||
// 选择条件变体(查询 ConditionalVariant.conditionFlag — 未实现 WorldStateRegistry 查询时直接使用默认序列)
|
|
||||||
var resolved = ResolveVariant(sequence);
|
var resolved = ResolveVariant(sequence);
|
||||||
|
|
||||||
|
if (resolved.lines == null || resolved.lines.Length == 0)
|
||||||
|
{
|
||||||
|
EndDialogue(npcId);
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var line in resolved.lines)
|
foreach (var line in resolved.lines)
|
||||||
{
|
{
|
||||||
_skipRequested = false;
|
_skipRequested = false;
|
||||||
_dialogueBox.ShowLine(line);
|
_dialogueBox.ShowLine(line);
|
||||||
|
_onLineStarted?.Raise();
|
||||||
|
|
||||||
// 等待打字完成,期间允许跳过
|
// 等待打字完成,期间允许跳过
|
||||||
yield return new WaitUntil(() => !_dialogueBox.IsTyping || _skipRequested);
|
yield return _waitTypingOrSkip;
|
||||||
if (_dialogueBox.IsTyping) _dialogueBox.SkipTyping();
|
if (_dialogueBox.IsTyping) _dialogueBox.SkipTyping();
|
||||||
|
|
||||||
// 等待玩家按 Submit 推进下一行
|
// 等待玩家按 Submit 推进下一行
|
||||||
_skipRequested = false;
|
_skipRequested = false;
|
||||||
yield return new WaitUntil(() => _skipRequested);
|
yield return _waitSkip;
|
||||||
|
_onLineEnded?.Raise();
|
||||||
}
|
}
|
||||||
|
|
||||||
EndDialogue(npcId);
|
EndDialogue(npcId);
|
||||||
@@ -105,18 +163,24 @@ namespace BaseGames.Dialogue
|
|||||||
_dialogueBox.Hide();
|
_dialogueBox.Hide();
|
||||||
IsDialogueActive = false;
|
IsDialogueActive = false;
|
||||||
|
|
||||||
// 恢复 Gameplay Action Map
|
if (_inputReader != null) _inputReader.EnableGameplayInput();
|
||||||
_inputReader.EnableGameplayInput();
|
|
||||||
|
|
||||||
_onDialogueEnded?.Raise();
|
_onDialogueEnded?.Raise();
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(npcId))
|
if (!string.IsNullOrEmpty(npcId))
|
||||||
_onNpcDialogueCompleted?.Raise(npcId);
|
_onNpcDialogueCompleted?.Raise(npcId);
|
||||||
|
|
||||||
|
// 自动播放队首等待中的对话(脚本触发的连续序列)
|
||||||
|
if (_pending.Count > 0)
|
||||||
|
{
|
||||||
|
var (seq, id) = _pending.Dequeue();
|
||||||
|
PlayImmediate(seq, id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 根据 ConditionalVariant 选择正确的序列版本。
|
/// 根据 ConditionalVariant 选择正确的序列版本。
|
||||||
/// 按顺序检查 variants:第一个满足 WorldStateRegistry 标志的变体胜出;
|
/// 按顺序检查 variants:所有 requiredFlags 均满足的第一个变体胜出(AND 关系);
|
||||||
/// 未注入 WorldStateRegistry 或无满足条件的变体时,返回原序列(默认)。
|
/// 未注入 WorldStateRegistry 或无满足条件的变体时,返回原序列(默认)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private DialogueSequenceSO ResolveVariant(DialogueSequenceSO sequence)
|
private DialogueSequenceSO ResolveVariant(DialogueSequenceSO sequence)
|
||||||
@@ -128,12 +192,31 @@ namespace BaseGames.Dialogue
|
|||||||
{
|
{
|
||||||
foreach (var variant in sequence.variants)
|
foreach (var variant in sequence.variants)
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrEmpty(variant.conditionFlag)
|
if (variant.sequence == null) continue;
|
||||||
&& variant.sequence != null
|
if (variant.requiredFlags == null || variant.requiredFlags.Length == 0)
|
||||||
&& _worldState.HasFlag(variant.conditionFlag))
|
return variant.sequence; // 无条件变体:直接采用
|
||||||
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
||||||
|
else if (sequence.variants.Length > 0)
|
||||||
|
{
|
||||||
|
Debug.LogWarning(
|
||||||
|
$"[DialogueManager] 序列 '{sequence.sequenceId}' 有 {sequence.variants.Length} 个条件变体," +
|
||||||
|
"但 WorldStateRegistry 未注入,将使用默认序列。请检查 Inspector 中的 _worldState 字段。",
|
||||||
|
this);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
return sequence;
|
return sequence;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,17 +5,38 @@ namespace BaseGames.Dialogue
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 对话行结构(架构 14_NarrativeModule §3)。
|
/// 对话行结构(架构 14_NarrativeModule §3)。
|
||||||
/// 每行包含说话人、文本(本地化 Key)和可选的语音片段。
|
/// 每行包含说话人、文本(本地化 Key)和可选的语音片段。
|
||||||
|
///
|
||||||
|
/// 推荐通过 actor 引用 DialogueActorSO 统一管理头像/名称;
|
||||||
|
/// speakerNameKey / portraitSprite 作为无 SO 时的直接覆盖(保持向后兼容)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[System.Serializable]
|
[System.Serializable]
|
||||||
public struct DialogueLine
|
public struct DialogueLine
|
||||||
{
|
{
|
||||||
public string speakerNameKey; // 本地化 key(如 "NPC_Elder_Name")
|
[Tooltip("说话人角色(推荐)。actor 优先;留空时回退到 speakerNameKey / portraitSprite")]
|
||||||
|
public DialogueActorSO actor;
|
||||||
|
|
||||||
|
[Tooltip("说话人本地化 key,留空时使用 actor.nameKey")]
|
||||||
|
public string speakerNameKey;
|
||||||
[TextArea(2, 5)]
|
[TextArea(2, 5)]
|
||||||
public string textKey; // 本地化文本 key(如 "DLG_Elder_001")
|
public string textKey; // 本地化文本 key(如 "DLG_Elder_001")
|
||||||
public Sprite portraitSprite; // 可选说话人头像
|
|
||||||
|
[Tooltip("说话人头像,留空时使用 actor.portrait")]
|
||||||
|
public Sprite portraitSprite;
|
||||||
public AudioClip voiceClip; // 可选语音
|
public AudioClip voiceClip; // 可选语音
|
||||||
[Min(0.01f)]
|
[Min(0.01f)]
|
||||||
public float typewriterDelay; // 每字符延迟(秒,0 = 使用默认 0.03f)
|
public float typewriterDelay; // 每字符延迟(秒,0 = 使用默认 0.03f)
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取最终使用的说话人名称 Key:actor 优先,回退到直接字段。
|
||||||
|
/// </summary>
|
||||||
|
public string ResolvedNameKey => actor != null && !string.IsNullOrEmpty(actor.nameKey)
|
||||||
|
? actor.nameKey : speakerNameKey;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取最终使用的头像:actor 优先,回退到直接字段。
|
||||||
|
/// </summary>
|
||||||
|
public Sprite ResolvedPortrait => actor != null && actor.portrait != null
|
||||||
|
? actor.portrait : portraitSprite;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -29,13 +50,66 @@ namespace BaseGames.Dialogue
|
|||||||
public string sequenceId; // 全局唯一,如 "DLG_Elder_Quest_Available"
|
public string sequenceId; // 全局唯一,如 "DLG_Elder_Quest_Available"
|
||||||
public DialogueLine[] lines;
|
public DialogueLine[] lines;
|
||||||
|
|
||||||
/// <summary>条件变体:满足特定世界标志时替换整个序列。</summary>
|
/// <summary>
|
||||||
|
/// 条件变体:所有 requiredFlags 均满足时替换整个序列(AND 关系)。
|
||||||
|
/// 与 NarrativeNPC.DialogueVersion 的多条件语义保持一致。
|
||||||
|
/// </summary>
|
||||||
[System.Serializable]
|
[System.Serializable]
|
||||||
public struct ConditionalVariant
|
public struct ConditionalVariant
|
||||||
{
|
{
|
||||||
public string conditionFlag; // WorldState flag key
|
[Tooltip("全部满足时激活此变体(AND 关系)。留空表示无条件。")]
|
||||||
|
[WorldStateFlag]
|
||||||
|
public string[] requiredFlags;
|
||||||
public DialogueSequenceSO sequence;
|
public DialogueSequenceSO sequence;
|
||||||
}
|
}
|
||||||
public ConditionalVariant[] variants;
|
public ConditionalVariant[] variants;
|
||||||
|
|
||||||
|
#if UNITY_EDITOR
|
||||||
|
// sequenceId → 资产路径,5 秒 TTL,跨所有 DialogueSequenceSO.OnValidate 共用,
|
||||||
|
// 避免每次 Save 都重扫所有同类 SO(O(1) 路径比对代替 O(n) 全量扫描)。
|
||||||
|
private static System.Collections.Generic.Dictionary<string, string> s_seqIdToPath;
|
||||||
|
private static double s_seqIdsCacheTime = -10.0;
|
||||||
|
|
||||||
|
private static System.Collections.Generic.Dictionary<string, string> GetSequenceIdCache()
|
||||||
|
{
|
||||||
|
double now = UnityEditor.EditorApplication.timeSinceStartup;
|
||||||
|
if (s_seqIdToPath != null && now - s_seqIdsCacheTime < 5.0)
|
||||||
|
return s_seqIdToPath;
|
||||||
|
|
||||||
|
s_seqIdToPath = new System.Collections.Generic.Dictionary<string, string>(System.StringComparer.Ordinal);
|
||||||
|
string[] guids = UnityEditor.AssetDatabase.FindAssets("t:DialogueSequenceSO");
|
||||||
|
foreach (var guid in guids)
|
||||||
|
{
|
||||||
|
var path = UnityEditor.AssetDatabase.GUIDToAssetPath(guid);
|
||||||
|
var seq = UnityEditor.AssetDatabase.LoadAssetAtPath<DialogueSequenceSO>(path);
|
||||||
|
if (seq != null && !string.IsNullOrEmpty(seq.sequenceId) && !s_seqIdToPath.ContainsKey(seq.sequenceId))
|
||||||
|
s_seqIdToPath[seq.sequenceId] = path;
|
||||||
|
}
|
||||||
|
s_seqIdsCacheTime = now;
|
||||||
|
return s_seqIdToPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnValidate()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(sequenceId))
|
||||||
|
{
|
||||||
|
sequenceId = name;
|
||||||
|
UnityEditor.EditorUtility.SetDirty(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测重复 sequenceId:缓存路径 vs 自身路径比对(O(1)),5 秒内无需重扫。
|
||||||
|
var cache = GetSequenceIdCache();
|
||||||
|
string myPath = UnityEditor.AssetDatabase.GetAssetPath(this);
|
||||||
|
if (!string.IsNullOrEmpty(myPath) &&
|
||||||
|
cache.TryGetValue(sequenceId, out var existingPath) &&
|
||||||
|
existingPath != myPath)
|
||||||
|
{
|
||||||
|
Debug.LogError(
|
||||||
|
$"[DialogueSequenceSO] sequenceId '{sequenceId}' 与 " +
|
||||||
|
$"'{System.IO.Path.GetFileNameWithoutExtension(existingPath)}' 重复!请修改其中一个。", this);
|
||||||
|
s_seqIdsCacheTime = -10.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ namespace BaseGames.Dialogue
|
|||||||
private DialogueLine _currentLine;
|
private DialogueLine _currentLine;
|
||||||
private const float DefaultTypewriterDelay = 0.03f;
|
private const float DefaultTypewriterDelay = 0.03f;
|
||||||
|
|
||||||
|
// 缓存单个 WaitForSecondsRealtime,避免 TypeLine 每字符 new 分配。
|
||||||
|
// 当 delay 值改变时才重新创建(不同行可能有不同打字速度)。
|
||||||
|
private WaitForSecondsRealtime _cachedTypeDelay;
|
||||||
|
private float _cachedTypeDelayValue = -1f;
|
||||||
|
|
||||||
/// <summary>当前是否仍在执行打字机效果。</summary>
|
/// <summary>当前是否仍在执行打字机效果。</summary>
|
||||||
public bool IsTyping { get; private set; }
|
public bool IsTyping { get; private set; }
|
||||||
|
|
||||||
@@ -35,20 +40,22 @@ namespace BaseGames.Dialogue
|
|||||||
public void ShowLine(DialogueLine line)
|
public void ShowLine(DialogueLine line)
|
||||||
{
|
{
|
||||||
_currentLine = line;
|
_currentLine = line;
|
||||||
_rootPanel.SetActive(true);
|
if (_rootPanel != null) _rootPanel.SetActive(true);
|
||||||
_continuePrompt.SetActive(false);
|
if (_continuePrompt != null) _continuePrompt.SetActive(false);
|
||||||
|
|
||||||
// 说话人名称
|
// 说话人名称(actor 优先,回退到直接字段)
|
||||||
bool hasSpeaker = !string.IsNullOrEmpty(line.speakerNameKey);
|
string resolvedNameKey = line.ResolvedNameKey;
|
||||||
|
bool hasSpeaker = !string.IsNullOrEmpty(resolvedNameKey);
|
||||||
if (_speakerNamePanel != null) _speakerNamePanel.SetActive(hasSpeaker);
|
if (_speakerNamePanel != null) _speakerNamePanel.SetActive(hasSpeaker);
|
||||||
if (hasSpeaker && _speakerNameText != null)
|
if (hasSpeaker && _speakerNameText != null)
|
||||||
_speakerNameText.text = LocalizationManager.Get(line.speakerNameKey, "Dialogue");
|
_speakerNameText.text = LocalizationManager.Get(resolvedNameKey, "Dialogue");
|
||||||
|
|
||||||
// 头像
|
// 头像(actor 优先,回退到直接字段)
|
||||||
|
var resolvedPortrait = line.ResolvedPortrait;
|
||||||
if (_speakerPortrait != null)
|
if (_speakerPortrait != null)
|
||||||
{
|
{
|
||||||
_speakerPortrait.gameObject.SetActive(line.portraitSprite != null);
|
_speakerPortrait.gameObject.SetActive(resolvedPortrait != null);
|
||||||
if (line.portraitSprite != null) _speakerPortrait.sprite = line.portraitSprite;
|
if (resolvedPortrait != null) _speakerPortrait.sprite = resolvedPortrait;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 语音播放
|
// 语音播放
|
||||||
@@ -95,6 +102,14 @@ namespace BaseGames.Dialogue
|
|||||||
{
|
{
|
||||||
IsTyping = true;
|
IsTyping = true;
|
||||||
float delay = line.typewriterDelay > 0f ? line.typewriterDelay : DefaultTypewriterDelay;
|
float delay = line.typewriterDelay > 0f ? line.typewriterDelay : DefaultTypewriterDelay;
|
||||||
|
|
||||||
|
// 复用缓存的 WaitForSecondsRealtime;仅当 delay 值变化时才重新 new
|
||||||
|
if (_cachedTypeDelay == null || !Mathf.Approximately(_cachedTypeDelayValue, delay))
|
||||||
|
{
|
||||||
|
_cachedTypeDelay = new WaitForSecondsRealtime(delay);
|
||||||
|
_cachedTypeDelayValue = delay;
|
||||||
|
}
|
||||||
|
|
||||||
string text = LocalizationManager.Get(line.textKey ?? "", "Dialogue");
|
string text = LocalizationManager.Get(line.textKey ?? "", "Dialogue");
|
||||||
|
|
||||||
// 性能:StringBuilder 避免每帧字符串分配(O(n²) → O(n))
|
// 性能:StringBuilder 避免每帧字符串分配(O(n²) → O(n))
|
||||||
@@ -105,7 +120,7 @@ namespace BaseGames.Dialogue
|
|||||||
{
|
{
|
||||||
sb.Append(c);
|
sb.Append(c);
|
||||||
if (_dialogueText != null) _dialogueText.SetText(sb); // TMP SetText(StringBuilder) 零分配
|
if (_dialogueText != null) _dialogueText.SetText(sb); // TMP SetText(StringBuilder) 零分配
|
||||||
yield return new WaitForSecondsRealtime(delay);
|
yield return _cachedTypeDelay;
|
||||||
}
|
}
|
||||||
|
|
||||||
IsTyping = false;
|
IsTyping = false;
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ namespace BaseGames.Dialogue
|
|||||||
return version.dialogue;
|
return version.dialogue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_fallbackDialogue == null)
|
||||||
|
Debug.LogWarning($"[NarrativeNPC] '{name}' 没有版本满足当前条件,且未配置兜底对话 (_fallbackDialogue)。", gameObject);
|
||||||
|
|
||||||
return _fallbackDialogue;
|
return _fallbackDialogue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -43,9 +46,11 @@ namespace BaseGames.Dialogue
|
|||||||
public DialogueSequenceSO dialogue;
|
public DialogueSequenceSO dialogue;
|
||||||
|
|
||||||
[Tooltip("全部满足才激活此版本(AND 关系)")]
|
[Tooltip("全部满足才激活此版本(AND 关系)")]
|
||||||
|
[WorldStateFlag]
|
||||||
public string[] requiredFlags;
|
public string[] requiredFlags;
|
||||||
|
|
||||||
[Tooltip("有任意一个 = 此版本不激活(NOT 关系)")]
|
[Tooltip("有任意一个 = 此版本不激活(NOT 关系)")]
|
||||||
|
[WorldStateFlag]
|
||||||
public string[] blockedByFlags;
|
public string[] blockedByFlags;
|
||||||
|
|
||||||
/// <summary>检查此版本的激活条件(AND requiredFlags / NOT blockedByFlags)。</summary>
|
/// <summary>检查此版本的激活条件(AND requiredFlags / NOT blockedByFlags)。</summary>
|
||||||
|
|||||||
10
Assets/_Game/Scripts/Dialogue/WorldStateFlagAttribute.cs
Normal file
10
Assets/_Game/Scripts/Dialogue/WorldStateFlagAttribute.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace BaseGames.Dialogue
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 标记一个 string 或 string[] 字段为世界状态标志 Key。
|
||||||
|
/// 在 Inspector 中会显示已知标志下拉菜单,支持直接输入新标志。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class WorldStateFlagAttribute : PropertyAttribute { }
|
||||||
|
}
|
||||||
209
Assets/_Game/Scripts/Editor/Dialogue/NarrativeNPCEditor.cs
Normal file
209
Assets/_Game/Scripts/Editor/Dialogue/NarrativeNPCEditor.cs
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
using UnityEditor;
|
||||||
|
using UnityEngine;
|
||||||
|
using BaseGames.Dialogue;
|
||||||
|
using BaseGames.World;
|
||||||
|
|
||||||
|
namespace BaseGames.Editor.Dialogue
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// NarrativeNPC 自定义 Inspector。
|
||||||
|
/// 在默认字段之下显示"对话版本激活状态"面板,
|
||||||
|
/// 以色彩提示每个 DialogueVersion 在当前 WorldStateRegistry 中是否激活,
|
||||||
|
/// 方便策划人员在编辑模式下即时核查对话版本切换逻辑。
|
||||||
|
/// </summary>
|
||||||
|
[CustomEditor(typeof(NarrativeNPC))]
|
||||||
|
public class NarrativeNPCEditor : UnityEditor.Editor
|
||||||
|
{
|
||||||
|
private static bool s_foldout = true;
|
||||||
|
|
||||||
|
private static readonly Color ColorActive = new(0.25f, 0.75f, 0.40f, 1f);
|
||||||
|
private static readonly Color ColorInactive = new(0.55f, 0.55f, 0.55f, 1f);
|
||||||
|
private static readonly Color ColorBlocked = new(0.85f, 0.40f, 0.35f, 1f);
|
||||||
|
|
||||||
|
public override void OnInspectorGUI()
|
||||||
|
{
|
||||||
|
DrawDefaultInspector();
|
||||||
|
|
||||||
|
EditorGUILayout.Space(6);
|
||||||
|
|
||||||
|
if (Application.isPlaying)
|
||||||
|
{
|
||||||
|
EditorGUILayout.HelpBox(
|
||||||
|
"PlayMode 中显示的是 WorldStateRegistry SO 的初始序列化值,非运行时动态 flags。\n" +
|
||||||
|
"如需查看实际激活版本,请在运行时检查 NPC 日志或在 SO 上断点调试。",
|
||||||
|
MessageType.Info);
|
||||||
|
}
|
||||||
|
|
||||||
|
s_foldout = EditorGUILayout.BeginFoldoutHeaderGroup(s_foldout, "对话版本激活状态");
|
||||||
|
|
||||||
|
if (s_foldout)
|
||||||
|
{
|
||||||
|
EditorGUI.indentLevel++;
|
||||||
|
DrawVersionStatus();
|
||||||
|
EditorGUI.indentLevel--;
|
||||||
|
}
|
||||||
|
|
||||||
|
EditorGUILayout.EndFoldoutHeaderGroup();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 版本状态绘制 ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private void DrawVersionStatus()
|
||||||
|
{
|
||||||
|
// 获取 WorldStateRegistry(运行时或编辑器 SO 引用均可)
|
||||||
|
var worldStateProp = serializedObject.FindProperty("_worldState");
|
||||||
|
var registry = worldStateProp?.objectReferenceValue as WorldStateRegistry;
|
||||||
|
|
||||||
|
var versionsProp = serializedObject.FindProperty("_dialogueVersions");
|
||||||
|
if (versionsProp == null || !versionsProp.isArray || versionsProp.arraySize == 0)
|
||||||
|
{
|
||||||
|
EditorGUILayout.HelpBox("未设置任何对话版本。", MessageType.None);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registry == null)
|
||||||
|
{
|
||||||
|
EditorGUILayout.HelpBox("未指定 WorldStateRegistry,无法预览激活状态。\n请在 Inspector 中设置 World State 字段。", MessageType.Warning);
|
||||||
|
DrawVersionLabelsOnly(versionsProp);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool higherActive = false;
|
||||||
|
|
||||||
|
for (int i = 0; i < versionsProp.arraySize; i++)
|
||||||
|
{
|
||||||
|
var element = versionsProp.GetArrayElementAtIndex(i);
|
||||||
|
|
||||||
|
string label = GetStringProp(element, "versionLabel");
|
||||||
|
string displayName = string.IsNullOrEmpty(label) ? $"版本 {i}" : label;
|
||||||
|
string dialogueName = GetObjectPropName(element, "dialogue");
|
||||||
|
|
||||||
|
// 计算激活状态(直接迭代 SerializedProperty,不分配中间 string[])
|
||||||
|
bool missingRequired = false;
|
||||||
|
string missingFlag = null;
|
||||||
|
var reqProp = element.FindPropertyRelative("requiredFlags");
|
||||||
|
if (reqProp != null && reqProp.isArray)
|
||||||
|
{
|
||||||
|
for (int j = 0; j < reqProp.arraySize; j++)
|
||||||
|
{
|
||||||
|
var f = reqProp.GetArrayElementAtIndex(j).stringValue;
|
||||||
|
if (!string.IsNullOrEmpty(f) && !registry.HasFlag(f))
|
||||||
|
{
|
||||||
|
missingRequired = true;
|
||||||
|
missingFlag = f;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool hasBlocker = false;
|
||||||
|
string blockerKey = null;
|
||||||
|
if (!missingRequired)
|
||||||
|
{
|
||||||
|
var blkProp = element.FindPropertyRelative("blockedByFlags");
|
||||||
|
if (blkProp != null && blkProp.isArray)
|
||||||
|
{
|
||||||
|
for (int j = 0; j < blkProp.arraySize; j++)
|
||||||
|
{
|
||||||
|
var f = blkProp.GetArrayElementAtIndex(j).stringValue;
|
||||||
|
if (!string.IsNullOrEmpty(f) && registry.HasFlag(f))
|
||||||
|
{
|
||||||
|
hasBlocker = true;
|
||||||
|
blockerKey = f;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool conditionsMet = !missingRequired && !hasBlocker;
|
||||||
|
bool isActive = conditionsMet && !higherActive;
|
||||||
|
|
||||||
|
Color statusColor;
|
||||||
|
string statusText;
|
||||||
|
|
||||||
|
if (isActive)
|
||||||
|
{
|
||||||
|
statusColor = ColorActive;
|
||||||
|
statusText = "✓ 激活中";
|
||||||
|
}
|
||||||
|
else if (conditionsMet && higherActive)
|
||||||
|
{
|
||||||
|
statusColor = ColorInactive;
|
||||||
|
statusText = "⏩ 被更高优先级覆盖";
|
||||||
|
}
|
||||||
|
else if (hasBlocker)
|
||||||
|
{
|
||||||
|
statusColor = ColorBlocked;
|
||||||
|
statusText = $"✗ blockedByFlag 阻断 [{blockerKey}]";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
statusColor = ColorInactive;
|
||||||
|
statusText = $"✗ 缺少 requiredFlag [{missingFlag}]";
|
||||||
|
}
|
||||||
|
|
||||||
|
DrawVersionRow(i, displayName, dialogueName, statusText, statusColor);
|
||||||
|
|
||||||
|
if (conditionsMet) higherActive = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兜底说明
|
||||||
|
EditorGUILayout.Space(2);
|
||||||
|
var fallbackProp = serializedObject.FindProperty("_fallbackDialogue");
|
||||||
|
if (fallbackProp?.objectReferenceValue != null)
|
||||||
|
{
|
||||||
|
using (new EditorGUI.DisabledScope(true))
|
||||||
|
EditorGUILayout.LabelField("兜底台词", fallbackProp.objectReferenceValue.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DrawVersionLabelsOnly(SerializedProperty versionsProp)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < versionsProp.arraySize; i++)
|
||||||
|
{
|
||||||
|
var element = versionsProp.GetArrayElementAtIndex(i);
|
||||||
|
string label = GetStringProp(element, "versionLabel");
|
||||||
|
string display = string.IsNullOrEmpty(label) ? $"版本 {i}" : label;
|
||||||
|
EditorGUILayout.LabelField($" {i}. {display}", EditorStyles.miniLabel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DrawVersionRow(int index, string versionLabel, string dialogueName, string statusText, Color statusColor)
|
||||||
|
{
|
||||||
|
using (new EditorGUILayout.HorizontalScope())
|
||||||
|
{
|
||||||
|
// 序号+名称
|
||||||
|
EditorGUILayout.LabelField($"{index}. {versionLabel}", GUILayout.Width(160));
|
||||||
|
|
||||||
|
// 对话 SO 名称
|
||||||
|
if (!string.IsNullOrEmpty(dialogueName))
|
||||||
|
{
|
||||||
|
using (new EditorGUI.DisabledScope(true))
|
||||||
|
EditorGUILayout.LabelField(dialogueName, EditorStyles.miniLabel, GUILayout.Width(140));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 状态徽章
|
||||||
|
var prevColor = GUI.contentColor;
|
||||||
|
GUI.contentColor = statusColor;
|
||||||
|
EditorGUILayout.LabelField(statusText, EditorStyles.boldLabel);
|
||||||
|
GUI.contentColor = prevColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 辅助:从 SerializedProperty 读取字段 ─────────────────────────────
|
||||||
|
|
||||||
|
private static string GetStringProp(SerializedProperty parent, string name)
|
||||||
|
{
|
||||||
|
var p = parent.FindPropertyRelative(name);
|
||||||
|
return p != null ? p.stringValue : string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetObjectPropName(SerializedProperty parent, string name)
|
||||||
|
{
|
||||||
|
var p = parent.FindPropertyRelative(name);
|
||||||
|
if (p == null || p.objectReferenceValue == null) return string.Empty;
|
||||||
|
return p.objectReferenceValue.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
195
Assets/_Game/Scripts/Editor/Dialogue/WorldStateFlagDrawer.cs
Normal file
195
Assets/_Game/Scripts/Editor/Dialogue/WorldStateFlagDrawer.cs
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using UnityEditor;
|
||||||
|
using UnityEngine;
|
||||||
|
using BaseGames.Dialogue;
|
||||||
|
using BaseGames.EventChain;
|
||||||
|
using BaseGames.Editor;
|
||||||
|
|
||||||
|
namespace BaseGames.Editor.Dialogue
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 在 Inspector 中为 [WorldStateFlag] 标记的 string 字段提供已知标志下拉菜单。
|
||||||
|
/// 扫描项目内所有 SetFlagAction / FlagSetCondition / ConditionalVariant.requiredFlags 收集已使用的标志名,
|
||||||
|
/// 供策划选取;同时保留直接输入新标志的能力。
|
||||||
|
/// </summary>
|
||||||
|
[CustomPropertyDrawer(typeof(WorldStateFlagAttribute))]
|
||||||
|
internal sealed class WorldStateFlagDrawer : PropertyDrawer
|
||||||
|
{
|
||||||
|
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
|
||||||
|
{
|
||||||
|
if (property.propertyType != SerializedPropertyType.String)
|
||||||
|
{
|
||||||
|
EditorGUI.PropertyField(position, property, label);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
EditorGUI.BeginProperty(position, label, property);
|
||||||
|
|
||||||
|
// 文本输入区 + 下拉箭头按钮
|
||||||
|
const float btnW = 22f;
|
||||||
|
var textRect = new Rect(position.x, position.y, position.width - btnW - 2f, position.height);
|
||||||
|
var btnRect = new Rect(position.xMax - btnW, position.y, btnW, position.height);
|
||||||
|
|
||||||
|
property.stringValue = EditorGUI.TextField(textRect, label, property.stringValue);
|
||||||
|
|
||||||
|
if (GUI.Button(btnRect, EditorGUIUtility.IconContent("d_icon dropdown"), EditorStyles.iconButton))
|
||||||
|
{
|
||||||
|
var flags = WorldStateFlagCollector.CollectKnownFlags();
|
||||||
|
var menu = new GenericMenu();
|
||||||
|
var current = property.stringValue;
|
||||||
|
var propPath = property.propertyPath;
|
||||||
|
var so = property.serializedObject;
|
||||||
|
|
||||||
|
foreach (var flag in flags)
|
||||||
|
{
|
||||||
|
var captured = flag;
|
||||||
|
menu.AddItem(
|
||||||
|
new GUIContent(captured),
|
||||||
|
current == captured,
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
var prop = so.FindProperty(propPath);
|
||||||
|
if (prop != null)
|
||||||
|
{
|
||||||
|
prop.stringValue = captured;
|
||||||
|
so.ApplyModifiedProperties();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flags.Count == 0)
|
||||||
|
menu.AddDisabledItem(new GUIContent("(项目中尚未定义任何标志)"));
|
||||||
|
|
||||||
|
menu.AddSeparator(string.Empty);
|
||||||
|
menu.AddItem(new GUIContent("刷新列表"), false, WorldStateFlagCollector.Invalidate);
|
||||||
|
|
||||||
|
menu.ShowAsContext();
|
||||||
|
}
|
||||||
|
|
||||||
|
EditorGUI.EndProperty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 标志扫描器 ────────────────────────────────────────────────────────────
|
||||||
|
// SO 来源(SetFlagAction / FlagSetCondition / DialogueSequenceSO):5 秒 TTL 自动刷新。
|
||||||
|
// NarrativeNPC prefab 来源:仅在用户点击"刷新列表"时执行(开销高,手动触发)。
|
||||||
|
|
||||||
|
internal static class WorldStateFlagCollector
|
||||||
|
{
|
||||||
|
private static double _lastSoCollect = -10.0;
|
||||||
|
private static double _lastPrefabCollect = double.MinValue; // 默认不自动触发
|
||||||
|
private static List<string> _cache = new();
|
||||||
|
private static SortedSet<string> _prefabFlags = new(System.StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 强制下次 CollectKnownFlags() 时重新扫描 SO 和 prefab(含 NarrativeNPC)。
|
||||||
|
/// 由下拉菜单的"刷新列表"触发。
|
||||||
|
/// </summary>
|
||||||
|
public static void Invalidate()
|
||||||
|
{
|
||||||
|
_lastSoCollect = -10.0;
|
||||||
|
_lastPrefabCollect = -10.0; // 重置 prefab 缓存使其在下次调用时立即扫描
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 收集项目内所有已使用的世界状态标志名。
|
||||||
|
/// - SO 来源(SetFlagAction / FlagSetCondition / ConditionalVariant):5 秒缓存。
|
||||||
|
/// - NarrativeNPC prefab 来源:仅在调用 Invalidate() 后才重新扫描。
|
||||||
|
/// </summary>
|
||||||
|
public static List<string> CollectKnownFlags()
|
||||||
|
{
|
||||||
|
double now = EditorApplication.timeSinceStartup;
|
||||||
|
|
||||||
|
bool soStale = now - _lastSoCollect >= 5.0;
|
||||||
|
bool prefabStale = now - _lastPrefabCollect >= 5.0 && _lastPrefabCollect < 0; // 仅在 Invalidate 后为负
|
||||||
|
|
||||||
|
if (!soStale && !prefabStale)
|
||||||
|
return _cache;
|
||||||
|
|
||||||
|
var found = new SortedSet<string>(System.StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
if (soStale)
|
||||||
|
{
|
||||||
|
CollectSoFlags(found);
|
||||||
|
_lastSoCollect = now;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// SO 缓存仍有效,直接把已有 SO 标志加入合并集
|
||||||
|
foreach (var f in _cache)
|
||||||
|
{
|
||||||
|
// _cache 里混有 prefab 标志,先全加;下面再合并 prefab
|
||||||
|
found.Add(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prefabStale)
|
||||||
|
{
|
||||||
|
_prefabFlags.Clear();
|
||||||
|
CollectNarrativeNpcFlags(_prefabFlags);
|
||||||
|
_lastPrefabCollect = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var f in _prefabFlags)
|
||||||
|
found.Add(f);
|
||||||
|
|
||||||
|
_cache = new List<string>(found);
|
||||||
|
return _cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CollectSoFlags(SortedSet<string> found)
|
||||||
|
{
|
||||||
|
foreach (var a in AssetOperations.FindAll<SetFlagAction>())
|
||||||
|
if (!string.IsNullOrWhiteSpace(a.flagId)) found.Add(a.flagId.Trim());
|
||||||
|
|
||||||
|
foreach (var c in AssetOperations.FindAll<FlagSetCondition>())
|
||||||
|
if (!string.IsNullOrWhiteSpace(c.flagId)) found.Add(c.flagId.Trim());
|
||||||
|
|
||||||
|
foreach (var seq in AssetOperations.FindAll<DialogueSequenceSO>())
|
||||||
|
{
|
||||||
|
if (seq.variants == null) continue;
|
||||||
|
foreach (var v in seq.variants)
|
||||||
|
{
|
||||||
|
if (v.requiredFlags == null) continue;
|
||||||
|
foreach (var f in v.requiredFlags)
|
||||||
|
if (!string.IsNullOrWhiteSpace(f)) found.Add(f.Trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CollectNarrativeNpcFlags(SortedSet<string> found)
|
||||||
|
{
|
||||||
|
var prefabGuids = AssetDatabase.FindAssets("t:Prefab");
|
||||||
|
foreach (var guid in prefabGuids)
|
||||||
|
{
|
||||||
|
var path = AssetDatabase.GUIDToAssetPath(guid);
|
||||||
|
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(path);
|
||||||
|
if (prefab == null) continue;
|
||||||
|
|
||||||
|
var npc = prefab.GetComponent<NarrativeNPC>();
|
||||||
|
if (npc == null) continue;
|
||||||
|
|
||||||
|
var so = new SerializedObject(npc);
|
||||||
|
var versProp = so.FindProperty("_dialogueVersions");
|
||||||
|
if (versProp == null || !versProp.isArray) continue;
|
||||||
|
|
||||||
|
for (int i = 0; i < versProp.arraySize; i++)
|
||||||
|
{
|
||||||
|
var elem = versProp.GetArrayElementAtIndex(i);
|
||||||
|
ExtractStringArrayProp(elem.FindPropertyRelative("requiredFlags"), found);
|
||||||
|
ExtractStringArrayProp(elem.FindPropertyRelative("blockedByFlags"), found);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ExtractStringArrayProp(SerializedProperty arr, SortedSet<string> found)
|
||||||
|
{
|
||||||
|
if (arr == null || !arr.isArray) return;
|
||||||
|
for (int i = 0; i < arr.arraySize; i++)
|
||||||
|
{
|
||||||
|
var val = arr.GetArrayElementAtIndex(i).stringValue;
|
||||||
|
if (!string.IsNullOrWhiteSpace(val)) found.Add(val.Trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -77,6 +77,9 @@ namespace BaseGames.Editor
|
|||||||
_modules.Add(new BossSkillModule());
|
_modules.Add(new BossSkillModule());
|
||||||
_modules.Add(new CharmModule());
|
_modules.Add(new CharmModule());
|
||||||
_modules.Add(new StreamingModule());
|
_modules.Add(new StreamingModule());
|
||||||
|
_modules.Add(new DialogueModule());
|
||||||
|
_modules.Add(new QuestModule());
|
||||||
|
_modules.Add(new ActorModule());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 布局 ─────────────────────────────────────────────────────────────
|
// ── 布局 ─────────────────────────────────────────────────────────────
|
||||||
|
|||||||
128
Assets/_Game/Scripts/Editor/Modules/ActorModule.cs
Normal file
128
Assets/_Game/Scripts/Editor/Modules/ActorModule.cs
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
using System;
|
||||||
|
using UnityEditor;
|
||||||
|
using UnityEditor.UIElements;
|
||||||
|
using UnityEngine;
|
||||||
|
using UnityEngine.UIElements;
|
||||||
|
using BaseGames.Dialogue;
|
||||||
|
|
||||||
|
namespace BaseGames.Editor.Modules
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// DataHub 对话角色模块 —— 管理 DialogueActorSO 资产。
|
||||||
|
/// 统一查看、创建、重命名、删除 NPC/玩家角色定义(头像、名称 Key、强调色)。
|
||||||
|
/// </summary>
|
||||||
|
public class ActorModule : IDataModule
|
||||||
|
{
|
||||||
|
private const string Folder = "Assets/_Game/Data/Dialogue/Actors";
|
||||||
|
private const string Prefix = "Actor_";
|
||||||
|
|
||||||
|
public string ModuleId => "actor";
|
||||||
|
public string DisplayName => "角色";
|
||||||
|
public string IconName => "d_Prefab Icon";
|
||||||
|
|
||||||
|
private SoListPane<DialogueActorSO> _listPane;
|
||||||
|
private DetailHeader _header;
|
||||||
|
private DialogueActorSO _selected;
|
||||||
|
|
||||||
|
public void Initialize()
|
||||||
|
{
|
||||||
|
_listPane = new SoListPane<DialogueActorSO>(
|
||||||
|
Folder, Prefix,
|
||||||
|
a => a.isPlayer ? "[玩家]" : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void BuildListPane(VisualElement container, Action<UnityEngine.Object> onSelected)
|
||||||
|
{
|
||||||
|
_listPane.SelectionChanged = sel =>
|
||||||
|
{
|
||||||
|
_selected = sel;
|
||||||
|
onSelected?.Invoke(sel);
|
||||||
|
};
|
||||||
|
container.Add(_listPane);
|
||||||
|
_listPane.Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void BuildDetailPane(VisualElement container, UnityEngine.Object selected)
|
||||||
|
{
|
||||||
|
_selected = selected as DialogueActorSO;
|
||||||
|
|
||||||
|
_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(DialogueActorSO a)
|
||||||
|
{
|
||||||
|
var card = SkillModule.MakeCard();
|
||||||
|
|
||||||
|
SkillModule.AddChip(card, "ID", string.IsNullOrEmpty(a.actorId) ? "(未设置)" : a.actorId);
|
||||||
|
SkillModule.AddChip(card, "名称 Key", string.IsNullOrEmpty(a.nameKey) ? "(未设置)" : a.nameKey);
|
||||||
|
if (a.isPlayer)
|
||||||
|
SkillModule.AddChip(card, "类型", "玩家");
|
||||||
|
|
||||||
|
// 头像预览
|
||||||
|
if (a.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 = a.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);
|
||||||
|
|
||||||
|
// 强调色色块
|
||||||
|
var swatch = new VisualElement();
|
||||||
|
swatch.style.width = 14;
|
||||||
|
swatch.style.height = 14;
|
||||||
|
swatch.style.marginLeft = 8;
|
||||||
|
swatch.style.backgroundColor = new StyleColor(a.accentColor);
|
||||||
|
swatch.style.borderTopLeftRadius = 3;
|
||||||
|
swatch.style.borderTopRightRadius = 3;
|
||||||
|
swatch.style.borderBottomLeftRadius = 3;
|
||||||
|
swatch.style.borderBottomRightRadius = 3;
|
||||||
|
row.Add(swatch);
|
||||||
|
|
||||||
|
card.Add(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
private VisualElement BuildActionBar(DialogueActorSO a)
|
||||||
|
{
|
||||||
|
return SkillModule.BuildStandardActionBar(
|
||||||
|
a, Folder, Prefix,
|
||||||
|
onCreated: c => _listPane.Refresh(c),
|
||||||
|
onCloned: c => _listPane.Refresh(c),
|
||||||
|
onDeleted: () => _listPane.Refresh(null));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
230
Assets/_Game/Scripts/Editor/Modules/DialogueModule.cs
Normal file
230
Assets/_Game/Scripts/Editor/Modules/DialogueModule.cs
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
using System;
|
||||||
|
using UnityEditor;
|
||||||
|
using UnityEditor.UIElements;
|
||||||
|
using UnityEngine;
|
||||||
|
using UnityEngine.UIElements;
|
||||||
|
using BaseGames.Dialogue;
|
||||||
|
|
||||||
|
namespace BaseGames.Editor.Modules
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// DataHub 对话序列模块 —— 管理 DialogueSequenceSO 资产。
|
||||||
|
/// </summary>
|
||||||
|
public class DialogueModule : IDataModule
|
||||||
|
{
|
||||||
|
private const string Folder = "Assets/_Game/Data/Dialogue";
|
||||||
|
private const string Prefix = "DLG_";
|
||||||
|
|
||||||
|
public string ModuleId => "dialogue";
|
||||||
|
public string DisplayName => "对话";
|
||||||
|
public string IconName => "d_UnityEditor.ConsoleWindow";
|
||||||
|
|
||||||
|
private SoListPane<DialogueSequenceSO> _listPane;
|
||||||
|
private DetailHeader _header;
|
||||||
|
private DialogueSequenceSO _selected;
|
||||||
|
|
||||||
|
public void Initialize()
|
||||||
|
{
|
||||||
|
_listPane = new SoListPane<DialogueSequenceSO>(
|
||||||
|
Folder, Prefix,
|
||||||
|
s =>
|
||||||
|
{
|
||||||
|
int v = s.variants != null ? s.variants.Length : 0;
|
||||||
|
return v > 0 ? $"{v}变体" : null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void BuildListPane(VisualElement container, Action<UnityEngine.Object> onSelected)
|
||||||
|
{
|
||||||
|
_listPane.SelectionChanged = sel =>
|
||||||
|
{
|
||||||
|
_selected = sel;
|
||||||
|
onSelected?.Invoke(sel);
|
||||||
|
};
|
||||||
|
container.Add(_listPane);
|
||||||
|
_listPane.Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void BuildDetailPane(VisualElement container, UnityEngine.Object selected)
|
||||||
|
{
|
||||||
|
_selected = selected as DialogueSequenceSO;
|
||||||
|
|
||||||
|
_header = new DetailHeader();
|
||||||
|
_header.SetAsset(_selected);
|
||||||
|
_header.RenameRequested += OnRenameRequested;
|
||||||
|
container.Add(_header);
|
||||||
|
|
||||||
|
if (_selected == null) return;
|
||||||
|
|
||||||
|
container.Add(BuildInfoCard(_selected));
|
||||||
|
container.Add(BuildLinesPreview(_selected));
|
||||||
|
if (_selected.variants != null && _selected.variants.Length > 0)
|
||||||
|
container.Add(BuildVariantsCard(_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(DialogueSequenceSO s)
|
||||||
|
{
|
||||||
|
var card = SkillModule.MakeCard();
|
||||||
|
int lineCount = s.lines != null ? s.lines.Length : 0;
|
||||||
|
int variantCount = s.variants != null ? s.variants.Length : 0;
|
||||||
|
|
||||||
|
SkillModule.AddChip(card, "ID", string.IsNullOrEmpty(s.sequenceId) ? "(未设置)" : s.sequenceId);
|
||||||
|
SkillModule.AddChip(card, "行数", lineCount.ToString());
|
||||||
|
if (variantCount > 0)
|
||||||
|
SkillModule.AddChip(card, "变体数", variantCount.ToString());
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static VisualElement BuildLinesPreview(DialogueSequenceSO s)
|
||||||
|
{
|
||||||
|
var section = new VisualElement();
|
||||||
|
section.style.paddingLeft = 12;
|
||||||
|
section.style.paddingRight = 12;
|
||||||
|
section.style.paddingTop = 6;
|
||||||
|
section.style.paddingBottom = 6;
|
||||||
|
|
||||||
|
var title = new Label("对话预览(前 5 行)");
|
||||||
|
title.style.fontSize = 11;
|
||||||
|
title.style.opacity = 0.55f;
|
||||||
|
title.style.marginBottom = 4;
|
||||||
|
title.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||||
|
section.Add(title);
|
||||||
|
|
||||||
|
if (s.lines == null || s.lines.Length == 0)
|
||||||
|
{
|
||||||
|
var empty = new Label("(无对话行)");
|
||||||
|
empty.style.opacity = 0.4f;
|
||||||
|
empty.style.fontSize = 11;
|
||||||
|
section.Add(empty);
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
|
||||||
|
int preview = Mathf.Min(5, s.lines.Length);
|
||||||
|
for (int i = 0; i < preview; i++)
|
||||||
|
{
|
||||||
|
var line = s.lines[i];
|
||||||
|
var row = new VisualElement();
|
||||||
|
row.style.flexDirection = FlexDirection.Row;
|
||||||
|
row.style.alignItems = Align.Center;
|
||||||
|
row.style.marginBottom = 3;
|
||||||
|
|
||||||
|
// 头像图标(actor 优先,回退到直接字段)
|
||||||
|
var portrait = line.ResolvedPortrait;
|
||||||
|
if (portrait != null)
|
||||||
|
{
|
||||||
|
var img = new Image { image = portrait.texture };
|
||||||
|
img.style.width = 18;
|
||||||
|
img.style.height = 18;
|
||||||
|
img.style.marginRight = 4;
|
||||||
|
img.style.borderTopLeftRadius = 2;
|
||||||
|
img.style.borderTopRightRadius = 2;
|
||||||
|
img.style.borderBottomLeftRadius = 2;
|
||||||
|
img.style.borderBottomRightRadius = 2;
|
||||||
|
row.Add(img);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 语音图标
|
||||||
|
if (line.voiceClip != null)
|
||||||
|
{
|
||||||
|
var ico = EditorGUIUtility.IconContent("d_AudioClip Icon");
|
||||||
|
if (ico?.image != null)
|
||||||
|
{
|
||||||
|
var img = new Image { image = ico.image };
|
||||||
|
img.style.width = 14;
|
||||||
|
img.style.height = 14;
|
||||||
|
img.style.marginRight = 4;
|
||||||
|
row.Add(img);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 说话人(actor 优先,回退到直接字段)
|
||||||
|
string speakerKey = line.ResolvedNameKey;
|
||||||
|
if (!string.IsNullOrEmpty(speakerKey))
|
||||||
|
{
|
||||||
|
var spk = new Label(speakerKey + ":");
|
||||||
|
spk.style.fontSize = 11;
|
||||||
|
spk.style.opacity = 0.55f;
|
||||||
|
spk.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||||
|
spk.style.marginRight = 4;
|
||||||
|
spk.style.flexShrink = 0;
|
||||||
|
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);
|
||||||
|
lbl.style.fontSize = 11;
|
||||||
|
lbl.style.overflow = Overflow.Hidden;
|
||||||
|
row.Add(lbl);
|
||||||
|
|
||||||
|
section.Add(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s.lines.Length > preview)
|
||||||
|
{
|
||||||
|
var more = new Label($"… 还有 {s.lines.Length - preview} 行");
|
||||||
|
more.style.opacity = 0.4f;
|
||||||
|
more.style.fontSize = 10;
|
||||||
|
section.Add(more);
|
||||||
|
}
|
||||||
|
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static VisualElement BuildVariantsCard(DialogueSequenceSO s)
|
||||||
|
{
|
||||||
|
var card = SkillModule.MakeCard();
|
||||||
|
card.style.flexDirection = FlexDirection.Column;
|
||||||
|
|
||||||
|
var title = new Label("条件变体");
|
||||||
|
title.style.fontSize = 11;
|
||||||
|
title.style.opacity = 0.55f;
|
||||||
|
title.style.marginBottom = 4;
|
||||||
|
title.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||||
|
card.Add(title);
|
||||||
|
|
||||||
|
foreach (var v in s.variants)
|
||||||
|
{
|
||||||
|
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 : "(未设置)");
|
||||||
|
card.Add(row);
|
||||||
|
}
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
private VisualElement BuildActionBar(DialogueSequenceSO s)
|
||||||
|
{
|
||||||
|
return SkillModule.BuildStandardActionBar(
|
||||||
|
s, Folder, Prefix,
|
||||||
|
onCreated: c => _listPane.Refresh(c),
|
||||||
|
onCloned: c => _listPane.Refresh(c),
|
||||||
|
onDeleted: () => _listPane.Refresh(null));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
342
Assets/_Game/Scripts/Editor/Modules/QuestModule.cs
Normal file
342
Assets/_Game/Scripts/Editor/Modules/QuestModule.cs
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
using UnityEditor;
|
||||||
|
using UnityEditor.UIElements;
|
||||||
|
using UnityEngine;
|
||||||
|
using UnityEngine.UIElements;
|
||||||
|
using BaseGames.Quest;
|
||||||
|
|
||||||
|
namespace BaseGames.Editor.Modules
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// DataHub 任务模块 —— 管理 QuestSO 资产。
|
||||||
|
/// </summary>
|
||||||
|
public class QuestModule : IDataModule
|
||||||
|
{
|
||||||
|
private const string Folder = "Assets/_Game/Data/Quest";
|
||||||
|
private const string Prefix = "Quest_";
|
||||||
|
|
||||||
|
public string ModuleId => "quest";
|
||||||
|
public string DisplayName => "任务";
|
||||||
|
public string IconName => "d_UnityEditor.InspectorWindow";
|
||||||
|
|
||||||
|
private SoListPane<QuestSO> _listPane;
|
||||||
|
private DetailHeader _header;
|
||||||
|
private QuestSO _selected;
|
||||||
|
|
||||||
|
public void Initialize()
|
||||||
|
{
|
||||||
|
_listPane = new SoListPane<QuestSO>(
|
||||||
|
Folder, Prefix,
|
||||||
|
s =>
|
||||||
|
{
|
||||||
|
bool hasPre = s.prerequisiteQuests != null && s.prerequisiteQuests.Length > 0;
|
||||||
|
return hasPre ? "有前置" : null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void BuildListPane(VisualElement container, Action<UnityEngine.Object> onSelected)
|
||||||
|
{
|
||||||
|
_listPane.SelectionChanged = sel =>
|
||||||
|
{
|
||||||
|
_selected = sel;
|
||||||
|
onSelected?.Invoke(sel);
|
||||||
|
};
|
||||||
|
container.Add(_listPane);
|
||||||
|
_listPane.Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void BuildDetailPane(VisualElement container, UnityEngine.Object selected)
|
||||||
|
{
|
||||||
|
_selected = selected as QuestSO;
|
||||||
|
|
||||||
|
_header = new DetailHeader();
|
||||||
|
_header.SetAsset(_selected);
|
||||||
|
_header.RenameRequested += OnRenameRequested;
|
||||||
|
container.Add(_header);
|
||||||
|
|
||||||
|
if (_selected == null) return;
|
||||||
|
|
||||||
|
container.Add(BuildInfoCard(_selected));
|
||||||
|
container.Add(BuildObjectivesList(_selected));
|
||||||
|
if (_selected.branches != null && _selected.branches.Length > 0)
|
||||||
|
container.Add(BuildBranchesCard(_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(QuestSO s)
|
||||||
|
{
|
||||||
|
var card = SkillModule.MakeCard();
|
||||||
|
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);
|
||||||
|
if (!string.IsNullOrEmpty(s.descriptionKey))
|
||||||
|
SkillModule.AddChip(card, "描述 Key", s.descriptionKey);
|
||||||
|
SkillModule.AddChip(card, "目标数", objCount.ToString());
|
||||||
|
|
||||||
|
if (s.prerequisiteQuests != null && s.prerequisiteQuests.Length > 0)
|
||||||
|
{
|
||||||
|
// 显示每个前置任务的 questId,方便策划一眼看清依赖链
|
||||||
|
var preIds = new System.Text.StringBuilder();
|
||||||
|
foreach (var pre in s.prerequisiteQuests)
|
||||||
|
{
|
||||||
|
if (pre == null) continue;
|
||||||
|
if (preIds.Length > 0) preIds.Append(", ");
|
||||||
|
preIds.Append(string.IsNullOrEmpty(pre.questId) ? pre.name : pre.questId);
|
||||||
|
}
|
||||||
|
if (preIds.Length > 0)
|
||||||
|
SkillModule.AddChip(card, "前置任务", preIds.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s.minAffinityToAccept > 0)
|
||||||
|
SkillModule.AddChip(card, "好感门槛", s.minAffinityToAccept.ToString());
|
||||||
|
if (s.canFail)
|
||||||
|
SkillModule.AddChip(card, "可失败", "✓");
|
||||||
|
if (s.reward != null)
|
||||||
|
SkillModule.AddChip(card, "奖励", s.reward.name);
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static VisualElement BuildObjectivesList(QuestSO s)
|
||||||
|
{
|
||||||
|
var section = new VisualElement();
|
||||||
|
section.style.paddingLeft = 12;
|
||||||
|
section.style.paddingRight = 12;
|
||||||
|
section.style.paddingTop = 6;
|
||||||
|
section.style.paddingBottom = 6;
|
||||||
|
|
||||||
|
var title = new Label("目标列表");
|
||||||
|
title.style.fontSize = 11;
|
||||||
|
title.style.opacity = 0.55f;
|
||||||
|
title.style.marginBottom = 4;
|
||||||
|
title.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||||
|
section.Add(title);
|
||||||
|
|
||||||
|
if (s.objectives == null || s.objectives.Length == 0)
|
||||||
|
{
|
||||||
|
var empty = new Label("(无目标)");
|
||||||
|
empty.style.opacity = 0.4f;
|
||||||
|
empty.style.fontSize = 11;
|
||||||
|
section.Add(empty);
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < s.objectives.Length; i++)
|
||||||
|
{
|
||||||
|
var obj = s.objectives[i];
|
||||||
|
if (obj == null) continue;
|
||||||
|
|
||||||
|
var row = new VisualElement();
|
||||||
|
row.style.flexDirection = FlexDirection.Row;
|
||||||
|
row.style.alignItems = Align.Center;
|
||||||
|
row.style.marginBottom = 3;
|
||||||
|
|
||||||
|
// 序号
|
||||||
|
var idx = new Label($"{i + 1}.");
|
||||||
|
idx.style.fontSize = 11;
|
||||||
|
idx.style.opacity = 0.5f;
|
||||||
|
idx.style.marginRight = 4;
|
||||||
|
idx.style.width = 16;
|
||||||
|
idx.style.flexShrink = 0;
|
||||||
|
row.Add(idx);
|
||||||
|
|
||||||
|
// 类型徽章
|
||||||
|
string badge = obj.BadgeLabel;
|
||||||
|
var badgeLbl = new Label(badge);
|
||||||
|
badgeLbl.style.fontSize = 10;
|
||||||
|
badgeLbl.style.opacity = 0.7f;
|
||||||
|
badgeLbl.style.marginRight = 6;
|
||||||
|
badgeLbl.style.flexShrink = 0;
|
||||||
|
badgeLbl.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||||
|
row.Add(badgeLbl);
|
||||||
|
|
||||||
|
// ID
|
||||||
|
string id = string.IsNullOrEmpty(obj.objectiveId) ? obj.name : obj.objectiveId;
|
||||||
|
var idLbl = new Label(id);
|
||||||
|
idLbl.style.fontSize = 11;
|
||||||
|
idLbl.style.flexGrow = 1;
|
||||||
|
row.Add(idLbl);
|
||||||
|
|
||||||
|
// 可选标记
|
||||||
|
if (obj.IsOptional)
|
||||||
|
{
|
||||||
|
var opt = new Label("[可选]");
|
||||||
|
opt.style.fontSize = 10;
|
||||||
|
opt.style.opacity = 0.5f;
|
||||||
|
opt.style.marginLeft = 4;
|
||||||
|
row.Add(opt);
|
||||||
|
}
|
||||||
|
|
||||||
|
section.Add(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static VisualElement BuildBranchesCard(QuestSO s)
|
||||||
|
{
|
||||||
|
var card = SkillModule.MakeCard();
|
||||||
|
card.style.flexDirection = FlexDirection.Column;
|
||||||
|
|
||||||
|
var title = new Label("完成后分支");
|
||||||
|
title.style.fontSize = 11;
|
||||||
|
title.style.opacity = 0.55f;
|
||||||
|
title.style.marginBottom = 4;
|
||||||
|
title.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||||
|
card.Add(title);
|
||||||
|
|
||||||
|
foreach (var branch in s.branches)
|
||||||
|
{
|
||||||
|
var row = new VisualElement();
|
||||||
|
row.style.flexDirection = FlexDirection.Row;
|
||||||
|
row.style.marginBottom = 2;
|
||||||
|
|
||||||
|
string condition = branch.conditionQuest != null ? branch.conditionQuest.questId : "(默认)";
|
||||||
|
string next = branch.nextQuest != null ? branch.nextQuest.name : "(无)";
|
||||||
|
SkillModule.AddChip(row, "条件", condition);
|
||||||
|
SkillModule.AddChip(row, "后续任务", next);
|
||||||
|
|
||||||
|
// 优先显示新 SO 引用;回退到旧字段(Obsolete)
|
||||||
|
string seqName = branch.npcDialogueSequence != null
|
||||||
|
? branch.npcDialogueSequence.name
|
||||||
|
#pragma warning disable CS0618
|
||||||
|
: branch.npcDialogueKey;
|
||||||
|
#pragma warning restore CS0618
|
||||||
|
if (!string.IsNullOrEmpty(seqName))
|
||||||
|
SkillModule.AddChip(row, "对话序列", seqName);
|
||||||
|
card.Add(row);
|
||||||
|
}
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
|
||||||
|
// 任务模块额外:代码常量生成
|
||||||
|
new Button(GenerateQuestKeys) { text = "生成常量" }.AlsoAddTo(bar);
|
||||||
|
|
||||||
|
return bar;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── QuestKeys.cs 常量生成器 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
private const string GeneratedFolder = "Assets/_Game/Scripts/Generated";
|
||||||
|
private const string QuestKeysPath = GeneratedFolder + "/QuestKeys.cs";
|
||||||
|
|
||||||
|
private static void GenerateQuestKeys()
|
||||||
|
{
|
||||||
|
// 收集所有 questId
|
||||||
|
var questIds = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var q in AssetOperations.FindAll<QuestSO>())
|
||||||
|
if (!string.IsNullOrWhiteSpace(q.questId))
|
||||||
|
questIds.Add(q.questId.Trim());
|
||||||
|
|
||||||
|
// 收集所有 targetNpcId(来自 TalkToNPC 目标 SO)
|
||||||
|
var npcIds = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var obj in AssetOperations.FindAll<TalkToNPCObjective>())
|
||||||
|
if (!string.IsNullOrWhiteSpace(obj.targetNpcId))
|
||||||
|
npcIds.Add(obj.targetNpcId.Trim());
|
||||||
|
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine("// <auto-generated>");
|
||||||
|
sb.AppendLine("// 由 QuestModule 工具自动生成,请勿手动编辑。");
|
||||||
|
sb.AppendLine("// 重新生成:DataHub → 任务 → 任意任务 → 生成常量");
|
||||||
|
sb.AppendLine("// </auto-generated>");
|
||||||
|
sb.AppendLine("namespace BaseGames.Quest");
|
||||||
|
sb.AppendLine("{");
|
||||||
|
sb.AppendLine(" /// <summary>任务 ID 常量(从 QuestSO 自动生成)。</summary>");
|
||||||
|
sb.AppendLine(" public static class QuestKeys");
|
||||||
|
sb.AppendLine(" {");
|
||||||
|
|
||||||
|
sb.AppendLine(" /// <summary>任务唯一 ID 常量。</summary>");
|
||||||
|
sb.AppendLine(" public static class Quest");
|
||||||
|
sb.AppendLine(" {");
|
||||||
|
if (questIds.Count == 0)
|
||||||
|
sb.AppendLine(" // (未发现任何 QuestSO)");
|
||||||
|
foreach (var id in questIds)
|
||||||
|
sb.AppendLine($" public const string {ToIdentifier(id)} = \"{id}\";");
|
||||||
|
sb.AppendLine(" }");
|
||||||
|
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine(" /// <summary>TalkToNPC 目标中使用的 NPC ID 常量。</summary>");
|
||||||
|
sb.AppendLine(" public static class NpcId");
|
||||||
|
sb.AppendLine(" {");
|
||||||
|
if (npcIds.Count == 0)
|
||||||
|
sb.AppendLine(" // (未发现任何 TalkToNPCObjective)");
|
||||||
|
foreach (var id in npcIds)
|
||||||
|
sb.AppendLine($" public const string {ToIdentifier(id)} = \"{id}\";");
|
||||||
|
sb.AppendLine(" }");
|
||||||
|
|
||||||
|
sb.AppendLine(" }");
|
||||||
|
sb.AppendLine("}");
|
||||||
|
|
||||||
|
if (!Directory.Exists(GeneratedFolder))
|
||||||
|
Directory.CreateDirectory(GeneratedFolder);
|
||||||
|
|
||||||
|
File.WriteAllText(QuestKeysPath, sb.ToString(), Encoding.UTF8);
|
||||||
|
AssetDatabase.Refresh();
|
||||||
|
Debug.Log($"[QuestModule] QuestKeys.cs 已生成:{questIds.Count} 个任务 ID,{npcIds.Count} 个 NPC ID。");
|
||||||
|
EditorUtility.DisplayDialog("生成成功",
|
||||||
|
$"QuestKeys.cs 已写入 {QuestKeysPath}\n任务 ID: {questIds.Count} NPC ID: {npcIds.Count}",
|
||||||
|
"确定");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>将任意字符串转换为合法的 C# 标识符(PascalCase)。C# 保留关键字加 @ 前缀。</summary>
|
||||||
|
private static string ToIdentifier(string raw)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(raw)) return "_Empty";
|
||||||
|
var parts = raw.Split('_', '-', ' ', '.', '/');
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
foreach (var part in parts)
|
||||||
|
{
|
||||||
|
if (part.Length == 0) continue;
|
||||||
|
sb.Append(char.ToUpperInvariant(part[0]));
|
||||||
|
if (part.Length > 1) sb.Append(part.Substring(1));
|
||||||
|
}
|
||||||
|
string result = sb.ToString();
|
||||||
|
if (result.Length > 0 && char.IsDigit(result[0]))
|
||||||
|
result = "_" + result;
|
||||||
|
if (string.IsNullOrEmpty(result)) return "_Empty";
|
||||||
|
// C# 保留关键字加 @ 前缀,避免生成无法编译的代码
|
||||||
|
if (s_CSharpKeywords.Contains(result))
|
||||||
|
result = "@" + result;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly HashSet<string> s_CSharpKeywords = new HashSet<string>(
|
||||||
|
System.StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
"abstract","as","base","bool","break","byte","case","catch","char","checked",
|
||||||
|
"class","const","continue","decimal","default","delegate","do","double","else",
|
||||||
|
"enum","event","explicit","extern","false","finally","fixed","float","for",
|
||||||
|
"foreach","goto","if","implicit","in","int","interface","internal","is","lock",
|
||||||
|
"long","namespace","new","null","object","operator","out","override","params",
|
||||||
|
"private","protected","public","readonly","ref","return","sbyte","sealed",
|
||||||
|
"short","sizeof","stackalloc","static","string","struct","switch","this",
|
||||||
|
"throw","true","try","typeof","uint","ulong","unchecked","unsafe","ushort",
|
||||||
|
"using","virtual","void","volatile","while"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -99,15 +99,7 @@ namespace BaseGames.Editor.Modules
|
|||||||
{ text = "克隆..." }.AlsoAddTo(bar);
|
{ text = "克隆..." }.AlsoAddTo(bar);
|
||||||
|
|
||||||
var del = new Button(() => { if (AssetOperations.Delete(s)) _listPane.Refresh(null); }) { text = "删除" };
|
var del = new Button(() => { if (AssetOperations.Delete(s)) _listPane.Refresh(null); }) { text = "删除" };
|
||||||
del.style.borderLeftColor = new StyleColor(new Color(0.8f, 0.3f, 0.3f, 0.6f));
|
ApplyDeleteStyle(del);
|
||||||
del.style.borderRightColor = new StyleColor(new Color(0.8f, 0.3f, 0.3f, 0.6f));
|
|
||||||
del.style.borderTopColor = new StyleColor(new Color(0.8f, 0.3f, 0.3f, 0.6f));
|
|
||||||
del.style.borderBottomColor = new StyleColor(new Color(0.8f, 0.3f, 0.3f, 0.6f));
|
|
||||||
del.style.borderLeftWidth = 1;
|
|
||||||
del.style.borderRightWidth = 1;
|
|
||||||
del.style.borderTopWidth = 1;
|
|
||||||
del.style.borderBottomWidth = 1;
|
|
||||||
del.style.marginLeft = 8;
|
|
||||||
del.AlsoAddTo(bar);
|
del.AlsoAddTo(bar);
|
||||||
|
|
||||||
return bar;
|
return bar;
|
||||||
@@ -171,6 +163,69 @@ namespace BaseGames.Editor.Modules
|
|||||||
d.style.backgroundColor = new StyleColor(new Color(0.5f, 0.5f, 0.5f, 0.2f));
|
d.style.backgroundColor = new StyleColor(new Color(0.5f, 0.5f, 0.5f, 0.2f));
|
||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>将删除按钮染成红色边框,统一各模块样式。</summary>
|
||||||
|
internal static void ApplyDeleteStyle(Button btn)
|
||||||
|
{
|
||||||
|
var red = new StyleColor(new Color(0.8f, 0.3f, 0.3f, 0.6f));
|
||||||
|
btn.style.borderLeftColor = red;
|
||||||
|
btn.style.borderRightColor = red;
|
||||||
|
btn.style.borderTopColor = red;
|
||||||
|
btn.style.borderBottomColor = red;
|
||||||
|
btn.style.borderLeftWidth = 1;
|
||||||
|
btn.style.borderRightWidth = 1;
|
||||||
|
btn.style.borderTopWidth = 1;
|
||||||
|
btn.style.borderBottomWidth = 1;
|
||||||
|
btn.style.marginLeft = 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 为任意 ScriptableObject 模块生成标准 ActionBar(新建 / 定位 / 克隆 / 删除)。
|
||||||
|
/// 各模块可在返回后向 bar 追加额外按钮。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="asset">当前选中资产。</param>
|
||||||
|
/// <param name="folder">资产所在文件夹(用于新建 / 克隆)。</param>
|
||||||
|
/// <param name="prefix">新建资产的文件名前缀。</param>
|
||||||
|
/// <param name="onCreated">新建完成回调(传入新资产)。</param>
|
||||||
|
/// <param name="onCloned">克隆完成回调(传入克隆资产)。</param>
|
||||||
|
/// <param name="onDeleted">删除完成回调。</param>
|
||||||
|
internal static VisualElement BuildStandardActionBar<T>(
|
||||||
|
T asset,
|
||||||
|
string folder,
|
||||||
|
string prefix,
|
||||||
|
Action<T> onCreated,
|
||||||
|
Action<T> onCloned,
|
||||||
|
Action onDeleted) where T : UnityEngine.ScriptableObject
|
||||||
|
{
|
||||||
|
var bar = MakeActionBar();
|
||||||
|
|
||||||
|
new Button(() =>
|
||||||
|
{
|
||||||
|
var c = AssetOperations.Create<T>(folder, prefix + "New");
|
||||||
|
if (c != null) onCreated?.Invoke(c);
|
||||||
|
}) { text = "新建" }.AlsoAddTo(bar);
|
||||||
|
|
||||||
|
new Button(() =>
|
||||||
|
{
|
||||||
|
EditorGUIUtility.PingObject(asset);
|
||||||
|
Selection.activeObject = asset;
|
||||||
|
}) { text = "定位" }.AlsoAddTo(bar);
|
||||||
|
|
||||||
|
new Button(() =>
|
||||||
|
{
|
||||||
|
var c = AssetOperations.Clone(asset, folder);
|
||||||
|
if (c != null) onCloned?.Invoke(c);
|
||||||
|
}) { text = "克隆..." }.AlsoAddTo(bar);
|
||||||
|
|
||||||
|
var del = new Button(() =>
|
||||||
|
{
|
||||||
|
if (AssetOperations.Delete(asset)) onDeleted?.Invoke();
|
||||||
|
}) { text = "删除" };
|
||||||
|
ApplyDeleteStyle(del);
|
||||||
|
del.AlsoAddTo(bar);
|
||||||
|
|
||||||
|
return bar;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Button 扩展(模块内共用)─────────────────────────────────────────────
|
// ── Button 扩展(模块内共用)─────────────────────────────────────────────
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using UnityEditor;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace BaseGames.Quest.Editor
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 监听 QuestSO 资产的增删/移动事件,自动刷新场景中 QuestManager 的 _allQuests 列表。
|
||||||
|
/// 保证策划通过 DataHub 创建新任务后无需手动触发 OnValidate。
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class QuestManagerPostprocessor : AssetPostprocessor
|
||||||
|
{
|
||||||
|
private static void OnPostprocessAllAssets(
|
||||||
|
string[] imported, string[] deleted, string[] moved, string[] movedFrom)
|
||||||
|
{
|
||||||
|
if (!NeedsRefresh(imported) && !NeedsRefresh(deleted) && !NeedsRefresh(moved))
|
||||||
|
return;
|
||||||
|
|
||||||
|
RefreshAllQuestManagers();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool NeedsRefresh(string[] paths)
|
||||||
|
{
|
||||||
|
if (paths == null) return false;
|
||||||
|
foreach (var p in paths)
|
||||||
|
{
|
||||||
|
if (p.EndsWith(".asset", System.StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& AssetDatabase.GetMainAssetTypeAtPath(p) == typeof(QuestSO))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 在所有已加载场景中查找 QuestManager,通过反射调用其
|
||||||
|
/// EditorRefreshQuestList 方法(#if UNITY_EDITOR 区块内定义)。
|
||||||
|
/// </summary>
|
||||||
|
private static void RefreshAllQuestManagers()
|
||||||
|
{
|
||||||
|
var managers = Object.FindObjectsByType<QuestManager>(
|
||||||
|
FindObjectsInactive.Include, FindObjectsSortMode.None);
|
||||||
|
|
||||||
|
if (managers == null || managers.Length == 0) return;
|
||||||
|
|
||||||
|
var method = typeof(QuestManager).GetMethod(
|
||||||
|
"EditorRefreshQuestList",
|
||||||
|
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
|
||||||
|
|
||||||
|
if (method == null)
|
||||||
|
{
|
||||||
|
Debug.LogWarning("[QuestManagerPostprocessor] 未找到 EditorRefreshQuestList 方法,请确认 QuestManager 包含该方法。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var mgr in managers)
|
||||||
|
{
|
||||||
|
method.Invoke(mgr, null);
|
||||||
|
EditorUtility.SetDirty(mgr);
|
||||||
|
}
|
||||||
|
|
||||||
|
Debug.Log($"[QuestManagerPostprocessor] 已刷新 {managers.Length} 个 QuestManager 的任务列表。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,20 +55,14 @@ namespace BaseGames.Editor
|
|||||||
string path = AssetDatabase.GetAssetPath(asset);
|
string path = AssetDatabase.GetAssetPath(asset);
|
||||||
if (string.IsNullOrEmpty(path)) return (false, "资产不在 AssetDatabase 中");
|
if (string.IsNullOrEmpty(path)) return (false, "资产不在 AssetDatabase 中");
|
||||||
|
|
||||||
// 先更新序列化内部名称
|
// 先重命名磁盘文件,成功后再修改内部名称,避免 Undo 状态被失败操作污染
|
||||||
string oldName = asset.name;
|
|
||||||
Undo.RecordObject(asset, "Rename " + oldName);
|
|
||||||
asset.name = newName;
|
|
||||||
EditorUtility.SetDirty(asset);
|
|
||||||
|
|
||||||
// 再重命名磁盘文件
|
|
||||||
string err = AssetDatabase.RenameAsset(path, newName);
|
string err = AssetDatabase.RenameAsset(path, newName);
|
||||||
if (!string.IsNullOrEmpty(err))
|
if (!string.IsNullOrEmpty(err))
|
||||||
{
|
|
||||||
asset.name = oldName;
|
|
||||||
EditorUtility.SetDirty(asset);
|
|
||||||
return (false, err);
|
return (false, err);
|
||||||
}
|
|
||||||
|
Undo.RecordObject(asset, "Rename " + asset.name);
|
||||||
|
asset.name = newName;
|
||||||
|
EditorUtility.SetDirty(asset);
|
||||||
|
|
||||||
AssetDatabase.SaveAssets();
|
AssetDatabase.SaveAssets();
|
||||||
AssetDatabase.Refresh();
|
AssetDatabase.Refresh();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System;
|
|||||||
using System.Collections;
|
using System.Collections;
|
||||||
using BaseGames.Core;
|
using BaseGames.Core;
|
||||||
using BaseGames.Core.Events;
|
using BaseGames.Core.Events;
|
||||||
|
using BaseGames.Dialogue;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
|
|
||||||
namespace BaseGames.EventChain
|
namespace BaseGames.EventChain
|
||||||
@@ -59,7 +60,7 @@ namespace BaseGames.EventChain
|
|||||||
public class BossDefeatedCondition : ChainCondition
|
public class BossDefeatedCondition : ChainCondition
|
||||||
{
|
{
|
||||||
public string bossId;
|
public string bossId;
|
||||||
private bool _met;
|
[System.NonSerialized] private bool _met;
|
||||||
public override void Register(EventChainManager m) => m.OnBossDefeated += Check;
|
public override void Register(EventChainManager m) => m.OnBossDefeated += Check;
|
||||||
public override void Unregister(EventChainManager m) => m.OnBossDefeated -= Check;
|
public override void Unregister(EventChainManager m) => m.OnBossDefeated -= Check;
|
||||||
public override bool IsMet() => _met;
|
public override bool IsMet() => _met;
|
||||||
@@ -80,7 +81,7 @@ namespace BaseGames.EventChain
|
|||||||
public class AbilityUnlockedCondition : ChainCondition
|
public class AbilityUnlockedCondition : ChainCondition
|
||||||
{
|
{
|
||||||
public string abilityId;
|
public string abilityId;
|
||||||
private bool _met;
|
[System.NonSerialized] private bool _met;
|
||||||
public override void Register(EventChainManager m) => m.OnAbilityUnlocked += Check;
|
public override void Register(EventChainManager m) => m.OnAbilityUnlocked += Check;
|
||||||
public override void Unregister(EventChainManager m) => m.OnAbilityUnlocked -= Check;
|
public override void Unregister(EventChainManager m) => m.OnAbilityUnlocked -= Check;
|
||||||
public override bool IsMet() => _met;
|
public override bool IsMet() => _met;
|
||||||
@@ -92,7 +93,7 @@ namespace BaseGames.EventChain
|
|||||||
public class CollectibleCollectedCondition : ChainCondition
|
public class CollectibleCollectedCondition : ChainCondition
|
||||||
{
|
{
|
||||||
public string itemId;
|
public string itemId;
|
||||||
private bool _met;
|
[System.NonSerialized] private bool _met;
|
||||||
public override void Register(EventChainManager m) => m.OnCollectiblePickedUp += Check;
|
public override void Register(EventChainManager m) => m.OnCollectiblePickedUp += Check;
|
||||||
public override void Unregister(EventChainManager m) => m.OnCollectiblePickedUp -= Check;
|
public override void Unregister(EventChainManager m) => m.OnCollectiblePickedUp -= Check;
|
||||||
public override bool IsMet() => _met;
|
public override bool IsMet() => _met;
|
||||||
@@ -104,7 +105,7 @@ namespace BaseGames.EventChain
|
|||||||
public class RoomEnteredCondition : ChainCondition
|
public class RoomEnteredCondition : ChainCondition
|
||||||
{
|
{
|
||||||
public string sceneName;
|
public string sceneName;
|
||||||
private bool _met;
|
[System.NonSerialized] private bool _met;
|
||||||
public override void Register(EventChainManager m) => m.OnRoomEntered += Check;
|
public override void Register(EventChainManager m) => m.OnRoomEntered += Check;
|
||||||
public override void Unregister(EventChainManager m) => m.OnRoomEntered -= Check;
|
public override void Unregister(EventChainManager m) => m.OnRoomEntered -= Check;
|
||||||
public override bool IsMet() => _met;
|
public override bool IsMet() => _met;
|
||||||
@@ -116,7 +117,7 @@ namespace BaseGames.EventChain
|
|||||||
public class DialogueCompletedCondition : ChainCondition
|
public class DialogueCompletedCondition : ChainCondition
|
||||||
{
|
{
|
||||||
public string npcId;
|
public string npcId;
|
||||||
private bool _met;
|
[System.NonSerialized] private bool _met;
|
||||||
public override void Register(EventChainManager m) => m.OnDialogueCompleted += Check;
|
public override void Register(EventChainManager m) => m.OnDialogueCompleted += Check;
|
||||||
public override void Unregister(EventChainManager m) => m.OnDialogueCompleted -= Check;
|
public override void Unregister(EventChainManager m) => m.OnDialogueCompleted -= Check;
|
||||||
public override bool IsMet() => _met;
|
public override bool IsMet() => _met;
|
||||||
@@ -128,7 +129,7 @@ namespace BaseGames.EventChain
|
|||||||
public class ChainCompletedCondition : ChainCondition
|
public class ChainCompletedCondition : ChainCondition
|
||||||
{
|
{
|
||||||
public string chainId;
|
public string chainId;
|
||||||
private bool _met;
|
[System.NonSerialized] private bool _met;
|
||||||
public override void Register(EventChainManager m) => m.OnChainCompleted += Check;
|
public override void Register(EventChainManager m) => m.OnChainCompleted += Check;
|
||||||
public override void Unregister(EventChainManager m) => m.OnChainCompleted -= Check;
|
public override void Unregister(EventChainManager m) => m.OnChainCompleted -= Check;
|
||||||
public override bool IsMet() => _met;
|
public override bool IsMet() => _met;
|
||||||
@@ -167,11 +168,18 @@ namespace BaseGames.EventChain
|
|||||||
[CreateAssetMenu(menuName = "BaseGames/EventChain/Action/SetFlag")]
|
[CreateAssetMenu(menuName = "BaseGames/EventChain/Action/SetFlag")]
|
||||||
public class SetFlagAction : ChainAction
|
public class SetFlagAction : ChainAction
|
||||||
{
|
{
|
||||||
|
[WorldStateFlag]
|
||||||
public string flagId;
|
public string flagId;
|
||||||
public bool value = true;
|
public bool value = true;
|
||||||
public override IEnumerator ExecuteAsync(MonoBehaviour runner)
|
public override IEnumerator ExecuteAsync(MonoBehaviour runner)
|
||||||
{
|
{
|
||||||
ServiceLocator.GetOrDefault<ISaveService>()?.SetFlag(flagId, value);
|
var saveService = ServiceLocator.GetOrDefault<ISaveService>();
|
||||||
|
if (saveService == null)
|
||||||
|
{
|
||||||
|
Debug.LogError($"[SetFlagAction] ISaveService 未注册,标志 '{flagId}' 无法持久化。", this);
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
saveService.SetFlag(flagId, value);
|
||||||
yield break;
|
yield break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,7 +122,64 @@ namespace BaseGames.Localization
|
|||||||
public static string Get(string key, string table = "UI")
|
public static string Get(string key, string table = "UI")
|
||||||
=> ServiceLocator.GetOrDefault<ILocalizationService>()?.Get(key, table) ?? key;
|
=> ServiceLocator.GetOrDefault<ILocalizationService>()?.Get(key, table) ?? key;
|
||||||
|
|
||||||
// ── 内部 ─────────────────────────────────────────────────────────────
|
// ── 编辑器预览(不依赖 ServiceLocator 实例)────────────────────────────
|
||||||
|
#if UNITY_EDITOR
|
||||||
|
// 编辑器预览缓存:"{language}/{table}" → (key → value)
|
||||||
|
// 生命周期与编辑器进程相同;域重载时自动清空(static 字段随域重载重置)。
|
||||||
|
private static readonly System.Collections.Generic.Dictionary<
|
||||||
|
string,
|
||||||
|
System.Collections.Generic.Dictionary<string, string>> s_editorPreviewCache = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 编辑器工具专用:不依赖运行时服务实例,直接从 Resources 读取本地化文本。
|
||||||
|
/// 结果缓存在静态字典中,同一编辑器会话内同一表只加载一次。
|
||||||
|
/// 找不到时返回 null(区别于运行时的 key 回退,便于调用方判断是否显示 key)。
|
||||||
|
/// </summary>
|
||||||
|
public static string GetEditorPreview(string key, string table = "UI",
|
||||||
|
Language language = Language.ChineseSimplified)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(key)) return null;
|
||||||
|
|
||||||
|
var dict = GetEditorTable(language, table)
|
||||||
|
?? GetEditorTable(Language.English, table); // 中文缺失时英文回退
|
||||||
|
if (dict == null) return null;
|
||||||
|
|
||||||
|
dict.TryGetValue(key, out var value);
|
||||||
|
return value; // 找不到 key 时返回 null
|
||||||
|
}
|
||||||
|
|
||||||
|
private static System.Collections.Generic.Dictionary<string, string> GetEditorTable(
|
||||||
|
Language language, string table)
|
||||||
|
{
|
||||||
|
string cacheKey = $"{language}/{table}";
|
||||||
|
if (s_editorPreviewCache.TryGetValue(cacheKey, out var cached))
|
||||||
|
return cached; // 已缓存(可能是 null 占位,表示文件不存在)
|
||||||
|
|
||||||
|
string path = $"Localization/{language}/{table}";
|
||||||
|
var asset = Resources.Load<TextAsset>(path);
|
||||||
|
if (asset == null)
|
||||||
|
{
|
||||||
|
s_editorPreviewCache[cacheKey] = null; // 记录"不存在",避免重复尝试
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsed = JsonUtility.FromJson<StringTableJson>(asset.text);
|
||||||
|
if (parsed?.entries == null)
|
||||||
|
{
|
||||||
|
s_editorPreviewCache[cacheKey] = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var dict = new System.Collections.Generic.Dictionary<string, string>(
|
||||||
|
parsed.entries.Count, System.StringComparer.Ordinal);
|
||||||
|
foreach (var entry in parsed.entries)
|
||||||
|
if (!string.IsNullOrEmpty(entry.key))
|
||||||
|
dict[entry.key] = entry.value ?? string.Empty;
|
||||||
|
|
||||||
|
s_editorPreviewCache[cacheKey] = dict;
|
||||||
|
return dict;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
private bool TryGetFromTable(Language language, string table, string key, out string value)
|
private bool TryGetFromTable(Language language, string table, string key, out string value)
|
||||||
{
|
{
|
||||||
var cacheKey = $"{language}/{table}";
|
var cacheKey = $"{language}/{table}";
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ namespace BaseGames.Quest
|
|||||||
protected override void Interact_Internal(Transform player)
|
protected override void Interact_Internal(Transform player)
|
||||||
{
|
{
|
||||||
var qm = SL.GetOrDefault<IQuestManager>();
|
var qm = SL.GetOrDefault<IQuestManager>();
|
||||||
var quest = GetCurrentQuest(qm);
|
var quest = GetCurrentOrCompletedQuest(qm);
|
||||||
if (quest == null || qm == null) return;
|
if (quest == null || qm == null) return;
|
||||||
|
|
||||||
var state = qm.GetState(quest.questId);
|
var state = qm.GetState(quest.questId);
|
||||||
@@ -36,7 +36,7 @@ namespace BaseGames.Quest
|
|||||||
{
|
{
|
||||||
qm.AcceptQuest(quest.questId);
|
qm.AcceptQuest(quest.questId);
|
||||||
}
|
}
|
||||||
else if (qm.IsReadyToComplete(quest.questId))
|
else if (state == QuestStateEnum.Active && qm.IsReadyToComplete(quest.questId))
|
||||||
{
|
{
|
||||||
// 直接从 player 获取 PlayerStats,避免对 PlayerController 的程序集依赖
|
// 直接从 player 获取 PlayerStats,避免对 PlayerController 的程序集依赖
|
||||||
var stats = player.GetComponentInParent<PlayerStats>();
|
var stats = player.GetComponentInParent<PlayerStats>();
|
||||||
@@ -47,7 +47,7 @@ namespace BaseGames.Quest
|
|||||||
protected override DialogueSequenceSO GetCurrentDialogue()
|
protected override DialogueSequenceSO GetCurrentDialogue()
|
||||||
{
|
{
|
||||||
var qm = SL.GetOrDefault<IQuestManager>();
|
var qm = SL.GetOrDefault<IQuestManager>();
|
||||||
var quest = GetCurrentQuest(qm);
|
var quest = GetCurrentOrCompletedQuest(qm);
|
||||||
if (quest == null || qm == null) return base.GetCurrentDialogue();
|
if (quest == null || qm == null) return base.GetCurrentDialogue();
|
||||||
|
|
||||||
var state = qm.GetState(quest.questId);
|
var state = qm.GetState(quest.questId);
|
||||||
@@ -64,19 +64,25 @@ namespace BaseGames.Quest
|
|||||||
|
|
||||||
// ── 私有辅助 ─────────────────────────────────────────────────────────
|
// ── 私有辅助 ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>返回当前处于 Available 或 Active 状态的第一个任务。</summary>
|
/// <summary>
|
||||||
private QuestSO GetCurrentQuest(IQuestManager qm = null)
|
/// 返回当前处于 Available 或 Active 状态的第一个任务;
|
||||||
|
/// 若全部已完成,返回最后一个已完成任务(用于显示 completedDialogue)。
|
||||||
|
/// </summary>
|
||||||
|
private QuestSO GetCurrentOrCompletedQuest(IQuestManager qm = null)
|
||||||
{
|
{
|
||||||
if (_offeredQuests == null) return null;
|
if (_offeredQuests == null) return null;
|
||||||
qm ??= SL.GetOrDefault<IQuestManager>();
|
qm ??= SL.GetOrDefault<IQuestManager>();
|
||||||
if (qm == null) return null;
|
if (qm == null) return null;
|
||||||
|
|
||||||
|
QuestSO lastCompleted = null;
|
||||||
foreach (var q in _offeredQuests)
|
foreach (var q in _offeredQuests)
|
||||||
{
|
{
|
||||||
if (q == null) continue;
|
if (q == null) continue;
|
||||||
var s = qm.GetState(q.questId);
|
var s = qm.GetState(q.questId);
|
||||||
if (s == QuestStateEnum.Available || s == QuestStateEnum.Active) return q;
|
if (s == QuestStateEnum.Available || s == QuestStateEnum.Active) return q;
|
||||||
|
if (s == QuestStateEnum.Completed) lastCompleted = q;
|
||||||
}
|
}
|
||||||
return null;
|
return lastCompleted;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,17 +11,23 @@ namespace BaseGames.Quest
|
|||||||
/// 挂在 Persistent 场景 [GameManagers] 下。
|
/// 挂在 Persistent 场景 [GameManagers] 下。
|
||||||
/// 事件驱动追踪目标进度,不主动轮询。
|
/// 事件驱动追踪目标进度,不主动轮询。
|
||||||
/// 实现 ISaveable,通过 SaveManager 持久化任务状态。
|
/// 实现 ISaveable,通过 SaveManager 持久化任务状态。
|
||||||
|
///
|
||||||
|
/// _allQuests 由编辑器 OnValidate / "刷新任务列表" 右键菜单自动填充,
|
||||||
|
/// 无需策划人员手动拖入 ScriptableObject。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class QuestManager : MonoBehaviour, ISaveable, IQuestManager
|
public class QuestManager : MonoBehaviour, ISaveable, IQuestManager
|
||||||
{
|
{
|
||||||
// ── Inspector ────────────────────────────────────────────────────────
|
// ── Inspector ────────────────────────────────────────────────────────
|
||||||
[SerializeField] private QuestSO[] _allQuests;
|
|
||||||
|
[Tooltip("所有 QuestSO 资产。编辑器会自动同步,无需手动维护。")]
|
||||||
|
[SerializeField] private QuestSO[] _allQuests;
|
||||||
|
|
||||||
[Header("Event Channels(监听)")]
|
[Header("Event Channels(监听)")]
|
||||||
[SerializeField] private StringEventChannelSO _onEnemyDied; // EVT_EnemyDied(enemyId)
|
[SerializeField] private StringEventChannelSO _onEnemyDied; // EVT_EnemyDied(enemyId)
|
||||||
[SerializeField] private StringEventChannelSO _onCollectiblePickup; // EVT_CollectiblePickup(itemId)
|
[SerializeField] private StringEventChannelSO _onCollectiblePickup; // EVT_CollectiblePickup(itemId)
|
||||||
[SerializeField] private StringEventChannelSO _onSceneLoaded; // EVT_SceneLoaded(sceneName)
|
[SerializeField] private StringEventChannelSO _onSceneLoaded; // EVT_SceneLoaded(sceneName)
|
||||||
[SerializeField] private StringEventChannelSO _onNpcDialogueCompleted; // EVT_NpcDialogueCompleted(npcId)
|
[SerializeField] private StringEventChannelSO _onNpcDialogueCompleted; // EVT_NpcDialogueCompleted(npcId)
|
||||||
|
[SerializeField] private StringEventChannelSO _onSkillUsed; // EVT_SkillUsed(abilityType.ToString())
|
||||||
|
|
||||||
[Header("Event Channels(广播)")]
|
[Header("Event Channels(广播)")]
|
||||||
[SerializeField] private StringEventChannelSO _onQuestStarted; // questId
|
[SerializeField] private StringEventChannelSO _onQuestStarted; // questId
|
||||||
@@ -52,14 +58,35 @@ namespace BaseGames.Quest
|
|||||||
foreach (var q in _allQuests)
|
foreach (var q in _allQuests)
|
||||||
if (q != null && !string.IsNullOrEmpty(q.questId))
|
if (q != null && !string.IsNullOrEmpty(q.questId))
|
||||||
_questIndex[q.questId] = q;
|
_questIndex[q.questId] = q;
|
||||||
|
|
||||||
|
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
||||||
|
ValidateQuestIds();
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
||||||
|
private void ValidateQuestIds()
|
||||||
|
{
|
||||||
|
if (_allQuests == null) return;
|
||||||
|
var seen = new HashSet<string>(System.StringComparer.Ordinal);
|
||||||
|
foreach (var q in _allQuests)
|
||||||
|
{
|
||||||
|
if (q == null) continue;
|
||||||
|
if (string.IsNullOrEmpty(q.questId))
|
||||||
|
Debug.LogError($"[QuestManager] QuestSO '{q.name}' 缺少 questId,此任务将无法被引用。", q);
|
||||||
|
else if (!seen.Add(q.questId))
|
||||||
|
Debug.LogError($"[QuestManager] 重复的 questId '{q.questId}'(资产:{q.name}),将导致任务系统异常。", q);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
private void OnEnable()
|
private void OnEnable()
|
||||||
{
|
{
|
||||||
_onEnemyDied?.Subscribe(HandleEnemyDefeated).AddTo(_subs);
|
_onEnemyDied?.Subscribe(HandleEnemyDefeated).AddTo(_subs);
|
||||||
_onCollectiblePickup?.Subscribe(HandleItemCollected).AddTo(_subs);
|
_onCollectiblePickup?.Subscribe(HandleItemCollected).AddTo(_subs);
|
||||||
_onSceneLoaded?.Subscribe(HandleSceneLoaded).AddTo(_subs);
|
_onSceneLoaded?.Subscribe(HandleSceneLoaded).AddTo(_subs);
|
||||||
_onNpcDialogueCompleted?.Subscribe(HandleNpcDialogue).AddTo(_subs);
|
_onNpcDialogueCompleted?.Subscribe(HandleNpcDialogue).AddTo(_subs);
|
||||||
|
_onSkillUsed?.Subscribe(HandleSkillUsed).AddTo(_subs);
|
||||||
BaseGames.Core.ServiceLocator.GetOrDefault<BaseGames.Core.Save.ISaveableRegistry>()?.Register(this);
|
BaseGames.Core.ServiceLocator.GetOrDefault<BaseGames.Core.Save.ISaveableRegistry>()?.Register(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,16 +121,17 @@ namespace BaseGames.Quest
|
|||||||
_onQuestCompleted?.Raise(questId);
|
_onQuestCompleted?.Raise(questId);
|
||||||
|
|
||||||
// 解锁后续任务(分支)
|
// 解锁后续任务(分支)
|
||||||
|
// conditionQuest == null 表示默认分支,conditionQuest != null 则要求该任务已完成。
|
||||||
|
// 不 break —— 允许同时解锁多个后续任务(如完成任务后同时开放多条支线)。
|
||||||
if (quest.branches != null)
|
if (quest.branches != null)
|
||||||
{
|
{
|
||||||
foreach (var branch in quest.branches)
|
foreach (var branch in quest.branches)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(branch.conditionQuestId) ||
|
if (branch.conditionQuest == null ||
|
||||||
GetState(branch.conditionQuestId) == QuestStateEnum.Completed)
|
GetState(branch.conditionQuest.questId) == QuestStateEnum.Completed)
|
||||||
{
|
{
|
||||||
if (branch.nextQuest != null)
|
if (branch.nextQuest != null)
|
||||||
_questStates[branch.nextQuest.questId] = QuestStateEnum.Available;
|
_questStates[branch.nextQuest.questId] = QuestStateEnum.Available;
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,9 +161,8 @@ namespace BaseGames.Quest
|
|||||||
{
|
{
|
||||||
data.Quests.QuestStates[id] = new BaseGames.Core.Save.QuestState
|
data.Quests.QuestStates[id] = new BaseGames.Core.Save.QuestState
|
||||||
{
|
{
|
||||||
Status = state.ToString(),
|
Status = state.ToString(),
|
||||||
ObjectiveIndex = 0,
|
ProgressCounts = BuildProgressList(id),
|
||||||
ProgressCounts = BuildProgressList(id),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -171,24 +198,36 @@ namespace BaseGames.Quest
|
|||||||
{
|
{
|
||||||
if (GetState(questId) != QuestStateEnum.Available) return false;
|
if (GetState(questId) != QuestStateEnum.Available) return false;
|
||||||
var quest = GetQuestSO(questId);
|
var quest = GetQuestSO(questId);
|
||||||
if (quest?.prerequisiteQuestIds == null) return true;
|
if (quest?.prerequisiteQuests == null) return true;
|
||||||
foreach (var pre in quest.prerequisiteQuestIds)
|
foreach (var pre in quest.prerequisiteQuests)
|
||||||
if (GetState(pre) != QuestStateEnum.Completed) return false;
|
{
|
||||||
|
if (pre == null) continue;
|
||||||
|
if (GetState(pre.questId) != QuestStateEnum.Completed) return false;
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool IsObjectiveComplete(QuestObjectiveSO obj)
|
private bool IsObjectiveComplete(QuestObjectiveSO obj)
|
||||||
{
|
{
|
||||||
_objectiveStates.TryGetValue(obj.objectiveId, out var s);
|
if (!_objectiveStates.TryGetValue(obj.objectiveId, out var s))
|
||||||
s ??= new QuestObjectiveState();
|
s = new QuestObjectiveState();
|
||||||
return obj.EvaluateCompletion(s);
|
|
||||||
|
bool result = obj.EvaluateCompletion(s);
|
||||||
|
|
||||||
|
// 首次达成时写回 completed 标志,避免 s 是本地临时对象时标志丢失
|
||||||
|
if (result && !s.completed)
|
||||||
|
{
|
||||||
|
s.completed = true;
|
||||||
|
_objectiveStates[obj.objectiveId] = s;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<int> BuildProgressList(string questId)
|
private List<int> BuildProgressList(string questId)
|
||||||
{
|
{
|
||||||
var list = new List<int>();
|
|
||||||
var quest = GetQuestSO(questId);
|
var quest = GetQuestSO(questId);
|
||||||
if (quest?.objectives == null) return list;
|
if (quest?.objectives == null) return new List<int>(0);
|
||||||
|
var list = new List<int>(quest.objectives.Length);
|
||||||
foreach (var obj in quest.objectives)
|
foreach (var obj in quest.objectives)
|
||||||
{
|
{
|
||||||
_objectiveStates.TryGetValue(obj?.objectiveId ?? string.Empty, out var os);
|
_objectiveStates.TryGetValue(obj?.objectiveId ?? string.Empty, out var os);
|
||||||
@@ -235,6 +274,17 @@ namespace BaseGames.Quest
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void HandleSkillUsed(string abilityTypeName)
|
||||||
|
{
|
||||||
|
// 用 Enum.TryParse 避免大小写/ToString 格式差异导致静默失败
|
||||||
|
if (!System.Enum.TryParse<AbilityType>(abilityTypeName, ignoreCase: true, out var parsed)) return;
|
||||||
|
ForEachActiveObjective<UseSkillObjective>(obj =>
|
||||||
|
{
|
||||||
|
if (obj.requiredAbility == parsed)
|
||||||
|
IncrementProgress(obj.objectiveId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private void ForEachActiveObjective<T>(System.Action<T> action) where T : QuestObjectiveSO
|
private void ForEachActiveObjective<T>(System.Action<T> action) where T : QuestObjectiveSO
|
||||||
{
|
{
|
||||||
foreach (var (qid, state) in _questStates)
|
foreach (var (qid, state) in _questStates)
|
||||||
@@ -263,5 +313,94 @@ namespace BaseGames.Quest
|
|||||||
|
|
||||||
private QuestSO GetQuestSO(string id)
|
private QuestSO GetQuestSO(string id)
|
||||||
=> _questIndex != null && _questIndex.TryGetValue(id, out var q) ? q : null;
|
=> _questIndex != null && _questIndex.TryGetValue(id, out var q) ? q : null;
|
||||||
|
|
||||||
|
// ── 编辑器自动维护 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#if UNITY_EDITOR
|
||||||
|
/// <summary>
|
||||||
|
/// 编辑器中每次属性变更时自动同步项目内所有 QuestSO,并校验前置任务是否存在循环引用。
|
||||||
|
/// </summary>
|
||||||
|
private void OnValidate()
|
||||||
|
{
|
||||||
|
EditorRefreshQuestList();
|
||||||
|
ValidatePrerequisites();
|
||||||
|
}
|
||||||
|
|
||||||
|
[ContextMenu("刷新任务列表")]
|
||||||
|
private void EditorRefreshQuestList()
|
||||||
|
{
|
||||||
|
string[] guids = UnityEditor.AssetDatabase.FindAssets("t:QuestSO");
|
||||||
|
var list = new List<QuestSO>(guids.Length);
|
||||||
|
foreach (var guid in guids)
|
||||||
|
{
|
||||||
|
string path = UnityEditor.AssetDatabase.GUIDToAssetPath(guid);
|
||||||
|
var q = UnityEditor.AssetDatabase.LoadAssetAtPath<QuestSO>(path);
|
||||||
|
if (q != null) list.Add(q);
|
||||||
|
}
|
||||||
|
_allQuests = list.ToArray();
|
||||||
|
UnityEditor.EditorUtility.SetDirty(this);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
||||||
|
/// <summary>
|
||||||
|
/// 通过 DFS 后序遍历检测 prerequisiteQuests 中是否存在循环引用。
|
||||||
|
/// 在编辑器 OnValidate 及开发构建 Awake 时调用,发现问题立即打 LogError。
|
||||||
|
/// </summary>
|
||||||
|
[System.Diagnostics.Conditional("UNITY_EDITOR")] // ContextMenu 只在编辑器生效
|
||||||
|
[UnityEngine.ContextMenu("校验前置任务循环引用")]
|
||||||
|
private void ValidatePrerequisites()
|
||||||
|
{
|
||||||
|
if (_allQuests == null) return;
|
||||||
|
|
||||||
|
// 先建立 id → SO 快速查找表
|
||||||
|
var index = new Dictionary<string, QuestSO>(_allQuests.Length);
|
||||||
|
foreach (var q in _allQuests)
|
||||||
|
{
|
||||||
|
if (q == null || string.IsNullOrEmpty(q.questId)) continue;
|
||||||
|
index[q.questId] = q;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 三色 DFS:0=未访问 1=灰(栈中)2=黑(已完成)
|
||||||
|
var color = new Dictionary<string, int>(index.Count);
|
||||||
|
|
||||||
|
bool HasCycle(string startId, List<string> path)
|
||||||
|
{
|
||||||
|
if (!index.ContainsKey(startId)) return false;
|
||||||
|
if (color.TryGetValue(startId, out int c))
|
||||||
|
{
|
||||||
|
if (c == 1)
|
||||||
|
{
|
||||||
|
path.Add(startId);
|
||||||
|
Debug.LogError(
|
||||||
|
$"[QuestManager] 前置任务循环引用: {string.Join(" → ", path)}",
|
||||||
|
this);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false; // 已完成
|
||||||
|
}
|
||||||
|
|
||||||
|
color[startId] = 1;
|
||||||
|
path.Add(startId);
|
||||||
|
|
||||||
|
var prereqs = index[startId].prerequisiteQuests;
|
||||||
|
if (prereqs != null)
|
||||||
|
{
|
||||||
|
foreach (var pre in prereqs)
|
||||||
|
{
|
||||||
|
if (pre == null || string.IsNullOrEmpty(pre.questId)) continue;
|
||||||
|
if (HasCycle(pre.questId, path)) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
path.RemoveAt(path.Count - 1);
|
||||||
|
color[startId] = 2;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var questId in index.Keys)
|
||||||
|
HasCycle(questId, new List<string>());
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,13 +11,32 @@ namespace BaseGames.Quest
|
|||||||
public abstract class QuestObjectiveSO : ScriptableObject
|
public abstract class QuestObjectiveSO : ScriptableObject
|
||||||
{
|
{
|
||||||
[Header("标识")]
|
[Header("标识")]
|
||||||
|
[Tooltip("目标唯一 ID,如 \"OBJ_TalkElder\"。空时由 OnValidate 自动以资产名填充。")]
|
||||||
public string objectiveId;
|
public string objectiveId;
|
||||||
|
[Tooltip("本地化 Key,格式如 \"Quest_FindMushroom_Obj1\"。通过 LocalizationManager.Get(displayTextKey, \"Quest\") 显示给玩家。")]
|
||||||
[TextArea(1, 4)]
|
[TextArea(1, 4)]
|
||||||
public string displayTextKey; // 本地化 key(通过 LocalizationManager.Get(displayTextKey, "Quest") 显示)
|
public string displayTextKey;
|
||||||
public bool IsOptional; // 可选目标(完成加奖励但不阻塞任务)
|
[Tooltip("勾选后此目标为可选项:完成可获奖励,但不阻塞任务交接。")]
|
||||||
|
public bool IsOptional;
|
||||||
|
|
||||||
/// <summary>根据当前进度判断目标是否完成。</summary>
|
/// <summary>根据当前进度判断目标是否完成。</summary>
|
||||||
public abstract bool EvaluateCompletion(QuestObjectiveState state);
|
public abstract bool EvaluateCompletion(QuestObjectiveState state);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 在 DataHub / 编辑器工具中显示的类型徽章文字。
|
||||||
|
/// 子类应 override 返回简洁中文标签(如 "[对话]")。
|
||||||
|
/// 避免工具代码中使用 type-switch 维护列表。
|
||||||
|
/// </summary>
|
||||||
|
public virtual string BadgeLabel => "[目标]";
|
||||||
|
|
||||||
|
#if UNITY_EDITOR
|
||||||
|
private void OnValidate()
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(objectiveId)) return;
|
||||||
|
objectiveId = name;
|
||||||
|
UnityEditor.EditorUtility.SetDirty(this);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 运行时目标进度状态(由 QuestManager 管理,不继承 SO)────────────────
|
// ── 运行时目标进度状态(由 QuestManager 管理,不继承 SO)────────────────
|
||||||
@@ -34,8 +53,9 @@ namespace BaseGames.Quest
|
|||||||
[CreateAssetMenu(menuName = "BaseGames/Quest/Objective/TalkToNPC")]
|
[CreateAssetMenu(menuName = "BaseGames/Quest/Objective/TalkToNPC")]
|
||||||
public class TalkToNPCObjective : QuestObjectiveSO
|
public class TalkToNPCObjective : QuestObjectiveSO
|
||||||
{
|
{
|
||||||
|
[Tooltip("目标 NPC 的唯一 ID,需与 NPC 组件上的 npcId / InteractableNPC.npcId 保持一致。")]
|
||||||
public string targetNpcId;
|
public string targetNpcId;
|
||||||
|
public override string BadgeLabel => "[对话]";
|
||||||
public override bool EvaluateCompletion(QuestObjectiveState s) => s.progressCount >= 1;
|
public override bool EvaluateCompletion(QuestObjectiveState s) => s.progressCount >= 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,9 +63,11 @@ namespace BaseGames.Quest
|
|||||||
[CreateAssetMenu(menuName = "BaseGames/Quest/Objective/Defeat")]
|
[CreateAssetMenu(menuName = "BaseGames/Quest/Objective/Defeat")]
|
||||||
public class DefeatEnemyObjective : QuestObjectiveSO
|
public class DefeatEnemyObjective : QuestObjectiveSO
|
||||||
{
|
{
|
||||||
|
[Tooltip("目标敌人的唯一 ID,需与敌人 SO 或敌人组件上的 enemyId 保持一致。")]
|
||||||
public string targetEnemyId;
|
public string targetEnemyId;
|
||||||
|
[Tooltip("需击败的次数,默认 1。")]
|
||||||
[Min(1)] public int defeatCount = 1;
|
[Min(1)] public int defeatCount = 1;
|
||||||
|
public override string BadgeLabel => "[击败]";
|
||||||
public override bool EvaluateCompletion(QuestObjectiveState s) => s.progressCount >= defeatCount;
|
public override bool EvaluateCompletion(QuestObjectiveState s) => s.progressCount >= defeatCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,9 +75,11 @@ namespace BaseGames.Quest
|
|||||||
[CreateAssetMenu(menuName = "BaseGames/Quest/Objective/Collect")]
|
[CreateAssetMenu(menuName = "BaseGames/Quest/Objective/Collect")]
|
||||||
public class CollectItemObjective : QuestObjectiveSO
|
public class CollectItemObjective : QuestObjectiveSO
|
||||||
{
|
{
|
||||||
|
[Tooltip("目标物品的唯一 ID,需与拾取事件广播的 itemId 保持一致。")]
|
||||||
public string itemId;
|
public string itemId;
|
||||||
|
[Tooltip("需收集的数量,默认 1。")]
|
||||||
[Min(1)] public int collectCount = 1;
|
[Min(1)] public int collectCount = 1;
|
||||||
|
public override string BadgeLabel => "[收集]";
|
||||||
public override bool EvaluateCompletion(QuestObjectiveState s) => s.progressCount >= collectCount;
|
public override bool EvaluateCompletion(QuestObjectiveState s) => s.progressCount >= collectCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,9 +87,11 @@ namespace BaseGames.Quest
|
|||||||
[CreateAssetMenu(menuName = "BaseGames/Quest/Objective/Reach")]
|
[CreateAssetMenu(menuName = "BaseGames/Quest/Objective/Reach")]
|
||||||
public class ReachAreaObjective : QuestObjectiveSO
|
public class ReachAreaObjective : QuestObjectiveSO
|
||||||
{
|
{
|
||||||
public string sceneName; // 需到达的场景
|
[Tooltip("需到达的场景名(Unity Build Settings 中的场景名称)。")]
|
||||||
public string markerTag; // 场景内的目标标记 Tag(预留)
|
public string sceneName;
|
||||||
|
[Tooltip("场景内的目标标记 Tag(预留字段,当前未启用)。")]
|
||||||
|
public string markerTag;
|
||||||
|
public override string BadgeLabel => "[到达]";
|
||||||
public override bool EvaluateCompletion(QuestObjectiveState s) => s.progressCount >= 1;
|
public override bool EvaluateCompletion(QuestObjectiveState s) => s.progressCount >= 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,9 +99,11 @@ namespace BaseGames.Quest
|
|||||||
[CreateAssetMenu(menuName = "BaseGames/Quest/Objective/UseSkill")]
|
[CreateAssetMenu(menuName = "BaseGames/Quest/Objective/UseSkill")]
|
||||||
public class UseSkillObjective : QuestObjectiveSO
|
public class UseSkillObjective : QuestObjectiveSO
|
||||||
{
|
{
|
||||||
|
[Tooltip("目标能力类型。事件频道 EVT_SkillUsed 广播 AbilityType.ToString(),与此值匹配时计数。")]
|
||||||
public AbilityType requiredAbility;
|
public AbilityType requiredAbility;
|
||||||
|
[Tooltip("需使用的次数,默认 1。")]
|
||||||
[Min(1)] public int useCount = 1;
|
[Min(1)] public int useCount = 1;
|
||||||
|
public override string BadgeLabel => "[使用]";
|
||||||
public override bool EvaluateCompletion(QuestObjectiveState s) => s.progressCount >= useCount;
|
public override bool EvaluateCompletion(QuestObjectiveState s) => s.progressCount >= useCount;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,17 +13,19 @@ namespace BaseGames.Quest
|
|||||||
{
|
{
|
||||||
[Header("标识")]
|
[Header("标识")]
|
||||||
public string questId; // 唯一 ID,如 "Quest_FindMushroom"
|
public string questId; // 唯一 ID,如 "Quest_FindMushroom"
|
||||||
public string displayName;
|
|
||||||
[TextArea(2, 6)]
|
[Tooltip("本地化 Key,格式如 \"Quest_FindMushroom_Name\"。通过 LocalizationManager.Get(displayNameKey, \"Quest\") 显示。")]
|
||||||
public string description;
|
public string displayNameKey;
|
||||||
|
[Tooltip("本地化 Key,格式如 \"Quest_FindMushroom_Desc\"。通过 LocalizationManager.Get(descriptionKey, \"Quest\") 显示。")]
|
||||||
|
public string descriptionKey;
|
||||||
public Sprite icon;
|
public Sprite icon;
|
||||||
|
|
||||||
[Header("目标链")]
|
[Header("目标链")]
|
||||||
public QuestObjectiveSO[] objectives; // 按顺序完成,全部完成 = 可交完
|
public QuestObjectiveSO[] objectives; // 按顺序完成,全部完成 = 可交完
|
||||||
|
|
||||||
[Header("前置条件")]
|
[Header("前置条件")]
|
||||||
public string[] prerequisiteQuestIds; // 所有前置任务 Completed 后才可接
|
public QuestSO[] prerequisiteQuests; // 所有前置任务 Completed 后才可接
|
||||||
public int minAffinityToAccept; // NPC 好感度门槛(0 = 无限制)
|
public int minAffinityToAccept; // NPC 好感度门槛(0 = 无限制)
|
||||||
|
|
||||||
[Header("奖励")]
|
[Header("奖励")]
|
||||||
public RewardSO reward;
|
public RewardSO reward;
|
||||||
@@ -34,13 +36,91 @@ namespace BaseGames.Quest
|
|||||||
|
|
||||||
[Header("完成后续任务(分支)")]
|
[Header("完成后续任务(分支)")]
|
||||||
public QuestBranch[] branches;
|
public QuestBranch[] branches;
|
||||||
|
|
||||||
|
// ── 编辑器校验 ────────────────────────────────────────────────────────
|
||||||
|
#if UNITY_EDITOR
|
||||||
|
// questId → 资产路径,5 秒 TTL,跨所有 QuestSO.OnValidate 共用。
|
||||||
|
// 重复检测时只需将缓存路径与自身路径比对(O(1)),无需全量扫描。
|
||||||
|
private static System.Collections.Generic.Dictionary<string, string> s_questIdToPath;
|
||||||
|
private static double s_questIdsCacheTime = -10.0;
|
||||||
|
|
||||||
|
private static System.Collections.Generic.Dictionary<string, string> GetQuestIdCache()
|
||||||
|
{
|
||||||
|
double now = UnityEditor.EditorApplication.timeSinceStartup;
|
||||||
|
if (s_questIdToPath != null && now - s_questIdsCacheTime < 5.0)
|
||||||
|
return s_questIdToPath;
|
||||||
|
|
||||||
|
s_questIdToPath = new System.Collections.Generic.Dictionary<string, string>(System.StringComparer.Ordinal);
|
||||||
|
string[] 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 && !string.IsNullOrEmpty(q.questId) && !s_questIdToPath.ContainsKey(q.questId))
|
||||||
|
s_questIdToPath[q.questId] = path;
|
||||||
|
}
|
||||||
|
s_questIdsCacheTime = now;
|
||||||
|
return s_questIdToPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnValidate()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(questId))
|
||||||
|
{
|
||||||
|
Debug.LogWarning($"[QuestSO] '{name}' 缺少 questId,保存前请填写。", this);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测重复 questId:缓存路径 vs 自身路径比对(O(1)),5 秒内无需重扫。
|
||||||
|
var cache = GetQuestIdCache();
|
||||||
|
string myPath = UnityEditor.AssetDatabase.GetAssetPath(this);
|
||||||
|
if (!string.IsNullOrEmpty(myPath) &&
|
||||||
|
cache.TryGetValue(questId, out var existingPath) &&
|
||||||
|
existingPath != myPath)
|
||||||
|
{
|
||||||
|
Debug.LogError(
|
||||||
|
$"[QuestSO] questId '{questId}' 与 " +
|
||||||
|
$"'{System.IO.Path.GetFileNameWithoutExtension(existingPath)}' 重复!请修改其中一个。", this);
|
||||||
|
s_questIdsCacheTime = -10.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ValidateBranchDialogueKeys();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ValidateBranchDialogueKeys()
|
||||||
|
{
|
||||||
|
if (branches == null || branches.Length == 0) return;
|
||||||
|
|
||||||
|
foreach (var branch in branches)
|
||||||
|
{
|
||||||
|
if (branch == null) continue;
|
||||||
|
|
||||||
|
// npcDialogueSequence 是 SO 直接引用,无需字符串校验。
|
||||||
|
// 旧字段 npcDialogueKey(Obsolete)有值时提示迁移。
|
||||||
|
#pragma warning disable CS0618
|
||||||
|
if (!string.IsNullOrEmpty(branch.npcDialogueKey) && branch.npcDialogueSequence == null)
|
||||||
|
{
|
||||||
|
Debug.LogWarning(
|
||||||
|
$"[QuestSO] '{name}' 分支仍使用旧字段 npcDialogueKey='{branch.npcDialogueKey}'," +
|
||||||
|
"请迁移至 npcDialogueSequence(直接拖入 DialogueSequenceSO)。", this);
|
||||||
|
}
|
||||||
|
#pragma warning restore CS0618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
[Serializable]
|
[Serializable]
|
||||||
public class QuestBranch
|
public class QuestBranch
|
||||||
{
|
{
|
||||||
public string conditionQuestId; // 若此任务已完成 → 走本分支(空 = 默认)
|
/// <summary>若此前置任务已完成 → 走本分支(null = 默认分支)。</summary>
|
||||||
public QuestSO nextQuest;
|
public QuestSO conditionQuest;
|
||||||
public string npcDialogueKey; // 触发 NPC 对话 key
|
public QuestSO nextQuest;
|
||||||
|
/// <summary>完成后触发的 NPC 对话序列(直接引用,避免手写 sequenceId 字符串出错)。</summary>
|
||||||
|
public DialogueSequenceSO npcDialogueSequence;
|
||||||
|
|
||||||
|
[System.Obsolete("已废弃,请改用 npcDialogueSequence(直接 SO 引用)。保留字段以兼容现有资产序列化。")]
|
||||||
|
[HideInInspector]
|
||||||
|
public string npcDialogueKey;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,13 +98,14 @@ namespace BaseGames.World
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 返回指定分类中所有已标记的 ID(快照副本)。
|
/// 返回指定分类中所有已标记的 ID(只读视图,非副本)。
|
||||||
/// 由 WorldStateRegistrySaver.OnSave 调用,将运行时状态写入 SaveData。
|
/// 由 WorldStateRegistrySaver.OnSave 调用,将运行时状态写入 SaveData。
|
||||||
|
/// 注意:不保证跨帧稳定性,调用方若需持久快照请自行 ToArray()。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public IReadOnlyCollection<string> GetAllIds(WorldObjectCategory category)
|
public IReadOnlyCollection<string> GetAllIds(WorldObjectCategory category)
|
||||||
{
|
{
|
||||||
if (_states.TryGetValue(category, out var set))
|
if (_states.TryGetValue(category, out var set))
|
||||||
return new HashSet<string>(set);
|
return set;
|
||||||
return System.Array.Empty<string>();
|
return System.Array.Empty<string>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user