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("Layout")] /// /// The vertical group stacking the crosshair icon, prompt and cost holder. Its spacing widens in /// build mode so the build cost readout gets room below the reticle, and tightens back otherwise. /// [SerializeField] private VerticalLayoutGroup verticalGroup; /// /// Spacing applied to the vertical group outside build mode (the compact reticle). /// [SerializeField] private float idleSpacing = 0f; /// /// Spacing applied to the vertical group while building, to separate the reticle from the cost. /// [SerializeField] private float buildSpacing = 12f; [Header("Build Cost")] /// /// The "Ressources Needed" container holding the cost entries. Activated while a build cost is /// shown and deactivated otherwise, so the whole readout (background, layout) disappears with it. /// [SerializeField] private GameObject costHolder; /// /// The pre-placed cost entries, assigned in the inspector in display order. Authored in the UI /// (not instantiated at runtime) — this manager only fills and shows/hides them per the active /// build's cost. /// [SerializeField] private BuildCostEntryUI[] costEntries; [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; #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; HideCostEntries(); } /// /// Subscribes to hover changes and snaps to the idle state. /// private void OnEnable() { PlayerEvents.InteractableHoverChanged += HandleHoverChanged; PlayerEvents.BuildCostChanged += HandleBuildCostChanged; PlayerEvents.BuildModeChanged += HandleBuildModeChanged; ApplyState(idleState, isHover: false, animated: false); SetLabel(null, animated: false); ApplySpacing(PlayerEvents.IsBuilding); } /// /// Unsubscribes — must mirror OnEnable exactly. /// private void OnDisable() { PlayerEvents.InteractableHoverChanged -= HandleHoverChanged; PlayerEvents.BuildCostChanged -= HandleBuildCostChanged; PlayerEvents.BuildModeChanged -= HandleBuildModeChanged; } /// /// 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); } /// /// Widens the vertical group in build mode (so the cost readout gets room) and tightens it back /// out of build mode. Pure view reaction to the composite bus state — no build logic here. /// private void HandleBuildModeChanged(bool building) => ApplySpacing(building); /// /// 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; if (costHolder != null) costHolder.SetActive(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 shows/hides the label. The label object starts inactive in the /// prefab, so it is activated when there is a prompt and deactivated when there is none — the /// alpha fade only runs while it is active. /// private void SetLabel(string prompt, bool animated) { if (promptLabel == null) return; bool show = !string.IsNullOrEmpty(prompt); promptLabel.gameObject.SetActive(show); if (!show) return; promptLabel.text = prompt; labelTween?.Kill(); if (!animated) { promptLabel.alpha = 1f; return; } promptLabel.alpha = 0f; labelTween = DOTween.To(() => promptLabel.alpha, a => promptLabel.alpha = a, 1f, labelFadeDuration); } /// /// Hides the cost holder and every inspector-assigned cost entry at startup, so PopulateCost only /// ever fills and toggles them. No-op on the parts that are unassigned. /// private void HideCostEntries() { if (costHolder != null) costHolder.SetActive(false); if (costEntries == null) return; for (int i = 0; i < costEntries.Length; i++) if (costEntries[i] != null) costEntries[i].gameObject.SetActive(false); } /// /// Fills and shows one assigned entry per cost line, hiding the entries beyond this build's line /// count. Warns and caps when a build needs more lines than there are assigned entries — add more /// entries in the inspector rather than relying on runtime instantiation. /// private void PopulateCost(IReadOnlyList cost) { if (costEntries == null || costEntries.Length == 0) { Debug.LogError("[CrosshairManager] No cost entries assigned — cannot show the build cost.", this); return; } int shown = Mathf.Min(cost.Count, costEntries.Length); if (cost.Count > costEntries.Length) Debug.LogWarning($"[CrosshairManager] Build needs {cost.Count} cost lines but only {costEntries.Length} entries exist — extra lines are hidden.", this); for (int i = 0; i < shown; i++) { if (costEntries[i] == null) continue; costEntries[i].gameObject.SetActive(true); costEntries[i].Set(cost[i].Icon, cost[i].Owned, cost[i].Amount, cost[i].Affordable); } for (int i = shown; i < costEntries.Length; 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; if (costEntries != null) for (int i = 0; i < costEntries.Length; i++) if (costEntries[i] != null) costEntries[i].gameObject.SetActive(false); if (costHolder != null) costHolder.SetActive(false); SetLabel(currentPrompt, animated: true); } /// /// Sets the vertical group's spacing to the build or idle value. No-op when no group is wired, /// so the crosshair still works on a rig that has no layout group. /// private void ApplySpacing(bool building) { if (verticalGroup == null) return; verticalGroup.spacing = building ? buildSpacing : idleSpacing; } /// /// Kills every cached tween so none survive a disable/destroy. /// private void KillTweens() { sizeTween?.Kill(); colorTween?.Kill(); labelTween?.Kill(); sizeTween = colorTween = labelTween = null; } #endregion } }