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 { /// /// 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 /// 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 _uiRaycasters = new(8); private readonly List _uiResults = new(32); private readonly RaycastHit[] _worldHits = new RaycastHit[8]; private IInteractable _currentHovered; private bool _disposed; public bool Initialized { get; private set; } /// /// If set, only this raycaster is allowed to receive UI interaction. /// Rendering is unaffected; this is input-only. /// 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(OnSelectedItemChanged); _eventCoordinator.Unsubscribe(OnClickReceived); _eventCoordinator.Unsubscribe(OnRightClickReceived); _hoverCts?.Cancel(); _hoverCts?.Dispose(); _currentHovered = null; _exclusiveRaycaster = null; _eventCoordinator.Publish(new HoverInteractableChangedEvent(null)); _eventCoordinator.Publish( new CursorStyleChangeEvent( UICursorService.CursorStyle.Default)); } /// /// Assign the active UI GraphicRaycaster (legacy compatibility). /// This replaces the full registered list. /// 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(); } /// /// Add another UI GraphicRaycaster. /// Safe to call multiple times; duplicates are ignored. /// public void AddUIRaycaster(GraphicRaycaster raycaster) { if (raycaster == null) return; if (!_uiRaycasters.Contains(raycaster)) _uiRaycasters.Add(raycaster); } /// /// Remove a UI GraphicRaycaster. /// 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(); } /// /// Restrict UI interaction to a single raycaster. /// Useful for modal windows that should block all lower UI without disabling canvases. /// public void SetExclusiveRaycaster(GraphicRaycaster raycaster) { _exclusiveRaycaster = raycaster; if (_currentHovered != null) ClearHover().Forget(); } /// /// Clear exclusive mode and return to using all registered raycasters. /// public void ClearExclusiveRaycaster() { _exclusiveRaycaster = null; if (_currentHovered != null) ClearHover().Forget(); } public void Initialize() { Debug.Log("[InteractManager] Initializing..."); _eventCoordinator.Subscribe(OnSelectedItemChanged); _eventCoordinator.Subscribe(OnClickReceived); _eventCoordinator.Subscribe(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(); 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(); 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; } } }