Files
Emberwild/Assets/GAME/Script/UI/CrosshairManager.cs
T
2026-07-22 12:56:13 +02:00

401 lines
14 KiB
C#

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
{
/// <summary>
/// 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.
/// </summary>
[DisallowMultipleComponent]
public class CrosshairManager : MonoBehaviour
{
#region Singleton
/// <summary>
/// Global access point; may be null if no crosshair exists in the scene.
/// </summary>
public static CrosshairManager Instance { get; private set; }
#endregion
#region Serialized Fields
[Header("References")]
/// <summary>
/// The single crosshair icon whose sprite, colour and size are driven by the
/// active CrosshairState.
/// </summary>
[SerializeField] private Image icon;
/// <summary>
/// Label under the crosshair filled with the interactable's prompt.
/// </summary>
[SerializeField] private TMP_Text promptLabel;
[Header("Layout")]
/// <summary>
/// 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.
/// </summary>
[SerializeField] private VerticalLayoutGroup verticalGroup;
/// <summary>
/// Spacing applied to the vertical group outside build mode (the compact reticle).
/// </summary>
[SerializeField] private float idleSpacing = 0f;
/// <summary>
/// Spacing applied to the vertical group while building, to separate the reticle from the cost.
/// </summary>
[SerializeField] private float buildSpacing = 12f;
[Header("Build Cost")]
/// <summary>
/// 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.
/// </summary>
[SerializeField] private GameObject costHolder;
/// <summary>
/// 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.
/// </summary>
[SerializeField] private BuildCostEntryUI[] costEntries;
[Header("States")]
/// <summary>
/// Look used when nothing is hovered (the plain dot).
/// </summary>
[SerializeField] private CrosshairState idleState;
/// <summary>
/// Look used when aiming at an interactable (the hand).
/// </summary>
[SerializeField] private CrosshairState hoverState;
[Header("Transition")]
/// <summary>
/// Duration of the icon size/colour transition, in seconds.
/// </summary>
[SerializeField] private float transitionDuration = 0.25f;
/// <summary>
/// Easing when growing into the hover state (OutBack gives the springy pop).
/// </summary>
[SerializeField] private Ease hoverEase = Ease.OutBack;
/// <summary>
/// Easing when settling back to the idle state.
/// </summary>
[SerializeField] private Ease idleEase = Ease.OutQuad;
/// <summary>
/// Duration of the prompt label fade, in seconds.
/// </summary>
[SerializeField] private float labelFadeDuration = 0.15f;
#endregion
#region State
/// <summary>
/// Active size tween, cached so it can be killed before restarting.
/// </summary>
private Tween sizeTween;
/// <summary>
/// Active colour tween, cached for the same reason.
/// </summary>
private Tween colorTween;
/// <summary>
/// Active label-fade tween, cached for the same reason.
/// </summary>
private Tween labelTween;
/// <summary>
/// The interactable currently hovered; polled each frame so dynamic prompts
/// (e.g. a cooking station whose food finishes cooking) refresh live.
/// </summary>
private IInteractable currentTarget;
/// <summary>
/// Last prompt shown, kept so the label is only rebuilt when it changes.
/// </summary>
private string currentPrompt;
/// <summary>
/// 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.
/// </summary>
private bool isShowingCost;
#endregion
#region Unity Lifecycle
/// <summary>
/// Establishes the singleton.
/// </summary>
private void Awake()
{
if (Instance != null && Instance != this)
{
Debug.LogWarning($"[CrosshairManager] Duplicate instance on '{name}' — destroying it.", this);
Destroy(this);
return;
}
Instance = this;
HideCostEntries();
}
/// <summary>
/// Subscribes to hover changes and snaps to the idle state.
/// </summary>
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);
}
/// <summary>
/// Unsubscribes — must mirror OnEnable exactly.
/// </summary>
private void OnDisable()
{
PlayerEvents.InteractableHoverChanged -= HandleHoverChanged;
PlayerEvents.BuildCostChanged -= HandleBuildCostChanged;
PlayerEvents.BuildModeChanged -= HandleBuildModeChanged;
}
/// <summary>
/// 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.
/// </summary>
private void Update()
{
if (currentTarget == null) return;
string prompt = currentTarget.InteractionPrompt;
if (prompt != currentPrompt)
{
currentPrompt = prompt;
if (!isShowingCost) SetLabel(prompt, animated: true);
}
}
/// <summary>
/// Kills any running tween and releases the singleton on teardown.
/// </summary>
private void OnDestroy()
{
KillTweens();
if (Instance == this)
Instance = null;
}
#endregion
#region Event Handlers
/// <summary>
/// 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).
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
private void HandleBuildModeChanged(bool building) => ApplySpacing(building);
/// <summary>
/// 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).
/// </summary>
private void HandleBuildCostChanged(IReadOnlyList<BuildCostView> 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
/// <summary>
/// Applies a CrosshairState to the icon: swaps the sprite immediately, then
/// tweens size and colour (or sets them instantly when not animated).
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
private void PopulateCost(IReadOnlyList<BuildCostView> 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);
}
/// <summary>
/// Hides every cost entry and restores the prompt for whatever is currently hovered. No-op when
/// no cost is being shown.
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
private void ApplySpacing(bool building)
{
if (verticalGroup == null) return;
verticalGroup.spacing = building ? buildSpacing : idleSpacing;
}
/// <summary>
/// Kills every cached tween so none survive a disable/destroy.
/// </summary>
private void KillTweens()
{
sizeTween?.Kill();
colorTween?.Kill();
labelTween?.Kill();
sizeTween = colorTween = labelTween = null;
}
#endregion
}
}