New optional bone field 'inherit_scale' (default true). When set false, world.scl = local.scl (no parent.scl multiplier). Position cascade still uses parent.scl (so child follows parent's stretch positionally). Use case: feet that move with leg-end as leg.scl_y cycles +1/0/-1, but foot sprite stays at constant scale (no Y-flip when leg flips, no collapse when leg collapses).
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 { <bone_id> = { angle = <number>, ... }, ... }
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 = <number> }).
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 <lib-id>/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.renderis 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)