-- ===================================================================== -- lib-core.interaction — Proximity-Trigger + Action-Dispatch (P.0) -- See: meta/docs/superpowers/specs/2026-05-10-p0-lib-interaction-design.md -- -- Scope: register proximity-triggers (target-point + range + action-name + -- callback), dispatch nearest matching trigger per action_name when action -- fires. Was-pressed semantics (single-frame edge, no hold-repeat). -- DEPRECATED-MVP for: AABB-targets, action-effect-pipeline, visual-feedback, -- action-cycling, hold-charging, per-action-resolution-override, -- callback-pcall-wrap, target-entity-binding, range-shape-variants. -- ===================================================================== local input = require("lib-core.input") local spatial = engine.spatial local M = {} -- module-local state local triggers = {} -- array of trigger records local next_id = 1 local function find_index_by_id(id) for i, t in ipairs(triggers) do if t.id == id then return i end end return nil end function M.register(target_x, target_y, range, action_name, callback) if type(target_x) ~= "number" or type(target_y) ~= "number" then error("interaction.register: target_x and target_y must be numbers") end if type(range) ~= "number" or range <= 0 then error("interaction.register: range must be a positive number") end if type(action_name) ~= "string" or action_name == "" then error("interaction.register: action_name must be a non-empty string") end if type(callback) ~= "function" then error("interaction.register: callback must be a function") end local id = next_id next_id = next_id + 1 triggers[#triggers + 1] = { id = id, target_x = target_x, target_y = target_y, range = range, action_name = action_name, callback = callback, } return id end function M.unregister(id) local idx = find_index_by_id(id) if idx then table.remove(triggers, idx) end -- silent no-op if id not found end function M.update(actor_x, actor_y) if type(actor_x) ~= "number" or type(actor_y) ~= "number" then error("interaction.update: actor_x and actor_y must be numbers") end -- group-by-action_name: { action_name -> { nearest_trigger, nearest_dist } } local groups = {} for _, t in ipairs(triggers) do local d = spatial.distance(actor_x, actor_y, t.target_x, t.target_y) if d <= t.range then local g = groups[t.action_name] if not g or d < g.dist then groups[t.action_name] = { trigger = t, dist = d } end end end -- fire nearest per action_name if action was pressed this frame for action_name, g in pairs(groups) do if input.was_action_pressed(action_name) then local t = g.trigger t.callback({ actor_x = actor_x, actor_y = actor_y, target_x = t.target_x, target_y = t.target_y, distance = g.dist, trigger_id = t.id, action_name = action_name, }) end end end function M.list() local out = {} for i, t in ipairs(triggers) do out[i] = { id = t.id, target_x = t.target_x, target_y = t.target_y, range = t.range, action_name = t.action_name, } end return out end function M.trigger_count() return #triggers end -- DEPRECATED-MVP: AABB-targets (range as rect) -- footprint-items slice -- DEPRECATED-MVP: action-effect-pipeline integration -- post-P.0 action-pipeline activation -- DEPRECATED-MVP: visual-feedback / moodlet UI hint -- UI slice -- DEPRECATED-MVP: action-cycling (multi-action UX) -- UI + multi-action-UX slice -- DEPRECATED-MVP: hold-vs-press semantics (charge, hold) -- charge-action slice -- DEPRECATED-MVP: per-action-resolution-override -- multi-pickup slice -- DEPRECATED-MVP: callback pcall-wrap (resilience) -- E.9 resilience refactor -- DEPRECATED-MVP: target-entity-binding (entity-handle) -- actor / composition slice -- DEPRECATED-MVP: range-shape variants (cone, line, AABB) -- combat / vision-cone slice return M