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

@@ -1,4 +1,5 @@
using System.Collections;
using System.Collections.Generic;
using System.Text;
using TMPro;
using UnityEngine;
@@ -20,20 +21,61 @@ namespace BaseGames.Dialogue
[SerializeField] private GameObject _speakerNamePanel; // 无名称时隐藏整个名称框
[SerializeField] private GameObject _continuePrompt; // "▼" 图标,打字完成后显示
[SerializeField] private Image _speakerPortrait; // 角色头像框
[SerializeField] private Image _speakerNameBackground; // 说话人名称框背景,用于应用 accentColor可选
[SerializeField] private AudioSource _voiceSource; // 语音播放源(可不配置)
private Coroutine _typingCoroutine;
[Header("选项系统(可选)")]
[Tooltip("选项按钮的父节点容器。ShowChoices 通过对象池激活/停用按钮HideChoices 停用全部。\n留空则分支选项功能静默禁用。")]
[SerializeField] private Transform _choicesContainer;
[Tooltip("选项按钮预制体(需包含 Button 组件和 TMP_Text 子组件)。\n首次使用时预热 PoolInitialSize 个到对象池,后续零 GC。")]
[SerializeField] private GameObject _choiceButtonPrefab;
[Tooltip("选项按钮池初始大小。设为预期最大选项数,默认 8 覆盖绝大多数情况。")]
[SerializeField] [Range(2, 16)] private int _choicePoolSize = 8;
// 说话人名称框背景的默认色Awake 时记录,切换角色后可还原)
private Color _defaultNameBgColor = Color.white;
// 缓存名称框 RectTransform避免 ShowLine 每次调用 GetComponent零堆分配
private RectTransform _speakerNamePanelRT;
// 选项按钮对象池Awake 时按 _choicePoolSize 预热ShowChoices/HideChoices 零 GC
private readonly List<(GameObject go, Button btn, TMP_Text lbl)> _choicePool = new();
private Coroutine _typingCoroutine;
private DialogueLine _currentLine;
private const float DefaultTypewriterDelay = 0.03f;
// 缓存单个 WaitForSecondsRealtime,避免 TypeLine 每字符 new 分配。
// 当 delay 值改变时才重新创建(不同行可能有不同打字速度)。
// 缓存 WaitForSecondsRealtimedelay 值不变时直接复用,避免每行 new 分配。
private WaitForSecondsRealtime _cachedTypeDelay;
private float _cachedTypeDelayValue = -1f;
// 缓存 StringBuilder每行 Clear() 复用,避免每行 new StringBuilder(n) 的堆分配。
// 初始容量 256足以容纳绝大多数对话行超长时会自动扩容扩容极少发生
private readonly StringBuilder _typingSB = new(256);
/// <summary>当前是否仍在执行打字机效果。</summary>
public bool IsTyping { get; private set; }
private void Awake()
{
if (_speakerNameBackground != null)
_defaultNameBgColor = _speakerNameBackground.color;
if (_speakerNamePanel != null)
_speakerNamePanelRT = _speakerNamePanel.GetComponent<RectTransform>();
// 预热选项按钮对象池:在此时创建可避免首次对话时的 Instantiate 停顿
if (_choicesContainer != null && _choiceButtonPrefab != null)
{
for (int i = 0; i < _choicePoolSize; i++)
{
var go = Instantiate(_choiceButtonPrefab, _choicesContainer);
var btn = go.GetComponent<Button>();
var lbl = go.GetComponentInChildren<TMP_Text>();
go.SetActive(false);
_choicePool.Add((go, btn, lbl));
}
}
}
// ── 公开 API ──────────────────────────────────────────────────────
/// <summary>显示一行对话并开始打字机效果。</summary>
@@ -50,6 +92,13 @@ namespace BaseGames.Dialogue
if (hasSpeaker && _speakerNameText != null)
_speakerNameText.text = LocalizationManager.Get(resolvedNameKey, "Dialogue");
// 说话人名称框背景颜色accentColor有 actor 时着色,无 actor 时还原默认色
if (_speakerNameBackground != null)
_speakerNameBackground.color = hasSpeaker ? line.ResolvedAccentColor : _defaultNameBgColor;
// 排版方向玩家角色说话时名称框靠右NPC 靠左
SetLayoutSide(line.ResolvedIsPlayer);
// 头像actor 优先,回退到直接字段)
var resolvedPortrait = line.ResolvedPortrait;
if (_speakerPortrait != null)
@@ -85,7 +134,20 @@ namespace BaseGames.Dialogue
}
_voiceSource?.Stop();
if (_dialogueText != null)
_dialogueText.text = LocalizationManager.Get(_currentLine.textKey ?? "", "Dialogue");
{
string key = _currentLine.textKey;
if (string.IsNullOrEmpty(key))
{
#if UNITY_EDITOR || DEVELOPMENT_BUILD
Debug.LogWarning("[DialogueUI] 当前对话行 textKey 为空,跳过打字机后显示空文本。");
#endif
_dialogueText.text = "";
}
else
{
_dialogueText.text = LocalizationManager.Get(key, "Dialogue");
}
}
IsTyping = false;
if (_continuePrompt != null) _continuePrompt.SetActive(true);
}
@@ -93,9 +155,80 @@ namespace BaseGames.Dialogue
/// <summary>隐藏对话框面板。</summary>
public void Hide()
{
_voiceSource?.Stop();
if (_rootPanel != null) _rootPanel.SetActive(false);
}
// ── 布局辅助 ──────────────────────────────────────────────────────
/// <summary>
/// 切换名称框的横向对齐方向玩家说话时靠右NPC 靠左。
/// 修改 RectTransform 的 anchorMin.x / anchorMax.x / pivot.x保持纵向不变。
/// </summary>
private void SetLayoutSide(bool isPlayer)
{
if (_speakerNamePanelRT == null) return;
float x = isPlayer ? 1f : 0f;
_speakerNamePanelRT.anchorMin = new Vector2(x, _speakerNamePanelRT.anchorMin.y);
_speakerNamePanelRT.anchorMax = new Vector2(x, _speakerNamePanelRT.anchorMax.y);
_speakerNamePanelRT.pivot = new Vector2(x, _speakerNamePanelRT.pivot.y);
}
/// <summary>
/// 显示玩家可选的分支选项列表。打字机效果结束后由 DialogueManager 调用。
/// 使用对象池(零 GC池不足时动态扩容点击后回调 onSelected(index)。
/// 若 _choicesContainer 或 _choiceButtonPrefab 未配置,则静默跳过(不影响流程)。
/// </summary>
public void ShowChoices(DialogueChoice[] choices, System.Action<int> onSelected)
{
if (_choicesContainer == null || _choiceButtonPrefab == null) return;
// 确保池中有足够按钮
while (_choicePool.Count < choices.Length)
{
var go = Instantiate(_choiceButtonPrefab, _choicesContainer);
var btn = go.GetComponent<Button>();
var lbl = go.GetComponentInChildren<TMP_Text>();
go.SetActive(false);
_choicePool.Add((go, btn, lbl));
}
// 激活前 N 个并绑定数据
for (int i = 0; i < choices.Length; i++)
{
int captured = i;
var (go, btn, lbl) = _choicePool[i];
go.SetActive(true);
if (lbl != null)
lbl.text = LocalizationManager.Get(choices[i].textKey ?? "", "Dialogue");
if (btn != null)
{
btn.onClick.RemoveAllListeners();
btn.onClick.AddListener(() => onSelected?.Invoke(captured));
}
}
// 多余的池对象保持隐藏
for (int i = choices.Length; i < _choicePool.Count; i++)
_choicePool[i].go.SetActive(false);
// 有选项时隐藏继续提示,避免与选项按钮视觉重叠
if (_continuePrompt != null) _continuePrompt.SetActive(false);
_choicesContainer.gameObject.SetActive(true);
}
/// <summary>隐藏选项列表,将池中按钮全部停用(零 GC。</summary>
public void HideChoices()
{
if (_choicesContainer == null) return;
foreach (var (go, btn, _) in _choicePool)
{
if (btn != null) btn.onClick.RemoveAllListeners();
go.SetActive(false);
}
_choicesContainer.gameObject.SetActive(false);
}
// ── 内部协程 ──────────────────────────────────────────────────────
private IEnumerator TypeLine(DialogueLine line)
@@ -110,16 +243,27 @@ namespace BaseGames.Dialogue
_cachedTypeDelayValue = delay;
}
string text = LocalizationManager.Get(line.textKey ?? "", "Dialogue");
string text;
if (string.IsNullOrEmpty(line.textKey))
{
#if UNITY_EDITOR || DEVELOPMENT_BUILD
Debug.LogWarning("[DialogueUI] 对话行 textKey 为空,打字机将显示空文本。请检查 DialogueSequenceSO 配置。");
#endif
text = "";
}
else
{
text = LocalizationManager.Get(line.textKey, "Dialogue");
}
// 性能:StringBuilder 避免每帧字符串分配O(n²) → O(n)
var sb = new StringBuilder(text.Length);
// 复用缓存 StringBuilder避免每行 new 分配TMP SetText(StringBuilder) 零分配
_typingSB.Clear();
if (_dialogueText != null) _dialogueText.text = "";
foreach (char c in text)
{
sb.Append(c);
if (_dialogueText != null) _dialogueText.SetText(sb); // TMP SetText(StringBuilder) 零分配
_typingSB.Append(c);
if (_dialogueText != null) _dialogueText.SetText(_typingSB);
yield return _cachedTypeDelay;
}