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. /// - Optional camera attraction: label is gently pulled toward screen center when the player looks near it. /// - Optional one-shot: label permanently disappears after being seen and moving away. /// 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("Camera Attraction (Optional)")] [Tooltip("When enabled, the label is gently pulled toward the screen center when the player looks near it.")] [SerializeField] private bool _enableCameraAttraction = false; [Tooltip("Half-angle (degrees) within which attraction is active. " + "Below this angle the label is drawn toward the screen center. " + "Keep small (e.g. 8-15°) so it only triggers when the player is really looking at the word.")] [SerializeField] [Range(1f, 45f)] private float _attractionAngle = 10f; [Tooltip("Maximum world-space offset applied toward the screen center at full attraction strength.")] [SerializeField] private float _attractionMaxOffset = 0.3f; [Tooltip("How quickly the attraction offset interpolates toward its target (higher = snappier).")] [SerializeField] private float _attractionSmoothness = 4f; [Header("One-Shot (Optional)")] [Tooltip("When enabled, the label permanently disappears once the player has seen it and then moved away.")] [SerializeField] private bool _enableOneShot = false; [Tooltip("Alpha threshold above which the label is considered 'seen'. " + "The player must look at the label long enough to exceed this visibility before the one-shot condition can trigger.")] [SerializeField] [Range(0.01f, 1f)] private float _seenAlphaThreshold = 0.5f; [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; // Camera attraction // World-space offset currently applied on top of the home+shake position. private Vector3 _attractionOffset; // One-shot // True once the label has been visible above the threshold at least once. private bool _hasBeenSeen; // True once the one-shot has fired and the label should stay invisible forever. private bool _permanentlyHidden; // ── 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 (_permanentlyHidden) return; 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(); // Compute the distance-based alpha first so one-shot and attraction can read it. float dist = Vector3.Distance(_camera.transform.position, _homePosition); float alpha = ComputeAlpha(dist) * _fadeInAlpha; // One-shot: track visibility and trigger permanent hide when the player walks away after seeing the label. if (_enableOneShot) { if (!_hasBeenSeen && alpha >= _seenAlphaThreshold) _hasBeenSeen = true; if (_hasBeenSeen && alpha <= 0f) { _permanentlyHidden = true; _textComponent.enabled = false; if (_ghostText != null) _ghostText.enabled = false; return; } } UpdateCameraAttraction(alpha); ApplyAlpha(alpha); UpdateAdaptiveScale(); if (_isShaking) UpdateShake(); UpdateGhostTrail(); } // ── Position ─────────────────────────────────────────────────────────── private void UpdatePosition() { Vector3 toHome = _homePosition - _camera.transform.position; float dist = toHome.magnitude; Vector3 basePos; if (dist < _minDistance && dist > 0.001f) basePos = _camera.transform.position + toHome.normalized * _minDistance; else basePos = _homePosition + _shakeOffset; transform.position = basePos + _attractionOffset; } // ── Visibility (distance fade) ───────────────────────────────────────── private void ApplyAlpha(float alpha) { if (_textComponent == null) return; if (_fadeInAlpha < 1f) _fadeInAlpha = Mathf.MoveTowards(_fadeInAlpha, 1f, Time.deltaTime / Mathf.Max(_fadeInDuration, 0.001f)); 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; if (_ghostText != null) _ghostText.transform.localScale = transform.localScale; } // ── Camera Attraction ────────────────────────────────────────────────── /// /// Pulls the label toward the screen center when the player is looking near it. /// Strength uses a quadratic falloff so it only kicks in meaningfully /// when the gaze angle is truly small. /// private void UpdateCameraAttraction(float currentAlpha) { if (!_enableCameraAttraction) { _attractionOffset = Vector3.zero; return; } // Direction from camera to the label's home position. Vector3 toLabel = _homePosition - _camera.transform.position; float dist = toLabel.magnitude; if (dist < 0.001f) { _attractionOffset = Vector3.zero; return; } // Angle between the camera's forward direction and the direction to the label. float angleDeg = Vector3.Angle(_camera.transform.forward, toLabel.normalized); // Quadratic falloff: 1 at angleDeg==0, 0 at angleDeg==_attractionAngle and beyond. float t = Mathf.Clamp01(1f - angleDeg / Mathf.Max(_attractionAngle, 0.001f)); float strength = t * t; // quadratic — only strong when really close to center // Also scale with the distance-based alpha so attraction is zero when the label is faded out. strength *= currentAlpha; // Target offset: move the label to where Camera.forward ray at distance |toLabel| lands. Vector3 labelOnCenterRay = _camera.transform.position + _camera.transform.forward * dist; Vector3 targetOffset = (labelOnCenterRay - _homePosition) * strength; // Clamp magnitude so it never overrides the home position completely. if (targetOffset.magnitude > _attractionMaxOffset) targetOffset = targetOffset.normalized * _attractionMaxOffset; _attractionOffset = Vector3.Lerp( _attractionOffset, targetOffset, Time.deltaTime * _attractionSmoothness ); } // ── Organic Shake ────────────────────────────────────────────────────── private Vector3 RandomShakeTarget() { 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 ); MeshRenderer mainRenderer = _textComponent.GetComponent(); MeshRenderer ghostRenderer = _ghostText.GetComponent(); if (mainRenderer != null && ghostRenderer != null) { ghostRenderer.sortingLayerID = mainRenderer.sortingLayerID; ghostRenderer.sortingOrder = mainRenderer.sortingOrder - 1; } ghostObj.AddComponent(); } private void UpdateGhostTrail() { if (!_enableGhostTrail || _ghostText == null) return; _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; _ghostText.transform.position = ghostPos + _camera.transform.forward * _ghostDepthOffset; _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; } /// Resets the one-shot state so the label can be seen again. public void ResetOneShot() { _hasBeenSeen = false; _permanentlyHidden = false; if (_textComponent != null) _textComponent.enabled = true; if (_ghostText != null) _ghostText.enabled = true; _fadeInAlpha = 0f; } #if UNITY_EDITOR private void OnValidate() { if (_textComponent != null && !string.IsNullOrEmpty(_overrideText)) _textComponent.text = _overrideText; } #endif } }