docs(puppet): README sync to v0.4.3 + multi-target/slerp/inherit_scale/virtual/sprite_rot APIs

Updates README to reflect the Subterrain-runtime-control model adopted
across v0.3.1–v0.4.3. Previous README described the original v0.3.0
substrate-port state and omitted all v0.4.x features.

Added to API section: set_look_target (multi-target, named target_id),
clear_look_target (per-target or all), write_bone (per-channel conflict
guard description). Updated description of bone_world_transform (returns
radians, not angle). Added conventions note on camera.begin/finish
requirement for render.

CHANGELOG entries added for v0.3.1, v0.3.2, v0.3.3, v0.4.0, v0.4.1,
v0.4.2, v0.4.3 covering all intermediate fixes and the model pivot.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Axel Meyer
2026-05-18 23:00:38 +02:00
parent 4448fc7a29
commit f7fa24965b

219
README.md
View File

@@ -1,13 +1,15 @@
# lib-core.puppet # lib-core.puppet
Skeletal animation primitive for top-down 2D characters: bones, Skeletal animation primitive for top-down 2D characters. Manages a
tracks, rest pose, keyframe animations, procedural animations bone hierarchy with cascaded world-transforms, keyframe animation
(Lua-side), and look-at constraint. No footplant IK in v0.1 playback (multi-channel: rot/pos/scl), multi-target look-at constraints
(deferred). (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 **Lib-ID:** lib-core.puppet
**Requires:** (none — pure Lua + engine.render.*) **Requires:** (none — pure Lua + engine.render.* + engine.asset.*)
**Tags:** animation, skeleton, puppet, character **Tags:** animation, skeleton, puppet, character
## Topology ## Topology
@@ -19,135 +21,184 @@ tracks, rest pose, keyframe animations, procedural animations
### `puppet.build_rig(rig_table)` ### `puppet.build_rig(rig_table)`
Validates a rig table (parsed from `*.rig.json`) and returns an Validates a rig table (parsed from `*.rig.json`) and returns an internal
internal representation with `bones_by_id`, parent-resolved object representation with `bones_by_id`, parent-resolved object references,
references, and track-membership maps. Raises on invalid data track-membership maps, z-sorted bone list, and optional locomotion config.
(missing parents, bones in 2 tracks, etc.). Raises on invalid data (missing parents, bones in 2 tracks, legacy
`rest.angle` field, etc.).
### `puppet.build_animation(anim_table, rig)` ### `puppet.build_animation(anim_table, rig)`
Validates an animation table against a built rig (bones referenced Validates an animation table against a built rig. Bones referenced in
in keyframes must exist and be in the animation's declared track). 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 `{ <bone_id> = { angle = <number>, ... }, ... }` Returns a frame `{ <bone_id> = { rot?, pos?, scl? }, ... }` sampling the
sampling the animation at time `t` with linear interpolation. Wraps animation at time `t` with linear interpolation per channel. Wraps on
on `t > duration` if `animation.loop` is true. `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 Spawn a new puppet instance at world position (x, y). Returns an opaque
opaque handle. `despawn` removes the instance. handle. `despawn` removes the instance and all its state.
### `puppet.update(dt)`, `puppet.render(handle)` ### `puppet.update(dt)`, `puppet.render(handle)`
`update(dt)` runs the pipeline for ALL spawned puppets (rest `update(dt)` runs the full pipeline for ALL spawned puppets (rest-reset
look-at → keyframes → procedural). `render(handle)` draws ONE → keyframes → look-at → procedural → compute_world → apply_locomotion).
puppet via `engine.render.draw_rect_rotated` per bone. `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 Set or clear a named look-at target. Bones with `look_at: "target_id"`
rig rotate to face the target. 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 Register or unregister a per-frame procedural callback. Callback signature:
full pipeline tick). Used by tests + by callers needing bone-state. `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 Write one or more channels of a bone's local transform from a procedural
signature: `function(handle, dt)`. Inside the callback, mutate callback. Per-channel conflict guard: raises if the written channel is also
bones via `puppet.write_bone(handle, bone_id, { angle = <number> })`. 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 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 Binds a built animation (from `build_animation`) to a puppet instance,
instance, making it available to `play`. making it available to `play`.
### `puppet.load_rig(path, asset_aliases?)` ### `puppet.load_rig(path, asset_aliases?)`
Loads a rig from a JSON file and optionally resolves its bone textures Loads a rig from a JSON file and optionally resolves bone textures via
via `puppet.load_textures`. Pass the module's `asset_aliases` table `puppet.load_textures`. Pass `engine.module.asset_aliases()` to enable
(from `engine.module.asset_aliases()`) to enable sprite-mode rendering; sprite-mode rendering; omit for colored-rect-only rigs.
omit for colored-rect-only rigs.
### `puppet.load_textures(rig, asset_aliases)` ### `puppet.load_textures(rig, asset_aliases)`
For each bone with a `texture` field, resolves the atlas-id to a 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]` texture-handle via the asset-lib indirection. Also resolves bone anchors
to get the lib-id, load `<lib-id>/assets/atlas.json`, look up the from the atlas (unless the bone has an explicit `anchor` field). Caches
atlas-entry by id, and call `engine.asset.load_texture` on the file texture dimensions for locomotion sprite_reach computation.
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)` ### `puppet.load_animation(path, rig)`
Convenience wrapper: reads a JSON file and calls `build_animation`. 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 Returns `x, y, rot` — the bone's cached world-transform from the last
applies speed). `facing` returns the last-movement-direction angle. `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 Returns `scl_x, scl_y` — the bone's cached world scale. Accounts for
ownership at write time. `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 ## Conventions
- World-coords + pixel-units throughout (per ADR-0031). - World-coords + pixel-units throughout (per ADR-0031).
- Bone angles in radians (internal). JSON rest-angles and animation - Bone angles in radians internally. JSON `rest.rot` and keyframe `rot`
keyframe angles are in degrees, converted on load. 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 - Update pipeline runs once per frame (`puppet.update(dt)`) for ALL
spawned puppets. `render` is per-puppet so callers can interleave spawned puppets. `render` is per-puppet so callers can interleave
with other rendering. with other rendering.
- `render` must be called inside a `camera.begin()` / `camera.finish()`
block to render in world-space.
## CHANGELOG ## 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 ### 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) - Cascaded world-transform with scale propagation (compute_world)
- Procedural locomotion layer via rig.locomotion block (apply_locomotion): - Procedural locomotion layer via rig.locomotion block (apply_locomotion)
step-trigger, step-placement, step-height, leg-aim + leg-stretch - Schema: rest.rot (renamed from rest.angle), rest.scl added
- 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, - New APIs: bone_world_scale, set_foot_sprite_reach_test_only,
foot_state_test_only, bone_animated_test_only foot_state_test_only, bone_animated_test_only
### v0.2.0 ### v0.2.0
- Rig-format extension (texture, z_order, scale, anchor, asset_pack - Rig-format extension (texture, z_order, anchor, asset_pack)
fields on bones; rig.bones_z_sorted for render-order) - Multi-level parent-chain transform + sprite-mode render
- 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 - 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 ### v0.1.0
- Initial release: skeleton + bones + tracks + rest + keyframe + - Initial release: skeleton + bones + tracks + rest + keyframe +
@@ -155,6 +206,8 @@ ownership at write time.
## References ## 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-0037 (Tests-as-Libs)
- ADR-0038 (API-Doc-Convention) - ADR-0038 (API-Doc-Convention)