feat(0.2.0): BREAKING — Actor-Entity statt Lua-locals (Phase A.6)
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.
Removed surface (use actor.* / composition.* instead):
M.set_position / M.position / M.set_size / M.size /
M.set_speed / M.speed
Neuer Flow:
composition.define_template{id="player", properties={position,
movement_speed, sprite_w, sprite_h, ...}, ...}
local p = actor.create{template="player", properties={...}}
player_control.bind_movement(...)
player_control.update(p, dt) -- liest position/movement_speed/sprite_w/h
-- aus dem actor; AABB-Collision um center,
-- commit via actor.move oder no-op
position.x/.y wird als visual CENTER behandelt (Phase-A Konvention).
4-corner-AABB-Check verschiebt sich entsprechend.
DEPRECATED-MVP-Marker auf :99 entfernt (actor-integration realisiert).
Bumps: 0.1.0 → 0.2.0 (BREAKING, pre-1.0 minor); +actor v0.1.0 dep.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
155
README.md
155
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
|
||||
```
|
||||
<!-- topology:end -->
|
||||
|
||||
## 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)
|
||||
|
||||
129
init.lua
129
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
|
||||
|
||||
@@ -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"}]}
|
||||
{"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"}]}
|
||||
Reference in New Issue
Block a user