Files
zeling_v2/Assets/_Game/Scripts/Dialogue/DialogueUI.cs
2026-05-25 11:54:37 +08:00

276 lines
12 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Collections;
using System.Collections.Generic;
using System.Text;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using BaseGames.Localization;
namespace BaseGames.Dialogue
{
/// <summary>
/// 对话框 UI 组件(架构 14_NarrativeModule §5
/// 挂载在 Canvas_Overlay 下的 DialogueBox 子对象。
/// 负责打字机效果、头像显示、继续提示。
/// </summary>
public class DialogueUI : MonoBehaviour
{
[SerializeField] private GameObject _rootPanel;
[SerializeField] private TMP_Text _speakerNameText;
[SerializeField] private TMP_Text _dialogueText;
[SerializeField] private GameObject _speakerNamePanel; // 无名称时隐藏整个名称框
[SerializeField] private GameObject _continuePrompt; // "▼" 图标,打字完成后显示
[SerializeField] private Image _speakerPortrait; // 角色头像框
[SerializeField] private Image _speakerNameBackground; // 说话人名称框背景,用于应用 accentColor可选
[SerializeField] private AudioSource _voiceSource; // 语音播放源(可不配置)
[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;
// 缓存 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>
public void ShowLine(DialogueLine line)
{
_currentLine = line;
if (_rootPanel != null) _rootPanel.SetActive(true);
if (_continuePrompt != null) _continuePrompt.SetActive(false);
// 说话人名称actor 优先,回退到直接字段)
string resolvedNameKey = line.ResolvedNameKey;
bool hasSpeaker = !string.IsNullOrEmpty(resolvedNameKey);
if (_speakerNamePanel != null) _speakerNamePanel.SetActive(hasSpeaker);
if (hasSpeaker && _speakerNameText != null)
_speakerNameText.text = LocalizationManager.Get(resolvedNameKey, LocalizationTable.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)
{
_speakerPortrait.gameObject.SetActive(resolvedPortrait != null);
if (resolvedPortrait != null) _speakerPortrait.sprite = resolvedPortrait;
}
// 语音播放
if (_voiceSource != null)
{
_voiceSource.Stop();
if (line.voiceClip != null)
{
_voiceSource.clip = line.voiceClip;
_voiceSource.Play();
}
}
// 开始打字机协程
if (_typingCoroutine != null) StopCoroutine(_typingCoroutine);
_typingCoroutine = StartCoroutine(TypeLine(line));
}
/// <summary>立即显示全部文字(跳过打字机效果)。</summary>
public void SkipTyping()
{
if (!IsTyping) return;
if (_typingCoroutine != null)
{
StopCoroutine(_typingCoroutine);
_typingCoroutine = null;
}
_voiceSource?.Stop();
if (_dialogueText != null)
{
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, LocalizationTable.Dialogue);
}
}
IsTyping = false;
if (_continuePrompt != null) _continuePrompt.SetActive(true);
}
/// <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 ?? "", LocalizationTable.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)
{
IsTyping = true;
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;
if (string.IsNullOrEmpty(line.textKey))
{
#if UNITY_EDITOR || DEVELOPMENT_BUILD
Debug.LogWarning("[DialogueUI] 对话行 textKey 为空,打字机将显示空文本。请检查 DialogueSequenceSO 配置。");
#endif
text = "";
}
else
{
text = LocalizationManager.Get(line.textKey, LocalizationTable.Dialogue);
}
// 复用缓存 StringBuilder避免每行 new 分配TMP SetText(StringBuilder) 零分配
_typingSB.Clear();
if (_dialogueText != null) _dialogueText.text = "";
foreach (char c in text)
{
_typingSB.Append(c);
if (_dialogueText != null) _dialogueText.SetText(_typingSB);
yield return _cachedTypeDelay;
}
IsTyping = false;
if (_continuePrompt != null) _continuePrompt.SetActive(true);
_typingCoroutine = null;
}
}
}