using UnityEngine; using TMPro; using System.Collections.Generic; namespace Unheimlich.Features.Dialogue.UI { /// /// 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. /// 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 _positionHistory = new Queue(); private float _historyTimer; private const float HISTORY_SAMPLE_RATE = 0.016f; // ── Unity ────────────────────────────────────────────────────────────── private void Awake() { _camera = Camera.main; if (_textComponent == null) _textComponent = GetComponent(); } 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(); _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 ghostRenderer = _ghostText.GetComponent(); if (mainRenderer != null && ghostRenderer != null) { ghostRenderer.sortingLayerID = mainRenderer.sortingLayerID; ghostRenderer.sortingOrder = mainRenderer.sortingOrder - 1; } // Ghost must also face the player. ghostObj.AddComponent(); } 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 ───────────────────────────────────────────────────────── /// Sets the displayed text at runtime. public void SetText(string text) { _overrideText = text; if (_textComponent != null) _textComponent.text = text; if (_ghostText != null) _ghostText.text = text; } /// Teleports the label's home position without animation. 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 } }