-- ===================================================================== -- lib-core.camera v0.3.0 — 2D Camera State + Free-Pan + Bounds-Clamping + Screen-Unproject -- See: meta/docs/superpowers/specs/2026-05-13-p3-5-camera-free-pan-design.md -- -- v0.3.0 additions (additiv über v0.2.0): -- - screen_to_world(sx, sy) — unproject screen-coord to world-coord -- v0.2.0 additions (additiv über v0.1.0): -- - Pan-API (keys / edge / drag, additiv, per-source toggle-bar) -- - Bounds-Clamping (always-on once set; clear_bounds to disable) -- - Drag-Invert + zoom-scaling -- - update(dt) for per-frame pan-summation -- -- v0.1.0 API unverändert: set_target/set_zoom/target/zoom/begin/finish. -- DEPRECATED-MVPs siehe Spec §7. -- ===================================================================== local input = require("lib-core.input") -- v0.1.0 state (unverändert) local target_x = 0 local target_y = 0 local zoom_v = 1.0 -- v0.2.0 NEW state local pan_speed_v = 300 -- px/s, shared keys + edge local edge_thresh_v = 20 -- px from window edge local pan_invert_v = false -- drag-direction (false = "Map grabs") local pan_enabled_v = { keys = false, edge = false, drag = false } local pan_actions = { left = nil, right = nil, up = nil, down = nil, drag = nil, } local bounds_v = nil -- nil OR {xmin, ymin, xmax, ymax} -- ---------- internal helpers ---------- local function clamp(v, lo, hi) if v < lo then return lo end if v > hi then return hi end return v end local function clamp_to_bounds() if bounds_v == nil then return end target_x = clamp(target_x, bounds_v.xmin, bounds_v.xmax) target_y = clamp(target_y, bounds_v.ymin, bounds_v.ymax) end local function check_source(source) if source ~= "keys" and source ~= "edge" and source ~= "drag" then error(string.format("camera: unknown source '%s' (expected 'keys' | 'edge' | 'drag')", tostring(source))) end end local function check_action_name(arg_name, value) if type(value) ~= "string" or value == "" then error(string.format("camera.%s: must be non-empty string", arg_name)) end end -- ---------- public API ---------- local M = {} -- v0.1.0 (unverändert außer: set_target ruft clamp_to_bounds nach) function M.set_target(x, y) if type(x) ~= "number" or type(y) ~= "number" then error("camera.set_target: x and y must be numbers") end target_x = x target_y = y clamp_to_bounds() end function M.set_zoom(z) if type(z) ~= "number" or z <= 0 then error(string.format("camera.set_zoom: zoom must be a positive number, got %s", tostring(z))) end zoom_v = z end function M.target() return { x = target_x, y = target_y } end function M.zoom() return zoom_v end function M.begin() engine.render.camera_2d_begin(target_x, target_y, zoom_v) end function M.finish() engine.render.camera_2d_end() end -- v0.2.0 NEW — Bindings function M.bind_pan_keys(left, right, up, down) check_action_name("bind_pan_keys.left", left) check_action_name("bind_pan_keys.right", right) check_action_name("bind_pan_keys.up", up) check_action_name("bind_pan_keys.down", down) pan_actions.left = left pan_actions.right = right pan_actions.up = up pan_actions.down = down pan_enabled_v.keys = true end function M.bind_pan_drag(action) check_action_name("bind_pan_drag.action", action) pan_actions.drag = action pan_enabled_v.drag = true end function M.enable_pan_edge() pan_enabled_v.edge = true end -- v0.2.0 NEW — Numeric Settings function M.set_pan_speed(n) if type(n) ~= "number" or n <= 0 then error(string.format("camera.set_pan_speed: must be positive number, got %s", tostring(n))) end pan_speed_v = n end function M.pan_speed() return pan_speed_v end function M.set_edge_threshold(n) if type(n) ~= "number" or n < 0 then error(string.format("camera.set_edge_threshold: must be non-negative number, got %s", tostring(n))) end edge_thresh_v = n end function M.edge_threshold() return edge_thresh_v end function M.set_pan_invert(b) if type(b) ~= "boolean" then error("camera.set_pan_invert: must be boolean") end pan_invert_v = b end function M.pan_invert() return pan_invert_v end -- v0.2.0 NEW — Per-Source Runtime-Toggle function M.set_pan_enabled(source, b) check_source(source) if type(b) ~= "boolean" then error("camera.set_pan_enabled: bool arg must be boolean") end pan_enabled_v[source] = b end function M.pan_enabled(source) check_source(source) return pan_enabled_v[source] end -- v0.2.0 NEW — Bounds function M.set_bounds(xmin, ymin, xmax, ymax) if type(xmin) ~= "number" or type(ymin) ~= "number" or type(xmax) ~= "number" or type(ymax) ~= "number" then error("camera.set_bounds: all 4 args must be numbers") end if xmin > xmax or ymin > ymax then error(string.format("camera.set_bounds: min > max (%s,%s vs %s,%s)", tostring(xmin), tostring(ymin), tostring(xmax), tostring(ymax))) end bounds_v = { xmin = xmin, ymin = ymin, xmax = xmax, ymax = ymax } clamp_to_bounds() end function M.clear_bounds() bounds_v = nil end function M.bounds() if bounds_v == nil then return nil end return { xmin = bounds_v.xmin, ymin = bounds_v.ymin, xmax = bounds_v.xmax, ymax = bounds_v.ymax } end -- v0.2.0 NEW — Per-Frame Pan-Summation function M.update(dt) local dx, dy = 0, 0 if pan_enabled_v.keys and pan_actions.left and pan_actions.right and pan_actions.up and pan_actions.down then if input.is_action_down(pan_actions.left) then dx = dx - pan_speed_v * dt end if input.is_action_down(pan_actions.right) then dx = dx + pan_speed_v * dt end if input.is_action_down(pan_actions.up) then dy = dy - pan_speed_v * dt end if input.is_action_down(pan_actions.down) then dy = dy + pan_speed_v * dt end end if pan_enabled_v.edge then local mx, my = engine.input.get_mouse_pos() local w, h = engine.window.size() if mx < edge_thresh_v then dx = dx - pan_speed_v * dt end if mx > w - edge_thresh_v then dx = dx + pan_speed_v * dt end if my < edge_thresh_v then dy = dy - pan_speed_v * dt end if my > h - edge_thresh_v then dy = dy + pan_speed_v * dt end end if pan_enabled_v.drag and pan_actions.drag then if input.is_action_down(pan_actions.drag) then local mdx, mdy = engine.input.get_mouse_delta() local sign = pan_invert_v and 1 or -1 dx = dx + (sign * mdx) / zoom_v dy = dy + (sign * mdy) / zoom_v end end if dx ~= 0 or dy ~= 0 then target_x = target_x + dx target_y = target_y + dy clamp_to_bounds() end end -- v0.3.0 NEW — Coordinate Unproject (Screen → World) function M.screen_to_world(sx, sy) if type(sx) ~= "number" or type(sy) ~= "number" then error("camera.screen_to_world: sx and sy must be numbers") end local w, h = engine.window.size() -- Inverse of Raylib 2D camera transform (origin = center, no rotation): -- screen.x = (world.x - target.x) * zoom + w/2 -- → world.x = (screen.x - w/2) / zoom + target.x local wx = (sx - w / 2) / zoom_v + target_x local wy = (sy - h / 2) / zoom_v + target_y return wx, wy end -- DEPRECATED-MVP: forward-compat stubs (siehe Spec §7): -- DEPRECATED-MVP: function M.follow(entity) -- lib-core.actor slice -- DEPRECATED-MVP: function M.lerp_to(target, dt) -- smooth-follow slice -- DEPRECATED-MVP: function M.shake(intensity, dur) -- effects/juice slice -- DEPRECATED-MVP: function M.set_rotation(deg) -- closed-topology slice -- DEPRECATED-MVP: function M.set_bounds_from_map(id) -- lib-core.maps bridge -- DEPRECATED-MVP: per-source speeds (key_speed vs edge_speed split) -- DEPRECATED-MVP: edge-scroll speed-ramping (proximity-to-edge scaling) -- DEPRECATED-MVP: window-focus-check (Raylib IsWindowFocused for edge-scroll) -- DEPRECATED-MVP: pan-velocity-smoothing (easing on key-press/release) -- DEPRECATED-MVP: mouse-delta-jump-detection (filter large deltas from tab-out) return M