Skip to content

Interaction

Interactables, player-facing verbs, and range/facing focus selection — the "what can I press E on" layer. Doc 07 catalog §33 frames this as a category with no existing king in the Kit's space, so it's cheap to be best: the scope stays tight (focus selection + focus/blur/interact notification) and deliberately punts prompt-UI polish and hold-to-confirm primitives to a later T2 UI wave.

Input-agnostic by design: nothing in this module reads UnityEngine.Input or any input system. The consumer's own input code calls InteractionDetectorComponent.TryInteract() when its "interact pressed" action fires — the module doesn't know or care whether that's a keyboard key, a gamepad button, or a UI click.

What it is

  • Headless core — InteractionResolver: a pure C# class (zero UnityEngine/engine references) that picks the single best InteractionCandidate from plain data — no interactable references, no transforms, just (handle, distanceSqr, facingDot, enabled) records the caller built. Fully unit-testable without Unity; portable if the Kit is ever spun out standalone.
  • IInteractable: the contract the focus/interact system consumes — identity (InteractableId), eligibility (CanInteract), and the player-facing Verb to show.
  • InteractionVerb: a small serializable class holding a verb's raw localization table + key (Display assembles the LocString on demand), per the Kit's LocString-on-a-serialized-field convention (see Calendar's CalendarConfig.PhaseDefinition).
  • InteractableComponent: the stock MonoBehaviour implementation of IInteractable. Placed on any world object a player should be able to interact with — data plus notification, no logic. Registers itself with InteractableRegistry while enabled.
  • InteractableRegistry: a static registered-list of currently enabled InteractableComponents — the module's candidate-gathering strategy instead of a Physics/Physics2D overlap query, so Interaction has no physics dependency at this scope.
  • InteractionDetectorComponent: the active side. Placed on a prober (typically the player; works equally for a turret's target picker or an NPC's dialogue picker). Gathers candidates from InteractableRegistry, computes distance/facing relative to itself, feeds InteractionResolver, tracks the result as CurrentFocus, and raises focus/blur/interact notifications — both pooled GameEvents and plain C# events for local wiring.

There is no service and no IServiceRegistrar for Interaction — unlike WorldFlags/Calendar, there is nothing global to register. InteractableComponent/InteractionDetectorComponent are opt-in by usage: drop them on the GameObjects that need them. GameplayKitConfig.enableInteraction is reserved — it exists in the config asset for the toggle's future use, but nothing in this module currently reads it. A module this component-driven has no boot-time registration step to gate; revisit if a later addition (e.g. a shared tuning config) needs one.

Quickstart

  1. Add InteractableComponent to a world object. Assign its Verb (localization table + key — e.g. table Interaction, key VERB_OPEN) in the Inspector; leave Interactable Id empty to fall back to the GameObject's name.
  2. Add InteractionDetectorComponent to the prober (typically the player), and tune Range and Min Facing Dot to taste (defaults: 3 world units, 0.5 — roughly a 60° cone).
  3. Refresh runs automatically every Update by default (autoRefresh, on by default) — disable it to drive Refresh() manually on your own cadence instead (a fixed simulation tick, for example).
  4. Wire your own input to TryInteract():
var detector = GetComponent<InteractionDetectorComponent>();

// From your input system's "interact pressed" callback — this module never reads input itself.
if (myInputAction.WasPressedThisFrame())
{
    detector.TryInteract();
}
  1. React to focus changes and interactions (see Samples/Scripts/InteractionSample.cs for the full listener pattern):
public class PromptUI : MonoBehaviour,
    IEventListener<InteractableFocusedEvent>,
    IEventListener<InteractableUnfocusedEvent>,
    IEventListener<InteractionPerformedEvent>
{
    void OnEnable()  { this.EventStartListening<InteractableFocusedEvent>(); this.EventStartListening<InteractableUnfocusedEvent>(); this.EventStartListening<InteractionPerformedEvent>(); }
    void OnDisable() { this.EventStopListening<InteractableFocusedEvent>();  this.EventStopListening<InteractableUnfocusedEvent>();  this.EventStopListening<InteractionPerformedEvent>(); }

    public void OnGameEvent(InteractableFocusedEvent evt)
    {
        string verb = localizationService.Format(new LocString(LocTable.From(evt.VerbLocTable), evt.VerbLocKey));
        // show "[E] <verb>" prompt
    }

    public void OnGameEvent(InteractableUnfocusedEvent evt) { /* hide prompt */ }
    public void OnGameEvent(InteractionPerformedEvent evt) { /* e.g. play a confirm sound */ }
}

Or skip the pooled event entirely and wire local C# events directly on the detector — cheaper for a single tightly-coupled consumer (e.g. the player's own HUD):

detector.Focused   += interactable => promptUI.Show(interactable.Verb.Display);
detector.Unfocused += _ => promptUI.Hide();

API overview

InteractionResolver (headless core)

Member Notes
TryResolveBest(candidates, maxRangeSqr, minFacingDot, out best) Returns the best eligible candidate, or false if none is eligible. See the selection rule below.

InteractableComponent (IInteractable)

Member Notes
InteractableId The assigned id, or the GameObject's name if left empty.
CanInteract Backed by the Interaction Enabled toggle; not tied to the GameObject's active state.
Verb The assigned InteractionVerb.
InteractionPoint The assigned override Transform, or this transform if none is assigned — what distance/facing are measured against.
InteractionEnabled Get/set the toggle at runtime (locked doors, depleted resources, etc.) without disabling the GameObject.
Interact() Fires the Interacted C# event then the Inspector-wired onInteracted UnityEvent. No eligibility check — the caller (normally the detector) is responsible for having confirmed CanInteract.
event Action<InteractableComponent> Interacted Code-side notification, alongside the Inspector UnityEvent.

InteractionDetectorComponent

Member Notes
Range, MinFacingDot Tunable at runtime; back the Inspector fields of the same names.
CurrentFocus The currently focused InteractableComponent, or null.
Refresh() Recomputes candidates and updates CurrentFocus, raising focus/blur notifications on a change. Runs automatically every Update unless autoRefresh is disabled.
TryInteract() Interacts with CurrentFocus if any and still CanInteract. Returns whether it actually happened. The consumer's own input code calls this.
event Action<InteractableComponent> Focused, Unfocused, Interacted Local (plain C#) counterparts to the pooled events below, for consumers that don't want IEventManager plumbing.

Key design decisions

  • Selection rule: a candidate is eligible when it is Enabled, its DistanceSqr is at or under the configured range (squared), and its FacingDot is at or above the configured minimum facing dot. Among eligible candidates, the smallest DistanceSqr wins. Ties (equal distance) resolve to whichever eligible candidate appears first in the input list — the running best is only replaced on a strictly smaller distance, so tie-break order is entirely a function of the input list's order (which, for InteractionDetectorComponent, is InteractableRegistry.Active's registration order). A range/facing threshold is inclusive at the boundary (exactly at max range, or exactly at the minimum facing dot, is eligible).
  • Registered-list, not physics: InteractableRegistry is a static list every enabled InteractableComponent adds/removes itself to/from — the module's candidate-gathering strategy instead of a Physics/Physics2D overlap query. Keeps Interaction free of a physics dependency at this scope; a consumer that wants physics-gated visibility (line-of-sight, occlusion) layers that on top by toggling InteractionEnabled or by filtering before handing candidates to the resolver.
  • InteractableComponent is thin — data plus notification, no logic: it does not decide focus, does not check range/facing, and Interact() performs no eligibility check of its own. All selection logic lives in InteractionResolver/InteractionDetectorComponent; the interactable just holds its verb/id/enabled state and fires notifications when told to.
  • Handles are caller-owned ints, not interactable references: InteractionCandidate.Handle is whatever the caller wants it to mean — InteractionDetectorComponent uses it as the candidate's index into the InteractableRegistry.Active snapshot it built that refresh, so it can map a winning candidate straight back to its InteractableComponent without the headless resolver ever touching an engine type.
  • enableInteraction is reserved, not wired: GameplayKitConfig.enableInteraction exists for forward-compatibility (a future shared config or service gate) but nothing in this module reads it today — a deliberate scope decision, not an oversight. There is no IServiceRegistrar for Interaction to gate in the first place.
  • LocString on a serialized field has no core precedent to copy: InteractionVerb follows the same pattern as Calendar's CalendarConfig.PhaseDefinitionLocString is sealed, has no parameterless constructor, and isn't [Serializable], so it cannot be a direct [SerializeField]. The raw table name + key are serialized instead, and Display assembles the LocString on demand.

Events contract

InteractableFocusedEvent, InteractableUnfocusedEvent, and InteractionPerformedEvent (all GameEvent) are dispatched via IEventManager.TriggerEvent — pooled, per the Kit's Events contract. Each carries InteractableId, VerbLocTable, and VerbLocKey — enough for a listener to build the interactable's LocString (new LocString(LocTable.From(evt.VerbLocTable), evt.VerbLocKey)) without holding a live reference to the interactable itself.

Order on a direct focus switch (one interactable focused while another is still in frame) is deterministic: InteractableUnfocusedEvent (old target) → InteractableFocusedEvent (new target). InteractionDetectorComponent being disabled while something is focused also raises InteractableUnfocusedEvent for the outgoing target — a detector going away is a blur, the same as its target going out of range.

If the focused interactable's GameObject is destroyed, the next Refresh() still raises the blur — but from an identity snapshot cached at focus time, since the dead component can no longer be read: the pooled InteractableUnfocusedEvent carries the cached InteractableId/verb keys, while the local Unfocused C# event receives null (there is no live component to pass). Check for null in local handlers that dereference the target.

InteractionDetectorComponent resolves IEventManager once, in Start() (matching the Kit's "Start(), not Awake()" convention for service resolution — services may not be up yet in Awake). If IEventManager is not available, the detector still works — focus tracking and TryInteract() behave identically, they just don't raise the pooled Interactable*Events (the local C# events on the detector fire either way, since that path doesn't go through the engine event system). No warning is logged for this — unlike WorldFlags/Calendar, Interaction has no service Initialize() to log from once, and a component silently degrading on every instance would be noisy; the detector's local C# events remain fully functional as the fallback.

Save behavior

Interaction has no save domain. InteractableComponent's enabled/disabled state (InteractionEnabled) is ordinary component state — if a consumer needs it to survive a save, it is exactly the kind of small per-object flag WorldFlags exists for (e.g. flags.SetBool("chest.duke_room.opened", true) and gate InteractionEnabled off that on load). InteractionDetectorComponent.CurrentFocus is transient UI-adjacent state, recomputed every Refresh() — never persisted.

Seam ledger

Per the Kit's portability discipline (doc 07, "Portability discipline"), every module's docs page lists exactly which engine surface it consumes. InteractionResolver (the headless core) consumes none — zero UnityEngine/LoLEngine.Core references. The rest of the module consumes:

Engine surface Namespace Used for
MonoBehaviour, Transform, [SerializeField], [Tooltip], [Range], [DisallowMultipleComponent], [RequireComponent] UnityEngine InteractableComponent/InteractionDetectorComponent's component shape.
UnityEvent UnityEngine.Events InteractableComponent's designer-wired onInteracted hook.
IEventManager, GameEvent LoLEngine.Core.Events.Interfaces / LoLEngine.Core.Events.GameEvents Pooled Interactable*Event/InteractionPerformedEvent dispatch.
ServiceLocator LoLEngine.Core.ServiceManagement.Service Resolving the optional IEventManager in InteractionDetectorComponent.Start().
LocString, LocTable LoLEngine.Core.Localization.LocString Verb display names (InteractionVerb.Display, resolved via ILocalizationService.Format by the consumer).
UnityEngine.Debug UnityEngine InteractableComponent.OnValidate's edit-time misconfiguration warnings.
RuntimeInitializeOnLoadMethod UnityEngine InteractableRegistry's domain-reload-safe static reset, mirroring the engine's EventRegister/LocString reset convention for Fast Enter Play Mode.

InteractionSample (the sample script, not part of the module's runtime surface) additionally consumes ILocalizationService to resolve verb display names for logging, and IEventListener<T>/EventRegister for the pooled-event listener pattern — expected for a consumer-facing sample, out of scope for the portability discipline (which concerns the module's own runtime code, not samples). Per the module's input-agnostic contract, the sample deliberately does not poll UnityEngine.Input; it exposes TryInteractWithFocus() as a [ContextMenu] action instead, standing in for whatever input hookup a real consumer wires.