fix(puppet): v0.3.1 — atan2 sprite-top + rot limits + speed + write_bone channels

- Look-at uses math.atan(dx, -dy) (substrate sprite-top convention):
  rot=0 means sprite top points up (in -Y), rot=pi/2 means right.
- New rest.rot_min/rot_max fields (degrees in JSON; clamped after look-at)
- New look_at_speed field (deg/s; rate-limits look-at via dt)
- write_bone now accepts rot/pos/scl channels (legacy 'angle' rejected)
- Bones not in any track are allowed; write_bone bypasses track-guard
  for them. Enables procedural root-rotation while keyframe-anims play.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Axel Meyer
2026-05-18 19:51:37 +02:00
parent 96ccb410d3
commit b8b8bf1a76
2 changed files with 72 additions and 16 deletions

View File

@@ -1,4 +1,4 @@
-- lib-core.puppet v0.3.0
-- lib-core.puppet v0.3.1
-- Skeletal animation: skeleton + bones + tracks + rest + keyframe
-- + procedural + look-at constraint. Multi-channel animation (rot/pos/scl)
-- + cascaded world-transform with scale propagation.
@@ -78,6 +78,16 @@ function M.build_rig(rig_table)
error("puppet.build_rig: bone '" .. b.id .. "' rest.scl must be [sx, sy] table")
end
-- A.2: Parse rot_min/rot_max (degrees in JSON → radians stored)
local rest_rot_min = nil
local rest_rot_max = nil
if b.rest.rot_min ~= nil then rest_rot_min = math.rad(b.rest.rot_min) end
if b.rest.rot_max ~= nil then rest_rot_max = math.rad(b.rest.rot_max) end
-- A.3: Parse look_at_speed (deg/s in JSON → radians/s stored)
local look_at_speed = nil
if b.look_at_speed ~= nil then look_at_speed = math.rad(b.look_at_speed) end
bones_by_id[b.id] = {
id = b.id,
parent = nil, -- resolved below
@@ -87,6 +97,9 @@ function M.build_rig(rig_table)
scl = { rest_scl[1], rest_scl[2] },
},
look_at = (b.look_at == true),
rest_rot_min = rest_rot_min,
rest_rot_max = rest_rot_max,
look_at_speed = look_at_speed,
color = b.color or { 200, 200, 200 },
texture = b.texture, -- optional atlas-id (string) or nil
z_order = b.z_order or 0, -- render-sort key; default 0
@@ -505,25 +518,42 @@ end
function M.write_bone(handle, bone_id, values)
local p = all_puppets[handle]
-- Validate this bone's track is not currently keyframe-active.
local track_id = p.rig.bone_to_track[bone_id]
if track_id == nil then
-- A.6: Validate bone exists in rig (not necessarily in a track).
if p.rig.bones_by_id[bone_id] == nil then
error("puppet.write_bone: bone '" .. bone_id .. "' not in rig")
end
-- A track is keyframe-active if any playing animation uses it.
for anim_id, _ in pairs(p.playing) do
local anim = p.animations[anim_id]
if anim and anim.track == track_id then
error("puppet.write_bone: bone '" .. bone_id
.. "' is on track '" .. track_id
.. "' currently active by keyframe '" .. anim_id .. "'")
local track_id = p.rig.bone_to_track[bone_id]
if track_id ~= nil then
-- Only check track-conflict if bone is in a track.
for anim_id, _ in pairs(p.playing) do
local anim = p.animations[anim_id]
if anim and anim.track == track_id then
error("puppet.write_bone: bone '" .. bone_id
.. "' is on track '" .. track_id
.. "' currently active by keyframe '" .. anim_id .. "'")
end
end
end
-- A.7: Accept rot/pos/scl channels; reject legacy 'angle'.
if values.angle ~= nil then
p.bone_state[bone_id].rot = math.rad(values.angle) -- caller passes degrees (legacy)
error("puppet.write_bone: 'angle' channel renamed to 'rot' in v0.3.0")
end
if values.rot ~= nil then
p.bone_state[bone_id].rot = math.rad(values.rot) -- caller passes degrees
p.bone_state[bone_id].rot = math.rad(values.rot) -- caller passes degrees
end
if values.pos ~= nil then
if type(values.pos) ~= "table" or values.pos[1] == nil or values.pos[2] == nil then
error("puppet.write_bone: pos must be [x, y] table")
end
p.bone_state[bone_id].pos.x = values.pos[1]
p.bone_state[bone_id].pos.y = values.pos[2]
end
if values.scl ~= nil then
if type(values.scl) ~= "table" or values.scl[1] == nil or values.scl[2] == nil then
error("puppet.write_bone: scl must be [sx, sy] table")
end
p.bone_state[bone_id].scl.x = values.scl[1]
p.bone_state[bone_id].scl.y = values.scl[2]
end
end
@@ -877,8 +907,34 @@ update_bone_recursive = function(p, b, dt)
local bone_world_y = py + (sin_a * lx * psx + cos_a * ly * psy)
local dx = p.look_target.x - bone_world_x
local dy = p.look_target.y - bone_world_y
local world_angle = math.atan(dy, dx)
p.bone_state[bid].rot = world_angle - pa
-- A.1: Substrate convention (puppet.c:748): sprite top extends in -Y direction at rot=0.
-- atan(dx, -dy) means: rot=0 when target is straight up (dy<0), rot=pi/2 when target is to the right.
local world_angle = math.atan(dx, -dy)
-- A.4: Apply look_at_speed (rate-limited tracking) + rot_min/rot_max clamp (local frame).
local target_local = world_angle - pa
local current = p.bone_state[bid].rot
local next_value
if b.look_at_speed then
-- Rate-limited rotation: allow up to look_at_speed * dt per frame.
local delta = target_local - current
-- Normalize delta to [-pi, pi] for shortest-path rotation.
while delta > math.pi do delta = delta - 2 * math.pi end
while delta < -math.pi do delta = delta + 2 * math.pi end
local max_step = b.look_at_speed * dt
if delta > max_step then delta = max_step end
if delta < -max_step then delta = -max_step end
next_value = current + delta
else
next_value = target_local
end
-- Apply rot_min/rot_max clamp (local-frame radians).
if b.rest_rot_min and next_value < b.rest_rot_min then next_value = b.rest_rot_min end
if b.rest_rot_max and next_value > b.rest_rot_max then next_value = b.rest_rot_max end
p.bone_state[bid].rot = next_value
end
-- 3.c Run procedural callbacks at root-visit (once per puppet, before world-transform compute).