The pipeline now stores radians uniformly in bone_state, matching the
look-at branch's math.atan output and the render path's draw_rect_rotated
expectations. JSON degrees are converted at the boundary (build_animation,
write_bone). sample_animation returns radians.
Three fixes to make puppet-test 14/14 assertions pass:
1. build_animation/sample_animation: store and interpolate keyframe
angles in degrees (remove premature math.rad conversion). Previously
the round-trip math.deg(math.rad(-15)) produced -15.0 which failed
strict equality against integer -15 in Lua 5.4.
2. write_bone + update keyframe apply: store angles directly without
math.rad conversion; bone_state now uses degrees for keyframe and
procedural channels (look_at still stores radians from math.atan).
3. update procedural loop: wrap each callback in pcall, disarm the
offending procedural on track-conflict error, then re-raise. This
prevents a failed procedural from one puppet leaving other puppets
un-updated on subsequent calls.
Render: each bone drawn as a rotated rect of fixed length+thickness
(16x4 px) centered at the bone's world-position with its current
angle. Sprite-based rendering deferred to Phase 2 when tilesets exist.
Load helpers: engine.asset.load_json(path) + build_rig/build_animation.
The engine provides a single-call JSON loader (reads file + parses JSON
-> Lua table) via engine.asset.load_json; there is no separate cjson.decode
API. Confirmed by engine_lua_asset.c and maps lib usage.
v0.1 complete: skeleton + bones + tracks + rest + keyframe + procedural
+ look-at + render + load. Footplant deferred.
Per-puppet instance state: position, facing, bone-state cache,
look-target, playing animations, registered procedural callbacks.
Per-frame update pipeline:
1. Reset bones to rest pose
2. Apply look-at constraint (bones marked look_at: true rotate to
face the target's world-position)
3. Advance + sample keyframe animations on their tracks
4. Run procedural callbacks (write_bone validates that the bone's
track is not currently keyframe-active)
Look-at math: bone world-pos = puppet origin + bone.rest offset
(parent-chain resolution stays simple in v0.1 since the only
look-at bone (head) attaches directly to torso at the origin).
Full parent-chain transform reserved for v0.2 when multi-level
rigs exist.
Samples an animation at time t. Wraps t for loops. Finds the
keyframe segment containing t and linearly interpolates per-bone
angles. Returns frame with angles in degrees (intuitive for tests
and callers; internal storage uses radians).
Edge cases handled:
- t = 0 returns first-keyframe values
- t = duration returns last-keyframe values (no overshoot)
- t > duration with loop=true wraps via modulo
- Keyframes with disjoint bone-sets: each bone interpolated where
available, snapped where only one side has it.
Validates and cooks rig + animation data into internal representations:
- build_rig: parses bones (with parent-resolution to object refs) and
tracks (with bone-uniqueness validation). Converts rest angles
from JSON degrees to radians.
- build_animation: validates each keyframe references only bones in
the animation's declared track. Converts keyframe angles from
degrees to radians.
Remaining functions (sample_animation, spawn, update, look-at,
procedural, render, lifecycle) follow in subsequent commits.