570 lines
20 KiB
C#
570 lines
20 KiB
C#
using UnityEngine;
|
|
using TMPro;
|
|
using System.Collections.Generic;
|
|
using Unheimlich.Data.Dialogues;
|
|
using Unheimlich.Managers;
|
|
using Unheimlich.Gameplay.Player;
|
|
|
|
namespace Unheimlich.Interactions.FloatingWords
|
|
{
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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?.ChoiceText ?? 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 = 150f;
|
|
[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<Vector3> _positionHistory = new Queue<Vector3>();
|
|
private float _historyTimer = 0f;
|
|
private const float HISTORY_SAMPLE_RATE = 0.016f;
|
|
|
|
private SketchFadeEffect _sketchFadeEffect;
|
|
|
|
void Awake()
|
|
{
|
|
_canvas = GetComponentInParent<Canvas>();
|
|
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<TextMeshProUGUI>();
|
|
_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;
|
|
|
|
Canvas ghostCanvas = ghostObj.AddComponent<Canvas>();
|
|
ghostCanvas.overrideSorting = true;
|
|
ghostCanvas.sortingOrder = -1;
|
|
|
|
_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;
|
|
}
|
|
|
|
public void SetShakeSpeedMultiplier(float multiplier)
|
|
{
|
|
_shakeSpeedMultiplier = Mathf.Max(0f, multiplier);
|
|
}
|
|
|
|
public void SetTextScale(float targetScale, float duration = 0.3f)
|
|
{
|
|
if (_choiceTextComponent == null)
|
|
return;
|
|
|
|
if (_textScaleTweenId >= 0)
|
|
LeanTween.cancel(_textScaleTweenId);
|
|
|
|
_textScaleTweenId = LeanTween.scale(_choiceTextComponent.gameObject, Vector3.one * targetScale, duration)
|
|
.setEase(LeanTweenType.easeOutQuad)
|
|
.id;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
public void ApplyFocusFeedback(float progress)
|
|
{
|
|
if (!_positionCaptured)
|
|
{
|
|
_startLocalPos = transform.localPosition;
|
|
_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(textScaleMultiplier, textScaleDuration);
|
|
}
|
|
|
|
transform.localScale = Vector3.one * Mathf.Lerp(_originalScale, MAX_FOCUS_SCALE, progress);
|
|
|
|
bool shouldMoveToPlayer = GetMoveTowardPlayerOnFocus();
|
|
|
|
if (shouldMoveToPlayer && Player.Instance != null)
|
|
{
|
|
float distance = GetFocusDistanceFromPlayer();
|
|
Vector3 targetPos = Player.Instance.transform.position
|
|
+ Player.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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Initializes the choice button with dialogue data.
|
|
/// Sets up text, creates/configures collider based on text bounds, and caches initial state.
|
|
/// </summary>
|
|
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<BoxCollider>();
|
|
if (_choiceCollider == null)
|
|
{
|
|
_choiceCollider = gameObject.AddComponent<BoxCollider>();
|
|
_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>();
|
|
_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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public void ResetFeedback(float duration)
|
|
{
|
|
LeanTween.cancel(gameObject);
|
|
|
|
if (_textScaleTweenId >= 0)
|
|
LeanTween.cancel(_textScaleTweenId);
|
|
|
|
StopOrganicShake();
|
|
|
|
ResetSketchFade();
|
|
|
|
LeanTween.moveLocal(gameObject, _startLocalPos, duration)
|
|
.setEase(LeanTweenType.easeOutQuad)
|
|
.setOnComplete(() => {
|
|
_positionCaptured = false;
|
|
|
|
if (DialogueManager.Instance != null)
|
|
DialogueManager.Instance.UnlockFocus();
|
|
|
|
StartOrganicShake();
|
|
});
|
|
|
|
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();
|
|
}
|
|
}
|
|
} |