feat: Round 48 narrative systems improvements

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

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-05-25 00:05:15 +08:00
parent 446fd5dcd0
commit 6eaa83dc71
72 changed files with 7080 additions and 373 deletions

View File

@@ -0,0 +1,86 @@
using UnityEngine;
namespace BaseGames.Dialogue
{
/// <summary>
/// NPC 元数据资产(架构 14_NarrativeModule §2
/// 将 NPC 的唯一 ID、本地化名称 Key、头像、好感度上限集中在一处管理。
///
/// 关联:
/// • <see cref="InteractableNPC"/> 通过 _npcId 字段与此 SO 对应。
/// • <see cref="DialogueActorSO"/> 管理对话 UI 侧头像/颜色(二者可共享同一 Sprite或独立维护
/// • <see cref="BaseGames.Quest.QuestSO"/> 的 <c>giverNpc</c> 字段直接引用此 SO避免手填字符串。
///
/// 资产路径Assets/_Game/Data/NPC/NPC_{npcId}.asset
/// </summary>
[CreateAssetMenu(menuName = "BaseGames/NPC/NPC")]
public class NpcSO : ScriptableObject
{
[Header("标识")]
[Tooltip("NPC 唯一 ID如 \"NPC_Elder\"。需与 InteractableNPC._npcId、QuestSO.giverNpcId 保持一致。")]
public string npcId;
[Header("显示")]
[Tooltip("本地化 Key如 \"NPC_Elder_Name\"。通过 LocalizationManager 解析为实际名称。")]
public string nameKey;
[Tooltip("NPC 头像用于地图、任务日志、DataHub 等 UI。")]
public Sprite portrait;
[Header("好感度")]
[Tooltip("该 NPC 的好感度上限0 = 无上限)。\n" +
"QuestManager.CompleteQuest 发放 affinityBonus 时,不超过此数值。\n" +
"UI 侧可用此值绘制好感度进度条满格。")]
[Min(0)] public int maxAffinity = 0;
[Header("交互提示")]
[Tooltip("与此 NPC 交互时显示的提示本地化 Key如 \"INTERACT_Talk\")。\n" +
"留空时 InteractableNPC 回退到内置字符串 \"对话\"。")]
public string interactPromptKey = "INTERACT_Talk";
#if UNITY_EDITOR
// npcId → 资产路径5 秒 TTL跨所有 NpcSO.OnValidate 共用O(1) 重复检测。
private static System.Collections.Generic.Dictionary<string, string> s_npcIdToPath;
private static double s_npcIdsCacheTime = -10.0;
private static System.Collections.Generic.Dictionary<string, string> GetNpcIdCache()
{
double now = UnityEditor.EditorApplication.timeSinceStartup;
if (s_npcIdToPath != null && now - s_npcIdsCacheTime < 5.0)
return s_npcIdToPath;
s_npcIdToPath = new System.Collections.Generic.Dictionary<string, string>(System.StringComparer.Ordinal);
string[] guids = UnityEditor.AssetDatabase.FindAssets("t:NpcSO");
foreach (var guid in guids)
{
var path = UnityEditor.AssetDatabase.GUIDToAssetPath(guid);
var npc = UnityEditor.AssetDatabase.LoadAssetAtPath<NpcSO>(path);
if (npc != null && !string.IsNullOrEmpty(npc.npcId) && !s_npcIdToPath.ContainsKey(npc.npcId))
s_npcIdToPath[npc.npcId] = path;
}
s_npcIdsCacheTime = now;
return s_npcIdToPath;
}
private void OnValidate()
{
if (string.IsNullOrWhiteSpace(npcId))
{
UnityEditor.EditorUtility.SetDirty(this);
npcId = name;
}
var cache = GetNpcIdCache();
string myPath = UnityEditor.AssetDatabase.GetAssetPath(this);
if (!string.IsNullOrEmpty(myPath) &&
cache.TryGetValue(npcId, out var existingPath) &&
existingPath != myPath)
{
Debug.LogError(
$"[NpcSO] npcId '{npcId}' 与 " +
$"'{System.IO.Path.GetFileNameWithoutExtension(existingPath)}' 重复!请修改其中一个。", this);
s_npcIdsCacheTime = -10.0;
}
}
#endif
}
}