using System.Collections.Generic; using DG.Tweening; using TMPro; using UnityEngine; using UnityEngine.UI; using Ashwild.Building; 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("Build Cost")] /// /// Container the per-resource cost entries are parented under while a build is being placed. /// Its own layout (e.g. a HorizontalLayoutGroup) arranges them. /// [SerializeField] private RectTransform costHolder; /// /// Prefab instantiated once per cost line (icon + amount) into the holder. Pooled and reused. /// [SerializeField] private BuildCostEntryUI costEntryPrefab; [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; /// /// True while a build's cost is displayed: the prompt label is suppressed and the holder takes /// over, so a hovered interactable's prompt never fights the cost readout mid-placement. /// private bool isShowingCost; /// /// The instantiated cost entries, pooled and reused across placements — activated up to the /// current cost's line count and hidden beyond it. /// private readonly List costEntries = new List(); #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; PlayerEvents.BuildCostChanged += HandleBuildCostChanged; ApplyState(idleState, isHover: false, animated: false); SetLabel(null, animated: false); } /// /// Unsubscribes — must mirror OnEnable exactly. /// private void OnDisable() { PlayerEvents.InteractableHoverChanged -= HandleHoverChanged; PlayerEvents.BuildCostChanged -= HandleBuildCostChanged; } /// /// 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; if (!isShowingCost) 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); if (!isShowingCost) SetLabel(currentPrompt, animated: true); } /// /// Shows the active build's cost (hiding the prompt label and filling the holder) while placing, /// or clears it and restores the normal prompt when the list is null/empty (placement ended). /// private void HandleBuildCostChanged(IReadOnlyList cost) { if (cost == null || cost.Count == 0) { ClearCost(); return; } isShowingCost = true; SetLabel(null, animated: true); PopulateCost(cost); } #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); } /// /// Fills the holder with one entry per cost line, reusing pooled entries and only instantiating /// when the current build needs more lines than any previous one. Entries beyond this build's /// count are hidden. Logs and no-ops when the holder or prefab reference is missing. /// private void PopulateCost(IReadOnlyList cost) { if (costHolder == null || costEntryPrefab == null) { Debug.LogError("[CrosshairManager] Cost holder or entry prefab not assigned — cannot show the build cost.", this); return; } for (int i = 0; i < cost.Count; i++) { BuildCostEntryUI entry; if (i < costEntries.Count) { entry = costEntries[i]; } else { entry = Instantiate(costEntryPrefab, costHolder); costEntries.Add(entry); } entry.gameObject.SetActive(true); entry.Set(cost[i].Icon, cost[i].Owned, cost[i].Amount, cost[i].Affordable); } for (int i = cost.Count; i < costEntries.Count; i++) if (costEntries[i] != null) costEntries[i].gameObject.SetActive(false); } /// /// Hides every cost entry and restores the prompt for whatever is currently hovered. No-op when /// no cost is being shown. /// private void ClearCost() { if (!isShowingCost) return; isShowingCost = false; for (int i = 0; i < costEntries.Count; i++) if (costEntries[i] != null) costEntries[i].gameObject.SetActive(false); SetLabel(currentPrompt, animated: true); } /// /// Kills every cached tween so none survive a disable/destroy. /// private void KillTweens() { sizeTween?.Kill(); colorTween?.Kill(); labelTween?.Kill(); sizeTween = colorTween = labelTween = null; } #endregion } }