using DG.Tweening; using TMPro; using UnityEngine; using UnityEngine.UI; using Ashwild.Interaction; using Ashwild.Player; namespace Ashwild.UI { /// /// Singleton that drives the crosshair from the interaction hover state. It owns /// a single icon Image and a prompt label, and swaps between two CrosshairState /// ScriptableObjects (idle dot vs interact hand) — interpolating sprite, tint and /// size with a DOTween bump. Pure bus consumer: it never raycasts. /// [DisallowMultipleComponent] public class CrosshairManager : MonoBehaviour { #region Singleton /// /// Global access point; may be null if no crosshair exists in the scene. /// public static CrosshairManager Instance { get; private set; } #endregion #region Serialized Fields [Header("References")] /// /// The single crosshair icon whose sprite, colour and size are driven by the /// active CrosshairState. /// [SerializeField] private Image icon; /// /// Label under the crosshair filled with the interactable's prompt. /// [SerializeField] private TMP_Text promptLabel; [Header("States")] /// /// Look used when nothing is hovered (the plain dot). /// [SerializeField] private CrosshairState idleState; /// /// Look used when aiming at an interactable (the hand). /// [SerializeField] private CrosshairState hoverState; [Header("Transition")] /// /// Duration of the icon size/colour transition, in seconds. /// [SerializeField] private float transitionDuration = 0.25f; /// /// Easing when growing into the hover state (OutBack gives the springy pop). /// [SerializeField] private Ease hoverEase = Ease.OutBack; /// /// Easing when settling back to the idle state. /// [SerializeField] private Ease idleEase = Ease.OutQuad; /// /// Duration of the prompt label fade, in seconds. /// [SerializeField] private float labelFadeDuration = 0.15f; #endregion #region State /// /// Active size tween, cached so it can be killed before restarting. /// private Tween sizeTween; /// /// Active colour tween, cached for the same reason. /// private Tween colorTween; /// /// Active label-fade tween, cached for the same reason. /// private Tween labelTween; /// /// The interactable currently hovered; polled each frame so dynamic prompts /// (e.g. a cooking station whose food finishes cooking) refresh live. /// private IInteractable currentTarget; /// /// Last prompt shown, kept so the label is only rebuilt when it changes. /// private string currentPrompt; #endregion #region Unity Lifecycle /// /// Establishes the singleton. /// private void Awake() { if (Instance != null && Instance != this) { Debug.LogWarning($"[CrosshairManager] Duplicate instance on '{name}' — destroying it.", this); Destroy(this); return; } Instance = this; } /// /// Subscribes to hover changes and snaps to the idle state. /// private void OnEnable() { PlayerEvents.InteractableHoverChanged += HandleHoverChanged; ApplyState(idleState, isHover: false, animated: false); SetLabel(null, animated: false); } /// /// Unsubscribes — must mirror OnEnable exactly. /// private void OnDisable() { PlayerEvents.InteractableHoverChanged -= HandleHoverChanged; } /// /// Re-reads the hovered target's prompt each frame so a prompt that changes /// while the player keeps aiming (cooking finishing, held item swapped) stays /// in sync. Does nothing while nothing is hovered. /// private void Update() { if (currentTarget == null) return; string prompt = currentTarget.InteractionPrompt; if (prompt != currentPrompt) { currentPrompt = prompt; SetLabel(prompt, animated: true); } } /// /// Kills any running tween and releases the singleton on teardown. /// private void OnDestroy() { KillTweens(); if (Instance == this) Instance = null; } #endregion #region Event Handlers /// /// Grows to the hover look with the action prompt when a target is hovered, /// or returns to the idle dot when the hover clears (target is null). /// private void HandleHoverChanged(IInteractable target) { bool hovering = target != null; currentTarget = target; currentPrompt = hovering ? target.InteractionPrompt : null; ApplyState(hovering ? hoverState : idleState, hovering, animated: true); SetLabel(currentPrompt, animated: true); } #endregion #region Internal Helpers /// /// Applies a CrosshairState to the icon: swaps the sprite immediately, then /// tweens size and colour (or sets them instantly when not animated). /// private void ApplyState(CrosshairState state, bool isHover, bool animated) { if (icon == null) return; if (state == null) { Debug.LogError("[CrosshairManager] A CrosshairState is not assigned.", this); return; } icon.sprite = state.Sprite; sizeTween?.Kill(); colorTween?.Kill(); if (!animated) { icon.rectTransform.sizeDelta = state.Size; icon.color = state.Color; return; } Ease ease = isHover ? hoverEase : idleEase; sizeTween = DOTween.To(() => icon.rectTransform.sizeDelta, v => icon.rectTransform.sizeDelta = v, state.Size, transitionDuration).SetEase(ease); colorTween = DOTween.To(() => icon.color, c => icon.color = c, state.Color, transitionDuration); } /// /// Sets the prompt text and fades the label in (non-null) or out (null). /// private void SetLabel(string prompt, bool animated) { if (promptLabel == null) return; bool show = !string.IsNullOrEmpty(prompt); if (show) promptLabel.text = prompt; labelTween?.Kill(); float target = show ? 1f : 0f; if (!animated) { promptLabel.alpha = target; return; } labelTween = DOTween.To(() => promptLabel.alpha, a => promptLabel.alpha = a, target, labelFadeDuration); } /// /// Kills every cached tween so none survive a disable/destroy. /// private void KillTweens() { sizeTween?.Kill(); colorTween?.Kill(); labelTween?.Kill(); sizeTween = colorTween = labelTween = null; } #endregion } }