541 lines
19 KiB
C#
541 lines
19 KiB
C#
using System;
|
||
using System.Collections;
|
||
using TMPro;
|
||
using UnityEngine;
|
||
using UnityEngine.UI;
|
||
using Febucci.UI;
|
||
using Febucci.UI.Core;
|
||
|
||
namespace AibisDream.UI
|
||
{
|
||
/// <summary>
|
||
/// Terminal风格的TMP_Text组件,自动滚动到底部并遮罩超出内容
|
||
/// </summary>
|
||
[RequireComponent(typeof(RectTransform))]
|
||
public class TerminalText : MonoBehaviour
|
||
{
|
||
#region 字段
|
||
|
||
[SerializeField] private TMP_Text tmpText;
|
||
[SerializeField] private RectTransform viewportRectTransform;
|
||
[SerializeField] private TextAnimator_TMP textAnimator;
|
||
[SerializeField] private TypewriterCore typewriter;
|
||
|
||
private RectTransform rectTransform;
|
||
private RectTransform tmpTextRectTransform;
|
||
private Coroutine autoScrollCoroutine;
|
||
private float lastContentHeight = 0f;
|
||
|
||
public event Action OnTextShown;
|
||
|
||
#endregion
|
||
|
||
#region Unity生命周期
|
||
|
||
private void Awake()
|
||
{
|
||
InitializeComponentRefs();
|
||
ValidateMaskComponent();
|
||
}
|
||
|
||
private void OnRectTransformDimensionsChange()
|
||
{
|
||
if (tmpText != null && rectTransform != null)
|
||
{
|
||
// Viewport尺寸变化时,重新调整位置
|
||
UpdateBounds();
|
||
}
|
||
}
|
||
|
||
private void OnDestroy()
|
||
{
|
||
// 取消事件注册
|
||
if (typewriter != null)
|
||
{
|
||
typewriter.onTextShowed.RemoveListener(OnTextAnimationComplete);
|
||
typewriter.onCharacterVisible.RemoveListener(OnCharacterVisible);
|
||
}
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region 初始化
|
||
|
||
private void InitializeComponentRefs()
|
||
{
|
||
// 获取Viewport的RectTransform
|
||
rectTransform = viewportRectTransform != null ? viewportRectTransform : transform as RectTransform;
|
||
|
||
// 如果没有指定TMP_Text,尝试从子对象获取
|
||
if (tmpText == null)
|
||
{
|
||
tmpText = GetComponentInChildren<TMP_Text>();
|
||
}
|
||
|
||
if (tmpText != null)
|
||
{
|
||
tmpTextRectTransform = tmpText.rectTransform;
|
||
// 初始化内容高度(使用GetWorldCorners方法)
|
||
tmpText.ForceMeshUpdate();
|
||
Vector3[] contentCorners = new Vector3[4];
|
||
tmpTextRectTransform.GetWorldCorners(contentCorners);
|
||
Matrix4x4 viewWorldToLocalMatrix = rectTransform.worldToLocalMatrix;
|
||
Vector3 vMin = new Vector3(float.MaxValue, float.MaxValue, float.MaxValue);
|
||
Vector3 vMax = new Vector3(float.MinValue, float.MinValue, float.MinValue);
|
||
|
||
for (int j = 0; j < 4; j++)
|
||
{
|
||
Vector3 v = viewWorldToLocalMatrix.MultiplyPoint3x4(contentCorners[j]);
|
||
vMin = Vector3.Min(v, vMin);
|
||
vMax = Vector3.Max(v, vMax);
|
||
}
|
||
|
||
Bounds contentBounds = new Bounds(vMin, Vector3.zero);
|
||
contentBounds.Encapsulate(vMax);
|
||
lastContentHeight = contentBounds.size.y;
|
||
}
|
||
else
|
||
{
|
||
Debug.LogError("TerminalText: 未找到TMP_Text组件!请在Inspector中指定或确保子对象中有TMP_Text组件。");
|
||
}
|
||
|
||
// 如果没有指定TextAnimator,尝试从TMP_Text的GameObject获取
|
||
if (textAnimator == null && tmpText != null)
|
||
{
|
||
textAnimator = tmpText.GetComponent<TextAnimator_TMP>();
|
||
}
|
||
|
||
// 如果没有指定Typewriter,尝试从TMP_Text的GameObject获取
|
||
if (typewriter == null && tmpText != null)
|
||
{
|
||
typewriter = tmpText.GetComponent<TypewriterCore>();
|
||
}
|
||
|
||
// 如果找到了Typewriter,注册事件
|
||
if (typewriter != null)
|
||
{
|
||
typewriter.onTextShowed.AddListener(OnTextAnimationComplete);
|
||
// 监听字符显示事件,在每次显示新字符时更新滚动位置
|
||
typewriter.onCharacterVisible.AddListener(OnCharacterVisible);
|
||
}
|
||
}
|
||
|
||
private void ValidateMaskComponent()
|
||
{
|
||
Mask mask = GetComponent<Mask>();
|
||
RectMask2D rectMask = GetComponent<RectMask2D>();
|
||
|
||
if (mask == null && rectMask == null)
|
||
{
|
||
Debug.LogWarning("TerminalText: Viewport上未找到Mask或RectMask2D组件,超出内容不会被遮罩!");
|
||
}
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region 公共方法
|
||
|
||
/// <summary>
|
||
/// 在注册新台词完成回调前停止旧动画,并丢弃可能迟到的旧回调。
|
||
/// 保留终端中已经显示的历史文本。
|
||
/// </summary>
|
||
public void PrepareForLine()
|
||
{
|
||
if (typewriter != null)
|
||
{
|
||
typewriter.StopShowingText();
|
||
}
|
||
|
||
OnTextShown = null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 添加新文本(追加到现有文本)
|
||
/// </summary>
|
||
public void AddText(string text)
|
||
{
|
||
if (string.IsNullOrEmpty(text))
|
||
text = " ";
|
||
|
||
// 如果使用Typewriter和TextAnimator,使用ShowText方法追加文本并播放动画
|
||
if (typewriter != null && textAnimator != null)
|
||
{
|
||
// 获取当前完整文本
|
||
string currentText = textAnimator.textFull;
|
||
|
||
// 添加换行符(如果不是第一行)
|
||
string textToAppend = string.IsNullOrEmpty(currentText)
|
||
? text
|
||
: "\n" + text;
|
||
|
||
// 记录当前已显示的字符数
|
||
int previousVisibleCount = textAnimator.maxVisibleCharacters;
|
||
|
||
if (previousVisibleCount > 0)
|
||
{
|
||
// 已经有文本显示,使用AppendText追加新文本
|
||
// AppendText会保留已显示文本的可见性,并正确初始化新字符
|
||
textAnimator.AppendText(textToAppend, hideText: true);
|
||
|
||
// AppendText最后会设置maxVisibleCharacters = CharactersCount(所有字符可见)
|
||
// 我们需要重新设置maxVisibleCharacters,保留之前已显示的字符,新追加的文本暂时隐藏
|
||
// 这样Typewriter就可以继续显示新文本了
|
||
textAnimator.firstVisibleCharacter = 0;
|
||
textAnimator.maxVisibleCharacters = previousVisibleCount;
|
||
|
||
// 等待一帧,确保AppendText的更新完成,然后再启动Typewriter
|
||
StartCoroutine(DelayedStartTypewriter());
|
||
}
|
||
else
|
||
{
|
||
// 如果是第一次添加文本,使用ShowText自动启动Typewriter
|
||
typewriter.ShowText(text);
|
||
}
|
||
}
|
||
else if (textAnimator != null)
|
||
{
|
||
// 只有TextAnimator,没有Typewriter
|
||
// 添加换行符(如果不是第一行)
|
||
string textToAppend = string.IsNullOrEmpty(textAnimator.textWithoutTextAnimTags)
|
||
? text
|
||
: "\n" + text;
|
||
|
||
// 使用TextAnimator的AppendText方法追加文本
|
||
textAnimator.AppendText(textToAppend, hideText: false);
|
||
|
||
// 立即更新布局和滚动
|
||
tmpText.ForceMeshUpdate();
|
||
StartCoroutine(DelayedAutoScroll());
|
||
}
|
||
else
|
||
{
|
||
// 没有TextAnimator时,使用原来的方式
|
||
if (tmpText == null)
|
||
return;
|
||
|
||
if (string.IsNullOrEmpty(tmpText.text))
|
||
{
|
||
tmpText.text = text;
|
||
}
|
||
else
|
||
{
|
||
tmpText.text += "\n" + text; // 添加换行符分隔
|
||
}
|
||
|
||
// 强制更新布局以获取最新边界
|
||
tmpText.ForceMeshUpdate();
|
||
|
||
// 自动滚动到底部(显示最新内容)
|
||
AutoScrollToBottom();
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 设置正文文本颜色
|
||
/// </summary>
|
||
public void SetTextColor(Color color)
|
||
{
|
||
if (tmpText != null)
|
||
{
|
||
tmpText.color = color;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 设置Typewriter速度
|
||
/// </summary>
|
||
public void SetTypewriterSpeed(float speed)
|
||
{
|
||
if (typewriter != null)
|
||
{
|
||
typewriter.resetTypingSpeedAtStartup = false;
|
||
typewriter.SetTypewriterSpeed(speed);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 文本动画完成回调
|
||
/// </summary>
|
||
private void OnTextAnimationComplete()
|
||
{
|
||
// 文本动画完成后,更新布局并滚动到底部
|
||
if (tmpText != null)
|
||
{
|
||
tmpText.ForceMeshUpdate();
|
||
StartCoroutine(DelayedAutoScroll());
|
||
// 并触发文本显示事件
|
||
OnTextShown?.Invoke();
|
||
OnTextShown = null;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 字符显示事件回调(每次显示新字符时调用)
|
||
/// </summary>
|
||
private void OnCharacterVisible(char character)
|
||
{
|
||
// 每次显示新字符时,延迟更新滚动位置
|
||
// 这样可以确保在文本增长过程中持续滚动到底部
|
||
// 使用协程延迟一帧,确保布局已更新
|
||
// 如果已经有协程在运行,先停止它
|
||
if (autoScrollCoroutine != null)
|
||
{
|
||
StopCoroutine(autoScrollCoroutine);
|
||
}
|
||
autoScrollCoroutine = StartCoroutine(DelayedAutoScroll());
|
||
}
|
||
|
||
/// <summary>
|
||
/// 在LateUpdate中持续检查文本高度变化并更新滚动位置
|
||
/// </summary>
|
||
private void LateUpdate()
|
||
{
|
||
if (tmpText == null || rectTransform == null || tmpTextRectTransform == null)
|
||
return;
|
||
|
||
// 如果使用Typewriter,在动画过程中持续检查
|
||
if (typewriter != null && typewriter.isShowingText)
|
||
{
|
||
tmpText.ForceMeshUpdate();
|
||
|
||
// 使用GetWorldCorners计算实际内容高度
|
||
Vector3[] contentCorners = new Vector3[4];
|
||
tmpTextRectTransform.GetWorldCorners(contentCorners);
|
||
Matrix4x4 viewWorldToLocalMatrix = rectTransform.worldToLocalMatrix;
|
||
Vector3 vMin = new Vector3(float.MaxValue, float.MaxValue, float.MaxValue);
|
||
Vector3 vMax = new Vector3(float.MinValue, float.MinValue, float.MinValue);
|
||
|
||
for (int j = 0; j < 4; j++)
|
||
{
|
||
Vector3 v = viewWorldToLocalMatrix.MultiplyPoint3x4(contentCorners[j]);
|
||
vMin = Vector3.Min(v, vMin);
|
||
vMax = Vector3.Max(v, vMax);
|
||
}
|
||
|
||
Bounds contentBounds = new Bounds(vMin, Vector3.zero);
|
||
contentBounds.Encapsulate(vMax);
|
||
float currentContentHeight = contentBounds.size.y;
|
||
|
||
// 如果内容高度发生变化,更新滚动位置
|
||
if (Mathf.Abs(currentContentHeight - lastContentHeight) > 0.01f)
|
||
{
|
||
lastContentHeight = currentContentHeight;
|
||
AutoScrollToBottom();
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 延迟一帧后自动滚动(确保布局已更新)
|
||
/// </summary>
|
||
private IEnumerator DelayedAutoScroll()
|
||
{
|
||
yield return null;
|
||
AutoScrollToBottom();
|
||
autoScrollCoroutine = null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 延迟启动Typewriter(确保AppendText的更新完成)
|
||
/// </summary>
|
||
private IEnumerator DelayedStartTypewriter()
|
||
{
|
||
yield return null;
|
||
if (typewriter != null && textAnimator != null)
|
||
{
|
||
// 临时设置maxVisibleCharacters为CharactersCount,让Typewriter能显示所有字符
|
||
// Typewriter的ShowTextRoutine会跳过已可见的字符,所以只会显示新追加的部分
|
||
int savedMaxVisible = textAnimator.maxVisibleCharacters;
|
||
textAnimator.maxVisibleCharacters = textAnimator.CharactersCount;
|
||
|
||
// 手动启动Typewriter继续显示新文本
|
||
typewriter.StartShowingText(false);
|
||
|
||
// 注意:Typewriter会在显示过程中更新maxVisibleCharacters,所以不需要恢复
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 清空所有文本并重置位置
|
||
/// </summary>
|
||
public void Clear()
|
||
{
|
||
// 先停止可能正在运行的Typewriter动画
|
||
if (typewriter != null)
|
||
{
|
||
typewriter.StopShowingText();
|
||
}
|
||
|
||
// 打字机被中途打断时补发一次"展示完成"信号,避免监听方(如 LineSyncToken)
|
||
// 永远停留在 PlayText 状态;与 AddText 完成路径保持一致。
|
||
OnTextShown?.Invoke();
|
||
OnTextShown = null;
|
||
|
||
// 同时清除TextAnimator和底层TMP_Text
|
||
if (textAnimator != null)
|
||
{
|
||
textAnimator.SetText(string.Empty);
|
||
}
|
||
|
||
if (tmpText != null)
|
||
{
|
||
tmpText.text = string.Empty;
|
||
tmpText.ForceMeshUpdate();
|
||
}
|
||
|
||
// 重置TextAnimator的显示状态
|
||
if (textAnimator != null)
|
||
{
|
||
textAnimator.firstVisibleCharacter = 0;
|
||
textAnimator.maxVisibleCharacters = 0;
|
||
}
|
||
|
||
// 重置位置到顶部
|
||
if (tmpTextRectTransform != null)
|
||
{
|
||
Vector2 pos = tmpTextRectTransform.anchoredPosition;
|
||
pos.y = 0;
|
||
tmpTextRectTransform.anchoredPosition = pos;
|
||
}
|
||
|
||
lastContentHeight = 0f;
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region 内部方法
|
||
|
||
/// <summary>
|
||
/// 更新边界信息(类似ScrollRect)
|
||
/// </summary>
|
||
private void UpdateBounds()
|
||
{
|
||
if (tmpText == null || rectTransform == null)
|
||
return;
|
||
|
||
// 强制更新布局以获取最新边界
|
||
tmpText.ForceMeshUpdate();
|
||
|
||
// 获取Viewport和Content的实际大小
|
||
float viewportHeight = rectTransform.rect.height;
|
||
float contentHeight = tmpText.bounds.size.y;
|
||
|
||
// 如果内容超出Viewport,调整位置显示底部
|
||
if (contentHeight > viewportHeight)
|
||
{
|
||
AutoScrollToBottom();
|
||
}
|
||
else
|
||
{
|
||
// 内容未满,重置到顶部
|
||
if (tmpTextRectTransform != null)
|
||
{
|
||
Vector2 pos = tmpTextRectTransform.anchoredPosition;
|
||
pos.y = 0;
|
||
tmpTextRectTransform.anchoredPosition = pos;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 自动滚动到底部(核心实现)
|
||
/// 使用类似ScrollRect的方法计算bounds和位置
|
||
/// </summary>
|
||
private void AutoScrollToBottom()
|
||
{
|
||
if (tmpText == null || rectTransform == null || tmpTextRectTransform == null)
|
||
return;
|
||
|
||
// 确保布局已更新
|
||
tmpText.ForceMeshUpdate();
|
||
|
||
// 使用GetWorldCorners获取content的实际bounds(类似ScrollRect的GetBounds方法)
|
||
Vector3[] contentCorners = new Vector3[4];
|
||
tmpTextRectTransform.GetWorldCorners(contentCorners);
|
||
|
||
// 将content的world corners转换到viewport的本地空间
|
||
Matrix4x4 viewWorldToLocalMatrix = rectTransform.worldToLocalMatrix;
|
||
Vector3 vMin = new Vector3(float.MaxValue, float.MaxValue, float.MaxValue);
|
||
Vector3 vMax = new Vector3(float.MinValue, float.MinValue, float.MinValue);
|
||
|
||
for (int j = 0; j < 4; j++)
|
||
{
|
||
Vector3 v = viewWorldToLocalMatrix.MultiplyPoint3x4(contentCorners[j]);
|
||
vMin = Vector3.Min(v, vMin);
|
||
vMax = Vector3.Max(v, vMax);
|
||
}
|
||
|
||
Bounds contentBounds = new Bounds(vMin, Vector3.zero);
|
||
contentBounds.Encapsulate(vMax);
|
||
|
||
// 获取Viewport的bounds
|
||
Bounds viewBounds = new Bounds(rectTransform.rect.center, rectTransform.rect.size);
|
||
|
||
// 计算内容高度和Viewport高度
|
||
float contentHeight = contentBounds.size.y;
|
||
float viewportHeight = viewBounds.size.y;
|
||
|
||
// 如果内容高度超过Viewport,需要调整位置
|
||
if (contentHeight > viewportHeight)
|
||
{
|
||
// 计算偏移量:让内容底部对齐到Viewport底部
|
||
// contentBounds.min.y是内容底部在viewport本地空间的位置
|
||
// viewBounds.min.y是viewport底部的位置(通常是负值)
|
||
// 我们需要让contentBounds.min.y = viewBounds.min.y
|
||
float currentBottom = contentBounds.min.y;
|
||
float targetBottom = viewBounds.min.y;
|
||
float offsetY = tmpTextRectTransform.anchoredPosition.y + (targetBottom - currentBottom);
|
||
|
||
Vector2 pos = tmpTextRectTransform.anchoredPosition;
|
||
pos.y = offsetY;
|
||
tmpTextRectTransform.anchoredPosition = pos;
|
||
}
|
||
else
|
||
{
|
||
// 内容未满,重置到顶部
|
||
// 让内容顶部对齐到viewport顶部
|
||
float currentTop = contentBounds.max.y;
|
||
float targetTop = viewBounds.max.y;
|
||
float offsetY = tmpTextRectTransform.anchoredPosition.y + (targetTop - currentTop);
|
||
|
||
Vector2 pos = tmpTextRectTransform.anchoredPosition;
|
||
pos.y = offsetY;
|
||
tmpTextRectTransform.anchoredPosition = pos;
|
||
}
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region Editor支持
|
||
|
||
#if UNITY_EDITOR
|
||
private void OnValidate()
|
||
{
|
||
// 在编辑器中验证组件引用
|
||
if (tmpText == null)
|
||
{
|
||
tmpText = GetComponentInChildren<TMP_Text>();
|
||
}
|
||
|
||
if (viewportRectTransform == null)
|
||
{
|
||
viewportRectTransform = transform as RectTransform;
|
||
}
|
||
|
||
// 自动查找TextAnimator和Typewriter
|
||
if (textAnimator == null && tmpText != null)
|
||
{
|
||
textAnimator = tmpText.GetComponent<TextAnimator_TMP>();
|
||
}
|
||
|
||
if (typewriter == null && tmpText != null)
|
||
{
|
||
typewriter = tmpText.GetComponent<TypewriterCore>();
|
||
}
|
||
}
|
||
#endif
|
||
|
||
#endregion
|
||
}
|
||
}
|