276 lines
12 KiB
C#
276 lines
12 KiB
C#
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;
|
||
|
||
// 缓存 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>
|
||
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;
|
||
}
|
||
}
|
||
}
|