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 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
}
}