using UnityEngine; using TMPro; using System.Collections.Generic; using Unheimlich.Features.Dialogue.Data; using Unheimlich.Features.Dialogue; using Unheimlich.Features.Player; using Unheimlich.Core.Utils; namespace Unheimlich.Features.Dialogue.UI { /// /// Manages individual dialogue choice button behavior including visual feedback during focus, /// dynamic collider sizing, and smooth animations. Responds to player gaze through the focus system. /// public class ChoiceFocusHandler : MonoBehaviour { [Header("Components")] [SerializeField] private TextMeshProUGUI _choiceTextComponent; [SerializeField] private TextMeshProUGUI _ghostTextComponent; public DialogueChoiceData DialogueData { get; private set; } public string GetChoiceText() { return DialogueData?.GetLocalizedChoiceText() ?? string.Empty; } public DialogueNode GetNextNode() { return DialogueData?.NextNode; } private FloatingWordsSpawner.SpawnConfig _config; private bool _hasConfig; [Header("Local Feedback Settings")] [SerializeField] private float _focusLerpSpeed = 10f; [SerializeField] private float _colliderSizeMultiplier = 1.4f; [SerializeField] private float _choiceDistanceFromPlayer = 1f; [Header("Text Sizing Settings")] [Tooltip("Maximum width for short texts (1-3 words)")] [SerializeField] private float _shortTextMaxWidth = 80f; [Tooltip("Maximum width for medium texts (4-5 words)")] [SerializeField] private float _mediumTextMaxWidth = 120f; [Tooltip("Maximum width for long texts (6-10 words)")] [SerializeField] private float _longTextMaxWidth = 200f; [Tooltip("Maximum width for very long texts (11+ words)")] [SerializeField] private float _veryLongTextMaxWidth = 250f; [Tooltip("Minimum height for text container")] [SerializeField] private float _minTextHeight = 40f; private const int FOCUSED_SORTING_ORDER = 10; private const float MAX_FOCUS_SCALE = 1.2f; private BoxCollider _choiceCollider; private Canvas _canvas; private int _originalSortingOrder; private Vector3 _startLocalPos; private float _originalScale; private float _originalTextScale = 1f; private int _textScaleTweenId = -1; private bool _positionCaptured = false; private Vector3 _shakeOffset = Vector3.zero; private Vector3 _shakeTarget = Vector3.zero; private float _shakeTimer = 0f; private bool _isShaking = false; private float _shakeSpeedMultiplier = 1f; private bool _disableFloatingEffects = false; private Queue _positionHistory = new Queue(); private float _historyTimer = 0f; private const float HISTORY_SAMPLE_RATE = 0.016f; private SketchFadeEffect _sketchFadeEffect; void Awake() { _canvas = GetComponentInParent(); if (_canvas != null) _originalSortingOrder = _canvas.sortingOrder; } void Update() { if (_isShaking) UpdateOrganicShake(); UpdateGhostTrail(); } private void SetupGhostTrail() { if (!GetEnableGhostTrail()) return; if (_ghostTextComponent == null && _choiceTextComponent != null) { GameObject ghostObj = new GameObject($"GhostText_{gameObject.name}"); ghostObj.transform.SetParent(transform.parent, false); ghostObj.transform.localPosition = transform.localPosition; ghostObj.transform.localRotation = transform.localRotation; ghostObj.transform.localScale = transform.localScale; ghostObj.layer =gameObject.layer; _ghostTextComponent = ghostObj.AddComponent(); _ghostTextComponent.font = _choiceTextComponent.font; _ghostTextComponent.fontSize = _choiceTextComponent.fontSize; _ghostTextComponent.alignment = _choiceTextComponent.alignment; _ghostTextComponent.textWrappingMode = _choiceTextComponent.textWrappingMode; _ghostTextComponent.text = _choiceTextComponent.text; _ghostTextComponent.rectTransform.sizeDelta = _choiceTextComponent.rectTransform.sizeDelta; _ghostTextComponent.rectTransform.anchorMin = _choiceTextComponent.rectTransform.anchorMin; _ghostTextComponent.rectTransform.anchorMax = _choiceTextComponent.rectTransform.anchorMax; _ghostTextComponent.rectTransform.pivot = _choiceTextComponent.rectTransform.pivot; Color ghostColor = _choiceTextComponent.color; ghostColor.a = GetGhostTrailAlpha(); _ghostTextComponent.color = ghostColor; _positionHistory.Clear(); } } private void UpdateGhostTrail() { if (!GetEnableGhostTrail() || _ghostTextComponent == null || _choiceTextComponent == null) return; _historyTimer += Time.deltaTime; if (_historyTimer >= HISTORY_SAMPLE_RATE) { _positionHistory.Enqueue(transform.localPosition); _historyTimer = 0f; int maxHistorySize = Mathf.CeilToInt(GetGhostTrailDelay() / HISTORY_SAMPLE_RATE); while (_positionHistory.Count > maxHistorySize && _positionHistory.Count > 0) _positionHistory.Dequeue(); } if (_positionHistory.Count > 0) { Vector3 delayedPosition = _positionHistory.Peek(); _ghostTextComponent.transform.localPosition = delayedPosition; _ghostTextComponent.transform.localRotation = transform.localRotation; } else { _ghostTextComponent.transform.localPosition = transform.localPosition; _ghostTextComponent.transform.localRotation = transform.localRotation; } _ghostTextComponent.text = _choiceTextComponent.text; _ghostTextComponent.fontSize = _choiceTextComponent.fontSize; _ghostTextComponent.rectTransform.sizeDelta = _choiceTextComponent.rectTransform.sizeDelta; Color ghostColor = _choiceTextComponent.color; ghostColor.a = GetGhostTrailAlpha(); _ghostTextComponent.color = ghostColor; } private void UpdateOrganicShake() { if (!GetEnableOrganicShake()) return; _shakeTimer += Time.deltaTime * GetShakeSpeed() * _shakeSpeedMultiplier; if (_shakeTimer >= 1f) { _shakeTimer = 0f; _shakeTarget = Random.insideUnitSphere * GetShakeRadius(); } _shakeOffset = Vector3.Lerp(_shakeOffset, _shakeTarget, Time.deltaTime * GetShakeSmoothness()); if (!_disableFloatingEffects) transform.localPosition = _startLocalPos + _shakeOffset; } public Vector3 GetShakeOffset(float convergenceFactor) { return _shakeOffset * convergenceFactor; } public void StartOrganicShake() { if (GetEnableOrganicShake()) { _isShaking = true; _shakeTimer = 0f; _shakeTarget = Random.insideUnitSphere * GetShakeRadius(); } } public void StopOrganicShake() { _isShaking = false; _shakeOffset = Vector3.zero; } /// /// Syncs the ghost word's sibling index so depth sorting covers both the main word and its ghost trail. /// Should be called right after SetSiblingIndex on the main word. /// public void SyncGhostSiblingIndex(int mainSiblingIndex) { if (_ghostTextComponent == null) return; // Place ghost just before its main word so it renders underneath it. int ghostIndex = Mathf.Max(0, mainSiblingIndex - 1); _ghostTextComponent.transform.SetSiblingIndex(ghostIndex); } public void SetShakeSpeedMultiplier(float multiplier) { _shakeSpeedMultiplier = Mathf.Max(0f, multiplier); } public void SetTextScale(float targetScale, float duration = 0.3f) { if (_choiceTextComponent == null) return; TweenHelper.CancelTween(ref _textScaleTweenId); _textScaleTweenId = TweenHelper.ScaleLocal( _choiceTextComponent.gameObject, Vector3.one * targetScale, duration, null, LeanTweenType.easeOutQuad ); } public void ResetTextScale(float duration = 0.3f) { SetTextScale(_originalTextScale, duration); } public void HideGhostTrail() { if (_ghostTextComponent != null) _ghostTextComponent.gameObject.SetActive(false); } public void ShowGhostTrail() { if (_ghostTextComponent != null) _ghostTextComponent.gameObject.SetActive(true); } /// /// Applies progressive visual feedback as player focuses on this choice. /// Scales the choice, changes color, optionally moves toward player, and adjusts sorting order. /// Progress ranges from 0 (unfocused) to 1 (fully focused). /// public void ApplyFocusFeedback(float progress) { if (!_positionCaptured) { // Subtract shake offset to get the clean base position, not the shaken one _startLocalPos = transform.localPosition - _shakeOffset; _positionCaptured = true; LeanTween.cancel(gameObject); StopOrganicShake(); DialogueAnimationSettings settings = GetAnimationSettings(); float textScaleMultiplier = settings != null ? settings.FocusedTextScaleMultiplier : 0.9f; float textScaleDuration = settings != null ? settings.FocusedTextScaleDuration : 0.3f; SetTextScale(_originalTextScale * textScaleMultiplier, textScaleDuration); } // transform.localScale = Vector3.one * Mathf.Lerp(_originalScale, MAX_FOCUS_SCALE, progress); bool shouldMoveToPlayer = GetMoveTowardPlayerOnFocus(); if (shouldMoveToPlayer && PlayerController.Instance != null) { float distance = GetFocusDistanceFromPlayer(); Vector3 targetPos = PlayerController.Instance.transform.position + PlayerController.Instance.transform.forward * distance; transform.position = Vector3.Lerp(transform.position, targetPos, Time.deltaTime * _focusLerpSpeed); } if (_choiceTextComponent != null) { _choiceTextComponent.color = Color.Lerp(Color.white, Color.red, progress); if (_ghostTextComponent != null) { float ghostAlpha = GetGhostTrailAlpha(); Color ghostColor = Color.Lerp(Color.white, Color.red, progress); ghostColor.a = ghostAlpha; _ghostTextComponent.color = ghostColor; } } if (_canvas != null && _canvas.sortingOrder != FOCUSED_SORTING_ORDER) { _canvas.sortingOrder = FOCUSED_SORTING_ORDER; _canvas.overrideSorting = true; } } private DialogueAnimationSettings GetAnimationSettings() { if (DialogueManager.Instance != null) return DialogueManager.Instance.CurrentAnimationSettings; return null; } private bool GetEnableGhostTrail() { if (_hasConfig) return _config.EnableGhostTrail; DialogueAnimationSettings settings = GetAnimationSettings(); return settings != null && settings.EnableGhostTrail; } private float GetGhostTrailAlpha() { if (_hasConfig) return _config.GhostTrailAlpha; DialogueAnimationSettings settings = GetAnimationSettings(); return settings != null ? settings.GhostTrailAlpha : 0.3f; } private float GetGhostTrailDelay() { if (_hasConfig) return _config.GhostTrailDelay; DialogueAnimationSettings settings = GetAnimationSettings(); return settings != null ? settings.GhostTrailDelay : 0.1f; } private bool GetEnableOrganicShake() { if (_hasConfig) return _config.EnableOrganicShake; DialogueAnimationSettings settings = GetAnimationSettings(); return settings != null && settings.EnableOrganicShake; } private float GetShakeRadius() { if (_hasConfig) return _config.ShakeRadius; DialogueAnimationSettings settings = GetAnimationSettings(); return settings != null ? settings.ShakeRadius : 0.01f; } private float GetShakeSpeed() { if (_hasConfig) return _config.ShakeSpeed; DialogueAnimationSettings settings = GetAnimationSettings(); return settings != null ? settings.ShakeSpeed : 1f; } private float GetShakeSmoothness() { if (_hasConfig) return _config.ShakeSmoothness; DialogueAnimationSettings settings = GetAnimationSettings(); return settings != null ? settings.ShakeSmoothness : 5f; } private bool GetMoveTowardPlayerOnFocus() { if (_hasConfig) return _config.MoveTowardPlayerOnFocus; DialogueAnimationSettings settings = GetAnimationSettings(); return settings == null || settings.MoveTowardPlayerOnFocus; } private float GetFocusDistanceFromPlayer() { if (_hasConfig) return _config.FocusDistanceFromPlayer; DialogueAnimationSettings settings = GetAnimationSettings(); return settings != null ? settings.FocusDistanceFromPlayer : _choiceDistanceFromPlayer; } /// /// Initializes the choice button with dialogue data. /// Sets up text, creates/configures collider based on text bounds, and caches initial state. /// public void Setup(DialogueChoiceData dialogueData, FloatingWordsSpawner.SpawnConfig config, bool disableFloatingEffects = false) { DialogueData = dialogueData; _config = config; _hasConfig = true; _disableFloatingEffects = disableFloatingEffects; PerformSetup(); } private void PerformSetup() { if (_choiceTextComponent != null) { _choiceTextComponent.text = GetChoiceText(); _choiceTextComponent.ForceMeshUpdate(); AdjustTextSizeForContent(); } _choiceCollider = GetComponent(); if (_choiceCollider == null) { _choiceCollider = gameObject.AddComponent(); _choiceCollider.isTrigger = true; } if (_choiceTextComponent != null) { Vector3 textSize = _choiceTextComponent.bounds.size; _choiceCollider.size = textSize * _colliderSizeMultiplier; _choiceCollider.center = _choiceTextComponent.rectTransform.localPosition; } _startLocalPos = transform.localPosition; _originalScale = transform.localScale.x; if (_choiceTextComponent != null) _originalTextScale = _choiceTextComponent.transform.localScale.x; LeanTween.cancel(gameObject); SetupGhostTrail(); if (_ghostTextComponent != null) _ghostTextComponent.text = GetChoiceText(); DialogueAnimationSettings settings = GetAnimationSettings(); if (settings != null && settings.EnableSketchFadeOnFocus && settings.SketchMaterial != null) { _sketchFadeEffect = gameObject.AddComponent(); _sketchFadeEffect.Initialize(_choiceTextComponent, _ghostTextComponent, settings.SketchMaterial); } StartOrganicShake(); } private void AdjustTextSizeForContent() { if (_choiceTextComponent == null) return; string text = GetChoiceText(); if (string.IsNullOrEmpty(text)) return; int wordCount = text.Split(new[] { ' ' }, System.StringSplitOptions.RemoveEmptyEntries).Length; float maxWidth = GetMaxWidthForWordCount(wordCount); string longestWord = GetLongestWord(text); Vector2 longestWordSize = _choiceTextComponent.GetPreferredValues(longestWord, float.MaxValue, 0f); if (longestWordSize.x > maxWidth) maxWidth = longestWordSize.x + 10f; _choiceTextComponent.rectTransform.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, maxWidth); _choiceTextComponent.ForceMeshUpdate(); Vector2 textSize = _choiceTextComponent.GetPreferredValues(text, maxWidth, 0f); float finalHeight = Mathf.Max(textSize.y, _minTextHeight); Vector2 newSize = new Vector2(maxWidth, finalHeight); RectTransform parentRect = transform as RectTransform; if (parentRect != null) parentRect.sizeDelta = newSize; ConfigureTextComponent(_choiceTextComponent, newSize); if (_ghostTextComponent != null) ConfigureTextComponent(_ghostTextComponent, newSize); } private void ConfigureTextComponent(TextMeshProUGUI textComponent, Vector2 size) { textComponent.textWrappingMode = TextWrappingModes.Normal; textComponent.overflowMode = TextOverflowModes.Overflow; textComponent.rectTransform.anchorMin = Vector2.one * 0.5f; textComponent.rectTransform.anchorMax = Vector2.one * 0.5f; textComponent.rectTransform.pivot = Vector2.one * 0.5f; textComponent.rectTransform.anchoredPosition = Vector2.zero; textComponent.rectTransform.sizeDelta = size; textComponent.ForceMeshUpdate(); } private string GetLongestWord(string text) { string[] words = text.Split(new[] { ' ' }, System.StringSplitOptions.RemoveEmptyEntries); string longest = ""; foreach (string word in words) { if (word.Length > longest.Length) longest = word; } return longest; } private float GetMaxWidthForWordCount(int wordCount) { if (wordCount <= 2) return _shortTextMaxWidth; else if (wordCount <= 5) return _mediumTextMaxWidth; else if (wordCount <= 10) return _longTextMaxWidth; else return _veryLongTextMaxWidth; } /// /// Smoothly returns the choice to its original state after losing focus. /// Animates position back to start, resets scale/color, and unlocks the focus system when complete. /// public void ResetFeedback(float duration) { LeanTween.cancel(gameObject); TweenHelper.CancelTween(ref _textScaleTweenId); StopOrganicShake(); ResetSketchFade(); // Reset position capture and restart shake immediately — don't rely on a LeanTween // callback for this, since stale tween IDs (Domain Reload disabled) can silently // drop callbacks, leaving some words permanently non-shaking. _positionCaptured = false; StartOrganicShake(); TweenHelper.MoveLocal(gameObject, _startLocalPos, duration, () => { if (DialogueManager.Instance != null) DialogueManager.Instance.UnlockFocus(); }, LeanTweenType.easeOutQuad); transform.localScale = Vector3.one * _originalScale; if (_choiceTextComponent != null) { _choiceTextComponent.transform.localScale = Vector3.one * _originalTextScale; _choiceTextComponent.color = Color.white; if (_ghostTextComponent != null) { float ghostAlpha = GetGhostTrailAlpha(); Color ghostColor = Color.white; ghostColor.a = ghostAlpha; _ghostTextComponent.color = ghostColor; } } if (_canvas != null) _canvas.sortingOrder = _originalSortingOrder; } public void ApplySketchFade() { DialogueAnimationSettings settings = GetAnimationSettings(); if (settings == null || !settings.EnableSketchFadeOnFocus || _sketchFadeEffect == null) return; _sketchFadeEffect.PlaySketchFadeEffect( settings.SketchDuration, settings.FadeOutDuration, settings.FinalFadeAlpha ); } public void ResetSketchFade() { if (_sketchFadeEffect != null) _sketchFadeEffect.ResetEffect(); } } }