Skip to content

Instantly share code, notes, and snippets.

@adammyhre
Created August 16, 2026 08:19
Show Gist options
  • Select an option

  • Save adammyhre/48e438b1c9a0823eff5feeabcf04394a to your computer and use it in GitHub Desktop.

Select an option

Save adammyhre/48e438b1c9a0823eff5feeabcf04394a to your computer and use it in GitHub Desktop.
Flexible Interaction System for Unity
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");
}
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);
}
}
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