452 lines
14 KiB
C#
452 lines
14 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Threading;
|
|
using BriarQueen.Framework.Coordinators.Events;
|
|
using BriarQueen.Framework.Events.Gameplay;
|
|
using BriarQueen.Framework.Events.Input;
|
|
using BriarQueen.Framework.Events.UI;
|
|
using BriarQueen.Framework.Managers.Input;
|
|
using BriarQueen.Framework.Managers.Interaction.Data;
|
|
using BriarQueen.Framework.Managers.Player.Data;
|
|
using BriarQueen.Framework.Managers.UI;
|
|
using Cysharp.Threading.Tasks;
|
|
using UnityEngine;
|
|
using UnityEngine.EventSystems;
|
|
using UnityEngine.UI;
|
|
using VContainer;
|
|
|
|
namespace BriarQueen.Framework.Managers.Interaction
|
|
{
|
|
/// <summary>
|
|
/// Unified interaction service (World + UI):
|
|
/// - One hover loop
|
|
/// - One interactable interface
|
|
/// - UI hover takes priority over world hover
|
|
/// - Any topmost UI blocks lower UI/world interaction
|
|
/// - Supports modal/exclusive UI raycasters without disabling canvases
|
|
/// </summary>
|
|
public sealed class InteractManager : IDisposable, IManager
|
|
{
|
|
private const float HOVER_INTERVAL_SECONDS = 0.05f; // 20Hz
|
|
private const float POINTER_MOVE_THRESHOLD_SQR = 4f; // pixels^2
|
|
private const float MAX_RAY_DISTANCE = 250f;
|
|
|
|
private readonly EventCoordinator _eventCoordinator;
|
|
private readonly EventSystem _eventSystem;
|
|
private readonly InputManager _inputManager;
|
|
|
|
// World raycasting
|
|
private readonly LayerMask _layerMask = ~0;
|
|
|
|
private readonly PointerEventData _uiPointerEventData;
|
|
|
|
// Scene-bound UI raycasters
|
|
private readonly List<GraphicRaycaster> _uiRaycasters = new(8);
|
|
private readonly List<RaycastResult> _uiResults = new(32);
|
|
private readonly RaycastHit[] _worldHits = new RaycastHit[8];
|
|
|
|
private IInteractable _currentHovered;
|
|
private bool _disposed;
|
|
|
|
|
|
public bool Initialized { get; private set; }
|
|
|
|
/// <summary>
|
|
/// If set, only this raycaster is allowed to receive UI interaction.
|
|
/// Rendering is unaffected; this is input-only.
|
|
/// </summary>
|
|
private GraphicRaycaster _exclusiveRaycaster;
|
|
|
|
private CancellationTokenSource _hoverCts;
|
|
private Vector2 _lastPointerPos;
|
|
private ItemDataSo _selectedItem;
|
|
|
|
[Inject]
|
|
public InteractManager(
|
|
EventCoordinator eventCoordinator,
|
|
InputManager inputManager,
|
|
EventSystem eventSystem)
|
|
{
|
|
_eventCoordinator = eventCoordinator;
|
|
_inputManager = inputManager;
|
|
_eventSystem = eventSystem;
|
|
|
|
_uiPointerEventData = _eventSystem != null
|
|
? new PointerEventData(_eventSystem)
|
|
: null;
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed)
|
|
return;
|
|
|
|
_disposed = true;
|
|
|
|
_eventCoordinator.Unsubscribe<SelectedItemChangedEvent>(OnSelectedItemChanged);
|
|
_eventCoordinator.Unsubscribe<OnClickEvent>(OnClickReceived);
|
|
_eventCoordinator.Unsubscribe<OnRightClickEvent>(OnRightClickReceived);
|
|
|
|
_hoverCts?.Cancel();
|
|
_hoverCts?.Dispose();
|
|
|
|
_currentHovered = null;
|
|
_exclusiveRaycaster = null;
|
|
|
|
_eventCoordinator.Publish(new HoverInteractableChangedEvent(null));
|
|
_eventCoordinator.Publish(
|
|
new CursorStyleChangeEvent(
|
|
UICursorService.CursorStyle.Default));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Assign the active UI GraphicRaycaster (legacy compatibility).
|
|
/// This replaces the full registered list.
|
|
/// </summary>
|
|
public void SetUIRaycaster(GraphicRaycaster raycaster)
|
|
{
|
|
_uiRaycasters.Clear();
|
|
_uiResults.Clear();
|
|
|
|
if (raycaster != null)
|
|
_uiRaycasters.Add(raycaster);
|
|
|
|
if (_exclusiveRaycaster != null && _exclusiveRaycaster != raycaster)
|
|
_exclusiveRaycaster = null;
|
|
|
|
if (_uiRaycasters.Count == 0 && _currentHovered != null)
|
|
ClearHover().Forget();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Add another UI GraphicRaycaster.
|
|
/// Safe to call multiple times; duplicates are ignored.
|
|
/// </summary>
|
|
public void AddUIRaycaster(GraphicRaycaster raycaster)
|
|
{
|
|
if (raycaster == null)
|
|
return;
|
|
|
|
if (!_uiRaycasters.Contains(raycaster))
|
|
_uiRaycasters.Add(raycaster);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Remove a UI GraphicRaycaster.
|
|
/// </summary>
|
|
public void RemoveUIRaycaster(GraphicRaycaster raycaster)
|
|
{
|
|
if (raycaster == null)
|
|
return;
|
|
|
|
_uiRaycasters.Remove(raycaster);
|
|
_uiResults.Clear();
|
|
|
|
if (_exclusiveRaycaster == raycaster)
|
|
_exclusiveRaycaster = null;
|
|
|
|
if (_uiRaycasters.Count == 0 && _currentHovered != null)
|
|
ClearHover().Forget();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Restrict UI interaction to a single raycaster.
|
|
/// Useful for modal windows that should block all lower UI without disabling canvases.
|
|
/// </summary>
|
|
public void SetExclusiveRaycaster(GraphicRaycaster raycaster)
|
|
{
|
|
_exclusiveRaycaster = raycaster;
|
|
|
|
if (_currentHovered != null)
|
|
ClearHover().Forget();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Clear exclusive mode and return to using all registered raycasters.
|
|
/// </summary>
|
|
public void ClearExclusiveRaycaster()
|
|
{
|
|
_exclusiveRaycaster = null;
|
|
|
|
if (_currentHovered != null)
|
|
ClearHover().Forget();
|
|
}
|
|
|
|
public void Initialize()
|
|
{
|
|
Debug.Log("[InteractManager] Initializing...");
|
|
_eventCoordinator.Subscribe<SelectedItemChangedEvent>(OnSelectedItemChanged);
|
|
_eventCoordinator.Subscribe<OnClickEvent>(OnClickReceived);
|
|
_eventCoordinator.Subscribe<OnRightClickEvent>(OnRightClickReceived);
|
|
|
|
StartHoverLoop();
|
|
Debug.Log("[InteractManager] Initialized.");
|
|
|
|
Initialized = true;
|
|
}
|
|
|
|
private void StartHoverLoop()
|
|
{
|
|
_hoverCts?.Cancel();
|
|
_hoverCts?.Dispose();
|
|
|
|
_hoverCts = new CancellationTokenSource();
|
|
_lastPointerPos = _inputManager.PointerPosition;
|
|
|
|
HoverLoop(_hoverCts.Token).Forget();
|
|
}
|
|
|
|
private async UniTaskVoid HoverLoop(CancellationToken token)
|
|
{
|
|
while (!token.IsCancellationRequested)
|
|
try
|
|
{
|
|
await UniTask.Delay(
|
|
TimeSpan.FromSeconds(HOVER_INTERVAL_SECONDS),
|
|
cancellationToken: token);
|
|
|
|
if (_disposed)
|
|
return;
|
|
|
|
var pointer = _inputManager.PointerPosition;
|
|
var delta = pointer - _lastPointerPos;
|
|
|
|
if (delta.sqrMagnitude < POINTER_MOVE_THRESHOLD_SQR && _currentHovered != null)
|
|
continue;
|
|
|
|
_lastPointerPos = pointer;
|
|
|
|
// Topmost UI always wins. If it is not interactable, it still blocks lower UI/world.
|
|
if (TryRaycastTopUI(pointer, out var uiHit))
|
|
{
|
|
if (uiHit != null)
|
|
await SetHovered(uiHit);
|
|
else
|
|
await ClearHover();
|
|
|
|
continue;
|
|
}
|
|
|
|
// World fallback only if no UI at all was hit.
|
|
var worldHit = RaycastWorld(pointer);
|
|
await SetHovered(worldHit);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
return;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Debug.LogWarning($"[InteractManager] Hover loop error: {ex}");
|
|
}
|
|
}
|
|
|
|
private bool TryRaycastTopUI(Vector2 pointer, out IInteractable interactable)
|
|
{
|
|
|
|
interactable = null;
|
|
|
|
if (_uiPointerEventData == null)
|
|
return false;
|
|
|
|
_uiPointerEventData.Reset();
|
|
_uiPointerEventData.position = pointer;
|
|
|
|
_uiResults.Clear();
|
|
|
|
if (_exclusiveRaycaster != null)
|
|
{
|
|
if (!_exclusiveRaycaster.isActiveAndEnabled)
|
|
return false;
|
|
|
|
_exclusiveRaycaster.Raycast(_uiPointerEventData, _uiResults);
|
|
}
|
|
else
|
|
{
|
|
if (_uiRaycasters.Count == 0)
|
|
return false;
|
|
|
|
for (var r = 0; r < _uiRaycasters.Count; r++)
|
|
{
|
|
var raycaster = _uiRaycasters[r];
|
|
if (raycaster == null || !raycaster.isActiveAndEnabled)
|
|
continue;
|
|
|
|
raycaster.Raycast(_uiPointerEventData, _uiResults);
|
|
}
|
|
}
|
|
|
|
if (_uiResults.Count == 0)
|
|
return false;
|
|
|
|
_uiResults.Sort((a, b) =>
|
|
{
|
|
if (a.sortingLayer != b.sortingLayer)
|
|
return b.sortingLayer.CompareTo(a.sortingLayer);
|
|
|
|
if (a.sortingOrder != b.sortingOrder)
|
|
return b.sortingOrder.CompareTo(a.sortingOrder);
|
|
|
|
if (a.depth != b.depth)
|
|
return b.depth.CompareTo(a.depth);
|
|
|
|
if (!Mathf.Approximately(a.distance, b.distance))
|
|
return a.distance.CompareTo(b.distance);
|
|
|
|
return a.index.CompareTo(b.index);
|
|
});
|
|
|
|
foreach (var rr in _uiResults)
|
|
{
|
|
if (rr.gameObject == null)
|
|
continue;
|
|
|
|
var candidate = rr.gameObject.GetComponentInParent<IInteractable>();
|
|
if (candidate != null)
|
|
{
|
|
interactable = candidate;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return true; // UI was hit, but nothing interactable was found, so UI still blocks world
|
|
}
|
|
|
|
private static bool IsRaycastResultHigherPriority(RaycastResult candidate, RaycastResult currentBest)
|
|
{
|
|
if (candidate.sortingLayer != currentBest.sortingLayer)
|
|
return candidate.sortingLayer > currentBest.sortingLayer;
|
|
|
|
if (candidate.sortingOrder != currentBest.sortingOrder)
|
|
return candidate.sortingOrder > currentBest.sortingOrder;
|
|
|
|
if (candidate.depth != currentBest.depth)
|
|
return candidate.depth > currentBest.depth;
|
|
|
|
if (!Mathf.Approximately(candidate.distance, currentBest.distance))
|
|
return candidate.distance < currentBest.distance;
|
|
|
|
return candidate.index < currentBest.index;
|
|
}
|
|
|
|
private IInteractable RaycastWorld(Vector2 pointer)
|
|
{
|
|
var cam = Camera.main;
|
|
if (cam == null)
|
|
return null;
|
|
|
|
var ray = cam.ScreenPointToRay(pointer);
|
|
var count = Physics.RaycastNonAlloc(
|
|
ray,
|
|
_worldHits,
|
|
MAX_RAY_DISTANCE,
|
|
_layerMask,
|
|
QueryTriggerInteraction.Collide);
|
|
|
|
if (count <= 0)
|
|
return null;
|
|
|
|
var bestDist = float.PositiveInfinity;
|
|
IInteractable best = null;
|
|
|
|
for (var i = 0; i < count; i++)
|
|
{
|
|
var hit = _worldHits[i];
|
|
if (hit.collider == null)
|
|
continue;
|
|
|
|
var candidate = hit.collider.GetComponentInParent<IInteractable>();
|
|
if (candidate == null)
|
|
continue;
|
|
|
|
if (hit.distance < bestDist)
|
|
{
|
|
bestDist = hit.distance;
|
|
best = candidate;
|
|
}
|
|
}
|
|
|
|
return best;
|
|
}
|
|
|
|
private async UniTask SetHovered(IInteractable next)
|
|
{
|
|
if (ReferenceEquals(_currentHovered, next))
|
|
return;
|
|
|
|
if (_currentHovered != null)
|
|
try
|
|
{
|
|
await _currentHovered.ExitHover();
|
|
}
|
|
catch
|
|
{
|
|
}
|
|
|
|
_currentHovered = next;
|
|
|
|
_eventCoordinator.Publish(
|
|
new HoverInteractableChangedEvent(_currentHovered));
|
|
|
|
var cursor =
|
|
_currentHovered?.ApplicableCursorStyle
|
|
?? UICursorService.CursorStyle.Default;
|
|
|
|
_eventCoordinator.Publish(
|
|
new CursorStyleChangeEvent(cursor));
|
|
|
|
if (_currentHovered != null)
|
|
try
|
|
{
|
|
await _currentHovered.EnterHover();
|
|
}
|
|
catch
|
|
{
|
|
}
|
|
}
|
|
|
|
private async UniTask ClearHover()
|
|
{
|
|
if (_currentHovered == null)
|
|
return;
|
|
|
|
try
|
|
{
|
|
await _currentHovered.ExitHover();
|
|
}
|
|
catch
|
|
{
|
|
}
|
|
|
|
_currentHovered = null;
|
|
|
|
_eventCoordinator.Publish(
|
|
new HoverInteractableChangedEvent(null));
|
|
|
|
_eventCoordinator.Publish(
|
|
new CursorStyleChangeEvent(
|
|
UICursorService.CursorStyle.Default));
|
|
}
|
|
|
|
private void OnClickReceived(OnClickEvent evt)
|
|
{
|
|
if (_disposed)
|
|
return;
|
|
|
|
if (_currentHovered == null)
|
|
return;
|
|
|
|
_currentHovered.OnInteract(_selectedItem).Forget();
|
|
}
|
|
|
|
private void OnRightClickReceived(OnRightClickEvent obj)
|
|
{
|
|
_eventCoordinator.Publish(new SelectedItemChangedEvent(null));
|
|
}
|
|
|
|
private void OnSelectedItemChanged(SelectedItemChangedEvent evt)
|
|
{
|
|
_selectedItem = evt.Item;
|
|
}
|
|
}
|
|
} |