# 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). **Version:** 0.3.0 **Lib-ID:** lib-core.puppet **Requires:** (none — pure Lua + engine.render.*) **Tags:** animation, skeleton, puppet, character ## Topology ## API ### `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.). ### `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). ### `puppet.sample_animation(animation, t)` Returns a frame `{ = { angle = , ... }, ... }` sampling the animation at time `t` with linear interpolation. Wraps on `t > duration` if `animation.loop` is true. ### `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. ### `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. ### `puppet.set_look_target(p, world_x, world_y)`, `puppet.clear_look_target(p)` Set or clear the look-at target. Bones marked `look_at: true` in the rig rotate to face the target. ### `puppet.bone_angle(p, bone_id)` Returns the current world-space angle of `bone_id` (after the full pipeline tick). Used by tests + by callers needing bone-state. ### `puppet.set_procedural(p, name, callback)`, `puppet.clear_procedural(p, name)` 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 = })`. ### `puppet.play(p, animation_name, { loop, speed })`, `puppet.stop(p, animation_name)`, `puppet.stop_all(p)`, `puppet.is_playing(p, animation_name)` Keyframe-layer controls. Animation must be registered via `puppet.register_animation(p, anim)` first. ### `puppet.register_animation(p, animation)` 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. ### `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. ### `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 owns its world position. `move_to` sets it instantly (caller applies speed). `facing` returns the last-movement-direction angle. ### `puppet.write_bone(handle, bone_id, { angle })` Procedural-callback-only API to mutate a bone. Validates track ownership at write time. ## 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. - Update pipeline runs once per frame (`puppet.update(dt)`) for ALL spawned puppets. `render` is per-puppet so callers can interleave with other rendering. ## CHANGELOG ### v0.3.0 - BoneState multi-channel (rot/pos/scl) with world.scl_x/y and world.rot - 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 - 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 - 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 + procedural + look-at constraint. Footplant IK deferred. ## References - `architecture/puppet.md` (Reference) - ADR-0037 (Tests-as-Libs) - ADR-0038 (API-Doc-Convention)