diff --git a/init.lua b/init.lua index ecb6056..50b667d 100644 --- a/init.lua +++ b/init.lua @@ -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). diff --git a/manifest.lib b/manifest.lib index 574b750..524dbcd 100644 --- a/manifest.lib +++ b/manifest.lib @@ -1,6 +1,6 @@ { "id": "lib-core.puppet", - "version": "0.3.0", + "version": "0.3.1", "api_min": "0.1", "deps": [] }