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 (zeroUnityEngine/engine references) that picks the single bestInteractionCandidatefrom 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-facingVerbto show.InteractionVerb: a small serializable class holding a verb's raw localization table + key (Displayassembles theLocStringon demand), per the Kit's LocString-on-a-serialized-field convention (see Calendar'sCalendarConfig.PhaseDefinition).InteractableComponent: the stockMonoBehaviourimplementation ofIInteractable. Placed on any world object a player should be able to interact with — data plus notification, no logic. Registers itself withInteractableRegistrywhile enabled.InteractableRegistry: a static registered-list of currently enabledInteractableComponents — 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 fromInteractableRegistry, computes distance/facing relative to itself, feedsInteractionResolver, tracks the result asCurrentFocus, and raises focus/blur/interact notifications — both pooledGameEvents 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¶
- Add
InteractableComponentto a world object. Assign its Verb (localization table + key — e.g. tableInteraction, keyVERB_OPEN) in the Inspector; leave Interactable Id empty to fall back to the GameObject's name. - Add
InteractionDetectorComponentto the prober (typically the player), and tune Range and Min Facing Dot to taste (defaults: 3 world units, 0.5 — roughly a 60° cone). - Refresh runs automatically every
Updateby default (autoRefresh, on by default) — disable it to driveRefresh()manually on your own cadence instead (a fixed simulation tick, for example). - 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();
}
- React to focus changes and interactions (see
Samples/Scripts/InteractionSample.csfor 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, itsDistanceSqris at or under the configured range (squared), and itsFacingDotis at or above the configured minimum facing dot. Among eligible candidates, the smallestDistanceSqrwins. 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, forInteractionDetectorComponent, isInteractableRegistry.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:
InteractableRegistryis a static list every enabledInteractableComponentadds/removes itself to/from — the module's candidate-gathering strategy instead of aPhysics/Physics2Doverlap 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 togglingInteractionEnabledor by filtering before handing candidates to the resolver. InteractableComponentis thin — data plus notification, no logic: it does not decide focus, does not check range/facing, andInteract()performs no eligibility check of its own. All selection logic lives inInteractionResolver/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.Handleis whatever the caller wants it to mean —InteractionDetectorComponentuses it as the candidate's index into theInteractableRegistry.Activesnapshot it built that refresh, so it can map a winning candidate straight back to itsInteractableComponentwithout the headless resolver ever touching an engine type. enableInteractionis reserved, not wired:GameplayKitConfig.enableInteractionexists 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 noIServiceRegistrarfor Interaction to gate in the first place.LocStringon a serialized field has no core precedent to copy:InteractionVerbfollows the same pattern as Calendar'sCalendarConfig.PhaseDefinition—LocStringis sealed, has no parameterless constructor, and isn't[Serializable], so it cannot be a direct[SerializeField]. The raw table name + key are serialized instead, andDisplayassembles theLocStringon 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.