diff --git a/init.lua b/init.lua index cedb6c5..a5f09ff 100644 --- a/init.lua +++ b/init.lua @@ -1,6 +1,7 @@ --- lib-core.puppet v0.2.0 +-- lib-core.puppet v0.3.0 -- Skeletal animation: skeleton + bones + tracks + rest + keyframe --- + procedural + look-at constraint. No footplant in v0.1. +-- + procedural + look-at constraint. Multi-channel animation (rot/pos/scl) +-- + cascaded world-transform with scale propagation. local M = {} @@ -28,12 +29,6 @@ local function clamp_angle_rad(a) return a end --- Subsequent tasks add: sample_animation, --- spawn, despawn, update, render, set_look_target, clear_look_target, --- bone_angle, set_procedural, clear_procedural, write_bone, play, stop, --- stop_all, is_playing, position, facing, move_to, register_animation, --- load_rig, load_animation. - -- ==================================================================== -- Rig validation + build -- ==================================================================== @@ -57,19 +52,35 @@ function M.build_rig(rig_table) if bones_by_id[b.id] ~= nil then error("puppet.build_rig: duplicate bone id: " .. b.id) end + + if type(b.rest) ~= "table" then + error("puppet.build_rig: bone '" .. b.id .. "' missing rest pose") + end + if b.rest.angle ~= nil then + error("puppet.build_rig: bone '" .. b.id + .. "' uses legacy `rest.angle`; rename to `rest.rot` (radians or set via degrees and lib will math.rad). Substrate-Parity v0.3.0 schema.") + end + -- New: rest.rot (degrees in JSON, math.rad to radians here) + local rest_rot = math.rad(b.rest.rot or 0) + -- New: rest.scl as [sx, sy], default [1, 1] + local rest_scl = b.rest.scl + if rest_scl == nil then rest_scl = {1, 1} end + if type(rest_scl) ~= "table" or rest_scl[1] == nil or rest_scl[2] == nil then + error("puppet.build_rig: bone '" .. b.id .. "' rest.scl must be [sx, sy] table") + end + bones_by_id[b.id] = { id = b.id, parent = nil, -- resolved below rest = { x = b.rest.x, y = b.rest.y, - -- JSON angles in degrees; convert to radians once. - angle = math.rad(b.rest.angle or 0), + rot = rest_rot, + scl = { rest_scl[1], rest_scl[2] }, }, look_at = (b.look_at == 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 - scale = b.scale or { 1.0, 1.0 }, -- [sx, sy]; negative = mirror anchor = b.anchor, -- optional [x, y] override of atlas-anchor; resolved in load_textures texture_handle = nil, -- populated by load_textures } @@ -144,8 +155,8 @@ function M.build_animation(anim_table, rig) error("puppet.build_animation: animation.track must be string") end if rig.tracks_by_id[anim_table.track] == nil then - error("puppet.build_animation: animation.track '" - .. anim_table.track .. "' not found in rig") + error("puppet.build_animation: animation.track '" .. anim_table.track + .. "' not found in rig") end if type(anim_table.duration) ~= "number" or anim_table.duration <= 0 then error("puppet.build_animation: animation.duration must be positive number") @@ -154,7 +165,6 @@ function M.build_animation(anim_table, rig) error("puppet.build_animation: animation.keyframes must be non-empty array") end - -- Validate each keyframe: t in [0, duration], bones referenced are in this animation's track. local track_bones = {} for _, bid in ipairs(rig.tracks_by_id[anim_table.track].bones) do track_bones[bid] = true @@ -175,9 +185,29 @@ function M.build_animation(anim_table, rig) error("puppet.build_animation: keyframe writes bone '" .. k .. "' which is not in animation's track '" .. anim_table.track .. "'") end - cooked.bones[k] = { - angle = math.rad(v.angle or 0), -- JSON degrees → radians for internal storage - } + if v.angle ~= nil then + error("puppet.build_animation: keyframe bone '" .. k + .. "' uses legacy `angle` channel; rename to `rot` (Substrate-Parity v0.3.0 schema)") + end + local ch = {} + if v.rot ~= nil then + ch.rot = math.rad(v.rot) -- JSON degrees → radians + end + if v.pos ~= nil then + if type(v.pos) ~= "table" or v.pos[1] == nil or v.pos[2] == nil then + error("puppet.build_animation: keyframe[" .. i .. "] bone '" .. k + .. "' pos must be [x, y] table") + end + ch.pos = { x = v.pos[1], y = v.pos[2] } + end + if v.scl ~= nil then + if type(v.scl) ~= "table" or v.scl[1] == nil or v.scl[2] == nil then + error("puppet.build_animation: keyframe[" .. i .. "] bone '" .. k + .. "' scl must be [sx, sy] table") + end + ch.scl = { x = v.scl[1], y = v.scl[2] } + end + cooked.bones[k] = ch end end keyframes[i] = cooked @@ -193,10 +223,10 @@ function M.build_animation(anim_table, rig) end -- ==================================================================== --- Animation sampling +-- Animation sampling (multi-channel: rot, pos, scl) -- ==================================================================== function M.sample_animation(anim, t) - -- Wrap t for loops. + -- Wrap t for loops if anim.loop and t > anim.duration then t = t % anim.duration end @@ -204,7 +234,6 @@ function M.sample_animation(anim, t) if t > anim.duration then t = anim.duration end local kfs = anim.keyframes - -- Find segment [kf_i, kf_{i+1}] containing t. local prev_kf, next_kf = kfs[1], kfs[1] for i = 1, #kfs - 1 do if t >= kfs[i].t and t <= kfs[i + 1].t then @@ -213,34 +242,54 @@ function M.sample_animation(anim, t) break end end - - -- If t equals last keyframe's t (or beyond and not looping), snap to last. if t >= kfs[#kfs].t then prev_kf = kfs[#kfs] next_kf = kfs[#kfs] end - -- Linear interpolation per bone present in either keyframe. - local frame = {} local span = next_kf.t - prev_kf.t local alpha = (span > 0) and ((t - prev_kf.t) / span) or 0 + local frame = {} local all_bones = {} for bid, _ in pairs(prev_kf.bones) do all_bones[bid] = true end for bid, _ in pairs(next_kf.bones) do all_bones[bid] = true end for bid, _ in pairs(all_bones) do - local prev_v = prev_kf.bones[bid] - local next_v = next_kf.bones[bid] - if prev_v and next_v then - frame[bid] = { - angle = prev_v.angle * (1 - alpha) + next_v.angle * alpha, - } - elseif prev_v then - frame[bid] = { angle = prev_v.angle } - elseif next_v then - frame[bid] = { angle = next_v.angle } + local p_ch = prev_kf.bones[bid] + local n_ch = next_kf.bones[bid] + local out = {} + -- rot channel + if p_ch and p_ch.rot ~= nil and n_ch and n_ch.rot ~= nil then + out.rot = p_ch.rot * (1 - alpha) + n_ch.rot * alpha + elseif p_ch and p_ch.rot ~= nil then + out.rot = p_ch.rot + elseif n_ch and n_ch.rot ~= nil then + out.rot = n_ch.rot end + -- pos channel + if p_ch and p_ch.pos and n_ch and n_ch.pos then + out.pos = { + x = p_ch.pos.x * (1 - alpha) + n_ch.pos.x * alpha, + y = p_ch.pos.y * (1 - alpha) + n_ch.pos.y * alpha, + } + elseif p_ch and p_ch.pos then + out.pos = { x = p_ch.pos.x, y = p_ch.pos.y } + elseif n_ch and n_ch.pos then + out.pos = { x = n_ch.pos.x, y = n_ch.pos.y } + end + -- scl channel + if p_ch and p_ch.scl and n_ch and n_ch.scl then + out.scl = { + x = p_ch.scl.x * (1 - alpha) + n_ch.scl.x * alpha, + y = p_ch.scl.y * (1 - alpha) + n_ch.scl.y * alpha, + } + elseif p_ch and p_ch.scl then + out.scl = { x = p_ch.scl.x, y = p_ch.scl.y } + elseif n_ch and n_ch.scl then + out.scl = { x = n_ch.scl.x, y = n_ch.scl.y } + end + frame[bid] = out end return frame @@ -257,12 +306,19 @@ function M.spawn(rig, pos) local handle = next_handle next_handle = next_handle + 1 - -- Per-bone live state (current angle, current world-pos cache). + -- Per-bone live state (current local pos/rot/scl + cached world-transform). local bone_state = {} for bid, b in pairs(rig.bones_by_id) do bone_state[bid] = { - angle = b.rest.angle, - world = { x = 0, y = 0, angle = b.rest.angle }, + rot = b.rest.rot, -- radians + pos = { x = b.rest.x, y = b.rest.y }, + scl = { x = b.rest.scl[1], y = b.rest.scl[2] }, + world = { + x = 0, y = 0, + rot = b.rest.rot, + scl_x = b.rest.scl[1], + scl_y = b.rest.scl[2], + }, } end @@ -279,6 +335,10 @@ function M.spawn(rig, pos) procedural = {}, -- {[name] = callback_fn} write_target_bone = nil, -- set during procedural callback to track track-conflicts write_target_track = nil, + test_overrides = {}, -- {[bone_id] = angle_rad} set by write_bone_test_only; applied after reset+keyframe + foot_state = {}, -- populated by locomotion at spawn (Task 3.7) + bone_animated = {}, -- {[bone_id] = true} per-frame keyframe flag (Task 3.12) + last_move_time = 0, -- engine.time.now() at last position-change (Task 3.10) } return handle end @@ -350,8 +410,8 @@ function M.clear_look_target(handle) end function M.bone_angle(handle, bone_id) - -- Returns the stored angle in radians (uniform across all channels). - return all_puppets[handle].bone_state[bone_id].angle + -- Returns the stored rot in radians. + return all_puppets[handle].bone_state[bone_id].rot end -- ==================================================================== @@ -385,7 +445,10 @@ function M.write_bone(handle, bone_id, values) end end if values.angle ~= nil then - p.bone_state[bone_id].angle = math.rad(values.angle) -- caller passes degrees + p.bone_state[bone_id].rot = math.rad(values.angle) -- caller passes degrees (legacy) + end + if values.rot ~= nil then + p.bone_state[bone_id].rot = math.rad(values.rot) -- caller passes degrees end end @@ -401,11 +464,13 @@ function M.bone_world_transform(handle, bone_id) if ws == nil then error("puppet.bone_world_transform: bone '" .. tostring(bone_id) .. "' has no cached world-transform") end - return ws.x, ws.y, ws.angle + return ws.x, ws.y, ws.rot end -- ==================================================================== -- Test-only: direct bone-angle write bypassing track-conflict guard. +-- Stored in test_overrides and applied during update after rest-reset +-- and keyframe sampling, so it survives the per-frame reset. -- Not for production module use. -- ==================================================================== function M.write_bone_test_only(handle, bone_id, angle_rad) @@ -416,7 +481,32 @@ function M.write_bone_test_only(handle, bone_id, angle_rad) if p.bone_state[bone_id] == nil then error("puppet.write_bone_test_only: bone '" .. tostring(bone_id) .. "' not in rig") end - p.bone_state[bone_id].angle = angle_rad + p.test_overrides[bone_id] = angle_rad +end + +-- ==================================================================== +-- Cascaded world-transform (substrate puppet.c:692-718 port). +-- Local pos is multiplied by parent.world.scl before rotation + translation. +-- Used in update_bone_recursive step 3.d. +-- ==================================================================== +local function compute_world(p, b) + local bs = p.bone_state[b.id] + if b.parent == nil then + bs.world.x = p.x + bs.pos.x + bs.world.y = p.y + bs.pos.y + bs.world.rot = bs.rot + bs.world.scl_x = bs.scl.x + bs.world.scl_y = bs.scl.y + else + local pw = p.bone_state[b.parent.id].world + local cos_r, sin_r = math.cos(pw.rot), math.sin(pw.rot) + local sx, sy = pw.scl_x, pw.scl_y + bs.world.x = pw.x + (cos_r * bs.pos.x * sx - sin_r * bs.pos.y * sy) + bs.world.y = pw.y + (sin_r * bs.pos.x * sx + cos_r * bs.pos.y * sy) + bs.world.rot = pw.rot + bs.rot + bs.world.scl_x = pw.scl_x * bs.scl.x + bs.world.scl_y = pw.scl_y * bs.scl.y + end end -- ==================================================================== @@ -428,19 +518,30 @@ local update_bone_recursive -- Update pipeline (per-frame, depth-first traversal). -- -- For each puppet: --- 1. Reset all bones to rest pose +-- 1. Reset all bones to rest pose (rot, pos, scl) +-- 1b. Reset per-frame bone_animated flags -- 2. Advance keyframe-anim sample-times -- 3. Depth-first traversal root -> children: --- a. Sample keyframe for bone if on active track --- b. Apply look-at if bone has look_at=true (uses parent's already-cascaded world) +-- a. Sample keyframe channels (rot/pos/scl) for bone if on active track +-- a2. Apply test-only overrides +-- b. Apply look-at if bone has look_at=true (scale-aware) -- c. Run procedural callbacks (once per puppet at root-visit) --- d. Compute world-transform from parent.world + own rest + own angle +-- d. Compute world-transform (cascaded with scale) -- ==================================================================== function M.update(dt) for handle, p in pairs(all_puppets) do - -- 1. Reset to rest pose. + -- 1. Reset bone state to rest pose (rot, pos, scl). for bid, b in pairs(p.rig.bones_by_id) do - p.bone_state[bid].angle = b.rest.angle + p.bone_state[bid].rot = b.rest.rot + p.bone_state[bid].pos.x = b.rest.x + p.bone_state[bid].pos.y = b.rest.y + p.bone_state[bid].scl.x = b.rest.scl[1] + p.bone_state[bid].scl.y = b.rest.scl[2] + end + + -- 1.b Reset per-frame keyframe-flag (used by locomotion in step 4 to skip animated bones) + for bid, _ in pairs(p.rig.bones_by_id) do + p.bone_animated[bid] = nil end -- 2. Advance keyframe sample-times. @@ -468,7 +569,7 @@ end update_bone_recursive = function(p, b, dt) local bid = b.id - -- 3.a Sample keyframe for this bone from any playing anim on its track. + -- 3.a Sample keyframes (multi-channel: rot/pos/scl) local track_id = p.rig.bone_to_track[bid] if track_id then for anim_id, play_state in pairs(p.playing) do @@ -476,34 +577,50 @@ update_bone_recursive = function(p, b, dt) if anim and anim.track == track_id then local frame = M.sample_animation(anim, play_state.t) local bone_kf = frame[bid] - if bone_kf and bone_kf.angle ~= nil then - p.bone_state[bid].angle = bone_kf.angle -- already radians per sample_animation + if bone_kf then + if bone_kf.rot ~= nil then + p.bone_state[bid].rot = bone_kf.rot + p.bone_animated[bid] = true + end + if bone_kf.pos ~= nil then + p.bone_state[bid].pos.x = bone_kf.pos.x + p.bone_state[bid].pos.y = bone_kf.pos.y + p.bone_animated[bid] = true + end + if bone_kf.scl ~= nil then + p.bone_state[bid].scl.x = bone_kf.scl.x + p.bone_state[bid].scl.y = bone_kf.scl.y + p.bone_animated[bid] = true + end end end end end - -- 3.b Apply look-at constraint if this bone is marked look_at. + -- 3.a2 Apply test-only overrides (written by write_bone_test_only before update call). + -- These survive the rest-reset done at the top of update; applied after keyframe + -- sampling so they always win for the current frame. + if p.test_overrides[bid] ~= nil then + 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 - -- Compute this bone's world-pos from parent's world + own rest offset - -- (parent's world has been computed earlier in the traversal). - local px, py, pa + local px, py, pa, psx, psy if b.parent then local pw = p.bone_state[b.parent.id].world - px, py, pa = pw.x, pw.y, pw.angle + px, py, pa, psx, psy = pw.x, pw.y, pw.rot, pw.scl_x, pw.scl_y else - px, py, pa = p.x, p.y, 0 + px, py, pa, psx, psy = p.x, p.y, 0, 1, 1 end local cos_a, sin_a = math.cos(pa), math.sin(pa) local lx, ly = b.rest.x, b.rest.y - local bone_world_x = px + lx * cos_a - ly * sin_a - local bone_world_y = py + lx * sin_a + ly * cos_a - -- Direction to target in world-space. + 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 - -- World-angle for look-at = atan2(dy, dx). Convert to local (subtract parent's accumulated). local world_angle = math.atan(dy, dx) - p.bone_state[bid].angle = world_angle - pa + p.bone_state[bid].rot = world_angle - pa end -- 3.c Run procedural callbacks at root-visit (once per puppet, before world-transform compute). @@ -517,20 +634,8 @@ update_bone_recursive = function(p, b, dt) end end - -- 3.d Compute this bone's world-transform from parent + own. - local px, py, pa - if b.parent then - local pw = p.bone_state[b.parent.id].world - px, py, pa = pw.x, pw.y, pw.angle - else - px, py, pa = p.x, p.y, 0 - end - local cos_a, sin_a = math.cos(pa), math.sin(pa) - local lx, ly = b.rest.x, b.rest.y - local wx = px + lx * cos_a - ly * sin_a - local wy = py + lx * sin_a + ly * cos_a - local wa = pa + p.bone_state[bid].angle - p.bone_state[bid].world = { x = wx, y = wy, angle = wa } + -- 3.d Compute world-transform (cascaded with scale). + compute_world(p, b) -- Recurse to children. for _, child_src in ipairs(p.rig.bones) do @@ -545,6 +650,7 @@ end -- Rendering (engine.render.*; only callable in render-phase). -- Walks bones in z-sorted order (back-to-front). For each bone: -- - If bone has texture_handle: draw via engine.render.draw_sprite_transform +-- using cascaded world.scl_x/y and world.rot -- - Else: fall back to engine.render.draw_rect_rotated with bone.color -- ==================================================================== function M.render(handle) @@ -554,19 +660,20 @@ function M.render(handle) for _, b in ipairs(p.rig.bones_z_sorted) do local ws = p.bone_state[b.id].world if b.texture_handle then - local sx, sy = b.scale[1], b.scale[2] - local ax, ay = b.anchor and b.anchor[1] or 0, - b.anchor and b.anchor[2] or 0 + -- Cascaded world-scale (was: per-bone constant b.scale) + local sx, sy = ws.scl_x, ws.scl_y + local ax = b.anchor and b.anchor[1] or 0 + local ay = b.anchor and b.anchor[2] or 0 engine.render.draw_sprite_transform( b.texture_handle, - ws.x, ws.y, ws.angle, + ws.x, ws.y, ws.rot, sx, sy, ax, ay, 0xFFFFFFFF ) else - -- Phase-1 fallback: colored rotated rect (16x4 px). + -- Fallback: colored rotated rect (Phase-1 behavior) 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.angle, color) + engine.render.draw_rect_rotated(ws.x, ws.y, 16, 4, ws.rot, color) end end end