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:
86
init.lua
86
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
|
-- Skeletal animation: skeleton + bones + tracks + rest + keyframe
|
||||||
-- + procedural + look-at constraint. Multi-channel animation (rot/pos/scl)
|
-- + procedural + look-at constraint. Multi-channel animation (rot/pos/scl)
|
||||||
-- + cascaded world-transform with scale propagation.
|
-- + 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")
|
error("puppet.build_rig: bone '" .. b.id .. "' rest.scl must be [sx, sy] table")
|
||||||
end
|
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] = {
|
bones_by_id[b.id] = {
|
||||||
id = b.id,
|
id = b.id,
|
||||||
parent = nil, -- resolved below
|
parent = nil, -- resolved below
|
||||||
@@ -87,6 +97,9 @@ function M.build_rig(rig_table)
|
|||||||
scl = { rest_scl[1], rest_scl[2] },
|
scl = { rest_scl[1], rest_scl[2] },
|
||||||
},
|
},
|
||||||
look_at = (b.look_at == true),
|
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 },
|
color = b.color or { 200, 200, 200 },
|
||||||
texture = b.texture, -- optional atlas-id (string) or nil
|
texture = b.texture, -- optional atlas-id (string) or nil
|
||||||
z_order = b.z_order or 0, -- render-sort key; default 0
|
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)
|
function M.write_bone(handle, bone_id, values)
|
||||||
local p = all_puppets[handle]
|
local p = all_puppets[handle]
|
||||||
-- Validate this bone's track is not currently keyframe-active.
|
-- A.6: Validate bone exists in rig (not necessarily in a track).
|
||||||
local track_id = p.rig.bone_to_track[bone_id]
|
if p.rig.bones_by_id[bone_id] == nil then
|
||||||
if track_id == nil then
|
|
||||||
error("puppet.write_bone: bone '" .. bone_id .. "' not in rig")
|
error("puppet.write_bone: bone '" .. bone_id .. "' not in rig")
|
||||||
end
|
end
|
||||||
-- A track is keyframe-active if any playing animation uses it.
|
local track_id = p.rig.bone_to_track[bone_id]
|
||||||
for anim_id, _ in pairs(p.playing) do
|
if track_id ~= nil then
|
||||||
local anim = p.animations[anim_id]
|
-- Only check track-conflict if bone is in a track.
|
||||||
if anim and anim.track == track_id then
|
for anim_id, _ in pairs(p.playing) do
|
||||||
error("puppet.write_bone: bone '" .. bone_id
|
local anim = p.animations[anim_id]
|
||||||
.. "' is on track '" .. track_id
|
if anim and anim.track == track_id then
|
||||||
.. "' currently active by keyframe '" .. anim_id .. "'")
|
error("puppet.write_bone: bone '" .. bone_id
|
||||||
|
.. "' is on track '" .. track_id
|
||||||
|
.. "' currently active by keyframe '" .. anim_id .. "'")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
-- A.7: Accept rot/pos/scl channels; reject legacy 'angle'.
|
||||||
if values.angle ~= nil then
|
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
|
end
|
||||||
if values.rot ~= nil then
|
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
|
||||||
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 bone_world_y = py + (sin_a * lx * psx + cos_a * ly * psy)
|
||||||
local dx = p.look_target.x - bone_world_x
|
local dx = p.look_target.x - bone_world_x
|
||||||
local dy = p.look_target.y - bone_world_y
|
local dy = p.look_target.y - bone_world_y
|
||||||
local world_angle = math.atan(dy, dx)
|
-- A.1: Substrate convention (puppet.c:748): sprite top extends in -Y direction at rot=0.
|
||||||
p.bone_state[bid].rot = world_angle - pa
|
-- 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
|
end
|
||||||
|
|
||||||
-- 3.c Run procedural callbacks at root-visit (once per puppet, before world-transform compute).
|
-- 3.c Run procedural callbacks at root-visit (once per puppet, before world-transform compute).
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"id": "lib-core.puppet",
|
"id": "lib-core.puppet",
|
||||||
"version": "0.3.0",
|
"version": "0.3.1",
|
||||||
"api_min": "0.1",
|
"api_min": "0.1",
|
||||||
"deps": []
|
"deps": []
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user