Files
aibis-dream/Assets/Scripts/UI/Components/TerminalText.cs
T
2026-07-22 00:07:54 +08:00

540 lines
19 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;
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.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
}
}