diff --git a/README.md b/README.md index 4509eaf..ff169f5 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,14 @@ # lib-core.player_control -Single-player movement lib. Top-left position + size + speed, reads input-direction via `lib-core.input`, applies 4-corner-AABB collision against `lib-core.maps.is_walkable`. All-or-nothing block (no wall-sliding in v0.1.0). +Input-driven movement for a single Actor-Entity. Reads input-direction +via `lib-core.input`, applies 4-corner-AABB collision against +`lib-core.maps.is_walkable` around the actor's visual center, then +commits via `actor.move`. All-or-nothing block (no wall-sliding). -**Version:** 0.1.0 -**Lib-ID:** lib-core.player_control -**Requires:** lib-core.maps v>=0.1.1, lib-core.input v>=0.4.0 -**Tags:** player, movement, aabb, collision +**Version:** 0.2.0 +**Lib-ID:** lib-core.player_control +**Requires:** lib-core.maps v>=0.5.7, lib-core.input v>=0.4.0, lib-core.actor v>=0.1.0 +**Tags:** player, movement, aabb, collision, actor ## Topology @@ -17,50 +20,30 @@ graph LR this --> lib_core_maps lib_core_input["lib-core.input"] this --> lib_core_input + lib_core_actor["lib-core.actor"] + this --> lib_core_actor engine["engine.*"] this --> engine ``` +## Scope (v0.2.0 — Phase A.6 refactor) + +**Breaking change vs v0.1.0:** position / size / speed are no longer +stored in player_control; they live on the actor-entity (read via +`actor.position` / `actor.movement_speed` / entity properties). The +lib retains only the per-frame update plus the action-bindings. + +This decouples player-control from any single-player assumption: each +call passes its own actor-handle. Multi-actor controllers (e.g. squad- +demos, RTS-style click-to-move) compose by holding multiple +actor-handles externally. + ## API -### `player.set_position(x, y)` -**Syntax:** `player.set_position(x: number, y: number) -> void` +### `player_control.bind_movement(left, right, up, down)` -**Example:** -```lua -player.set_position(244, 244) -``` - -**Description:** Sets the player's top-left position in pixel-coords. Lua `error(...)` on non-number args. - -### `player.position()` -**Syntax:** `player.position() -> {x: number, y: number}` - -**Description:** Returns the current top-left position. Compute center as `p.x + size.w/2`, `p.y + size.h/2`. - -### `player.set_size(w, h)` -**Syntax:** `player.set_size(w: number, h: number) -> void` - -**Description:** Sets the AABB bounding-box size in pixels. Default 24x24. - -### `player.size()` -**Syntax:** `player.size() -> {w: number, h: number}` - -**Description:** Returns current bounding-box size. - -### `player.set_speed(s)` -**Syntax:** `player.set_speed(s: number) -> void` - -**Description:** Sets move-speed in px/sec. Default 200. - -### `player.speed()` -**Syntax:** `player.speed() -> number` - -**Description:** Returns current speed. - -### `player.bind_movement(left, right, up, down)` -**Syntax:** `player.bind_movement(left: string, right: string, up: string, down: string) -> void` +**Syntax:** `player_control.bind_movement(left: string, right: string, up: string, down: string) -> void` **Example:** ```lua @@ -68,70 +51,110 @@ input.bind("move_left", { "a", "left" }) input.bind("move_right", { "d", "right" }) input.bind("move_up", { "w", "up" }) input.bind("move_down", { "s", "down" }) -player.bind_movement("move_left", "move_right", "move_up", "move_down") +player_control.bind_movement("move_left", "move_right", "move_up", "move_down") ``` -**Description:** Registers 4 input-action names for the player's WASD-style direction. Until called, `update(dt)` is a silent no-op. +**Description:** Registers 4 input-action names for WASD-style +direction. Until called, `update(actor, dt)` is a silent no-op (debug- +friendly). -### `player.update(dt)` -**Syntax:** `player.update(dt: number) -> void` +### `player_control.update(actor_handle, dt)` + +**Syntax:** `player_control.update(actor_handle, dt: number) -> void` **Example:** ```lua function update(ctx, dt) - player.update(dt) + player_control.update(state.player, dt) end ``` -**Description:** Reads bound actions, computes direction-vector via `lib-core.input.direction`, predicts next position, runs 4-corner-AABB walkability check against the current map, and mutates position only if all corners walkable (all-or-nothing block; no wall-sliding in v0.1.0). Silent no-op until `bind_movement` is called. +**Description:** Reads bound actions; computes direction-vector via +`lib-core.input.direction`; reads the actor's `position` (center), +`movement_speed`, and `sprite_w`/`sprite_h` properties; predicts a +new center position; runs 4-corner-AABB walkability check around +the new center; commits via `actor.move(handle, ncx-cx, ncy-cy)` on +pass, no-op on blocked (all-or-nothing). + +Loud-Error if `actor_handle` is nil. Silent no-op before +`bind_movement` is called. ## Conventions -- Position is top-left of the AABB (matches `engine.render.draw_rect` origin). -- Center calculation lives in caller: `cx = position.x + size.w/2`. -- All-or-nothing block on collision (no wall-sliding in v0.1.0). -- Module owns rendering (lib has no `draw` fn). Module composes camera-follow. -- Silent no-op for `update()` before `bind_movement` (debug-friendly). -- Lua `error(...)` for setter misuse (caller-bug fast-fail). +- **Position is visual CENTER** of the actor (matches + `render.draw_entities` Phase-A convention). +- **AABB-Collision around the center**: the 4 corners are + `(cx ± w/2, cy ± h/2)`. +- All-or-nothing block on collision (wall-sliding deferred). +- Caller owns rendering (lib has no `draw` fn). Pair with + `lib-core.render.draw_entities{tag = "renderable"}`. +- Module composes camera-follow via `camera.set_target(actor.position(handle))`. - Y-down-positive per ADR-0031. ## Consumer pattern ```lua -local player = require("lib-core.player_control") -local input = require("lib-core.input") -local camera = require("lib-core.camera") +local player_control = require("lib-core.player_control") +local actor = require("lib-core.actor") +local composition = require("lib-core.composition") +local input = require("lib-core.input") +local camera = require("lib-core.camera") + +composition.define_template{ + id = "player", + properties = { + position = {x = 0, y = 0}, + movement_speed = 200, + sprite_color = engine.render.rgb(200, 60, 80), + sprite_w = 24, + sprite_h = 24, + }, + tags = {"renderable"}, +} input.bind("move_left", { "a", "left" }) input.bind("move_right", { "d", "right" }) input.bind("move_up", { "w", "up" }) input.bind("move_down", { "s", "down" }) -player.set_position(244, 244) -player.bind_movement("move_left", "move_right", "move_up", "move_down") +local p = actor.create{ + template = "player", + properties = { + position = {x = 244, y = 244}, + movement_speed = 200, + }, +} +player_control.bind_movement("move_left", "move_right", "move_up", "move_down") function update(ctx, dt) - player.update(dt) - local p = player.position() - local s = player.size() - camera.set_target(p.x + s.w/2, p.y + s.h/2) + player_control.update(p, dt) + camera.set_target(actor.position(p)) end function render(ctx) - local p = player.position() - local s = player.size() - engine.render.draw_rect(p.x, p.y, s.w, s.h, engine.render.rgb(200, 60, 80)) + -- Player gets drawn together with all renderable entities. + render.draw_entities{ tag = "renderable" } end ``` ## CHANGELOG +### v0.2.0 (Phase A.6 — 2026-06-09) +- BREAKING: removed `set_position` / `position` / `set_size` / `size` / + `set_speed` / `speed`. Use `actor.position` / `actor.movement_speed` + / `actor_handle:get_property("sprite_w")` etc. +- `update` now takes `(actor_handle, dt)` instead of just `(dt)`. +- Position is now visual-CENTER (was top-left in v0.1). +- New dep: `lib-core.actor` v0.1.0. + ### v0.1.0 (P.0) -- Initial release: position/size/speed + bind_movement + per-frame update with 4-corner-AABB collision. +- Initial release: position/size/speed + bind_movement + per-frame + update with 4-corner-AABB collision. ## References - Spec v0.1.0 (P.0): `meta/docs/superpowers/specs/2026-05-09-p0-lib-player_control-design.md` +- Spec v0.2.0 (Phase A): `meta/docs/superpowers/specs/2026-06-09-phase-A-inactive-entities-composition-actor-reentry-design.md` - ADR-0001 (engine knows verbs, libs bring nouns) - ADR-0031 (pixel-convention: Y-down-positive) - ADR-0038 (API-Doc-Convention) diff --git a/init.lua b/init.lua index 9b984e7..b7cc9bd 100644 --- a/init.lua +++ b/init.lua @@ -1,59 +1,46 @@ -- ===================================================================== --- lib-core.player_control — Player Movement + AABB-Wall-Collision (P.0) +-- lib-core.player_control v0.2.0 — Input → Actor Movement + AABB-Collision -- See: meta/docs/superpowers/specs/2026-05-09-p0-lib-player_control-design.md +-- meta/docs/superpowers/specs/2026-06-09-phase-A-... -- --- Scope: 1 player, top-left position + size + speed, 4-corner-AABB --- collision against maps.is_walkable, action-bindings via lib-core.input. --- DEPRECATED-MVP for: wall-sliding, actor-integration, sprite-rendering, --- click-to-move, drag-select, animation, velocity/momentum, multi-player. +-- v0.2.0 (Phase A.6) — BREAKING REFACTOR: +-- Position/size/speed leben jetzt auf einer Actor-Entity (lib-core.actor +-- + lib-core.composition); player_control hält nur noch die Action- +-- Bindings und das per-frame Update. +-- +-- Caller sets up: +-- composition.define_template{id="player", properties={position, +-- movement_speed, sprite_w, sprite_h, sprite_color, ...}, ...} +-- local p = actor.create{template="player", properties={...}} +-- player_control.bind_movement("move_left", ...) +-- player_control.update(p, dt) -- jeden Frame +-- +-- position.x/.y on the actor is treated as visual CENTER (per Phase-A +-- render-convention). The collision-check uses sprite_w/sprite_h +-- read from the actor's properties. +-- +-- Removed in v0.2.0 (use actor.* / composition.* instead): +-- M.set_position / M.position / M.set_size / M.size / +-- M.set_speed / M.speed — alle obsolete; lese via +-- actor.position(handle) / handle:get_property("sprite_w") / +-- actor.movement_speed(handle). +-- +-- DEPRECATED-MVP (unverändert): +-- - separate-axis wall-sliding (fluidity slice) +-- - sprite-rendering (lebt jetzt in render.draw_entities) +-- - click-to-move + drag-select (P.3 RTS slice) +-- - velocity/momentum (juice slice) +-- - collision against maps.walls field (when walls populated) -- ===================================================================== local maps = require("lib-core.maps") local input = require("lib-core.input") - -local player = { - x = 0, y = 0, -- top-left in pixel-coords - w = 24, h = 24, - speed = 200, -- px/sec -} +local actor = require("lib-core.actor") local actions = { left = nil, right = nil, up = nil, down = nil } local M = {} -function M.set_position(x, y) - if type(x) ~= "number" or type(y) ~= "number" then - error("player_control.set_position: x and y must be numbers") - end - player.x = x; player.y = y -end - -function M.position() - return { x = player.x, y = player.y } -end - -function M.set_size(w, h) - if type(w) ~= "number" or type(h) ~= "number" or w <= 0 or h <= 0 then - error("player_control.set_size: w and h must be positive numbers") - end - player.w = w; player.h = h -end - -function M.size() - return { w = player.w, h = player.h } -end - -function M.set_speed(s) - if type(s) ~= "number" or s < 0 then - error("player_control.set_speed: speed must be a non-negative number") - end - player.speed = s -end - -function M.speed() - return player.speed -end - function M.bind_movement(left, right, up, down) actions.left = left actions.right = right @@ -61,15 +48,18 @@ function M.bind_movement(left, right, up, down) actions.down = down end --- 4-corner-AABB-check: returns true iff all 4 corners of (x, y, w, h) --- sit on walkable tiles per maps.is_walkable. -local function can_occupy(x, y) +-- 4-corner-AABB-check: returns true iff all 4 corners of the rect +-- centered at (cx, cy) with size (w, h) sit on walkable tiles per +-- maps.is_walkable. cx, cy is the visual center (Phase-A convention). +local function can_occupy_centered(cx, cy, w, h) local ts = maps.tile_size() + local x0 = cx - w / 2 + local y0 = cy - h / 2 local corners = { - {x, y}, - {x + player.w - 1, y}, - {x, y + player.h - 1}, - {x + player.w - 1, y + player.h - 1}, + {x0, y0}, + {x0 + w - 1, y0}, + {x0, y0 + h - 1}, + {x0 + w - 1, y0 + h - 1}, } for _, c in ipairs(corners) do local tx, ty = engine.spatial.pixel_to_tile(c[1], c[2], ts) @@ -78,29 +68,34 @@ local function can_occupy(x, y) return true end -function M.update(dt) +-- Per-frame input-driven movement. Reads movement-speed + sprite-size +-- from the actor's own properties; applies 4-corner collision check +-- against the current map; commits via actor.move on pass, no-op on +-- blocked (no wall-sliding in v0.2.0). +-- +-- Caller is expected to have invoked actor.create + bind_movement +-- before the first update. +function M.update(actor_handle, dt) + if actor_handle == nil then + error("player_control.update: actor_handle must not be nil") + end if not actions.left then return end -- not bound, silent no-op local d = input.direction(actions.left, actions.right, actions.up, actions.down) if d.x == 0 and d.y == 0 then return end - local nx = player.x + d.x * player.speed * dt - local ny = player.y + d.y * player.speed * dt + local cx, cy = actor.position(actor_handle) + local speed = actor.movement_speed(actor_handle) + local w = actor_handle:get_property("sprite_w") + local h = actor_handle:get_property("sprite_h") - if can_occupy(nx, ny) then - player.x = nx - player.y = ny + local ncx = cx + d.x * speed * dt + local ncy = cy + d.y * speed * dt + + if can_occupy_centered(ncx, ncy, w, h) then + actor.move(actor_handle, ncx - cx, ncy - cy) end - -- else: blocked, no movement (no wall-sliding in P.0) + -- else: blocked, no movement (no wall-sliding in P.0/v0.2.0) end --- DEPRECATED-MVP: separate-axis wall-sliding -- fluidity slice --- DEPRECATED-MVP: actor-integration (player as Actor) -- lib-core.actor slice --- DEPRECATED-MVP: sprite-rendering -- texture-render slice --- DEPRECATED-MVP: click-to-move + drag-select -- P.3 RTS slice --- DEPRECATED-MVP: animation-state-machine -- animation slice --- DEPRECATED-MVP: velocity/momentum -- juice slice --- DEPRECATED-MVP: collision against maps.walls field -- when walls populated --- DEPRECATED-MVP: multi-player (multi-instance) -- multiplayer slice - return M diff --git a/manifest.lib b/manifest.lib index be86a51..fa380ba 100644 --- a/manifest.lib +++ b/manifest.lib @@ -1 +1 @@ -{"id":"lib-core.player_control","version":"0.1.0","api_min":"0.1","deps":[{"id":"lib-core.maps","version":"0.5.7"},{"id":"lib-core.input","version":"0.4.0"}]} \ No newline at end of file +{"id":"lib-core.player_control","version":"0.2.0","api_min":"0.1","deps":[{"id":"lib-core.maps","version":"0.5.7"},{"id":"lib-core.input","version":"0.4.0"},{"id":"lib-core.actor","version":"0.1.0"}]} \ No newline at end of file