lib-core.puppet

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.5.0 Lib-ID: lib-core.puppet Requires: (none — pure Lua + engine.render.* + engine.asset.*) 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, 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. Raises on unknown bones, wrong-track bones, or legacy angle channel name.

puppet.sample_animation(anim, t)

Returns a frame { <bone_id> = { 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)

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 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(handle, target_id, x, y), puppet.clear_look_target(handle, target_id?)

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.set_procedural(handle, name, callback), puppet.clear_procedural(handle, name)

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.write_bone(handle, bone_id, {rot?, pos?, scl?})

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(handle, anim_id, {loop?, speed?}), puppet.stop(handle, anim_id), puppet.stop_all(handle), puppet.is_playing(handle, anim_id), puppet.set_play_speed(handle, anim_id, speed) (v0.4.4)

Keyframe-layer controls. Animation must be registered via puppet.register_animation(handle, anim) first. speed defaults to 1.0; pass a value like 0.45 for a slower cadence. set_play_speed mutates the speed of a currently-playing anim without resetting its cycle position — useful for mid-anim speed changes (e.g., sprint toggle). Silent no-op if the anim is not currently playing.

puppet.register_animation(handle, anim)

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 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)

Lädt den Atlas des Rigs (asset_pack) und bindet jeden Bone mit texture-Feld an dessen UV-Rect im Atlas. Seit v0.5.0 (M.2-Rewrite) hält das Rig einen Atlas-Texture-Handle (rig.atlas.diffuse_texture_handle); die Bones referenzieren Atlas-Index + UV-Rect statt eines per-Bone-Handles über die alte Asset-Alias-Indirection. Löst zudem Bone-Anchors aus dem Atlas auf (sofern der Bone kein explizites anchor-Feld hat) und cacht die Texture-Höhe für die Locomotion-sprite_reach-Berechnung.

puppet.load_animation(path, rig)

Convenience wrapper: reads a JSON file and calls build_animation.

puppet.bone_world_transform(handle, bone_id)

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.bone_world_scale(handle, bone_id)

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 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.5.0

  • Atlas-Handle Bone-Draw: render zeichnet jeden Bone über den einzelnen Atlas-Texture-Handle (rig.atlas.diffuse_texture_handle) plus per-Bone UV-Rect (ADR-0044 Source-Rect-Args), statt eines per-Bone-Handles.
  • load_textures M.2-Rewrite: Per-Atlas-Texture-Handle; Bones referenzieren Atlas-Index + UV-Rect statt der alten per-Bone Asset-Alias-Indirection.
  • bone_world_transform(handle, bone_id): gibt die gecachte Welt-Transform x, y, rot des Bones zurück.
  • Anchor-Hardcoding: Render liest b.anchor[1]/[2] (Default 0); fehlender Bone-Anchor wird in load_textures auf die Tile-UV-Mitte gesetzt.

v0.4.4

  • puppet.set_play_speed(handle, anim_id, speed): mutates the play-speed of a currently-running animation WITHOUT resetting its cycle position. Silent no-op if anim is not playing. Use case: sprint-toggle mid-walk doubles cadence smoothly (puppet.play with new speed would reset t = 0 and visibly restart the cycle).

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
  • Cascaded world-transform with scale propagation (compute_world)
  • 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, anchor, asset_pack)
  • Multi-level parent-chain transform + sprite-mode render
  • New API: load_textures, bone_world_transform, write_bone_test_only

v0.1.0

  • Initial release: skeleton + bones + tracks + rest + keyframe + procedural + look-at constraint. Footplant IK deferred.

References

  • architecture/puppet.md (Reference — updated to v0.5.0)
  • 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)
Description
Skeletal animation primitive: bones + tracks + keyframes + procedural + look-at
Readme 319 KiB
Languages
Lua 100%