using UnityEngine; using TMPro; using System.Collections; using System.Collections.Generic; using Unheimlich.Features.Hallucination; using Unheimlich.Features.Shared; namespace Unheimlich.Features.Dialogue.UI { /// /// Non-interactive floating word/phrase for world-space storytelling. /// /// Changes from previous version: /// - Registers/unregisters itself with HallucinationLabelRegistry on enable/disable /// so the HallucinationManager can drive baseline behaviour without FindObjectsOfType. /// - Exposes SetShakeParams() and SetGhostTrail() for runtime overrides. /// - ApplyBaseline() / ApplyOverride() drive the two-layer system. /// - One-shot trigger distance is now explicit and independent from the visual near-fade. /// - One-shot fade-out runs independently from the distance-based alpha so it is never overwritten. /// public class FloatingLabel : MonoBehaviour { // ─── Inspector ───────────────────────────────────────────────────────── [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")] [SerializeField] private float _maxVisibleDistance = 10f; [SerializeField] private float _fadeRange = 1.5f; [Header("Min Distance")] [SerializeField] private float _minDistance = 0.5f; [SerializeField] private float _nearFadeRange = 0.8f; [Header("Adaptive Scale")] [SerializeField] private bool _enableAdaptiveScale = true; [SerializeField] private float _referenceDistance = 3f; [SerializeField] private float _minScaleFactor = 0.2f; [SerializeField] private float _maxScaleFactor = 3f; [Header("Organic Shake — Baseline")] [Tooltip("Prefab defaults. HallucinationManager lerps these at runtime based on sanity.")] [SerializeField] private bool _enableOrganicShake = false; [SerializeField] private float _shakeRadius = 0.05f; [SerializeField] private float _shakeSpeed = 1.5f; [SerializeField] private float _shakeSmoothness = 2f; [Header("Ghost Trail — Baseline")] [Tooltip("Prefab defaults. HallucinationManager lerps these at runtime based on sanity.")] [SerializeField] private bool _enableGhostTrail = false; [SerializeField] [Range(0f, 1f)] private float _ghostAlpha = 0.3f; [SerializeField] private float _ghostDelay = 0.15f; [SerializeField] private float _ghostDepthOffset = 0.02f; [Header("Camera Attraction (Optional)")] [SerializeField] private bool _enableCameraAttraction = false; [SerializeField] [Range(1f, 45f)] private float _attractionAngle = 10f; [SerializeField] private float _attractionMaxOffset = 0.3f; [SerializeField] private float _attractionSmoothness = 4f; [Header("One-Shot (Optional)")] [Tooltip("Enable one-shot behaviour: the label permanently disappears after the player gets close enough.")] [SerializeField] private bool _enableOneShot = false; [Tooltip("If true, the one-shot resets when the player walks far away (outside far-fade zone) without having triggered it.")] [SerializeField] private bool _isRetriggerableFar = false; [Tooltip("Distance at which the one-shot triggers, completely independent from the visual near-fade.")] [SerializeField] private float _oneShotTriggerDistance = 0.5f; [Tooltip("Duration of the fade-out once the one-shot is triggered.")] [SerializeField] private float _fadeOutDuration = 0.5f; [Header("Reappear Fade")] [SerializeField] private float _fadeInDuration = 0.5f; [Header("Performance")] [SerializeField] private float _cullCheckInterval = 0.4f; // ─── Runtime state ───────────────────────────────────────────────────── private Camera _camera; private Vector3 _localHomePosition; private Vector3 _homePosition; private Vector3 _baseScale; // Culling & fade-in private bool _isActive = true; private float _cullTimer; private float _fadeInAlpha = 1f; // Shake — live values driven by baseline or override private bool _isShaking; private Vector3 _shakeOffset; private Vector3 _shakeTarget; private float _shakeTimer; private float _liveShakeRadius; private float _liveShakeSpeed; private float _liveShakeSmoothness; // Ghost trail — live values private bool _liveGhostEnabled; private float _liveGhostAlpha; private TextMeshPro _ghostText; private Queue _positionHistory = new(); private float _historyTimer; private const float HISTORY_SAMPLE_RATE = 0.016f; // Camera attraction private Vector3 _attractionOffset; // One-shot private bool _hasBeenSeen; private bool _permanentlyHidden; private float _closestDistanceReached = float.MaxValue; // One-shot fade-out — runs independently from the distance alpha private bool _isFadingOut = false; private float _fadeOutTimer = 0f; private float _fadeOutStartAlpha = 1f; // Override coroutine private Coroutine _overrideCoroutine; // Baseline snapshot (so revert after override is accurate) private float _baselineShakeRadius; private float _baselineShakeSpeed; private float _baselineShakeSmoothness; private bool _baselineGhostEnabled; private float _baselineGhostAlpha; // ─── Unity ───────────────────────────────────────────────────────────── private void Awake() { _camera = Camera.main; if (_textComponent == null) _textComponent = GetComponent(); // Initialise live values from inspector defaults _liveShakeRadius = _shakeRadius; _liveShakeSpeed = _shakeSpeed; _liveShakeSmoothness = _shakeSmoothness; _liveGhostEnabled = _enableGhostTrail; _liveGhostAlpha = _ghostAlpha; } private void Start() { _localHomePosition = transform.localPosition; _baseScale = transform.localScale; if (_textComponent != null && !string.IsNullOrEmpty(_overrideText)) _textComponent.text = _overrideText; SaveBaselineSnapshot(); SetupGhostTrail(); if (_enableOrganicShake) StartShakeInternal(); } private void OnEnable() => HallucinationLabelRegistry.Register(this); private void OnDisable() => HallucinationLabelRegistry.Unregister(this); private void Update() { if (_permanentlyHidden) return; if (_camera == null) { _camera = Camera.main; return; } // ── Distance cull ────────────────────────────────────────────────── _cullTimer += Time.deltaTime; if (_cullTimer >= _cullCheckInterval) { _cullTimer = 0f; bool shouldBeActive = Vector3.Distance(_camera.transform.position, transform.position) <= _maxVisibleDistance + _fadeRange; if (shouldBeActive != _isActive) { _isActive = shouldBeActive; _textComponent.enabled = _isActive; if (_ghostText != null) _ghostText.enabled = _isActive && _liveGhostEnabled; if (_isActive) _fadeInAlpha = 0f; } } if (!_isActive) return; UpdatePosition(); float dist = Vector3.Distance(_camera.transform.position, transform.position); // ── One-shot ─────────────────────────────────────────────────────── if (_enableOneShot) { // Track the closest the player has ever been since the label was seen if (_hasBeenSeen && dist < _closestDistanceReached) _closestDistanceReached = dist; // Mark as seen as soon as the player enters the visible range if (!_hasBeenSeen && dist <= _maxVisibleDistance) { _hasBeenSeen = true; _closestDistanceReached = dist; } // Fade-out is running — drive alpha independently, skip normal alpha pipeline if (_isFadingOut) { _fadeOutTimer += Time.deltaTime; float t = 1f - Mathf.Clamp01(_fadeOutTimer / _fadeOutDuration); float a = _fadeOutStartAlpha * t; SetTextAlpha(_textComponent, a); if (_ghostText != null) SetTextAlpha(_ghostText, a * _liveGhostAlpha); if (_fadeOutTimer >= _fadeOutDuration) { _permanentlyHidden = true; _isFadingOut = false; _textComponent.enabled = false; if (_ghostText != null) _ghostText.enabled = false; } return; } if (_hasBeenSeen) { // Trigger: player reached the explicit trigger distance at some point bool hasTriggered = _closestDistanceReached <= _oneShotTriggerDistance; // Reset: player walked far away without ever triggering bool isFarNow = dist > _maxVisibleDistance - _fadeRange; if (hasTriggered) { _isFadingOut = true; _fadeOutTimer = 0f; _fadeOutStartAlpha = ComputeAlpha(dist) * _fadeInAlpha; _closestDistanceReached = float.MaxValue; return; } if (_isRetriggerableFar && isFarNow) { _hasBeenSeen = false; _closestDistanceReached = float.MaxValue; } } } // ── Normal alpha pipeline ────────────────────────────────────────── float alpha = ComputeAlpha(dist) * _fadeInAlpha; UpdateCameraAttraction(alpha); ApplyAlpha(alpha); UpdateAdaptiveScale(); if (_isShaking) UpdateShake(); UpdateGhostTrail(); } // ─── Public API — two-layer system ───────────────────────────────────── /// /// Pushes sanity-driven baseline values. Ignored while an override is active. /// Called every frame by HallucinationManager for labels within range. /// public void ApplyBaseline(float shakeRadius, float shakeSpeed, float shakeSmoothness, bool ghostEnabled, float ghostAlpha) { if (_overrideCoroutine != null) return; SetShakeParams(shakeRadius, shakeSpeed, shakeSmoothness); SetGhostTrail(ghostEnabled, ghostAlpha); // Keep snapshot fresh so revert after override is correct _baselineShakeRadius = shakeRadius; _baselineShakeSpeed = shakeSpeed; _baselineShakeSmoothness = shakeSmoothness; _baselineGhostEnabled = ghostEnabled; _baselineGhostAlpha = ghostAlpha; } /// /// Applies a timed override then reverts to the last baseline snapshot. /// Called by HallucinationEvent at trigger time. /// public void ApplyOverride(float shakeRadius, float shakeSpeed, float shakeSmoothness, bool ghostEnabled, float ghostAlpha, float duration) { if (_overrideCoroutine != null) StopCoroutine(_overrideCoroutine); _overrideCoroutine = StartCoroutine(OverrideRoutine( shakeRadius, shakeSpeed, shakeSmoothness, ghostEnabled, ghostAlpha, duration)); } private IEnumerator OverrideRoutine(float shakeRadius, float shakeSpeed, float shakeSmoothness, bool ghostEnabled, float ghostAlpha, float duration) { SetShakeParams(shakeRadius, shakeSpeed, shakeSmoothness); SetGhostTrail(ghostEnabled, ghostAlpha); yield return new WaitForSeconds(duration); SetShakeParams(_baselineShakeRadius, _baselineShakeSpeed, _baselineShakeSmoothness); SetGhostTrail(_baselineGhostEnabled, _baselineGhostAlpha); _overrideCoroutine = null; } // ─── Low-level setters (public for editor tooling / direct calls) ─────── public void SetShakeParams(float radius, float speed, float smoothness) { _liveShakeRadius = radius; _liveShakeSpeed = speed; _liveShakeSmoothness = smoothness; if (radius > 0f && !_isShaking) StartShakeInternal(); else if (radius <= 0f) _isShaking = false; } public void SetGhostTrail(bool enabled, float alpha) { _liveGhostEnabled = enabled; _liveGhostAlpha = alpha; if (_ghostText != null) _ghostText.enabled = _isActive && enabled; } // ─── Existing API ────────────────────────────────────────────────────── public void SetText(string text) { _overrideText = text; if (_textComponent != null) _textComponent.text = text; if (_ghostText != null) _ghostText.text = text; } public void SetHomePosition(Vector3 worldPos) { _homePosition = worldPos; transform.position = worldPos; } public void ResetOneShot() { _hasBeenSeen = false; _permanentlyHidden = false; _isFadingOut = false; _fadeOutTimer = 0f; _fadeOutStartAlpha = 1f; _closestDistanceReached = float.MaxValue; if (_textComponent != null) _textComponent.enabled = true; if (_ghostText != null) _ghostText.enabled = _liveGhostEnabled; _fadeInAlpha = 0f; } // ─── Internals ───────────────────────────────────────────────────────── private void SaveBaselineSnapshot() { _baselineShakeRadius = _shakeRadius; _baselineShakeSpeed = _shakeSpeed; _baselineShakeSmoothness = _shakeSmoothness; _baselineGhostEnabled = _enableGhostTrail; _baselineGhostAlpha = _ghostAlpha; } private void UpdatePosition() { if (transform.parent != null) _homePosition = transform.parent.TransformPoint(_localHomePosition); Vector3 toHome = _homePosition - _camera.transform.position; float dist = toHome.magnitude; Vector3 basePos = (dist < _minDistance && dist > 0.001f) ? _camera.transform.position + toHome.normalized * _minDistance : _homePosition + _shakeOffset; transform.position = basePos + _attractionOffset; } 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 && _liveGhostEnabled) SetTextAlpha(_ghostText, alpha * _liveGhostAlpha); } private float ComputeAlpha(float dist) { if (dist >= _maxVisibleDistance) return 0f; float farFadeStart = _maxVisibleDistance - _fadeRange; if (dist > farFadeStart && _fadeRange > 0f) return 1f - (dist - farFadeStart) / _fadeRange; 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; } 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; } private void UpdateCameraAttraction(float currentAlpha) { if (!_enableCameraAttraction) { _attractionOffset = Vector3.zero; return; } Vector3 toLabel = _homePosition - _camera.transform.position; float dist = toLabel.magnitude; if (dist < 0.001f) { _attractionOffset = Vector3.zero; return; } float angleDeg = Vector3.Angle(_camera.transform.forward, toLabel.normalized); float t = Mathf.Clamp01(1f - angleDeg / Mathf.Max(_attractionAngle, 0.001f)); float strength = t * t * currentAlpha; Vector3 labelOnCenterRay = _camera.transform.position + _camera.transform.forward * dist; Vector3 targetOffset = (labelOnCenterRay - _homePosition) * strength; if (targetOffset.magnitude > _attractionMaxOffset) targetOffset = targetOffset.normalized * _attractionMaxOffset; _attractionOffset = Vector3.Lerp(_attractionOffset, targetOffset, Time.deltaTime * _attractionSmoothness); } private void StartShakeInternal() { _isShaking = true; _shakeTimer = 0f; _shakeTarget = RandomShakeTarget(); } private Vector3 RandomShakeTarget() { float scaledRadius = _liveShakeRadius * transform.localScale.x; Vector2 circle = Random.insideUnitCircle * scaledRadius; return new Vector3(circle.x, circle.y, 0f); } private void UpdateShake() { _shakeTimer += Time.deltaTime * _liveShakeSpeed; if (_shakeTimer >= 1f) { _shakeTimer = 0f; _shakeTarget = RandomShakeTarget(); } _shakeOffset = Vector3.Lerp(_shakeOffset, _shakeTarget, Time.deltaTime * _liveShakeSmoothness); } private void SetupGhostTrail() { // Ghost GO is always created when the feature might be enabled at runtime if (_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, _liveGhostAlpha); MeshRenderer mainRenderer = _textComponent.GetComponent(); MeshRenderer ghostRenderer = _ghostText.GetComponent(); if (mainRenderer != null && ghostRenderer != null) { ghostRenderer.sortingLayerID = mainRenderer.sortingLayerID; ghostRenderer.sortingOrder = mainRenderer.sortingOrder - 1; } ghostObj.AddComponent(); // Start hidden — the manager or an override will enable it when needed _ghostText.enabled = _liveGhostEnabled; } private void UpdateGhostTrail() { if (!_liveGhostEnabled || _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; } #if UNITY_EDITOR private void OnValidate() { if (_textComponent != null && !string.IsNullOrEmpty(_overrideText)) _textComponent.text = _overrideText; } #endif } }