using UnityEngine; using Ashwild.Interaction; namespace Ashwild.Player { /// /// Raycasts forward on interact input and triggers the first IInteractable hit. /// Draws a selection gizmo that turns its "can interact" colour and shortens to /// the hit point when the aim is currently pointing at an interactable target. /// [DisallowMultipleComponent] public class PlayerInteractor : MonoBehaviour { #region Serialized Fields [Header("Raycast")] /// /// Maximum distance, in metres, at which an interactable can be triggered. /// [SerializeField] private float interactRange = 3f; /// /// Layers the interaction ray is allowed to hit. /// [SerializeField] private LayerMask interactMask = ~0; /// /// Origin and forward direction of the interaction ray (usually the camera). /// [SerializeField] private Transform raycastOrigin; /// /// How long, in seconds, a new target must stay under the ray before the /// hover change is committed. Debounces fast view sweeps so the crosshair /// animation never flickers on objects only glanced at for a few frames. /// [SerializeField] private float hoverStabilityTime = 0.08f; [Header("Gizmo")] /// /// Gizmo colour when the ray is not aiming at any interactable. /// [SerializeField] private Color idleGizmoColor = Color.green; /// /// Gizmo colour when the ray is aiming at a valid interactable. /// [SerializeField] private Color interactableGizmoColor = Color.cyan; #endregion #region State /// /// The committed hover target broadcast on the bus, reused by interact. /// private IInteractable currentHover; /// /// The target the ray currently sees, pending stability before it is /// committed as the hover (debounce against fast view sweeps). /// private IInteractable candidateHover; /// /// How long the candidate has stayed unchanged, in seconds. /// private float candidateTimer; #endregion #region Unity Lifecycle /// /// Subscribes to the interact input event. /// private void OnEnable() { PlayerEvents.InteractPressed += OnInteractPressed; } /// /// Unsubscribes — must mirror OnEnable exactly — and clears any hover so /// consumers (crosshair) reset when this component is disabled. /// private void OnDisable() { PlayerEvents.InteractPressed -= OnInteractPressed; CommitHover(null); } /// /// Detects the interactable under the aim ray each frame and commits hover /// changes only once a new target has stayed stable for hoverStabilityTime, /// so quick view sweeps never spam the bus. One raycast per frame. /// private void Update() { // While input is locked, drop the hover immediately (no debounce). if (PlayerEvents.InputLocked) { candidateHover = null; candidateTimer = 0f; CommitHover(null); return; } IInteractable raw = null; if (raycastOrigin != null && Physics.Raycast(raycastOrigin.position, raycastOrigin.forward, out RaycastHit hit, interactRange, interactMask, QueryTriggerInteraction.Collide)) { hit.collider.TryGetComponent(out raw); } // Restart the stability timer whenever the seen target changes. if (!ReferenceEquals(raw, candidateHover)) { candidateHover = raw; candidateTimer = 0f; } else { candidateTimer += Time.deltaTime; } // Commit only once the candidate has been stable long enough. if (!ReferenceEquals(candidateHover, currentHover) && candidateTimer >= hoverStabilityTime) CommitHover(candidateHover); } #endregion #region Hover Detection /// /// Stores the committed hover target and raises the bus event when it /// differs from the previous one. /// private void CommitHover(IInteractable hover) { if (ReferenceEquals(hover, currentHover)) return; currentHover = hover; PlayerEvents.RaiseInteractableHoverChanged(hover); } #endregion #region Event Handlers /// /// Interacts with whatever the ray points at right now. Uses a fresh raycast /// rather than the debounced hover so a quick aim-and-click never misses. /// private void OnInteractPressed() { if (PlayerEvents.InputLocked || raycastOrigin == null) return; if (Physics.Raycast(raycastOrigin.position, raycastOrigin.forward, out RaycastHit hit, interactRange, interactMask, QueryTriggerInteraction.Collide) && hit.collider.TryGetComponent(out IInteractable interactable)) { interactable.Interact(); } } #endregion #region Gizmos /// /// Draws the interaction ray when selected. The ray turns the "interactable" /// colour and stops at the hit point whenever it is aiming at an IInteractable; /// otherwise it stays the idle colour and spans the full range. /// private void OnDrawGizmosSelected() { Transform o = raycastOrigin != null ? raycastOrigin : transform; bool hitInteractable = Physics.Raycast(o.position, o.forward, out RaycastHit hit, interactRange, interactMask, QueryTriggerInteraction.Collide) && hit.collider.TryGetComponent(out IInteractable _); float length = hitInteractable ? hit.distance : interactRange; Vector3 endPoint = o.position + o.forward * length; Gizmos.color = hitInteractable ? interactableGizmoColor : idleGizmoColor; Gizmos.DrawRay(o.position, o.forward * length); Gizmos.DrawWireSphere(endPoint, 0.1f); } #endregion } }