diff --git a/README.md b/README.md index 5dcb3ab..0f6a725 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,15 @@ # lib-core.puppet -Skeletal animation primitive for top-down 2D characters: bones, -tracks, rest pose, keyframe animations, procedural animations -(Lua-side), and look-at constraint. No footplant IK in v0.1 -(deferred). +Skeletal animation primitive for top-down 2D characters. Manages a +bone hierarchy with cascaded world-transforms, keyframe animation +playback (multi-channel: rot/pos/scl), multi-target look-at constraints +(slerp or rate-limit or instant-snap), procedural animation callbacks, +and a per-channel conflict guard that allows animation and procedural +layers to coexist on the same bones. -**Version:** 0.3.0 +**Version:** 0.4.3 **Lib-ID:** lib-core.puppet -**Requires:** (none — pure Lua + engine.render.*) +**Requires:** (none — pure Lua + engine.render.* + engine.asset.*) **Tags:** animation, skeleton, puppet, character ## Topology @@ -19,135 +21,184 @@ tracks, rest pose, keyframe animations, procedural animations ### `puppet.build_rig(rig_table)` -Validates a rig table (parsed from `*.rig.json`) and returns an -internal representation with `bones_by_id`, parent-resolved object -references, and track-membership maps. Raises on invalid data -(missing parents, bones in 2 tracks, etc.). +Validates a rig table (parsed from `*.rig.json`) and returns an internal +representation with `bones_by_id`, parent-resolved object references, +track-membership maps, z-sorted bone list, and optional locomotion config. +Raises on invalid data (missing parents, bones in 2 tracks, legacy +`rest.angle` field, etc.). ### `puppet.build_animation(anim_table, rig)` -Validates an animation table against a built rig (bones referenced -in keyframes must exist and be in the animation's declared track). +Validates an animation table against a built rig. Bones referenced in +keyframes must exist and be in the animation's declared track. Raises on +unknown bones, wrong-track bones, or legacy `angle` channel name. -### `puppet.sample_animation(animation, t)` +### `puppet.sample_animation(anim, t)` -Returns a frame `{ = { angle = , ... }, ... }` -sampling the animation at time `t` with linear interpolation. Wraps -on `t > duration` if `animation.loop` is true. +Returns a frame `{ = { rot?, pos?, scl? }, ... }` sampling the +animation at time `t` with linear interpolation per channel. Wraps on +`t > duration` if `animation.loop` is true. Each channel is sampled +independently; absent channel means no override (hold-on-single-side +semantics). -### `puppet.spawn(rig, { x, y })`, `puppet.despawn(handle)` +### `puppet.spawn(rig, {x, y})`, `puppet.despawn(handle)` -Spawn a new puppet instance at world position (x, y). Returns an -opaque handle. `despawn` removes the instance. +Spawn a new puppet instance at world position (x, y). Returns an opaque +handle. `despawn` removes the instance and all its state. ### `puppet.update(dt)`, `puppet.render(handle)` -`update(dt)` runs the pipeline for ALL spawned puppets (rest → -look-at → keyframes → procedural). `render(handle)` draws ONE -puppet via `engine.render.draw_rect_rotated` per bone. +`update(dt)` runs the full pipeline for ALL spawned puppets (rest-reset +→ keyframes → look-at → procedural → compute_world → apply_locomotion). +`render(handle)` draws ONE puppet via `engine.render.draw_sprite_transform` +per non-virtual bone. Call `render` inside a `camera.begin()/finish()` +block. -### `puppet.set_look_target(p, world_x, world_y)`, `puppet.clear_look_target(p)` +### `puppet.set_look_target(handle, target_id, x, y)`, `puppet.clear_look_target(handle, target_id?)` -Set or clear the look-at target. Bones marked `look_at: true` in the -rig rotate to face the target. +Set or clear a named look-at target. Bones with `look_at: "target_id"` +in the rig rotate to face the matching target position each frame. The +2-arg legacy form `set_look_target(handle, x, y)` sets the "default" +target. `clear_look_target(handle)` without a target_id clears all targets. -### `puppet.bone_angle(p, bone_id)` +### `puppet.set_procedural(handle, name, callback)`, `puppet.clear_procedural(handle, name)` -Returns the current world-space angle of `bone_id` (after the -full pipeline tick). Used by tests + by callers needing bone-state. +Register or unregister a per-frame procedural callback. Callback signature: +`function(handle, dt)`. Inside the callback, mutate bone transforms via +`puppet.write_bone`. Callbacks are invoked once per puppet per frame, at +root-visit time (before compute_world for that frame). -### `puppet.set_procedural(p, name, callback)`, `puppet.clear_procedural(p, name)` +### `puppet.write_bone(handle, bone_id, {rot?, pos?, scl?})` -Register or unregister a per-frame procedural callback. Callback -signature: `function(handle, dt)`. Inside the callback, mutate -bones via `puppet.write_bone(handle, bone_id, { angle = })`. +Write one or more channels of a bone's local transform from a procedural +callback. Per-channel conflict guard: raises if the written channel is also +written by a keyframe in any active animation on that bone's track. Allows +procedural to write `rot` while an animation writes `scl` on the same bone. -### `puppet.play(p, animation_name, { loop, speed })`, `puppet.stop(p, animation_name)`, `puppet.stop_all(p)`, `puppet.is_playing(p, animation_name)` +### `puppet.play(handle, anim_id, {loop?, speed?})`, `puppet.stop(handle, anim_id)`, `puppet.stop_all(handle)`, `puppet.is_playing(handle, anim_id)` Keyframe-layer controls. Animation must be registered via -`puppet.register_animation(p, anim)` first. +`puppet.register_animation(handle, anim)` first. `speed` defaults to 1.0; +pass a value like `0.45` for a slower cadence. -### `puppet.register_animation(p, animation)` +### `puppet.register_animation(handle, anim)` -Binds a built animation (from `build_animation`) to a puppet -instance, making it available to `play`. +Binds a built animation (from `build_animation`) to a puppet instance, +making it available to `play`. ### `puppet.load_rig(path, asset_aliases?)` -Loads a rig from a JSON file and optionally resolves its bone textures -via `puppet.load_textures`. Pass the module's `asset_aliases` table -(from `engine.module.asset_aliases()`) to enable sprite-mode rendering; -omit for colored-rect-only rigs. +Loads a rig from a JSON file and optionally resolves bone textures via +`puppet.load_textures`. Pass `engine.module.asset_aliases()` to enable +sprite-mode rendering; omit for colored-rect-only rigs. ### `puppet.load_textures(rig, asset_aliases)` For each bone with a `texture` field, resolves the atlas-id to a -texture-handle via the asset-lib indirection: read `asset_aliases[rig.asset_pack]` -to get the lib-id, load `/assets/atlas.json`, look up the -atlas-entry by id, and call `engine.asset.load_texture` on the file -path. Also resolves bone anchors from the atlas (unless bone has an -explicit `anchor` field). - -### `puppet.bone_world_transform(handle, bone_id)` - -Returns the bone's cached world-transform `(x, y, angle)` from the -last `puppet.update` tick. Used by tests and by modules needing world- -space queries (e.g., for spawning effects at a bone position). - -### `puppet.write_bone_test_only(handle, bone_id, angle_rad)` - -Test-setup helper: directly write a bone angle bypassing the track- -conflict-guard. Not for production use; modules should write via -procedural callbacks. +texture-handle via the asset-lib indirection. Also resolves bone anchors +from the atlas (unless the bone has an explicit `anchor` field). Caches +texture dimensions for locomotion sprite_reach computation. ### `puppet.load_animation(path, rig)` Convenience wrapper: reads a JSON file and calls `build_animation`. -### `puppet.position(p)`, `puppet.facing(p)`, `puppet.move_to(p, x, y)` +### `puppet.bone_world_transform(handle, bone_id)` -Puppet owns its world position. `move_to` sets it instantly (caller -applies speed). `facing` returns the last-movement-direction angle. +Returns `x, y, rot` — the bone's cached world-transform from the last +`puppet.update` tick. World rot is in radians. Used by procedural callbacks +to read parent bone state (e.g., `body.world.rot` for leg orientation math). -### `puppet.write_bone(handle, bone_id, { angle })` +### `puppet.bone_world_scale(handle, bone_id)` -Procedural-callback-only API to mutate a bone. Validates track -ownership at write time. +Returns `scl_x, scl_y` — the bone's cached world scale. Accounts for +`inherit_scale` flag (false → returns local.scl, not parent.scl * local.scl). + +### `puppet.position(handle)`, `puppet.facing(handle)`, `puppet.move_to(handle, x, y)` + +Puppet world position management. `move_to` sets position instantly and +updates `facing` (last-movement-direction angle, radians). `facing` returns +the last movement direction. + +### `puppet.write_bone_test_only(handle, bone_id, angle_rad)` + +Test-setup helper: directly write a bone angle bypassing the per-channel +conflict guard. Stored in `test_overrides`; applied after rest-reset and +keyframe sampling. Not for production use. + +### `puppet.set_foot_sprite_reach_test_only(handle, leg_index, value)`, `puppet.foot_state_test_only(handle, leg_index)`, `puppet.bone_animated_test_only(handle, bone_id)` + +Test-only access to locomotion internal state and per-frame keyframe flags. +Not for production use. ## Conventions - World-coords + pixel-units throughout (per ADR-0031). -- Bone angles in radians (internal). JSON rest-angles and animation - keyframe angles are in degrees, converted on load. +- Bone angles in radians internally. JSON `rest.rot` and keyframe `rot` + are in degrees; converted on load via `math.rad`. +- `look_at_speed` is degrees/s in JSON; stored as radians/s. +- `look_at_slerp` is a unitless rate (not converted). +- `sprite_rot` is degrees in JSON; stored as radians. +- `rest.rot_min` / `rest.rot_max` are degrees in JSON; stored as radians. - Update pipeline runs once per frame (`puppet.update(dt)`) for ALL spawned puppets. `render` is per-puppet so callers can interleave with other rendering. +- `render` must be called inside a `camera.begin()` / `camera.finish()` + block to render in world-space. ## CHANGELOG +### v0.4.3 +- `inherit_scale: false` bone flag: when false, `world.scl = local.scl` + (parent.world.scl not multiplied). Position cascade still uses parent.scl. + Primary use case: feet that track leg-end positionally without Y-flip + inheritance when leg.scl_y inverts during walk cycle. + +### v0.4.2 +- Per-channel write_bone conflict check (was per-track). Procedural can now + write channels (e.g., rot) that no active animation writes on the same bone + (e.g., animation writes scl). Enables animation + procedural on same bone + without conflict. + +### v0.4.1 +- target_local normalization to (-π, π] in look-at block. Fixes head-flick + when body world.rot crosses ±π (south-facing direction). + +### v0.4.0 +- Multi-look-target API: `puppet.set_look_target(handle, target_id, x, y)`. + Bones declare `look_at: "target_id"` (string). Legacy `look_at: true` + maps to "default" target. +- Slerp smoothing: `look_at_slerp` field (unitless rate, exponential approach). + `look_at_speed` (linear rate-limit) kept for backward-compat. +- Virtual bones: `virtual: true` — participates in compute_world, skipped + in render. +- Legs re-parented to body (was root in v0.3.0). + +### v0.3.3 +- Per-bone `sprite_rot` (degrees in JSON): render-time rotation offset + applied as `world.rot + sprite_rot`. Matches substrate puppet.c:2101 + convention. + +### v0.3.2 +- Persistent `look_at_state` per bone. Fixes rate-limit reset-bug where + each frame's rest-reset wiped the accumulated look-at rotation. + +### v0.3.1 +- Look-at math fix: `world_angle = atan(dx, -dy)` (sprite-top convention: + rot=0 = top faces target, not game-forward). + ### v0.3.0 -- BoneState multi-channel (rot/pos/scl) with world.scl_x/y and world.rot +- BoneState multi-channel (rot/pos/scl) with world.scl_x/y - Cascaded world-transform with scale propagation (compute_world) -- Procedural locomotion layer via rig.locomotion block (apply_locomotion): - step-trigger, step-placement, step-height, leg-aim + leg-stretch -- Idle-return logic in apply_locomotion: after (now - last_move_time) > - idle_return_delay, the most-displaced planted leg steps back to its hip - position over idle_step_duration; group-stagger via idle_return_stagger -- M.move_to tracks p.last_move_time when position actually changes -- Schema changes: rest.rot/scl required; legacy rest.angle rejected with - clear error message (Substrate-Parity v0.3.0 schema) -- Bone-rig top-level scale field removed; per-frame world state replaces it +- Procedural locomotion layer via rig.locomotion block (apply_locomotion) +- Schema: rest.rot (renamed from rest.angle), rest.scl added - New APIs: bone_world_scale, set_foot_sprite_reach_test_only, foot_state_test_only, bone_animated_test_only ### v0.2.0 -- Rig-format extension (texture, z_order, scale, anchor, asset_pack - fields on bones; rig.bones_z_sorted for render-order) -- Multi-level parent-chain transform with depth-first update pipeline -- Sprite-mode render via engine.render.draw_sprite_transform +- Rig-format extension (texture, z_order, anchor, asset_pack) +- Multi-level parent-chain transform + sprite-mode render - New API: load_textures, bone_world_transform, write_bone_test_only -- All v0.1.0 rigs continue to work (texture-less bones fall back to - colored-rect render) ### v0.1.0 - Initial release: skeleton + bones + tracks + rest + keyframe + @@ -155,6 +206,8 @@ ownership at write time. ## References -- `architecture/puppet.md` (Reference) +- `architecture/puppet.md` (Reference — updated to v0.4.3) +- `superpowers/specs/2026-05-18-puppet-subterrain-model-design.md` (Baseline Spec v0.4.3) +- `docs/adrs/0040-puppet-subterrain-model-pivot.md` (Pivot rationale) - ADR-0037 (Tests-as-Libs) - ADR-0038 (API-Doc-Convention)