diff --git a/init.lua b/init.lua index 6453832..0179523 100644 --- a/init.lua +++ b/init.lua @@ -1,8 +1,10 @@ --- lib-core.puppet v0.3.2 +-- lib-core.puppet v0.4.0 -- Skeletal animation: skeleton + bones + tracks + rest + keyframe -- + procedural + look-at constraint. Multi-channel animation (rot/pos/scl) -- + cascaded world-transform with scale propagation. -- Render path: world.scl_x/y (cascaded) + world.rot used for draw_sprite_transform. +-- v0.4.0: Multi-look-target (look_at = "target_id" string), slerp-style smoothing +-- (look_at_slerp field), virtual bone flag (virtual = true skips render). local M = {} @@ -88,6 +90,10 @@ function M.build_rig(rig_table) local look_at_speed = nil if b.look_at_speed ~= nil then look_at_speed = math.rad(b.look_at_speed) end + -- v0.4.0: Parse look_at_slerp (unitless float, NOT converted — slerp-rate per second) + local look_at_slerp = nil + if b.look_at_slerp ~= nil then look_at_slerp = b.look_at_slerp end + -- A.4: Parse sprite_rot (deg in JSON → radians stored). Render-time offset -- applied as `world.rot + sprite_rot` to compensate for sprite assets -- whose natural orientation at rot=0 is not the bone's "forward" (-Y). @@ -95,6 +101,17 @@ function M.build_rig(rig_table) local sprite_rot = 0 if b.sprite_rot ~= nil then sprite_rot = math.rad(b.sprite_rot) end + -- v0.4.0: Parse look_at as string (target_id) or bool (legacy: true→"default") + local look_at_target = nil + if type(b.look_at) == "string" then + look_at_target = b.look_at + elseif b.look_at == true then + look_at_target = "default" -- legacy compatibility + end + + -- v0.4.0: Parse virtual flag (virtual=true skips render; bone still participates in compute_world) + local virtual = (b.virtual == true) + bones_by_id[b.id] = { id = b.id, parent = nil, -- resolved below @@ -103,11 +120,14 @@ function M.build_rig(rig_table) rot = rest_rot, scl = { rest_scl[1], rest_scl[2] }, }, - look_at = (b.look_at == true), + look_at = (b.look_at == true or type(b.look_at) == "string"), -- kept for legacy compat + look_at_target = look_at_target, -- v0.4.0: string target_id or nil rest_rot_min = rest_rot_min, rest_rot_max = rest_rot_max, look_at_speed = look_at_speed, + look_at_slerp = look_at_slerp, -- v0.4.0: slerp-rate (unitless float) sprite_rot = sprite_rot, + virtual = virtual, -- v0.4.0: skip render if true 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 @@ -427,8 +447,9 @@ function M.spawn(rig, pos) foot_state = foot_state, bone_animated = {}, last_move_time = 0, - look_target = nil, - look_at_state = {}, -- {[bone_id] = { angle = X }} persistent rate-limit state + look_target = nil, -- legacy single-target (deprecated but kept for backward-compat) + look_targets = {}, -- v0.4.0: { [target_id] = { x, y } } + look_at_state = {}, -- {[bone_id] = { angle = X }} persistent slerp/rate-limit state animations = {}, -- {[anim_id] = built_anim} playing = {}, -- {[anim_id] = { t = 0, loop, speed }} procedural = {}, -- {[name] = callback_fn} @@ -498,12 +519,40 @@ end -- ==================================================================== -- Look-at + bone queries -- ==================================================================== -function M.set_look_target(handle, world_x, world_y) - all_puppets[handle].look_target = { x = world_x, y = world_y } +-- v0.4.0: set_look_target(handle, target_id, world_x, world_y) — multi-target API +-- Legacy 2-arg form: set_look_target(handle, world_x, world_y) — sets "default" target +function M.set_look_target(handle, target_id_or_x, world_x_or_y, world_y) + local p = all_puppets[handle] + if world_y ~= nil then + -- 3-arg form: (handle, target_id, x, y) + local target_id = target_id_or_x + p.look_targets[target_id] = { x = world_x_or_y, y = world_y } + -- Also set legacy look_target for backward-compat if target_id == "default" + if target_id == "default" then + p.look_target = { x = world_x_or_y, y = world_y } + end + else + -- 2-arg legacy form: (handle, x, y) + local world_x = target_id_or_x + local wy = world_x_or_y + p.look_targets["default"] = { x = world_x, y = wy } + p.look_target = { x = world_x, y = wy } + end end -function M.clear_look_target(handle) - all_puppets[handle].look_target = nil +-- v0.4.0: clear_look_target(handle, target_id) — remove one target +-- clear_look_target(handle) — clear all targets +function M.clear_look_target(handle, target_id) + local p = all_puppets[handle] + if target_id ~= nil then + p.look_targets[target_id] = nil + if target_id == "default" then + p.look_target = nil + end + else + p.look_targets = {} + p.look_target = nil + end end function M.bone_angle(handle, bone_id) @@ -901,8 +950,18 @@ update_bone_recursive = function(p, b, dt) p.bone_state[bid].rot = p.test_overrides[bid] end - -- 3.b Apply look-at constraint if this bone is marked look_at (scale-aware). - if b.look_at and p.look_target ~= nil then + -- 3.b Apply look-at constraint (v0.4.0: multi-target; scale-aware). + -- Bone must declare look_at_target (string) AND that target must be set in p.look_targets. + -- Legacy: look_at=true → look_at_target="default", still works with 2-arg set_look_target. + local look_target = nil + if b.look_at_target ~= nil then + look_target = p.look_targets[b.look_at_target] + -- Fall back to legacy look_target if this is the "default" target id + if look_target == nil and b.look_at_target == "default" then + look_target = p.look_target + end + end + if look_target ~= nil then -- Compute bone-world-pos (scale-aware). local px, py, pa, psx, psy if b.parent then @@ -915,14 +974,14 @@ update_bone_recursive = function(p, b, dt) local lx, ly = b.rest.x, b.rest.y local bone_world_x = px + (cos_a * lx * psx - sin_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 dy = p.look_target.y - bone_world_y + local dx = look_target.x - bone_world_x + local dy = look_target.y - bone_world_y -- Sprite-top convention: rot=0 means sprite top points up (-Y direction). -- atan(dx, -dy): rot=0 when target is straight up, rot=pi/2 when target is to the right. local world_angle = math.atan(dx, -dy) local target_local = world_angle - pa - -- Persistent state for rate-limit (carries angle across frames). + -- Persistent state for slerp/rate-limit (carries angle across frames). local state = p.look_at_state[bid] if state == nil then -- Initialize to bone's rest rotation so first frame advances from rest toward target. @@ -930,8 +989,17 @@ update_bone_recursive = function(p, b, dt) p.look_at_state[bid] = state end - if b.look_at_speed then - -- Rate-limit using persistent state (not current frame's bone_state.rot). + if b.look_at_slerp then + -- v0.4.0: Slerp-style exponential approach. delta normalized to [-pi, pi]. + local delta = target_local - state.angle + while delta > math.pi do delta = delta - 2 * math.pi end + while delta < -math.pi do delta = delta + 2 * math.pi end + local t = b.look_at_slerp * dt + if t > 1 then t = 1 end + if t < 0 then t = 0 end + state.angle = state.angle + delta * t + elseif b.look_at_speed then + -- Linear rate-limit (kept for backward-compat / linear-rotation use cases). local delta = target_local - state.angle while delta > math.pi do delta = delta - 2 * math.pi end while delta < -math.pi do delta = delta + 2 * math.pi end @@ -987,6 +1055,8 @@ function M.render(handle) if p == nil then return end for _, b in ipairs(p.rig.bones_z_sorted) do + -- v0.4.0: Skip virtual bones (they participate in compute_world but are not drawn). + if b.virtual then goto continue end local ws = p.bone_state[b.id].world if b.texture_handle then -- Cascaded world-scale (was: per-bone constant b.scale) @@ -1004,6 +1074,7 @@ function M.render(handle) local color = engine.render.rgb(b.color[1], b.color[2], b.color[3]) engine.render.draw_rect_rotated(ws.x, ws.y, 16, 4, ws.rot, color) end + ::continue:: end end diff --git a/manifest.lib b/manifest.lib index debb54a..a7ec5bb 100644 --- a/manifest.lib +++ b/manifest.lib @@ -1,6 +1,6 @@ { "id": "lib-core.puppet", - "version": "0.3.3", + "version": "0.4.0", "api_min": "0.1", "deps": [] }