260 lines
8.0 KiB
C#
260 lines
8.0 KiB
C#
using DG.Tweening;
|
|
using TMPro;
|
|
using UnityEngine;
|
|
using UnityEngine.UI;
|
|
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("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;
|
|
|
|
#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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Subscribes to hover changes and snaps to the idle state.
|
|
/// </summary>
|
|
private void OnEnable()
|
|
{
|
|
PlayerEvents.InteractableHoverChanged += HandleHoverChanged;
|
|
ApplyState(idleState, isHover: false, animated: false);
|
|
SetLabel(null, animated: false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Unsubscribes — must mirror OnEnable exactly.
|
|
/// </summary>
|
|
private void OnDisable()
|
|
{
|
|
PlayerEvents.InteractableHoverChanged -= HandleHoverChanged;
|
|
}
|
|
|
|
/// <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;
|
|
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);
|
|
SetLabel(currentPrompt, animated: true);
|
|
}
|
|
|
|
#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 fades the label in (non-null) or out (null).
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <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
|
|
}
|
|
}
|