Handle-Registry (get_pos + set_pos + speed callbacks) + lib-driven auto-wire (bind_move_action + bind_target_provider) + per-frame movement with arrive-snap. Move-only execution; context-dispatch deferred to P.4. See: meta/docs/superpowers/specs/2026-05-14-p3-7-lib-command-design.md
249 lines
7.3 KiB
Lua
249 lines
7.3 KiB
Lua
-- =====================================================================
|
|
-- lib-core.command v0.1.0 — Move-Commands + Per-Frame-Execution
|
|
-- See: meta/docs/superpowers/specs/2026-05-14-p3-7-lib-command-design.md
|
|
--
|
|
-- Handle-Registry mit get_pos + set_pos + speed (extending selection-pattern
|
|
-- mit write-callback). Lib-driven auto-wire via action-binding (press-edge,
|
|
-- analog selection). Per-frame movement: direction-normalize + step-vs-distance
|
|
-- → arrive-snap. Move-only in v0.1.0; context-sensitive dispatch deferred.
|
|
--
|
|
-- DEPRECATED-MVPs siehe Spec §7 + inline-comments unten.
|
|
-- =====================================================================
|
|
|
|
local input = require("lib-core.input")
|
|
local camera = require("lib-core.camera")
|
|
|
|
-- Registry: array of {handle, get_pos, set_pos, speed}.
|
|
local units = {}
|
|
local next_handle = 1
|
|
|
|
-- Active commands: handle → {type, target_x, target_y}.
|
|
local active_commands = {}
|
|
|
|
-- Bindings.
|
|
local move_action = nil
|
|
local target_provider = nil
|
|
|
|
-- Settings.
|
|
local arrive_threshold = 2 -- px
|
|
local enabled_flag = true
|
|
|
|
-- ---------- internal helpers ----------
|
|
|
|
local function check_function(arg_name, value)
|
|
if type(value) ~= "function" then
|
|
error(string.format("command.%s: must be a function", arg_name))
|
|
end
|
|
end
|
|
|
|
local function check_positive(arg_name, value)
|
|
if type(value) ~= "number" or value <= 0 then
|
|
error(string.format("command.%s: must be positive number", arg_name))
|
|
end
|
|
end
|
|
|
|
local function check_string(arg_name, value)
|
|
if type(value) ~= "string" or value == "" then
|
|
error(string.format("command.%s: must be non-empty string", arg_name))
|
|
end
|
|
end
|
|
|
|
local function find_unit_index(handle)
|
|
for i, u in ipairs(units) do
|
|
if u.handle == handle then return i end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
local function find_unit(handle)
|
|
for _, u in ipairs(units) do
|
|
if u.handle == handle then return u end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- ---------- public API ----------
|
|
|
|
local M = {}
|
|
|
|
-- Registry
|
|
|
|
function M.register(get_pos_fn, set_pos_fn, speed)
|
|
check_function("register.get_pos_fn", get_pos_fn)
|
|
check_function("register.set_pos_fn", set_pos_fn)
|
|
check_positive("register.speed", speed)
|
|
local h = next_handle
|
|
next_handle = next_handle + 1
|
|
units[#units + 1] = {
|
|
handle = h,
|
|
get_pos = get_pos_fn,
|
|
set_pos = set_pos_fn,
|
|
speed = speed,
|
|
}
|
|
return h
|
|
end
|
|
|
|
function M.unregister(handle)
|
|
local idx = find_unit_index(handle)
|
|
if idx then
|
|
table.remove(units, idx)
|
|
active_commands[handle] = nil -- clear dangling command
|
|
end
|
|
-- silent no-op on unknown handle
|
|
end
|
|
|
|
function M.count_registered()
|
|
return #units
|
|
end
|
|
|
|
-- Per-Unit Speed
|
|
|
|
function M.set_speed(handle, n)
|
|
local u = find_unit(handle)
|
|
if u == nil then
|
|
error("command.set_speed: handle " .. tostring(handle) .. " not registered")
|
|
end
|
|
check_positive("set_speed.n", n)
|
|
u.speed = n
|
|
end
|
|
|
|
function M.speed(handle)
|
|
local u = find_unit(handle)
|
|
if u == nil then
|
|
error("command.speed: handle " .. tostring(handle) .. " not registered")
|
|
end
|
|
return u.speed
|
|
end
|
|
|
|
-- Command-Issue (Programmatic)
|
|
|
|
function M.move_to(handles, target_x, target_y)
|
|
if type(handles) ~= "table" then
|
|
error("command.move_to: handles must be a table (array of handles)")
|
|
end
|
|
if type(target_x) ~= "number" or type(target_y) ~= "number" then
|
|
error("command.move_to: target_x and target_y must be numbers")
|
|
end
|
|
-- Validate ALL handles BEFORE mutating any state.
|
|
for _, h in ipairs(handles) do
|
|
if find_unit(h) == nil then
|
|
error("command.move_to: handle " .. tostring(h) .. " not registered")
|
|
end
|
|
end
|
|
for _, h in ipairs(handles) do
|
|
active_commands[h] = { type = "move", target_x = target_x, target_y = target_y }
|
|
end
|
|
end
|
|
|
|
function M.cancel(handle)
|
|
active_commands[handle] = nil -- silent no-op
|
|
end
|
|
|
|
function M.cancel_all()
|
|
active_commands = {}
|
|
end
|
|
|
|
-- Command-State-Query
|
|
|
|
function M.current(handle)
|
|
local cmd = active_commands[handle]
|
|
if cmd == nil then return nil end
|
|
return { type = cmd.type, target_x = cmd.target_x, target_y = cmd.target_y }
|
|
end
|
|
|
|
function M.is_moving(handle)
|
|
return active_commands[handle] ~= nil
|
|
end
|
|
|
|
-- Bindings
|
|
|
|
function M.bind_move_action(action_name)
|
|
check_string("bind_move_action", action_name)
|
|
move_action = action_name
|
|
end
|
|
|
|
function M.bind_target_provider(fn)
|
|
check_function("bind_target_provider", fn)
|
|
target_provider = fn
|
|
end
|
|
|
|
-- Settings
|
|
|
|
function M.set_arrive_threshold(n)
|
|
check_positive("set_arrive_threshold", n)
|
|
arrive_threshold = n
|
|
end
|
|
|
|
function M.arrive_threshold()
|
|
return arrive_threshold
|
|
end
|
|
|
|
function M.set_enabled(b)
|
|
if type(b) ~= "boolean" then
|
|
error("command.set_enabled: must be boolean")
|
|
end
|
|
enabled_flag = b
|
|
end
|
|
|
|
function M.enabled()
|
|
return enabled_flag
|
|
end
|
|
|
|
-- Per-Frame
|
|
|
|
function M.update(dt)
|
|
if not enabled_flag then return end
|
|
|
|
-- A) Input-Polling für Auto-Wire (press-edge):
|
|
if move_action ~= nil and target_provider ~= nil then
|
|
if input.was_action_pressed(move_action) then
|
|
local handles = target_provider()
|
|
if type(handles) == "table" and #handles > 0 then
|
|
local mx, my = engine.input.get_mouse_pos()
|
|
local wx, wy = camera.screen_to_world(mx, my)
|
|
M.move_to(handles, wx, wy)
|
|
end
|
|
end
|
|
end
|
|
|
|
-- B) Movement-Tick: iterate active commands:
|
|
for handle, cmd in pairs(active_commands) do
|
|
local unit = find_unit(handle)
|
|
if unit == nil then
|
|
active_commands[handle] = nil -- handle unregistered
|
|
elseif cmd.type == "move" then
|
|
local pos = unit.get_pos()
|
|
if type(pos) == "table"
|
|
and type(pos.x) == "number" and type(pos.y) == "number" then
|
|
local dx = cmd.target_x - pos.x
|
|
local dy = cmd.target_y - pos.y
|
|
local dist = math.sqrt(dx * dx + dy * dy)
|
|
local step = unit.speed * dt
|
|
if dist <= arrive_threshold or step >= dist then
|
|
unit.set_pos(cmd.target_x, cmd.target_y)
|
|
active_commands[handle] = nil
|
|
else
|
|
local ratio = step / dist
|
|
unit.set_pos(pos.x + dx * ratio, pos.y + dy * ratio)
|
|
end
|
|
end
|
|
-- invalid pos return: skip frame (best-effort)
|
|
end
|
|
end
|
|
end
|
|
|
|
-- DEPRECATED-MVP: context-sensitive RMB dispatch (attack/harvest/follow per
|
|
-- target-type) — own slice when 2nd command-type exists.
|
|
-- DEPRECATED-MVP: pathfinding (A* / nav-mesh) — own slice, P.4
|
|
-- DEPRECATED-MVP: stop-command via dedicated action — comes with attack slice
|
|
-- DEPRECATED-MVP: formation-move (offset keeping) — P.4 RTS-polish
|
|
-- DEPRECATED-MVP: command-queue (Shift+RMB waypoints) — own slice
|
|
-- DEPRECATED-MVP: path-move (waypoint-list as single target) — precursor to queue
|
|
-- DEPRECATED-MVP: per-command speed-multiplier (sprint, walk) — gameplay-driven
|
|
-- DEPRECATED-MVP: faction/ownership system (own vs enemy command-eligibility) — P.4+
|
|
-- DEPRECATED-MVP: observer pattern (on_command_complete callback) — UI-refresh
|
|
-- DEPRECATED-MVP: direction-smoothing (turn-rate, acceleration) — P.4 RTS-polish
|
|
-- DEPRECATED-MVP: collision-avoidance (units pushing each other) — P.4+
|
|
|
|
return M
|