355 lines
15 KiB
C#
355 lines
15 KiB
C#
using UnityEngine;
|
|
using TMPro;
|
|
using System.Collections.Generic;
|
|
|
|
namespace Unheimlich.Features.Dialogue.UI
|
|
{
|
|
/// <summary>
|
|
/// Non-interactive floating word/phrase for world-space storytelling (thoughts, guidance, atmosphere).
|
|
/// Uses TextMeshPro 3D (not Canvas). Designed to sit alongside BillboardUI on the same prefab.
|
|
///
|
|
/// Features:
|
|
/// - Adaptive scale: label always appears the same angular size in the player's camera.
|
|
/// - Max distance: fades out progressively when player is far away.
|
|
/// - Min distance: label is pushed away from the camera so it never clips into the player's face.
|
|
/// - Optional organic shake: label drifts gently around its home position.
|
|
/// - Optional ghost trail: a semi-transparent copy follows with a position delay.
|
|
/// </summary>
|
|
public class FloatingLabel : MonoBehaviour
|
|
{
|
|
[Header("Text")]
|
|
[Tooltip("Leave empty to keep text set directly on the TextMeshPro component.")]
|
|
[SerializeField] [TextArea] private string _overrideText;
|
|
[SerializeField] private TextMeshPro _textComponent;
|
|
|
|
[Header("Visibility")]
|
|
[Tooltip("Player distance beyond which the label becomes invisible.")]
|
|
[SerializeField] private float _maxVisibleDistance = 10f;
|
|
[Tooltip("Distance range over which the label fades in/out near the max visibility boundary.")]
|
|
[SerializeField] private float _fadeRange = 1.5f;
|
|
|
|
[Header("Min Distance")]
|
|
[Tooltip("When the camera is closer than this, the label slides away to stay readable.")]
|
|
[SerializeField] private float _minDistance = 0.5f;
|
|
[Tooltip("Distance range over which the label fades out as the player approaches min distance.")]
|
|
[SerializeField] private float _nearFadeRange = 0.8f;
|
|
|
|
[Header("Adaptive Scale")]
|
|
[Tooltip("Scale adjusts so the label always appears the same size in the camera view.")]
|
|
[SerializeField] private bool _enableAdaptiveScale = true;
|
|
[Tooltip("Distance at which the label renders at its Inspector-set scale (1:1 reference).")]
|
|
[SerializeField] private float _referenceDistance = 3f;
|
|
[Tooltip("Minimum scale multiplier (relative to base scale). Prevents the label from shrinking too much up close.")]
|
|
[SerializeField] private float _minScaleFactor = 0.2f;
|
|
[Tooltip("Maximum scale multiplier (relative to base scale). Prevents the label from growing too large at a distance.")]
|
|
[SerializeField] private float _maxScaleFactor = 3f;
|
|
|
|
[Header("Organic Shake (Optional)")]
|
|
[SerializeField] private bool _enableOrganicShake = false;
|
|
[Tooltip("Max drift radius in world units around the home position.")]
|
|
[SerializeField] private float _shakeRadius = 0.05f;
|
|
[Tooltip("How often a new shake target is picked (per second).")]
|
|
[SerializeField] private float _shakeSpeed = 1.5f;
|
|
[Tooltip("Higher = smoother drift transition.")]
|
|
[SerializeField] private float _shakeSmoothness = 2f;
|
|
|
|
[Header("Ghost Trail (Optional)")]
|
|
[SerializeField] private bool _enableGhostTrail = false;
|
|
[Tooltip("Opacity of the ghost copy.")]
|
|
[SerializeField] [Range(0f, 1f)] private float _ghostAlpha = 0.3f;
|
|
[Tooltip("How many seconds the ghost lags behind the main label.")]
|
|
[SerializeField] private float _ghostDelay = 0.15f;
|
|
[Tooltip("How far behind the main label the ghost is pushed (along the camera-to-label ray). Ensures the ghost is always occluded by the main word regardless of viewing angle.")]
|
|
[SerializeField] private float _ghostDepthOffset = 0.02f;
|
|
|
|
[Header("Reappear Fade")]
|
|
[Tooltip("Duration of the fade-in when the label reappears after being culled.")]
|
|
[SerializeField] private float _fadeInDuration = 0.5f;
|
|
|
|
[Header("Performance")]
|
|
[Tooltip("How often (in seconds) distance culling is evaluated. Higher = cheaper but slightly delayed visibility toggle.")]
|
|
[SerializeField] private float _cullCheckInterval = 0.4f;
|
|
|
|
// ── State ──────────────────────────────────────────────────────────────
|
|
private Camera _camera;
|
|
private Vector3 _homePosition;
|
|
private Vector3 _baseScale;
|
|
|
|
// Culling & fade-in
|
|
private bool _isActive = true;
|
|
private float _cullTimer;
|
|
private float _fadeInAlpha = 1f;
|
|
|
|
// Shake
|
|
private bool _isShaking;
|
|
private Vector3 _shakeOffset;
|
|
private Vector3 _shakeTarget;
|
|
private float _shakeTimer;
|
|
|
|
// Ghost trail
|
|
private TextMeshPro _ghostText;
|
|
private Queue<Vector3> _positionHistory = new Queue<Vector3>();
|
|
private float _historyTimer;
|
|
private const float HISTORY_SAMPLE_RATE = 0.016f;
|
|
|
|
// ── Unity ──────────────────────────────────────────────────────────────
|
|
private void Awake()
|
|
{
|
|
_camera = Camera.main;
|
|
|
|
if (_textComponent == null)
|
|
_textComponent = GetComponent<TextMeshPro>();
|
|
}
|
|
|
|
private void Start()
|
|
{
|
|
_homePosition = transform.position;
|
|
_baseScale = transform.localScale;
|
|
|
|
if (_textComponent != null && !string.IsNullOrEmpty(_overrideText))
|
|
_textComponent.text = _overrideText;
|
|
|
|
SetupGhostTrail();
|
|
|
|
if (_enableOrganicShake)
|
|
StartShake();
|
|
}
|
|
|
|
private void Update()
|
|
{
|
|
if (_camera == null)
|
|
{
|
|
_camera = Camera.main;
|
|
return;
|
|
}
|
|
|
|
// Low-frequency distance cull: skip all logic and disable renderers when out of range.
|
|
_cullTimer += Time.deltaTime;
|
|
if (_cullTimer >= _cullCheckInterval)
|
|
{
|
|
_cullTimer = 0f;
|
|
bool shouldBeActive = Vector3.Distance(_camera.transform.position, _homePosition)
|
|
<= _maxVisibleDistance + _fadeRange;
|
|
if (shouldBeActive != _isActive)
|
|
{
|
|
_isActive = shouldBeActive;
|
|
_textComponent.enabled = _isActive;
|
|
if (_ghostText != null) _ghostText.enabled = _isActive;
|
|
|
|
// Reset fade-in so the label doesn't pop in abruptly.
|
|
if (_isActive) _fadeInAlpha = 0f;
|
|
}
|
|
}
|
|
|
|
if (!_isActive) return;
|
|
|
|
UpdatePosition();
|
|
UpdateVisibility();
|
|
UpdateAdaptiveScale();
|
|
|
|
if (_isShaking)
|
|
UpdateShake();
|
|
|
|
UpdateGhostTrail();
|
|
}
|
|
|
|
// ── Position ───────────────────────────────────────────────────────────
|
|
|
|
private void UpdatePosition()
|
|
{
|
|
Vector3 toHome = _homePosition - _camera.transform.position;
|
|
float dist = toHome.magnitude;
|
|
|
|
if (dist < _minDistance && dist > 0.001f)
|
|
{
|
|
// Push label away so it stays at least _minDistance from the camera.
|
|
transform.position = _camera.transform.position + toHome.normalized * _minDistance;
|
|
}
|
|
else
|
|
{
|
|
transform.position = _homePosition + _shakeOffset;
|
|
}
|
|
}
|
|
|
|
// ── Visibility (distance fade) ─────────────────────────────────────────
|
|
|
|
private void UpdateVisibility()
|
|
{
|
|
if (_textComponent == null) return;
|
|
|
|
if (_fadeInAlpha < 1f)
|
|
_fadeInAlpha = Mathf.MoveTowards(_fadeInAlpha, 1f, Time.deltaTime / Mathf.Max(_fadeInDuration, 0.001f));
|
|
|
|
float dist = Vector3.Distance(_camera.transform.position, _homePosition);
|
|
float alpha = ComputeAlpha(dist) * _fadeInAlpha;
|
|
|
|
SetTextAlpha(_textComponent, alpha);
|
|
|
|
if (_ghostText != null)
|
|
SetTextAlpha(_ghostText, alpha * _ghostAlpha);
|
|
}
|
|
|
|
private float ComputeAlpha(float dist)
|
|
{
|
|
// Far fade-out
|
|
if (dist >= _maxVisibleDistance)
|
|
return 0f;
|
|
|
|
float farFadeStart = _maxVisibleDistance - _fadeRange;
|
|
if (dist > farFadeStart && _fadeRange > 0f)
|
|
return 1f - (dist - farFadeStart) / _fadeRange;
|
|
|
|
// Near fade-out
|
|
float nearFadeEnd = _minDistance + _nearFadeRange;
|
|
if (dist < nearFadeEnd && _nearFadeRange > 0f)
|
|
return Mathf.Clamp01((dist - _minDistance) / _nearFadeRange);
|
|
|
|
return 1f;
|
|
}
|
|
|
|
private static void SetTextAlpha(TextMeshPro text, float alpha)
|
|
{
|
|
Color c = text.color;
|
|
c.a = alpha;
|
|
text.color = c;
|
|
}
|
|
|
|
// ── Adaptive Scale ─────────────────────────────────────────────────────
|
|
|
|
private void UpdateAdaptiveScale()
|
|
{
|
|
if (!_enableAdaptiveScale) return;
|
|
|
|
float dist = Vector3.Distance(_camera.transform.position, transform.position);
|
|
float scaleFactor = Mathf.Clamp(dist / Mathf.Max(_referenceDistance, 0.001f), _minScaleFactor, _maxScaleFactor);
|
|
transform.localScale = _baseScale * scaleFactor;
|
|
|
|
// Keep ghost in sync — it has its own BillboardUI for rotation but we drive scale here.
|
|
if (_ghostText != null)
|
|
_ghostText.transform.localScale = transform.localScale;
|
|
}
|
|
|
|
// ── Organic Shake ──────────────────────────────────────────────────────
|
|
|
|
private Vector3 RandomShakeTarget()
|
|
{
|
|
// XY only — no depth displacement.
|
|
float scaledRadius = _shakeRadius * transform.localScale.x;
|
|
Vector2 circle = Random.insideUnitCircle * scaledRadius;
|
|
return new Vector3(circle.x, circle.y, 0f);
|
|
}
|
|
|
|
private void StartShake()
|
|
{
|
|
_isShaking = true;
|
|
_shakeTimer = 0f;
|
|
_shakeTarget = RandomShakeTarget();
|
|
}
|
|
|
|
private void UpdateShake()
|
|
{
|
|
_shakeTimer += Time.deltaTime * _shakeSpeed;
|
|
|
|
if (_shakeTimer >= 1f)
|
|
{
|
|
_shakeTimer = 0f;
|
|
_shakeTarget = RandomShakeTarget();
|
|
}
|
|
|
|
_shakeOffset = Vector3.Lerp(_shakeOffset, _shakeTarget, Time.deltaTime * _shakeSmoothness);
|
|
}
|
|
|
|
// ── Ghost Trail ────────────────────────────────────────────────────────
|
|
|
|
private void SetupGhostTrail()
|
|
{
|
|
if (!_enableGhostTrail || _textComponent == null) return;
|
|
|
|
GameObject ghostObj = new GameObject($"Ghost_{gameObject.name}");
|
|
ghostObj.transform.SetParent(transform.parent, false);
|
|
ghostObj.transform.position = transform.position;
|
|
ghostObj.transform.rotation = transform.rotation;
|
|
ghostObj.transform.localScale = transform.localScale;
|
|
ghostObj.layer = gameObject.layer;
|
|
|
|
_ghostText = ghostObj.AddComponent<TextMeshPro>();
|
|
_ghostText.font = _textComponent.font;
|
|
_ghostText.fontSize = _textComponent.fontSize;
|
|
_ghostText.alignment = _textComponent.alignment;
|
|
_ghostText.text = _textComponent.text;
|
|
_ghostText.color = new Color(
|
|
_textComponent.color.r,
|
|
_textComponent.color.g,
|
|
_textComponent.color.b,
|
|
_ghostAlpha
|
|
);
|
|
|
|
// Ghost renderer always sorts behind the main word's renderer.
|
|
MeshRenderer mainRenderer = _textComponent.GetComponent<MeshRenderer>();
|
|
MeshRenderer ghostRenderer = _ghostText.GetComponent<MeshRenderer>();
|
|
if (mainRenderer != null && ghostRenderer != null)
|
|
{
|
|
ghostRenderer.sortingLayerID = mainRenderer.sortingLayerID;
|
|
ghostRenderer.sortingOrder = mainRenderer.sortingOrder - 1;
|
|
}
|
|
|
|
// Ghost must also face the player.
|
|
ghostObj.AddComponent<BillboardUI>();
|
|
}
|
|
|
|
private void UpdateGhostTrail()
|
|
{
|
|
if (!_enableGhostTrail || _ghostText == null) return;
|
|
|
|
// Sample position history at a fixed rate.
|
|
_historyTimer += Time.deltaTime;
|
|
if (_historyTimer >= HISTORY_SAMPLE_RATE)
|
|
{
|
|
_positionHistory.Enqueue(transform.position);
|
|
_historyTimer = 0f;
|
|
|
|
int maxSize = Mathf.CeilToInt(_ghostDelay / HISTORY_SAMPLE_RATE);
|
|
while (_positionHistory.Count > maxSize)
|
|
_positionHistory.Dequeue();
|
|
}
|
|
|
|
Vector3 ghostPos = _positionHistory.Count > 0
|
|
? _positionHistory.Peek()
|
|
: transform.position;
|
|
|
|
// Push ghost along camera forward (away from camera) so it always sits
|
|
// behind the main word in depth — stable regardless of viewing angle.
|
|
_ghostText.transform.position = ghostPos + _camera.transform.forward * _ghostDepthOffset;
|
|
|
|
// Keep text content and size in sync.
|
|
_ghostText.text = _textComponent.text;
|
|
_ghostText.fontSize = _textComponent.fontSize;
|
|
}
|
|
|
|
// ── Public API ─────────────────────────────────────────────────────────
|
|
|
|
/// <summary>Sets the displayed text at runtime.</summary>
|
|
public void SetText(string text)
|
|
{
|
|
_overrideText = text;
|
|
if (_textComponent != null) _textComponent.text = text;
|
|
if (_ghostText != null) _ghostText.text = text;
|
|
}
|
|
|
|
/// <summary>Teleports the label's home position without animation.</summary>
|
|
public void SetHomePosition(Vector3 worldPos)
|
|
{
|
|
_homePosition = worldPos;
|
|
transform.position = worldPos;
|
|
}
|
|
|
|
#if UNITY_EDITOR
|
|
private void OnValidate()
|
|
{
|
|
if (_textComponent != null && !string.IsNullOrEmpty(_overrideText))
|
|
_textComponent.text = _overrideText;
|
|
}
|
|
#endif
|
|
}
|
|
}
|