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