Files

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;
}
}
}