495 lines
17 KiB
C#
495 lines
17 KiB
C#
using UnityEngine;
|
||
using TMPro;
|
||
using DG.Tweening;
|
||
using System.Collections.Generic;
|
||
|
||
namespace AibisDream.MiniGame.Language
|
||
{
|
||
/// <summary>
|
||
/// 文字粒子基类
|
||
/// </summary>
|
||
public class TextParticle : MonoBehaviour
|
||
{
|
||
[Header("组件")]
|
||
public TextMeshPro textMesh;
|
||
|
||
[Header("属性")]
|
||
public Vector2 velocity;
|
||
public float alpha;
|
||
public float baseAlpha = 1f;
|
||
public float size = 4f;
|
||
[SerializeField]
|
||
[UnityEngine.Serialization.FormerlySerializedAs("currentChar")]
|
||
private string currentUnitText;
|
||
public string CurrentUnitText => currentUnitText;
|
||
|
||
[System.Obsolete("Use CurrentUnitText instead.")]
|
||
public string currentChar
|
||
{
|
||
get => currentUnitText;
|
||
set
|
||
{
|
||
currentUnitText = value;
|
||
UpdateText();
|
||
}
|
||
}
|
||
|
||
[Header("状态")]
|
||
public bool isStatic = false;
|
||
public bool isCalmed = false;
|
||
public float ChangeInterval => changeInterval;
|
||
|
||
protected float changeTimer;
|
||
protected float changeInterval = 1f;
|
||
private static readonly IReadOnlyList<string> EmptyCharacterPool =
|
||
new List<string>().AsReadOnly();
|
||
protected IReadOnlyList<string> defaultUnitPool = EmptyCharacterPool;
|
||
protected IReadOnlyList<string> interferenceUnitPool = EmptyCharacterPool;
|
||
protected IReadOnlyList<string> overrideUnitPool = EmptyCharacterPool;
|
||
protected Bounds movementBounds;
|
||
protected bool useCircularBounds = false;
|
||
protected Vector3 boundsCenter;
|
||
protected float boundsRadius;
|
||
protected TMP_FontAsset customFont;
|
||
private Material customFontMaterial;
|
||
private TMPRectClipper screenClipper;
|
||
private Vector2 preferredLocalSize;
|
||
private bool preferredSizeDirty = true;
|
||
|
||
protected virtual void Awake()
|
||
{
|
||
if (textMesh == null)
|
||
textMesh = GetComponentInChildren<TextMeshPro>();
|
||
|
||
if (textMesh == null)
|
||
{
|
||
var textObj = new GameObject("Text");
|
||
textObj.transform.SetParent(transform, false);
|
||
textObj.layer = gameObject.layer; // 继承父对象的 layer(SetParent 后仍强制)
|
||
|
||
// 添加 RectTransform(在 Canvas 下必需)
|
||
RectTransform rectTransform = textObj.AddComponent<RectTransform>();
|
||
rectTransform.localPosition = Vector3.zero;
|
||
rectTransform.localScale = Vector3.one;
|
||
rectTransform.sizeDelta = new Vector2(10, 10); // 设置足够大的尺寸
|
||
rectTransform.anchorMin = new Vector2(0.5f, 0.5f);
|
||
rectTransform.anchorMax = new Vector2(0.5f, 0.5f);
|
||
rectTransform.pivot = new Vector2(0.5f, 0.5f);
|
||
|
||
// 添加 TextMeshPro 组件
|
||
textMesh = textObj.AddComponent<TextMeshPro>();
|
||
textMesh.alignment = TextAlignmentOptions.Center;
|
||
textMesh.fontSize = size;
|
||
textMesh.enableAutoSizing = false;
|
||
textMesh.overflowMode = TextOverflowModes.Overflow; // 允许溢出,不裁剪
|
||
|
||
// 设置字体(优先使用自定义字体)
|
||
SetupFont();
|
||
|
||
// 设置渲染层级,确保文字显示在正确的层
|
||
textMesh.sortingOrder = 10;
|
||
}
|
||
else
|
||
{
|
||
// 如果已存在 textMesh,也要确保设置字体
|
||
SetupFont();
|
||
}
|
||
|
||
SyncLayerToChildren();
|
||
|
||
changeTimer = Random.Range(0.5f, 1.5f);
|
||
currentUnitText = string.Empty;
|
||
UpdateText();
|
||
// TMP 重建 mesh / SubMesh 后再对齐一次 layer
|
||
SyncLayerToChildren();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 将自身与所有子物体(Text / Glow / TMP SubMesh)设为同一 Layer。
|
||
/// </summary>
|
||
public void SyncLayerToChildren()
|
||
{
|
||
int layer = gameObject.layer;
|
||
var transforms = GetComponentsInChildren<Transform>(true);
|
||
for (int i = 0; i < transforms.Length; i++)
|
||
transforms[i].gameObject.layer = layer;
|
||
}
|
||
|
||
protected virtual void Update()
|
||
{
|
||
if (!isStatic && !isCalmed)
|
||
{
|
||
// 更新位置
|
||
transform.position += (Vector3)velocity * Time.deltaTime;
|
||
|
||
// 边界检测
|
||
CheckBounds();
|
||
|
||
// 字符变化:目标粒子用本轮默认池,非目标粒子从干扰短句字符池中随机
|
||
changeTimer -= Time.deltaTime;
|
||
if (changeTimer <= 0)
|
||
{
|
||
currentUnitText = GetNextDisplayUnit();
|
||
UpdateText();
|
||
changeTimer = changeInterval;
|
||
}
|
||
|
||
// 速度衰减
|
||
velocity *= 0.95f;
|
||
}
|
||
|
||
// 更新视觉效果
|
||
UpdateVisuals();
|
||
}
|
||
|
||
public void SetMovementBounds(Bounds bounds)
|
||
{
|
||
movementBounds = bounds;
|
||
useCircularBounds = false;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Registers the shared television-screen rect. Each particle owns a separate TMP
|
||
/// material instance, so moving one particle never changes another particle's clip.
|
||
/// </summary>
|
||
public void SetScreenClipRect(RectTransform rect)
|
||
{
|
||
if (textMesh == null)
|
||
return;
|
||
|
||
if (screenClipper == null)
|
||
screenClipper = textMesh.GetComponent<TMPRectClipper>() ?? textMesh.gameObject.AddComponent<TMPRectClipper>();
|
||
screenClipper.Initialize(textMesh, rect);
|
||
}
|
||
|
||
public void ClearScreenClip()
|
||
{
|
||
if (screenClipper != null)
|
||
screenClipper.ReleaseMaterial();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 设置圆形运动边界(与 SetMovementBounds 二选一,圆形模式下会覆盖矩形边界)
|
||
/// </summary>
|
||
public void SetCircularBounds(Vector3 center, float radius)
|
||
{
|
||
boundsCenter = center;
|
||
boundsRadius = radius;
|
||
useCircularBounds = true;
|
||
}
|
||
|
||
protected void CheckBounds()
|
||
{
|
||
Vector3 pos = transform.position;
|
||
bool bounced = false;
|
||
|
||
if (useCircularBounds)
|
||
{
|
||
Bounds visualBounds = GetVisualWorldBounds();
|
||
float visualRadius = Mathf.Max(
|
||
visualBounds.extents.x,
|
||
visualBounds.extents.y);
|
||
float allowedRadius = Mathf.Max(0f, boundsRadius - visualRadius);
|
||
Vector2 visualCenter = visualBounds.center;
|
||
Vector2 offset = visualCenter - (Vector2)boundsCenter;
|
||
float dist = offset.magnitude;
|
||
if (dist > allowedRadius && boundsRadius > 0.0001f)
|
||
{
|
||
Vector2 norm = offset / dist;
|
||
Vector2 correction =
|
||
(Vector2)boundsCenter + norm * allowedRadius - visualCenter;
|
||
pos += (Vector3)correction;
|
||
// 反弹速度(沿径向反射)
|
||
Vector2 vel = velocity;
|
||
Vector2 reflect = vel - 2f * Vector2.Dot(vel, norm) * norm;
|
||
velocity = reflect * 0.8f;
|
||
bounced = true;
|
||
}
|
||
}
|
||
else
|
||
{
|
||
Bounds visualBounds = GetVisualWorldBounds();
|
||
if (visualBounds.min.x < movementBounds.min.x ||
|
||
visualBounds.max.x > movementBounds.max.x)
|
||
{
|
||
velocity.x *= -1;
|
||
if (visualBounds.min.x < movementBounds.min.x)
|
||
pos.x += movementBounds.min.x - visualBounds.min.x;
|
||
else
|
||
pos.x -= visualBounds.max.x - movementBounds.max.x;
|
||
bounced = true;
|
||
}
|
||
|
||
if (visualBounds.min.y < movementBounds.min.y ||
|
||
visualBounds.max.y > movementBounds.max.y)
|
||
{
|
||
velocity.y *= -1;
|
||
if (visualBounds.min.y < movementBounds.min.y)
|
||
pos.y += movementBounds.min.y - visualBounds.min.y;
|
||
else
|
||
pos.y -= visualBounds.max.y - movementBounds.max.y;
|
||
bounced = true;
|
||
}
|
||
}
|
||
|
||
if (bounced)
|
||
transform.position = pos;
|
||
}
|
||
|
||
protected string GetRandomChar()
|
||
{
|
||
return GetRandomPoolElement(defaultUnitPool);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 从干扰短句(笑话等)的字符中随机取一个字(每次只显示一个字符)。
|
||
/// 如 "哈哈|嘿嘿|呵呵" 会从 哈、嘿、呵 中随机。
|
||
/// </summary>
|
||
protected string GetRandomCharFromPhrases()
|
||
{
|
||
if (interferenceUnitPool == null || interferenceUnitPool.Count == 0)
|
||
return GetRandomChar();
|
||
return GetRandomPoolElement(interferenceUnitPool);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 获取下一次要显示的字符。子类可重写以区分目标粒子与非目标粒子(与红蓝颜色解绑)。
|
||
/// 默认从本轮本地化字符池随机返回一个 Unicode 文本元素。
|
||
/// </summary>
|
||
protected virtual string GetNextDisplayUnit()
|
||
{
|
||
if (TryGetOverridePoolChar(out string overrideChar))
|
||
return overrideChar;
|
||
return GetRandomChar();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 设置换字字符池覆盖(老虎机演出用);传空/null 清除,恢复默认字符池。
|
||
/// </summary>
|
||
public void SetOverrideUnitPool(string pool)
|
||
{
|
||
SetOverrideUnitPool(
|
||
ExpressionTextTokenizer.GetVisibleElements(pool));
|
||
}
|
||
|
||
public void SetOverrideUnitPool(IReadOnlyList<string> pool)
|
||
{
|
||
overrideUnitPool = pool != null && pool.Count > 0
|
||
? pool
|
||
: EmptyCharacterPool;
|
||
}
|
||
|
||
[System.Obsolete("Use SetOverrideUnitPool instead.")]
|
||
public void SetOverrideCharacterPool(IReadOnlyList<string> characterPool)
|
||
{
|
||
SetOverrideUnitPool(characterPool);
|
||
}
|
||
|
||
protected bool TryGetOverridePoolChar(out string character)
|
||
{
|
||
if (!HasOverrideCharacterPool)
|
||
{
|
||
character = null;
|
||
return false;
|
||
}
|
||
|
||
character = GetRandomPoolElement(overrideUnitPool);
|
||
return true;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 设置本轮默认与干扰字符池。调用方负责传入不可变的本轮快照。
|
||
/// </summary>
|
||
public void SetUnitPools(
|
||
IReadOnlyList<string> defaultPool,
|
||
IReadOnlyList<string> interferencePool)
|
||
{
|
||
defaultUnitPool = defaultPool != null && defaultPool.Count > 0
|
||
? defaultPool
|
||
: EmptyCharacterPool;
|
||
interferenceUnitPool =
|
||
interferencePool != null && interferencePool.Count > 0
|
||
? interferencePool
|
||
: EmptyCharacterPool;
|
||
}
|
||
|
||
[System.Obsolete("Use SetUnitPools instead.")]
|
||
public void SetCharacterPools(
|
||
IReadOnlyList<string> defaultCharacters,
|
||
IReadOnlyList<string> interferenceCharacters)
|
||
{
|
||
SetUnitPools(defaultCharacters, interferenceCharacters);
|
||
}
|
||
|
||
public void InitializeRandomUnit()
|
||
{
|
||
currentUnitText = GetNextDisplayUnit();
|
||
UpdateText();
|
||
changeTimer = Random.Range(0.5f, 1.5f);
|
||
}
|
||
|
||
[System.Obsolete("Use InitializeRandomUnit instead.")]
|
||
public void InitializeRandomCharacter()
|
||
{
|
||
InitializeRandomUnit();
|
||
}
|
||
|
||
protected bool HasOverrideCharacterPool =>
|
||
overrideUnitPool != null && overrideUnitPool.Count > 0;
|
||
|
||
private static string GetRandomPoolElement(IReadOnlyList<string> pool)
|
||
{
|
||
if (pool == null || pool.Count == 0)
|
||
return string.Empty;
|
||
return pool[Random.Range(0, pool.Count)];
|
||
}
|
||
|
||
protected void UpdateText()
|
||
{
|
||
if (textMesh != null)
|
||
{
|
||
textMesh.text = currentUnitText;
|
||
preferredSizeDirty = true;
|
||
}
|
||
}
|
||
|
||
public void ForceSetUnitText(string unitText)
|
||
{
|
||
currentUnitText = unitText;
|
||
UpdateText();
|
||
}
|
||
|
||
[System.Obsolete("Use ForceSetUnitText instead.")]
|
||
public void ForceSetCharacter(string character)
|
||
{
|
||
ForceSetUnitText(character);
|
||
}
|
||
|
||
public Bounds GetVisualWorldBounds()
|
||
{
|
||
RefreshPreferredSize();
|
||
Transform textTransform = textMesh != null ? textMesh.transform : transform;
|
||
Vector3 scale = textTransform.lossyScale;
|
||
Vector3 size = new Vector3(
|
||
Mathf.Max(0.001f, preferredLocalSize.x * Mathf.Abs(scale.x)),
|
||
Mathf.Max(0.001f, preferredLocalSize.y * Mathf.Abs(scale.y)),
|
||
0.001f);
|
||
return new Bounds(textTransform.position, size);
|
||
}
|
||
|
||
public float DistanceToVisualBounds(Vector2 worldPoint)
|
||
{
|
||
Bounds bounds = GetVisualWorldBounds();
|
||
float dx = Mathf.Max(bounds.min.x - worldPoint.x, 0f, worldPoint.x - bounds.max.x);
|
||
float dy = Mathf.Max(bounds.min.y - worldPoint.y, 0f, worldPoint.y - bounds.max.y);
|
||
return Mathf.Sqrt(dx * dx + dy * dy);
|
||
}
|
||
|
||
protected void InvalidateVisualBounds()
|
||
{
|
||
preferredSizeDirty = true;
|
||
}
|
||
|
||
private void RefreshPreferredSize()
|
||
{
|
||
if (!preferredSizeDirty)
|
||
return;
|
||
|
||
preferredSizeDirty = false;
|
||
if (textMesh == null || string.IsNullOrEmpty(currentUnitText))
|
||
{
|
||
preferredLocalSize = Vector2.zero;
|
||
return;
|
||
}
|
||
|
||
preferredLocalSize = textMesh.GetPreferredValues(currentUnitText);
|
||
}
|
||
|
||
public void SetStaticState(bool value)
|
||
{
|
||
isStatic = value;
|
||
}
|
||
|
||
public void SetCalmedState(bool value)
|
||
{
|
||
isCalmed = value;
|
||
}
|
||
|
||
public void SetChangeInterval(float interval)
|
||
{
|
||
changeInterval = Mathf.Max(0.02f, interval);
|
||
}
|
||
|
||
public void ResetChangeTimer(float timer)
|
||
{
|
||
changeTimer = timer;
|
||
}
|
||
|
||
protected virtual void UpdateVisuals()
|
||
{
|
||
if (textMesh == null)
|
||
return;
|
||
|
||
Color color = textMesh.color;
|
||
color.a = alpha;
|
||
textMesh.color = color;
|
||
}
|
||
|
||
public void ApplyForce(Vector2 force)
|
||
{
|
||
velocity += force;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 设置字体资源
|
||
/// </summary>
|
||
public void SetFont(TMP_FontAsset fontAsset)
|
||
{
|
||
SetFont(fontAsset, null);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 设置字体与可选的 Material Preset(须基于同一份 SDF Atlas)
|
||
/// </summary>
|
||
public void SetFont(TMP_FontAsset fontAsset, Material fontMaterialPreset)
|
||
{
|
||
customFont = fontAsset;
|
||
customFontMaterial = fontMaterialPreset;
|
||
SetupFont();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 内部方法:设置字体
|
||
/// </summary>
|
||
private void SetupFont()
|
||
{
|
||
if (textMesh == null) return;
|
||
|
||
// 优先级:自定义字体 > 默认字体 > 警告
|
||
if (customFont != null)
|
||
{
|
||
textMesh.font = customFont;
|
||
}
|
||
else if (TMP_Settings.defaultFontAsset != null)
|
||
{
|
||
textMesh.font = TMP_Settings.defaultFontAsset;
|
||
}
|
||
else
|
||
{
|
||
Debug.LogWarning("[TextParticle] TextMeshPro 字体未设置!请在 LanguageParticleManager 中指定中文字体资源,或在 Project Settings > TextMesh Pro > Settings 中设置默认字体。");
|
||
}
|
||
|
||
if (customFontMaterial != null)
|
||
textMesh.fontSharedMaterial = customFontMaterial;
|
||
|
||
InvalidateVisualBounds();
|
||
}
|
||
|
||
protected virtual void OnDestroy()
|
||
{
|
||
ClearScreenClip();
|
||
}
|
||
}
|
||
}
|
||
|