Created
August 16, 2026 08:19
-
-
Save adammyhre/48e438b1c9a0823eff5feeabcf04394a to your computer and use it in GitHub Desktop.
Flexible Interaction System for Unity
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| using System.Collections.Generic; | |
| using UnityEngine; | |
| public class DoorInteractable : MonoBehaviour, IInteractable { | |
| [SerializeField] InteractionUI ui; | |
| bool isOpen; | |
| List<InteractionAction> actions; | |
| public InteractionUI UI => ui; | |
| public List<InteractionAction> GetAvailableActions(Interactor interactor) => actions; | |
| void Awake() { | |
| actions = new List<InteractionAction>(2) { | |
| new InteractionAction("Open", _ => !isOpen, _ => Open(), "already open"), | |
| new InteractionAction("Finish", null, i => i.ExitMenu()) | |
| }; | |
| } | |
| void Open() { | |
| isOpen = true; | |
| Debug.Log("Door opened"); | |
| } | |
| public string GetPrompt() => isOpen | |
| ? "Door is open — Hold E for options" | |
| : "Near door: Press E to open / Hold E for options"; | |
| public void OnInteractionStarted(Interactor interactor) => Debug.Log("Door interaction started"); | |
| public void OnInteractionEnded(Interactor interactor) => Debug.Log("Door interaction ended"); | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| using System; | |
| using System.Collections.Generic; | |
| public interface IInteractable { | |
| InteractionUI UI { get; } | |
| string GetPrompt(); | |
| List<InteractionAction> GetAvailableActions(Interactor interactor); | |
| void OnInteractionStarted(Interactor interactor); | |
| void OnInteractionEnded(Interactor interactor); | |
| } | |
| public class InteractionAction { | |
| public string Name; | |
| public string UnavailableHint; | |
| public Func<Interactor, bool> IsAvailable; | |
| public Action<Interactor> Execute; | |
| public InteractionAction(string name, Func<Interactor, bool> isAvailable, Action<Interactor> execute, string unavailableHint = null) { | |
| Name = name; | |
| IsAvailable = isAvailable; | |
| Execute = execute; | |
| UnavailableHint = unavailableHint; | |
| } | |
| public bool CanExecute(Interactor interactor) { | |
| return IsAvailable == null || IsAvailable(interactor); | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| using System.Collections.Generic; | |
| using ImprovedTimers; // https://github.com/adammyhre/Unity-Improved-Timers | |
| using UnityEngine; | |
| using UnityEngine.InputSystem; | |
| public class Interactor : MonoBehaviour { | |
| #region Fields | |
| [SerializeField] float range = 2.5f; | |
| [SerializeField] LayerMask interactMask = 1 << 8; | |
| [SerializeField] Key interactKey = Key.E; | |
| [SerializeField] float holdDuration = 1f; | |
| [SerializeField, Min(8)] int maxOverlapHits = 16; | |
| public bool ControlsLocked { get; private set; } | |
| IInteractable current; | |
| CountdownTimer holdTimer; | |
| Collider[] overlapHits; | |
| bool inMenu; | |
| bool holdConsumed; | |
| bool holdKeyWasDown; | |
| #endregion | |
| InteractionUI ActiveUi => current?.UI; | |
| void Awake() { | |
| overlapHits = new Collider[Mathf.Max(8, maxOverlapHits)]; | |
| holdTimer = new CountdownTimer(holdDuration); | |
| holdTimer.OnTimerStart += HandleHoldStarted; | |
| holdTimer.OnTimerStop += HandleHoldStopped; | |
| } | |
| void DetectTarget() { | |
| IInteractable nearest = null; | |
| float best = range * range; | |
| var hitCount = Physics.OverlapSphereNonAlloc(transform.position, range, overlapHits, interactMask, QueryTriggerInteraction.Ignore); | |
| for (int i = 0; i < hitCount; i++) { | |
| var hit = overlapHits[i]; | |
| if (!hit) continue; | |
| var interactable = hit.GetComponentInParent<IInteractable>(); | |
| if (interactable == null || interactable.UI == null) continue; | |
| var target = (interactable as Component).transform; | |
| float sqr = (target.position - transform.position).sqrMagnitude; | |
| if (sqr >= best) continue; | |
| best = sqr; | |
| nearest = interactable; | |
| } | |
| if (nearest == current) { | |
| if (current != null && !inMenu) ActiveUi.ShowPrompt(current.GetPrompt()); | |
| return; | |
| } | |
| if (holdTimer.IsRunning) CancelHold(); | |
| ActiveUi?.HideAll(); | |
| current = nearest; | |
| if (current != null) ActiveUi.ShowPrompt(current.GetPrompt()); | |
| } | |
| void CancelHold() { | |
| if (holdTimer.IsRunning) holdTimer.Stop(); | |
| ActiveUi?.HideHoldProgress(); | |
| } | |
| void QuickInteract() { | |
| List<InteractionAction> actions = current.GetAvailableActions(this); | |
| for (int i = 0; i < actions.Count; i++) { | |
| InteractionAction action = actions[i]; | |
| if (action.Name == "Finish" || !action.CanExecute(this)) continue; | |
| action.Execute(this); | |
| return; | |
| } | |
| } | |
| void HandleHoldStarted() => ActiveUi?.ShowHoldProgress(); | |
| void HandleHoldStopped() { | |
| ActiveUi?.HideHoldProgress(); | |
| if (!holdTimer.IsFinished || holdConsumed || current == null) return; | |
| holdConsumed = true; | |
| EnterMenu(); | |
| } | |
| void HandleMenu() { | |
| var kb = Keyboard.current; | |
| if (kb == null) return; | |
| if (kb.digit1Key.wasPressedThisFrame) TrySelect(0); | |
| if (kb.digit2Key.wasPressedThisFrame) TrySelect(1); | |
| if (kb.digit3Key.wasPressedThisFrame) TrySelect(2); | |
| if (kb.digit4Key.wasPressedThisFrame) TrySelect(3); | |
| if (kb.escapeKey.wasPressedThisFrame) ExitMenu(); | |
| } | |
| void TrySelect(int index) { | |
| List<InteractionAction> actions = current.GetAvailableActions(this); | |
| if (index < 0 || index >= actions.Count) return; | |
| InteractionAction action = actions[index]; | |
| if (!action.CanExecute(this)) return; | |
| action.Execute(this); | |
| if (action.Name == "Finish") { | |
| ExitMenu(); | |
| return; | |
| } | |
| ActiveUi.ShowActions(actions, this); | |
| } | |
| void EnterMenu() { | |
| inMenu = true; | |
| ControlsLocked = true; | |
| current.OnInteractionStarted(this); | |
| ActiveUi.ShowActions(current.GetAvailableActions(this), this); | |
| } | |
| public void ExitMenu() { | |
| if (!inMenu) return; | |
| current.OnInteractionEnded(this); | |
| inMenu = false; | |
| ControlsLocked = false; | |
| ActiveUi?.HideActions(); | |
| } | |
| void HandlePressHold() { | |
| var kb = Keyboard.current; | |
| if (kb == null) return; | |
| var key = kb[interactKey]; | |
| bool down = key.isPressed; | |
| if (down && !holdKeyWasDown) { | |
| holdConsumed = false; | |
| if (current != null) { | |
| holdTimer.Reset(holdDuration); | |
| holdTimer.Start(); | |
| } | |
| } | |
| if (!down && holdKeyWasDown) { | |
| if (holdTimer.IsRunning) { | |
| CancelHold(); | |
| if (current != null && !holdConsumed) QuickInteract(); | |
| } else if (current == null && !holdConsumed) { | |
| Debug.Log("Nothing to interact with — move closer to a target."); | |
| } | |
| } | |
| holdKeyWasDown = down; | |
| } | |
| void Update() { | |
| if (inMenu) { | |
| HandleMenu(); | |
| return; | |
| } | |
| DetectTarget(); | |
| HandlePressHold(); | |
| if (holdTimer.IsRunning && ActiveUi != null) | |
| ActiveUi.SetHoldProgress(1f - holdTimer.Progress); | |
| } | |
| void OnDestroy() { | |
| if (holdTimer == null) return; | |
| holdTimer.OnTimerStart -= HandleHoldStarted; | |
| holdTimer.OnTimerStop -= HandleHoldStopped; | |
| holdTimer.Dispose(); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment