更新
This commit is contained in:
@@ -0,0 +1,505 @@
|
||||
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组件,超出内容不会被遮罩!");
|
||||
}
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (Input.GetKeyDown(KeyCode.K))
|
||||
{
|
||||
AddText("Hello, World!");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 公共方法
|
||||
|
||||
/// <summary>
|
||||
/// 添加新文本(追加到现有文本)
|
||||
/// </summary>
|
||||
public void AddText(string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
return;
|
||||
|
||||
// 如果使用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>
|
||||
/// 设置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()
|
||||
{
|
||||
// 如果使用TextAnimator,使用SetText方法
|
||||
if (textAnimator != null)
|
||||
{
|
||||
textAnimator.SetText(string.Empty);
|
||||
}
|
||||
else if (tmpText != null)
|
||||
{
|
||||
tmpText.text = string.Empty;
|
||||
}
|
||||
|
||||
// 重置位置到顶部
|
||||
if (tmpTextRectTransform != null)
|
||||
{
|
||||
Vector2 pos = tmpTextRectTransform.anchoredPosition;
|
||||
pos.y = 0;
|
||||
tmpTextRectTransform.anchoredPosition = pos;
|
||||
}
|
||||
|
||||
if (tmpText != null)
|
||||
{
|
||||
tmpText.ForceMeshUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
#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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f60fdec8f05a9af4b9e405d676855a06
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user